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:40:56 +00:00
committed by copyberry
parent 7b9e7d99bd
commit 8afccec87a
14 changed files with 667 additions and 108 deletions
+6 -1
View File
@@ -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"
+14
View File
@@ -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:
@@ -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}",
],
}
+158 -1
View File
@@ -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)
@@ -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)
+21 -4
View File
@@ -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",
],
}