diff --git a/sdk/python/README.md b/sdk/python/README.md index d3aeff7ac5..a04d59b239 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -26,6 +26,8 @@ when you intentionally want to run against a specific local app-server binary. from openai_codex import Codex with Codex() as codex: + # Call login_api_key(...) first when this app-server session is not + # already authenticated. thread = codex.thread_start(model="gpt-5") result = thread.run("Say hello in one sentence.") print(result.final_response) @@ -35,6 +37,34 @@ with Codex() as codex: `result.final_response` is `None` when the turn completes without a final-answer or phase-less assistant message item. +## Login + +Use the auth helper that matches your app: + +```python +from openai_codex import Codex + +with Codex() as codex: + codex.login_api_key("sk-...") + account = codex.account() + print(account.account) +``` + +Interactive ChatGPT login returns a handle. Open the provided URL or device-code +page, then wait for the matching completion event: + +```python +with Codex() as codex: + login = codex.login_chatgpt() + print(login.auth_url) + completed = login.wait() + print(completed.success) +``` + +Use `login_chatgpt_device_code()` for device-code auth, `handle.cancel()` to +stop an in-progress interactive login, and `logout()` to clear the active +app-server account session. + ## Docs map - Golden path tutorial: `docs/getting-started.md` diff --git a/sdk/python/docs/api-reference.md b/sdk/python/docs/api-reference.md index 6be7e19737..1b885b8c89 100644 --- a/sdk/python/docs/api-reference.md +++ b/sdk/python/docs/api-reference.md @@ -12,6 +12,10 @@ from openai_codex import ( Codex, AsyncCodex, ApprovalMode, + ChatgptLoginHandle, + DeviceCodeLoginHandle, + AsyncChatgptLoginHandle, + AsyncDeviceCodeLoginHandle, RunResult, Thread, AsyncThread, @@ -26,6 +30,11 @@ from openai_codex import ( MentionInput, ) from openai_codex.types import ( + Account, + AccountLoginCompletedNotification, + CancelLoginAccountResponse, + CancelLoginAccountStatus, + GetAccountResponse, InitializeResponse, ThreadItem, ThreadTokenUsage, @@ -47,6 +56,11 @@ Properties/methods: - `metadata -> InitializeResponse` - `close() -> None` +- `login_api_key(api_key: str) -> None` +- `login_chatgpt() -> ChatgptLoginHandle` +- `login_chatgpt_device_code() -> DeviceCodeLoginHandle` +- `account(*, refresh_token: bool = False) -> GetAccountResponse` +- `logout() -> None` - `thread_start(*, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, personality=None, sandbox=None) -> Thread` - `thread_list(*, archived=None, cursor=None, cwd=None, limit=None, model_providers=None, sort_key=None, source_kinds=None) -> ThreadListResponse` - `thread_resume(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, personality=None, sandbox=None) -> Thread` @@ -82,6 +96,11 @@ Properties/methods: - `metadata -> InitializeResponse` - `close() -> Awaitable[None]` +- `login_api_key(api_key: str) -> Awaitable[None]` +- `login_chatgpt() -> Awaitable[AsyncChatgptLoginHandle]` +- `login_chatgpt_device_code() -> Awaitable[AsyncDeviceCodeLoginHandle]` +- `account(*, refresh_token: bool = False) -> Awaitable[GetAccountResponse]` +- `logout() -> Awaitable[None]` - `thread_start(*, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, personality=None, sandbox=None) -> Awaitable[AsyncThread]` - `thread_list(*, archived=None, cursor=None, cwd=None, limit=None, model_providers=None, sort_key=None, source_kinds=None) -> Awaitable[ThreadListResponse]` - `thread_resume(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, personality=None, sandbox=None) -> Awaitable[AsyncThread]` @@ -97,6 +116,30 @@ async with AsyncCodex() as codex: ... ``` +## Login handles + +### ChatgptLoginHandle / AsyncChatgptLoginHandle + +- `login_id: str` +- `auth_url: str` +- `wait() -> AccountLoginCompletedNotification` +- `cancel() -> CancelLoginAccountResponse` + +Async handle methods return awaitables. + +### DeviceCodeLoginHandle / AsyncDeviceCodeLoginHandle + +- `login_id: str` +- `verification_url: str` +- `user_code: str` +- `wait() -> AccountLoginCompletedNotification` +- `cancel() -> CancelLoginAccountResponse` + +Async handle methods return awaitables. + +`wait()` consumes only the completion notification for its matching login +attempt. API-key login completes synchronously and does not return a handle. + ## Thread / AsyncThread `Thread` and `AsyncThread` share the same shape and intent. @@ -176,6 +219,11 @@ The SDK wrappers return and accept public app-server models wherever possible: ```python from openai_codex.types import ( + Account, + AccountLoginCompletedNotification, + CancelLoginAccountResponse, + CancelLoginAccountStatus, + GetAccountResponse, ThreadReadResponse, Turn, TurnStatus, diff --git a/sdk/python/docs/faq.md b/sdk/python/docs/faq.md index 1a87b50a14..d1732e74aa 100644 --- a/sdk/python/docs/faq.md +++ b/sdk/python/docs/faq.md @@ -23,6 +23,16 @@ Choose `run()` for most apps. Choose `stream()` for progress UIs, custom timeout If your app is not already async, stay with `Codex`. +## How do I log in? + +- `login_api_key(...)` authenticates immediately with an API key. +- `login_chatgpt()` starts browser login and returns a handle with `auth_url`. +- `login_chatgpt_device_code()` starts device-code login and returns a handle + with `verification_url` and `user_code`. +- Interactive handles expose `wait()` for the matching + `account/login/completed` notification and `cancel()` to stop that attempt. +- `account()` reads the current account state, and `logout()` clears it. + ## Public kwargs are snake_case Public API keyword names are snake_case. The SDK still maps them to wire camelCase under the hood. @@ -56,7 +66,6 @@ Common causes: - published runtime package (`openai-codex-cli-bin`) is not installed - local `codex_bin` override points to a missing file -- local auth/session is missing - incompatible/old app-server ## Why does a turn "hang"? diff --git a/sdk/python/docs/getting-started.md b/sdk/python/docs/getting-started.md index 6e37314e77..b362a90347 100644 --- a/sdk/python/docs/getting-started.md +++ b/sdk/python/docs/getting-started.md @@ -19,9 +19,37 @@ Requirements: - Python `>=3.10` - uv - installed `openai-codex-cli-bin` runtime package, or an explicit `codex_bin` override -- local Codex auth/session configured -## 2) Run your first turn (sync) +## 2) Authenticate when needed + +Existing Codex auth state is reused automatically. To authenticate from the SDK, +use the flow that fits your app: + +```python +from openai_codex import Codex + +with Codex() as codex: + codex.login_api_key("sk-...") + account = codex.account() + print(account.account) +``` + +Interactive ChatGPT browser login returns a handle that carries the URL and the +matching completion event: + +```python +with Codex() as codex: + login = codex.login_chatgpt() + print(login.auth_url) + completed = login.wait() + print(completed.success) +``` + +Device-code login works the same way with +`login_chatgpt_device_code()`, which exposes `verification_url`, `user_code`, +and `wait()`. + +## 3) Run your first turn (sync) ```python from openai_codex import Codex @@ -47,7 +75,7 @@ What happened: - use `thread.turn(...)` when you need a `TurnHandle` for streaming, steering, interrupting, or turn IDs/status - one client can consume multiple active turns concurrently; turn streams are routed by turn ID -## 3) Continue the same thread (multi-turn) +## 4) Continue the same thread (multi-turn) ```python from openai_codex import Codex @@ -62,7 +90,7 @@ with Codex() as codex: print("second:", second.final_response) ``` -## 4) Async parity +## 5) Async parity Use `async with AsyncCodex()` as the normal async entrypoint. `AsyncCodex` initializes lazily, and context entry makes startup/shutdown explicit. @@ -82,7 +110,7 @@ async def main() -> None: asyncio.run(main()) ``` -## 5) Resume an existing thread +## 6) Resume an existing thread ```python from openai_codex import Codex @@ -95,7 +123,7 @@ with Codex() as codex: print(result.final_response) ``` -## 6) Public app-server types +## 7) Public app-server types The convenience wrappers live at the package root. Public app-server value and event types live under: @@ -104,7 +132,7 @@ event types live under: from openai_codex.types import ThreadReadResponse, Turn, TurnStatus ``` -## 7) Next stops +## 8) Next stops - API surface and signatures: `docs/api-reference.md` - Common decisions/pitfalls: `docs/faq.md` diff --git a/sdk/python/examples/15_login_and_account/async.py b/sdk/python/examples/15_login_and_account/async.py new file mode 100644 index 0000000000..7de6c2afa5 --- /dev/null +++ b/sdk/python/examples/15_login_and_account/async.py @@ -0,0 +1,34 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ensure_local_sdk_src, runtime_config + +ensure_local_sdk_src() + +import asyncio + +from openai_codex import AsyncCodex + + +async def main() -> None: + async with AsyncCodex(config=runtime_config()) as codex: + # Browser login returns a live handle. Open `auth_url` and await `wait()` + # in a real app; this example cancels immediately so it stays non-blocking. + login = await codex.login_chatgpt() + canceled = await login.cancel() + completed = await login.wait() + account = await codex.account() + + print("login.id:", login.login_id) + print("login.auth_url:", login.auth_url) + print("login.cancel.status:", canceled.status) + print("login.completed.success:", completed.success) + print("account.requires_openai_auth:", account.requires_openai_auth) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/python/examples/15_login_and_account/sync.py b/sdk/python/examples/15_login_and_account/sync.py new file mode 100644 index 0000000000..ed20531186 --- /dev/null +++ b/sdk/python/examples/15_login_and_account/sync.py @@ -0,0 +1,26 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ensure_local_sdk_src, runtime_config + +ensure_local_sdk_src() + +from openai_codex import Codex + +with Codex(config=runtime_config()) as codex: + # Browser login returns a live handle. Open `auth_url` and call `wait()` + # in a real app; this example cancels immediately so it stays non-blocking. + login = codex.login_chatgpt() + canceled = login.cancel() + completed = login.wait() + account = codex.account() + + print("login.id:", login.login_id) + print("login.auth_url:", login.auth_url) + print("login.cancel.status:", canceled.status) + print("login.completed.success:", completed.success) + print("account.requires_openai_auth:", account.requires_openai_auth) diff --git a/sdk/python/examples/README.md b/sdk/python/examples/README.md index fe5b630898..569a51f477 100644 --- a/sdk/python/examples/README.md +++ b/sdk/python/examples/README.md @@ -82,3 +82,5 @@ python examples/01_quickstart_constructor/async.py - list models, pick highest model + highest supported reasoning effort, run turns, print message and usage - `14_turn_controls/` - separate best-effort `steer()` and `interrupt()` demos with concise summaries +- `15_login_and_account/` + - browser-login handle lifecycle, cancellation, and account inspection diff --git a/sdk/python/notebooks/sdk_walkthrough.ipynb b/sdk/python/notebooks/sdk_walkthrough.ipynb index 62480fe18a..e51c217dbd 100644 --- a/sdk/python/notebooks/sdk_walkthrough.ipynb +++ b/sdk/python/notebooks/sdk_walkthrough.ipynb @@ -140,6 +140,27 @@ ")\n" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Cell 2b: browser login handle lifecycle\n", + "with Codex() as codex:\n", + " # Open this URL and call `wait()` without canceling when completing login for real.\n", + " login = codex.login_chatgpt()\n", + " canceled = login.cancel()\n", + " completed = login.wait()\n", + " account = codex.account()\n", + "\n", + " print('login.id:', login.login_id)\n", + " print('login.auth_url:', login.auth_url)\n", + " print('login.cancel.status:', canceled.status)\n", + " print('login.completed.success:', completed.success)\n", + " print('account.requires_openai_auth:', account.requires_openai_auth)\n" + ] + }, { "cell_type": "code", "execution_count": null, diff --git a/sdk/python/src/openai_codex/__init__.py b/sdk/python/src/openai_codex/__init__.py index 4a68fabbef..8c8dcc3eb2 100644 --- a/sdk/python/src/openai_codex/__init__.py +++ b/sdk/python/src/openai_codex/__init__.py @@ -1,10 +1,14 @@ from ._version import __version__ from .api import ( ApprovalMode, + AsyncChatgptLoginHandle, AsyncCodex, + AsyncDeviceCodeLoginHandle, AsyncThread, AsyncTurnHandle, + ChatgptLoginHandle, Codex, + DeviceCodeLoginHandle, ImageInput, Input, InputItem, @@ -39,6 +43,10 @@ __all__ = [ "Codex", "AsyncCodex", "ApprovalMode", + "ChatgptLoginHandle", + "DeviceCodeLoginHandle", + "AsyncChatgptLoginHandle", + "AsyncDeviceCodeLoginHandle", "Thread", "AsyncThread", "TurnHandle", diff --git a/sdk/python/src/openai_codex/_login.py b/sdk/python/src/openai_codex/_login.py new file mode 100644 index 0000000000..cdfdda41da --- /dev/null +++ b/sdk/python/src/openai_codex/_login.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +from .async_client import AsyncAppServerClient +from .client import AppServerClient +from .generated.v2_all import ( + AccountLoginCompletedNotification, + CancelLoginAccountResponse, + ChatgptDeviceCodeLoginAccountParams, + ChatgptDeviceCodeLoginAccountResponse, + ChatgptLoginAccountParams, + ChatgptLoginAccountResponse, + LoginAccountParams, +) + + +class _AsyncLoginOwner(Protocol): + """Subset of AsyncCodex needed by async login handles.""" + + _client: AsyncAppServerClient + + async def _ensure_initialized(self) -> None: + """Ensure the owning SDK client has a live app-server connection.""" + ... + + +def start_chatgpt_login(client: AppServerClient) -> ChatgptLoginHandle: + """Start browser ChatGPT login and return the handle for that attempt.""" + response = client.account_login_start( + LoginAccountParams( + root=ChatgptLoginAccountParams(type="chatgpt"), + ) + ) + response_root = response.root + if not isinstance(response_root, ChatgptLoginAccountResponse): + raise RuntimeError(f"unexpected ChatGPT login response: {response_root!r}") + return ChatgptLoginHandle( + client, + response_root.login_id, + response_root.auth_url, + ) + + +async def async_start_chatgpt_login(owner: _AsyncLoginOwner) -> AsyncChatgptLoginHandle: + """Start async browser ChatGPT login and return that attempt's handle.""" + response = await owner._client.account_login_start( + LoginAccountParams( + root=ChatgptLoginAccountParams(type="chatgpt"), + ) + ) + response_root = response.root + if not isinstance(response_root, ChatgptLoginAccountResponse): + raise RuntimeError(f"unexpected ChatGPT login response: {response_root!r}") + return AsyncChatgptLoginHandle( + owner, + response_root.login_id, + response_root.auth_url, + ) + + +def start_device_code_login(client: AppServerClient) -> DeviceCodeLoginHandle: + """Start device-code ChatGPT login and return the handle for that attempt.""" + response = client.account_login_start( + LoginAccountParams( + root=ChatgptDeviceCodeLoginAccountParams(type="chatgptDeviceCode"), + ) + ) + response_root = response.root + if not isinstance(response_root, ChatgptDeviceCodeLoginAccountResponse): + raise RuntimeError(f"unexpected device-code login response: {response_root!r}") + return DeviceCodeLoginHandle( + client, + response_root.login_id, + response_root.verification_url, + response_root.user_code, + ) + + +async def async_start_device_code_login( + owner: _AsyncLoginOwner, +) -> AsyncDeviceCodeLoginHandle: + """Start async device-code ChatGPT login and return that attempt's handle.""" + response = await owner._client.account_login_start( + LoginAccountParams( + root=ChatgptDeviceCodeLoginAccountParams(type="chatgptDeviceCode"), + ) + ) + response_root = response.root + if not isinstance(response_root, ChatgptDeviceCodeLoginAccountResponse): + raise RuntimeError(f"unexpected device-code login response: {response_root!r}") + return AsyncDeviceCodeLoginHandle( + owner, + response_root.login_id, + response_root.verification_url, + response_root.user_code, + ) + + +@dataclass(slots=True) +class ChatgptLoginHandle: + """Live browser-login attempt returned by `Codex.login_chatgpt()`.""" + + _client: AppServerClient + login_id: str + auth_url: str + + def wait(self) -> AccountLoginCompletedNotification: + """Wait for this browser login attempt's completion notification.""" + return self._client.wait_for_login_completed(self.login_id) + + def cancel(self) -> CancelLoginAccountResponse: + """Cancel this browser login attempt.""" + return self._client.account_login_cancel(self.login_id) + + +@dataclass(slots=True) +class DeviceCodeLoginHandle: + """Live device-code login attempt returned by `Codex.login_chatgpt_device_code()`.""" + + _client: AppServerClient + login_id: str + verification_url: str + user_code: str + + def wait(self) -> AccountLoginCompletedNotification: + """Wait for this device-code login attempt's completion notification.""" + return self._client.wait_for_login_completed(self.login_id) + + def cancel(self) -> CancelLoginAccountResponse: + """Cancel this device-code login attempt.""" + return self._client.account_login_cancel(self.login_id) + + +@dataclass(slots=True) +class AsyncChatgptLoginHandle: + """Live browser-login attempt returned by `AsyncCodex.login_chatgpt()`.""" + + _codex: _AsyncLoginOwner + login_id: str + auth_url: str + + async def wait(self) -> AccountLoginCompletedNotification: + """Wait for this browser login attempt's completion notification.""" + await self._codex._ensure_initialized() + return await self._codex._client.wait_for_login_completed(self.login_id) + + async def cancel(self) -> CancelLoginAccountResponse: + """Cancel this browser login attempt.""" + await self._codex._ensure_initialized() + return await self._codex._client.account_login_cancel(self.login_id) + + +@dataclass(slots=True) +class AsyncDeviceCodeLoginHandle: + """Live device-code attempt returned by `AsyncCodex.login_chatgpt_device_code()`.""" + + _codex: _AsyncLoginOwner + login_id: str + verification_url: str + user_code: str + + async def wait(self) -> AccountLoginCompletedNotification: + """Wait for this device-code login attempt's completion notification.""" + await self._codex._ensure_initialized() + return await self._codex._client.wait_for_login_completed(self.login_id) + + async def cancel(self) -> CancelLoginAccountResponse: + """Cancel this device-code login attempt.""" + await self._codex._ensure_initialized() + return await self._codex._client.account_login_cancel(self.login_id) diff --git a/sdk/python/src/openai_codex/_message_router.py b/sdk/python/src/openai_codex/_message_router.py index 7d4554bb8a..14a01336a4 100644 --- a/sdk/python/src/openai_codex/_message_router.py +++ b/sdk/python/src/openai_codex/_message_router.py @@ -6,6 +6,7 @@ from collections import deque from .errors import AppServerError, map_jsonrpc_error from .generated.notification_registry import notification_turn_id +from .generated.v2_all import AccountLoginCompletedNotification from .models import JsonValue, Notification, UnknownNotification ResponseQueueItem = JsonValue | BaseException @@ -25,6 +26,8 @@ class MessageRouter: """Create empty response, turn, and global notification queues.""" self._lock = threading.Lock() self._response_waiters: dict[str, queue.Queue[ResponseQueueItem]] = {} + self._login_notifications: dict[str, queue.Queue[NotificationQueueItem]] = {} + self._pending_login_notifications: dict[str, deque[Notification]] = {} self._turn_notifications: dict[str, queue.Queue[NotificationQueueItem]] = {} self._pending_turn_notifications: dict[str, deque[Notification]] = {} self._global_notifications: queue.Queue[NotificationQueueItem] = queue.Queue() @@ -51,6 +54,36 @@ class MessageRouter: raise item return item + def register_login(self, login_id: str) -> None: + """Register a queue for one interactive login attempt.""" + + login_queue: queue.Queue[NotificationQueueItem] = queue.Queue() + with self._lock: + if login_id in self._login_notifications: + return + pending = self._pending_login_notifications.pop(login_id, deque()) + self._login_notifications[login_id] = login_queue + for notification in pending: + login_queue.put(notification) + + def unregister_login(self, login_id: str) -> None: + """Stop routing future notifications for one login attempt.""" + + with self._lock: + self._login_notifications.pop(login_id, None) + + def next_login_notification(self, login_id: str) -> Notification: + """Block until the next notification for a registered login attempt.""" + + with self._lock: + login_queue = self._login_notifications.get(login_id) + if login_queue is None: + raise RuntimeError(f"login {login_id!r} is not registered for waiting") + item = login_queue.get() + if isinstance(item, BaseException): + raise item + return item + def register_turn(self, turn_id: str) -> None: """Register a queue for a turn stream and replay early events.""" @@ -111,6 +144,18 @@ class MessageRouter: def route_notification(self, notification: Notification) -> None: """Deliver a notification to a turn queue or the global queue.""" + login_id = self._notification_login_id(notification) + if login_id is not None: + with self._lock: + login_queue = self._login_notifications.get(login_id) + if login_queue is None: + self._pending_login_notifications.setdefault(login_id, deque()).append( + notification + ) + return + login_queue.put(notification) + return + turn_id = self._notification_turn_id(notification) if turn_id is None: self._global_notifications.put(notification) @@ -132,16 +177,35 @@ class MessageRouter: with self._lock: response_waiters = list(self._response_waiters.values()) self._response_waiters.clear() + login_queues = list(self._login_notifications.values()) + self._login_notifications.clear() + self._pending_login_notifications.clear() turn_queues = list(self._turn_notifications.values()) self._pending_turn_notifications.clear() # Put the same transport failure into every queue so no SDK call blocks # forever waiting for a response that cannot arrive. for waiter in response_waiters: waiter.put(exc) + for login_queue in login_queues: + login_queue.put(exc) for turn_queue in turn_queues: turn_queue.put(exc) self._global_notifications.put(exc) + def _notification_login_id(self, notification: Notification) -> str | None: + """Extract the login attempt id from completion notifications.""" + if notification.method != "account/login/completed": + return None + + payload = notification.payload + if isinstance(payload, AccountLoginCompletedNotification): + return payload.login_id + if isinstance(payload, UnknownNotification): + raw_login_id = payload.params.get("loginId") + if isinstance(raw_login_id, str): + return raw_login_id + return None + def _notification_turn_id(self, notification: Notification) -> str | None: """Extract routing ids from known generated payloads or raw unknown payloads.""" payload = notification.payload diff --git a/sdk/python/src/openai_codex/api.py b/sdk/python/src/openai_codex/api.py index 0ea99f9b3d..fe9b5bc67f 100644 --- a/sdk/python/src/openai_codex/api.py +++ b/sdk/python/src/openai_codex/api.py @@ -22,6 +22,16 @@ from ._inputs import ( _normalize_run_input, _to_wire_input, ) +from ._login import ( + AsyncChatgptLoginHandle, + AsyncDeviceCodeLoginHandle, + ChatgptLoginHandle, + DeviceCodeLoginHandle, + async_start_chatgpt_login, + async_start_device_code_login, + start_chatgpt_login, + start_device_code_login, +) from ._run import ( RunResult, _collect_async_run_result, @@ -30,6 +40,10 @@ from ._run import ( from .async_client import AsyncAppServerClient from .client import AppServerClient, AppServerConfig from .generated.v2_all import ( + ApiKeyLoginAccountParams, + GetAccountParams, + GetAccountResponse, + LoginAccountParams, ModelListResponse, Personality, ReasoningEffort, @@ -85,6 +99,33 @@ class Codex: def close(self) -> None: self._client.close() + def login_api_key(self, api_key: str) -> None: + """Authenticate app-server with an API key.""" + self._client.account_login_start( + LoginAccountParams( + root=ApiKeyLoginAccountParams( + api_key=api_key, + type="apiKey", + ) + ) + ) + + def login_chatgpt(self) -> ChatgptLoginHandle: + """Start browser-based ChatGPT login and return its live handle.""" + return start_chatgpt_login(self._client) + + def login_chatgpt_device_code(self) -> DeviceCodeLoginHandle: + """Start device-code ChatGPT login and return its live handle.""" + return start_device_code_login(self._client) + + def account(self, *, refresh_token: bool = False) -> GetAccountResponse: + """Read the current app-server account state.""" + return self._client.account_read(GetAccountParams(refresh_token=refresh_token)) + + def logout(self) -> None: + """Clear the current app-server account session.""" + self._client.account_logout() + # BEGIN GENERATED: Codex.flat_methods def thread_start( self, @@ -286,6 +327,38 @@ class AsyncCodex: self._init = None self._initialized = False + async def login_api_key(self, api_key: str) -> None: + """Authenticate app-server with an API key.""" + await self._ensure_initialized() + await self._client.account_login_start( + LoginAccountParams( + root=ApiKeyLoginAccountParams( + api_key=api_key, + type="apiKey", + ) + ) + ) + + async def login_chatgpt(self) -> AsyncChatgptLoginHandle: + """Start browser-based ChatGPT login and return its live handle.""" + await self._ensure_initialized() + return await async_start_chatgpt_login(self) + + async def login_chatgpt_device_code(self) -> AsyncDeviceCodeLoginHandle: + """Start device-code ChatGPT login and return its live handle.""" + await self._ensure_initialized() + return await async_start_device_code_login(self) + + async def account(self, *, refresh_token: bool = False) -> GetAccountResponse: + """Read the current app-server account state.""" + await self._ensure_initialized() + return await self._client.account_read(GetAccountParams(refresh_token=refresh_token)) + + async def logout(self) -> None: + """Clear the current app-server account session.""" + await self._ensure_initialized() + await self._client.account_logout() + # BEGIN GENERATED: AsyncCodex.flat_methods async def thread_start( self, diff --git a/sdk/python/src/openai_codex/async_client.py b/sdk/python/src/openai_codex/async_client.py index 2c38a43087..282154b0cc 100644 --- a/sdk/python/src/openai_codex/async_client.py +++ b/sdk/python/src/openai_codex/async_client.py @@ -8,7 +8,14 @@ from pydantic import BaseModel from .client import AppServerClient, AppServerConfig from .generated.v2_all import ( + AccountLoginCompletedNotification, AgentMessageDeltaNotification, + CancelLoginAccountResponse, + GetAccountParams as V2GetAccountParams, + GetAccountResponse, + LoginAccountParams as V2LoginAccountParams, + LoginAccountResponse, + LogoutAccountResponse, ModelListResponse, ThreadArchiveResponse, ThreadCompactStartResponse, @@ -88,6 +95,14 @@ class AsyncAppServerClient: """Register a turn notification queue on the wrapped sync client.""" self._sync.register_turn_notifications(turn_id) + def register_login_notifications(self, login_id: str) -> None: + """Register a login notification queue on the wrapped sync client.""" + self._sync.register_login_notifications(login_id) + + def unregister_login_notifications(self, login_id: str) -> None: + """Unregister a login notification queue on the wrapped sync client.""" + self._sync.unregister_login_notifications(login_id) + def unregister_turn_notifications(self, turn_id: str) -> None: """Unregister a turn notification queue on the wrapped sync client.""" self._sync.unregister_turn_notifications(turn_id) @@ -107,6 +122,28 @@ class AsyncAppServerClient: response_model=response_model, ) + async def account_login_start( + self, + params: V2LoginAccountParams | JsonObject, + ) -> LoginAccountResponse: + """Start one account login attempt through the wrapped sync client.""" + return await self._call_sync(self._sync.account_login_start, params) + + async def account_login_cancel(self, login_id: str) -> CancelLoginAccountResponse: + """Cancel one active account login attempt through the wrapped sync client.""" + return await self._call_sync(self._sync.account_login_cancel, login_id) + + async def account_read( + self, + params: V2GetAccountParams | JsonObject | None = None, + ) -> GetAccountResponse: + """Read current account state through the wrapped sync client.""" + return await self._call_sync(self._sync.account_read, params) + + async def account_logout(self) -> LogoutAccountResponse: + """Clear the active account session through the wrapped sync client.""" + return await self._call_sync(self._sync.account_logout) + async def thread_start( self, params: V2ThreadStartParams | JsonObject | None = None ) -> ThreadStartResponse: @@ -211,10 +248,21 @@ class AsyncAppServerClient: """Wait for the next global notification without blocking the event loop.""" return await self._call_sync(self._sync.next_notification) + async def next_login_notification(self, login_id: str) -> Notification: + """Wait for the next notification routed to one login attempt.""" + return await self._call_sync(self._sync.next_login_notification, login_id) + async def next_turn_notification(self, turn_id: str) -> Notification: """Wait for the next notification routed to one turn.""" return await self._call_sync(self._sync.next_turn_notification, turn_id) + async def wait_for_login_completed( + self, + login_id: str, + ) -> AccountLoginCompletedNotification: + """Wait for the completion notification routed to one login attempt.""" + return await self._call_sync(self._sync.wait_for_login_completed, login_id) + async def wait_for_turn_completed(self, turn_id: str) -> TurnCompletedNotification: """Wait for the completion notification routed to one turn.""" return await self._call_sync(self._sync.wait_for_turn_completed, turn_id) diff --git a/sdk/python/src/openai_codex/client.py b/sdk/python/src/openai_codex/client.py index a4b1e4acaf..6f2e2b8aac 100644 --- a/sdk/python/src/openai_codex/client.py +++ b/sdk/python/src/openai_codex/client.py @@ -17,7 +17,16 @@ from ._version import __version__ as SDK_VERSION from .errors import AppServerError, TransportClosedError from .generated.notification_registry import NOTIFICATION_MODELS from .generated.v2_all import ( + AccountLoginCompletedNotification, AgentMessageDeltaNotification, + CancelLoginAccountResponse, + ChatgptDeviceCodeLoginAccountResponse, + ChatgptLoginAccountResponse, + GetAccountParams as V2GetAccountParams, + GetAccountResponse, + LoginAccountParams as V2LoginAccountParams, + LoginAccountResponse, + LogoutAccountResponse, ModelListResponse, ThreadArchiveResponse, ThreadCompactStartResponse, @@ -59,6 +68,8 @@ def _params_dict( | V2ThreadListParams | V2ThreadForkParams | V2TurnStartParams + | V2GetAccountParams + | V2LoginAccountParams | JsonObject | None ), @@ -246,7 +257,10 @@ class AppServerClient: waiter = self._router.create_response_waiter(request_id) try: - self._write_message({"id": request_id, "method": method, "params": params or {}}) + message: JsonObject = {"id": request_id, "method": method} + if params is not None: + message["params"] = params + self._write_message(message) except BaseException: self._router.discard_response_waiter(request_id) raise @@ -258,12 +272,27 @@ class AppServerClient: def notify(self, method: str, params: JsonObject | None = None) -> None: """Send a JSON-RPC notification without waiting for a response.""" - self._write_message({"method": method, "params": params or {}}) + message: JsonObject = {"method": method} + if params is not None: + message["params"] = params + self._write_message(message) def next_notification(self) -> Notification: """Return the next notification that is not scoped to an active turn.""" return self._router.next_global_notification() + def register_login_notifications(self, login_id: str) -> None: + """Start routing notifications for one interactive login attempt.""" + self._router.register_login(login_id) + + def unregister_login_notifications(self, login_id: str) -> None: + """Stop routing notifications for one interactive login attempt.""" + self._router.unregister_login(login_id) + + def next_login_notification(self, login_id: str) -> Notification: + """Return the next routed notification for the requested login id.""" + return self._router.next_login_notification(login_id) + def register_turn_notifications(self, turn_id: str) -> None: """Start routing notifications for one turn into its dedicated queue.""" self._router.register_turn(turn_id) @@ -276,6 +305,43 @@ class AppServerClient: """Return the next routed notification for the requested turn id.""" return self._router.next_turn_notification(turn_id) + def account_login_start( + self, + params: V2LoginAccountParams | JsonObject, + ) -> LoginAccountResponse: + response = self.request( + "account/login/start", + _params_dict(params), + response_model=LoginAccountResponse, + ) + response_root = response.root + if isinstance( + response_root, + ChatgptLoginAccountResponse | ChatgptDeviceCodeLoginAccountResponse, + ): + self.register_login_notifications(response_root.login_id) + return response + + def account_login_cancel(self, login_id: str) -> CancelLoginAccountResponse: + return self.request( + "account/login/cancel", + {"loginId": login_id}, + response_model=CancelLoginAccountResponse, + ) + + def account_read( + self, + params: V2GetAccountParams | JsonObject | None = None, + ) -> GetAccountResponse: + return self.request( + "account/read", + _params_dict(params), + response_model=GetAccountResponse, + ) + + def account_logout(self) -> LogoutAccountResponse: + return self.request("account/logout", None, response_model=LogoutAccountResponse) + def thread_start( self, params: V2ThreadStartParams | JsonObject | None = None ) -> ThreadStartResponse: @@ -417,6 +483,24 @@ class AppServerClient: finally: self.unregister_turn_notifications(turn_id) + def wait_for_login_completed( + self, + login_id: str, + ) -> AccountLoginCompletedNotification: + """Block until the matching interactive login attempt completes.""" + self.register_login_notifications(login_id) + try: + while True: + notification = self.next_login_notification(login_id) + if ( + notification.method == "account/login/completed" + and isinstance(notification.payload, AccountLoginCompletedNotification) + and notification.payload.login_id == login_id + ): + return notification.payload + finally: + self.unregister_login_notifications(login_id) + def stream_text( self, thread_id: str, diff --git a/sdk/python/src/openai_codex/types.py b/sdk/python/src/openai_codex/types.py index 995daa68b9..26ffdbb3ef 100644 --- a/sdk/python/src/openai_codex/types.py +++ b/sdk/python/src/openai_codex/types.py @@ -3,8 +3,13 @@ from __future__ import annotations from .generated.v2_all import ( + Account, + AccountLoginCompletedNotification, ApprovalsReviewer, AskForApproval, + CancelLoginAccountResponse, + CancelLoginAccountStatus, + GetAccountResponse, ModelListResponse, Personality, PlanType, @@ -35,8 +40,13 @@ from .generated.v2_all import ( from .models import InitializeResponse, JsonObject, Notification __all__ = [ + "Account", + "AccountLoginCompletedNotification", "ApprovalsReviewer", "AskForApproval", + "CancelLoginAccountResponse", + "CancelLoginAccountStatus", + "GetAccountResponse", "InitializeResponse", "JsonObject", "ModelListResponse", diff --git a/sdk/python/tests/app_server_harness.py b/sdk/python/tests/app_server_harness.py index 26fa8cf7ef..63de28d3af 100644 --- a/sdk/python/tests/app_server_harness.py +++ b/sdk/python/tests/app_server_harness.py @@ -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() ) diff --git a/sdk/python/tests/test_app_server_login.py b/sdk/python/tests/test_app_server_login.py new file mode 100644 index 0000000000..088310e5e8 --- /dev/null +++ b/sdk/python/tests/test_app_server_login.py @@ -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}", + } diff --git a/sdk/python/tests/test_public_api_signatures.py b/sdk/python/tests/test_public_api_signatures.py index f13fb35c08..04eb18e4cb 100644 --- a/sdk/python/tests/test_public_api_signatures.py +++ b/sdk/python/tests/test_public_api_signatures.py @@ -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", diff --git a/sdk/python/tests/test_real_app_server_integration.py b/sdk/python/tests/test_real_app_server_integration.py index 7fcadcf534..0e688ff3c6 100644 --- a/sdk/python/tests/test_real_app_server_integration.py +++ b/sdk/python/tests/test_real_app_server_integration.py @@ -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)