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:
Ahmed Ibrahim
2026-05-17 05:49:28 +03:00
committed by GitHub
Unverified
parent 0445b290fe
commit 4c89772314
19 changed files with 772 additions and 13 deletions
+8
View File
@@ -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",
+172
View File
@@ -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)
@@ -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
+73
View File
@@ -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,
@@ -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)
+86 -2
View File
@@ -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,
+10
View File
@@ -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",