From fc75d07504ae816c57ec8d3102a45137e89c535f Mon Sep 17 00:00:00 2001 From: Shaqayeq Date: Tue, 17 Mar 2026 16:05:56 -0700 Subject: [PATCH] Add Python SDK public API and examples (#14446) ## TL;DR WIP esp the examples Thin the Python SDK public surface so the wrapper layer returns canonical app-server generated models directly. - keeps `Codex` / `AsyncCodex` / `Thread` / `Turn` and input helpers, but removes alias-only type layers and custom result models - `metadata` now returns `InitializeResponse` and `run()` returns the generated app-server `Turn` - updates docs, examples, notebook, and tests to use canonical generated types and regenerates `v2_all.py` against current schema - keeps the pinned runtime-package integration flow and real integration coverage ## Validation - `PYTHONPATH=sdk/python/src python3 -m pytest sdk/python/tests` - `GH_TOKEN="$(gh auth token)" RUN_REAL_CODEX_TESTS=1 PYTHONPATH=sdk/python/src python3 -m pytest sdk/python/tests -rs` --------- Co-authored-by: Codex --- sdk/python/README.md | 13 +- sdk/python/_runtime_setup.py | 359 +++++++++ sdk/python/docs/api-reference.md | 190 +++++ sdk/python/docs/faq.md | 45 +- sdk/python/docs/getting-started.md | 71 +- .../01_quickstart_constructor/async.py | 38 + .../01_quickstart_constructor/sync.py | 28 + sdk/python/examples/02_turn_run/async.py | 43 ++ sdk/python/examples/02_turn_run/sync.py | 34 + .../examples/03_turn_stream_events/async.py | 63 ++ .../examples/03_turn_stream_events/sync.py | 55 ++ .../examples/04_models_and_metadata/async.py | 26 + .../examples/04_models_and_metadata/sync.py | 18 + .../examples/05_existing_thread/async.py | 34 + .../examples/05_existing_thread/sync.py | 25 + .../06_thread_lifecycle_and_controls/async.py | 70 ++ .../06_thread_lifecycle_and_controls/sync.py | 63 ++ .../examples/07_image_and_text/async.py | 42 ++ sdk/python/examples/07_image_and_text/sync.py | 33 + .../examples/08_local_image_and_text/async.py | 43 ++ .../examples/08_local_image_and_text/sync.py | 34 + sdk/python/examples/09_async_parity/sync.py | 31 + .../10_error_handling_and_retry/async.py | 98 +++ .../10_error_handling_and_retry/sync.py | 47 ++ sdk/python/examples/11_cli_mini_app/async.py | 96 +++ sdk/python/examples/11_cli_mini_app/sync.py | 89 +++ .../12_turn_params_kitchen_sink/async.py | 88 +++ .../12_turn_params_kitchen_sink/sync.py | 78 ++ .../13_model_select_and_turn_params/async.py | 125 ++++ .../13_model_select_and_turn_params/sync.py | 116 +++ sdk/python/examples/14_turn_controls/async.py | 71 ++ sdk/python/examples/14_turn_controls/sync.py | 63 ++ sdk/python/examples/README.md | 85 +++ sdk/python/examples/_bootstrap.py | 152 ++++ sdk/python/notebooks/sdk_walkthrough.ipynb | 587 +++++++++++++++ sdk/python/scripts/update_sdk_artifacts.py | 10 +- sdk/python/src/codex_app_server/__init__.py | 105 ++- sdk/python/src/codex_app_server/api.py | 701 ++++++++++++++++++ .../src/codex_app_server/async_client.py | 208 ++++++ .../codex_app_server/generated/v2_types.py | 25 - .../test_artifact_workflow_and_binaries.py | 46 ++ .../tests/test_async_client_behavior.py | 64 ++ sdk/python/tests/test_contract_generation.py | 2 +- .../tests/test_public_api_runtime_behavior.py | 235 ++++++ .../tests/test_public_api_signatures.py | 222 ++++++ .../tests/test_real_app_server_integration.py | 479 ++++++++++++ 46 files changed, 5081 insertions(+), 69 deletions(-) create mode 100644 sdk/python/_runtime_setup.py create mode 100644 sdk/python/docs/api-reference.md create mode 100644 sdk/python/examples/01_quickstart_constructor/async.py create mode 100644 sdk/python/examples/01_quickstart_constructor/sync.py create mode 100644 sdk/python/examples/02_turn_run/async.py create mode 100644 sdk/python/examples/02_turn_run/sync.py create mode 100644 sdk/python/examples/03_turn_stream_events/async.py create mode 100644 sdk/python/examples/03_turn_stream_events/sync.py create mode 100644 sdk/python/examples/04_models_and_metadata/async.py create mode 100644 sdk/python/examples/04_models_and_metadata/sync.py create mode 100644 sdk/python/examples/05_existing_thread/async.py create mode 100644 sdk/python/examples/05_existing_thread/sync.py create mode 100644 sdk/python/examples/06_thread_lifecycle_and_controls/async.py create mode 100644 sdk/python/examples/06_thread_lifecycle_and_controls/sync.py create mode 100644 sdk/python/examples/07_image_and_text/async.py create mode 100644 sdk/python/examples/07_image_and_text/sync.py create mode 100644 sdk/python/examples/08_local_image_and_text/async.py create mode 100644 sdk/python/examples/08_local_image_and_text/sync.py create mode 100644 sdk/python/examples/09_async_parity/sync.py create mode 100644 sdk/python/examples/10_error_handling_and_retry/async.py create mode 100644 sdk/python/examples/10_error_handling_and_retry/sync.py create mode 100644 sdk/python/examples/11_cli_mini_app/async.py create mode 100644 sdk/python/examples/11_cli_mini_app/sync.py create mode 100644 sdk/python/examples/12_turn_params_kitchen_sink/async.py create mode 100644 sdk/python/examples/12_turn_params_kitchen_sink/sync.py create mode 100644 sdk/python/examples/13_model_select_and_turn_params/async.py create mode 100644 sdk/python/examples/13_model_select_and_turn_params/sync.py create mode 100644 sdk/python/examples/14_turn_controls/async.py create mode 100644 sdk/python/examples/14_turn_controls/sync.py create mode 100644 sdk/python/examples/README.md create mode 100644 sdk/python/examples/_bootstrap.py create mode 100644 sdk/python/notebooks/sdk_walkthrough.ipynb create mode 100644 sdk/python/src/codex_app_server/api.py create mode 100644 sdk/python/src/codex_app_server/async_client.py delete mode 100644 sdk/python/src/codex_app_server/generated/v2_types.py create mode 100644 sdk/python/tests/test_async_client_behavior.py create mode 100644 sdk/python/tests/test_public_api_runtime_behavior.py create mode 100644 sdk/python/tests/test_public_api_signatures.py create mode 100644 sdk/python/tests/test_real_app_server_integration.py diff --git a/sdk/python/README.md b/sdk/python/README.md index ef3abdf63..993e4bcec 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -12,8 +12,9 @@ python -m pip install -e . ``` Published SDK builds pin an exact `codex-cli-bin` runtime dependency. For local -repo development, pass `AppServerConfig(codex_bin=...)` to point at a local -build explicitly. +repo development, either pass `AppServerConfig(codex_bin=...)` to point at a +local build explicitly, or use the repo examples/notebook bootstrap which +installs the pinned runtime package automatically. ## Quickstart @@ -22,8 +23,9 @@ from codex_app_server import Codex, TextInput with Codex() as codex: thread = codex.thread_start(model="gpt-5") - result = thread.turn(TextInput("Say hello in one sentence.")).run() - print(result.text) + completed_turn = thread.turn(TextInput("Say hello in one sentence.")).run() + print(completed_turn.status) + print(completed_turn.id) ``` ## Docs map @@ -54,7 +56,8 @@ wheel. For local repo development, the checked-in `sdk/python-runtime` package is only a template for staged release artifacts. Editable installs should use an -explicit `codex_bin` override instead. +explicit `codex_bin` override for manual SDK usage; the repo examples and +notebook bootstrap the pinned runtime package automatically. ## Maintainer workflow diff --git a/sdk/python/_runtime_setup.py b/sdk/python/_runtime_setup.py new file mode 100644 index 000000000..5eb3999f4 --- /dev/null +++ b/sdk/python/_runtime_setup.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +import importlib +import importlib.util +import json +import os +import platform +import shutil +import subprocess +import sys +import tarfile +import tempfile +import urllib.error +import urllib.request +import zipfile +from pathlib import Path + +PACKAGE_NAME = "codex-cli-bin" +PINNED_RUNTIME_VERSION = "0.116.0-alpha.1" +REPO_SLUG = "openai/codex" + + +class RuntimeSetupError(RuntimeError): + pass + + +def pinned_runtime_version() -> str: + return PINNED_RUNTIME_VERSION + + +def ensure_runtime_package_installed( + python_executable: str | Path, + sdk_python_dir: Path, + install_target: Path | None = None, +) -> str: + requested_version = pinned_runtime_version() + installed_version = None + if install_target is None: + installed_version = _installed_runtime_version(python_executable) + normalized_requested = _normalized_package_version(requested_version) + + if installed_version is not None and _normalized_package_version(installed_version) == normalized_requested: + return requested_version + + with tempfile.TemporaryDirectory(prefix="codex-python-runtime-") as temp_root_str: + temp_root = Path(temp_root_str) + archive_path = _download_release_archive(requested_version, temp_root) + runtime_binary = _extract_runtime_binary(archive_path, temp_root) + staged_runtime_dir = _stage_runtime_package( + sdk_python_dir, + requested_version, + runtime_binary, + temp_root / "runtime-stage", + ) + _install_runtime_package(python_executable, staged_runtime_dir, install_target) + + if install_target is not None: + return requested_version + + if Path(python_executable).resolve() == Path(sys.executable).resolve(): + importlib.invalidate_caches() + + installed_version = _installed_runtime_version(python_executable) + if installed_version is None or _normalized_package_version(installed_version) != normalized_requested: + raise RuntimeSetupError( + f"Expected {PACKAGE_NAME} {requested_version} in {python_executable}, " + f"but found {installed_version!r} after installation." + ) + return requested_version + + +def platform_asset_name() -> str: + system = platform.system().lower() + machine = platform.machine().lower() + + if system == "darwin": + if machine in {"arm64", "aarch64"}: + return "codex-aarch64-apple-darwin.tar.gz" + if machine in {"x86_64", "amd64"}: + return "codex-x86_64-apple-darwin.tar.gz" + elif system == "linux": + if machine in {"aarch64", "arm64"}: + return "codex-aarch64-unknown-linux-musl.tar.gz" + if machine in {"x86_64", "amd64"}: + return "codex-x86_64-unknown-linux-musl.tar.gz" + elif system == "windows": + if machine in {"aarch64", "arm64"}: + return "codex-aarch64-pc-windows-msvc.exe.zip" + if machine in {"x86_64", "amd64"}: + return "codex-x86_64-pc-windows-msvc.exe.zip" + + raise RuntimeSetupError( + f"Unsupported runtime artifact platform: system={platform.system()!r}, " + f"machine={platform.machine()!r}" + ) + + +def runtime_binary_name() -> str: + return "codex.exe" if platform.system().lower() == "windows" else "codex" + + +def _installed_runtime_version(python_executable: str | Path) -> str | None: + snippet = ( + "import importlib.metadata, json, sys\n" + "try:\n" + " from codex_cli_bin import bundled_codex_path\n" + " bundled_codex_path()\n" + " print(json.dumps({'version': importlib.metadata.version('codex-cli-bin')}))\n" + "except Exception:\n" + " sys.exit(1)\n" + ) + result = subprocess.run( + [str(python_executable), "-c", snippet], + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + return None + return json.loads(result.stdout)["version"] + + +def _release_metadata(version: str) -> dict[str, object]: + url = f"https://api.github.com/repos/{REPO_SLUG}/releases/tags/rust-v{version}" + token = _github_token() + attempts = [True, False] if token is not None else [False] + last_error: urllib.error.HTTPError | None = None + + for include_auth in attempts: + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "codex-python-runtime-setup", + } + if include_auth and token is not None: + headers["Authorization"] = f"Bearer {token}" + + request = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(request) as response: + return json.load(response) + except urllib.error.HTTPError as exc: + last_error = exc + if include_auth and exc.code == 401: + continue + break + + assert last_error is not None + raise RuntimeSetupError( + f"Failed to resolve release metadata for rust-v{version} from {REPO_SLUG}: " + f"{last_error.code} {last_error.reason}" + ) from last_error + + +def _download_release_archive(version: str, temp_root: Path) -> Path: + asset_name = platform_asset_name() + archive_path = temp_root / asset_name + + browser_download_url = ( + f"https://github.com/{REPO_SLUG}/releases/download/rust-v{version}/{asset_name}" + ) + request = urllib.request.Request( + browser_download_url, + headers={"User-Agent": "codex-python-runtime-setup"}, + ) + try: + with urllib.request.urlopen(request) as response, archive_path.open("wb") as fh: + shutil.copyfileobj(response, fh) + return archive_path + except urllib.error.HTTPError: + pass + + metadata = _release_metadata(version) + assets = metadata.get("assets") + if not isinstance(assets, list): + raise RuntimeSetupError(f"Release rust-v{version} returned malformed assets metadata.") + asset = next( + ( + item + for item in assets + if isinstance(item, dict) and item.get("name") == asset_name + ), + None, + ) + if asset is None: + raise RuntimeSetupError( + f"Release rust-v{version} does not contain asset {asset_name} for this platform." + ) + + api_url = asset.get("url") + if not isinstance(api_url, str): + api_url = None + + if api_url is not None: + token = _github_token() + if token is not None: + request = urllib.request.Request( + api_url, + headers=_github_api_headers("application/octet-stream"), + ) + try: + with urllib.request.urlopen(request) as response, archive_path.open("wb") as fh: + shutil.copyfileobj(response, fh) + return archive_path + except urllib.error.HTTPError: + pass + + if shutil.which("gh") is None: + raise RuntimeSetupError( + f"Unable to download {asset_name} for rust-v{version}. " + "Provide GH_TOKEN/GITHUB_TOKEN or install/authenticate GitHub CLI." + ) + + try: + subprocess.run( + [ + "gh", + "release", + "download", + f"rust-v{version}", + "--repo", + REPO_SLUG, + "--pattern", + asset_name, + "--dir", + str(temp_root), + ], + check=True, + text=True, + capture_output=True, + ) + except subprocess.CalledProcessError as exc: + raise RuntimeSetupError( + f"gh release download failed for rust-v{version} asset {asset_name}.\n" + f"STDOUT:\n{exc.stdout}\nSTDERR:\n{exc.stderr}" + ) from exc + return archive_path + + +def _extract_runtime_binary(archive_path: Path, temp_root: Path) -> Path: + extract_dir = temp_root / "extracted" + extract_dir.mkdir(parents=True, exist_ok=True) + if archive_path.name.endswith(".tar.gz"): + with tarfile.open(archive_path, "r:gz") as tar: + try: + tar.extractall(extract_dir, filter="data") + except TypeError: + tar.extractall(extract_dir) + elif archive_path.suffix == ".zip": + with zipfile.ZipFile(archive_path) as zip_file: + zip_file.extractall(extract_dir) + else: + raise RuntimeSetupError(f"Unsupported release archive format: {archive_path.name}") + + binary_name = runtime_binary_name() + archive_stem = archive_path.name.removesuffix(".tar.gz").removesuffix(".zip") + candidates = [ + path + for path in extract_dir.rglob("*") + if path.is_file() + and ( + path.name == binary_name + or path.name == archive_stem + or path.name.startswith("codex-") + ) + ] + if not candidates: + raise RuntimeSetupError( + f"Failed to find {binary_name} in extracted runtime archive {archive_path.name}." + ) + return candidates[0] + + +def _stage_runtime_package( + sdk_python_dir: Path, + runtime_version: str, + runtime_binary: Path, + staging_dir: Path, +) -> Path: + script_module = _load_update_script_module(sdk_python_dir) + return script_module.stage_python_runtime_package( # type: ignore[no-any-return] + staging_dir, + runtime_version, + runtime_binary.resolve(), + ) + + +def _install_runtime_package( + python_executable: str | Path, + staged_runtime_dir: Path, + install_target: Path | None, +) -> None: + args = [ + str(python_executable), + "-m", + "pip", + "install", + "--force-reinstall", + "--no-deps", + ] + if install_target is not None: + install_target.mkdir(parents=True, exist_ok=True) + args.extend(["--target", str(install_target)]) + args.append(str(staged_runtime_dir)) + try: + subprocess.run( + args, + check=True, + text=True, + capture_output=True, + ) + except subprocess.CalledProcessError as exc: + raise RuntimeSetupError( + f"Failed to install {PACKAGE_NAME} into {python_executable} from {staged_runtime_dir}.\n" + f"STDOUT:\n{exc.stdout}\nSTDERR:\n{exc.stderr}" + ) from exc + + +def _load_update_script_module(sdk_python_dir: Path): + script_path = sdk_python_dir / "scripts" / "update_sdk_artifacts.py" + spec = importlib.util.spec_from_file_location("update_sdk_artifacts", script_path) + if spec is None or spec.loader is None: + raise RuntimeSetupError(f"Failed to load {script_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _github_api_headers(accept: str) -> dict[str, str]: + headers = { + "Accept": accept, + "User-Agent": "codex-python-runtime-setup", + } + token = _github_token() + if token is not None: + headers["Authorization"] = f"Bearer {token}" + return headers + + +def _github_token() -> str | None: + for env_name in ("GH_TOKEN", "GITHUB_TOKEN"): + token = os.environ.get(env_name) + if token: + return token + return None + + +def _normalized_package_version(version: str) -> str: + return version.strip().replace("-alpha.", "a").replace("-beta.", "b") + + +__all__ = [ + "PACKAGE_NAME", + "PINNED_RUNTIME_VERSION", + "RuntimeSetupError", + "ensure_runtime_package_installed", + "pinned_runtime_version", + "platform_asset_name", +] diff --git a/sdk/python/docs/api-reference.md b/sdk/python/docs/api-reference.md new file mode 100644 index 000000000..29396b773 --- /dev/null +++ b/sdk/python/docs/api-reference.md @@ -0,0 +1,190 @@ +# Codex App Server SDK — API Reference + +Public surface of `codex_app_server` for app-server v2. + +This SDK surface is experimental. The current implementation intentionally allows only one active `TurnHandle.stream()` or `TurnHandle.run()` consumer per client instance at a time. + +## Package Entry + +```python +from codex_app_server import ( + Codex, + AsyncCodex, + Thread, + AsyncThread, + TurnHandle, + AsyncTurnHandle, + InitializeResponse, + Input, + InputItem, + TextInput, + ImageInput, + LocalImageInput, + SkillInput, + MentionInput, + TurnStatus, +) +from codex_app_server.generated.v2_all import ThreadItem +``` + +- Version: `codex_app_server.__version__` +- Requires Python >= 3.10 +- Canonical generated app-server models live in `codex_app_server.generated.v2_all` + +## Codex (sync) + +```python +Codex(config: AppServerConfig | None = None) +``` + +Properties/methods: + +- `metadata -> InitializeResponse` +- `close() -> None` +- `thread_start(*, approval_policy=None, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, personality=None, sandbox=None) -> Thread` +- `thread_list(*, archived=None, cursor=None, cwd=None, limit=None, model_providers=None, sort_key=None, source_kinds=None) -> ThreadListResponse` +- `thread_resume(thread_id: str, *, approval_policy=None, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, personality=None, sandbox=None) -> Thread` +- `thread_fork(thread_id: str, *, approval_policy=None, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, sandbox=None) -> Thread` +- `thread_archive(thread_id: str) -> ThreadArchiveResponse` +- `thread_unarchive(thread_id: str) -> Thread` +- `models(*, include_hidden: bool = False) -> ModelListResponse` + +Context manager: + +```python +with Codex() as codex: + ... +``` + +## AsyncCodex (async parity) + +```python +AsyncCodex(config: AppServerConfig | None = None) +``` + +Preferred usage: + +```python +async with AsyncCodex() as codex: + ... +``` + +`AsyncCodex` initializes lazily. Context entry is the standard path because it +ensures startup and shutdown are paired explicitly. + +Properties/methods: + +- `metadata -> InitializeResponse` +- `close() -> Awaitable[None]` +- `thread_start(*, approval_policy=None, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, personality=None, sandbox=None) -> Awaitable[AsyncThread]` +- `thread_list(*, archived=None, cursor=None, cwd=None, limit=None, model_providers=None, sort_key=None, source_kinds=None) -> Awaitable[ThreadListResponse]` +- `thread_resume(thread_id: str, *, approval_policy=None, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, personality=None, sandbox=None) -> Awaitable[AsyncThread]` +- `thread_fork(thread_id: str, *, approval_policy=None, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, sandbox=None) -> Awaitable[AsyncThread]` +- `thread_archive(thread_id: str) -> Awaitable[ThreadArchiveResponse]` +- `thread_unarchive(thread_id: str) -> Awaitable[AsyncThread]` +- `models(*, include_hidden: bool = False) -> Awaitable[ModelListResponse]` + +Async context manager: + +```python +async with AsyncCodex() as codex: + ... +``` + +## Thread / AsyncThread + +`Thread` and `AsyncThread` share the same shape and intent. + +### Thread + +- `turn(input: Input, *, approval_policy=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, summary=None) -> TurnHandle` +- `read(*, include_turns: bool = False) -> ThreadReadResponse` +- `set_name(name: str) -> ThreadSetNameResponse` +- `compact() -> ThreadCompactStartResponse` + +### AsyncThread + +- `turn(input: Input, *, approval_policy=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, summary=None) -> Awaitable[AsyncTurnHandle]` +- `read(*, include_turns: bool = False) -> Awaitable[ThreadReadResponse]` +- `set_name(name: str) -> Awaitable[ThreadSetNameResponse]` +- `compact() -> Awaitable[ThreadCompactStartResponse]` + +## TurnHandle / AsyncTurnHandle + +### TurnHandle + +- `steer(input: Input) -> TurnSteerResponse` +- `interrupt() -> TurnInterruptResponse` +- `stream() -> Iterator[Notification]` +- `run() -> codex_app_server.generated.v2_all.Turn` + +Behavior notes: + +- `stream()` and `run()` are exclusive per client instance in the current experimental build +- starting a second turn consumer on the same `Codex` instance raises `RuntimeError` + +### AsyncTurnHandle + +- `steer(input: Input) -> Awaitable[TurnSteerResponse]` +- `interrupt() -> Awaitable[TurnInterruptResponse]` +- `stream() -> AsyncIterator[Notification]` +- `run() -> Awaitable[codex_app_server.generated.v2_all.Turn]` + +Behavior notes: + +- `stream()` and `run()` are exclusive per client instance in the current experimental build +- starting a second turn consumer on the same `AsyncCodex` instance raises `RuntimeError` + +## Inputs + +```python +@dataclass class TextInput: text: str +@dataclass class ImageInput: url: str +@dataclass class LocalImageInput: path: str +@dataclass class SkillInput: name: str; path: str +@dataclass class MentionInput: name: str; path: str + +InputItem = TextInput | ImageInput | LocalImageInput | SkillInput | MentionInput +Input = list[InputItem] | InputItem +``` + +## Generated Models + +The SDK wrappers return and accept canonical generated app-server models wherever possible: + +```python +from codex_app_server.generated.v2_all import ( + AskForApproval, + ThreadReadResponse, + Turn, + TurnStartParams, + TurnStatus, +) +``` + +## Retry + errors + +```python +from codex_app_server import ( + retry_on_overload, + JsonRpcError, + MethodNotFoundError, + InvalidParamsError, + ServerBusyError, + is_retryable_error, +) +``` + +- `retry_on_overload(...)` retries transient overload errors with exponential backoff + jitter. +- `is_retryable_error(exc)` checks if an exception is transient/overload-like. + +## Example + +```python +from codex_app_server import Codex, TextInput + +with Codex() as codex: + thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + completed_turn = thread.turn(TextInput("Say hello in one sentence.")).run() + print(completed_turn.id, completed_turn.status) +``` diff --git a/sdk/python/docs/faq.md b/sdk/python/docs/faq.md index ebfd2ddad..b2c9cf3b1 100644 --- a/sdk/python/docs/faq.md +++ b/sdk/python/docs/faq.md @@ -8,24 +8,45 @@ ## `run()` vs `stream()` -- `Turn.run()` is the easiest path. It consumes events until completion and returns `TurnResult`. -- `Turn.stream()` yields raw notifications (`Notification`) so you can react event-by-event. +- `TurnHandle.run()` / `AsyncTurnHandle.run()` is the easiest path. It consumes events until completion and returns the canonical generated app-server `Turn` model. +- `TurnHandle.stream()` / `AsyncTurnHandle.stream()` yields raw notifications (`Notification`) so you can react event-by-event. Choose `run()` for most apps. Choose `stream()` for progress UIs, custom timeout logic, or custom parsing. ## Sync vs async clients -- `Codex` is the minimal sync SDK and best default. -- `AsyncAppServerClient` wraps the sync transport with `asyncio.to_thread(...)` for async-friendly call sites. +- `Codex` is the sync public API. +- `AsyncCodex` is an async replica of the same public API shape. +- Prefer `async with AsyncCodex()` for async code. It is the standard path for + explicit startup/shutdown, and `AsyncCodex` initializes lazily on context + entry or first awaited API use. If your app is not already async, stay with `Codex`. -## `thread(...)` vs `thread_resume(...)` +## Public kwargs are snake_case -- `codex.thread(thread_id)` only binds a local helper to an existing thread ID. -- `codex.thread_resume(thread_id, ...)` performs a `thread/resume` RPC and can apply overrides (model, instructions, sandbox, etc.). +Public API keyword names are snake_case. The SDK still maps them to wire camelCase under the hood. -Use `thread(...)` for simple continuation. Use `thread_resume(...)` when you need explicit resume semantics or override fields. +If you are migrating older code, update these names: + +- `approvalPolicy` -> `approval_policy` +- `baseInstructions` -> `base_instructions` +- `developerInstructions` -> `developer_instructions` +- `modelProvider` -> `model_provider` +- `modelProviders` -> `model_providers` +- `sortKey` -> `sort_key` +- `sourceKinds` -> `source_kinds` +- `outputSchema` -> `output_schema` +- `sandboxPolicy` -> `sandbox_policy` + +## Why only `thread_start(...)` and `thread_resume(...)`? + +The public API keeps only explicit lifecycle calls: + +- `thread_start(...)` to create new threads +- `thread_resume(thread_id, ...)` to continue existing threads + +This avoids duplicate ways to do the same operation and keeps behavior explicit. ## Why does constructor fail? @@ -61,7 +82,7 @@ python scripts/update_sdk_artifacts.py \ A turn is complete only when `turn/completed` arrives for that turn ID. - `run()` waits for this automatically. -- With `stream()`, make sure you keep consuming notifications until completion. +- With `stream()`, keep consuming notifications until completion. ## How do I retry safely? @@ -72,6 +93,6 @@ Do not blindly retry all errors. For `InvalidParamsError` or `MethodNotFoundErro ## Common pitfalls - Starting a new thread for every prompt when you wanted continuity. -- Forgetting to `close()` (or not using `with Codex() as codex:`). -- Ignoring `TurnResult.status` and `TurnResult.error`. -- Mixing SDK input classes with raw dicts incorrectly in minimal API paths. +- Forgetting to `close()` (or not using context managers). +- Assuming `run()` returns extra SDK-only fields instead of the generated `Turn` model. +- Mixing SDK input classes with raw dicts incorrectly. diff --git a/sdk/python/docs/getting-started.md b/sdk/python/docs/getting-started.md index 9108902b3..aaa6298d4 100644 --- a/sdk/python/docs/getting-started.md +++ b/sdk/python/docs/getting-started.md @@ -1,6 +1,8 @@ # Getting Started -This is the fastest path from install to a multi-turn thread using the minimal SDK surface. +This is the fastest path from install to a multi-turn thread using the public SDK surface. + +The SDK is experimental. Treat the API, bundled runtime strategy, and packaging details as unstable until the first public release. ## 1) Install @@ -15,30 +17,32 @@ Requirements: - Python `>=3.10` - installed `codex-cli-bin` runtime package, or an explicit `codex_bin` override -- Local Codex auth/session configured +- local Codex auth/session configured -## 2) Run your first turn +## 2) Run your first turn (sync) ```python from codex_app_server import Codex, TextInput with Codex() as codex: - print("Server:", codex.metadata.server_name, codex.metadata.server_version) + server = codex.metadata.serverInfo + print("Server:", None if server is None else server.name, None if server is None else server.version) - thread = codex.thread_start(model="gpt-5") - result = thread.turn(TextInput("Say hello in one sentence.")).run() + thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + completed_turn = thread.turn(TextInput("Say hello in one sentence.")).run() - print("Thread:", result.thread_id) - print("Turn:", result.turn_id) - print("Status:", result.status) - print("Text:", result.text) + print("Thread:", thread.id) + print("Turn:", completed_turn.id) + print("Status:", completed_turn.status) + print("Items:", len(completed_turn.items or [])) ``` What happened: - `Codex()` started and initialized `codex app-server`. - `thread_start(...)` created a thread. -- `turn(...).run()` consumed events until `turn/completed` and returned a `TurnResult`. +- `turn(...).run()` consumed events until `turn/completed` and returned the canonical generated app-server `Turn` model. +- one client can have only one active `TurnHandle.stream()` / `TurnHandle.run()` consumer at a time in the current experimental build ## 3) Continue the same thread (multi-turn) @@ -46,16 +50,37 @@ What happened: from codex_app_server import Codex, TextInput with Codex() as codex: - thread = codex.thread_start(model="gpt-5") + thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) first = thread.turn(TextInput("Summarize Rust ownership in 2 bullets.")).run() second = thread.turn(TextInput("Now explain it to a Python developer.")).run() - print("first:", first.text) - print("second:", second.text) + print("first:", first.id, first.status) + print("second:", second.id, second.status) ``` -## 4) Resume an existing thread +## 4) Async parity + +Use `async with AsyncCodex()` as the normal async entrypoint. `AsyncCodex` +initializes lazily, and context entry makes startup/shutdown explicit. + +```python +import asyncio +from codex_app_server import AsyncCodex, TextInput + + +async def main() -> None: + async with AsyncCodex() as codex: + thread = await codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + turn = await thread.turn(TextInput("Continue where we left off.")) + completed_turn = await turn.run() + print(completed_turn.id, completed_turn.status) + + +asyncio.run(main()) +``` + +## 5) Resume an existing thread ```python from codex_app_server import Codex, TextInput @@ -63,12 +88,20 @@ from codex_app_server import Codex, TextInput THREAD_ID = "thr_123" # replace with a real id with Codex() as codex: - thread = codex.thread(THREAD_ID) - result = thread.turn(TextInput("Continue where we left off.")).run() - print(result.text) + thread = codex.thread_resume(THREAD_ID) + completed_turn = thread.turn(TextInput("Continue where we left off.")).run() + print(completed_turn.id, completed_turn.status) ``` -## 5) Next stops +## 6) Generated models + +The convenience wrappers live at the package root, but the canonical app-server models live under: + +```python +from codex_app_server.generated.v2_all import Turn, TurnStatus, ThreadReadResponse +``` + +## 7) Next stops - API surface and signatures: `docs/api-reference.md` - Common decisions/pitfalls: `docs/faq.md` diff --git a/sdk/python/examples/01_quickstart_constructor/async.py b/sdk/python/examples/01_quickstart_constructor/async.py new file mode 100644 index 000000000..cf525fa63 --- /dev/null +++ b/sdk/python/examples/01_quickstart_constructor/async.py @@ -0,0 +1,38 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ( + assistant_text_from_turn, + ensure_local_sdk_src, + find_turn_by_id, + runtime_config, + server_label, +) + +ensure_local_sdk_src() + +import asyncio + +from codex_app_server import AsyncCodex, TextInput + + +async def main() -> None: + async with AsyncCodex(config=runtime_config()) as codex: + print("Server:", server_label(codex.metadata)) + + thread = await codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + turn = await thread.turn(TextInput("Say hello in one sentence.")) + result = await turn.run() + persisted = await thread.read(include_turns=True) + persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) + + print("Status:", result.status) + print("Text:", assistant_text_from_turn(persisted_turn)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/python/examples/01_quickstart_constructor/sync.py b/sdk/python/examples/01_quickstart_constructor/sync.py new file mode 100644 index 000000000..6abf29af3 --- /dev/null +++ b/sdk/python/examples/01_quickstart_constructor/sync.py @@ -0,0 +1,28 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ( + assistant_text_from_turn, + ensure_local_sdk_src, + find_turn_by_id, + runtime_config, + server_label, +) + +ensure_local_sdk_src() + +from codex_app_server import Codex, TextInput + +with Codex(config=runtime_config()) as codex: + print("Server:", server_label(codex.metadata)) + + thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + result = thread.turn(TextInput("Say hello in one sentence.")).run() + persisted = thread.read(include_turns=True) + persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) + print("Status:", result.status) + print("Text:", assistant_text_from_turn(persisted_turn)) diff --git a/sdk/python/examples/02_turn_run/async.py b/sdk/python/examples/02_turn_run/async.py new file mode 100644 index 000000000..de681a828 --- /dev/null +++ b/sdk/python/examples/02_turn_run/async.py @@ -0,0 +1,43 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ( + assistant_text_from_turn, + ensure_local_sdk_src, + find_turn_by_id, + runtime_config, +) + +ensure_local_sdk_src() + +import asyncio + +from codex_app_server import AsyncCodex, TextInput + + +async def main() -> None: + async with AsyncCodex(config=runtime_config()) as codex: + thread = await codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + turn = await thread.turn(TextInput("Give 3 bullets about SIMD.")) + result = await turn.run() + persisted = await thread.read(include_turns=True) + persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) + + print("thread_id:", thread.id) + print("turn_id:", result.id) + print("status:", result.status) + if result.error is not None: + print("error:", result.error) + print("text:", assistant_text_from_turn(persisted_turn)) + print( + "persisted.items.count:", + 0 if persisted_turn is None else len(persisted_turn.items or []), + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/python/examples/02_turn_run/sync.py b/sdk/python/examples/02_turn_run/sync.py new file mode 100644 index 000000000..823ffb7fd --- /dev/null +++ b/sdk/python/examples/02_turn_run/sync.py @@ -0,0 +1,34 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ( + assistant_text_from_turn, + ensure_local_sdk_src, + find_turn_by_id, + runtime_config, +) + +ensure_local_sdk_src() + +from codex_app_server import Codex, TextInput + +with Codex(config=runtime_config()) as codex: + thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + result = thread.turn(TextInput("Give 3 bullets about SIMD.")).run() + persisted = thread.read(include_turns=True) + persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) + + print("thread_id:", thread.id) + print("turn_id:", result.id) + print("status:", result.status) + if result.error is not None: + print("error:", result.error) + print("text:", assistant_text_from_turn(persisted_turn)) + print( + "persisted.items.count:", + 0 if persisted_turn is None else len(persisted_turn.items or []), + ) diff --git a/sdk/python/examples/03_turn_stream_events/async.py b/sdk/python/examples/03_turn_stream_events/async.py new file mode 100644 index 000000000..33509ffec --- /dev/null +++ b/sdk/python/examples/03_turn_stream_events/async.py @@ -0,0 +1,63 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ( + assistant_text_from_turn, + ensure_local_sdk_src, + find_turn_by_id, + runtime_config, +) + +ensure_local_sdk_src() + +import asyncio + +from codex_app_server import AsyncCodex, TextInput + + +async def main() -> None: + async with AsyncCodex(config=runtime_config()) as codex: + thread = await codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + turn = await thread.turn(TextInput("Explain SIMD in 3 short bullets.")) + + event_count = 0 + saw_started = False + saw_delta = False + completed_status = "unknown" + + async for event in turn.stream(): + event_count += 1 + if event.method == "turn/started": + saw_started = True + print("stream.started") + continue + if event.method == "item/agentMessage/delta": + delta = getattr(event.payload, "delta", "") + if delta: + if not saw_delta: + print("assistant> ", end="", flush=True) + print(delta, end="", flush=True) + saw_delta = True + continue + if event.method == "turn/completed": + completed_status = getattr(event.payload.turn.status, "value", str(event.payload.turn.status)) + + if saw_delta: + print() + else: + persisted = await thread.read(include_turns=True) + persisted_turn = find_turn_by_id(persisted.thread.turns, turn.id) + final_text = assistant_text_from_turn(persisted_turn).strip() or "[no assistant text]" + print("assistant>", final_text) + + print("stream.started.seen:", saw_started) + print("stream.completed:", completed_status) + print("events.count:", event_count) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/python/examples/03_turn_stream_events/sync.py b/sdk/python/examples/03_turn_stream_events/sync.py new file mode 100644 index 000000000..d458e171f --- /dev/null +++ b/sdk/python/examples/03_turn_stream_events/sync.py @@ -0,0 +1,55 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ( + assistant_text_from_turn, + ensure_local_sdk_src, + find_turn_by_id, + runtime_config, +) + +ensure_local_sdk_src() + +from codex_app_server import Codex, TextInput + +with Codex(config=runtime_config()) as codex: + thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + turn = thread.turn(TextInput("Explain SIMD in 3 short bullets.")) + + event_count = 0 + saw_started = False + saw_delta = False + completed_status = "unknown" + + for event in turn.stream(): + event_count += 1 + if event.method == "turn/started": + saw_started = True + print("stream.started") + continue + if event.method == "item/agentMessage/delta": + delta = getattr(event.payload, "delta", "") + if delta: + if not saw_delta: + print("assistant> ", end="", flush=True) + print(delta, end="", flush=True) + saw_delta = True + continue + if event.method == "turn/completed": + completed_status = getattr(event.payload.turn.status, "value", str(event.payload.turn.status)) + + if saw_delta: + print() + else: + persisted = thread.read(include_turns=True) + persisted_turn = find_turn_by_id(persisted.thread.turns, turn.id) + final_text = assistant_text_from_turn(persisted_turn).strip() or "[no assistant text]" + print("assistant>", final_text) + + print("stream.started.seen:", saw_started) + print("stream.completed:", completed_status) + print("events.count:", event_count) diff --git a/sdk/python/examples/04_models_and_metadata/async.py b/sdk/python/examples/04_models_and_metadata/async.py new file mode 100644 index 000000000..e434b4321 --- /dev/null +++ b/sdk/python/examples/04_models_and_metadata/async.py @@ -0,0 +1,26 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ensure_local_sdk_src, runtime_config, server_label + +ensure_local_sdk_src() + +import asyncio + +from codex_app_server import AsyncCodex + + +async def main() -> None: + async with AsyncCodex(config=runtime_config()) as codex: + print("server:", server_label(codex.metadata)) + models = await codex.models() + print("models.count:", len(models.data)) + print("models:", ", ".join(model.id for model in models.data[:5]) or "[none]") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/python/examples/04_models_and_metadata/sync.py b/sdk/python/examples/04_models_and_metadata/sync.py new file mode 100644 index 000000000..66c33548c --- /dev/null +++ b/sdk/python/examples/04_models_and_metadata/sync.py @@ -0,0 +1,18 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ensure_local_sdk_src, runtime_config, server_label + +ensure_local_sdk_src() + +from codex_app_server import Codex + +with Codex(config=runtime_config()) as codex: + print("server:", server_label(codex.metadata)) + models = codex.models() + print("models.count:", len(models.data)) + print("models:", ", ".join(model.id for model in models.data[:5]) or "[none]") diff --git a/sdk/python/examples/05_existing_thread/async.py b/sdk/python/examples/05_existing_thread/async.py new file mode 100644 index 000000000..8ce2a1af9 --- /dev/null +++ b/sdk/python/examples/05_existing_thread/async.py @@ -0,0 +1,34 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import assistant_text_from_turn, ensure_local_sdk_src, find_turn_by_id, runtime_config + +ensure_local_sdk_src() + +import asyncio + +from codex_app_server import AsyncCodex, TextInput + + +async def main() -> None: + async with AsyncCodex(config=runtime_config()) as codex: + original = await codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + + first_turn = await original.turn(TextInput("Tell me one fact about Saturn.")) + _ = await first_turn.run() + print("Created thread:", original.id) + + resumed = await codex.thread_resume(original.id) + second_turn = await resumed.turn(TextInput("Continue with one more fact.")) + second = await second_turn.run() + persisted = await resumed.read(include_turns=True) + persisted_turn = find_turn_by_id(persisted.thread.turns, second.id) + print(assistant_text_from_turn(persisted_turn)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/python/examples/05_existing_thread/sync.py b/sdk/python/examples/05_existing_thread/sync.py new file mode 100644 index 000000000..f5a0c4ec4 --- /dev/null +++ b/sdk/python/examples/05_existing_thread/sync.py @@ -0,0 +1,25 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import assistant_text_from_turn, ensure_local_sdk_src, find_turn_by_id, runtime_config + +ensure_local_sdk_src() + +from codex_app_server import Codex, TextInput + +with Codex(config=runtime_config()) as codex: + # Create an initial thread and turn so we have a real thread to resume. + original = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + first = original.turn(TextInput("Tell me one fact about Saturn.")).run() + print("Created thread:", original.id) + + # Resume the existing thread by ID. + resumed = codex.thread_resume(original.id) + second = resumed.turn(TextInput("Continue with one more fact.")).run() + persisted = resumed.read(include_turns=True) + persisted_turn = find_turn_by_id(persisted.thread.turns, second.id) + print(assistant_text_from_turn(persisted_turn)) diff --git a/sdk/python/examples/06_thread_lifecycle_and_controls/async.py b/sdk/python/examples/06_thread_lifecycle_and_controls/async.py new file mode 100644 index 000000000..1600b7b8e --- /dev/null +++ b/sdk/python/examples/06_thread_lifecycle_and_controls/async.py @@ -0,0 +1,70 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ensure_local_sdk_src, runtime_config + +ensure_local_sdk_src() + +import asyncio + +from codex_app_server import AsyncCodex, TextInput + + +async def main() -> None: + async with AsyncCodex(config=runtime_config()) as codex: + thread = await codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + first = await (await thread.turn(TextInput("One sentence about structured planning."))).run() + second = await (await thread.turn(TextInput("Now restate it for a junior engineer."))).run() + + reopened = await codex.thread_resume(thread.id) + listing_active = await codex.thread_list(limit=20, archived=False) + reading = await reopened.read(include_turns=True) + + _ = await reopened.set_name("sdk-lifecycle-demo") + _ = await codex.thread_archive(reopened.id) + listing_archived = await codex.thread_list(limit=20, archived=True) + unarchived = await codex.thread_unarchive(reopened.id) + + resumed_info = "n/a" + try: + resumed = await codex.thread_resume( + unarchived.id, + model="gpt-5.4", + config={"model_reasoning_effort": "high"}, + ) + resumed_result = await (await resumed.turn(TextInput("Continue in one short sentence."))).run() + resumed_info = f"{resumed_result.id} {resumed_result.status}" + except Exception as exc: + resumed_info = f"skipped({type(exc).__name__})" + + forked_info = "n/a" + try: + forked = await codex.thread_fork(unarchived.id, model="gpt-5.4") + forked_result = await (await forked.turn(TextInput("Take a different angle in one short sentence."))).run() + forked_info = f"{forked_result.id} {forked_result.status}" + except Exception as exc: + forked_info = f"skipped({type(exc).__name__})" + + compact_info = "sent" + try: + _ = await unarchived.compact() + except Exception as exc: + compact_info = f"skipped({type(exc).__name__})" + + print("Lifecycle OK:", thread.id) + print("first:", first.id, first.status) + print("second:", second.id, second.status) + print("read.turns:", len(reading.thread.turns or [])) + print("list.active:", len(listing_active.data)) + print("list.archived:", len(listing_archived.data)) + print("resumed:", resumed_info) + print("forked:", forked_info) + print("compact:", compact_info) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py b/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py new file mode 100644 index 000000000..f485ce3ca --- /dev/null +++ b/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py @@ -0,0 +1,63 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ensure_local_sdk_src, runtime_config + +ensure_local_sdk_src() + +from codex_app_server import Codex, TextInput + + +with Codex(config=runtime_config()) as codex: + thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + first = thread.turn(TextInput("One sentence about structured planning.")).run() + second = thread.turn(TextInput("Now restate it for a junior engineer.")).run() + + reopened = codex.thread_resume(thread.id) + listing_active = codex.thread_list(limit=20, archived=False) + reading = reopened.read(include_turns=True) + + _ = reopened.set_name("sdk-lifecycle-demo") + _ = codex.thread_archive(reopened.id) + listing_archived = codex.thread_list(limit=20, archived=True) + unarchived = codex.thread_unarchive(reopened.id) + + resumed_info = "n/a" + try: + resumed = codex.thread_resume( + unarchived.id, + model="gpt-5.4", + config={"model_reasoning_effort": "high"}, + ) + resumed_result = resumed.turn(TextInput("Continue in one short sentence.")).run() + resumed_info = f"{resumed_result.id} {resumed_result.status}" + except Exception as exc: + resumed_info = f"skipped({type(exc).__name__})" + + forked_info = "n/a" + try: + forked = codex.thread_fork(unarchived.id, model="gpt-5.4") + forked_result = forked.turn(TextInput("Take a different angle in one short sentence.")).run() + forked_info = f"{forked_result.id} {forked_result.status}" + except Exception as exc: + forked_info = f"skipped({type(exc).__name__})" + + compact_info = "sent" + try: + _ = unarchived.compact() + except Exception as exc: + compact_info = f"skipped({type(exc).__name__})" + + print("Lifecycle OK:", thread.id) + print("first:", first.id, first.status) + print("second:", second.id, second.status) + print("read.turns:", len(reading.thread.turns or [])) + print("list.active:", len(listing_active.data)) + print("list.archived:", len(listing_archived.data)) + print("resumed:", resumed_info) + print("forked:", forked_info) + print("compact:", compact_info) diff --git a/sdk/python/examples/07_image_and_text/async.py b/sdk/python/examples/07_image_and_text/async.py new file mode 100644 index 000000000..6087222d2 --- /dev/null +++ b/sdk/python/examples/07_image_and_text/async.py @@ -0,0 +1,42 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ( + assistant_text_from_turn, + ensure_local_sdk_src, + find_turn_by_id, + runtime_config, +) + +ensure_local_sdk_src() + +import asyncio + +from codex_app_server import AsyncCodex, ImageInput, TextInput + +REMOTE_IMAGE_URL = "https://raw.githubusercontent.com/github/explore/main/topics/python/python.png" + + +async def main() -> None: + async with AsyncCodex(config=runtime_config()) as codex: + thread = await codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + turn = await thread.turn( + [ + TextInput("What is in this image? Give 3 bullets."), + ImageInput(REMOTE_IMAGE_URL), + ] + ) + result = await turn.run() + persisted = await thread.read(include_turns=True) + persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) + + print("Status:", result.status) + print(assistant_text_from_turn(persisted_turn)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/python/examples/07_image_and_text/sync.py b/sdk/python/examples/07_image_and_text/sync.py new file mode 100644 index 000000000..a857fab83 --- /dev/null +++ b/sdk/python/examples/07_image_and_text/sync.py @@ -0,0 +1,33 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ( + assistant_text_from_turn, + ensure_local_sdk_src, + find_turn_by_id, + runtime_config, +) + +ensure_local_sdk_src() + +from codex_app_server import Codex, ImageInput, TextInput + +REMOTE_IMAGE_URL = "https://raw.githubusercontent.com/github/explore/main/topics/python/python.png" + +with Codex(config=runtime_config()) as codex: + thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + result = thread.turn( + [ + TextInput("What is in this image? Give 3 bullets."), + ImageInput(REMOTE_IMAGE_URL), + ] + ).run() + persisted = thread.read(include_turns=True) + persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) + + print("Status:", result.status) + print(assistant_text_from_turn(persisted_turn)) diff --git a/sdk/python/examples/08_local_image_and_text/async.py b/sdk/python/examples/08_local_image_and_text/async.py new file mode 100644 index 000000000..07f06b312 --- /dev/null +++ b/sdk/python/examples/08_local_image_and_text/async.py @@ -0,0 +1,43 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ( + assistant_text_from_turn, + ensure_local_sdk_src, + find_turn_by_id, + runtime_config, + temporary_sample_image_path, +) + +ensure_local_sdk_src() + +import asyncio + +from codex_app_server import AsyncCodex, LocalImageInput, TextInput + + +async def main() -> None: + with temporary_sample_image_path() as image_path: + async with AsyncCodex(config=runtime_config()) as codex: + thread = await codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + + turn = await thread.turn( + [ + TextInput("Read this generated local image and summarize the colors/layout in 2 bullets."), + LocalImageInput(str(image_path.resolve())), + ] + ) + result = await turn.run() + persisted = await thread.read(include_turns=True) + persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) + + print("Status:", result.status) + print(assistant_text_from_turn(persisted_turn)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/python/examples/08_local_image_and_text/sync.py b/sdk/python/examples/08_local_image_and_text/sync.py new file mode 100644 index 000000000..883e05a6b --- /dev/null +++ b/sdk/python/examples/08_local_image_and_text/sync.py @@ -0,0 +1,34 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ( + assistant_text_from_turn, + ensure_local_sdk_src, + find_turn_by_id, + runtime_config, + temporary_sample_image_path, +) + +ensure_local_sdk_src() + +from codex_app_server import Codex, LocalImageInput, TextInput + +with temporary_sample_image_path() as image_path: + with Codex(config=runtime_config()) as codex: + thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + + result = thread.turn( + [ + TextInput("Read this generated local image and summarize the colors/layout in 2 bullets."), + LocalImageInput(str(image_path.resolve())), + ] + ).run() + persisted = thread.read(include_turns=True) + persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) + + print("Status:", result.status) + print(assistant_text_from_turn(persisted_turn)) diff --git a/sdk/python/examples/09_async_parity/sync.py b/sdk/python/examples/09_async_parity/sync.py new file mode 100644 index 000000000..257707296 --- /dev/null +++ b/sdk/python/examples/09_async_parity/sync.py @@ -0,0 +1,31 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ( + assistant_text_from_turn, + ensure_local_sdk_src, + find_turn_by_id, + runtime_config, + server_label, +) + +ensure_local_sdk_src() + +from codex_app_server import Codex, TextInput + +with Codex(config=runtime_config()) as codex: + print("Server:", server_label(codex.metadata)) + + thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + turn = thread.turn(TextInput("Say hello in one sentence.")) + result = turn.run() + persisted = thread.read(include_turns=True) + persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) + + print("Thread:", thread.id) + print("Turn:", result.id) + print("Text:", assistant_text_from_turn(persisted_turn).strip()) diff --git a/sdk/python/examples/10_error_handling_and_retry/async.py b/sdk/python/examples/10_error_handling_and_retry/async.py new file mode 100644 index 000000000..c23ee0084 --- /dev/null +++ b/sdk/python/examples/10_error_handling_and_retry/async.py @@ -0,0 +1,98 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ( + assistant_text_from_turn, + ensure_local_sdk_src, + find_turn_by_id, + runtime_config, +) + +ensure_local_sdk_src() + +import asyncio +import random +from collections.abc import Awaitable, Callable +from typing import TypeVar + +from codex_app_server import ( + AsyncCodex, + JsonRpcError, + ServerBusyError, + TextInput, + TurnStatus, + is_retryable_error, +) + +ResultT = TypeVar("ResultT") + + +async def retry_on_overload_async( + op: Callable[[], Awaitable[ResultT]], + *, + max_attempts: int = 3, + initial_delay_s: float = 0.25, + max_delay_s: float = 2.0, + jitter_ratio: float = 0.2, +) -> ResultT: + if max_attempts < 1: + raise ValueError("max_attempts must be >= 1") + + delay = initial_delay_s + attempt = 0 + while True: + attempt += 1 + try: + return await op() + except Exception as exc: # noqa: BLE001 + if attempt >= max_attempts or 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: + await asyncio.sleep(sleep_for) + delay = min(max_delay_s, delay * 2) + + +async def main() -> None: + async with AsyncCodex(config=runtime_config()) as codex: + thread = await codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + + try: + result = await retry_on_overload_async( + _run_turn(thread, "Summarize retry best practices in 3 bullets."), + max_attempts=3, + initial_delay_s=0.25, + max_delay_s=2.0, + ) + except ServerBusyError as exc: + print("Server overloaded after retries:", exc.message) + print("Text:") + return + except JsonRpcError as exc: + print(f"JSON-RPC error {exc.code}: {exc.message}") + print("Text:") + return + + persisted = await thread.read(include_turns=True) + persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) + if result.status == TurnStatus.failed: + print("Turn failed:", result.error) + + print("Text:", assistant_text_from_turn(persisted_turn)) + + +def _run_turn(thread, prompt: str): + async def _inner(): + turn = await thread.turn(TextInput(prompt)) + return await turn.run() + + return _inner + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/python/examples/10_error_handling_and_retry/sync.py b/sdk/python/examples/10_error_handling_and_retry/sync.py new file mode 100644 index 000000000..585f24a9d --- /dev/null +++ b/sdk/python/examples/10_error_handling_and_retry/sync.py @@ -0,0 +1,47 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ( + assistant_text_from_turn, + ensure_local_sdk_src, + find_turn_by_id, + runtime_config, +) + +ensure_local_sdk_src() + +from codex_app_server import ( + Codex, + JsonRpcError, + ServerBusyError, + TextInput, + TurnStatus, + retry_on_overload, +) + +with Codex(config=runtime_config()) as codex: + thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + + try: + result = retry_on_overload( + lambda: thread.turn(TextInput("Summarize retry best practices in 3 bullets.")).run(), + max_attempts=3, + initial_delay_s=0.25, + max_delay_s=2.0, + ) + except ServerBusyError as exc: + print("Server overloaded after retries:", exc.message) + print("Text:") + except JsonRpcError as exc: + print(f"JSON-RPC error {exc.code}: {exc.message}") + print("Text:") + else: + persisted = thread.read(include_turns=True) + persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) + if result.status == TurnStatus.failed: + print("Turn failed:", result.error) + print("Text:", assistant_text_from_turn(persisted_turn)) diff --git a/sdk/python/examples/11_cli_mini_app/async.py b/sdk/python/examples/11_cli_mini_app/async.py new file mode 100644 index 000000000..4216cf782 --- /dev/null +++ b/sdk/python/examples/11_cli_mini_app/async.py @@ -0,0 +1,96 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ensure_local_sdk_src, runtime_config + +ensure_local_sdk_src() + +import asyncio + +from codex_app_server import ( + AsyncCodex, + TextInput, + ThreadTokenUsageUpdatedNotification, + TurnCompletedNotification, +) + + +def _status_value(status: object | None) -> str: + return str(getattr(status, "value", status)) + + +def _format_usage(usage: object | None) -> str: + if usage is None: + return "usage> (none)" + + last = getattr(usage, "last", None) + total = getattr(usage, "total", None) + if last is None or total is None: + return f"usage> {usage}" + + return ( + "usage>\n" + f" last: input={last.input_tokens} output={last.output_tokens} reasoning={last.reasoning_output_tokens} total={last.total_tokens} cached={last.cached_input_tokens}\n" + f" total: input={total.input_tokens} output={total.output_tokens} reasoning={total.reasoning_output_tokens} total={total.total_tokens} cached={total.cached_input_tokens}" + ) + + +async def main() -> None: + print("Codex async mini CLI. Type /exit to quit.") + + async with AsyncCodex(config=runtime_config()) as codex: + thread = await codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + print("Thread:", thread.id) + + while True: + try: + user_input = (await asyncio.to_thread(input, "you> ")).strip() + except EOFError: + break + + if not user_input: + continue + if user_input in {"/exit", "/quit"}: + break + + turn = await thread.turn(TextInput(user_input)) + usage = None + status = None + error = None + printed_delta = False + + print("assistant> ", end="", flush=True) + async for event in turn.stream(): + payload = event.payload + if event.method == "item/agentMessage/delta": + delta = getattr(payload, "delta", "") + if delta: + print(delta, end="", flush=True) + printed_delta = True + continue + if isinstance(payload, ThreadTokenUsageUpdatedNotification): + usage = payload.token_usage + continue + if isinstance(payload, TurnCompletedNotification): + status = payload.turn.status + error = payload.turn.error + + if printed_delta: + print() + else: + print("[no text]") + + status_text = _status_value(status) + print(f"assistant.status> {status_text}") + if status_text == "failed": + print("assistant.error>", error) + + print(_format_usage(usage)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/python/examples/11_cli_mini_app/sync.py b/sdk/python/examples/11_cli_mini_app/sync.py new file mode 100644 index 000000000..e961cfbcc --- /dev/null +++ b/sdk/python/examples/11_cli_mini_app/sync.py @@ -0,0 +1,89 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ensure_local_sdk_src, runtime_config + +ensure_local_sdk_src() + +from codex_app_server import ( + Codex, + TextInput, + ThreadTokenUsageUpdatedNotification, + TurnCompletedNotification, +) + +print("Codex mini CLI. Type /exit to quit.") + + +def _status_value(status: object | None) -> str: + return str(getattr(status, "value", status)) + + +def _format_usage(usage: object | None) -> str: + if usage is None: + return "usage> (none)" + + last = getattr(usage, "last", None) + total = getattr(usage, "total", None) + if last is None or total is None: + return f"usage> {usage}" + + return ( + "usage>\n" + f" last: input={last.input_tokens} output={last.output_tokens} reasoning={last.reasoning_output_tokens} total={last.total_tokens} cached={last.cached_input_tokens}\n" + f" total: input={total.input_tokens} output={total.output_tokens} reasoning={total.reasoning_output_tokens} total={total.total_tokens} cached={total.cached_input_tokens}" + ) + + +with Codex(config=runtime_config()) as codex: + thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + print("Thread:", thread.id) + + while True: + try: + user_input = input("you> ").strip() + except EOFError: + break + + if not user_input: + continue + if user_input in {"/exit", "/quit"}: + break + + turn = thread.turn(TextInput(user_input)) + usage = None + status = None + error = None + printed_delta = False + + print("assistant> ", end="", flush=True) + for event in turn.stream(): + payload = event.payload + if event.method == "item/agentMessage/delta": + delta = getattr(payload, "delta", "") + if delta: + print(delta, end="", flush=True) + printed_delta = True + continue + if isinstance(payload, ThreadTokenUsageUpdatedNotification): + usage = payload.token_usage + continue + if isinstance(payload, TurnCompletedNotification): + status = payload.turn.status + error = payload.turn.error + + if printed_delta: + print() + else: + print("[no text]") + + status_text = _status_value(status) + print(f"assistant.status> {status_text}") + if status_text == "failed": + print("assistant.error>", error) + + print(_format_usage(usage)) diff --git a/sdk/python/examples/12_turn_params_kitchen_sink/async.py b/sdk/python/examples/12_turn_params_kitchen_sink/async.py new file mode 100644 index 000000000..88a24535c --- /dev/null +++ b/sdk/python/examples/12_turn_params_kitchen_sink/async.py @@ -0,0 +1,88 @@ +import json +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ( + assistant_text_from_turn, + ensure_local_sdk_src, + find_turn_by_id, + runtime_config, +) + +ensure_local_sdk_src() + +import asyncio + +from codex_app_server import ( + AskForApproval, + AsyncCodex, + Personality, + ReasoningSummary, + TextInput, +) + +OUTPUT_SCHEMA = { + "type": "object", + "properties": { + "summary": {"type": "string"}, + "actions": { + "type": "array", + "items": {"type": "string"}, + }, + }, + "required": ["summary", "actions"], + "additionalProperties": False, +} + +SUMMARY = ReasoningSummary.model_validate("concise") + +PROMPT = ( + "Analyze a safe rollout plan for enabling a feature flag in production. " + "Return JSON matching the requested schema." +) +APPROVAL_POLICY = AskForApproval.model_validate("never") + + +async def main() -> None: + async with AsyncCodex(config=runtime_config()) as codex: + thread = await codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + + turn = await thread.turn( + TextInput(PROMPT), + approval_policy=APPROVAL_POLICY, + output_schema=OUTPUT_SCHEMA, + personality=Personality.pragmatic, + summary=SUMMARY, + ) + result = await turn.run() + persisted = await thread.read(include_turns=True) + persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) + structured_text = assistant_text_from_turn(persisted_turn).strip() + try: + structured = json.loads(structured_text) + except json.JSONDecodeError as exc: + raise RuntimeError(f"Expected JSON matching OUTPUT_SCHEMA, got: {structured_text!r}") from exc + + summary = structured.get("summary") + actions = structured.get("actions") + if not isinstance(summary, str) or not isinstance(actions, list) or not all( + isinstance(action, str) for action in actions + ): + raise RuntimeError( + f"Expected structured output with string summary/actions, got: {structured!r}" + ) + + print("Status:", result.status) + print("summary:", summary) + print("actions:") + for action in actions: + print("-", action) + print("Items:", 0 if persisted_turn is None else len(persisted_turn.items or [])) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/python/examples/12_turn_params_kitchen_sink/sync.py b/sdk/python/examples/12_turn_params_kitchen_sink/sync.py new file mode 100644 index 000000000..e4095c8ec --- /dev/null +++ b/sdk/python/examples/12_turn_params_kitchen_sink/sync.py @@ -0,0 +1,78 @@ +import json +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ( + assistant_text_from_turn, + ensure_local_sdk_src, + find_turn_by_id, + runtime_config, +) + +ensure_local_sdk_src() + +from codex_app_server import ( + AskForApproval, + Codex, + Personality, + ReasoningSummary, + TextInput, +) + +OUTPUT_SCHEMA = { + "type": "object", + "properties": { + "summary": {"type": "string"}, + "actions": { + "type": "array", + "items": {"type": "string"}, + }, + }, + "required": ["summary", "actions"], + "additionalProperties": False, +} + +SUMMARY = ReasoningSummary.model_validate("concise") + +PROMPT = ( + "Analyze a safe rollout plan for enabling a feature flag in production. " + "Return JSON matching the requested schema." +) +APPROVAL_POLICY = AskForApproval.model_validate("never") + +with Codex(config=runtime_config()) as codex: + thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + + turn = thread.turn( + TextInput(PROMPT), + approval_policy=APPROVAL_POLICY, + output_schema=OUTPUT_SCHEMA, + personality=Personality.pragmatic, + summary=SUMMARY, + ) + result = turn.run() + persisted = thread.read(include_turns=True) + persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) + structured_text = assistant_text_from_turn(persisted_turn).strip() + try: + structured = json.loads(structured_text) + except json.JSONDecodeError as exc: + raise RuntimeError(f"Expected JSON matching OUTPUT_SCHEMA, got: {structured_text!r}") from exc + + summary = structured.get("summary") + actions = structured.get("actions") + if not isinstance(summary, str) or not isinstance(actions, list) or not all( + isinstance(action, str) for action in actions + ): + raise RuntimeError(f"Expected structured output with string summary/actions, got: {structured!r}") + + print("Status:", result.status) + print("summary:", summary) + print("actions:") + for action in actions: + print("-", action) + print("Items:", 0 if persisted_turn is None else len(persisted_turn.items or [])) diff --git a/sdk/python/examples/13_model_select_and_turn_params/async.py b/sdk/python/examples/13_model_select_and_turn_params/async.py new file mode 100644 index 000000000..cbbcff462 --- /dev/null +++ b/sdk/python/examples/13_model_select_and_turn_params/async.py @@ -0,0 +1,125 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import assistant_text_from_turn, ensure_local_sdk_src, find_turn_by_id, runtime_config + +ensure_local_sdk_src() + +import asyncio + +from codex_app_server import ( + AskForApproval, + AsyncCodex, + Personality, + ReasoningEffort, + ReasoningSummary, + SandboxPolicy, + TextInput, +) + +REASONING_RANK = { + "none": 0, + "minimal": 1, + "low": 2, + "medium": 3, + "high": 4, + "xhigh": 5, +} +PREFERRED_MODEL = "gpt-5.4" + + +def _pick_highest_model(models): + visible = [m for m in models if not m.hidden] or models + preferred = next((m for m in visible if m.model == PREFERRED_MODEL or m.id == PREFERRED_MODEL), None) + if preferred is not None: + return preferred + known_names = {m.id for m in visible} | {m.model for m in visible} + top_candidates = [m for m in visible if not (m.upgrade and m.upgrade in known_names)] + pool = top_candidates or visible + return max(pool, key=lambda m: (m.model, m.id)) + + +def _pick_highest_turn_effort(model) -> ReasoningEffort: + if not model.supported_reasoning_efforts: + return ReasoningEffort.medium + + best = max( + model.supported_reasoning_efforts, + key=lambda option: REASONING_RANK.get(option.reasoning_effort.value, -1), + ) + return ReasoningEffort(best.reasoning_effort.value) + + +OUTPUT_SCHEMA = { + "type": "object", + "properties": { + "summary": {"type": "string"}, + "actions": { + "type": "array", + "items": {"type": "string"}, + }, + }, + "required": ["summary", "actions"], + "additionalProperties": False, +} + +SANDBOX_POLICY = SandboxPolicy.model_validate( + { + "type": "readOnly", + "access": {"type": "fullAccess"}, + } +) +APPROVAL_POLICY = AskForApproval.model_validate("never") + + +async def main() -> None: + async with AsyncCodex(config=runtime_config()) as codex: + models = await codex.models(include_hidden=True) + selected_model = _pick_highest_model(models.data) + selected_effort = _pick_highest_turn_effort(selected_model) + + print("selected.model:", selected_model.model) + print("selected.effort:", selected_effort.value) + + thread = await codex.thread_start( + model=selected_model.model, + config={"model_reasoning_effort": selected_effort.value}, + ) + + first_turn = await thread.turn( + TextInput("Give one short sentence about reliable production releases."), + model=selected_model.model, + effort=selected_effort, + ) + first = await first_turn.run() + persisted = await thread.read(include_turns=True) + first_persisted_turn = find_turn_by_id(persisted.thread.turns, first.id) + + print("agent.message:", assistant_text_from_turn(first_persisted_turn)) + print("items:", 0 if first_persisted_turn is None else len(first_persisted_turn.items or [])) + + second_turn = await thread.turn( + TextInput("Return JSON for a safe feature-flag rollout plan."), + approval_policy=APPROVAL_POLICY, + cwd=str(Path.cwd()), + effort=selected_effort, + model=selected_model.model, + output_schema=OUTPUT_SCHEMA, + personality=Personality.pragmatic, + sandbox_policy=SANDBOX_POLICY, + summary=ReasoningSummary.model_validate("concise"), + ) + second = await second_turn.run() + persisted = await thread.read(include_turns=True) + second_persisted_turn = find_turn_by_id(persisted.thread.turns, second.id) + + print("agent.message.params:", assistant_text_from_turn(second_persisted_turn)) + print("items.params:", 0 if second_persisted_turn is None else len(second_persisted_turn.items or [])) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/python/examples/13_model_select_and_turn_params/sync.py b/sdk/python/examples/13_model_select_and_turn_params/sync.py new file mode 100644 index 000000000..e02d99cf7 --- /dev/null +++ b/sdk/python/examples/13_model_select_and_turn_params/sync.py @@ -0,0 +1,116 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import assistant_text_from_turn, ensure_local_sdk_src, find_turn_by_id, runtime_config + +ensure_local_sdk_src() + +from codex_app_server import ( + AskForApproval, + Codex, + Personality, + ReasoningEffort, + ReasoningSummary, + SandboxPolicy, + TextInput, +) + +REASONING_RANK = { + "none": 0, + "minimal": 1, + "low": 2, + "medium": 3, + "high": 4, + "xhigh": 5, +} +PREFERRED_MODEL = "gpt-5.4" + + +def _pick_highest_model(models): + visible = [m for m in models if not m.hidden] or models + preferred = next((m for m in visible if m.model == PREFERRED_MODEL or m.id == PREFERRED_MODEL), None) + if preferred is not None: + return preferred + known_names = {m.id for m in visible} | {m.model for m in visible} + top_candidates = [m for m in visible if not (m.upgrade and m.upgrade in known_names)] + pool = top_candidates or visible + return max(pool, key=lambda m: (m.model, m.id)) + + +def _pick_highest_turn_effort(model) -> ReasoningEffort: + if not model.supported_reasoning_efforts: + return ReasoningEffort.medium + + best = max( + model.supported_reasoning_efforts, + key=lambda option: REASONING_RANK.get(option.reasoning_effort.value, -1), + ) + return ReasoningEffort(best.reasoning_effort.value) + + +OUTPUT_SCHEMA = { + "type": "object", + "properties": { + "summary": {"type": "string"}, + "actions": { + "type": "array", + "items": {"type": "string"}, + }, + }, + "required": ["summary", "actions"], + "additionalProperties": False, +} + +SANDBOX_POLICY = SandboxPolicy.model_validate( + { + "type": "readOnly", + "access": {"type": "fullAccess"}, + } +) +APPROVAL_POLICY = AskForApproval.model_validate("never") + + +with Codex(config=runtime_config()) as codex: + models = codex.models(include_hidden=True) + selected_model = _pick_highest_model(models.data) + selected_effort = _pick_highest_turn_effort(selected_model) + + print("selected.model:", selected_model.model) + print("selected.effort:", selected_effort.value) + + thread = codex.thread_start( + model=selected_model.model, + config={"model_reasoning_effort": selected_effort.value}, + ) + + first = thread.turn( + TextInput("Give one short sentence about reliable production releases."), + model=selected_model.model, + effort=selected_effort, + ).run() + persisted = thread.read(include_turns=True) + first_turn = find_turn_by_id(persisted.thread.turns, first.id) + + print("agent.message:", assistant_text_from_turn(first_turn)) + print("items:", 0 if first_turn is None else len(first_turn.items or [])) + + second = thread.turn( + TextInput("Return JSON for a safe feature-flag rollout plan."), + approval_policy=APPROVAL_POLICY, + cwd=str(Path.cwd()), + effort=selected_effort, + model=selected_model.model, + output_schema=OUTPUT_SCHEMA, + personality=Personality.pragmatic, + sandbox_policy=SANDBOX_POLICY, + summary=ReasoningSummary.model_validate("concise"), + ).run() + persisted = thread.read(include_turns=True) + second_turn = find_turn_by_id(persisted.thread.turns, second.id) + + print("agent.message.params:", assistant_text_from_turn(second_turn)) + print("items.params:", 0 if second_turn is None else len(second_turn.items or [])) diff --git a/sdk/python/examples/14_turn_controls/async.py b/sdk/python/examples/14_turn_controls/async.py new file mode 100644 index 000000000..e180482e3 --- /dev/null +++ b/sdk/python/examples/14_turn_controls/async.py @@ -0,0 +1,71 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ( + assistant_text_from_turn, + ensure_local_sdk_src, + runtime_config, +) + +ensure_local_sdk_src() + +import asyncio + +from codex_app_server import AsyncCodex, TextInput + + +async def main() -> None: + async with AsyncCodex(config=runtime_config()) as codex: + thread = await codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + steer_turn = await thread.turn(TextInput("Count from 1 to 40 with commas, then one summary sentence.")) + steer_result = "sent" + try: + _ = await steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers.")) + except Exception as exc: + steer_result = f"skipped {type(exc).__name__}" + + steer_event_count = 0 + steer_completed_status = "unknown" + steer_completed_turn = None + async for event in steer_turn.stream(): + steer_event_count += 1 + if event.method == "turn/completed": + steer_completed_turn = event.payload.turn + steer_completed_status = getattr(event.payload.turn.status, "value", str(event.payload.turn.status)) + + steer_preview = assistant_text_from_turn(steer_completed_turn).strip() or "[no assistant text]" + + interrupt_turn = await thread.turn(TextInput("Count from 1 to 200 with commas, then one summary sentence.")) + interrupt_result = "sent" + try: + _ = await interrupt_turn.interrupt() + except Exception as exc: + interrupt_result = f"skipped {type(exc).__name__}" + + interrupt_event_count = 0 + interrupt_completed_status = "unknown" + interrupt_completed_turn = None + async for event in interrupt_turn.stream(): + interrupt_event_count += 1 + if event.method == "turn/completed": + interrupt_completed_turn = event.payload.turn + interrupt_completed_status = getattr(event.payload.turn.status, "value", str(event.payload.turn.status)) + + interrupt_preview = assistant_text_from_turn(interrupt_completed_turn).strip() or "[no assistant text]" + + print("steer.result:", steer_result) + print("steer.final.status:", steer_completed_status) + print("steer.events.count:", steer_event_count) + print("steer.assistant.preview:", steer_preview) + print("interrupt.result:", interrupt_result) + print("interrupt.final.status:", interrupt_completed_status) + print("interrupt.events.count:", interrupt_event_count) + print("interrupt.assistant.preview:", interrupt_preview) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/python/examples/14_turn_controls/sync.py b/sdk/python/examples/14_turn_controls/sync.py new file mode 100644 index 000000000..9e9de4dc1 --- /dev/null +++ b/sdk/python/examples/14_turn_controls/sync.py @@ -0,0 +1,63 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ( + assistant_text_from_turn, + ensure_local_sdk_src, + runtime_config, +) + +ensure_local_sdk_src() + +from codex_app_server import Codex, TextInput + +with Codex(config=runtime_config()) as codex: + thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + steer_turn = thread.turn(TextInput("Count from 1 to 40 with commas, then one summary sentence.")) + steer_result = "sent" + try: + _ = steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers.")) + except Exception as exc: + steer_result = f"skipped {type(exc).__name__}" + + steer_event_count = 0 + steer_completed_status = "unknown" + steer_completed_turn = None + for event in steer_turn.stream(): + steer_event_count += 1 + if event.method == "turn/completed": + steer_completed_turn = event.payload.turn + steer_completed_status = getattr(event.payload.turn.status, "value", str(event.payload.turn.status)) + + steer_preview = assistant_text_from_turn(steer_completed_turn).strip() or "[no assistant text]" + + interrupt_turn = thread.turn(TextInput("Count from 1 to 200 with commas, then one summary sentence.")) + interrupt_result = "sent" + try: + _ = interrupt_turn.interrupt() + except Exception as exc: + interrupt_result = f"skipped {type(exc).__name__}" + + interrupt_event_count = 0 + interrupt_completed_status = "unknown" + interrupt_completed_turn = None + for event in interrupt_turn.stream(): + interrupt_event_count += 1 + if event.method == "turn/completed": + interrupt_completed_turn = event.payload.turn + interrupt_completed_status = getattr(event.payload.turn.status, "value", str(event.payload.turn.status)) + + interrupt_preview = assistant_text_from_turn(interrupt_completed_turn).strip() or "[no assistant text]" + + print("steer.result:", steer_result) + print("steer.final.status:", steer_completed_status) + print("steer.events.count:", steer_event_count) + print("steer.assistant.preview:", steer_preview) + print("interrupt.result:", interrupt_result) + print("interrupt.final.status:", interrupt_completed_status) + print("interrupt.events.count:", interrupt_event_count) + print("interrupt.assistant.preview:", interrupt_preview) diff --git a/sdk/python/examples/README.md b/sdk/python/examples/README.md new file mode 100644 index 000000000..5edf2badb --- /dev/null +++ b/sdk/python/examples/README.md @@ -0,0 +1,85 @@ +# Python SDK Examples + +Each example folder contains runnable versions: + +- `sync.py` (public sync surface: `Codex`) +- `async.py` (public async surface: `AsyncCodex`) + +All examples intentionally use only public SDK exports from `codex_app_server`. + +## Prerequisites + +- Python `>=3.10` +- Install SDK dependencies for the same Python interpreter you will use to run examples + +Recommended setup (from `sdk/python`): + +```bash +python -m venv .venv +source .venv/bin/activate +python -m pip install -U pip +python -m pip install -e . +``` + +When running examples from this repo checkout, the SDK source uses the local +tree and does not bundle a runtime binary. The helper in `examples/_bootstrap.py` +uses the installed `codex-cli-bin` runtime package. + +If the pinned `codex-cli-bin` runtime is not already installed, the bootstrap +will download the matching GitHub release artifact, stage a temporary local +`codex-cli-bin` package, install it into your active interpreter, and clean up +the temporary files afterward. + +Current pinned runtime version: `0.116.0-alpha.1` + +## Run examples + +From `sdk/python`: + +```bash +python examples//sync.py +python examples//async.py +``` + +The examples bootstrap local imports from `sdk/python/src` automatically, so no +SDK wheel install is required. You only need the Python dependencies for your +active interpreter and an installed `codex-cli-bin` runtime package (either +already present or automatically provisioned by the bootstrap). + +## Recommended first run + +```bash +python examples/01_quickstart_constructor/sync.py +python examples/01_quickstart_constructor/async.py +``` + +## Index + +- `01_quickstart_constructor/` + - first run / sanity check +- `02_turn_run/` + - inspect full turn output fields +- `03_turn_stream_events/` + - stream a turn with a small curated event view +- `04_models_and_metadata/` + - discover visible models for the connected runtime +- `05_existing_thread/` + - resume a real existing thread (created in-script) +- `06_thread_lifecycle_and_controls/` + - thread lifecycle + control calls +- `07_image_and_text/` + - remote image URL + text multimodal turn +- `08_local_image_and_text/` + - local image + text multimodal turn using a generated temporary sample image +- `09_async_parity/` + - parity-style sync flow (see async parity in other examples) +- `10_error_handling_and_retry/` + - overload retry pattern + typed error handling structure +- `11_cli_mini_app/` + - interactive chat loop +- `12_turn_params_kitchen_sink/` + - structured output with a curated advanced `turn(...)` configuration +- `13_model_select_and_turn_params/` + - list models, pick highest model + highest supported reasoning effort, run turns, print message and usage +- `14_turn_controls/` + - separate best-effort `steer()` and `interrupt()` demos with concise summaries diff --git a/sdk/python/examples/_bootstrap.py b/sdk/python/examples/_bootstrap.py new file mode 100644 index 000000000..00cd62a0b --- /dev/null +++ b/sdk/python/examples/_bootstrap.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import contextlib +import importlib.util +import os +import sys +import tempfile +import zlib +from pathlib import Path +from typing import Iterable, Iterator + +_SDK_PYTHON_DIR = Path(__file__).resolve().parents[1] +_SDK_PYTHON_STR = str(_SDK_PYTHON_DIR) +if _SDK_PYTHON_STR not in sys.path: + sys.path.insert(0, _SDK_PYTHON_STR) + +from _runtime_setup import ensure_runtime_package_installed + + +def _ensure_runtime_dependencies(sdk_python_dir: Path) -> None: + if importlib.util.find_spec("pydantic") is not None: + return + + python = sys.executable + raise RuntimeError( + "Missing required dependency: pydantic.\n" + f"Interpreter: {python}\n" + "Install dependencies with the same interpreter used to run this example:\n" + f" {python} -m pip install -e {sdk_python_dir}\n" + "If you installed with `pip` from another Python, reinstall using the command above." + ) + + +def ensure_local_sdk_src() -> Path: + """Add sdk/python/src to sys.path so examples run without installing the package.""" + sdk_python_dir = _SDK_PYTHON_DIR + src_dir = sdk_python_dir / "src" + package_dir = src_dir / "codex_app_server" + if not package_dir.exists(): + raise RuntimeError(f"Could not locate local SDK package at {package_dir}") + + _ensure_runtime_dependencies(sdk_python_dir) + + src_str = str(src_dir) + if src_str not in sys.path: + sys.path.insert(0, src_str) + return src_dir + + +def runtime_config(): + """Return an example-friendly AppServerConfig for repo-source SDK usage.""" + from codex_app_server import AppServerConfig + + ensure_runtime_package_installed(sys.executable, _SDK_PYTHON_DIR) + return AppServerConfig() + + +def _png_chunk(chunk_type: bytes, data: bytes) -> bytes: + import struct + + payload = chunk_type + data + checksum = zlib.crc32(payload) & 0xFFFFFFFF + return struct.pack(">I", len(data)) + payload + struct.pack(">I", checksum) + + +def _generated_sample_png_bytes() -> bytes: + import struct + + width = 96 + height = 96 + top_left = (120, 180, 255) + top_right = (255, 220, 90) + bottom_left = (90, 180, 95) + bottom_right = (180, 85, 85) + + rows = bytearray() + for y in range(height): + rows.append(0) + for x in range(width): + if y < height // 2 and x < width // 2: + color = top_left + elif y < height // 2: + color = top_right + elif x < width // 2: + color = bottom_left + else: + color = bottom_right + rows.extend(color) + + header = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) + return ( + b"\x89PNG\r\n\x1a\n" + + _png_chunk(b"IHDR", header) + + _png_chunk(b"IDAT", zlib.compress(bytes(rows))) + + _png_chunk(b"IEND", b"") + ) + + +@contextlib.contextmanager +def temporary_sample_image_path() -> Iterator[Path]: + with tempfile.TemporaryDirectory(prefix="codex-python-example-image-") as temp_root: + image_path = Path(temp_root) / "generated_sample.png" + image_path.write_bytes(_generated_sample_png_bytes()) + yield image_path + + +def server_label(metadata: object) -> str: + server = getattr(metadata, "serverInfo", None) + server_name = ((getattr(server, "name", None) or "") if server is not None else "").strip() + server_version = ((getattr(server, "version", None) or "") if server is not None else "").strip() + if server_name and server_version: + return f"{server_name} {server_version}" + + user_agent = ((getattr(metadata, "userAgent", None) or "") if metadata is not None else "").strip() + return user_agent or "unknown" + + +def find_turn_by_id(turns: Iterable[object] | None, turn_id: str) -> object | None: + for turn in turns or []: + if getattr(turn, "id", None) == turn_id: + return turn + return None + + +def assistant_text_from_turn(turn: object | None) -> str: + if turn is None: + return "" + + chunks: list[str] = [] + for item in getattr(turn, "items", []) or []: + raw_item = item.model_dump(mode="json") if hasattr(item, "model_dump") else item + if not isinstance(raw_item, dict): + continue + + item_type = raw_item.get("type") + if item_type == "agentMessage": + text = raw_item.get("text") + if isinstance(text, str) and text: + chunks.append(text) + continue + + if item_type != "message" or raw_item.get("role") != "assistant": + continue + + for content in raw_item.get("content") or []: + if not isinstance(content, dict) or content.get("type") != "output_text": + continue + text = content.get("text") + if isinstance(text, str) and text: + chunks.append(text) + + return "".join(chunks) diff --git a/sdk/python/notebooks/sdk_walkthrough.ipynb b/sdk/python/notebooks/sdk_walkthrough.ipynb new file mode 100644 index 000000000..951cb24e4 --- /dev/null +++ b/sdk/python/notebooks/sdk_walkthrough.ipynb @@ -0,0 +1,587 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Codex Python SDK Walkthrough\n", + "\n", + "Public SDK surface only (`codex_app_server` root exports)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Cell 1: bootstrap local SDK imports + pinned runtime package\n", + "import os\n", + "import sys\n", + "from pathlib import Path\n", + "\n", + "if sys.version_info < (3, 10):\n", + " raise RuntimeError(\n", + " f'Notebook requires Python 3.10+; current interpreter is {sys.version.split()[0]}.'\n", + " )\n", + "\n", + "try:\n", + " _ = os.getcwd()\n", + "except FileNotFoundError:\n", + " os.chdir(str(Path.home()))\n", + "\n", + "\n", + "def _is_sdk_python_dir(path: Path) -> bool:\n", + " return (path / 'pyproject.toml').exists() and (path / 'src' / 'codex_app_server').exists()\n", + "\n", + "\n", + "def _iter_home_fallback_candidates(home: Path):\n", + " # bounded depth scan under home to support launching notebooks from unrelated cwd values\n", + " patterns = ('sdk/python', '*/sdk/python', '*/*/sdk/python', '*/*/*/sdk/python')\n", + " for pattern in patterns:\n", + " yield from home.glob(pattern)\n", + "\n", + "\n", + "def _find_sdk_python_dir(start: Path) -> Path | None:\n", + " checked = set()\n", + "\n", + " def _consider(candidate: Path) -> Path | None:\n", + " resolved = candidate.resolve()\n", + " if resolved in checked:\n", + " return None\n", + " checked.add(resolved)\n", + " if _is_sdk_python_dir(resolved):\n", + " return resolved\n", + " return None\n", + "\n", + " for candidate in [start, *start.parents]:\n", + " found = _consider(candidate)\n", + " if found is not None:\n", + " return found\n", + "\n", + " for candidate in [start / 'sdk' / 'python', *(parent / 'sdk' / 'python' for parent in start.parents)]:\n", + " found = _consider(candidate)\n", + " if found is not None:\n", + " return found\n", + "\n", + " env_dir = os.environ.get('CODEX_PYTHON_SDK_DIR')\n", + " if env_dir:\n", + " found = _consider(Path(env_dir).expanduser())\n", + " if found is not None:\n", + " return found\n", + "\n", + " for entry in sys.path:\n", + " if not entry:\n", + " continue\n", + " entry_path = Path(entry).expanduser()\n", + " for candidate in (entry_path, entry_path / 'sdk' / 'python'):\n", + " found = _consider(candidate)\n", + " if found is not None:\n", + " return found\n", + "\n", + " home = Path.home()\n", + " for candidate in _iter_home_fallback_candidates(home):\n", + " found = _consider(candidate)\n", + " if found is not None:\n", + " return found\n", + "\n", + " return None\n", + "\n", + "\n", + "repo_python_dir = _find_sdk_python_dir(Path.cwd())\n", + "if repo_python_dir is None:\n", + " raise RuntimeError('Could not locate sdk/python. Set CODEX_PYTHON_SDK_DIR to your sdk/python path.')\n", + "\n", + "repo_python_str = str(repo_python_dir)\n", + "if repo_python_str not in sys.path:\n", + " sys.path.insert(0, repo_python_str)\n", + "\n", + "from _runtime_setup import ensure_runtime_package_installed\n", + "\n", + "runtime_version = ensure_runtime_package_installed(\n", + " sys.executable,\n", + " repo_python_dir,\n", + ")\n", + "\n", + "src_dir = repo_python_dir / 'src'\n", + "examples_dir = repo_python_dir / 'examples'\n", + "src_str = str(src_dir)\n", + "examples_str = str(examples_dir)\n", + "if src_str not in sys.path:\n", + " sys.path.insert(0, src_str)\n", + "if examples_str not in sys.path:\n", + " sys.path.insert(0, examples_str)\n", + "\n", + "# Force fresh imports after SDK upgrades in the same notebook kernel.\n", + "for module_name in list(sys.modules):\n", + " if module_name == 'codex_app_server' or module_name.startswith('codex_app_server.'):\n", + " sys.modules.pop(module_name, None)\n", + "\n", + "print('Kernel:', sys.executable)\n", + "print('SDK source:', src_dir)\n", + "print('Runtime package:', runtime_version)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Cell 2: imports (public only)\n", + "from _bootstrap import assistant_text_from_turn, find_turn_by_id, server_label\n", + "from codex_app_server import (\n", + " AsyncCodex,\n", + " Codex,\n", + " ImageInput,\n", + " LocalImageInput,\n", + " TextInput,\n", + " retry_on_overload,\n", + ")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Cell 3: simple sync conversation\n", + "with Codex() as codex:\n", + " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", + " turn = thread.turn(TextInput('Explain gradient descent in 3 bullets.'))\n", + " result = turn.run()\n", + " persisted = thread.read(include_turns=True)\n", + " persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n", + "\n", + " print('server:', server_label(codex.metadata))\n", + " print('status:', result.status)\n", + " print(assistant_text_from_turn(persisted_turn))\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Cell 4: multi-turn continuity in same thread\n", + "with Codex() as codex:\n", + " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", + "\n", + " first = thread.turn(TextInput('Give a short summary of transformers.')).run()\n", + " second = thread.turn(TextInput('Now explain that to a high-school student.')).run()\n", + " persisted = thread.read(include_turns=True)\n", + " second_turn = find_turn_by_id(persisted.thread.turns, second.id)\n", + "\n", + " print('first status:', first.status)\n", + " print('second status:', second.status)\n", + " print('second text:', assistant_text_from_turn(second_turn))\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Cell 5: full thread lifecycle and branching (sync)\n", + "with Codex() as codex:\n", + " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", + " first = thread.turn(TextInput('One sentence about structured planning.')).run()\n", + " second = thread.turn(TextInput('Now restate it for a junior engineer.')).run()\n", + "\n", + " reopened = codex.thread_resume(thread.id)\n", + " listing_active = codex.thread_list(limit=20, archived=False)\n", + " reading = reopened.read(include_turns=True)\n", + "\n", + " _ = reopened.set_name('sdk-lifecycle-demo')\n", + " _ = codex.thread_archive(reopened.id)\n", + " listing_archived = codex.thread_list(limit=20, archived=True)\n", + " unarchived = codex.thread_unarchive(reopened.id)\n", + "\n", + " resumed_info = 'n/a'\n", + " try:\n", + " resumed = codex.thread_resume(\n", + " unarchived.id,\n", + " model='gpt-5.4',\n", + " config={'model_reasoning_effort': 'high'},\n", + " )\n", + " resumed_result = resumed.turn(TextInput('Continue in one short sentence.')).run()\n", + " resumed_info = f'{resumed_result.id} {resumed_result.status}'\n", + " except Exception as e:\n", + " resumed_info = f'skipped({type(e).__name__})'\n", + "\n", + " forked_info = 'n/a'\n", + " try:\n", + " forked = codex.thread_fork(unarchived.id, model='gpt-5.4')\n", + " forked_result = forked.turn(TextInput('Take a different angle in one short sentence.')).run()\n", + " forked_info = f'{forked_result.id} {forked_result.status}'\n", + " except Exception as e:\n", + " forked_info = f'skipped({type(e).__name__})'\n", + "\n", + " compact_info = 'sent'\n", + " try:\n", + " _ = unarchived.compact()\n", + " except Exception as e:\n", + " compact_info = f'skipped({type(e).__name__})'\n", + "\n", + " print('Lifecycle OK:', thread.id)\n", + " print('first:', first.id, first.status)\n", + " print('second:', second.id, second.status)\n", + " print('read.turns:', len(reading.thread.turns or []))\n", + " print('list.active:', len(listing_active.data))\n", + " print('list.archived:', len(listing_archived.data))\n", + " print('resumed:', resumed_info)\n", + " print('forked:', forked_info)\n", + " print('compact:', compact_info)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Cell 5b: one turn with most optional turn params\n", + "from pathlib import Path\n", + "from codex_app_server import (\n", + " AskForApproval,\n", + " Personality,\n", + " ReasoningEffort,\n", + " ReasoningSummary,\n", + " SandboxPolicy,\n", + ")\n", + "\n", + "output_schema = {\n", + " 'type': 'object',\n", + " 'properties': {\n", + " 'summary': {'type': 'string'},\n", + " 'actions': {'type': 'array', 'items': {'type': 'string'}},\n", + " },\n", + " 'required': ['summary', 'actions'],\n", + " 'additionalProperties': False,\n", + "}\n", + "\n", + "sandbox_policy = SandboxPolicy.model_validate({'type': 'readOnly', 'access': {'type': 'fullAccess'}})\n", + "summary = ReasoningSummary.model_validate('concise')\n", + "\n", + "with Codex() as codex:\n", + " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", + " turn = thread.turn(\n", + " TextInput('Propose a safe production feature-flag rollout. Return JSON matching the schema.'),\n", + " approval_policy=AskForApproval.model_validate('never'),\n", + " cwd=str(Path.cwd()),\n", + " effort=ReasoningEffort.medium,\n", + " model='gpt-5.4',\n", + " output_schema=output_schema,\n", + " personality=Personality.pragmatic,\n", + " sandbox_policy=sandbox_policy,\n", + " summary=summary,\n", + " )\n", + " result = turn.run()\n", + " persisted = thread.read(include_turns=True)\n", + " persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n", + "\n", + " print('status:', result.status)\n", + " print(assistant_text_from_turn(persisted_turn))\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Cell 5c: choose highest model + highest supported reasoning, then run turns\n", + "from pathlib import Path\n", + "from codex_app_server import (\n", + " AskForApproval,\n", + " Personality,\n", + " ReasoningEffort,\n", + " ReasoningSummary,\n", + " SandboxPolicy,\n", + ")\n", + "\n", + "reasoning_rank = {\n", + " 'none': 0,\n", + " 'minimal': 1,\n", + " 'low': 2,\n", + " 'medium': 3,\n", + " 'high': 4,\n", + " 'xhigh': 5,\n", + "}\n", + "\n", + "\n", + "def pick_highest_model(models):\n", + " visible = [m for m in models if not m.hidden] or models\n", + " known_names = {m.id for m in visible} | {m.model for m in visible}\n", + " top_candidates = [m for m in visible if not (m.upgrade and m.upgrade in known_names)]\n", + " pool = top_candidates or visible\n", + " return max(pool, key=lambda m: (m.model, m.id))\n", + "\n", + "\n", + "def pick_highest_turn_effort(model) -> ReasoningEffort:\n", + " if not model.supported_reasoning_efforts:\n", + " return ReasoningEffort.medium\n", + " best = max(model.supported_reasoning_efforts, key=lambda opt: reasoning_rank.get(opt.reasoning_effort.value, -1))\n", + " return ReasoningEffort(best.reasoning_effort.value)\n", + "\n", + "\n", + "output_schema = {\n", + " 'type': 'object',\n", + " 'properties': {\n", + " 'summary': {'type': 'string'},\n", + " 'actions': {'type': 'array', 'items': {'type': 'string'}},\n", + " },\n", + " 'required': ['summary', 'actions'],\n", + " 'additionalProperties': False,\n", + "}\n", + "sandbox_policy = SandboxPolicy.model_validate({'type': 'readOnly', 'access': {'type': 'fullAccess'}})\n", + "\n", + "with Codex() as codex:\n", + " models = codex.models(include_hidden=True)\n", + " selected_model = pick_highest_model(models.data)\n", + " selected_effort = pick_highest_turn_effort(selected_model)\n", + "\n", + " print('selected.model:', selected_model.model)\n", + " print('selected.effort:', selected_effort.value)\n", + "\n", + " thread = codex.thread_start(model=selected_model.model, config={'model_reasoning_effort': selected_effort.value})\n", + "\n", + " first = thread.turn(\n", + " TextInput('Give one short sentence about reliable production releases.'),\n", + " model=selected_model.model,\n", + " effort=selected_effort,\n", + " ).run()\n", + " persisted = thread.read(include_turns=True)\n", + " first_turn = find_turn_by_id(persisted.thread.turns, first.id)\n", + " print('agent.message:', assistant_text_from_turn(first_turn))\n", + " print('items:', 0 if first_turn is None else len(first_turn.items or []))\n", + "\n", + " second = thread.turn(\n", + " TextInput('Return JSON for a safe feature-flag rollout plan.'),\n", + " approval_policy=AskForApproval.model_validate('never'),\n", + " cwd=str(Path.cwd()),\n", + " effort=selected_effort,\n", + " model=selected_model.model,\n", + " output_schema=output_schema,\n", + " personality=Personality.pragmatic,\n", + " sandbox_policy=sandbox_policy,\n", + " summary=ReasoningSummary.model_validate('concise'),\n", + " ).run()\n", + " persisted = thread.read(include_turns=True)\n", + " second_turn = find_turn_by_id(persisted.thread.turns, second.id)\n", + " print('agent.message.params:', assistant_text_from_turn(second_turn))\n", + " print('items.params:', 0 if second_turn is None else len(second_turn.items or []))\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Cell 6: multimodal with remote image\n", + "remote_image_url = 'https://raw.githubusercontent.com/github/explore/main/topics/python/python.png'\n", + "\n", + "with Codex() as codex:\n", + " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", + " result = thread.turn([\n", + " TextInput('What do you see in this image? 3 bullets.'),\n", + " ImageInput(remote_image_url),\n", + " ]).run()\n", + " persisted = thread.read(include_turns=True)\n", + " persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n", + "\n", + " print('status:', result.status)\n", + " print(assistant_text_from_turn(persisted_turn))\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Cell 7: multimodal with local image (generated temporary file)\n", + "with temporary_sample_image_path() as local_image_path:\n", + " with Codex() as codex:\n", + " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", + " result = thread.turn([\n", + " TextInput('Describe the colors and layout in this generated local image in 2 bullets.'),\n", + " LocalImageInput(str(local_image_path.resolve())),\n", + " ]).run()\n", + " persisted = thread.read(include_turns=True)\n", + " persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n", + "\n", + " print('status:', result.status)\n", + " print(assistant_text_from_turn(persisted_turn))\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Cell 8: retry-on-overload pattern\n", + "with Codex() as codex:\n", + " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", + "\n", + " result = retry_on_overload(\n", + " lambda: thread.turn(TextInput('List 5 failure modes in distributed systems.')).run(),\n", + " max_attempts=3,\n", + " initial_delay_s=0.25,\n", + " max_delay_s=2.0,\n", + " )\n", + " persisted = thread.read(include_turns=True)\n", + " persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n", + "\n", + " print('status:', result.status)\n", + " print(assistant_text_from_turn(persisted_turn))\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Cell 9: full thread lifecycle and branching (async)\n", + "import asyncio\n", + "\n", + "\n", + "async def async_lifecycle_demo():\n", + " async with AsyncCodex() as codex:\n", + " thread = await codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", + " first = await (await thread.turn(TextInput('One sentence about structured planning.'))).run()\n", + " second = await (await thread.turn(TextInput('Now restate it for a junior engineer.'))).run()\n", + "\n", + " reopened = await codex.thread_resume(thread.id)\n", + " listing_active = await codex.thread_list(limit=20, archived=False)\n", + " reading = await reopened.read(include_turns=True)\n", + "\n", + " _ = await reopened.set_name('sdk-lifecycle-demo')\n", + " _ = await codex.thread_archive(reopened.id)\n", + " listing_archived = await codex.thread_list(limit=20, archived=True)\n", + " unarchived = await codex.thread_unarchive(reopened.id)\n", + "\n", + " resumed_info = 'n/a'\n", + " try:\n", + " resumed = await codex.thread_resume(\n", + " unarchived.id,\n", + " model='gpt-5.4',\n", + " config={'model_reasoning_effort': 'high'},\n", + " )\n", + " resumed_result = await (await resumed.turn(TextInput('Continue in one short sentence.'))).run()\n", + " resumed_info = f'{resumed_result.id} {resumed_result.status}'\n", + " except Exception as e:\n", + " resumed_info = f'skipped({type(e).__name__})'\n", + "\n", + " forked_info = 'n/a'\n", + " try:\n", + " forked = await codex.thread_fork(unarchived.id, model='gpt-5.4')\n", + " forked_result = await (await forked.turn(TextInput('Take a different angle in one short sentence.'))).run()\n", + " forked_info = f'{forked_result.id} {forked_result.status}'\n", + " except Exception as e:\n", + " forked_info = f'skipped({type(e).__name__})'\n", + "\n", + " compact_info = 'sent'\n", + " try:\n", + " _ = await unarchived.compact()\n", + " except Exception as e:\n", + " compact_info = f'skipped({type(e).__name__})'\n", + "\n", + " print('Lifecycle OK:', thread.id)\n", + " print('first:', first.id, first.status)\n", + " print('second:', second.id, second.status)\n", + " print('read.turns:', len(reading.thread.turns or []))\n", + " print('list.active:', len(listing_active.data))\n", + " print('list.archived:', len(listing_archived.data))\n", + " print('resumed:', resumed_info)\n", + " print('forked:', forked_info)\n", + " print('compact:', compact_info)\n", + "\n", + "\n", + "await async_lifecycle_demo()\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Cell 10: async turn controls (best effort steer + interrupt)\n", + "import asyncio\n", + "\n", + "\n", + "async def async_stream_demo():\n", + " async with AsyncCodex() as codex:\n", + " thread = await codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", + " steer_turn = await thread.turn(TextInput('Count from 1 to 40 with commas, then one summary sentence.'))\n", + "\n", + " steer_result = 'sent'\n", + " try:\n", + " _ = await steer_turn.steer(TextInput('Keep it brief and stop after 10 numbers.'))\n", + " except Exception as e:\n", + " steer_result = f'skipped {type(e).__name__}'\n", + "\n", + " steer_event_count = 0\n", + " steer_completed_status = 'unknown'\n", + " steer_completed_turn = None\n", + " async for event in steer_turn.stream():\n", + " steer_event_count += 1\n", + " if event.method == 'turn/completed':\n", + " steer_completed_turn = event.payload.turn\n", + " steer_completed_status = getattr(event.payload.turn.status, 'value', str(event.payload.turn.status))\n", + "\n", + " steer_preview = assistant_text_from_turn(steer_completed_turn).strip() or '[no assistant text]'\n", + "\n", + " interrupt_turn = await thread.turn(TextInput('Count from 1 to 200 with commas, then one summary sentence.'))\n", + " interrupt_result = 'sent'\n", + " try:\n", + " _ = await interrupt_turn.interrupt()\n", + " except Exception as e:\n", + " interrupt_result = f'skipped {type(e).__name__}'\n", + "\n", + " interrupt_event_count = 0\n", + " interrupt_completed_status = 'unknown'\n", + " interrupt_completed_turn = None\n", + " async for event in interrupt_turn.stream():\n", + " interrupt_event_count += 1\n", + " if event.method == 'turn/completed':\n", + " interrupt_completed_turn = event.payload.turn\n", + " interrupt_completed_status = getattr(event.payload.turn.status, 'value', str(event.payload.turn.status))\n", + "\n", + " interrupt_preview = assistant_text_from_turn(interrupt_completed_turn).strip() or '[no assistant text]'\n", + "\n", + " print('steer.result:', steer_result)\n", + " print('steer.final.status:', steer_completed_status)\n", + " print('steer.events.count:', steer_event_count)\n", + " print('steer.assistant.preview:', steer_preview)\n", + " print('interrupt.result:', interrupt_result)\n", + " print('interrupt.final.status:', interrupt_completed_status)\n", + " print('interrupt.events.count:', interrupt_event_count)\n", + " print('interrupt.assistant.preview:', interrupt_preview)\n", + "\n", + "\n", + "await async_stream_demo()\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10+" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index da4cbceb1..6685fd099 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -793,7 +793,7 @@ def _render_thread_block( " input: Input,", " *,", *_kw_signature_lines(turn_fields), - " ) -> Turn:", + " ) -> TurnHandle:", " wire_input = _to_wire_input(input)", " params = TurnStartParams(", " thread_id=self.id,", @@ -801,7 +801,7 @@ def _render_thread_block( *_model_arg_lines(turn_fields), " )", " turn = self._client.turn_start(self.id, wire_input, params=params)", - " return Turn(self._client, self.id, turn.turn.id)", + " return TurnHandle(self._client, self.id, turn.turn.id)", ] return "\n".join(lines) @@ -815,7 +815,7 @@ def _render_async_thread_block( " input: Input,", " *,", *_kw_signature_lines(turn_fields), - " ) -> AsyncTurn:", + " ) -> AsyncTurnHandle:", " await self._codex._ensure_initialized()", " wire_input = _to_wire_input(input)", " params = TurnStartParams(", @@ -828,14 +828,14 @@ def _render_async_thread_block( " wire_input,", " params=params,", " )", - " return AsyncTurn(self._codex, self.id, turn.turn.id)", + " return AsyncTurnHandle(self._codex, self.id, turn.turn.id)", ] return "\n".join(lines) def generate_public_api_flat_methods() -> None: src_dir = sdk_root() / "src" - public_api_path = src_dir / "codex_app_server" / "public_api.py" + public_api_path = src_dir / "codex_app_server" / "api.py" if not public_api_path.exists(): # PR2 can run codegen before the ergonomic public API layer is added. return diff --git a/sdk/python/src/codex_app_server/__init__.py b/sdk/python/src/codex_app_server/__init__.py index aff63176b..91f334df8 100644 --- a/sdk/python/src/codex_app_server/__init__.py +++ b/sdk/python/src/codex_app_server/__init__.py @@ -1,10 +1,111 @@ +from .async_client import AsyncAppServerClient from .client import AppServerClient, AppServerConfig -from .errors import AppServerError, JsonRpcError, TransportClosedError +from .errors import ( + AppServerError, + AppServerRpcError, + InternalRpcError, + InvalidParamsError, + InvalidRequestError, + JsonRpcError, + MethodNotFoundError, + ParseError, + RetryLimitExceededError, + ServerBusyError, + TransportClosedError, + is_retryable_error, +) +from .generated.v2_all import ( + AskForApproval, + Personality, + PlanType, + ReasoningEffort, + ReasoningSummary, + SandboxMode, + SandboxPolicy, + ServiceTier, + ThreadItem, + ThreadForkParams, + ThreadListParams, + ThreadResumeParams, + ThreadSortKey, + ThreadSourceKind, + ThreadStartParams, + ThreadTokenUsageUpdatedNotification, + TurnCompletedNotification, + TurnStartParams, + TurnStatus, + TurnSteerParams, +) +from .models import InitializeResponse +from .api import ( + AsyncCodex, + AsyncThread, + AsyncTurnHandle, + Codex, + ImageInput, + Input, + InputItem, + LocalImageInput, + MentionInput, + SkillInput, + TextInput, + Thread, + TurnHandle, +) +from .retry import retry_on_overload + +__version__ = "0.2.0" __all__ = [ + "__version__", "AppServerClient", + "AsyncAppServerClient", "AppServerConfig", + "Codex", + "AsyncCodex", + "Thread", + "AsyncThread", + "TurnHandle", + "AsyncTurnHandle", + "InitializeResponse", + "Input", + "InputItem", + "TextInput", + "ImageInput", + "LocalImageInput", + "SkillInput", + "MentionInput", + "ThreadItem", + "ThreadTokenUsageUpdatedNotification", + "TurnCompletedNotification", + "AskForApproval", + "Personality", + "PlanType", + "ReasoningEffort", + "ReasoningSummary", + "SandboxMode", + "SandboxPolicy", + "ServiceTier", + "ThreadStartParams", + "ThreadResumeParams", + "ThreadListParams", + "ThreadSortKey", + "ThreadSourceKind", + "ThreadForkParams", + "TurnStatus", + "TurnStartParams", + "TurnSteerParams", + "retry_on_overload", "AppServerError", - "JsonRpcError", "TransportClosedError", + "JsonRpcError", + "AppServerRpcError", + "ParseError", + "InvalidRequestError", + "MethodNotFoundError", + "InvalidParamsError", + "InternalRpcError", + "ServerBusyError", + "RetryLimitExceededError", + "is_retryable_error", ] diff --git a/sdk/python/src/codex_app_server/api.py b/sdk/python/src/codex_app_server/api.py new file mode 100644 index 000000000..b465c574d --- /dev/null +++ b/sdk/python/src/codex_app_server/api.py @@ -0,0 +1,701 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import AsyncIterator, Iterator + +from .async_client import AsyncAppServerClient +from .client import AppServerClient, AppServerConfig +from .generated.v2_all import ( + AskForApproval, + ModelListResponse, + Personality, + ReasoningEffort, + ReasoningSummary, + SandboxMode, + SandboxPolicy, + ServiceTier, + ThreadArchiveResponse, + ThreadCompactStartResponse, + ThreadForkParams, + ThreadItem, + ThreadListParams, + ThreadListResponse, + ThreadReadResponse, + ThreadResumeParams, + ThreadSetNameResponse, + ThreadSortKey, + ThreadSourceKind, + ThreadStartParams, + Turn as AppServerTurn, + TurnCompletedNotification, + TurnInterruptResponse, + TurnStartParams, + TurnSteerResponse, +) +from .models import InitializeResponse, JsonObject, Notification, ServerInfo + + +@dataclass(slots=True) +class TextInput: + text: str + + +@dataclass(slots=True) +class ImageInput: + url: str + + +@dataclass(slots=True) +class LocalImageInput: + path: str + + +@dataclass(slots=True) +class SkillInput: + name: str + path: str + + +@dataclass(slots=True) +class MentionInput: + name: str + path: str + + +InputItem = TextInput | ImageInput | LocalImageInput | SkillInput | MentionInput +Input = list[InputItem] | InputItem + + +def _to_wire_item(item: InputItem) -> JsonObject: + if isinstance(item, TextInput): + return {"type": "text", "text": item.text} + if isinstance(item, ImageInput): + return {"type": "image", "url": item.url} + if isinstance(item, LocalImageInput): + return {"type": "localImage", "path": item.path} + if isinstance(item, SkillInput): + return {"type": "skill", "name": item.name, "path": item.path} + if isinstance(item, MentionInput): + return {"type": "mention", "name": item.name, "path": item.path} + raise TypeError(f"unsupported input item: {type(item)!r}") + + +def _to_wire_input(input: Input) -> list[JsonObject]: + if isinstance(input, list): + return [_to_wire_item(i) for i in input] + return [_to_wire_item(input)] + + +def _split_user_agent(user_agent: str) -> tuple[str | None, str | None]: + raw = user_agent.strip() + if not raw: + return None, None + if "/" in raw: + name, version = raw.split("/", 1) + return (name or None), (version or None) + parts = raw.split(maxsplit=1) + if len(parts) == 2: + return parts[0], parts[1] + return raw, None + + +class Codex: + """Minimal typed SDK surface for app-server v2.""" + + def __init__(self, config: AppServerConfig | None = None) -> None: + self._client = AppServerClient(config=config) + try: + self._client.start() + self._init = self._validate_initialize(self._client.initialize()) + except Exception: + self._client.close() + raise + + def __enter__(self) -> "Codex": + return self + + def __exit__(self, _exc_type, _exc, _tb) -> None: + self.close() + + @staticmethod + def _validate_initialize(payload: InitializeResponse) -> InitializeResponse: + user_agent = (payload.userAgent or "").strip() + server = payload.serverInfo + + server_name: str | None = None + server_version: str | None = None + + if server is not None: + server_name = (server.name or "").strip() or None + server_version = (server.version or "").strip() or None + + if (server_name is None or server_version is None) and user_agent: + parsed_name, parsed_version = _split_user_agent(user_agent) + if server_name is None: + server_name = parsed_name + if server_version is None: + server_version = parsed_version + + normalized_server_name = (server_name or "").strip() + normalized_server_version = (server_version or "").strip() + if not user_agent or not normalized_server_name or not normalized_server_version: + raise RuntimeError( + "initialize response missing required metadata " + f"(user_agent={user_agent!r}, server_name={normalized_server_name!r}, server_version={normalized_server_version!r})" + ) + + if server is None: + payload.serverInfo = ServerInfo( + name=normalized_server_name, + version=normalized_server_version, + ) + else: + server.name = normalized_server_name + server.version = normalized_server_version + + return payload + + @property + def metadata(self) -> InitializeResponse: + return self._init + + def close(self) -> None: + self._client.close() + + # BEGIN GENERATED: Codex.flat_methods + def thread_start( + self, + *, + approval_policy: AskForApproval | None = None, + approvals_reviewer: ApprovalsReviewer | None = None, + base_instructions: str | None = None, + config: JsonObject | None = None, + cwd: str | None = None, + developer_instructions: str | None = None, + ephemeral: bool | None = None, + model: str | None = None, + model_provider: str | None = None, + personality: Personality | None = None, + sandbox: SandboxMode | None = None, + service_name: str | None = None, + service_tier: ServiceTier | None = None, + ) -> Thread: + params = ThreadStartParams( + approval_policy=approval_policy, + approvals_reviewer=approvals_reviewer, + base_instructions=base_instructions, + config=config, + cwd=cwd, + developer_instructions=developer_instructions, + ephemeral=ephemeral, + model=model, + model_provider=model_provider, + personality=personality, + sandbox=sandbox, + service_name=service_name, + service_tier=service_tier, + ) + started = self._client.thread_start(params) + return Thread(self._client, started.thread.id) + + def thread_list( + self, + *, + archived: bool | None = None, + cursor: str | None = None, + cwd: str | None = None, + limit: int | None = None, + model_providers: list[str] | None = None, + search_term: str | None = None, + sort_key: ThreadSortKey | None = None, + source_kinds: list[ThreadSourceKind] | None = None, + ) -> ThreadListResponse: + params = ThreadListParams( + archived=archived, + cursor=cursor, + cwd=cwd, + limit=limit, + model_providers=model_providers, + search_term=search_term, + sort_key=sort_key, + source_kinds=source_kinds, + ) + return self._client.thread_list(params) + + def thread_resume( + self, + thread_id: str, + *, + approval_policy: AskForApproval | None = None, + approvals_reviewer: ApprovalsReviewer | None = None, + base_instructions: str | None = None, + config: JsonObject | None = None, + cwd: str | None = None, + developer_instructions: str | None = None, + model: str | None = None, + model_provider: str | None = None, + personality: Personality | None = None, + sandbox: SandboxMode | None = None, + service_tier: ServiceTier | None = None, + ) -> Thread: + params = ThreadResumeParams( + thread_id=thread_id, + approval_policy=approval_policy, + approvals_reviewer=approvals_reviewer, + base_instructions=base_instructions, + config=config, + cwd=cwd, + developer_instructions=developer_instructions, + model=model, + model_provider=model_provider, + personality=personality, + sandbox=sandbox, + service_tier=service_tier, + ) + resumed = self._client.thread_resume(thread_id, params) + return Thread(self._client, resumed.thread.id) + + def thread_fork( + self, + thread_id: str, + *, + approval_policy: AskForApproval | None = None, + approvals_reviewer: ApprovalsReviewer | None = None, + base_instructions: str | None = None, + config: JsonObject | None = None, + cwd: str | None = None, + developer_instructions: str | None = None, + ephemeral: bool | None = None, + model: str | None = None, + model_provider: str | None = None, + sandbox: SandboxMode | None = None, + service_tier: ServiceTier | None = None, + ) -> Thread: + params = ThreadForkParams( + thread_id=thread_id, + approval_policy=approval_policy, + approvals_reviewer=approvals_reviewer, + base_instructions=base_instructions, + config=config, + cwd=cwd, + developer_instructions=developer_instructions, + ephemeral=ephemeral, + model=model, + model_provider=model_provider, + sandbox=sandbox, + service_tier=service_tier, + ) + forked = self._client.thread_fork(thread_id, params) + return Thread(self._client, forked.thread.id) + + def thread_archive(self, thread_id: str) -> ThreadArchiveResponse: + return self._client.thread_archive(thread_id) + + def thread_unarchive(self, thread_id: str) -> Thread: + unarchived = self._client.thread_unarchive(thread_id) + return Thread(self._client, unarchived.thread.id) + # END GENERATED: Codex.flat_methods + + def models(self, *, include_hidden: bool = False) -> ModelListResponse: + return self._client.model_list(include_hidden=include_hidden) + + +class AsyncCodex: + """Async mirror of :class:`Codex`. + + Prefer ``async with AsyncCodex()`` so initialization and shutdown are + explicit and paired. The async client initializes lazily on context entry + or first awaited API use. + """ + + def __init__(self, config: AppServerConfig | None = None) -> None: + self._client = AsyncAppServerClient(config=config) + self._init: InitializeResponse | None = None + self._initialized = False + self._init_lock = asyncio.Lock() + + async def __aenter__(self) -> "AsyncCodex": + await self._ensure_initialized() + return self + + async def __aexit__(self, _exc_type, _exc, _tb) -> None: + await self.close() + + async def _ensure_initialized(self) -> None: + if self._initialized: + return + async with self._init_lock: + if self._initialized: + return + try: + await self._client.start() + payload = await self._client.initialize() + self._init = Codex._validate_initialize(payload) + self._initialized = True + except Exception: + await self._client.close() + self._init = None + self._initialized = False + raise + + @property + def metadata(self) -> InitializeResponse: + if self._init is None: + raise RuntimeError( + "AsyncCodex is not initialized yet. Prefer `async with AsyncCodex()`; " + "initialization also happens on first awaited API use." + ) + return self._init + + async def close(self) -> None: + await self._client.close() + self._init = None + self._initialized = False + + # BEGIN GENERATED: AsyncCodex.flat_methods + async def thread_start( + self, + *, + approval_policy: AskForApproval | None = None, + approvals_reviewer: ApprovalsReviewer | None = None, + base_instructions: str | None = None, + config: JsonObject | None = None, + cwd: str | None = None, + developer_instructions: str | None = None, + ephemeral: bool | None = None, + model: str | None = None, + model_provider: str | None = None, + personality: Personality | None = None, + sandbox: SandboxMode | None = None, + service_name: str | None = None, + service_tier: ServiceTier | None = None, + ) -> AsyncThread: + await self._ensure_initialized() + params = ThreadStartParams( + approval_policy=approval_policy, + approvals_reviewer=approvals_reviewer, + base_instructions=base_instructions, + config=config, + cwd=cwd, + developer_instructions=developer_instructions, + ephemeral=ephemeral, + model=model, + model_provider=model_provider, + personality=personality, + sandbox=sandbox, + service_name=service_name, + service_tier=service_tier, + ) + started = await self._client.thread_start(params) + return AsyncThread(self, started.thread.id) + + async def thread_list( + self, + *, + archived: bool | None = None, + cursor: str | None = None, + cwd: str | None = None, + limit: int | None = None, + model_providers: list[str] | None = None, + search_term: str | None = None, + sort_key: ThreadSortKey | None = None, + source_kinds: list[ThreadSourceKind] | None = None, + ) -> ThreadListResponse: + await self._ensure_initialized() + params = ThreadListParams( + archived=archived, + cursor=cursor, + cwd=cwd, + limit=limit, + model_providers=model_providers, + search_term=search_term, + sort_key=sort_key, + source_kinds=source_kinds, + ) + return await self._client.thread_list(params) + + async def thread_resume( + self, + thread_id: str, + *, + approval_policy: AskForApproval | None = None, + approvals_reviewer: ApprovalsReviewer | None = None, + base_instructions: str | None = None, + config: JsonObject | None = None, + cwd: str | None = None, + developer_instructions: str | None = None, + model: str | None = None, + model_provider: str | None = None, + personality: Personality | None = None, + sandbox: SandboxMode | None = None, + service_tier: ServiceTier | None = None, + ) -> AsyncThread: + await self._ensure_initialized() + params = ThreadResumeParams( + thread_id=thread_id, + approval_policy=approval_policy, + approvals_reviewer=approvals_reviewer, + base_instructions=base_instructions, + config=config, + cwd=cwd, + developer_instructions=developer_instructions, + model=model, + model_provider=model_provider, + personality=personality, + sandbox=sandbox, + service_tier=service_tier, + ) + resumed = await self._client.thread_resume(thread_id, params) + return AsyncThread(self, resumed.thread.id) + + async def thread_fork( + self, + thread_id: str, + *, + approval_policy: AskForApproval | None = None, + approvals_reviewer: ApprovalsReviewer | None = None, + base_instructions: str | None = None, + config: JsonObject | None = None, + cwd: str | None = None, + developer_instructions: str | None = None, + ephemeral: bool | None = None, + model: str | None = None, + model_provider: str | None = None, + sandbox: SandboxMode | None = None, + service_tier: ServiceTier | None = None, + ) -> AsyncThread: + await self._ensure_initialized() + params = ThreadForkParams( + thread_id=thread_id, + approval_policy=approval_policy, + approvals_reviewer=approvals_reviewer, + base_instructions=base_instructions, + config=config, + cwd=cwd, + developer_instructions=developer_instructions, + ephemeral=ephemeral, + model=model, + model_provider=model_provider, + sandbox=sandbox, + service_tier=service_tier, + ) + forked = await self._client.thread_fork(thread_id, params) + return AsyncThread(self, forked.thread.id) + + async def thread_archive(self, thread_id: str) -> ThreadArchiveResponse: + await self._ensure_initialized() + return await self._client.thread_archive(thread_id) + + async def thread_unarchive(self, thread_id: str) -> AsyncThread: + await self._ensure_initialized() + unarchived = await self._client.thread_unarchive(thread_id) + return AsyncThread(self, unarchived.thread.id) + # END GENERATED: AsyncCodex.flat_methods + + async def models(self, *, include_hidden: bool = False) -> ModelListResponse: + await self._ensure_initialized() + return await self._client.model_list(include_hidden=include_hidden) + + +@dataclass(slots=True) +class Thread: + _client: AppServerClient + id: str + + # BEGIN GENERATED: Thread.flat_methods + def turn( + self, + input: Input, + *, + approval_policy: AskForApproval | None = None, + approvals_reviewer: ApprovalsReviewer | None = None, + cwd: str | None = None, + effort: ReasoningEffort | None = None, + model: str | None = None, + output_schema: JsonObject | None = None, + personality: Personality | None = None, + sandbox_policy: SandboxPolicy | None = None, + service_tier: ServiceTier | None = None, + summary: ReasoningSummary | None = None, + ) -> TurnHandle: + wire_input = _to_wire_input(input) + params = TurnStartParams( + thread_id=self.id, + input=wire_input, + approval_policy=approval_policy, + approvals_reviewer=approvals_reviewer, + cwd=cwd, + effort=effort, + model=model, + output_schema=output_schema, + personality=personality, + sandbox_policy=sandbox_policy, + service_tier=service_tier, + summary=summary, + ) + turn = self._client.turn_start(self.id, wire_input, params=params) + return TurnHandle(self._client, self.id, turn.turn.id) + # END GENERATED: Thread.flat_methods + + def read(self, *, include_turns: bool = False) -> ThreadReadResponse: + return self._client.thread_read(self.id, include_turns=include_turns) + + def set_name(self, name: str) -> ThreadSetNameResponse: + return self._client.thread_set_name(self.id, name) + + def compact(self) -> ThreadCompactStartResponse: + return self._client.thread_compact(self.id) + + +@dataclass(slots=True) +class AsyncThread: + _codex: AsyncCodex + id: str + + # BEGIN GENERATED: AsyncThread.flat_methods + async def turn( + self, + input: Input, + *, + approval_policy: AskForApproval | None = None, + approvals_reviewer: ApprovalsReviewer | None = None, + cwd: str | None = None, + effort: ReasoningEffort | None = None, + model: str | None = None, + output_schema: JsonObject | None = None, + personality: Personality | None = None, + sandbox_policy: SandboxPolicy | None = None, + service_tier: ServiceTier | None = None, + summary: ReasoningSummary | None = None, + ) -> AsyncTurnHandle: + await self._codex._ensure_initialized() + wire_input = _to_wire_input(input) + params = TurnStartParams( + thread_id=self.id, + input=wire_input, + approval_policy=approval_policy, + approvals_reviewer=approvals_reviewer, + cwd=cwd, + effort=effort, + model=model, + output_schema=output_schema, + personality=personality, + sandbox_policy=sandbox_policy, + service_tier=service_tier, + summary=summary, + ) + turn = await self._codex._client.turn_start( + self.id, + wire_input, + params=params, + ) + return AsyncTurnHandle(self._codex, self.id, turn.turn.id) + # END GENERATED: AsyncThread.flat_methods + + async def read(self, *, include_turns: bool = False) -> ThreadReadResponse: + await self._codex._ensure_initialized() + return await self._codex._client.thread_read(self.id, include_turns=include_turns) + + async def set_name(self, name: str) -> ThreadSetNameResponse: + await self._codex._ensure_initialized() + return await self._codex._client.thread_set_name(self.id, name) + + async def compact(self) -> ThreadCompactStartResponse: + await self._codex._ensure_initialized() + return await self._codex._client.thread_compact(self.id) + + +@dataclass(slots=True) +class TurnHandle: + _client: AppServerClient + thread_id: str + id: str + + def steer(self, input: Input) -> TurnSteerResponse: + return self._client.turn_steer(self.thread_id, self.id, _to_wire_input(input)) + + def interrupt(self) -> TurnInterruptResponse: + return self._client.turn_interrupt(self.thread_id, self.id) + + def stream(self) -> Iterator[Notification]: + # TODO: replace this client-wide experimental guard with per-turn event demux. + self._client.acquire_turn_consumer(self.id) + try: + while True: + event = self._client.next_notification() + yield event + if ( + event.method == "turn/completed" + and isinstance(event.payload, TurnCompletedNotification) + and event.payload.turn.id == self.id + ): + break + finally: + self._client.release_turn_consumer(self.id) + + def run(self) -> AppServerTurn: + completed: TurnCompletedNotification | None = None + stream = self.stream() + try: + for event in stream: + payload = event.payload + if isinstance(payload, TurnCompletedNotification) and payload.turn.id == self.id: + completed = payload + finally: + stream.close() + + if completed is None: + raise RuntimeError("turn completed event not received") + return completed.turn + + +@dataclass(slots=True) +class AsyncTurnHandle: + _codex: AsyncCodex + thread_id: str + id: str + + async def steer(self, input: Input) -> TurnSteerResponse: + await self._codex._ensure_initialized() + return await self._codex._client.turn_steer( + self.thread_id, + self.id, + _to_wire_input(input), + ) + + async def interrupt(self) -> TurnInterruptResponse: + await self._codex._ensure_initialized() + return await self._codex._client.turn_interrupt(self.thread_id, self.id) + + async def stream(self) -> AsyncIterator[Notification]: + await self._codex._ensure_initialized() + # TODO: replace this client-wide experimental guard with per-turn event demux. + self._codex._client.acquire_turn_consumer(self.id) + try: + while True: + event = await self._codex._client.next_notification() + yield event + if ( + event.method == "turn/completed" + and isinstance(event.payload, TurnCompletedNotification) + and event.payload.turn.id == self.id + ): + break + finally: + self._codex._client.release_turn_consumer(self.id) + + async def run(self) -> AppServerTurn: + completed: TurnCompletedNotification | None = None + stream = self.stream() + try: + async for event in stream: + payload = event.payload + if isinstance(payload, TurnCompletedNotification) and payload.turn.id == self.id: + completed = payload + finally: + await stream.aclose() + + if completed is None: + raise RuntimeError("turn completed event not received") + return completed.turn diff --git a/sdk/python/src/codex_app_server/async_client.py b/sdk/python/src/codex_app_server/async_client.py new file mode 100644 index 000000000..6ca0c42a7 --- /dev/null +++ b/sdk/python/src/codex_app_server/async_client.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Iterator +from typing import AsyncIterator, Callable, Iterable, ParamSpec, TypeVar + +from pydantic import BaseModel + +from .client import AppServerClient, AppServerConfig +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, Notification + +ModelT = TypeVar("ModelT", bound=BaseModel) +ParamsT = ParamSpec("ParamsT") +ReturnT = TypeVar("ReturnT") + + +class AsyncAppServerClient: + """Async wrapper around AppServerClient using thread offloading.""" + + def __init__(self, config: AppServerConfig | None = None) -> None: + self._sync = AppServerClient(config=config) + # Single stdio transport cannot be read safely from multiple threads. + self._transport_lock = asyncio.Lock() + + async def __aenter__(self) -> "AsyncAppServerClient": + await self.start() + return self + + async def __aexit__(self, _exc_type, _exc, _tb) -> None: + await self.close() + + async def _call_sync( + self, + fn: Callable[ParamsT, ReturnT], + /, + *args: ParamsT.args, + **kwargs: ParamsT.kwargs, + ) -> ReturnT: + async with self._transport_lock: + return await asyncio.to_thread(fn, *args, **kwargs) + + @staticmethod + def _next_from_iterator( + iterator: Iterator[AgentMessageDeltaNotification], + ) -> tuple[bool, AgentMessageDeltaNotification | None]: + try: + return True, next(iterator) + except StopIteration: + return False, None + + async def start(self) -> None: + await self._call_sync(self._sync.start) + + async def close(self) -> None: + await self._call_sync(self._sync.close) + + async def initialize(self) -> InitializeResponse: + return await self._call_sync(self._sync.initialize) + + def acquire_turn_consumer(self, turn_id: str) -> None: + self._sync.acquire_turn_consumer(turn_id) + + def release_turn_consumer(self, turn_id: str) -> None: + self._sync.release_turn_consumer(turn_id) + + async def request( + self, + method: str, + params: JsonObject | None, + *, + response_model: type[ModelT], + ) -> ModelT: + return await self._call_sync( + self._sync.request, + method, + params, + response_model=response_model, + ) + + async def thread_start(self, params: V2ThreadStartParams | JsonObject | None = None) -> ThreadStartResponse: + return await self._call_sync(self._sync.thread_start, params) + + async def thread_resume( + self, + thread_id: str, + params: V2ThreadResumeParams | JsonObject | None = None, + ) -> ThreadResumeResponse: + return await self._call_sync(self._sync.thread_resume, thread_id, params) + + async def thread_list(self, params: V2ThreadListParams | JsonObject | None = None) -> ThreadListResponse: + return await self._call_sync(self._sync.thread_list, params) + + async def thread_read(self, thread_id: str, include_turns: bool = False) -> ThreadReadResponse: + return await self._call_sync(self._sync.thread_read, thread_id, include_turns) + + async def thread_fork( + self, + thread_id: str, + params: V2ThreadForkParams | JsonObject | None = None, + ) -> ThreadForkResponse: + return await self._call_sync(self._sync.thread_fork, thread_id, params) + + async def thread_archive(self, thread_id: str) -> ThreadArchiveResponse: + return await self._call_sync(self._sync.thread_archive, thread_id) + + async def thread_unarchive(self, thread_id: str) -> ThreadUnarchiveResponse: + return await self._call_sync(self._sync.thread_unarchive, thread_id) + + async def thread_set_name(self, thread_id: str, name: str) -> ThreadSetNameResponse: + return await self._call_sync(self._sync.thread_set_name, thread_id, name) + + async def thread_compact(self, thread_id: str) -> ThreadCompactStartResponse: + return await self._call_sync(self._sync.thread_compact, thread_id) + + async def turn_start( + self, + thread_id: str, + input_items: list[JsonObject] | JsonObject | str, + params: V2TurnStartParams | JsonObject | None = None, + ) -> TurnStartResponse: + return await self._call_sync(self._sync.turn_start, thread_id, input_items, params) + + async def turn_interrupt(self, thread_id: str, turn_id: str) -> TurnInterruptResponse: + return await self._call_sync(self._sync.turn_interrupt, thread_id, turn_id) + + async def turn_steer( + self, + thread_id: str, + expected_turn_id: str, + input_items: list[JsonObject] | JsonObject | str, + ) -> TurnSteerResponse: + return await self._call_sync( + self._sync.turn_steer, + thread_id, + expected_turn_id, + input_items, + ) + + async def model_list(self, include_hidden: bool = False) -> ModelListResponse: + return await self._call_sync(self._sync.model_list, include_hidden) + + async 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 await self._call_sync( + self._sync.request_with_retry_on_overload, + method, + params, + response_model=response_model, + max_attempts=max_attempts, + initial_delay_s=initial_delay_s, + max_delay_s=max_delay_s, + ) + + async def next_notification(self) -> Notification: + return await self._call_sync(self._sync.next_notification) + + async def wait_for_turn_completed(self, turn_id: str) -> TurnCompletedNotification: + return await self._call_sync(self._sync.wait_for_turn_completed, turn_id) + + async def stream_until_methods(self, methods: Iterable[str] | str) -> list[Notification]: + return await self._call_sync(self._sync.stream_until_methods, methods) + + async def stream_text( + self, + thread_id: str, + text: str, + params: V2TurnStartParams | JsonObject | None = None, + ) -> AsyncIterator[AgentMessageDeltaNotification]: + async with self._transport_lock: + iterator = self._sync.stream_text(thread_id, text, params) + while True: + has_value, chunk = await asyncio.to_thread( + self._next_from_iterator, + iterator, + ) + if not has_value: + break + yield chunk diff --git a/sdk/python/src/codex_app_server/generated/v2_types.py b/sdk/python/src/codex_app_server/generated/v2_types.py deleted file mode 100644 index 932ab438d..000000000 --- a/sdk/python/src/codex_app_server/generated/v2_types.py +++ /dev/null @@ -1,25 +0,0 @@ -"""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", -] diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py index 938de05e2..b19dc745a 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -2,9 +2,11 @@ from __future__ import annotations import ast import importlib.util +import io import json import sys import tomllib +import urllib.error from pathlib import Path import pytest @@ -23,6 +25,17 @@ def _load_update_script_module(): return module +def _load_runtime_setup_module(): + runtime_setup_path = ROOT / "_runtime_setup.py" + spec = importlib.util.spec_from_file_location("_runtime_setup", runtime_setup_path) + if spec is None or spec.loader is None: + raise AssertionError(f"Failed to load runtime setup module: {runtime_setup_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + def test_generation_has_single_maintenance_entrypoint_script() -> None: scripts = sorted(p.name for p in (ROOT / "scripts").glob("*.py")) assert scripts == ["update_sdk_artifacts.py"] @@ -146,6 +159,39 @@ def test_runtime_package_template_has_no_checked_in_binaries() -> None: ) == ["__init__.py"] +def test_examples_readme_matches_pinned_runtime_version() -> None: + runtime_setup = _load_runtime_setup_module() + readme = (ROOT / "examples" / "README.md").read_text() + assert ( + f"Current pinned runtime version: `{runtime_setup.pinned_runtime_version()}`" + in readme + ) + + +def test_release_metadata_retries_without_invalid_auth(monkeypatch: pytest.MonkeyPatch) -> None: + runtime_setup = _load_runtime_setup_module() + authorizations: list[str | None] = [] + + def fake_urlopen(request): + authorization = request.headers.get("Authorization") + authorizations.append(authorization) + if authorization is not None: + raise urllib.error.HTTPError( + request.full_url, + 401, + "Unauthorized", + hdrs=None, + fp=None, + ) + return io.StringIO('{"assets": []}') + + monkeypatch.setenv("GH_TOKEN", "invalid-token") + monkeypatch.setattr(runtime_setup.urllib.request, "urlopen", fake_urlopen) + + assert runtime_setup._release_metadata("1.2.3") == {"assets": []} + assert authorizations == ["Bearer invalid-token", None] + + def test_runtime_package_is_wheel_only_and_builds_platform_specific_wheels() -> None: pyproject = tomllib.loads( (ROOT.parent / "python-runtime" / "pyproject.toml").read_text() diff --git a/sdk/python/tests/test_async_client_behavior.py b/sdk/python/tests/test_async_client_behavior.py new file mode 100644 index 000000000..580ff2a93 --- /dev/null +++ b/sdk/python/tests/test_async_client_behavior.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import asyncio +import time + +from codex_app_server.async_client import AsyncAppServerClient + + +def test_async_client_serializes_transport_calls() -> None: + async def scenario() -> int: + client = AsyncAppServerClient() + active = 0 + max_active = 0 + + def fake_model_list(include_hidden: bool = False) -> bool: + nonlocal active, max_active + active += 1 + max_active = max(max_active, active) + time.sleep(0.05) + active -= 1 + return include_hidden + + client._sync.model_list = fake_model_list # type: ignore[method-assign] + await asyncio.gather(client.model_list(), client.model_list()) + return max_active + + assert asyncio.run(scenario()) == 1 + + +def test_async_stream_text_is_incremental_and_blocks_parallel_calls() -> None: + async def scenario() -> tuple[str, list[str], bool]: + client = AsyncAppServerClient() + + def fake_stream_text(thread_id: str, text: str, params=None): # type: ignore[no-untyped-def] + yield "first" + time.sleep(0.03) + yield "second" + yield "third" + + def fake_model_list(include_hidden: bool = False) -> str: + return "done" + + client._sync.stream_text = fake_stream_text # type: ignore[method-assign] + client._sync.model_list = fake_model_list # type: ignore[method-assign] + + stream = client.stream_text("thread-1", "hello") + first = await anext(stream) + + blocked_before_stream_done = False + competing_call = asyncio.create_task(client.model_list()) + await asyncio.sleep(0.01) + blocked_before_stream_done = not competing_call.done() + + remaining: list[str] = [] + async for item in stream: + remaining.append(item) + + await competing_call + return first, remaining, blocked_before_stream_done + + first, remaining, blocked = asyncio.run(scenario()) + assert first == "first" + assert remaining == ["second", "third"] + assert blocked diff --git a/sdk/python/tests/test_contract_generation.py b/sdk/python/tests/test_contract_generation.py index ae926e481..bb5ec18bb 100644 --- a/sdk/python/tests/test_contract_generation.py +++ b/sdk/python/tests/test_contract_generation.py @@ -9,7 +9,7 @@ ROOT = Path(__file__).resolve().parents[1] GENERATED_TARGETS = [ Path("src/codex_app_server/generated/notification_registry.py"), Path("src/codex_app_server/generated/v2_all.py"), - Path("src/codex_app_server/public_api.py"), + Path("src/codex_app_server/api.py"), ] diff --git a/sdk/python/tests/test_public_api_runtime_behavior.py b/sdk/python/tests/test_public_api_runtime_behavior.py new file mode 100644 index 000000000..dfddd3196 --- /dev/null +++ b/sdk/python/tests/test_public_api_runtime_behavior.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +import asyncio +from collections import deque +from pathlib import Path + +import pytest + +import codex_app_server.api as public_api_module +from codex_app_server.client import AppServerClient +from codex_app_server.generated.v2_all import ( + AgentMessageDeltaNotification, + TurnCompletedNotification, + TurnStatus, +) +from codex_app_server.models import InitializeResponse, Notification +from codex_app_server.api import ( + AsyncCodex, + AsyncTurnHandle, + Codex, + TurnHandle, +) + +ROOT = Path(__file__).resolve().parents[1] + + +def _delta_notification( + *, + thread_id: str = "thread-1", + turn_id: str = "turn-1", + text: str = "delta-text", +) -> Notification: + return Notification( + method="item/agentMessage/delta", + payload=AgentMessageDeltaNotification.model_validate( + { + "delta": text, + "itemId": "item-1", + "threadId": thread_id, + "turnId": turn_id, + } + ), + ) + + +def _completed_notification( + *, + thread_id: str = "thread-1", + turn_id: str = "turn-1", + status: str = "completed", +) -> Notification: + return Notification( + method="turn/completed", + payload=TurnCompletedNotification.model_validate( + { + "threadId": thread_id, + "turn": { + "id": turn_id, + "items": [], + "status": status, + }, + } + ), + ) + + +def test_codex_init_failure_closes_client(monkeypatch: pytest.MonkeyPatch) -> None: + closed: list[bool] = [] + + class FakeClient: + def __init__(self, config=None) -> None: # noqa: ANN001,ARG002 + self._closed = False + + def start(self) -> None: + return None + + def initialize(self) -> InitializeResponse: + return InitializeResponse.model_validate({}) + + def close(self) -> None: + self._closed = True + closed.append(True) + + monkeypatch.setattr(public_api_module, "AppServerClient", FakeClient) + + with pytest.raises(RuntimeError, match="missing required metadata"): + Codex() + + assert closed == [True] + + +def test_async_codex_init_failure_closes_client() -> None: + async def scenario() -> None: + codex = AsyncCodex() + close_calls = 0 + + async def fake_start() -> None: + return None + + async def fake_initialize() -> InitializeResponse: + return InitializeResponse.model_validate({}) + + async def fake_close() -> None: + nonlocal close_calls + close_calls += 1 + + codex._client.start = fake_start # type: ignore[method-assign] + codex._client.initialize = fake_initialize # type: ignore[method-assign] + codex._client.close = fake_close # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match="missing required metadata"): + await codex.models() + + assert close_calls == 1 + assert codex._initialized is False + assert codex._init is None + + asyncio.run(scenario()) + + +def test_async_codex_initializes_only_once_under_concurrency() -> None: + async def scenario() -> None: + codex = AsyncCodex() + start_calls = 0 + initialize_calls = 0 + ready = asyncio.Event() + + async def fake_start() -> None: + nonlocal start_calls + start_calls += 1 + + async def fake_initialize() -> InitializeResponse: + nonlocal initialize_calls + initialize_calls += 1 + ready.set() + await asyncio.sleep(0.02) + return InitializeResponse.model_validate( + { + "userAgent": "codex-cli/1.2.3", + "serverInfo": {"name": "codex-cli", "version": "1.2.3"}, + } + ) + + async def fake_model_list(include_hidden: bool = False): # noqa: ANN202,ARG001 + await ready.wait() + return object() + + codex._client.start = fake_start # type: ignore[method-assign] + codex._client.initialize = fake_initialize # type: ignore[method-assign] + codex._client.model_list = fake_model_list # type: ignore[method-assign] + + await asyncio.gather(codex.models(), codex.models()) + + assert start_calls == 1 + assert initialize_calls == 1 + + asyncio.run(scenario()) + + +def test_turn_stream_rejects_second_active_consumer() -> None: + client = AppServerClient() + notifications: deque[Notification] = deque( + [ + _delta_notification(turn_id="turn-1"), + _completed_notification(turn_id="turn-1"), + ] + ) + client.next_notification = notifications.popleft # type: ignore[method-assign] + + first_stream = TurnHandle(client, "thread-1", "turn-1").stream() + assert next(first_stream).method == "item/agentMessage/delta" + + second_stream = TurnHandle(client, "thread-1", "turn-2").stream() + with pytest.raises(RuntimeError, match="Concurrent turn consumers are not yet supported"): + next(second_stream) + + first_stream.close() + + +def test_async_turn_stream_rejects_second_active_consumer() -> None: + async def scenario() -> None: + codex = AsyncCodex() + + async def fake_ensure_initialized() -> None: + return None + + notifications: deque[Notification] = deque( + [ + _delta_notification(turn_id="turn-1"), + _completed_notification(turn_id="turn-1"), + ] + ) + + async def fake_next_notification() -> Notification: + return notifications.popleft() + + codex._ensure_initialized = fake_ensure_initialized # type: ignore[method-assign] + codex._client.next_notification = fake_next_notification # type: ignore[method-assign] + + first_stream = AsyncTurnHandle(codex, "thread-1", "turn-1").stream() + assert (await anext(first_stream)).method == "item/agentMessage/delta" + + second_stream = AsyncTurnHandle(codex, "thread-1", "turn-2").stream() + with pytest.raises(RuntimeError, match="Concurrent turn consumers are not yet supported"): + await anext(second_stream) + + await first_stream.aclose() + + asyncio.run(scenario()) + + +def test_turn_run_returns_completed_turn_payload() -> None: + client = AppServerClient() + notifications: deque[Notification] = deque( + [ + _completed_notification(), + ] + ) + client.next_notification = notifications.popleft # type: ignore[method-assign] + + result = TurnHandle(client, "thread-1", "turn-1").run() + + assert result.id == "turn-1" + assert result.status == TurnStatus.completed + assert result.items == [] + + +def test_retry_examples_compare_status_with_enum() -> None: + for path in ( + ROOT / "examples" / "10_error_handling_and_retry" / "sync.py", + ROOT / "examples" / "10_error_handling_and_retry" / "async.py", + ): + source = path.read_text() + assert '== "failed"' not in source + assert "TurnStatus.failed" in source diff --git a/sdk/python/tests/test_public_api_signatures.py b/sdk/python/tests/test_public_api_signatures.py new file mode 100644 index 000000000..4ac051c03 --- /dev/null +++ b/sdk/python/tests/test_public_api_signatures.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import importlib.resources as resources +import inspect +from typing import Any + +from codex_app_server import AppServerConfig +from codex_app_server.models import InitializeResponse +from codex_app_server.api import AsyncCodex, AsyncThread, Codex, Thread + + +def _keyword_only_names(fn: object) -> list[str]: + signature = inspect.signature(fn) + return [ + param.name + for param in signature.parameters.values() + if param.kind == inspect.Parameter.KEYWORD_ONLY + ] + + +def _assert_no_any_annotations(fn: object) -> None: + signature = inspect.signature(fn) + for param in signature.parameters.values(): + if param.annotation is Any: + raise AssertionError(f"{fn} has public parameter typed as Any: {param.name}") + if signature.return_annotation is Any: + raise AssertionError(f"{fn} has public return annotation typed as Any") + + +def test_root_exports_app_server_config() -> None: + assert AppServerConfig.__name__ == "AppServerConfig" + + +def test_package_includes_py_typed_marker() -> None: + marker = resources.files("codex_app_server").joinpath("py.typed") + assert marker.is_file() + + +def test_generated_public_signatures_are_snake_case_and_typed() -> None: + expected = { + Codex.thread_start: [ + "approval_policy", + "approvals_reviewer", + "base_instructions", + "config", + "cwd", + "developer_instructions", + "ephemeral", + "model", + "model_provider", + "personality", + "sandbox", + "service_name", + "service_tier", + ], + Codex.thread_list: [ + "archived", + "cursor", + "cwd", + "limit", + "model_providers", + "search_term", + "sort_key", + "source_kinds", + ], + Codex.thread_resume: [ + "approval_policy", + "approvals_reviewer", + "base_instructions", + "config", + "cwd", + "developer_instructions", + "model", + "model_provider", + "personality", + "sandbox", + "service_tier", + ], + Codex.thread_fork: [ + "approval_policy", + "approvals_reviewer", + "base_instructions", + "config", + "cwd", + "developer_instructions", + "ephemeral", + "model", + "model_provider", + "sandbox", + "service_tier", + ], + Thread.turn: [ + "approval_policy", + "approvals_reviewer", + "cwd", + "effort", + "model", + "output_schema", + "personality", + "sandbox_policy", + "service_tier", + "summary", + ], + AsyncCodex.thread_start: [ + "approval_policy", + "approvals_reviewer", + "base_instructions", + "config", + "cwd", + "developer_instructions", + "ephemeral", + "model", + "model_provider", + "personality", + "sandbox", + "service_name", + "service_tier", + ], + AsyncCodex.thread_list: [ + "archived", + "cursor", + "cwd", + "limit", + "model_providers", + "search_term", + "sort_key", + "source_kinds", + ], + AsyncCodex.thread_resume: [ + "approval_policy", + "approvals_reviewer", + "base_instructions", + "config", + "cwd", + "developer_instructions", + "model", + "model_provider", + "personality", + "sandbox", + "service_tier", + ], + AsyncCodex.thread_fork: [ + "approval_policy", + "approvals_reviewer", + "base_instructions", + "config", + "cwd", + "developer_instructions", + "ephemeral", + "model", + "model_provider", + "sandbox", + "service_tier", + ], + AsyncThread.turn: [ + "approval_policy", + "approvals_reviewer", + "cwd", + "effort", + "model", + "output_schema", + "personality", + "sandbox_policy", + "service_tier", + "summary", + ], + } + + for fn, expected_kwargs in expected.items(): + actual = _keyword_only_names(fn) + assert actual == expected_kwargs, f"unexpected kwargs for {fn}: {actual}" + assert all(name == name.lower() for name in actual), f"non snake_case kwargs in {fn}: {actual}" + _assert_no_any_annotations(fn) + + +def test_lifecycle_methods_are_codex_scoped() -> None: + assert hasattr(Codex, "thread_resume") + assert hasattr(Codex, "thread_fork") + assert hasattr(Codex, "thread_archive") + assert hasattr(Codex, "thread_unarchive") + assert hasattr(AsyncCodex, "thread_resume") + assert hasattr(AsyncCodex, "thread_fork") + assert hasattr(AsyncCodex, "thread_archive") + assert hasattr(AsyncCodex, "thread_unarchive") + assert not hasattr(Codex, "thread") + assert not hasattr(AsyncCodex, "thread") + + assert not hasattr(Thread, "resume") + assert not hasattr(Thread, "fork") + assert not hasattr(Thread, "archive") + assert not hasattr(Thread, "unarchive") + assert not hasattr(AsyncThread, "resume") + assert not hasattr(AsyncThread, "fork") + assert not hasattr(AsyncThread, "archive") + assert not hasattr(AsyncThread, "unarchive") + + for fn in ( + Codex.thread_archive, + Codex.thread_unarchive, + AsyncCodex.thread_archive, + AsyncCodex.thread_unarchive, + ): + _assert_no_any_annotations(fn) + + +def test_initialize_metadata_parses_user_agent_shape() -> None: + payload = InitializeResponse.model_validate({"userAgent": "codex-cli/1.2.3"}) + parsed = Codex._validate_initialize(payload) + assert parsed is payload + assert parsed.userAgent == "codex-cli/1.2.3" + assert parsed.serverInfo is not None + assert parsed.serverInfo.name == "codex-cli" + assert parsed.serverInfo.version == "1.2.3" + + +def test_initialize_metadata_requires_non_empty_information() -> None: + try: + Codex._validate_initialize(InitializeResponse.model_validate({})) + except RuntimeError as exc: + assert "missing required metadata" in str(exc) + else: + raise AssertionError("expected RuntimeError when initialize metadata is missing") diff --git a/sdk/python/tests/test_real_app_server_integration.py b/sdk/python/tests/test_real_app_server_integration.py new file mode 100644 index 000000000..3790e37dc --- /dev/null +++ b/sdk/python/tests/test_real_app_server_integration.py @@ -0,0 +1,479 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import textwrap +from dataclasses import dataclass +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +EXAMPLES_DIR = ROOT / "examples" +NOTEBOOK_PATH = ROOT / "notebooks" / "sdk_walkthrough.ipynb" + +root_str = str(ROOT) +if root_str not in sys.path: + sys.path.insert(0, root_str) + +from _runtime_setup import ensure_runtime_package_installed, pinned_runtime_version + +RUN_REAL_CODEX_TESTS = os.environ.get("RUN_REAL_CODEX_TESTS") == "1" +pytestmark = pytest.mark.skipif( + not RUN_REAL_CODEX_TESTS, + reason="set RUN_REAL_CODEX_TESTS=1 to run real Codex integration coverage", +) + +# 11_cli_mini_app is interactive; we still run it by feeding one prompt, then '/exit'. +EXAMPLE_CASES: list[tuple[str, str]] = [ + ("01_quickstart_constructor", "sync.py"), + ("01_quickstart_constructor", "async.py"), + ("02_turn_run", "sync.py"), + ("02_turn_run", "async.py"), + ("03_turn_stream_events", "sync.py"), + ("03_turn_stream_events", "async.py"), + ("04_models_and_metadata", "sync.py"), + ("04_models_and_metadata", "async.py"), + ("05_existing_thread", "sync.py"), + ("05_existing_thread", "async.py"), + ("06_thread_lifecycle_and_controls", "sync.py"), + ("06_thread_lifecycle_and_controls", "async.py"), + ("07_image_and_text", "sync.py"), + ("07_image_and_text", "async.py"), + ("08_local_image_and_text", "sync.py"), + ("08_local_image_and_text", "async.py"), + ("09_async_parity", "sync.py"), + # 09_async_parity async path is represented by 01 async + dedicated async-based cases above. + ("10_error_handling_and_retry", "sync.py"), + ("10_error_handling_and_retry", "async.py"), + ("11_cli_mini_app", "sync.py"), + ("11_cli_mini_app", "async.py"), + ("12_turn_params_kitchen_sink", "sync.py"), + ("12_turn_params_kitchen_sink", "async.py"), + ("13_model_select_and_turn_params", "sync.py"), + ("13_model_select_and_turn_params", "async.py"), + ("14_turn_controls", "sync.py"), + ("14_turn_controls", "async.py"), +] + + +@dataclass(frozen=True) +class PreparedRuntimeEnv: + python: str + env: dict[str, str] + runtime_version: str + + +@pytest.fixture(scope="session") +def runtime_env(tmp_path_factory: pytest.TempPathFactory) -> PreparedRuntimeEnv: + runtime_version = pinned_runtime_version() + temp_root = tmp_path_factory.mktemp("python-runtime-env") + isolated_site = temp_root / "site-packages" + python = sys.executable + + _run_command( + [ + python, + "-m", + "pip", + "install", + "--target", + str(isolated_site), + "pydantic>=2.12", + ], + cwd=ROOT, + env=os.environ.copy(), + timeout_s=240, + ) + ensure_runtime_package_installed( + python, + ROOT, + install_target=isolated_site, + ) + + env = os.environ.copy() + env["PYTHONPATH"] = os.pathsep.join([str(isolated_site), str(ROOT / "src")]) + env["CODEX_PYTHON_SDK_DIR"] = str(ROOT) + return PreparedRuntimeEnv(python=python, env=env, runtime_version=runtime_version) + + +def _run_command( + args: list[str], + *, + cwd: Path, + env: dict[str, str], + timeout_s: int, + stdin: str | None = None, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + args, + cwd=str(cwd), + env=env, + input=stdin, + text=True, + capture_output=True, + timeout=timeout_s, + check=False, + ) + + +def _run_python( + runtime_env: PreparedRuntimeEnv, + source: str, + *, + cwd: Path | None = None, + timeout_s: int = 180, +) -> subprocess.CompletedProcess[str]: + return _run_command( + [str(runtime_env.python), "-c", source], + cwd=cwd or ROOT, + env=runtime_env.env, + timeout_s=timeout_s, + ) + + +def _runtime_compatibility_hint( + runtime_env: PreparedRuntimeEnv, + *, + stdout: str, + stderr: str, +) -> str: + combined = f"{stdout}\n{stderr}" + if "ThreadStartResponse" in combined and "approvalsReviewer" in combined: + return ( + "\nCompatibility hint:\n" + f"Pinned runtime {runtime_env.runtime_version} returned a thread/start payload " + "that is older than the current SDK schema and is missing " + "`approvalsReviewer`. Bump `sdk/python/_runtime_setup.py` to a matching " + "released runtime version.\n" + ) + return "" + + +def _run_json_python( + runtime_env: PreparedRuntimeEnv, + source: str, + *, + cwd: Path | None = None, + timeout_s: int = 180, +) -> dict[str, object]: + result = _run_python(runtime_env, source, cwd=cwd, timeout_s=timeout_s) + assert result.returncode == 0, ( + "Python snippet failed.\n" + f"STDOUT:\n{result.stdout}\n" + f"STDERR:\n{result.stderr}" + f"{_runtime_compatibility_hint(runtime_env, stdout=result.stdout, stderr=result.stderr)}" + ) + return json.loads(result.stdout) + + +def _run_example( + runtime_env: PreparedRuntimeEnv, + folder: str, + script: str, + *, + timeout_s: int = 180, +) -> subprocess.CompletedProcess[str]: + path = EXAMPLES_DIR / folder / script + assert path.exists(), f"Missing example script: {path}" + + stdin = ( + "Give 3 short bullets on SIMD.\nNow rewrite that as 1 short sentence.\n/exit\n" + if folder == "11_cli_mini_app" + else None + ) + return _run_command( + [str(runtime_env.python), str(path)], + cwd=ROOT, + env=runtime_env.env, + timeout_s=timeout_s, + stdin=stdin, + ) + + +def _notebook_cell_source(cell_index: int) -> str: + notebook = json.loads(NOTEBOOK_PATH.read_text()) + return "".join(notebook["cells"][cell_index]["source"]) + + +def test_real_initialize_and_model_list(runtime_env: PreparedRuntimeEnv) -> None: + data = _run_json_python( + runtime_env, + textwrap.dedent( + """ + import json + from codex_app_server import Codex + + with Codex() as codex: + models = codex.models(include_hidden=True) + server = codex.metadata.serverInfo + print(json.dumps({ + "user_agent": codex.metadata.userAgent, + "server_name": None if server is None else server.name, + "server_version": None if server is None else server.version, + "model_count": len(models.data), + })) + """ + ), + ) + + assert isinstance(data["user_agent"], str) and data["user_agent"].strip() + if data["server_name"] is not None: + assert isinstance(data["server_name"], str) and data["server_name"].strip() + if data["server_version"] is not None: + assert isinstance(data["server_version"], str) and data["server_version"].strip() + assert isinstance(data["model_count"], int) + + +def test_real_thread_and_turn_start_smoke(runtime_env: PreparedRuntimeEnv) -> None: + data = _run_json_python( + runtime_env, + textwrap.dedent( + """ + import json + from codex_app_server import Codex, TextInput + + with Codex() as codex: + thread = codex.thread_start( + model="gpt-5.4", + config={"model_reasoning_effort": "high"}, + ) + result = thread.turn(TextInput("hello")).run() + persisted = thread.read(include_turns=True) + persisted_turn = next( + (turn for turn in persisted.thread.turns or [] if turn.id == result.id), + None, + ) + print(json.dumps({ + "thread_id": thread.id, + "turn_id": result.id, + "status": result.status.value, + "items_count": len(result.items or []), + "persisted_items_count": 0 if persisted_turn is None else len(persisted_turn.items or []), + })) + """ + ), + ) + + assert isinstance(data["thread_id"], str) and data["thread_id"].strip() + assert isinstance(data["turn_id"], str) and data["turn_id"].strip() + assert data["status"] == "completed" + assert isinstance(data["items_count"], int) + assert isinstance(data["persisted_items_count"], int) + + +def test_real_async_thread_turn_usage_and_ids_smoke( + runtime_env: PreparedRuntimeEnv, +) -> None: + data = _run_json_python( + runtime_env, + textwrap.dedent( + """ + import asyncio + import json + from codex_app_server import AsyncCodex, TextInput + + async def main(): + async with AsyncCodex() as codex: + thread = await codex.thread_start( + model="gpt-5.4", + config={"model_reasoning_effort": "high"}, + ) + result = await (await thread.turn(TextInput("say ok"))).run() + persisted = await thread.read(include_turns=True) + persisted_turn = next( + (turn for turn in persisted.thread.turns or [] if turn.id == result.id), + None, + ) + print(json.dumps({ + "thread_id": thread.id, + "turn_id": result.id, + "status": result.status.value, + "items_count": len(result.items or []), + "persisted_items_count": 0 if persisted_turn is None else len(persisted_turn.items or []), + })) + + asyncio.run(main()) + """ + ), + ) + + assert isinstance(data["thread_id"], str) and data["thread_id"].strip() + assert isinstance(data["turn_id"], str) and data["turn_id"].strip() + assert data["status"] == "completed" + assert isinstance(data["items_count"], int) + assert isinstance(data["persisted_items_count"], int) + + +def test_notebook_bootstrap_resolves_sdk_and_runtime_from_unrelated_cwd( + runtime_env: PreparedRuntimeEnv, +) -> None: + cell_1_source = _notebook_cell_source(1) + env = runtime_env.env.copy() + + with tempfile.TemporaryDirectory() as temp_cwd: + result = _run_command( + [str(runtime_env.python), "-c", cell_1_source], + cwd=Path(temp_cwd), + env=env, + timeout_s=180, + ) + + assert result.returncode == 0, ( + f"Notebook bootstrap failed from unrelated cwd.\n" + f"STDOUT:\n{result.stdout}\n" + f"STDERR:\n{result.stderr}" + ) + assert "SDK source:" in result.stdout + assert f"Runtime package: {runtime_env.runtime_version}" in result.stdout + + +def test_notebook_sync_cell_smoke(runtime_env: PreparedRuntimeEnv) -> None: + source = "\n\n".join( + [ + _notebook_cell_source(1), + _notebook_cell_source(2), + _notebook_cell_source(3), + ] + ) + result = _run_python(runtime_env, source, timeout_s=240) + assert result.returncode == 0, ( + f"Notebook sync smoke failed.\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + assert "status:" in result.stdout + assert "server:" in result.stdout + + +def test_notebook_advanced_cell_smoke(runtime_env: PreparedRuntimeEnv) -> None: + source = "\n\n".join( + [ + _notebook_cell_source(1), + _notebook_cell_source(2), + _notebook_cell_source(7), + ] + ) + result = _run_python(runtime_env, source, timeout_s=360) + assert result.returncode == 0, ( + f"Notebook advanced smoke failed.\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + assert "selected.model:" in result.stdout + assert "agent.message.params:" in result.stdout + assert "items.params:" in result.stdout + + +def test_real_streaming_smoke_turn_completed(runtime_env: PreparedRuntimeEnv) -> None: + data = _run_json_python( + runtime_env, + textwrap.dedent( + """ + import json + from codex_app_server import Codex, TextInput + + with Codex() as codex: + thread = codex.thread_start( + model="gpt-5.4", + config={"model_reasoning_effort": "high"}, + ) + turn = thread.turn(TextInput("Reply with one short sentence.")) + saw_delta = False + saw_completed = False + for event in turn.stream(): + if event.method == "item/agentMessage/delta": + saw_delta = True + if event.method == "turn/completed": + saw_completed = True + print(json.dumps({ + "saw_delta": saw_delta, + "saw_completed": saw_completed, + })) + """ + ), + ) + + assert data["saw_completed"] is True + assert isinstance(data["saw_delta"], bool) + + +def test_real_turn_interrupt_smoke(runtime_env: PreparedRuntimeEnv) -> None: + data = _run_json_python( + runtime_env, + textwrap.dedent( + """ + import json + from codex_app_server import Codex, TextInput + + with Codex() as codex: + thread = codex.thread_start( + model="gpt-5.4", + config={"model_reasoning_effort": "high"}, + ) + turn = thread.turn(TextInput("Count from 1 to 200 with commas.")) + turn.interrupt() + follow_up = thread.turn(TextInput("Say 'ok' only.")).run() + print(json.dumps({"status": follow_up.status.value})) + """ + ), + ) + + assert data["status"] in {"completed", "failed"} + + +@pytest.mark.parametrize(("folder", "script"), EXAMPLE_CASES) +def test_real_examples_run_and_assert( + runtime_env: PreparedRuntimeEnv, + folder: str, + script: str, +) -> None: + result = _run_example(runtime_env, folder, script) + + assert result.returncode == 0, ( + f"Example failed: {folder}/{script}\n" + f"STDOUT:\n{result.stdout}\n" + f"STDERR:\n{result.stderr}" + f"{_runtime_compatibility_hint(runtime_env, stdout=result.stdout, stderr=result.stderr)}" + ) + + out = result.stdout + + if folder == "01_quickstart_constructor": + assert "Status:" in out and "Text:" in out + assert "Server: unknown" not in out + elif folder == "02_turn_run": + assert "thread_id:" in out and "turn_id:" in out and "status:" in out + assert "persisted.items.count:" in out + elif folder == "03_turn_stream_events": + assert "stream.completed:" in out + assert "assistant>" in out + elif folder == "04_models_and_metadata": + assert "server:" in out + assert "models.count:" in out + assert "models:" in out + assert "metadata:" not in out + elif folder == "05_existing_thread": + assert "Created thread:" in out + elif folder == "06_thread_lifecycle_and_controls": + assert "Lifecycle OK:" in out + elif folder in {"07_image_and_text", "08_local_image_and_text"}: + assert "completed" in out.lower() or "Status:" in out + elif folder == "09_async_parity": + assert "Thread:" in out and "Turn:" in out + elif folder == "10_error_handling_and_retry": + assert "Text:" in out + elif folder == "11_cli_mini_app": + assert "Thread:" in out + assert out.count("assistant>") >= 2 + assert out.count("assistant.status>") >= 2 + assert out.count("usage>") >= 2 + elif folder == "12_turn_params_kitchen_sink": + assert "Status:" in out + assert "summary:" in out + assert "actions:" in out + assert "Items:" in out + elif folder == "13_model_select_and_turn_params": + assert "selected.model:" in out and "agent.message.params:" in out and "items.params:" in out + elif folder == "14_turn_controls": + assert "steer.result:" in out and "steer.final.status:" in out + assert "interrupt.result:" in out and "interrupt.final.status:" in out