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
+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,