Expose Python SDK history selection and per-turn options (#44084)

## Why

Python callers need control over response history loading and a way to override the service tier for one turn. These options also need runtime compatibility checks to prevent older CLIs from silently ignoring them.

## What changed

- Add `include_turns` to sync and async thread resume/fork methods. Omission preserves server defaults; `False` skips response history loading without changing model context.
- Add `turn_service_tier` and `source` to sync and async `run()` and `turn()`, and generate both methods together to keep their options aligned.
- Require CLI `0.151.0` or newer when sending the new options, with lazy schema checks for unversioned local builds.
- Pin the bundled runtime dependency to `0.153.4` and reject unsupported runtime versions during SDK packaging.

## Testing

Add coverage for option forwarding, history flag omission and inversion, runtime version checks, cached schema probing, and packaging compatibility. Extend app-server and installed SDK smoke tests to exercise the new options.

GitOrigin-RevId: 4bcc9cff687b0651e67852e7c080df7fadac6d76
This commit is contained in:
Ahmed Ibrahim
2026-09-09 06:27:42 +00:00
committed by copyberry
parent 7b9e7d99bd
commit 8afccec87a
14 changed files with 667 additions and 108 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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")