mirror of
https://github.com/openai/codex.git
synced 2026-09-16 12:13:30 +00:00
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:
64
sdk/python/src/openai_codex/_runtime_requirements.py
Normal file
64
sdk/python/src/openai_codex/_runtime_requirements.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user