diff --git a/sdk/python/docs/api-reference.md b/sdk/python/docs/api-reference.md index 46c76f92a8..6442daaf73 100644 --- a/sdk/python/docs/api-reference.md +++ b/sdk/python/docs/api-reference.md @@ -67,8 +67,8 @@ Properties/methods: - `logout() -> None` - `thread_start(*, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, personality=None, sandbox: Sandbox | None = 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_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, personality=None, sandbox: Sandbox | None = None) -> Thread` -- `thread_fork(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, sandbox: Sandbox | None = None) -> Thread` +- `thread_resume(thread_id: str, *, approval_mode=None, base_instructions=None, config=None, cwd=None, developer_instructions=None, include_turns: bool | None = None, model=None, model_provider=None, personality=None, sandbox: Sandbox | None = None, service_tier=None) -> Thread` +- `thread_fork(thread_id: str, *, approval_mode=None, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, include_turns: bool | None = None, model=None, model_provider=None, sandbox: Sandbox | None = None, service_tier=None) -> Thread` - `thread_archive(thread_id: str) -> ThreadArchiveResponse` - `thread_unarchive(thread_id: str) -> Thread` - `models(*, include_hidden: bool = False) -> ModelListResponse` @@ -80,6 +80,13 @@ with Codex() as codex: ... ``` +`thread_resume(...)` and `thread_fork(...)` accept `include_turns` to control +whether the server loads turn history into its response. `False` skips that +work; `True` requests it. Omitting the option, or passing `None`, preserves the +server's default behavior. This does not remove history from the model's +context. Both methods return a thread handle; use `thread.read(include_turns=True)` +to retrieve its history. + ## AsyncCodex (async parity) ```python @@ -107,8 +114,8 @@ Properties/methods: - `logout() -> Awaitable[None]` - `thread_start(*, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, personality=None, sandbox: Sandbox | None = 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_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, personality=None, sandbox: Sandbox | None = None) -> Awaitable[AsyncThread]` -- `thread_fork(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, sandbox: Sandbox | None = None) -> Awaitable[AsyncThread]` +- `thread_resume(thread_id: str, *, approval_mode=None, base_instructions=None, config=None, cwd=None, developer_instructions=None, include_turns: bool | None = None, model=None, model_provider=None, personality=None, sandbox: Sandbox | None = None, service_tier=None) -> Awaitable[AsyncThread]` +- `thread_fork(thread_id: str, *, approval_mode=None, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, include_turns: bool | None = None, model=None, model_provider=None, sandbox: Sandbox | None = None, service_tier=None) -> Awaitable[AsyncThread]` - `thread_archive(thread_id: str) -> Awaitable[ThreadArchiveResponse]` - `thread_unarchive(thread_id: str) -> Awaitable[AsyncThread]` - `models(*, include_hidden: bool = False) -> Awaitable[ModelListResponse]` @@ -150,23 +157,23 @@ attempt. API-key login completes synchronously and does not return a handle. ### Thread -- `run(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> TurnResult` -- `turn(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> TurnHandle` +- `run(input: RunInput, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, source=None, summary=None, turn_service_tier=None) -> TurnResult` +- `turn(input: RunInput, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, source=None, summary=None, turn_service_tier=None) -> TurnHandle` - `read(*, include_turns: bool = False) -> ThreadReadResponse` - `set_name(name: str) -> ThreadSetNameResponse` - `compact() -> ThreadCompactStartResponse` ### AsyncThread -- `run(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> Awaitable[TurnResult]` -- `turn(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> Awaitable[AsyncTurnHandle]` +- `run(input: RunInput, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, source=None, summary=None, turn_service_tier=None) -> Awaitable[TurnResult]` +- `turn(input: RunInput, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, source=None, summary=None, turn_service_tier=None) -> Awaitable[AsyncTurnHandle]` - `read(*, include_turns: bool = False) -> Awaitable[ThreadReadResponse]` - `set_name(name: str) -> Awaitable[ThreadSetNameResponse]` - `compact() -> Awaitable[ThreadCompactStartResponse]` -`run(...)` is the common-case convenience path. It accepts plain strings, starts -the turn, consumes notifications until completion, and returns a small result -object with: +`run(...)` is the common-case convenience path. It accepts the same input and +options as `turn(...)`, consumes notifications until completion, and returns a +small result object with: - `id: str` - `status: TurnStatus` @@ -184,6 +191,24 @@ phase-less assistant message item. Use `turn(...)` when you need low-level turn control (`stream()`, `steer()`, `interrupt()`) before collecting the turn result. +### Turn options + +These options have the same behavior on sync and async `run(...)` and `turn(...)`: + +| Option | Behavior | +| --- | --- | +| `service_tier: str | None = None` | Sets the thread's service tier for this and subsequent turns. | +| `turn_service_tier: str | None = None` | Overrides the tier for a newly started turn only. `None` inherits the thread setting; `"default"` selects standard speed. Does not change the thread default and is ignored when input joins an active turn. | +| `source: str | None = None` | Labels the caller that initiated a new turn, such as `"review_ui"`. This is metadata; it does not schedule work or grant authority. Ignored when input joins an active turn. | + +`turn_service_tier`, `source`, and explicit `include_turns` +on resume/fork require Codex CLI 0.151.0 or newer. The SDK raises `CodexError` +before sending these options to an older runtime, which would otherwise ignore +them. Published SDK releases install a matching runtime automatically; when +using `CodexConfig.codex_bin`, choose a compatible executable. Unversioned local +builds are checked lazily against their experimental schema before these options +are sent. A custom `launch_args_override` must report a supported version. + ## Sandbox Use `sandbox=` consistently on thread lifecycle methods and turns: diff --git a/sdk/python/docs/faq.md b/sdk/python/docs/faq.md index b633ea420a..7886e335e8 100644 --- a/sdk/python/docs/faq.md +++ b/sdk/python/docs/faq.md @@ -37,6 +37,22 @@ Choose `run()` for most apps. Choose `stream()` for progress UIs, custom timeout If your app is not already async, stay with `Codex`. +## Does `include_turns=False` remove the conversation's context? + +No. On `thread_resume(...)` and `thread_fork(...)`, it only skips loading turn +history into the server's response. Omitting it preserves the server's +existing default. Retrieve saved history with `thread.read(include_turns=True)`. + +## How do I change the service tier for just one turn? + +Pass `turn_service_tier=` to `thread.run(...)` or `thread.turn(...)`. +`None` inherits the thread setting, and `"default"` selects standard speed. +The override applies only when starting a new turn. Use `service_tier=` when +you want to change the thread's setting for subsequent turns too. + +`source=` on those methods only labels what initiated the turn. It does not +schedule work or grant authority, and it is ignored when joining an active turn. + ## How do I log in? - `login_api_key(...)` authenticates immediately with an API key. diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index e7f88396f0..1e08cd3ce6 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -16,7 +16,7 @@ classifiers = [ "Intended Audience :: Developers", "Topic :: Software Development :: Libraries :: Python Modules", ] -dependencies = ["pydantic>=2.12", "openai-codex-cli-bin==0.147.0"] +dependencies = ["pydantic>=2.12", "packaging>=26.2", "openai-codex-cli-bin==0.153.4"] [project.urls] Homepage = "https://github.com/openai/codex" @@ -68,10 +68,10 @@ combine-as-imports = true [tool.uv] exclude-newer = "7 days" -exclude-newer-package = { openai-codex-cli-bin = "2026-08-19T00:00:00Z" } +exclude-newer-package = { openai-codex-cli-bin = "2026-09-09T06:06:45Z" } index-strategy = "first-index" [tool.uv.pip] exclude-newer = "7 days" -exclude-newer-package = { openai-codex-cli-bin = "2026-08-19T00:00:00Z" } +exclude-newer-package = { openai-codex-cli-bin = "2026-09-09T06:06:45Z" } index-strategy = "first-index" diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index 1b022177d9..2b26e8e900 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -5,6 +5,7 @@ import importlib.util import json import platform import re +import runpy import shutil import subprocess import sys @@ -175,6 +176,11 @@ def stage_python_sdk_package( ) if len(runtime_versions) != 1: raise RuntimeError("Expected exactly one pinned Codex runtime dependency") + requirements = runpy.run_path(sdk_root() / "src/openai_codex/_runtime_requirements.py") + try: + requirements["require_runtime_version"](runtime_versions[0]) + except ValueError as exc: + raise RuntimeError(f"Cannot package the Python SDK: {exc}") from exc pyproject_path.write_text(pyproject_text) return staging_dir @@ -834,7 +840,10 @@ FIELD_ANNOTATION_OVERRIDES: dict[str, str] = { } PUBLIC_FIELD_NAMES = { + "exclude_turns": "include_turns", "sandbox_policy": "sandbox", + "service_tier_for_turn": "turn_service_tier", + "turn_trigger": "source", } # Adding a protocol field must not silently add a public SDK parameter. These @@ -873,6 +882,7 @@ PUBLIC_METHOD_FIELDS = { "config", "cwd", "developer_instructions", + "exclude_turns", "model", "model_provider", "personality", @@ -885,6 +895,7 @@ PUBLIC_METHOD_FIELDS = { "cwd", "developer_instructions", "ephemeral", + "exclude_turns", "model", "model_provider", "sandbox", @@ -899,7 +910,9 @@ PUBLIC_METHOD_FIELDS = { "personality", "sandbox_policy", "service_tier", + "service_tier_for_turn", "summary", + "turn_trigger", ), } @@ -1033,6 +1046,8 @@ def _model_arg_lines(fields: list[PublicFieldSpec], *, indent: str = " arg = "_sandbox_mode(sandbox)" elif field.wire_name == "sandbox_policy": arg = "_sandbox_policy(sandbox)" + elif field.wire_name == "exclude_turns": + arg = "None if include_turns is None else not include_turns" lines.append(f"{indent}{field.wire_name}={arg},") return lines @@ -1088,7 +1103,11 @@ def _render_codex_block( *_approval_mode_override_signature_lines(), *_kw_signature_lines(resume_fields), " ) -> Thread:", - ' """Resume an existing conversation thread by ID."""', + ' """Resume an existing conversation thread by ID.', + "", + " include_turns controls the runtime response history, not model context.", + " Omit it to preserve the runtime default. Use thread.read() for history.", + ' """', _approval_mode_assignment_line("_approval_mode_override_settings"), " params = ThreadResumeParams(", " thread_id=thread_id,", @@ -1105,7 +1124,11 @@ def _render_codex_block( *_approval_mode_override_signature_lines(), *_kw_signature_lines(fork_fields), " ) -> Thread:", - ' """Create a new thread from an existing thread."""', + ' """Create a new thread from an existing thread.', + "", + " include_turns controls the runtime response history, not model context.", + " Omit it to preserve the runtime default. Use thread.read() for history.", + ' """', _approval_mode_assignment_line("_approval_mode_override_settings"), " params = ThreadForkParams(", " thread_id=thread_id,", @@ -1169,7 +1192,11 @@ def _render_async_codex_block( *_approval_mode_override_signature_lines(), *_kw_signature_lines(resume_fields), " ) -> AsyncThread:", - ' """Resume an existing conversation thread by ID."""', + ' """Resume an existing conversation thread by ID.', + "", + " include_turns controls the runtime response history, not model context.", + " Omit it to preserve the runtime default. Use thread.read() for history.", + ' """', " await self._ensure_initialized()", _approval_mode_assignment_line("_approval_mode_override_settings"), " params = ThreadResumeParams(", @@ -1187,7 +1214,11 @@ def _render_async_codex_block( *_approval_mode_override_signature_lines(), *_kw_signature_lines(fork_fields), " ) -> AsyncThread:", - ' """Create a new thread from an existing thread."""', + ' """Create a new thread from an existing thread.', + "", + " include_turns controls the runtime response history, not model context.", + " Omit it to preserve the runtime default. Use thread.read() for history.", + ' """', " await self._ensure_initialized()", _approval_mode_assignment_line("_approval_mode_override_settings"), " params = ThreadForkParams(", @@ -1212,19 +1243,46 @@ def _render_async_codex_block( return "\n".join(lines) -def _render_thread_block( - turn_fields: list[PublicFieldSpec], -) -> str: +def _render_thread_block(turn_fields: list[PublicFieldSpec], *, is_async: bool = False) -> str: + async_prefix = "async " if is_async else "" + await_prefix = "await " if is_async else "" + client = "self._codex._client" if is_async else "self._client" + handle_type = "AsyncTurnHandle" if is_async else "TurnHandle" + handle_owner = "self._codex" if is_async else "self._client" lines = [ - " def turn(", + f" {async_prefix}def run(", " self,", " input: RunInput,", " *,", *_approval_mode_override_signature_lines(), *_kw_signature_lines(turn_fields), - " ) -> TurnHandle:", - ' """Start a turn and return a handle for streaming or control."""', + " ) -> TurnResult:", + ' """Run a complete turn and collect its final result.', + "", + " Accepts the same input and options as turn().", + ' """', + f" turn = {await_prefix}self.turn(", + " input,", + " approval_mode=approval_mode,", + *[f" {field.py_name}={field.py_name}," for field in turn_fields], + " )", + f" return {await_prefix}turn.run()", + "", + f" {async_prefix}def turn(", + " self,", + " input: RunInput,", + " *,", + *_approval_mode_override_signature_lines(), + *_kw_signature_lines(turn_fields), + f" ) -> {handle_type}:", + ' """Start a turn and return a handle for streaming or control.', + "", + " turn_service_tier applies only to this new turn; service_tier updates", + " the thread default. source labels what initiated a new turn and grants", + " no authority. Both turn_service_tier and source are ignored when joining.", + ' """', " wire_input = _to_wire_input(_normalize_run_input(input))", + *([" await self._codex._ensure_initialized()"] if is_async else []), _approval_mode_assignment_line("_approval_mode_override_settings"), " params = TurnStartParams(", " thread_id=self.id,", @@ -1232,39 +1290,8 @@ def _render_thread_block( *_approval_mode_model_arg_lines(), *_model_arg_lines(turn_fields), " )", - " turn = self._client.turn_start(self.id, wire_input, params=params)", - " return TurnHandle(self._client, self.id, turn.turn.id)", - ] - return "\n".join(lines) - - -def _render_async_thread_block( - turn_fields: list[PublicFieldSpec], -) -> str: - lines = [ - " async def turn(", - " self,", - " input: RunInput,", - " *,", - *_approval_mode_override_signature_lines(), - *_kw_signature_lines(turn_fields), - " ) -> AsyncTurnHandle:", - ' """Start a turn and return a handle for streaming or control."""', - " await self._codex._ensure_initialized()", - " wire_input = _to_wire_input(_normalize_run_input(input))", - _approval_mode_assignment_line("_approval_mode_override_settings"), - " params = TurnStartParams(", - " thread_id=self.id,", - " input=wire_input,", - *_approval_mode_model_arg_lines(), - *_model_arg_lines(turn_fields), - " )", - " turn = await self._codex._client.turn_start(", - " self.id,", - " wire_input,", - " params=params,", - " )", - " return AsyncTurnHandle(self._codex, self.id, turn.turn.id)", + f" turn = {await_prefix}{client}.turn_start(self.id, wire_input, params=params)", + f" return {handle_type}({handle_owner}, self.id, turn.turn.id)", ] return "\n".join(lines) @@ -1315,7 +1342,7 @@ def generate_public_api_flat_methods() -> None: source = _replace_generated_block( source, "AsyncThread.flat_methods", - _render_async_thread_block(turn_start_fields), + _render_thread_block(turn_start_fields, is_async=True), ) public_api_path.write_text(source) run_python_module("ruff", ["format", str(public_api_path)], cwd=sdk_root()) diff --git a/sdk/python/src/openai_codex/_runtime_requirements.py b/sdk/python/src/openai_codex/_runtime_requirements.py new file mode 100644 index 0000000000..ccc89ce360 --- /dev/null +++ b/sdk/python/src/openai_codex/_runtime_requirements.py @@ -0,0 +1,64 @@ +"""Runtime version and checkout-schema requirements for newer SDK options.""" + +import json +import re +import subprocess +from dataclasses import dataclass +from functools import cached_property +from pathlib import Path +from tempfile import TemporaryDirectory + +from packaging.version import InvalidVersion, Version + +MINIMUM_RUNTIME_VERSION = "0.151.0" + + +def require_runtime_version(version: str | None) -> None: + """Reject unknown or unsupported versions, including prereleases at the minimum.""" + # CLI alpha hotfixes use 0.154.0-alpha.1.2; PEP 440 spells that a1.post2. + normalized = re.sub(r"-alpha\.(\d+)\.(\d+)$", r"a\1.post\2", version or "") + try: + if Version(normalized) >= Version(MINIMUM_RUNTIME_VERSION): + return + except InvalidVersion: + pass + raise ValueError( + f"Codex CLI {MINIMUM_RUNTIME_VERSION} or newer is required; " + f"reported version is {version or 'unknown'!r}" + ) + + +@dataclass +class CheckoutCapabilities: + """Lazily inspect the same executable and configuration as a running checkout.""" + + command: tuple[str, ...] + cwd: str | None + env: dict[str, str] + + @cached_property + def fields(self) -> dict[str, frozenset[str]]: + try: + with TemporaryDirectory(prefix="codex-sdk-schema-") as directory: + subprocess.run( + [*self.command, "generate-json-schema", "--experimental", "--out", directory], + cwd=self.cwd, + env=self.env, + capture_output=True, + check=True, + timeout=30, + ) + result = {} + for method, name in ( + ("turn/start", "TurnStartParams"), + ("thread/resume", "ThreadResumeParams"), + ("thread/fork", "ThreadForkParams"), + ): + schema = json.loads((Path(directory) / "v2" / f"{name}.json").read_text()) + properties = schema.get("properties") if isinstance(schema, dict) else None + if not isinstance(properties, dict): + raise ValueError(f"Missing properties in {name} schema") + result[method] = frozenset(properties) + return result + except (OSError, subprocess.SubprocessError, ValueError) as exc: + raise ValueError("Could not inspect the unversioned CLI's experimental schema") from exc diff --git a/sdk/python/src/openai_codex/api.py b/sdk/python/src/openai_codex/api.py index 00a3d364fa..d68d2c8771 100644 --- a/sdk/python/src/openai_codex/api.py +++ b/sdk/python/src/openai_codex/api.py @@ -209,13 +209,18 @@ class Codex: config: JsonObject | None = None, cwd: str | None = None, developer_instructions: str | None = None, + include_turns: bool | None = None, model: str | None = None, model_provider: str | None = None, personality: Personality | None = None, sandbox: Sandbox | None = None, service_tier: str | None = None, ) -> Thread: - """Resume an existing conversation thread by ID.""" + """Resume an existing conversation thread by ID. + + include_turns controls the runtime response history, not model context. + Omit it to preserve the runtime default. Use thread.read() for history. + """ approval_policy, approvals_reviewer = _approval_mode_override_settings(approval_mode) params = ThreadResumeParams( thread_id=thread_id, @@ -225,6 +230,7 @@ class Codex: config=config, cwd=cwd, developer_instructions=developer_instructions, + exclude_turns=None if include_turns is None else not include_turns, model=model, model_provider=model_provider, personality=personality, @@ -244,13 +250,18 @@ class Codex: cwd: str | None = None, developer_instructions: str | None = None, ephemeral: bool | None = None, + include_turns: bool | None = None, model: str | None = None, model_provider: str | None = None, sandbox: Sandbox | None = None, service_tier: str | None = None, thread_source: ThreadSource | None = None, ) -> Thread: - """Create a new thread from an existing thread.""" + """Create a new thread from an existing thread. + + include_turns controls the runtime response history, not model context. + Omit it to preserve the runtime default. Use thread.read() for history. + """ approval_policy, approvals_reviewer = _approval_mode_override_settings(approval_mode) params = ThreadForkParams( thread_id=thread_id, @@ -261,6 +272,7 @@ class Codex: cwd=cwd, developer_instructions=developer_instructions, ephemeral=ephemeral, + exclude_turns=None if include_turns is None else not include_turns, model=model, model_provider=model_provider, sandbox=_sandbox_mode(sandbox), @@ -453,13 +465,18 @@ class AsyncCodex: config: JsonObject | None = None, cwd: str | None = None, developer_instructions: str | None = None, + include_turns: bool | None = None, model: str | None = None, model_provider: str | None = None, personality: Personality | None = None, sandbox: Sandbox | None = None, service_tier: str | None = None, ) -> AsyncThread: - """Resume an existing conversation thread by ID.""" + """Resume an existing conversation thread by ID. + + include_turns controls the runtime response history, not model context. + Omit it to preserve the runtime default. Use thread.read() for history. + """ await self._ensure_initialized() approval_policy, approvals_reviewer = _approval_mode_override_settings(approval_mode) params = ThreadResumeParams( @@ -470,6 +487,7 @@ class AsyncCodex: config=config, cwd=cwd, developer_instructions=developer_instructions, + exclude_turns=None if include_turns is None else not include_turns, model=model, model_provider=model_provider, personality=personality, @@ -489,13 +507,18 @@ class AsyncCodex: cwd: str | None = None, developer_instructions: str | None = None, ephemeral: bool | None = None, + include_turns: bool | None = None, model: str | None = None, model_provider: str | None = None, sandbox: Sandbox | None = None, service_tier: str | None = None, thread_source: ThreadSource | None = None, ) -> AsyncThread: - """Create a new thread from an existing thread.""" + """Create a new thread from an existing thread. + + include_turns controls the runtime response history, not model context. + Omit it to preserve the runtime default. Use thread.read() for history. + """ await self._ensure_initialized() approval_policy, approvals_reviewer = _approval_mode_override_settings(approval_mode) params = ThreadForkParams( @@ -507,6 +530,7 @@ class AsyncCodex: cwd=cwd, developer_instructions=developer_instructions, ephemeral=ephemeral, + exclude_turns=None if include_turns is None else not include_turns, model=model, model_provider=model_provider, sandbox=_sandbox_mode(sandbox), @@ -541,6 +565,7 @@ class Thread: _client: CodexClient id: str + # BEGIN GENERATED: Thread.flat_methods def run( self, input: RunInput, @@ -553,9 +578,14 @@ class Thread: personality: Personality | None = None, sandbox: Sandbox | None = None, service_tier: str | None = None, + source: str | None = None, summary: ReasoningSummary | None = None, + turn_service_tier: str | None = None, ) -> TurnResult: - """Run a complete turn and collect its final result.""" + """Run a complete turn and collect its final result. + + Accepts the same input and options as turn(). + """ turn = self.turn( input, approval_mode=approval_mode, @@ -566,15 +596,12 @@ class Thread: personality=personality, sandbox=sandbox, service_tier=service_tier, + source=source, summary=summary, + turn_service_tier=turn_service_tier, ) - stream = turn.stream() - try: - return _collect_turn_result(stream, turn_id=turn.id) - finally: - stream.close() + return turn.run() - # BEGIN GENERATED: Thread.flat_methods def turn( self, input: RunInput, @@ -587,9 +614,16 @@ class Thread: personality: Personality | None = None, sandbox: Sandbox | None = None, service_tier: str | None = None, + source: str | None = None, summary: ReasoningSummary | None = None, + turn_service_tier: str | None = None, ) -> TurnHandle: - """Start a turn and return a handle for streaming or control.""" + """Start a turn and return a handle for streaming or control. + + turn_service_tier applies only to this new turn; service_tier updates + the thread default. source labels what initiated a new turn and grants + no authority. Both turn_service_tier and source are ignored when joining. + """ wire_input = _to_wire_input(_normalize_run_input(input)) approval_policy, approvals_reviewer = _approval_mode_override_settings(approval_mode) params = TurnStartParams( @@ -604,7 +638,9 @@ class Thread: personality=personality, sandbox_policy=_sandbox_policy(sandbox), service_tier=service_tier, + turn_trigger=source, summary=summary, + service_tier_for_turn=turn_service_tier, ) turn = self._client.turn_start(self.id, wire_input, params=params) return TurnHandle(self._client, self.id, turn.turn.id) @@ -629,6 +665,7 @@ class AsyncThread: _codex: AsyncCodex id: str + # BEGIN GENERATED: AsyncThread.flat_methods async def run( self, input: RunInput, @@ -641,9 +678,14 @@ class AsyncThread: personality: Personality | None = None, sandbox: Sandbox | None = None, service_tier: str | None = None, + source: str | None = None, summary: ReasoningSummary | None = None, + turn_service_tier: str | None = None, ) -> TurnResult: - """Run a complete turn asynchronously and collect its final result.""" + """Run a complete turn and collect its final result. + + Accepts the same input and options as turn(). + """ turn = await self.turn( input, approval_mode=approval_mode, @@ -654,15 +696,12 @@ class AsyncThread: personality=personality, sandbox=sandbox, service_tier=service_tier, + source=source, summary=summary, + turn_service_tier=turn_service_tier, ) - stream = turn.stream() - try: - return await _collect_async_turn_result(stream, turn_id=turn.id) - finally: - await stream.aclose() + return await turn.run() - # BEGIN GENERATED: AsyncThread.flat_methods async def turn( self, input: RunInput, @@ -675,11 +714,18 @@ class AsyncThread: personality: Personality | None = None, sandbox: Sandbox | None = None, service_tier: str | None = None, + source: str | None = None, summary: ReasoningSummary | None = None, + turn_service_tier: str | None = None, ) -> AsyncTurnHandle: - """Start a turn and return a handle for streaming or control.""" - await self._codex._ensure_initialized() + """Start a turn and return a handle for streaming or control. + + turn_service_tier applies only to this new turn; service_tier updates + the thread default. source labels what initiated a new turn and grants + no authority. Both turn_service_tier and source are ignored when joining. + """ wire_input = _to_wire_input(_normalize_run_input(input)) + await self._codex._ensure_initialized() approval_policy, approvals_reviewer = _approval_mode_override_settings(approval_mode) params = TurnStartParams( thread_id=self.id, @@ -693,13 +739,11 @@ class AsyncThread: personality=personality, sandbox_policy=_sandbox_policy(sandbox), service_tier=service_tier, + turn_trigger=source, summary=summary, + service_tier_for_turn=turn_service_tier, ) - turn = await self._codex._client.turn_start( - self.id, - wire_input, - params=params, - ) + 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 diff --git a/sdk/python/src/openai_codex/client.py b/sdk/python/src/openai_codex/client.py index ab5390ae52..b8513a5caa 100644 --- a/sdk/python/src/openai_codex/client.py +++ b/sdk/python/src/openai_codex/client.py @@ -14,7 +14,9 @@ from typing import Callable, Iterator, TypeVar from pydantic import BaseModel from ._goal import _GoalOperationState +from ._initialize_metadata import _split_user_agent from ._message_router import MessageRouter +from ._runtime_requirements import CheckoutCapabilities, require_runtime_version from ._version import __version__ as SDK_VERSION from .errors import CodexError, InvalidRequestError, TransportClosedError from .generated.notification_registry import NOTIFICATION_MODELS @@ -227,6 +229,8 @@ class CodexClient: self._stderr_lines: deque[str] = deque(maxlen=400) self._stderr_thread: threading.Thread | None = None self._reader_thread: threading.Thread | None = None + self._runtime_version: str | None = None + self._checkout_capabilities: CheckoutCapabilities | None = None def __enter__(self) -> "CodexClient": self.start() @@ -256,6 +260,11 @@ class CodexClient: env.update(self.config.env) _prepend_path_dirs(env, path_dirs) + if self.config.launch_args_override is None: + self._checkout_capabilities = CheckoutCapabilities( + command=tuple(args[:-2]), cwd=self.config.cwd, env=env.copy() + ) + self._proc = subprocess.Popen( args, stdin=subprocess.PIPE, @@ -272,6 +281,8 @@ class CodexClient: self._start_reader_thread() def close(self) -> None: + self._runtime_version = None + self._checkout_capabilities = None if self._proc is None: return proc = self._proc @@ -291,6 +302,7 @@ class CodexClient: self._reader_thread.join(timeout=0.5) def initialize(self) -> InitializeResponse: + self._runtime_version = None result = self.request( "initialize", { @@ -305,6 +317,10 @@ class CodexClient: }, response_model=InitializeResponse, ) + version = result.serverInfo.version if result.serverInfo is not None else None + if not version or not version.strip(): + _, version = _split_user_agent(result.userAgent or "") + self._runtime_version = version.split()[0] if version and version.strip() else None self.notify("initialized", None) return result @@ -315,6 +331,35 @@ class CodexClient: *, response_model: type[ModelT], ) -> ModelT: + runtime_fields = { + "turn/start": ("turnTrigger", "serviceTierForTurn"), + "thread/resume": ("excludeTurns",), + "thread/fork": ("excludeTurns",), + } + supplied_fields = [ + field + for field in runtime_fields.get(method, ()) + if (params or {}).get(field) is not None + ] + if supplied_fields: + try: + if self._runtime_version == "0.0.0": + if self._checkout_capabilities is None: + raise ValueError( + "Cannot verify an unversioned CLI with a custom launch command" + ) + supported = self._checkout_capabilities.fields[method] + if missing := set(supplied_fields) - supported: + raise ValueError( + f"The checkout does not support {', '.join(sorted(missing))}" + ) + else: + require_runtime_version(self._runtime_version) + except ValueError as exc: + raise CodexError( + f"{method} with {', '.join(supplied_fields)}: {exc}. " + "Configure CodexConfig.codex_bin with a supported CLI." + ) from exc result = self._request_raw(method, params) if not isinstance(result, dict): raise CodexError(f"{method} response must be a JSON object") diff --git a/sdk/python/tests/installed_sdk_smoke.py b/sdk/python/tests/installed_sdk_smoke.py index 352a047a10..cf662bd5b7 100644 --- a/sdk/python/tests/installed_sdk_smoke.py +++ b/sdk/python/tests/installed_sdk_smoke.py @@ -21,7 +21,12 @@ def main() -> None: harness.responses.enqueue_assistant_message("Installed SDK works") config = replace(harness.app_server_config(), codex_bin=None) with Codex(config=config) as codex: - result = codex.thread_start().run("Check the installed SDK") + thread = codex.thread_start() + result = thread.run( + "Check the installed SDK", turn_service_tier="default", source="automation" + ) + codex.thread_resume(thread.id, include_turns=False) + codex.thread_fork(thread.id, include_turns=True) assert result.final_response == "Installed SDK works" assert harness.responses.single_request().message_input_texts("user")[-1:] == [ "Check the installed SDK" diff --git a/sdk/python/tests/test_app_server_run.py b/sdk/python/tests/test_app_server_run.py index 4e453bf66e..bda8a68dc7 100644 --- a/sdk/python/tests/test_app_server_run.py +++ b/sdk/python/tests/test_app_server_run.py @@ -52,6 +52,20 @@ def test_sync_thread_run_uses_mock_responses( } +def test_checkout_supports_new_options_and_history_selection(tmp_path) -> None: + with AppServerHarness(tmp_path) as harness: + harness.responses.enqueue_assistant_message("Options supported") + with Codex(config=harness.app_server_config()) as codex: + thread = codex.thread_start() + result = thread.run("hello", turn_service_tier="default", source="automation") + resumed = codex.thread_resume(thread.id, include_turns=False) + forked = codex.thread_fork(thread.id, include_turns=True) + assert result.final_response == "Options supported" + assert resumed.id == thread.id + assert forked.id != thread.id + assert harness.responses.single_request().message_input_texts("user")[-1:] == ["hello"] + + def test_run_params_and_usage_cross_app_server_boundary(tmp_path) -> None: """Thread.run should pass overrides and collect app-server token usage.""" with AppServerHarness(tmp_path) as harness: diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py index 80a5da37ca..c89f75d1a1 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -637,7 +637,7 @@ def test_runtime_setup_reads_independent_runtime_pin_and_release_tags() -> None: } == { "package_name": "openai-codex-cli-bin", "sdk_template_version": "0.0.0-dev", - "runtime_pin": "0.147.0", + "runtime_pin": "0.153.4", "normalized_release_version": "0.116.0a1", "normalized_alpha_hotfix_version": "0.116.0a1.post2", "release_tag": "rust-v0.116.0-alpha.1", @@ -955,17 +955,24 @@ def test_stage_sdk_release_packages_reviewed_artifacts( assert not any((staged / "src" / "openai_codex").glob("bin/**")) +@pytest.mark.parametrize("source_runtime", ["0.147.0", "0.153.0"]) @pytest.mark.parametrize("sdk_version", ["0.154.0", "0.2.0b1"]) def test_built_sdk_uses_explicit_release_versions( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, sdk_release_source: Path, sdk_version: str + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + sdk_release_source: Path, + sdk_version: str, + source_runtime: str, ) -> None: script = _load_update_script_module() monkeypatch.setattr(script, "sdk_root", lambda: sdk_release_source) - source_project = (sdk_release_source / "pyproject.toml").read_bytes() + project_path = sdk_release_source / "pyproject.toml" + project_path.write_text(project_path.read_text().replace("==0.153.0", f"=={source_runtime}")) + source_project = project_path.read_bytes() expected_dependencies = { *tomllib.loads(source_project.decode())["project"]["dependencies"], "openai-codex-cli-bin==0.154.0", - } - {"openai-codex-cli-bin==0.153.0"} + } - {f"openai-codex-cli-bin=={source_runtime}"} reviewed_files = { path: (sdk_release_source / path).read_bytes() for path in ( @@ -1058,11 +1065,52 @@ def test_sdk_release_matches_stable_runtime( "runtime_version": "0.153.0", "sdk_dependencies": [ "pydantic>=2.12", + "packaging>=26.2", "openai-codex-cli-bin==0.153.0", ], } +@pytest.mark.parametrize("runtime_version", ["0.149.0", "0.151.0a1", "0.0.0", "unknown"]) +def test_sdk_release_rejects_unsupported_runtime_even_for_beta( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + sdk_release_source: Path, + runtime_version: str, +) -> None: + script = _load_update_script_module() + monkeypatch.setattr(script, "sdk_root", lambda: sdk_release_source) + project = sdk_release_source / "pyproject.toml" + project.write_text(project.read_text().replace("==0.153.0", f"=={runtime_version}")) + + with pytest.raises(RuntimeError, match=r"Cannot package.*Codex CLI 0\.151\.0 or newer"): + script.stage_python_sdk_package(tmp_path / "sdk-stage", "0.1.0b1") + + +def test_sdk_runtime_override_is_checked_after_stamping( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, sdk_release_source: Path +) -> None: + script = _load_update_script_module() + monkeypatch.setattr(script, "sdk_root", lambda: sdk_release_source) + with pytest.raises(RuntimeError, match=r"Cannot package.*Codex CLI 0\.151\.0 or newer"): + script.stage_python_sdk_package(tmp_path / "sdk-stage", "0.1.0b1", "0.149.0") + + +def test_sdk_beta_can_use_a_supported_runtime( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, sdk_release_source: Path +) -> None: + script = _load_update_script_module() + monkeypatch.setattr(script, "sdk_root", lambda: sdk_release_source) + + staged = script.stage_python_sdk_package(tmp_path / "sdk-stage", "0.1.0b1") + + project = tomllib.loads((staged / "pyproject.toml").read_text())["project"] + assert (project["version"], project["dependencies"]) == ( + "0.1.0b1", + ["pydantic>=2.12", "packaging>=26.2", "openai-codex-cli-bin==0.153.0"], + ) + + def test_stage_runtime_stages_package_without_type_generation(tmp_path: Path) -> None: script = _load_update_script_module() package_archive = _write_fake_codex_package_archive(tmp_path, script) @@ -1255,7 +1303,7 @@ def test_sdk_beta_can_pin_an_independent_runtime(tmp_path: Path) -> None: project = tomllib.loads((staged / "pyproject.toml").read_text())["project"] assert (project["version"], project["dependencies"]) == ( "0.1.0b1", - ["pydantic>=2.12", "openai-codex-cli-bin==0.153.0"], + ["pydantic>=2.12", "packaging>=26.2", "openai-codex-cli-bin==0.153.0"], ) @@ -1297,6 +1345,7 @@ def test_sdk_release_matches_runtime( "runtime_version": package_version, "sdk_dependencies": [ "pydantic>=2.12", + "packaging>=26.2", f"openai-codex-cli-bin=={package_version}", ], } diff --git a/sdk/python/tests/test_client_rpc_methods.py b/sdk/python/tests/test_client_rpc_methods.py index e055e551c4..3f9035ee30 100644 --- a/sdk/python/tests/test_client_rpc_methods.py +++ b/sdk/python/tests/test_client_rpc_methods.py @@ -1,11 +1,14 @@ from __future__ import annotations +import json from pathlib import Path from typing import get_type_hints import pytest +from openai_codex._runtime_requirements import CheckoutCapabilities from openai_codex.client import CodexClient, _params_dict +from openai_codex.errors import CodexError from openai_codex.generated.notification_registry import notification_turn_id from openai_codex.generated.v2_all import ( AbsolutePathBuf, @@ -30,7 +33,7 @@ from openai_codex.generated.v2_all import ( TurnStartParams, WarningNotification, ) -from openai_codex.models import Notification, UnknownNotification +from openai_codex.models import InitializeResponse, JsonObject, Notification, UnknownNotification from openai_codex.types import ThreadSource ROOT = Path(__file__).resolve().parents[1] @@ -61,6 +64,160 @@ def test_approval_review_paths_preserve_existing_wrappers(model, fields) -> None assert isinstance(action.cwd, AbsolutePathBuf) +def _initialized_client( + monkeypatch: pytest.MonkeyPatch, metadata: JsonObject +) -> tuple[CodexClient, list[tuple[str, JsonObject | None]]]: + client = CodexClient() + requests: list[tuple[str, JsonObject | None]] = [] + + def request_raw(method: str, params: JsonObject | None) -> JsonObject: + requests.append((method, params)) + return metadata if method == "initialize" else {} + + monkeypatch.setattr(client, "_request_raw", request_raw) + monkeypatch.setattr(client, "notify", lambda *_args: None) + client.initialize() + requests.clear() + return client, requests + + +@pytest.mark.parametrize( + ("method", "params"), + [ + ("turn/start", {"input": [], "turnTrigger": "automation"}), + ("turn/start", {"input": [], "serviceTierForTurn": "default"}), + ("thread/resume", {"threadId": "thread-1", "excludeTurns": False}), + ("thread/fork", {"threadId": "thread-1", "excludeTurns": True}), + ], +) +@pytest.mark.parametrize("version", ["0.147.0", "0.149.0", "0.151.0-alpha.6", "unknown", ""]) +def test_new_options_reject_unsupported_runtime_before_sending( + monkeypatch: pytest.MonkeyPatch, method: str, params: JsonObject, version: str +) -> None: + client, requests = _initialized_client(monkeypatch, {"userAgent": f"codex-cli/{version}"}) + + with pytest.raises(CodexError, match=r"Codex CLI 0\.151\.0 or newer"): + client.request(method, params, response_model=InitializeResponse) + + assert requests == [] + + +@pytest.mark.parametrize( + "metadata", + [ + {"userAgent": "codex-cli/0.151.0 (Linux)"}, + {"userAgent": "codex-cli 0.153.0"}, + {"userAgent": "codex-cli/0.154.0-alpha.1"}, + {"userAgent": "codex-cli/0.154.0-alpha.1.2"}, + {"userAgent": "codex-cli/0.151.0.post1"}, + {"userAgent": "unknown", "serverInfo": {"name": "codex", "version": "0.153.0"}}, + ], +) +def test_new_options_accept_supported_runtime_metadata( + monkeypatch: pytest.MonkeyPatch, metadata: JsonObject +) -> None: + client, requests = _initialized_client(monkeypatch, metadata) + params = {"input": [], "turnTrigger": "automation"} + + client.request("turn/start", params, response_model=InitializeResponse) + + assert requests == [("turn/start", params)] + + +@pytest.mark.parametrize("supports_options", [True, False]) +def test_unversioned_checkout_probes_and_caches_its_own_schema( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, supports_options: bool +) -> None: + client, requests = _initialized_client(monkeypatch, {"userAgent": "codex-cli/0.0.0"}) + command = ("checkout-codex", "--config", "key=value", "app-server") + client._checkout_capabilities = CheckoutCapabilities( + command, str(tmp_path), {"CUSTOM": "value"} + ) + probes = [] + + def generate_schema(args, **kwargs): + probes.append((args[:-1], kwargs)) + output = Path(args[-1]) / "v2" + output.mkdir() + for name, fields in ( + ("TurnStartParams", ["turnTrigger", "serviceTierForTurn"]), + ("ThreadResumeParams", ["excludeTurns"]), + ("ThreadForkParams", ["excludeTurns"]), + ): + (output / f"{name}.json").write_text( + json.dumps( + {"properties": {field: {} for field in fields} if supports_options else {}} + ) + ) + + monkeypatch.setattr("openai_codex._runtime_requirements.subprocess.run", generate_schema) + for method, params in ( + ("turn/start", {"input": [], "turnTrigger": "automation"}), + ("thread/resume", {"threadId": "thread-1", "excludeTurns": False}), + ): + if supports_options: + client.request(method, params, response_model=InitializeResponse) + else: + with pytest.raises(CodexError, match="checkout does not support"): + client.request(method, params, response_model=InitializeResponse) + assert len(requests) == (2 if supports_options else 0) + assert probes == [ + ( + [*command, "generate-json-schema", "--experimental", "--out"], + { + "cwd": str(tmp_path), + "env": {"CUSTOM": "value"}, + "capture_output": True, + "check": True, + "timeout": 30, + }, + ) + ] + client.close() + assert client._checkout_capabilities is None + + +def test_unversioned_custom_launch_requires_verifiable_capabilities( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client, requests = _initialized_client(monkeypatch, {"userAgent": "codex-cli/0.0.0"}) + with pytest.raises(CodexError, match="Cannot verify an unversioned CLI"): + client.request( + "turn/start", {"turnTrigger": "automation"}, response_model=InitializeResponse + ) + assert requests == [] + + +@pytest.mark.parametrize("metadata", [{}, {"userAgent": "codex-cli/0.147.0"}]) +def test_ordinary_requests_keep_working_on_old_or_unknown_runtime( + monkeypatch: pytest.MonkeyPatch, metadata: JsonObject +) -> None: + client, requests = _initialized_client(monkeypatch, metadata) + params = {"input": [{"type": "text", "text": "Hello"}], "serviceTier": "default"} + + client.request("turn/start", params, response_model=InitializeResponse) + client.request("thread/resume", {"threadId": "thread-1"}, response_model=InitializeResponse) + client.request("thread/fork", {"threadId": "thread-1"}, response_model=InitializeResponse) + + assert requests == [ + ("turn/start", params), + ("thread/resume", {"threadId": "thread-1"}), + ("thread/fork", {"threadId": "thread-1"}), + ] + + +def test_new_options_require_fresh_initialize_metadata_after_close( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client, requests = _initialized_client(monkeypatch, {"userAgent": "codex-cli/0.153.0"}) + client.close() + + with pytest.raises(CodexError, match="reported version is 'unknown'"): + client.request("thread/resume", {"excludeTurns": True}, response_model=InitializeResponse) + + assert requests == [] + + def test_generated_params_models_are_snake_case_and_dump_by_alias() -> None: params = ThreadListParams(search_term="needle", limit=5) diff --git a/sdk/python/tests/test_public_api_runtime_behavior.py b/sdk/python/tests/test_public_api_runtime_behavior.py index 4cee695fee..a948a86178 100644 --- a/sdk/python/tests/test_public_api_runtime_behavior.py +++ b/sdk/python/tests/test_public_api_runtime_behavior.py @@ -2,7 +2,9 @@ from __future__ import annotations import asyncio from pathlib import Path +from types import SimpleNamespace from typing import Any +from unittest.mock import AsyncMock, Mock import pytest @@ -13,8 +15,9 @@ from openai_codex.api import ( Codex, Sandbox, ) -from openai_codex.generated.v2_all import TurnStartParams -from openai_codex.models import InitializeResponse +from openai_codex.client import _params_dict +from openai_codex.generated.v2_all import TurnCompletedNotification, TurnStartParams +from openai_codex.models import InitializeResponse, Notification ROOT = Path(__file__).resolve().parents[1] @@ -128,6 +131,97 @@ def test_async_codex_initializes_only_once_under_concurrency() -> None: asyncio.run(scenario()) +@pytest.mark.parametrize("api_type", [Codex, AsyncCodex]) +@pytest.mark.parametrize( + ("options", "expected"), + [ + ({}, {}), + ({"include_turns": None}, {}), + ({"include_turns": True}, {"excludeTurns": False}), + ({"include_turns": False}, {"excludeTurns": True}), + ], +) +def test_include_turns_preserves_omission_and_inverts_explicit_values( + api_type, options, expected +) -> None: + async def scenario() -> None: + async_api = api_type is AsyncCodex + rpc = AsyncMock if async_api else Mock + thread_response = SimpleNamespace(thread=SimpleNamespace(id="thread-2")) + client = SimpleNamespace( + thread_resume=rpc(return_value=thread_response), + thread_fork=rpc(return_value=thread_response), + ) + codex = api_type.__new__(api_type) + codex._client = client + codex._initialized = True + + for method in ("thread_resume", "thread_fork"): + thread = getattr(codex, method)("thread-1", **options) + if async_api: + thread = await thread + assert thread.id == "thread-2" + assert _params_dict(getattr(client, method).call_args.args[1]) == { + "threadId": "thread-1", + **expected, + } + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("api_type", [Codex, AsyncCodex]) +@pytest.mark.parametrize("method", ["run", "turn"]) +def test_turn_inputs_and_options_reach_the_client(api_type, method) -> None: + """Turn options reach the client through both sync and async entry points.""" + + async def scenario() -> None: + async_api = api_type is AsyncCodex + rpc = AsyncMock if async_api else Mock + completed = Notification( + method="turn/completed", + payload=TurnCompletedNotification.model_validate( + { + "threadId": "thread-1", + "turn": {"id": "turn-1", "items": [], "status": "completed"}, + } + ), + ) + client = SimpleNamespace( + turn_start=rpc(return_value=SimpleNamespace(turn=SimpleNamespace(id="turn-1"))), + register_turn_notifications=Mock(), + unregister_turn_notifications=Mock(), + next_turn_notification=rpc(return_value=completed), + ) + codex = api_type.__new__(api_type) + codex._client = client + codex._initialized = True + thread = ( + public_api_module.AsyncThread(codex, "thread-1") + if async_api + else public_api_module.Thread(client, "thread-1") + ) + input = "Continue." + turn = getattr(thread, method)( + input, + service_tier="priority", + turn_service_tier="default", + source="automation", + ) + if async_api: + turn = await turn + assert turn.id == "turn-1" + expected_input = [{"type": "text", "text": "Continue.", "text_elements": []}] + assert _params_dict(client.turn_start.call_args.kwargs["params"]) == { + "threadId": "thread-1", + "input": expected_input, + "serviceTier": "priority", + "serviceTierForTurn": "default", + "turnTrigger": "automation", + } + + asyncio.run(scenario()) + + def _approval_mode_turn_params(approval_mode: ApprovalMode) -> TurnStartParams: """Build real generated turn params from one public approval mode.""" approval_policy, approvals_reviewer = public_api_module._approval_mode_settings(approval_mode) diff --git a/sdk/python/tests/test_public_api_signatures.py b/sdk/python/tests/test_public_api_signatures.py index 9a67073b52..f9349196da 100644 --- a/sdk/python/tests/test_public_api_signatures.py +++ b/sdk/python/tests/test_public_api_signatures.py @@ -5,8 +5,6 @@ import inspect from pathlib import Path from typing import Any -import tomllib - import openai_codex import openai_codex.types as public_types from openai_codex import ( @@ -24,6 +22,11 @@ from openai_codex import ( from openai_codex._initialize_metadata import validate_initialize_metadata from openai_codex.types import InitializeResponse +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib + EXPECTED_ROOT_EXPORTS = [ "__version__", "CodexConfig", @@ -176,13 +179,15 @@ def test_turn_input_methods_accept_string_shortcut() -> None: Thread.turn, AsyncThread.run, AsyncThread.turn, - TurnHandle.steer, - AsyncTurnHandle.steer, ] assert {fn: inspect.signature(fn).parameters["input"].annotation for fn in funcs} == ( dict.fromkeys(funcs, "RunInput") ) + assert { + fn: inspect.signature(fn).parameters["input"].annotation + for fn in (TurnHandle.steer, AsyncTurnHandle.steer) + } == dict.fromkeys((TurnHandle.steer, AsyncTurnHandle.steer), "RunInput") def test_root_exports_approval_mode() -> None: @@ -351,6 +356,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None: "config", "cwd", "developer_instructions", + "include_turns", "model", "model_provider", "personality", @@ -364,6 +370,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None: "cwd", "developer_instructions", "ephemeral", + "include_turns", "model", "model_provider", "sandbox", @@ -379,7 +386,9 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None: "personality", "sandbox", "service_tier", + "source", "summary", + "turn_service_tier", ], Thread.run: [ "approval_mode", @@ -390,7 +399,9 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None: "personality", "sandbox", "service_tier", + "source", "summary", + "turn_service_tier", ], AsyncCodex.thread_start: [ "approval_mode", @@ -427,6 +438,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None: "config", "cwd", "developer_instructions", + "include_turns", "model", "model_provider", "personality", @@ -440,6 +452,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None: "cwd", "developer_instructions", "ephemeral", + "include_turns", "model", "model_provider", "sandbox", @@ -455,7 +468,9 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None: "personality", "sandbox", "service_tier", + "source", "summary", + "turn_service_tier", ], AsyncThread.run: [ "approval_mode", @@ -466,7 +481,9 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None: "personality", "sandbox", "service_tier", + "source", "summary", + "turn_service_tier", ], } diff --git a/sdk/python/uv.lock b/sdk/python/uv.lock index 6f6f867ede..f2727dc162 100644 --- a/sdk/python/uv.lock +++ b/sdk/python/uv.lock @@ -7,7 +7,7 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P7D" [options.exclude-newer-package] -openai-codex-cli-bin = "2026-08-19T00:00:00Z" +openai-codex-cli-bin = "2026-09-09T06:06:45Z" [[package]] name = "annotated-types" @@ -286,6 +286,7 @@ version = "0.0.0.dev0" source = { editable = "." } dependencies = [ { name = "openai-codex-cli-bin" }, + { name = "packaging" }, { name = "pydantic" }, ] @@ -306,7 +307,8 @@ test = [ [package.metadata] requires-dist = [ - { name = "openai-codex-cli-bin", specifier = "==0.147.0" }, + { name = "openai-codex-cli-bin", specifier = "==0.153.4" }, + { name = "packaging", specifier = ">=26.2" }, { name = "pydantic", specifier = ">=2.12" }, ] @@ -325,17 +327,17 @@ test = [ [[package]] name = "openai-codex-cli-bin" -version = "0.147.0" +version = "0.153.4" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/85/3302e5265f35941a24f614573e1a27e6f218d7fa71e4dd3b1353d1726c5a/openai_codex_cli_bin-0.147.0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:19c3a72a0eac6706bb5023088ab7eb31d8cfc06bf7860250b5c5b90f4505489b", size = 116948927, upload-time = "2026-08-18T06:05:53.479Z" }, - { url = "https://files.pythonhosted.org/packages/b8/0c/6474ef2f854ecf217550d06667a8ac988b5656c448b680e5b5ece5ae5152/openai_codex_cli_bin-0.147.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b851943fffc48aa7c5c130b6a34be09964833d2785546eda96d749427c6e24f2", size = 107573930, upload-time = "2026-08-18T06:05:57.701Z" }, - { url = "https://files.pythonhosted.org/packages/59/4b/0efce457a301271301b9229b36b3f53ca94d5f4db1d605c86e0da9833565/openai_codex_cli_bin-0.147.0-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:aab3e27ce07cd7bc7a78708efa40375b28e89f586851c495ef55aa6cb6806d11", size = 111220520, upload-time = "2026-08-18T06:06:01.636Z" }, - { url = "https://files.pythonhosted.org/packages/f2/e4/5e4fb0f61ca90c2bb42a8823e08fe05957c54591d1e161c56a93d27d565d/openai_codex_cli_bin-0.147.0-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:cb3907d633cda87c4b68b47ff4979e6054c02a08c41b15aa2e9a689558a61074", size = 119921665, upload-time = "2026-08-18T06:06:05.666Z" }, - { url = "https://files.pythonhosted.org/packages/f9/83/a52e32fc63bde10996e38cb4e1175c402c578ee7c7ca79b96abee5219127/openai_codex_cli_bin-0.147.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:ea0bfdee98164fc7d589eea5623aff06c8f8f2ba3f9bc550297429db45ea68f7", size = 111220518, upload-time = "2026-08-18T06:06:09.538Z" }, - { url = "https://files.pythonhosted.org/packages/e9/b6/c159da0a1ec136fde6d2742ec05347e311f000dfe19024ed7d708cf60aa5/openai_codex_cli_bin-0.147.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:a9be23f46326494b7ebf4bd98cbe985542f5ad076355f3b0fb636a984df56816", size = 119921665, upload-time = "2026-08-18T06:06:15.061Z" }, - { url = "https://files.pythonhosted.org/packages/54/38/1bb47e08b9c523b673358e4f5597fc99699e855ec63bd02fdc157f1679c9/openai_codex_cli_bin-0.147.0-py3-none-win_amd64.whl", hash = "sha256:1a403ff803ae27e078189a0fd24687f32d43c46f152e50cdbe3eddc9e302f697", size = 127586208, upload-time = "2026-08-18T06:06:19.395Z" }, - { url = "https://files.pythonhosted.org/packages/d2/1e/a44a8da140ef080aebac6da6517e8fe6ac1aad49436c8b1f9b87e735b813/openai_codex_cli_bin-0.147.0-py3-none-win_arm64.whl", hash = "sha256:be0c8b9e34b067151d0964a24e9f4b8b48ea516448a08ef2636f357ebdc877b7", size = 117863410, upload-time = "2026-08-18T06:06:23.554Z" }, + { url = "https://files.pythonhosted.org/packages/4f/16/b28109968c0912956a52f41eb022605c6eaba2d4793b5e9f4b9479c954e2/openai_codex_cli_bin-0.153.4-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:637bdf17d6387e9ae12e1094af6aa22dabc2ba55c100ee0310178e79427b2e69", size = 121719355, upload-time = "2026-09-09T06:06:11.682Z" }, + { url = "https://files.pythonhosted.org/packages/68/69/7dd4cb9135ac893985c92ecc03dc3df8408917d469fb3df6650af2fde328/openai_codex_cli_bin-0.153.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08c80523fd3674e7d6f36bccc69557f9bac257cbd7ad937309dad0457e1a7845", size = 111925786, upload-time = "2026-09-09T06:06:16.767Z" }, + { url = "https://files.pythonhosted.org/packages/16/e7/57e5dce0b13c0643cd20f308d9e727a44d542bca38f958f038204f959687/openai_codex_cli_bin-0.153.4-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:f92f0aff384258f5bac3519cd9ad2639be08239004452d0e4b00725e5fe62bba", size = 117599105, upload-time = "2026-09-09T06:06:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b3/938bfc0cfdaa06881d904b1c9a3151c1252e278d00fe1afcbb954cee65c4/openai_codex_cli_bin-0.153.4-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:584ecdbd81b01b3002ecbdddfa009dc8d7bfdbed9c208a0fa8c131a2e337bba6", size = 127000985, upload-time = "2026-09-09T06:06:26.535Z" }, + { url = "https://files.pythonhosted.org/packages/59/e8/9743befc32fa292d3682075c15e056044652dae1bab9581a9e9ee0cd352b/openai_codex_cli_bin-0.153.4-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:fef1cc54d7408da08f9bfa6179c7fad4231591c29f76eb339d0a59e540b3e718", size = 117599103, upload-time = "2026-09-09T06:06:30.967Z" }, + { url = "https://files.pythonhosted.org/packages/06/d2/ded14722031d8dda9c796ab8d39f8a29cbae27d28bf2b1a0b12c2e429c6f/openai_codex_cli_bin-0.153.4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0f97697f39fd9fd32fbf9caa29df8ff0a6cfe33755f4f007bc97ee4cb88a7743", size = 127000983, upload-time = "2026-09-09T06:06:35.565Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b4/afec4ffe8a3e3f2b5fa7ef6d892b830f63ee8aaa5c222b23652329bc3e4e/openai_codex_cli_bin-0.153.4-py3-none-win_amd64.whl", hash = "sha256:9b52da6531617a363a165c246aded59a1a61e82fac623ab97e095cc37a5038c5", size = 136934929, upload-time = "2026-09-09T06:06:40.046Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d3/e8de747460d8d2934dce84867ac8227d4d2d4b31f111e881076f97525a72/openai_codex_cli_bin-0.153.4-py3-none-win_arm64.whl", hash = "sha256:23240c7041a8ef5f22297c3fead21da0143dcec2a186d9b64f386f52b396da47", size = 126478600, upload-time = "2026-09-09T06:06:44.431Z" }, ] [[package]]