This commit is contained in:
Alexander Embiricos
2025-12-09 21:02:47 -08:00
parent da3869eeb6
commit 55edfc386a
25 changed files with 1736 additions and 0 deletions

99
sdk/python/EXEC_PLAN.md Normal file
View File

@@ -0,0 +1,99 @@
# Build a Python SDK for Codex CLI
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. There is no PLANS.md file in this repository; all guidance comes from EXEC_PLAN.md.
## Purpose / Big Picture
Deliver a first-class Python SDK that mirrors the TypeScript SDK in `sdk/typescript`. Python developers should be able to embed the Codex CLI agent by instantiating a client, starting or resuming threads, and running turns synchronously or as a streamed generator of events. Success means someone can pip-install the package locally (editable install is fine), run the sample code from the README, and observe structured events and final responses without referring back to the TypeScript sources.
## Progress
- [x] (2025-12-05 18:31Z) Reviewed EXEC_PLAN.md, TypeScript SDK sources/tests, and exec event schema to scope the Python SDK.
- [x] (2025-12-05 18:44Z) Established Python package scaffold and parity API surface (modules, classes, type hints).
- [x] (2025-12-05 18:44Z) Implemented process runner, thread logic, schema handling, and streaming/parsing.
- [x] (2025-12-05 18:45Z) Added README, examples, and tests exercising options, streaming, cancellation, and schema handling.
- [ ] (2025-12-05 18:45Z) Ran unittest suite (skipped in sandbox due to loopback bind restrictions); need re-run in an environment that allows local HTTP servers to validate fully.
## Surprises & Discoveries
- pip install -e . with build isolation failed offline; reran with --no-build-isolation but installation was blocked by site-packages permissions. Worked around by running tests with PYTHONPATH instead of installation.
- Sandbox denied binding a loopback HTTP server, so tests that rely on SSE proxies were skipped in this environment. The proxy helper now skips cleanly when sockets are unavailable.
## Decision Log
- Decision: Follow the TypeScript SDK behavior and default flags, using the Codex CLI (`codex exec --experimental-json`) as the transport. Adopt a Pythonic surface (snake_case, context managers where useful) while keeping method names close to TS (`start_thread`, `resume_thread`, `run`, `run_streamed`).
Rationale: Minimizes divergence for users moving between languages while respecting Python conventions.
Date/Author: 2025-12-05 / assistant
- Decision: Skip proxy-backed tests when the sandbox forbids binding a loopback HTTP server.
Rationale: Allows local test invocation without hard failures in restricted environments while preserving coverage when sockets are available.
Date/Author: 2025-12-05 / assistant
## Outcomes & Retrospective
- To be filled after implementation and validation.
## Context and Orientation
- The TypeScript SDK lives in `sdk/typescript`. Key files: `src/codex.ts`, `src/thread.ts`, `src/exec.ts`, `src/events.ts`, `src/items.ts`, and `src/outputSchemaFile.ts`. Tests in `sdk/typescript/tests` exercise threading, streaming, options, env overrides, output schema handling, images, additional directories, and abort signals.
- The Codex CLI emits JSONL events described in `codex-rs/exec/src/exec_events.rs`. Events include `thread.started`, `turn.started`, `item.*`, `turn.completed`, `turn.failed`, and `error`, with item payloads such as `agent_message`, `command_execution`, `file_change`, `mcp_tool_call`, `web_search`, `todo_list`, and `error`.
- The CLI is invoked as `codex exec --experimental-json` with flags like `--model`, `--sandbox`, `--cd`, `--add-dir`, `--skip-git-repo-check`, `--output-schema`, and `--config` entries for `model_reasoning_effort`, `sandbox_workspace_write.network_access`, `features.web_search_request`, and `approval_policy`. Images are forwarded via repeated `--image` flags.
- The TypeScript SDK writes output schemas to a temp file and cleans them up after each turn. It aggregates text inputs separated by blank lines, forwards images, sets `CODEX_INTERNAL_ORIGINATOR_OVERRIDE` to `codex_sdk_ts`, and injects `OPENAI_BASE_URL` and `CODEX_API_KEY` into the child env unless overridden.
- Tests use a local Codex binary built from `codex-rs` (e.g., `codex-rs/target/debug/codex`) and a lightweight HTTP proxy in tests to capture `/responses` requests and stream SSE events. We should reuse this strategy with Pythons stdlib to avoid network calls.
## Plan of Work
First, scaffold a Python package under `sdk/python` with `pyproject.toml`, `README.md`, and `src/codex_sdk` module files. Mirror the TS module layout: `codex.py` (entry client), `thread.py` (thread state and run/run_streamed), `exec.py` (process runner), `types.py` (events/items dataclasses), and `schema_file.py` (temp schema handling). Export a `Codex` class that owns a `CodexExec` runner and produces `Thread` instances via `start_thread`/`resume_thread`. Thread options should include model, sandbox_mode, working_directory, skip_git_repo_check, model_reasoning_effort, network_access_enabled, web_search_enabled, approval_policy, and additional_directories; turn options should allow `output_schema` and `cancellation` (event or asyncio task cancellation).
Implement `CodexExec.run` to spawn the Codex CLI with the same flags as TS, accept a `signal`/cancel hook, wire stdin/stdout, and yield decoded lines. Build env injection mirroring TS: inherit `os.environ` unless overridden, set `CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codex_sdk_py`, and overlay `OPENAI_BASE_URL`/`CODEX_API_KEY` when provided. Provide binary resolution similar to TS (`vendor/<target-triple>/codex/codex[.exe]`) with optional override path.
Implement event parsing into typed dataclasses, raising clear errors on JSON decode or unexpected structures. `Thread.run_streamed` should normalize inputs (string or list of `{type: "text"|"local_image"}` dicts), concatenate text segments with blank lines, collect image paths, and call `CodexExec.run`. As events stream, update `thread_id` on `thread.started`, forward each parsed event to the caller, and on completion capture `usage` and `final_response` (last `agent_message` text) while collecting items. `Thread.run` should drain the generator and either return `{items, final_response, usage}` or raise on `turn.failed` or stream-level errors. Ensure output schema temp dir is cleaned even on exceptions.
Add tests with `unittest` mirroring TS coverage: successful run collecting items/usage; repeated runs continue thread and include previous assistant output; resume_thread by id; options mapped to CLI flags; env override; output schema temp file creation and cleanup; input concatenation; image forwarding; additional_directories; working_directory with and without `skip_git_repo_check`; originator header; turn failure surfaces errors; streaming path yields events. Implement an HTTP proxy helper (akin to `responsesProxy.ts`) using `http.server` + SSE formatting to capture requests for assertions. Provide a simple abort/cancel test using `threading.Event` or `asyncio` cancellation to ensure process termination.
Document usage in `sdk/python/README.md` with quickstart, streaming example, structured output, image input, resume thread, working directory controls, and env overrides. Note Python version requirement (e.g., 3.9+), install steps (`pip install -e .`), and test command.
## Concrete Steps
- Work in `sdk/python`.
- Create packaging scaffold: `pyproject.toml` with `setuptools`, `src/codex_sdk/__init__.py`, and module files. Add `README.md` referencing examples below.
- Port core logic from TS to Python modules as described above.
- Write tests in `sdk/python/tests` using stdlib `unittest` and helper proxy server; point Codex path to `../codex-rs/target/debug/codex`.
- Commands to run (once implemented):
- Build/install editable for development: `cd sdk/python && python -m pip install -e .`
- Run tests: `cd sdk/python && python -m unittest discover -v`
- Optional manual demo: `python -m examples.basic` once examples exist.
- Keep this section updated if commands change during implementation.
## Validation and Acceptance
Acceptance hinges on observable behavior: installing the package locally, running the quickstart script, and seeing a turn complete with streamed events and final response text. Automated validation: `python -m unittest discover -v` passes, including cases for options mapping, output schema lifecycle, image forwarding, and cancellation. Manual validation: start a thread, call `run` twice to confirm thread continuity, and `resume_thread` with saved id. The SDK should emit helpful errors on malformed events or CLI failures.
## Idempotence and Recovery
All commands are safe to re-run. Package builds are additive; re-running tests or installs is safe. Temp schema directories and proxy servers should be cleaned in `finally` blocks; retrying a failed test should not leak files or processes. If the CLI is missing, the SDK should raise a clear error rather than leaving partial state.
## Artifacts and Notes
- Keep key test transcripts or sample outputs short and inline in this plan as progress is made.
- Note deviations from TS behavior with rationale in the Decision Log.
## Interfaces and Dependencies
- Public classes/functions to expose in `codex_sdk`:
- `Codex(codex_path_override: Optional[str] = None, base_url: Optional[str] = None, api_key: Optional[str] = None, env: Optional[Dict[str, str]] = None)`
- `Codex.start_thread(options: ThreadOptions = None) -> Thread`
- `Codex.resume_thread(thread_id: str, options: ThreadOptions = None) -> Thread`
- `Thread.run(input: Input, turn_options: TurnOptions = None) -> TurnResult`
- `Thread.run_streamed(input: Input, turn_options: TurnOptions = None) -> StreamedTurn` where `StreamedTurn.events` is an iterator/generator of `ThreadEvent`.
- `Thread.id` property returning the current thread id (or None before first turn).
- Types:
- `Input = Union[str, List[InputEntry]]` with `InputEntry` dicts `{ "type": "text", "text": str }` or `{ "type": "local_image", "path": str }`.
- `ThreadEvent`/`ThreadItem` dataclasses mirroring `codex-rs/exec/src/exec_events.rs`.
- `TurnResult` holding `items: List[ThreadItem]`, `final_response: str`, `usage: Optional[Usage]`.
- `ThreadOptions` (model, sandbox_mode, working_directory, skip_git_repo_check, model_reasoning_effort, network_access_enabled, web_search_enabled, approval_policy, additional_directories) and `TurnOptions` (output_schema, signal/cancel hook).
- Dependencies: prefer Python stdlib; avoid third-party packages unless already vendored. Use `subprocess`, `json`, `tempfile`, `pathlib`, `typing`, `dataclasses`, and `asyncio` if needed for cancellation support.
---
Revision note: Initial version created to guide Python SDK implementation based on the TypeScript SDK, following EXEC_PLAN.md requirements.
Revision note: Updated after implementing the Python SDK, adding docs/tests, and documenting sandbox-related test skips (2025-12-05).

129
sdk/python/README.md Normal file
View File

@@ -0,0 +1,129 @@
# Codex SDK for Python
Embed the Codex agent in Python workflows by spawning the Codex CLI and consuming structured events.
The SDK launches the bundled `codex` binary (or a custom path) and exchanges JSONL events over stdin/stdout.
## Installation
Until packages are published, install locally in editable mode:
```
pip install -e .
```
Requires Python 3.9+ and a Codex binary reachable either via the packaged vendor path or `codex_path_override`.
## Quickstart
```python
from codex_sdk import Codex
codex = Codex()
thread = codex.start_thread()
turn = thread.run("Diagnose the test failure and propose a fix")
print(turn.final_response)
print(turn.items)
```
Call `run()` again on the same `Thread` to continue the conversation.
```python
next_turn = thread.run("Implement the fix")
```
## Streaming responses
`run()` buffers events. To react to intermediate progress—tool calls, streamed responses, and file change notifications—use `run_streamed()` instead. It returns a generator of structured events.
```python
from codex_sdk import ThreadEvent
stream = thread.run_streamed("Diagnose the test failure and propose a fix")
for event in stream.events:
if event.type == "item.completed":
print("item", event.item)
elif event.type == "turn.completed":
print("usage", event.usage)
```
## Structured output
Provide a JSON schema per turn to receive structured assistant responses.
```python
schema = {
"type": "object",
"properties": {
"summary": {"type": "string"},
"status": {"type": "string", "enum": ["ok", "action_required"]},
},
"required": ["summary", "status"],
"additionalProperties": False,
}
from codex_sdk import TurnOptions
turn = thread.run("Summarize repository status", TurnOptions(output_schema=schema))
print(turn.final_response)
```
## Attaching images
Pass structured input entries when including images alongside text. Text entries are concatenated into the prompt; image entries are forwarded to the Codex CLI via `--image`.
```python
turn = thread.run([
{"type": "text", "text": "Describe these screenshots"},
{"type": "local_image", "path": "./ui.png"},
{"type": "local_image", "path": "./diagram.jpg"},
])
```
## Resuming an existing thread
Threads persist in `~/.codex/sessions`. If you lose the in-memory `Thread`, reconstruct it with `resume_thread()` and keep going.
```python
import os
saved_thread_id = os.environ["CODEX_THREAD_ID"]
thread = codex.resume_thread(saved_thread_id)
thread.run("Implement the fix")
```
## Working directory controls
Codex runs in the current working directory by default. To bypass the Git repository check for temporary directories, pass `skip_git_repo_check=True` when creating a thread.
```python
from codex_sdk import ThreadOptions
thread = codex.start_thread(ThreadOptions(working_directory="/path/to/project", skip_git_repo_check=True))
```
## Controlling the Codex CLI environment
By default, the CLI inherits `os.environ`. Override it when you need a sandboxed environment and the SDK will inject required variables (`OPENAI_BASE_URL`, `CODEX_API_KEY`, and the SDK originator marker).
```python
from codex_sdk import CodexOptions
codex = Codex(CodexOptions(env={"PATH": "/usr/local/bin"}))
```
## Options reference
- `CodexOptions`: `codex_path_override`, `base_url`, `api_key`, `env`
- `ThreadOptions`: `model`, `sandbox_mode`, `working_directory`, `skip_git_repo_check`, `model_reasoning_effort`, `network_access_enabled`, `web_search_enabled`, `approval_policy`, `additional_directories`
- `TurnOptions`: `output_schema`, `cancellation_event`
## Running tests
```
python -m unittest discover -v
```
Set `codex_path_override` if the bundled binary is unavailable; the test suite expects a Codex binary and will exercise a local HTTP proxy to avoid external calls.

35
sdk/python/pyproject.toml Normal file
View File

@@ -0,0 +1,35 @@
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "codex-sdk"
version = "0.0.0.dev0"
description = "Python SDK for Codex CLI."
readme = "README.md"
requires-python = ">=3.9"
license = { text = "Apache-2.0" }
authors = [{ name = "OpenAI" }]
keywords = ["openai", "codex", "sdk", "python"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Libraries",
]
[project.urls]
Homepage = "https://github.com/openai/codex"
Repository = "https://github.com/openai/codex"
[tool.setuptools]
package-dir = { "" = "src" }
[tool.setuptools.packages.find]
where = ["src"]

View File

@@ -0,0 +1,39 @@
from .codex import Codex
from .options import CodexOptions, ThreadOptions, TurnOptions
from .thread import Input, StreamedTurn, Thread, ThreadRunError, TurnResult
from .types import (
AgentMessageItem,
CommandExecutionItem,
ErrorItem,
FileChangeItem,
McpToolCallItem,
ReasoningItem,
ThreadEvent,
ThreadItem,
TodoListItem,
Usage,
WebSearchItem,
)
__all__ = [
"Codex",
"CodexOptions",
"ThreadOptions",
"TurnOptions",
"Thread",
"ThreadRunError",
"TurnResult",
"StreamedTurn",
"Input",
"ThreadEvent",
"ThreadItem",
"Usage",
"AgentMessageItem",
"ReasoningItem",
"CommandExecutionItem",
"FileChangeItem",
"McpToolCallItem",
"WebSearchItem",
"TodoListItem",
"ErrorItem",
]

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
from typing import Optional
from .exec import CodexExec
from .options import CodexOptions, ThreadOptions
from .thread import Thread
class Codex:
"""Main entry point for interacting with the Codex agent."""
def __init__(self, options: Optional[CodexOptions] = None) -> None:
opts = options or CodexOptions()
self._options = opts
self._exec = CodexExec(opts.codex_path_override, opts.env)
def start_thread(self, options: Optional[ThreadOptions] = None) -> Thread:
return Thread(self._exec, self._options, options or ThreadOptions(), None)
def resume_thread(self, thread_id: str, options: Optional[ThreadOptions] = None) -> Thread:
return Thread(self._exec, self._options, options or ThreadOptions(), thread_id)

View File

@@ -0,0 +1,196 @@
from __future__ import annotations
import os
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Generator, List, Optional
from .options import CancellationEvent
INTERNAL_ORIGINATOR_ENV = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE"
PYTHON_SDK_ORIGINATOR = "codex_sdk_py"
class CancelledError(Exception):
"""Raised when a turn is cancelled before completion."""
@dataclass
class CodexExecArgs:
input: str
base_url: Optional[str] = None
api_key: Optional[str] = None
thread_id: Optional[str] = None
images: Optional[List[str]] = None
model: Optional[str] = None
sandbox_mode: Optional[str] = None
working_directory: Optional[str] = None
additional_directories: Optional[List[str]] = None
skip_git_repo_check: bool = False
output_schema_file: Optional[str] = None
model_reasoning_effort: Optional[str] = None
cancellation_event: Optional[CancellationEvent] = None
network_access_enabled: Optional[bool] = None
web_search_enabled: Optional[bool] = None
approval_policy: Optional[str] = None
class CodexExec:
def __init__(self, executable_path: Optional[str] = None, env: Optional[Dict[str, str]] = None) -> None:
self.executable_path = executable_path or find_codex_path()
self.env_override = env
def run(self, args: CodexExecArgs) -> Generator[str, None, None]:
cancel_event = args.cancellation_event
if cancel_event and cancel_event.is_set():
raise CancelledError("Turn cancelled before start")
command_args: list[str] = ["exec", "--experimental-json"]
if args.model:
command_args.extend(["--model", args.model])
if args.sandbox_mode:
command_args.extend(["--sandbox", args.sandbox_mode])
if args.working_directory:
command_args.extend(["--cd", args.working_directory])
if args.additional_directories:
for extra_dir in args.additional_directories:
command_args.extend(["--add-dir", extra_dir])
if args.skip_git_repo_check:
command_args.append("--skip-git-repo-check")
if args.output_schema_file:
command_args.extend(["--output-schema", args.output_schema_file])
if args.model_reasoning_effort:
command_args.extend(["--config", f'model_reasoning_effort="{args.model_reasoning_effort}"'])
if args.network_access_enabled is not None:
command_args.extend(
["--config", f"sandbox_workspace_write.network_access={str(args.network_access_enabled).lower()}"]
)
if args.web_search_enabled is not None:
command_args.extend(["--config", f"features.web_search_request={str(args.web_search_enabled).lower()}"])
if args.approval_policy:
command_args.extend(["--config", f'approval_policy="{args.approval_policy}"'])
if args.images:
for image in args.images:
command_args.extend(["--image", image])
if args.thread_id:
command_args.extend(["resume", args.thread_id])
env = self._build_env(args)
process: Optional[subprocess.Popen[str]] = None
try:
process = subprocess.Popen(
[self.executable_path, *command_args],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
env=env,
)
if not process.stdin or not process.stdout:
raise RuntimeError("Failed to open stdio for Codex process")
process.stdin.write(args.input)
process.stdin.close()
if cancel_event and cancel_event.is_set():
raise CancelledError("Turn cancelled before first event")
for raw_line in process.stdout:
if cancel_event and cancel_event.is_set():
raise CancelledError("Turn cancelled")
yield raw_line.rstrip("\r\n")
process.wait()
if cancel_event and cancel_event.is_set():
raise CancelledError("Turn cancelled after process exit")
if process.returncode:
stderr_output = process.stderr.read() if process.stderr else ""
raise RuntimeError(f"Codex Exec exited with code {process.returncode}: {stderr_output}")
except CancelledError:
if process:
terminate_process(process)
raise
except Exception:
if process:
terminate_process(process)
raise
finally:
if process:
if process.poll() is None:
terminate_process(process)
if process.stdout and not process.stdout.closed:
process.stdout.close()
if process.stderr and not process.stderr.closed:
process.stderr.close()
def _build_env(self, args: CodexExecArgs) -> Dict[str, str]:
env: Dict[str, str] = {}
if self.env_override is not None:
env.update(self.env_override)
else:
env.update({key: value for key, value in os.environ.items() if value is not None})
if INTERNAL_ORIGINATOR_ENV not in env:
env[INTERNAL_ORIGINATOR_ENV] = PYTHON_SDK_ORIGINATOR
if args.base_url:
env["OPENAI_BASE_URL"] = args.base_url
if args.api_key:
env["CODEX_API_KEY"] = args.api_key
return env
def terminate_process(process: subprocess.Popen[str]) -> None:
try:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=2)
except Exception:
if process.poll() is None:
process.kill()
except Exception:
try:
if process.poll() is None:
process.kill()
except Exception:
pass
def find_codex_path() -> str:
platform_name = sys.platform
machine = platform.machine().lower()
target_triple = None
if platform_name.startswith("linux") or platform_name == "android":
if machine in {"x86_64", "amd64"}:
target_triple = "x86_64-unknown-linux-musl"
elif machine in {"aarch64", "arm64"}:
target_triple = "aarch64-unknown-linux-musl"
elif platform_name == "darwin":
if machine in {"x86_64", "amd64"}:
target_triple = "x86_64-apple-darwin"
elif machine in {"arm64", "aarch64"}:
target_triple = "aarch64-apple-darwin"
elif platform_name == "win32":
if machine in {"x86_64", "amd64"}:
target_triple = "x86_64-pc-windows-msvc"
elif machine in {"arm64", "aarch64"}:
target_triple = "aarch64-pc-windows-msvc"
if target_triple is None:
raise RuntimeError(f"Unsupported platform: {platform_name} ({machine})")
package_root = Path(__file__).resolve().parent.parent
vendor_root = package_root / "vendor" / target_triple / "codex"
binary_name = "codex.exe" if platform_name == "win32" else "codex"
binary_path = vendor_root / binary_name
if not binary_path.exists():
raise RuntimeError(
f"Codex binary not found at {binary_path}. "
"Install Codex or provide codex_path_override when creating the client."
)
return str(binary_path)

View File

@@ -0,0 +1,36 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, Optional, Protocol
class CancellationEvent(Protocol):
def is_set(self) -> bool:
...
@dataclass
class CodexOptions:
codex_path_override: Optional[str] = None
base_url: Optional[str] = None
api_key: Optional[str] = None
env: Optional[Dict[str, str]] = None
@dataclass
class ThreadOptions:
model: Optional[str] = None
sandbox_mode: Optional[str] = None
working_directory: Optional[str] = None
skip_git_repo_check: bool = False
model_reasoning_effort: Optional[str] = None
network_access_enabled: Optional[bool] = None
web_search_enabled: Optional[bool] = None
approval_policy: Optional[str] = None
additional_directories: Optional[list[str]] = None
@dataclass
class TurnOptions:
output_schema: Optional[object] = None
cancellation_event: Optional[CancellationEvent] = None

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
import json
import shutil
import tempfile
from pathlib import Path
from typing import Callable, Optional, Tuple
SchemaFile = Tuple[Optional[str], Callable[[], None]]
def create_output_schema_file(schema: object) -> SchemaFile:
if schema is None:
return None, _noop_cleanup
if not isinstance(schema, dict):
raise ValueError("output_schema must be a plain JSON object")
schema_dir = Path(tempfile.mkdtemp(prefix="codex-output-schema-"))
schema_path = schema_dir / "schema.json"
def cleanup() -> None:
try:
shutil.rmtree(schema_dir, ignore_errors=True)
except Exception:
pass
try:
schema_path.write_text(json.dumps(schema), encoding="utf-8")
return str(schema_path), cleanup
except Exception:
cleanup()
raise
def _noop_cleanup() -> None:
return None

View File

@@ -0,0 +1,140 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Generator, List, Optional, Union
from .exec import CancelledError, CodexExec, CodexExecArgs
from .options import CodexOptions, ThreadOptions, TurnOptions
from .schema_file import create_output_schema_file
from .types import (
AgentMessageItem,
ItemCompletedEvent,
ThreadErrorEvent,
ThreadEvent,
ThreadItem,
ThreadStartedEvent,
TurnCompletedEvent,
TurnFailedEvent,
Usage,
parse_thread_event,
)
InputEntry = dict
Input = Union[str, List[InputEntry]]
@dataclass
class TurnResult:
items: List[ThreadItem]
final_response: str
usage: Optional[Usage]
@dataclass
class StreamedTurn:
events: Generator[ThreadEvent, None, None]
class ThreadRunError(Exception):
"""Raised when a turn fails."""
class Thread:
def __init__(
self,
exec_client: CodexExec,
options: CodexOptions,
thread_options: ThreadOptions,
thread_id: Optional[str] = None,
) -> None:
self._exec = exec_client
self._options = options
self._thread_options = thread_options
self._id = thread_id
@property
def id(self) -> Optional[str]:
return self._id
def run_streamed(self, input: Input, turn_options: Optional[TurnOptions] = None) -> StreamedTurn:
return StreamedTurn(events=self._run_streamed_internal(input, turn_options or TurnOptions()))
def _run_streamed_internal(
self, input: Input, turn_options: TurnOptions
) -> Generator[ThreadEvent, None, None]:
prompt, images = normalize_input(input)
schema_path, cleanup = create_output_schema_file(turn_options.output_schema)
args = CodexExecArgs(
input=prompt,
base_url=self._options.base_url,
api_key=self._options.api_key,
thread_id=self._id,
images=images,
model=self._thread_options.model,
sandbox_mode=self._thread_options.sandbox_mode,
working_directory=self._thread_options.working_directory,
additional_directories=self._thread_options.additional_directories,
skip_git_repo_check=self._thread_options.skip_git_repo_check,
output_schema_file=schema_path,
model_reasoning_effort=self._thread_options.model_reasoning_effort,
cancellation_event=turn_options.cancellation_event,
network_access_enabled=self._thread_options.network_access_enabled,
web_search_enabled=self._thread_options.web_search_enabled,
approval_policy=self._thread_options.approval_policy,
)
generator = self._exec.run(args)
try:
for line in generator:
event = parse_thread_event(line)
if isinstance(event, ThreadStartedEvent):
self._id = event.thread_id
yield event
finally:
cleanup()
def run(self, input: Input, turn_options: Optional[TurnOptions] = None) -> TurnResult:
generator = self._run_streamed_internal(input, turn_options or TurnOptions())
items: List[ThreadItem] = []
final_response = ""
usage: Optional[Usage] = None
turn_failure: Optional[str] = None
try:
try:
for event in generator:
if isinstance(event, ItemCompletedEvent):
if isinstance(event.item, AgentMessageItem):
final_response = event.item.text
items.append(event.item)
elif isinstance(event, TurnCompletedEvent):
usage = event.usage
elif isinstance(event, TurnFailedEvent):
turn_failure = event.error.message
break
elif isinstance(event, ThreadErrorEvent):
turn_failure = event.message
break
except CancelledError:
raise
finally:
generator.close()
if turn_failure:
raise ThreadRunError(turn_failure)
return TurnResult(items=items, final_response=final_response, usage=usage)
def normalize_input(input: Input) -> tuple[str, List[str]]:
if isinstance(input, str):
return input, []
prompt_parts: List[str] = []
images: List[str] = []
for item in input:
item_type = item.get("type")
if item_type == "text":
text = item.get("text")
if text is not None:
prompt_parts.append(str(text))
elif item_type == "local_image":
path = item.get("path")
if path:
images.append(str(path))
return "\n\n".join(prompt_parts), images

View File

@@ -0,0 +1,266 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Union
JsonDict = Dict[str, Any]
@dataclass
class Usage:
input_tokens: int = 0
cached_input_tokens: int = 0
output_tokens: int = 0
@dataclass
class ThreadError:
message: str
@dataclass
class AgentMessageItem:
id: str
type: str
text: str
@dataclass
class ReasoningItem:
id: str
type: str
text: str
@dataclass
class CommandExecutionItem:
id: str
type: str
command: str
aggregated_output: str
status: str
exit_code: Optional[int] = None
@dataclass
class FileUpdateChange:
path: str
kind: str
@dataclass
class FileChangeItem:
id: str
type: str
changes: List[FileUpdateChange]
status: str
@dataclass
class McpToolCallResult:
content: List[JsonDict]
structured_content: Any
@dataclass
class McpToolCallError:
message: str
@dataclass
class McpToolCallItem:
id: str
type: str
server: str
tool: str
arguments: Any
status: str
result: Optional[McpToolCallResult] = None
error: Optional[McpToolCallError] = None
@dataclass
class WebSearchItem:
id: str
type: str
query: str
@dataclass
class TodoItem:
text: str
completed: bool
@dataclass
class TodoListItem:
id: str
type: str
items: List[TodoItem]
@dataclass
class ErrorItem:
id: str
type: str
message: str
ThreadItem = Union[
AgentMessageItem,
ReasoningItem,
CommandExecutionItem,
FileChangeItem,
McpToolCallItem,
WebSearchItem,
TodoListItem,
ErrorItem,
]
@dataclass
class ThreadStartedEvent:
type: str
thread_id: str
@dataclass
class TurnStartedEvent:
type: str
@dataclass
class TurnCompletedEvent:
type: str
usage: Usage
@dataclass
class TurnFailedEvent:
type: str
error: ThreadError
@dataclass
class ItemStartedEvent:
type: str
item: ThreadItem
@dataclass
class ItemUpdatedEvent:
type: str
item: ThreadItem
@dataclass
class ItemCompletedEvent:
type: str
item: ThreadItem
@dataclass
class ThreadErrorEvent:
type: str
message: str
ThreadEvent = Union[
ThreadStartedEvent,
TurnStartedEvent,
TurnCompletedEvent,
TurnFailedEvent,
ItemStartedEvent,
ItemUpdatedEvent,
ItemCompletedEvent,
ThreadErrorEvent,
]
def parse_thread_event(payload: Union[str, JsonDict]) -> ThreadEvent:
data = json.loads(payload) if isinstance(payload, str) else dict(payload)
event_type = data.get("type")
if event_type == "thread.started":
return ThreadStartedEvent(type=event_type, thread_id=str(data["thread_id"]))
if event_type == "turn.started":
return TurnStartedEvent(type=event_type)
if event_type == "turn.completed":
usage_data = data.get("usage", {}) or {}
usage = Usage(
input_tokens=int(usage_data.get("input_tokens", 0) or 0),
cached_input_tokens=int(usage_data.get("cached_input_tokens", 0) or 0),
output_tokens=int(usage_data.get("output_tokens", 0) or 0),
)
return TurnCompletedEvent(type=event_type, usage=usage)
if event_type == "turn.failed":
error = data.get("error") or {}
return TurnFailedEvent(type=event_type, error=ThreadError(message=str(error.get("message", ""))))
if event_type == "item.started":
return ItemStartedEvent(type=event_type, item=parse_thread_item(data["item"]))
if event_type == "item.updated":
return ItemUpdatedEvent(type=event_type, item=parse_thread_item(data["item"]))
if event_type == "item.completed":
return ItemCompletedEvent(type=event_type, item=parse_thread_item(data["item"]))
if event_type == "error":
return ThreadErrorEvent(type=event_type, message=str(data.get("message", "")))
raise ValueError(f"Unsupported event type: {event_type}")
def parse_thread_item(data: JsonDict) -> ThreadItem:
item_type = data.get("type")
item_id = str(data.get("id", ""))
if item_type == "agent_message":
return AgentMessageItem(id=item_id, type=item_type, text=str(data.get("text", "")))
if item_type == "reasoning":
return ReasoningItem(id=item_id, type=item_type, text=str(data.get("text", "")))
if item_type == "command_execution":
return CommandExecutionItem(
id=item_id,
type=item_type,
command=str(data.get("command", "")),
aggregated_output=str(data.get("aggregated_output", "")),
exit_code=data.get("exit_code"),
status=str(data.get("status", "")),
)
if item_type == "file_change":
changes_data = data.get("changes") or []
changes = [
FileUpdateChange(path=str(change.get("path", "")), kind=str(change.get("kind", "")))
for change in changes_data
]
return FileChangeItem(id=item_id, type=item_type, changes=changes, status=str(data.get("status", "")))
if item_type == "mcp_tool_call":
result_data = data.get("result")
error_data = data.get("error")
result = None
if isinstance(result_data, dict):
result = McpToolCallResult(
content=list(result_data.get("content") or []),
structured_content=result_data.get("structured_content"),
)
error = None
if isinstance(error_data, dict):
error = McpToolCallError(message=str(error_data.get("message", "")))
return McpToolCallItem(
id=item_id,
type=item_type,
server=str(data.get("server", "")),
tool=str(data.get("tool", "")),
arguments=data.get("arguments"),
status=str(data.get("status", "")),
result=result,
error=error,
)
if item_type == "web_search":
return WebSearchItem(id=item_id, type=item_type, query=str(data.get("query", "")))
if item_type == "todo_list":
todos_data = data.get("items") or []
todos = [
TodoItem(text=str(todo.get("text", "")), completed=bool(todo.get("completed", False)))
for todo in todos_data
]
return TodoListItem(id=item_id, type=item_type, items=todos)
if item_type == "error":
return ErrorItem(id=item_id, type=item_type, message=str(data.get("message", "")))
raise ValueError(f"Unsupported item type: {item_type}")

View File

@@ -0,0 +1 @@
#

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,130 @@
import json
import threading
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any, Callable, Dict, Generator, Iterable, List, Optional, Tuple
DEFAULT_RESPONSE_ID = "resp_mock"
DEFAULT_MESSAGE_ID = "msg_mock"
class _ServerState:
def __init__(self, responses: Iterable[Dict[str, Any]], status_code: int) -> None:
self.responses = iter(responses)
self.requests: List[RecordedRequest] = []
self.status_code = status_code
self.error: Optional[Exception] = None
class RecordedRequest:
def __init__(self, body: str, headers: Dict[str, str]) -> None:
self.body = body
self.json = json.loads(body)
self.headers = headers
def format_sse_event(event: Dict[str, Any]) -> str:
return f"event: {event['type']}\n" + f"data: {json.dumps(event)}\n\n"
def start_responses_test_proxy(
response_bodies: Iterable[Dict[str, Any]], status_code: int = HTTPStatus.OK
) -> Tuple[str, List[RecordedRequest], Callable[[], None]]:
responses_iterable = response_bodies if isinstance(response_bodies, Generator) else list(response_bodies)
state = _ServerState(responses_iterable, int(status_code))
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args: Any) -> None: # pragma: no cover - silence stderr noise
return
def _read_body(self) -> str:
length = int(self.headers.get("content-length", "0"))
return self.rfile.read(length).decode("utf-8")
def do_POST(self) -> None: # noqa: N802
if self.path != "/responses":
self.send_error(HTTPStatus.NOT_FOUND)
return
body = self._read_body()
state.requests.append(RecordedRequest(body, dict(self.headers)))
try:
response = next(state.responses)
except Exception as exc: # pragma: no cover - defensive
state.error = exc
self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR, explain=str(exc))
return
self.send_response(state.status_code)
self.send_header("content-type", "text/event-stream")
self.end_headers()
for event in response["events"]:
self.wfile.write(format_sse_event(event).encode("utf-8"))
self.wfile.flush()
try:
server = HTTPServer(("127.0.0.1", 0), Handler)
except PermissionError as exc:
raise RuntimeError("Cannot bind loopback HTTP server inside sandbox") from exc
address, port = server.server_address
url = f"http://{address}:{port}"
def serve() -> None:
with server:
server.serve_forever(poll_interval=0.1)
thread = threading.Thread(target=serve, daemon=True)
thread.start()
return url, state.requests, lambda: _stop_server(server, thread)
def _stop_server(server: HTTPServer, thread: threading.Thread) -> None:
server.shutdown()
thread.join(timeout=2)
def sse(*events: Dict[str, Any]) -> Dict[str, Any]:
return {"kind": "sse", "events": list(events)}
def response_started(response_id: str = DEFAULT_RESPONSE_ID) -> Dict[str, Any]:
return {"type": "response.created", "response": {"id": response_id}}
def assistant_message(text: str, item_id: str = DEFAULT_MESSAGE_ID) -> Dict[str, Any]:
return {
"type": "response.output_item.done",
"item": {
"type": "message",
"role": "assistant",
"id": item_id,
"content": [{"type": "output_text", "text": text}],
},
}
def shell_call() -> Dict[str, Any]:
command = ["bash", "-lc", "echo 'Hello, world!'"]
return {
"type": "response.output_item.done",
"item": {
"type": "function_call",
"call_id": f"call_id{threading.get_ident()}",
"name": "shell",
"arguments": json.dumps({"command": command, "timeout_ms": 100}),
},
}
def response_failed(error_message: str) -> Dict[str, Any]:
return {"type": "error", "error": {"code": "rate_limit_exceeded", "message": error_message}}
def response_completed(response_id: str = DEFAULT_RESPONSE_ID, usage: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
usage_payload = usage or {
"input_tokens": 42,
"input_tokens_details": {"cached_tokens": 12},
"output_tokens": 5,
"output_tokens_details": None,
"total_tokens": 47,
}
return {"type": "response.completed", "response": {"id": response_id, "usage": usage_payload}}

View File

@@ -0,0 +1,452 @@
import json
import os
import subprocess
import tempfile
import threading
import time
import unittest
import unittest.mock
from contextlib import contextmanager
from pathlib import Path
from typing import List, Tuple
from codex_sdk import (
Codex,
CodexOptions,
ThreadOptions,
ThreadRunError,
TurnOptions,
)
from codex_sdk.exec import INTERNAL_ORIGINATOR_ENV
from codex_sdk.types import ThreadEvent
from .responses_proxy import (
assistant_message,
response_completed,
response_failed,
response_started,
shell_call,
sse,
start_responses_test_proxy,
)
CODEX_EXEC_PATH = Path(__file__).resolve().parents[2] / "codex-rs" / "target" / "debug" / "codex"
def expect_pair(args: List[str], flag: str, value: str) -> None:
self_index = args.index(flag)
assert args[self_index + 1] == value
@contextmanager
def spy_popen() -> Tuple[list, list]:
real_popen = subprocess.Popen
calls: list = []
envs: list = []
def wrapper(*args, **kwargs):
calls.append(list(args[0]))
envs.append(kwargs.get("env"))
return real_popen(*args, **kwargs)
with unittest.mock.patch("codex_sdk.exec.subprocess.Popen", side_effect=wrapper):
yield calls, envs
class CodexSdkTests(unittest.TestCase):
def _start_proxy(self, bodies):
try:
return start_responses_test_proxy(bodies)
except RuntimeError as exc:
self.skipTest(str(exc))
def test_returns_thread_events(self) -> None:
url, requests, close = self._start_proxy([sse(response_started(), assistant_message("Hi!"), response_completed())])
try:
client = Codex(CodexOptions(codex_path_override=str(CODEX_EXEC_PATH), base_url=url, api_key="test"))
thread = client.start_thread()
result = thread.run("Hello, world!")
self.assertEqual(result.final_response, "Hi!")
self.assertEqual(len(result.items), 1)
self.assertIsNotNone(result.usage)
self.assertIsNotNone(thread.id)
self.assertGreater(len(requests), 0)
finally:
close()
def test_run_twice_continues_thread(self) -> None:
url, requests, close = self._start_proxy(
[
sse(response_started("response_1"), assistant_message("First response", "item_1"), response_completed("response_1")),
sse(response_started("response_2"), assistant_message("Second response", "item_2"), response_completed("response_2")),
]
)
try:
client = Codex(CodexOptions(codex_path_override=str(CODEX_EXEC_PATH), base_url=url, api_key="test"))
thread = client.start_thread()
thread.run("first input")
thread.run("second input")
second_request = requests[1]
payload = second_request.json
assistant_entry = next((entry for entry in payload["input"] if entry.get("role") == "assistant"), None)
self.assertIsNotNone(assistant_entry)
content = assistant_entry.get("content") or []
assistant_text = next((item.get("text") for item in content if item.get("type") == "output_text"), None)
self.assertEqual(assistant_text, "First response")
finally:
close()
def test_resume_thread_by_id(self) -> None:
url, requests, close = self._start_proxy(
[
sse(response_started("response_1"), assistant_message("First response", "item_1"), response_completed("response_1")),
sse(response_started("response_2"), assistant_message("Second response", "item_2"), response_completed("response_2")),
]
)
try:
client = Codex(CodexOptions(codex_path_override=str(CODEX_EXEC_PATH), base_url=url, api_key="test"))
original = client.start_thread()
original.run("first input")
resumed = client.resume_thread(original.id or "")
result = resumed.run("second input")
self.assertEqual(resumed.id, original.id)
self.assertEqual(result.final_response, "Second response")
second_request = requests[1]
payload = second_request.json
assistant_entry = next((entry for entry in payload["input"] if entry.get("role") == "assistant"), None)
content = assistant_entry.get("content") if assistant_entry else []
assistant_text = next((item.get("text") for item in content if item.get("type") == "output_text"), None)
self.assertEqual(assistant_text, "First response")
finally:
close()
def test_run_streamed(self) -> None:
url, requests, close = self._start_proxy(
[sse(response_started(), assistant_message("Hi!"), response_completed())]
)
try:
client = Codex(CodexOptions(codex_path_override=str(CODEX_EXEC_PATH), base_url=url, api_key="test"))
thread = client.start_thread()
streamed = thread.run_streamed("Hello, world!")
events: List[ThreadEvent] = list(streamed.events)
self.assertEqual(len(events), 4)
self.assertIsNotNone(thread.id)
self.assertGreater(len(requests), 0)
finally:
close()
def test_thread_options_passed_to_exec(self) -> None:
url, requests, close = self._start_proxy(
[sse(response_started("response_1"), assistant_message("Turn options applied", "item_1"), response_completed("response_1"))]
)
with spy_popen() as (calls, _envs):
try:
client = Codex(CodexOptions(codex_path_override=str(CODEX_EXEC_PATH), base_url=url, api_key="test"))
thread = client.start_thread(ThreadOptions(model="gpt-test-1", sandbox_mode="workspace-write"))
thread.run("apply options")
command_args = calls[0]
self.assertIn("--sandbox", command_args)
self.assertIn("workspace-write", command_args)
self.assertIn("--model", command_args)
self.assertIn("gpt-test-1", command_args)
finally:
close()
def test_model_reasoning_effort_flag(self) -> None:
url, _requests, close = self._start_proxy(
[sse(response_started("response_1"), assistant_message("Reasoning effort applied", "item_1"), response_completed("response_1"))]
)
with spy_popen() as (calls, _envs):
try:
client = Codex(CodexOptions(codex_path_override=str(CODEX_EXEC_PATH), base_url=url, api_key="test"))
thread = client.start_thread(ThreadOptions(model_reasoning_effort="high"))
thread.run("apply reasoning effort")
command_args = calls[0]
expect_pair(command_args, "--config", 'model_reasoning_effort="high"')
finally:
close()
def test_network_access_flag(self) -> None:
url, _requests, close = self._start_proxy(
[sse(response_started("response_1"), assistant_message("Network access enabled", "item_1"), response_completed("response_1"))]
)
with spy_popen() as (calls, _envs):
try:
client = Codex(CodexOptions(codex_path_override=str(CODEX_EXEC_PATH), base_url=url, api_key="test"))
thread = client.start_thread(ThreadOptions(network_access_enabled=True))
thread.run("test network access")
command_args = calls[0]
expect_pair(command_args, "--config", "sandbox_workspace_write.network_access=true")
finally:
close()
def test_web_search_flag(self) -> None:
url, _requests, close = self._start_proxy(
[sse(response_started("response_1"), assistant_message("Web search enabled", "item_1"), response_completed("response_1"))]
)
with spy_popen() as (calls, _envs):
try:
client = Codex(CodexOptions(codex_path_override=str(CODEX_EXEC_PATH), base_url=url, api_key="test"))
thread = client.start_thread(ThreadOptions(web_search_enabled=True))
thread.run("test web search")
command_args = calls[0]
expect_pair(command_args, "--config", "features.web_search_request=true")
finally:
close()
def test_approval_policy_flag(self) -> None:
url, _requests, close = self._start_proxy(
[sse(response_started("response_1"), assistant_message("Approval policy set", "item_1"), response_completed("response_1"))]
)
with spy_popen() as (calls, _envs):
try:
client = Codex(CodexOptions(codex_path_override=str(CODEX_EXEC_PATH), base_url=url, api_key="test"))
thread = client.start_thread(ThreadOptions(approval_policy="on-request"))
thread.run("test approval policy")
command_args = calls[0]
expect_pair(command_args, "--config", 'approval_policy="on-request"')
finally:
close()
def test_env_override(self) -> None:
url, _requests, close = self._start_proxy(
[sse(response_started("response_1"), assistant_message("Custom env", "item_1"), response_completed("response_1"))]
)
os.environ["CODEX_ENV_SHOULD_NOT_LEAK"] = "leak"
with spy_popen() as (_calls, envs):
try:
client = Codex(
CodexOptions(
codex_path_override=str(CODEX_EXEC_PATH),
base_url=url,
api_key="test",
env={"CUSTOM_ENV": "custom"},
)
)
thread = client.start_thread()
thread.run("custom env")
spawn_env = envs[0]
self.assertIsNotNone(spawn_env)
if spawn_env:
self.assertEqual(spawn_env.get("CUSTOM_ENV"), "custom")
self.assertIsNone(spawn_env.get("CODEX_ENV_SHOULD_NOT_LEAK"))
self.assertEqual(spawn_env.get("OPENAI_BASE_URL"), url)
self.assertEqual(spawn_env.get("CODEX_API_KEY"), "test")
self.assertEqual(spawn_env.get(INTERNAL_ORIGINATOR_ENV), "codex_sdk_py")
finally:
os.environ.pop("CODEX_ENV_SHOULD_NOT_LEAK", None)
close()
def test_additional_directories(self) -> None:
url, _requests, close = self._start_proxy(
[sse(response_started("response_1"), assistant_message("Additional directories applied", "item_1"), response_completed("response_1"))]
)
with spy_popen() as (calls, _envs):
try:
client = Codex(CodexOptions(codex_path_override=str(CODEX_EXEC_PATH), base_url=url, api_key="test"))
thread = client.start_thread(ThreadOptions(additional_directories=["../backend", "/tmp/shared"]))
thread.run("test additional dirs")
command_args = calls[0]
forwarded = []
for index, arg in enumerate(command_args):
if arg == "--add-dir" and index + 1 < len(command_args):
forwarded.append(command_args[index + 1])
self.assertEqual(forwarded, ["../backend", "/tmp/shared"])
finally:
close()
def test_output_schema_written_and_cleaned(self) -> None:
url, requests, close = self._start_proxy(
[sse(response_started("response_1"), assistant_message("Structured response", "item_1"), response_completed("response_1"))]
)
schema = {
"type": "object",
"properties": {"answer": {"type": "string"}},
"required": ["answer"],
"additionalProperties": False,
}
with spy_popen() as (calls, _envs):
try:
client = Codex(CodexOptions(codex_path_override=str(CODEX_EXEC_PATH), base_url=url, api_key="test"))
thread = client.start_thread()
thread.run("structured", TurnOptions(output_schema=schema))
payload = requests[0].json
text = payload.get("text")
self.assertIsNotNone(text)
if text:
self.assertEqual(
text.get("format"),
{"name": "codex_output_schema", "type": "json_schema", "strict": True, "schema": schema},
)
command_args = calls[0]
schema_index = command_args.index("--output-schema")
schema_path = command_args[schema_index + 1]
self.assertFalse(Path(schema_path).exists())
finally:
close()
def test_combines_text_segments(self) -> None:
url, requests, close = self._start_proxy(
[sse(response_started("response_1"), assistant_message("Combined input applied", "item_1"), response_completed("response_1"))]
)
try:
client = Codex(CodexOptions(codex_path_override=str(CODEX_EXEC_PATH), base_url=url, api_key="test"))
thread = client.start_thread()
thread.run(
[
{"type": "text", "text": "Describe file changes"},
{"type": "text", "text": "Focus on impacted tests"},
]
)
payload = requests[0].json
last_user = payload["input"][-1]
text = last_user["content"][0]["text"]
self.assertEqual(text, "Describe file changes\n\nFocus on impacted tests")
finally:
close()
def test_forwards_images(self) -> None:
url, _requests, close = self._start_proxy(
[sse(response_started("response_1"), assistant_message("Images applied", "item_1"), response_completed("response_1"))]
)
temp_dir = tempfile.mkdtemp(prefix="codex-images-")
images = [str(Path(temp_dir) / "first.png"), str(Path(temp_dir) / "second.jpg")]
for index, image_path in enumerate(images):
Path(image_path).write_text(f"image-{index}", encoding="utf-8")
with spy_popen() as (calls, _envs):
try:
client = Codex(CodexOptions(codex_path_override=str(CODEX_EXEC_PATH), base_url=url, api_key="test"))
thread = client.start_thread()
thread.run(
[
{"type": "text", "text": "describe the images"},
{"type": "local_image", "path": images[0]},
{"type": "local_image", "path": images[1]},
]
)
command_args = calls[0]
forwarded = []
for index, arg in enumerate(command_args):
if arg == "--image" and index + 1 < len(command_args):
forwarded.append(command_args[index + 1])
self.assertEqual(forwarded, images)
finally:
close()
try:
for image in images:
Path(image).unlink(missing_ok=True)
Path(temp_dir).rmdir()
except Exception:
pass
def test_working_directory(self) -> None:
url, _requests, close = self._start_proxy(
[sse(response_started("response_1"), assistant_message("Working directory applied", "item_1"), response_completed("response_1"))]
)
with spy_popen() as (calls, _envs):
try:
working_dir = tempfile.mkdtemp(prefix="codex-working-dir-")
client = Codex(CodexOptions(codex_path_override=str(CODEX_EXEC_PATH), base_url=url, api_key="test"))
thread = client.start_thread(ThreadOptions(working_directory=working_dir, skip_git_repo_check=True))
thread.run("use custom working directory")
command_args = calls[0]
expect_pair(command_args, "--cd", working_dir)
finally:
close()
try:
Path(working_dir).rmdir()
except Exception:
pass
def test_working_directory_without_git_check_fails(self) -> None:
url, _requests, close = self._start_proxy(
[sse(response_started("response_1"), assistant_message("Working directory applied", "item_1"), response_completed("response_1"))]
)
try:
working_dir = tempfile.mkdtemp(prefix="codex-working-dir-")
client = Codex(CodexOptions(codex_path_override=str(CODEX_EXEC_PATH), base_url=url, api_key="test"))
thread = client.start_thread(ThreadOptions(working_directory=working_dir))
with self.assertRaises(ThreadRunError):
thread.run("use custom working directory")
finally:
close()
try:
Path(working_dir).rmdir()
except Exception:
pass
def test_originator_header(self) -> None:
url, requests, close = self._start_proxy(
[sse(response_started(), assistant_message("Hi!"), response_completed())]
)
try:
client = Codex(CodexOptions(codex_path_override=str(CODEX_EXEC_PATH), base_url=url, api_key="test"))
thread = client.start_thread()
thread.run("Hello, originator!")
originator = requests[0].headers.get("originator")
self.assertIn("codex_sdk_py", originator)
finally:
close()
def test_turn_failure_raises(self) -> None:
def failure_events():
yield sse(response_started("response_1"))
while True:
yield sse(response_failed("rate limit exceeded"))
url, _requests, close = self._start_proxy(failure_events())
try:
client = Codex(CodexOptions(codex_path_override=str(CODEX_EXEC_PATH), base_url=url, api_key="test"))
thread = client.start_thread()
with self.assertRaises(ThreadRunError):
thread.run("fail")
finally:
close()
def test_cancellation_before_start(self) -> None:
url, _requests, close = self._start_proxy(
[sse(response_started(), shell_call(), response_completed())]
)
cancel_event = threading.Event()
cancel_event.set()
try:
client = Codex(CodexOptions(codex_path_override=str(CODEX_EXEC_PATH), base_url=url, api_key="test"))
thread = client.start_thread()
with self.assertRaises(Exception):
thread.run("Hello, world!", TurnOptions(cancellation_event=cancel_event))
finally:
close()
def test_cancellation_during_iteration(self) -> None:
def endless_shell_calls():
while True:
yield sse(response_started(), shell_call(), response_completed())
url, _requests, close = self._start_proxy(endless_shell_calls())
cancel_event = threading.Event()
try:
client = Codex(CodexOptions(codex_path_override=str(CODEX_EXEC_PATH), base_url=url, api_key="test"))
thread = client.start_thread()
turn = thread.run_streamed("Hello, world!", TurnOptions(cancellation_event=cancel_event))
def cancel_soon():
time.sleep(0.05)
cancel_event.set()
threading.Thread(target=cancel_soon, daemon=True).start()
with self.assertRaises(Exception):
for _event in turn.events:
pass
finally:
close()