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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user