mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 14:50:14 +00:00
Browser sign-in on the registered loopback port; tokens in the SecretStore profile. Rides the Responses provider for conversion/streaming; refresh-on-expiry and on-401. REST: signin (background + status poll), status, signout; oauth rows in providers list.
129 lines
5.0 KiB
Python
129 lines
5.0 KiB
Python
"""`openai-codex` provider — OpenAI models through a ChatGPT subscription.
|
|
|
|
The backend speaks the same Responses wire as `/v1/responses` (stateless: full
|
|
history each turn, `store: false`, encrypted reasoning in the `_openai` sidecar), so
|
|
all conversion/parsing is inherited from `OpenAIResponsesProvider` — this subclass
|
|
only swaps the credential: a short-lived OAuth bearer from `codex_auth` instead of an
|
|
API key, plus the account/originator/session headers the backend requires.
|
|
|
|
Differences from the API-key path:
|
|
- The backend serves streamed responses only, so `complete()` drains `stream()`.
|
|
- 401 → one refresh-and-retry (the bearer died mid-flight); a rejected refresh
|
|
token surfaces as a typed sign-in-required error, never a crash loop.
|
|
- 429 → the plan's rolling usage window, surfaced as a user-readable message.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from typing import Any, Optional
|
|
|
|
from .base import AssistantTurn
|
|
from .codex_auth import (
|
|
CODEX_BASE_URL,
|
|
PLAN_LIMIT_ERROR,
|
|
CodexTokenStore,
|
|
backend_headers,
|
|
)
|
|
from .openai_responses import OpenAIResponsesProvider
|
|
|
|
|
|
def _status_code(exc: Exception) -> Optional[int]:
|
|
status = getattr(exc, "status_code", None)
|
|
if isinstance(status, int):
|
|
return status
|
|
status = getattr(getattr(exc, "response", None), "status_code", None)
|
|
return status if isinstance(status, int) else None
|
|
|
|
|
|
class CodexProvider(OpenAIResponsesProvider):
|
|
def __init__(
|
|
self,
|
|
client: Any = None,
|
|
*,
|
|
secrets: Any = None,
|
|
default_model: str = "gpt-5.2-codex",
|
|
reasoning_summary: bool = True,
|
|
):
|
|
super().__init__(
|
|
client=client,
|
|
default_model=default_model,
|
|
base_url=CODEX_BASE_URL,
|
|
reasoning_summary=reasoning_summary,
|
|
)
|
|
self._store = CodexTokenStore(secrets)
|
|
# One conversation per provider instance in practice (the router caches one
|
|
# client per provider); a uuid per instance satisfies the per-conversation
|
|
# session header without threading conversation ids through ProviderClient.
|
|
self._session_id = str(uuid.uuid4())
|
|
self._client_token: Optional[str] = None
|
|
self._injected = client is not None
|
|
|
|
def _ensure_client(self) -> Any:
|
|
if self._injected:
|
|
return self._client
|
|
# The bearer is short-lived: fetch per call (refreshes itself near expiry)
|
|
# and rebuild the SDK client whenever the token rotated.
|
|
token, account = self._store.access_token()
|
|
if self._client is None or token != self._client_token:
|
|
from openai import OpenAI
|
|
|
|
self._client = OpenAI(
|
|
api_key=token,
|
|
base_url=CODEX_BASE_URL,
|
|
default_headers=backend_headers(account, self._session_id),
|
|
)
|
|
self._client_token = token
|
|
return self._client
|
|
|
|
def _request_kwargs(
|
|
self,
|
|
*,
|
|
model: str,
|
|
messages: list[dict[str, Any]],
|
|
tools: Optional[list[dict[str, Any]]],
|
|
settings: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
kwargs = super()._request_kwargs(
|
|
model=model, messages=messages, tools=tools, settings=settings
|
|
)
|
|
# Unlike stock /v1/responses, this backend honors a reasoning effort knob.
|
|
effort = settings.get("reasoning_effort")
|
|
if isinstance(effort, str) and effort:
|
|
kwargs["reasoning"] = {**kwargs.get("reasoning", {}), "effort": effort}
|
|
# The backend rejects requests without instructions; history normally
|
|
# carries a system prompt — this is only the bare-call fallback.
|
|
kwargs.setdefault("instructions", "You are a helpful assistant.")
|
|
return kwargs
|
|
|
|
def _create(self, client: Any, kwargs: dict[str, Any]) -> Any:
|
|
try:
|
|
return super()._create(client, kwargs)
|
|
except Exception as exc:
|
|
status = _status_code(exc)
|
|
if status == 401 and not self._injected:
|
|
# The bearer died mid-flight: force one refresh and retry once.
|
|
# A rejected refresh raises CodexSignInRequired out of the store.
|
|
self._store.refresh()
|
|
self._client = None
|
|
self._client_token = None
|
|
return super()._create(self._ensure_client(), kwargs)
|
|
if status == 429:
|
|
raise RuntimeError(PLAN_LIMIT_ERROR) from exc
|
|
raise
|
|
|
|
def complete(
|
|
self,
|
|
*,
|
|
model: str,
|
|
messages: list[dict[str, Any]],
|
|
tools: Optional[list[dict[str, Any]]] = None,
|
|
**settings: Any,
|
|
) -> AssistantTurn:
|
|
# The backend only serves streamed responses — aggregate the stream.
|
|
turn: Optional[AssistantTurn] = None
|
|
for chunk in self.stream(model=model, messages=messages, tools=tools, **settings):
|
|
if chunk.turn is not None:
|
|
turn = chunk.turn
|
|
return turn if turn is not None else AssistantTurn()
|