mirror of
https://github.com/openai/codex.git
synced 2026-04-29 02:41:12 +03:00
Add Python app-server SDK (#14435)
## TL;DR Bring the Python app-server SDK from `main-with-prs-13953-and-14232` onto current `main` as a standalone SDK-only PR. - adds the new `sdk/python` and `sdk/python-runtime` package trees - keeps the scope to the SDK payload only, without the unrelated branch-history or workflow changes from the source branch - regenerates `sdk/python/src/codex_app_server/generated/v2_all.py` against current `main` schema so the extracted SDK matches today's protocol definitions ## Validation - `PYTHONPATH=sdk/python/src python3 -m pytest sdk/python/tests` Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
10
sdk/python/src/codex_app_server/__init__.py
Normal file
10
sdk/python/src/codex_app_server/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from .client import AppServerClient, AppServerConfig
|
||||
from .errors import AppServerError, JsonRpcError, TransportClosedError
|
||||
|
||||
__all__ = [
|
||||
"AppServerClient",
|
||||
"AppServerConfig",
|
||||
"AppServerError",
|
||||
"JsonRpcError",
|
||||
"TransportClosedError",
|
||||
]
|
||||
540
sdk/python/src/codex_app_server/client.py
Normal file
540
sdk/python/src/codex_app_server/client.py
Normal file
@@ -0,0 +1,540 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import uuid
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable, Iterator, TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .errors import AppServerError, TransportClosedError, map_jsonrpc_error
|
||||
from .generated.notification_registry import NOTIFICATION_MODELS
|
||||
from .generated.v2_all import (
|
||||
AgentMessageDeltaNotification,
|
||||
ModelListResponse,
|
||||
ThreadArchiveResponse,
|
||||
ThreadCompactStartResponse,
|
||||
ThreadForkParams as V2ThreadForkParams,
|
||||
ThreadForkResponse,
|
||||
ThreadListParams as V2ThreadListParams,
|
||||
ThreadListResponse,
|
||||
ThreadReadResponse,
|
||||
ThreadResumeParams as V2ThreadResumeParams,
|
||||
ThreadResumeResponse,
|
||||
ThreadSetNameResponse,
|
||||
ThreadStartParams as V2ThreadStartParams,
|
||||
ThreadStartResponse,
|
||||
ThreadUnarchiveResponse,
|
||||
TurnCompletedNotification,
|
||||
TurnInterruptResponse,
|
||||
TurnStartParams as V2TurnStartParams,
|
||||
TurnStartResponse,
|
||||
TurnSteerResponse,
|
||||
)
|
||||
from .models import (
|
||||
InitializeResponse,
|
||||
JsonObject,
|
||||
JsonValue,
|
||||
Notification,
|
||||
UnknownNotification,
|
||||
)
|
||||
from .retry import retry_on_overload
|
||||
|
||||
ModelT = TypeVar("ModelT", bound=BaseModel)
|
||||
ApprovalHandler = Callable[[str, JsonObject | None], JsonObject]
|
||||
RUNTIME_PKG_NAME = "codex-cli-bin"
|
||||
|
||||
|
||||
def _params_dict(
|
||||
params: (
|
||||
V2ThreadStartParams
|
||||
| V2ThreadResumeParams
|
||||
| V2ThreadListParams
|
||||
| V2ThreadForkParams
|
||||
| V2TurnStartParams
|
||||
| JsonObject
|
||||
| None
|
||||
),
|
||||
) -> JsonObject:
|
||||
if params is None:
|
||||
return {}
|
||||
if hasattr(params, "model_dump"):
|
||||
dumped = params.model_dump(
|
||||
by_alias=True,
|
||||
exclude_none=True,
|
||||
mode="json",
|
||||
)
|
||||
if not isinstance(dumped, dict):
|
||||
raise TypeError("Expected model_dump() to return dict")
|
||||
return dumped
|
||||
if isinstance(params, dict):
|
||||
return params
|
||||
raise TypeError(f"Expected generated params model or dict, got {type(params).__name__}")
|
||||
|
||||
|
||||
def _installed_codex_path() -> Path:
|
||||
try:
|
||||
from codex_cli_bin import bundled_codex_path
|
||||
except ImportError as exc:
|
||||
raise FileNotFoundError(
|
||||
"Unable to locate the pinned Codex runtime. Install the published SDK build "
|
||||
f"with its {RUNTIME_PKG_NAME} dependency, or set AppServerConfig.codex_bin "
|
||||
"explicitly."
|
||||
) from exc
|
||||
|
||||
return bundled_codex_path()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CodexBinResolverOps:
|
||||
installed_codex_path: Callable[[], Path]
|
||||
path_exists: Callable[[Path], bool]
|
||||
|
||||
|
||||
def _default_codex_bin_resolver_ops() -> CodexBinResolverOps:
|
||||
return CodexBinResolverOps(
|
||||
installed_codex_path=_installed_codex_path,
|
||||
path_exists=lambda path: path.exists(),
|
||||
)
|
||||
|
||||
|
||||
def resolve_codex_bin(config: "AppServerConfig", ops: CodexBinResolverOps) -> Path:
|
||||
if config.codex_bin is not None:
|
||||
codex_bin = Path(config.codex_bin)
|
||||
if not ops.path_exists(codex_bin):
|
||||
raise FileNotFoundError(
|
||||
f"Codex binary not found at {codex_bin}. Set AppServerConfig.codex_bin "
|
||||
"to a valid binary path."
|
||||
)
|
||||
return codex_bin
|
||||
|
||||
return ops.installed_codex_path()
|
||||
|
||||
|
||||
def _resolve_codex_bin(config: "AppServerConfig") -> Path:
|
||||
return resolve_codex_bin(config, _default_codex_bin_resolver_ops())
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AppServerConfig:
|
||||
codex_bin: str | None = None
|
||||
launch_args_override: tuple[str, ...] | None = None
|
||||
config_overrides: tuple[str, ...] = ()
|
||||
cwd: str | None = None
|
||||
env: dict[str, str] | None = None
|
||||
client_name: str = "codex_python_sdk"
|
||||
client_title: str = "Codex Python SDK"
|
||||
client_version: str = "0.2.0"
|
||||
experimental_api: bool = True
|
||||
|
||||
|
||||
class AppServerClient:
|
||||
"""Synchronous typed JSON-RPC client for `codex app-server` over stdio."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AppServerConfig | None = None,
|
||||
approval_handler: ApprovalHandler | None = None,
|
||||
) -> None:
|
||||
self.config = config or AppServerConfig()
|
||||
self._approval_handler = approval_handler or self._default_approval_handler
|
||||
self._proc: subprocess.Popen[str] | None = None
|
||||
self._lock = threading.Lock()
|
||||
self._turn_consumer_lock = threading.Lock()
|
||||
self._active_turn_consumer: str | None = None
|
||||
self._pending_notifications: deque[Notification] = deque()
|
||||
self._stderr_lines: deque[str] = deque(maxlen=400)
|
||||
self._stderr_thread: threading.Thread | None = None
|
||||
|
||||
def __enter__(self) -> "AppServerClient":
|
||||
self.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, _exc_type, _exc, _tb) -> None:
|
||||
self.close()
|
||||
|
||||
def start(self) -> None:
|
||||
if self._proc is not None:
|
||||
return
|
||||
|
||||
if self.config.launch_args_override is not None:
|
||||
args = list(self.config.launch_args_override)
|
||||
else:
|
||||
codex_bin = _resolve_codex_bin(self.config)
|
||||
args = [str(codex_bin)]
|
||||
for kv in self.config.config_overrides:
|
||||
args.extend(["--config", kv])
|
||||
args.extend(["app-server", "--listen", "stdio://"])
|
||||
|
||||
env = os.environ.copy()
|
||||
if self.config.env:
|
||||
env.update(self.config.env)
|
||||
|
||||
self._proc = subprocess.Popen(
|
||||
args,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
cwd=self.config.cwd,
|
||||
env=env,
|
||||
bufsize=1,
|
||||
)
|
||||
|
||||
self._start_stderr_drain_thread()
|
||||
|
||||
def close(self) -> None:
|
||||
if self._proc is None:
|
||||
return
|
||||
proc = self._proc
|
||||
self._proc = None
|
||||
self._active_turn_consumer = None
|
||||
|
||||
if proc.stdin:
|
||||
proc.stdin.close()
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
|
||||
if self._stderr_thread and self._stderr_thread.is_alive():
|
||||
self._stderr_thread.join(timeout=0.5)
|
||||
|
||||
def initialize(self) -> InitializeResponse:
|
||||
result = self.request(
|
||||
"initialize",
|
||||
{
|
||||
"clientInfo": {
|
||||
"name": self.config.client_name,
|
||||
"title": self.config.client_title,
|
||||
"version": self.config.client_version,
|
||||
},
|
||||
"capabilities": {
|
||||
"experimentalApi": self.config.experimental_api,
|
||||
},
|
||||
},
|
||||
response_model=InitializeResponse,
|
||||
)
|
||||
self.notify("initialized", None)
|
||||
return result
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
params: JsonObject | None,
|
||||
*,
|
||||
response_model: type[ModelT],
|
||||
) -> ModelT:
|
||||
result = self._request_raw(method, params)
|
||||
if not isinstance(result, dict):
|
||||
raise AppServerError(f"{method} response must be a JSON object")
|
||||
return response_model.model_validate(result)
|
||||
|
||||
def _request_raw(self, method: str, params: JsonObject | None = None) -> JsonValue:
|
||||
request_id = str(uuid.uuid4())
|
||||
self._write_message({"id": request_id, "method": method, "params": params or {}})
|
||||
|
||||
while True:
|
||||
msg = self._read_message()
|
||||
|
||||
if "method" in msg and "id" in msg:
|
||||
response = self._handle_server_request(msg)
|
||||
self._write_message({"id": msg["id"], "result": response})
|
||||
continue
|
||||
|
||||
if "method" in msg and "id" not in msg:
|
||||
self._pending_notifications.append(
|
||||
self._coerce_notification(msg["method"], msg.get("params"))
|
||||
)
|
||||
continue
|
||||
|
||||
if msg.get("id") != request_id:
|
||||
continue
|
||||
|
||||
if "error" in msg:
|
||||
err = msg["error"]
|
||||
if isinstance(err, dict):
|
||||
raise map_jsonrpc_error(
|
||||
int(err.get("code", -32000)),
|
||||
str(err.get("message", "unknown")),
|
||||
err.get("data"),
|
||||
)
|
||||
raise AppServerError("Malformed JSON-RPC error response")
|
||||
|
||||
return msg.get("result")
|
||||
|
||||
def notify(self, method: str, params: JsonObject | None = None) -> None:
|
||||
self._write_message({"method": method, "params": params or {}})
|
||||
|
||||
def next_notification(self) -> Notification:
|
||||
if self._pending_notifications:
|
||||
return self._pending_notifications.popleft()
|
||||
|
||||
while True:
|
||||
msg = self._read_message()
|
||||
if "method" in msg and "id" in msg:
|
||||
response = self._handle_server_request(msg)
|
||||
self._write_message({"id": msg["id"], "result": response})
|
||||
continue
|
||||
if "method" in msg and "id" not in msg:
|
||||
return self._coerce_notification(msg["method"], msg.get("params"))
|
||||
|
||||
def acquire_turn_consumer(self, turn_id: str) -> None:
|
||||
with self._turn_consumer_lock:
|
||||
if self._active_turn_consumer is not None:
|
||||
raise RuntimeError(
|
||||
"Concurrent turn consumers are not yet supported in the experimental SDK. "
|
||||
f"Client is already streaming turn {self._active_turn_consumer!r}; "
|
||||
f"cannot start turn {turn_id!r} until the active consumer finishes."
|
||||
)
|
||||
self._active_turn_consumer = turn_id
|
||||
|
||||
def release_turn_consumer(self, turn_id: str) -> None:
|
||||
with self._turn_consumer_lock:
|
||||
if self._active_turn_consumer == turn_id:
|
||||
self._active_turn_consumer = None
|
||||
|
||||
def thread_start(self, params: V2ThreadStartParams | JsonObject | None = None) -> ThreadStartResponse:
|
||||
return self.request("thread/start", _params_dict(params), response_model=ThreadStartResponse)
|
||||
|
||||
def thread_resume(
|
||||
self,
|
||||
thread_id: str,
|
||||
params: V2ThreadResumeParams | JsonObject | None = None,
|
||||
) -> ThreadResumeResponse:
|
||||
payload = {"threadId": thread_id, **_params_dict(params)}
|
||||
return self.request("thread/resume", payload, response_model=ThreadResumeResponse)
|
||||
|
||||
def thread_list(self, params: V2ThreadListParams | JsonObject | None = None) -> ThreadListResponse:
|
||||
return self.request("thread/list", _params_dict(params), response_model=ThreadListResponse)
|
||||
|
||||
def thread_read(self, thread_id: str, include_turns: bool = False) -> ThreadReadResponse:
|
||||
return self.request(
|
||||
"thread/read",
|
||||
{"threadId": thread_id, "includeTurns": include_turns},
|
||||
response_model=ThreadReadResponse,
|
||||
)
|
||||
|
||||
def thread_fork(
|
||||
self,
|
||||
thread_id: str,
|
||||
params: V2ThreadForkParams | JsonObject | None = None,
|
||||
) -> ThreadForkResponse:
|
||||
payload = {"threadId": thread_id, **_params_dict(params)}
|
||||
return self.request("thread/fork", payload, response_model=ThreadForkResponse)
|
||||
|
||||
def thread_archive(self, thread_id: str) -> ThreadArchiveResponse:
|
||||
return self.request("thread/archive", {"threadId": thread_id}, response_model=ThreadArchiveResponse)
|
||||
|
||||
def thread_unarchive(self, thread_id: str) -> ThreadUnarchiveResponse:
|
||||
return self.request("thread/unarchive", {"threadId": thread_id}, response_model=ThreadUnarchiveResponse)
|
||||
|
||||
def thread_set_name(self, thread_id: str, name: str) -> ThreadSetNameResponse:
|
||||
return self.request(
|
||||
"thread/name/set",
|
||||
{"threadId": thread_id, "name": name},
|
||||
response_model=ThreadSetNameResponse,
|
||||
)
|
||||
|
||||
def thread_compact(self, thread_id: str) -> ThreadCompactStartResponse:
|
||||
return self.request(
|
||||
"thread/compact/start",
|
||||
{"threadId": thread_id},
|
||||
response_model=ThreadCompactStartResponse,
|
||||
)
|
||||
|
||||
def turn_start(
|
||||
self,
|
||||
thread_id: str,
|
||||
input_items: list[JsonObject] | JsonObject | str,
|
||||
params: V2TurnStartParams | JsonObject | None = None,
|
||||
) -> TurnStartResponse:
|
||||
payload = {
|
||||
**_params_dict(params),
|
||||
"threadId": thread_id,
|
||||
"input": self._normalize_input_items(input_items),
|
||||
}
|
||||
return self.request("turn/start", payload, response_model=TurnStartResponse)
|
||||
|
||||
def turn_interrupt(self, thread_id: str, turn_id: str) -> TurnInterruptResponse:
|
||||
return self.request(
|
||||
"turn/interrupt",
|
||||
{"threadId": thread_id, "turnId": turn_id},
|
||||
response_model=TurnInterruptResponse,
|
||||
)
|
||||
|
||||
def turn_steer(
|
||||
self,
|
||||
thread_id: str,
|
||||
expected_turn_id: str,
|
||||
input_items: list[JsonObject] | JsonObject | str,
|
||||
) -> TurnSteerResponse:
|
||||
return self.request(
|
||||
"turn/steer",
|
||||
{
|
||||
"threadId": thread_id,
|
||||
"expectedTurnId": expected_turn_id,
|
||||
"input": self._normalize_input_items(input_items),
|
||||
},
|
||||
response_model=TurnSteerResponse,
|
||||
)
|
||||
|
||||
def model_list(self, include_hidden: bool = False) -> ModelListResponse:
|
||||
return self.request(
|
||||
"model/list",
|
||||
{"includeHidden": include_hidden},
|
||||
response_model=ModelListResponse,
|
||||
)
|
||||
|
||||
def request_with_retry_on_overload(
|
||||
self,
|
||||
method: str,
|
||||
params: JsonObject | None,
|
||||
*,
|
||||
response_model: type[ModelT],
|
||||
max_attempts: int = 3,
|
||||
initial_delay_s: float = 0.25,
|
||||
max_delay_s: float = 2.0,
|
||||
) -> ModelT:
|
||||
return retry_on_overload(
|
||||
lambda: self.request(method, params, response_model=response_model),
|
||||
max_attempts=max_attempts,
|
||||
initial_delay_s=initial_delay_s,
|
||||
max_delay_s=max_delay_s,
|
||||
)
|
||||
|
||||
def wait_for_turn_completed(self, turn_id: str) -> TurnCompletedNotification:
|
||||
while True:
|
||||
notification = self.next_notification()
|
||||
if (
|
||||
notification.method == "turn/completed"
|
||||
and isinstance(notification.payload, TurnCompletedNotification)
|
||||
and notification.payload.turn.id == turn_id
|
||||
):
|
||||
return notification.payload
|
||||
|
||||
def stream_until_methods(self, methods: Iterable[str] | str) -> list[Notification]:
|
||||
target_methods = {methods} if isinstance(methods, str) else set(methods)
|
||||
out: list[Notification] = []
|
||||
while True:
|
||||
notification = self.next_notification()
|
||||
out.append(notification)
|
||||
if notification.method in target_methods:
|
||||
return out
|
||||
|
||||
def stream_text(
|
||||
self,
|
||||
thread_id: str,
|
||||
text: str,
|
||||
params: V2TurnStartParams | JsonObject | None = None,
|
||||
) -> Iterator[AgentMessageDeltaNotification]:
|
||||
started = self.turn_start(thread_id, text, params=params)
|
||||
turn_id = started.turn.id
|
||||
while True:
|
||||
notification = self.next_notification()
|
||||
if (
|
||||
notification.method == "item/agentMessage/delta"
|
||||
and isinstance(notification.payload, AgentMessageDeltaNotification)
|
||||
and notification.payload.turn_id == turn_id
|
||||
):
|
||||
yield notification.payload
|
||||
continue
|
||||
if (
|
||||
notification.method == "turn/completed"
|
||||
and isinstance(notification.payload, TurnCompletedNotification)
|
||||
and notification.payload.turn.id == turn_id
|
||||
):
|
||||
break
|
||||
|
||||
def _coerce_notification(self, method: str, params: object) -> Notification:
|
||||
params_dict = params if isinstance(params, dict) else {}
|
||||
|
||||
model = NOTIFICATION_MODELS.get(method)
|
||||
if model is None:
|
||||
return Notification(method=method, payload=UnknownNotification(params=params_dict))
|
||||
|
||||
try:
|
||||
payload = model.model_validate(params_dict)
|
||||
except Exception: # noqa: BLE001
|
||||
return Notification(method=method, payload=UnknownNotification(params=params_dict))
|
||||
return Notification(method=method, payload=payload)
|
||||
|
||||
def _normalize_input_items(
|
||||
self,
|
||||
input_items: list[JsonObject] | JsonObject | str,
|
||||
) -> list[JsonObject]:
|
||||
if isinstance(input_items, str):
|
||||
return [{"type": "text", "text": input_items}]
|
||||
if isinstance(input_items, dict):
|
||||
return [input_items]
|
||||
return input_items
|
||||
|
||||
def _default_approval_handler(self, method: str, params: JsonObject | None) -> JsonObject:
|
||||
if method == "item/commandExecution/requestApproval":
|
||||
return {"decision": "accept"}
|
||||
if method == "item/fileChange/requestApproval":
|
||||
return {"decision": "accept"}
|
||||
return {}
|
||||
|
||||
def _start_stderr_drain_thread(self) -> None:
|
||||
if self._proc is None or self._proc.stderr is None:
|
||||
return
|
||||
|
||||
def _drain() -> None:
|
||||
stderr = self._proc.stderr
|
||||
if stderr is None:
|
||||
return
|
||||
for line in stderr:
|
||||
self._stderr_lines.append(line.rstrip("\n"))
|
||||
|
||||
self._stderr_thread = threading.Thread(target=_drain, daemon=True)
|
||||
self._stderr_thread.start()
|
||||
|
||||
def _stderr_tail(self, limit: int = 40) -> str:
|
||||
return "\n".join(list(self._stderr_lines)[-limit:])
|
||||
|
||||
def _handle_server_request(self, msg: dict[str, JsonValue]) -> JsonObject:
|
||||
method = msg["method"]
|
||||
params = msg.get("params")
|
||||
if not isinstance(method, str):
|
||||
return {}
|
||||
return self._approval_handler(
|
||||
method,
|
||||
params if isinstance(params, dict) else None,
|
||||
)
|
||||
|
||||
def _write_message(self, payload: JsonObject) -> None:
|
||||
if self._proc is None or self._proc.stdin is None:
|
||||
raise TransportClosedError("app-server is not running")
|
||||
with self._lock:
|
||||
self._proc.stdin.write(json.dumps(payload) + "\n")
|
||||
self._proc.stdin.flush()
|
||||
|
||||
def _read_message(self) -> dict[str, JsonValue]:
|
||||
if self._proc is None or self._proc.stdout is None:
|
||||
raise TransportClosedError("app-server is not running")
|
||||
|
||||
line = self._proc.stdout.readline()
|
||||
if not line:
|
||||
raise TransportClosedError(
|
||||
f"app-server closed stdout. stderr_tail={self._stderr_tail()[:2000]}"
|
||||
)
|
||||
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise AppServerError(f"Invalid JSON-RPC line: {line!r}") from exc
|
||||
|
||||
if not isinstance(message, dict):
|
||||
raise AppServerError(f"Invalid JSON-RPC payload: {message!r}")
|
||||
return message
|
||||
|
||||
|
||||
def default_codex_home() -> str:
|
||||
return str(Path.home() / ".codex")
|
||||
125
sdk/python/src/codex_app_server/errors.py
Normal file
125
sdk/python/src/codex_app_server/errors.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AppServerError(Exception):
|
||||
"""Base exception for SDK errors."""
|
||||
|
||||
|
||||
class JsonRpcError(AppServerError):
|
||||
"""Raw JSON-RPC error wrapper from the server."""
|
||||
|
||||
def __init__(self, code: int, message: str, data: Any = None):
|
||||
super().__init__(f"JSON-RPC error {code}: {message}")
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.data = data
|
||||
|
||||
|
||||
class TransportClosedError(AppServerError):
|
||||
"""Raised when the app-server transport closes unexpectedly."""
|
||||
|
||||
|
||||
class AppServerRpcError(JsonRpcError):
|
||||
"""Base typed error for JSON-RPC failures."""
|
||||
|
||||
|
||||
class ParseError(AppServerRpcError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidRequestError(AppServerRpcError):
|
||||
pass
|
||||
|
||||
|
||||
class MethodNotFoundError(AppServerRpcError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidParamsError(AppServerRpcError):
|
||||
pass
|
||||
|
||||
|
||||
class InternalRpcError(AppServerRpcError):
|
||||
pass
|
||||
|
||||
|
||||
class ServerBusyError(AppServerRpcError):
|
||||
"""Server is overloaded / unavailable and caller should retry."""
|
||||
|
||||
|
||||
class RetryLimitExceededError(ServerBusyError):
|
||||
"""Server exhausted internal retry budget for a retryable operation."""
|
||||
|
||||
|
||||
def _contains_retry_limit_text(message: str) -> bool:
|
||||
lowered = message.lower()
|
||||
return "retry limit" in lowered or "too many failed attempts" in lowered
|
||||
|
||||
|
||||
def _is_server_overloaded(data: Any) -> bool:
|
||||
if data is None:
|
||||
return False
|
||||
|
||||
if isinstance(data, str):
|
||||
return data.lower() == "server_overloaded"
|
||||
|
||||
if isinstance(data, dict):
|
||||
direct = (
|
||||
data.get("codex_error_info")
|
||||
or data.get("codexErrorInfo")
|
||||
or data.get("errorInfo")
|
||||
)
|
||||
if isinstance(direct, str) and direct.lower() == "server_overloaded":
|
||||
return True
|
||||
if isinstance(direct, dict):
|
||||
for value in direct.values():
|
||||
if isinstance(value, str) and value.lower() == "server_overloaded":
|
||||
return True
|
||||
for value in data.values():
|
||||
if _is_server_overloaded(value):
|
||||
return True
|
||||
|
||||
if isinstance(data, list):
|
||||
return any(_is_server_overloaded(value) for value in data)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def map_jsonrpc_error(code: int, message: str, data: Any = None) -> JsonRpcError:
|
||||
"""Map a raw JSON-RPC error into a richer SDK exception class."""
|
||||
|
||||
if code == -32700:
|
||||
return ParseError(code, message, data)
|
||||
if code == -32600:
|
||||
return InvalidRequestError(code, message, data)
|
||||
if code == -32601:
|
||||
return MethodNotFoundError(code, message, data)
|
||||
if code == -32602:
|
||||
return InvalidParamsError(code, message, data)
|
||||
if code == -32603:
|
||||
return InternalRpcError(code, message, data)
|
||||
|
||||
if -32099 <= code <= -32000:
|
||||
if _is_server_overloaded(data):
|
||||
if _contains_retry_limit_text(message):
|
||||
return RetryLimitExceededError(code, message, data)
|
||||
return ServerBusyError(code, message, data)
|
||||
if _contains_retry_limit_text(message):
|
||||
return RetryLimitExceededError(code, message, data)
|
||||
return AppServerRpcError(code, message, data)
|
||||
|
||||
return JsonRpcError(code, message, data)
|
||||
|
||||
|
||||
def is_retryable_error(exc: BaseException) -> bool:
|
||||
"""True if the exception is a transient overload-style error."""
|
||||
|
||||
if isinstance(exc, ServerBusyError):
|
||||
return True
|
||||
|
||||
if isinstance(exc, JsonRpcError):
|
||||
return _is_server_overloaded(exc.data)
|
||||
|
||||
return False
|
||||
1
sdk/python/src/codex_app_server/generated/__init__.py
Normal file
1
sdk/python/src/codex_app_server/generated/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Auto-generated Python types derived from the app-server schemas."""
|
||||
@@ -0,0 +1,102 @@
|
||||
# Auto-generated by scripts/update_sdk_artifacts.py
|
||||
# DO NOT EDIT MANUALLY.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .v2_all import AccountLoginCompletedNotification
|
||||
from .v2_all import AccountRateLimitsUpdatedNotification
|
||||
from .v2_all import AccountUpdatedNotification
|
||||
from .v2_all import AgentMessageDeltaNotification
|
||||
from .v2_all import AppListUpdatedNotification
|
||||
from .v2_all import CommandExecOutputDeltaNotification
|
||||
from .v2_all import CommandExecutionOutputDeltaNotification
|
||||
from .v2_all import ConfigWarningNotification
|
||||
from .v2_all import ContextCompactedNotification
|
||||
from .v2_all import DeprecationNoticeNotification
|
||||
from .v2_all import ErrorNotification
|
||||
from .v2_all import FileChangeOutputDeltaNotification
|
||||
from .v2_all import FuzzyFileSearchSessionCompletedNotification
|
||||
from .v2_all import FuzzyFileSearchSessionUpdatedNotification
|
||||
from .v2_all import HookCompletedNotification
|
||||
from .v2_all import HookStartedNotification
|
||||
from .v2_all import ItemCompletedNotification
|
||||
from .v2_all import ItemStartedNotification
|
||||
from .v2_all import McpServerOauthLoginCompletedNotification
|
||||
from .v2_all import McpToolCallProgressNotification
|
||||
from .v2_all import ModelReroutedNotification
|
||||
from .v2_all import PlanDeltaNotification
|
||||
from .v2_all import ReasoningSummaryPartAddedNotification
|
||||
from .v2_all import ReasoningSummaryTextDeltaNotification
|
||||
from .v2_all import ReasoningTextDeltaNotification
|
||||
from .v2_all import ServerRequestResolvedNotification
|
||||
from .v2_all import SkillsChangedNotification
|
||||
from .v2_all import TerminalInteractionNotification
|
||||
from .v2_all import ThreadArchivedNotification
|
||||
from .v2_all import ThreadClosedNotification
|
||||
from .v2_all import ThreadNameUpdatedNotification
|
||||
from .v2_all import ThreadRealtimeClosedNotification
|
||||
from .v2_all import ThreadRealtimeErrorNotification
|
||||
from .v2_all import ThreadRealtimeItemAddedNotification
|
||||
from .v2_all import ThreadRealtimeOutputAudioDeltaNotification
|
||||
from .v2_all import ThreadRealtimeStartedNotification
|
||||
from .v2_all import ThreadStartedNotification
|
||||
from .v2_all import ThreadStatusChangedNotification
|
||||
from .v2_all import ThreadTokenUsageUpdatedNotification
|
||||
from .v2_all import ThreadUnarchivedNotification
|
||||
from .v2_all import TurnCompletedNotification
|
||||
from .v2_all import TurnDiffUpdatedNotification
|
||||
from .v2_all import TurnPlanUpdatedNotification
|
||||
from .v2_all import TurnStartedNotification
|
||||
from .v2_all import WindowsSandboxSetupCompletedNotification
|
||||
from .v2_all import WindowsWorldWritableWarningNotification
|
||||
|
||||
NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
|
||||
"account/login/completed": AccountLoginCompletedNotification,
|
||||
"account/rateLimits/updated": AccountRateLimitsUpdatedNotification,
|
||||
"account/updated": AccountUpdatedNotification,
|
||||
"app/list/updated": AppListUpdatedNotification,
|
||||
"command/exec/outputDelta": CommandExecOutputDeltaNotification,
|
||||
"configWarning": ConfigWarningNotification,
|
||||
"deprecationNotice": DeprecationNoticeNotification,
|
||||
"error": ErrorNotification,
|
||||
"fuzzyFileSearch/sessionCompleted": FuzzyFileSearchSessionCompletedNotification,
|
||||
"fuzzyFileSearch/sessionUpdated": FuzzyFileSearchSessionUpdatedNotification,
|
||||
"hook/completed": HookCompletedNotification,
|
||||
"hook/started": HookStartedNotification,
|
||||
"item/agentMessage/delta": AgentMessageDeltaNotification,
|
||||
"item/commandExecution/outputDelta": CommandExecutionOutputDeltaNotification,
|
||||
"item/commandExecution/terminalInteraction": TerminalInteractionNotification,
|
||||
"item/completed": ItemCompletedNotification,
|
||||
"item/fileChange/outputDelta": FileChangeOutputDeltaNotification,
|
||||
"item/mcpToolCall/progress": McpToolCallProgressNotification,
|
||||
"item/plan/delta": PlanDeltaNotification,
|
||||
"item/reasoning/summaryPartAdded": ReasoningSummaryPartAddedNotification,
|
||||
"item/reasoning/summaryTextDelta": ReasoningSummaryTextDeltaNotification,
|
||||
"item/reasoning/textDelta": ReasoningTextDeltaNotification,
|
||||
"item/started": ItemStartedNotification,
|
||||
"mcpServer/oauthLogin/completed": McpServerOauthLoginCompletedNotification,
|
||||
"model/rerouted": ModelReroutedNotification,
|
||||
"serverRequest/resolved": ServerRequestResolvedNotification,
|
||||
"skills/changed": SkillsChangedNotification,
|
||||
"thread/archived": ThreadArchivedNotification,
|
||||
"thread/closed": ThreadClosedNotification,
|
||||
"thread/compacted": ContextCompactedNotification,
|
||||
"thread/name/updated": ThreadNameUpdatedNotification,
|
||||
"thread/realtime/closed": ThreadRealtimeClosedNotification,
|
||||
"thread/realtime/error": ThreadRealtimeErrorNotification,
|
||||
"thread/realtime/itemAdded": ThreadRealtimeItemAddedNotification,
|
||||
"thread/realtime/outputAudio/delta": ThreadRealtimeOutputAudioDeltaNotification,
|
||||
"thread/realtime/started": ThreadRealtimeStartedNotification,
|
||||
"thread/started": ThreadStartedNotification,
|
||||
"thread/status/changed": ThreadStatusChangedNotification,
|
||||
"thread/tokenUsage/updated": ThreadTokenUsageUpdatedNotification,
|
||||
"thread/unarchived": ThreadUnarchivedNotification,
|
||||
"turn/completed": TurnCompletedNotification,
|
||||
"turn/diff/updated": TurnDiffUpdatedNotification,
|
||||
"turn/plan/updated": TurnPlanUpdatedNotification,
|
||||
"turn/started": TurnStartedNotification,
|
||||
"windows/worldWritableWarning": WindowsWorldWritableWarningNotification,
|
||||
"windowsSandbox/setupCompleted": WindowsSandboxSetupCompletedNotification,
|
||||
}
|
||||
8407
sdk/python/src/codex_app_server/generated/v2_all.py
Normal file
8407
sdk/python/src/codex_app_server/generated/v2_all.py
Normal file
File diff suppressed because it is too large
Load Diff
25
sdk/python/src/codex_app_server/generated/v2_types.py
Normal file
25
sdk/python/src/codex_app_server/generated/v2_types.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Stable aliases over full v2 autogenerated models (datamodel-code-generator)."""
|
||||
|
||||
from .v2_all.ModelListResponse import ModelListResponse
|
||||
from .v2_all.ThreadCompactStartResponse import ThreadCompactStartResponse
|
||||
from .v2_all.ThreadListResponse import ThreadListResponse
|
||||
from .v2_all.ThreadReadResponse import ThreadReadResponse
|
||||
from .v2_all.ThreadTokenUsageUpdatedNotification import (
|
||||
ThreadTokenUsageUpdatedNotification,
|
||||
)
|
||||
from .v2_all.TurnCompletedNotification import ThreadItem153 as ThreadItem
|
||||
from .v2_all.TurnCompletedNotification import (
|
||||
TurnCompletedNotification as TurnCompletedNotificationPayload,
|
||||
)
|
||||
from .v2_all.TurnSteerResponse import TurnSteerResponse
|
||||
|
||||
__all__ = [
|
||||
"ModelListResponse",
|
||||
"ThreadCompactStartResponse",
|
||||
"ThreadListResponse",
|
||||
"ThreadReadResponse",
|
||||
"ThreadTokenUsageUpdatedNotification",
|
||||
"TurnCompletedNotificationPayload",
|
||||
"TurnSteerResponse",
|
||||
"ThreadItem",
|
||||
]
|
||||
97
sdk/python/src/codex_app_server/models.py
Normal file
97
sdk/python/src/codex_app_server/models.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TypeAlias
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .generated.v2_all import (
|
||||
AccountLoginCompletedNotification,
|
||||
AccountRateLimitsUpdatedNotification,
|
||||
AccountUpdatedNotification,
|
||||
AgentMessageDeltaNotification,
|
||||
AppListUpdatedNotification,
|
||||
CommandExecutionOutputDeltaNotification,
|
||||
ConfigWarningNotification,
|
||||
ContextCompactedNotification,
|
||||
DeprecationNoticeNotification,
|
||||
ErrorNotification,
|
||||
FileChangeOutputDeltaNotification,
|
||||
ItemCompletedNotification,
|
||||
ItemStartedNotification,
|
||||
McpServerOauthLoginCompletedNotification,
|
||||
McpToolCallProgressNotification,
|
||||
PlanDeltaNotification,
|
||||
RawResponseItemCompletedNotification,
|
||||
ReasoningSummaryPartAddedNotification,
|
||||
ReasoningSummaryTextDeltaNotification,
|
||||
ReasoningTextDeltaNotification,
|
||||
TerminalInteractionNotification,
|
||||
ThreadNameUpdatedNotification,
|
||||
ThreadStartedNotification,
|
||||
ThreadTokenUsageUpdatedNotification,
|
||||
TurnCompletedNotification,
|
||||
TurnDiffUpdatedNotification,
|
||||
TurnPlanUpdatedNotification,
|
||||
TurnStartedNotification,
|
||||
WindowsWorldWritableWarningNotification,
|
||||
)
|
||||
|
||||
JsonScalar: TypeAlias = str | int | float | bool | None
|
||||
JsonValue: TypeAlias = JsonScalar | dict[str, "JsonValue"] | list["JsonValue"]
|
||||
JsonObject: TypeAlias = dict[str, JsonValue]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UnknownNotification:
|
||||
params: JsonObject
|
||||
|
||||
|
||||
NotificationPayload: TypeAlias = (
|
||||
AccountLoginCompletedNotification
|
||||
| AccountRateLimitsUpdatedNotification
|
||||
| AccountUpdatedNotification
|
||||
| AgentMessageDeltaNotification
|
||||
| AppListUpdatedNotification
|
||||
| CommandExecutionOutputDeltaNotification
|
||||
| ConfigWarningNotification
|
||||
| ContextCompactedNotification
|
||||
| DeprecationNoticeNotification
|
||||
| ErrorNotification
|
||||
| FileChangeOutputDeltaNotification
|
||||
| ItemCompletedNotification
|
||||
| ItemStartedNotification
|
||||
| McpServerOauthLoginCompletedNotification
|
||||
| McpToolCallProgressNotification
|
||||
| PlanDeltaNotification
|
||||
| RawResponseItemCompletedNotification
|
||||
| ReasoningSummaryPartAddedNotification
|
||||
| ReasoningSummaryTextDeltaNotification
|
||||
| ReasoningTextDeltaNotification
|
||||
| TerminalInteractionNotification
|
||||
| ThreadNameUpdatedNotification
|
||||
| ThreadStartedNotification
|
||||
| ThreadTokenUsageUpdatedNotification
|
||||
| TurnCompletedNotification
|
||||
| TurnDiffUpdatedNotification
|
||||
| TurnPlanUpdatedNotification
|
||||
| TurnStartedNotification
|
||||
| WindowsWorldWritableWarningNotification
|
||||
| UnknownNotification
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Notification:
|
||||
method: str
|
||||
payload: NotificationPayload
|
||||
|
||||
|
||||
class ServerInfo(BaseModel):
|
||||
name: str | None = None
|
||||
version: str | None = None
|
||||
|
||||
|
||||
class InitializeResponse(BaseModel):
|
||||
serverInfo: ServerInfo | None = None
|
||||
userAgent: str | None = None
|
||||
0
sdk/python/src/codex_app_server/py.typed
Normal file
0
sdk/python/src/codex_app_server/py.typed
Normal file
41
sdk/python/src/codex_app_server/retry.py
Normal file
41
sdk/python/src/codex_app_server/retry.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import time
|
||||
from typing import Callable, TypeVar
|
||||
|
||||
from .errors import is_retryable_error
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def retry_on_overload(
|
||||
op: Callable[[], T],
|
||||
*,
|
||||
max_attempts: int = 3,
|
||||
initial_delay_s: float = 0.25,
|
||||
max_delay_s: float = 2.0,
|
||||
jitter_ratio: float = 0.2,
|
||||
) -> T:
|
||||
"""Retry helper for transient server-overload errors."""
|
||||
|
||||
if max_attempts < 1:
|
||||
raise ValueError("max_attempts must be >= 1")
|
||||
|
||||
delay = initial_delay_s
|
||||
attempt = 0
|
||||
while True:
|
||||
attempt += 1
|
||||
try:
|
||||
return op()
|
||||
except Exception as exc:
|
||||
if attempt >= max_attempts:
|
||||
raise
|
||||
if not is_retryable_error(exc):
|
||||
raise
|
||||
|
||||
jitter = delay * jitter_ratio
|
||||
sleep_for = min(max_delay_s, delay) + random.uniform(-jitter, jitter)
|
||||
if sleep_for > 0:
|
||||
time.sleep(sleep_for)
|
||||
delay = min(max_delay_s, delay * 2)
|
||||
Reference in New Issue
Block a user