mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
sdk/python: add first-class login support (#23093)
## Why The Python SDK can already create threads and run turns, but authentication still has to be arranged outside the SDK. App-server already exposes account login, account inspection, logout, and `account/login/completed` notifications, so SDK users currently have to work around a missing public client layer for a core setup step. This change makes authentication a normal SDK workflow while preserving the backend flow shape: API-key login completes immediately, and interactive ChatGPT flows return live handles that complete later through app-server notifications. ## What changed - Added public sync and async auth methods on `Codex` / `AsyncCodex`: - `login_api_key(...)` - `login_chatgpt()` - `login_chatgpt_device_code()` - `account(...)` - `logout()` - Added public browser-login and device-code handle types with attempt-local `wait()` and `cancel()` helpers. Cancellation stays on the handle instead of a root-level SDK method. - Extended the Python app-server client and notification router so login completion events are routed by `login_id` without consuming unrelated global notifications. - Kept login request/handle logic in a focused internal `_login.py` module so `api.py` remains the public facade instead of absorbing more auth plumbing. - Exported the new handle types plus curated account/login response types from the SDK surfaces. - Updated SDK docs, added sync/async login walkthrough examples, and added a notebook login walkthrough cell. ## Verification Added SDK coverage for: - API-key login, account readback, and logout through the app-server harness in both sync and async clients. - Browser login cancellation plus `handle.wait()` completion through the real app-server boundary used by the Python SDK harness. - Waiter routing that stays scoped across replaced interactive login attempts, plus async handle cancellation coverage. - Login notification demuxing, replay of early completion events, and async client delegation. - Public export/signature assertions. - Real integration-suite smoke coverage for the new examples and notebook login cell.
This commit is contained in:
committed by
GitHub
Unverified
parent
0445b290fe
commit
4c89772314
@@ -206,10 +206,11 @@ class MockResponsesServer:
|
||||
class AppServerHarness:
|
||||
"""Test fixture that points a pinned runtime app-server at MockResponsesServer."""
|
||||
|
||||
def __init__(self, tmp_path: Path) -> None:
|
||||
def __init__(self, tmp_path: Path, *, requires_openai_auth: bool = False) -> None:
|
||||
self.tmp_path = tmp_path
|
||||
self.codex_home = tmp_path / "codex-home"
|
||||
self.workspace = tmp_path / "workspace"
|
||||
self.requires_openai_auth = requires_openai_auth
|
||||
self.responses = MockResponsesServer()
|
||||
|
||||
def __enter__(self) -> AppServerHarness:
|
||||
@@ -238,6 +239,7 @@ class AppServerHarness:
|
||||
def _write_config(self) -> None:
|
||||
"""Write config.toml that routes model calls to the mock server."""
|
||||
config_toml = self.codex_home / "config.toml"
|
||||
requires_openai_auth = "requires_openai_auth = true\n" if self.requires_openai_auth else ""
|
||||
config_toml.write_text(
|
||||
f"""
|
||||
model = "mock-model"
|
||||
@@ -252,6 +254,7 @@ base_url = "{self.responses.url}/v1"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
{requires_openai_auth}
|
||||
""".lstrip()
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
from app_server_harness import AppServerHarness
|
||||
|
||||
from openai_codex import AppServerConfig, Codex
|
||||
from openai_codex.generated.v2_all import (
|
||||
ChatgptAuthTokensLoginAccountParams,
|
||||
LoginAccountParams,
|
||||
)
|
||||
|
||||
|
||||
def _app_server_config(harness: AppServerHarness) -> AppServerConfig:
|
||||
"""Build an isolated login config without inheriting ambient API-key auth."""
|
||||
config = harness.app_server_config()
|
||||
config.env = {**(config.env or {}), "OPENAI_API_KEY": ""}
|
||||
return config
|
||||
|
||||
|
||||
def test_api_key_login_authenticates_follow_up_model_requests(tmp_path) -> None:
|
||||
"""API-key login should authorize the next Responses request with that key."""
|
||||
with AppServerHarness(tmp_path, requires_openai_auth=True) as harness:
|
||||
harness.responses.enqueue_assistant_message("api key auth", response_id="api-key-auth")
|
||||
|
||||
with Codex(config=_app_server_config(harness)) as codex:
|
||||
codex.login_api_key("sk-sdk-login-test")
|
||||
result = codex.thread_start().run("prove api key auth")
|
||||
request = harness.responses.single_request()
|
||||
|
||||
assert {
|
||||
"final_response": result.final_response,
|
||||
"authorization": request.header("authorization"),
|
||||
} == {
|
||||
"final_response": "api key auth",
|
||||
"authorization": "Bearer sk-sdk-login-test",
|
||||
}
|
||||
|
||||
|
||||
def test_chatgpt_token_login_authenticates_follow_up_model_requests(tmp_path) -> None:
|
||||
"""ChatGPT token handoff should authorize later Responses requests with that token."""
|
||||
account_id = "workspace-sdk-chatgpt"
|
||||
|
||||
def _encode(payload: dict[str, object]) -> str:
|
||||
raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
# App-server parses claims from the access token before persisting external ChatGPT auth.
|
||||
header = _encode({"alg": "none", "typ": "JWT"})
|
||||
claims = _encode(
|
||||
{
|
||||
"email": "sdk-chatgpt@example.com",
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": account_id,
|
||||
"chatgpt_plan_type": "pro",
|
||||
},
|
||||
}
|
||||
)
|
||||
access_token = f"{header}.{claims}.sig"
|
||||
|
||||
with AppServerHarness(tmp_path, requires_openai_auth=True) as harness:
|
||||
harness.responses.enqueue_assistant_message(
|
||||
"chatgpt token auth",
|
||||
response_id="chatgpt-token-auth",
|
||||
)
|
||||
|
||||
with Codex(config=_app_server_config(harness)) as codex:
|
||||
login = codex._client.account_login_start(
|
||||
LoginAccountParams(
|
||||
root=ChatgptAuthTokensLoginAccountParams(
|
||||
access_token=access_token,
|
||||
chatgpt_account_id=account_id,
|
||||
chatgpt_plan_type="pro",
|
||||
type="chatgptAuthTokens",
|
||||
)
|
||||
)
|
||||
)
|
||||
result = codex.thread_start().run("prove chatgpt token auth")
|
||||
request = harness.responses.single_request()
|
||||
|
||||
assert {
|
||||
"login_type": login.root.type,
|
||||
"final_response": result.final_response,
|
||||
"authorization": request.header("authorization"),
|
||||
} == {
|
||||
"login_type": "chatgptAuthTokens",
|
||||
"final_response": "chatgpt token auth",
|
||||
"authorization": f"Bearer {access_token}",
|
||||
}
|
||||
@@ -27,6 +27,10 @@ EXPECTED_ROOT_EXPORTS = [
|
||||
"Codex",
|
||||
"AsyncCodex",
|
||||
"ApprovalMode",
|
||||
"ChatgptLoginHandle",
|
||||
"DeviceCodeLoginHandle",
|
||||
"AsyncChatgptLoginHandle",
|
||||
"AsyncDeviceCodeLoginHandle",
|
||||
"Thread",
|
||||
"AsyncThread",
|
||||
"TurnHandle",
|
||||
@@ -55,8 +59,13 @@ EXPECTED_ROOT_EXPORTS = [
|
||||
]
|
||||
|
||||
EXPECTED_TYPES_EXPORTS = [
|
||||
"Account",
|
||||
"AccountLoginCompletedNotification",
|
||||
"ApprovalsReviewer",
|
||||
"AskForApproval",
|
||||
"CancelLoginAccountResponse",
|
||||
"CancelLoginAccountStatus",
|
||||
"GetAccountResponse",
|
||||
"InitializeResponse",
|
||||
"JsonObject",
|
||||
"ModelListResponse",
|
||||
|
||||
@@ -434,7 +434,7 @@ def test_notebook_sync_cell_smoke(runtime_env: PreparedRuntimeEnv) -> None:
|
||||
[
|
||||
_notebook_cell_source(1),
|
||||
_notebook_cell_source(2),
|
||||
_notebook_cell_source(3),
|
||||
_notebook_cell_source(4),
|
||||
]
|
||||
)
|
||||
result = _run_python(runtime_env, source, timeout_s=240)
|
||||
@@ -450,7 +450,7 @@ def test_notebook_advanced_cell_smoke(runtime_env: PreparedRuntimeEnv) -> None:
|
||||
[
|
||||
_notebook_cell_source(1),
|
||||
_notebook_cell_source(2),
|
||||
_notebook_cell_source(7),
|
||||
_notebook_cell_source(8),
|
||||
]
|
||||
)
|
||||
result = _run_python(runtime_env, source, timeout_s=360)
|
||||
|
||||
Reference in New Issue
Block a user