From d80a43263fb52a089ba2e725bdd7599f9096d0e3 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sat, 9 May 2026 12:03:09 +0300 Subject: [PATCH] Default Python SDK approval policy to never Co-authored-by: Codex --- sdk/python/docs/api-reference.md | 1 + .../12_turn_params_kitchen_sink/async.py | 2 +- .../12_turn_params_kitchen_sink/sync.py | 2 +- .../13_model_select_and_turn_params/async.py | 2 +- .../13_model_select_and_turn_params/sync.py | 2 +- sdk/python/notebooks/sdk_walkthrough.ipynb | 4 +- sdk/python/scripts/update_sdk_artifacts.py | 52 ++++++- sdk/python/src/openai_codex/api.py | 26 ++-- .../src/openai_codex/generated/v2_all.py | 12 +- .../tests/test_public_api_runtime_behavior.py | 135 ++++++++++++++++++ 10 files changed, 220 insertions(+), 18 deletions(-) diff --git a/sdk/python/docs/api-reference.md b/sdk/python/docs/api-reference.md index 99eb8ebaa9..5437ac1de7 100644 --- a/sdk/python/docs/api-reference.md +++ b/sdk/python/docs/api-reference.md @@ -3,6 +3,7 @@ Public surface of `openai_codex` for app-server v2. This SDK surface is experimental. Turn streams are routed by turn ID so one client can consume multiple active turns concurrently. +Thread and turn starts currently send `AskForApproval.never` while SDK approval request handling is still pending. ## Package Entry diff --git a/sdk/python/examples/12_turn_params_kitchen_sink/async.py b/sdk/python/examples/12_turn_params_kitchen_sink/async.py index b921891f00..7d38a854f3 100644 --- a/sdk/python/examples/12_turn_params_kitchen_sink/async.py +++ b/sdk/python/examples/12_turn_params_kitchen_sink/async.py @@ -46,7 +46,7 @@ PROMPT = ( "Analyze a safe rollout plan for enabling a feature flag in production. " "Return JSON matching the requested schema." ) -APPROVAL_POLICY = AskForApproval.model_validate("never") +APPROVAL_POLICY = AskForApproval.never async def main() -> None: diff --git a/sdk/python/examples/12_turn_params_kitchen_sink/sync.py b/sdk/python/examples/12_turn_params_kitchen_sink/sync.py index 11d31da64d..e5b5fa0b18 100644 --- a/sdk/python/examples/12_turn_params_kitchen_sink/sync.py +++ b/sdk/python/examples/12_turn_params_kitchen_sink/sync.py @@ -44,7 +44,7 @@ PROMPT = ( "Analyze a safe rollout plan for enabling a feature flag in production. " "Return JSON matching the requested schema." ) -APPROVAL_POLICY = AskForApproval.model_validate("never") +APPROVAL_POLICY = AskForApproval.never with Codex(config=runtime_config()) as codex: thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) diff --git a/sdk/python/examples/13_model_select_and_turn_params/async.py b/sdk/python/examples/13_model_select_and_turn_params/async.py index 2a661856d6..2a117bb6cc 100644 --- a/sdk/python/examples/13_model_select_and_turn_params/async.py +++ b/sdk/python/examples/13_model_select_and_turn_params/async.py @@ -75,7 +75,7 @@ SANDBOX_POLICY = SandboxPolicy.model_validate( "access": {"type": "fullAccess"}, } ) -APPROVAL_POLICY = AskForApproval.model_validate("never") +APPROVAL_POLICY = AskForApproval.never async def main() -> None: diff --git a/sdk/python/examples/13_model_select_and_turn_params/sync.py b/sdk/python/examples/13_model_select_and_turn_params/sync.py index 6b5904bddc..f4a37debf1 100644 --- a/sdk/python/examples/13_model_select_and_turn_params/sync.py +++ b/sdk/python/examples/13_model_select_and_turn_params/sync.py @@ -73,7 +73,7 @@ SANDBOX_POLICY = SandboxPolicy.model_validate( "access": {"type": "fullAccess"}, } ) -APPROVAL_POLICY = AskForApproval.model_validate("never") +APPROVAL_POLICY = AskForApproval.never with Codex(config=runtime_config()) as codex: diff --git a/sdk/python/notebooks/sdk_walkthrough.ipynb b/sdk/python/notebooks/sdk_walkthrough.ipynb index db83b62773..0c70ba4e7b 100644 --- a/sdk/python/notebooks/sdk_walkthrough.ipynb +++ b/sdk/python/notebooks/sdk_walkthrough.ipynb @@ -270,7 +270,7 @@ " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", " turn = thread.turn(\n", " TextInput('Propose a safe production feature-flag rollout. Return JSON matching the schema.'),\n", - " approval_policy=AskForApproval.model_validate('never'),\n", + " approval_policy=AskForApproval.never,\n", " cwd=str(Path.cwd()),\n", " effort=ReasoningEffort.medium,\n", " model='gpt-5.4',\n", @@ -361,7 +361,7 @@ "\n", " second = thread.turn(\n", " TextInput('Return JSON for a safe feature-flag rollout plan.'),\n", - " approval_policy=AskForApproval.model_validate('never'),\n", + " approval_policy=AskForApproval.never,\n", " cwd=str(Path.cwd()),\n", " effort=selected_effort,\n", " model=selected_model.model,\n", diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index 3c646207b5..bfbeee4168 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -615,6 +615,50 @@ def generate_v2_all(schema_dir: Path) -> None: cwd=sdk_root(), ) _normalize_generated_timestamps(out_path) + _add_ask_for_approval_aliases(out_path) + + +def _add_ask_for_approval_aliases(out_path: Path) -> None: + """Add ergonomic approval policy constants to the generated RootModel class.""" + source = out_path.read_text() + source = source.replace( + "from typing import Annotated, Any, Literal", + "from typing import Annotated, Any, ClassVar, Literal", + ) + if "AskForApproval.never =" in source: + out_path.write_text(source) + return + + needle = """class AskForApproval(RootModel[AskForApprovalValue | GranularAskForApproval]): + model_config = ConfigDict( + populate_by_name=True, + ) + root: AskForApprovalValue | GranularAskForApproval + + +""" + replacement = """class AskForApproval(RootModel[AskForApprovalValue | GranularAskForApproval]): + model_config = ConfigDict( + populate_by_name=True, + ) + root: AskForApprovalValue | GranularAskForApproval + untrusted: ClassVar[AskForApproval] + on_failure: ClassVar[AskForApproval] + on_request: ClassVar[AskForApproval] + never: ClassVar[AskForApproval] + + +AskForApproval.untrusted = AskForApproval(root=AskForApprovalValue.untrusted) +AskForApproval.on_failure = AskForApproval(root=AskForApprovalValue.on_failure) +AskForApproval.on_request = AskForApproval(root=AskForApprovalValue.on_request) +AskForApproval.never = AskForApproval(root=AskForApprovalValue.never) + + +""" + updated, count = source.replace(needle, replacement, 1), source.count(needle) + if count != 1: + raise RuntimeError("Could not add AskForApproval aliases to generated types") + out_path.write_text(updated) def _notification_specs(schema_dir: Path) -> list[tuple[str, str]]: @@ -884,7 +928,13 @@ def _kw_signature_lines(fields: list[PublicFieldSpec]) -> list[str]: def _model_arg_lines( fields: list[PublicFieldSpec], *, indent: str = " " ) -> list[str]: - return [f"{indent}{field.wire_name}={field.py_name}," for field in fields] + lines: list[str] = [] + for field in fields: + value = field.py_name + if field.py_name == "approval_policy": + value = "_approval_policy_never(approval_policy)" + lines.append(f"{indent}{field.wire_name}={value},") + return lines def _replace_generated_block(source: str, block_name: str, body: str) -> str: diff --git a/sdk/python/src/openai_codex/api.py b/sdk/python/src/openai_codex/api.py index 54ef491787..db948c2700 100644 --- a/sdk/python/src/openai_codex/api.py +++ b/sdk/python/src/openai_codex/api.py @@ -69,6 +69,12 @@ def _split_user_agent(user_agent: str) -> tuple[str | None, str | None]: return raw, None +def _approval_policy_never(_approval_policy: AskForApproval | None) -> AskForApproval: + # TODO: Handle approval requests in the SDK before honoring caller-supplied + # policies. + return AskForApproval.never + + class Codex: """Minimal typed SDK surface for app-server v2.""" @@ -157,7 +163,7 @@ class Codex: thread_source: ThreadSource | None = None, ) -> Thread: params = ThreadStartParams( - approval_policy=approval_policy, + approval_policy=_approval_policy_never(approval_policy), approvals_reviewer=approvals_reviewer, base_instructions=base_instructions, config=config, @@ -222,7 +228,7 @@ class Codex: ) -> Thread: params = ThreadResumeParams( thread_id=thread_id, - approval_policy=approval_policy, + approval_policy=_approval_policy_never(approval_policy), approvals_reviewer=approvals_reviewer, base_instructions=base_instructions, config=config, @@ -256,7 +262,7 @@ class Codex: ) -> Thread: params = ThreadForkParams( thread_id=thread_id, - approval_policy=approval_policy, + approval_policy=_approval_policy_never(approval_policy), approvals_reviewer=approvals_reviewer, base_instructions=base_instructions, config=config, @@ -359,7 +365,7 @@ class AsyncCodex: ) -> AsyncThread: await self._ensure_initialized() params = ThreadStartParams( - approval_policy=approval_policy, + approval_policy=_approval_policy_never(approval_policy), approvals_reviewer=approvals_reviewer, base_instructions=base_instructions, config=config, @@ -426,7 +432,7 @@ class AsyncCodex: await self._ensure_initialized() params = ThreadResumeParams( thread_id=thread_id, - approval_policy=approval_policy, + approval_policy=_approval_policy_never(approval_policy), approvals_reviewer=approvals_reviewer, base_instructions=base_instructions, config=config, @@ -461,7 +467,7 @@ class AsyncCodex: await self._ensure_initialized() params = ThreadForkParams( thread_id=thread_id, - approval_policy=approval_policy, + approval_policy=_approval_policy_never(approval_policy), approvals_reviewer=approvals_reviewer, base_instructions=base_instructions, config=config, @@ -515,7 +521,7 @@ class Thread: ) -> RunResult: turn = self.turn( _normalize_run_input(input), - approval_policy=approval_policy, + approval_policy=_approval_policy_never(approval_policy), approvals_reviewer=approvals_reviewer, cwd=cwd, effort=effort, @@ -552,7 +558,7 @@ class Thread: params = TurnStartParams( thread_id=self.id, input=wire_input, - approval_policy=approval_policy, + approval_policy=_approval_policy_never(approval_policy), approvals_reviewer=approvals_reviewer, cwd=cwd, effort=effort, @@ -600,7 +606,7 @@ class AsyncThread: ) -> RunResult: turn = await self.turn( _normalize_run_input(input), - approval_policy=approval_policy, + approval_policy=_approval_policy_never(approval_policy), approvals_reviewer=approvals_reviewer, cwd=cwd, effort=effort, @@ -638,7 +644,7 @@ class AsyncThread: params = TurnStartParams( thread_id=self.id, input=wire_input, - approval_policy=approval_policy, + approval_policy=_approval_policy_never(approval_policy), approvals_reviewer=approvals_reviewer, cwd=cwd, effort=effort, diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index f573f667a7..1d277971c6 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -3,7 +3,7 @@ from __future__ import annotations from pydantic import BaseModel, ConfigDict, Field, RootModel -from typing import Annotated, Any, Literal +from typing import Annotated, Any, ClassVar, Literal from enum import Enum @@ -248,6 +248,16 @@ class AskForApproval(RootModel[AskForApprovalValue | GranularAskForApproval]): populate_by_name=True, ) root: AskForApprovalValue | GranularAskForApproval + untrusted: ClassVar[AskForApproval] + on_failure: ClassVar[AskForApproval] + on_request: ClassVar[AskForApproval] + never: ClassVar[AskForApproval] + + +AskForApproval.untrusted = AskForApproval(root=AskForApprovalValue.untrusted) +AskForApproval.on_failure = AskForApproval(root=AskForApprovalValue.on_failure) +AskForApproval.on_request = AskForApproval(root=AskForApprovalValue.on_request) +AskForApproval.never = AskForApproval(root=AskForApprovalValue.never) class AuthMode(Enum): diff --git a/sdk/python/tests/test_public_api_runtime_behavior.py b/sdk/python/tests/test_public_api_runtime_behavior.py index ac6e9c9388..94b1367aa7 100644 --- a/sdk/python/tests/test_public_api_runtime_behavior.py +++ b/sdk/python/tests/test_public_api_runtime_behavior.py @@ -4,6 +4,7 @@ import asyncio from collections import deque from pathlib import Path from types import SimpleNamespace +from typing import Any import pytest @@ -24,13 +25,23 @@ from openai_codex.api import ( AsyncTurnHandle, Codex, RunResult, + TextInput, Thread, TurnHandle, ) +from openai_codex.types import AskForApproval ROOT = Path(__file__).resolve().parents[1] +def _approval_policy_values(params: list[Any]) -> list[object]: + """Return serialized approval policies from captured Pydantic params.""" + return [ + param.model_dump(by_alias=True, mode="json").get("approvalPolicy") + for param in params + ] + + def _delta_notification( *, thread_id: str = "thread-1", @@ -229,6 +240,130 @@ def test_async_codex_initializes_only_once_under_concurrency() -> None: asyncio.run(scenario()) +def test_ask_for_approval_exposes_simple_policy_constants() -> None: + """AskForApproval should expose enum-like aliases for simple policies.""" + assert { + "untrusted": AskForApproval.untrusted.model_dump(mode="json"), + "on_failure": AskForApproval.on_failure.model_dump(mode="json"), + "on_request": AskForApproval.on_request.model_dump(mode="json"), + "never": AskForApproval.never.model_dump(mode="json"), + } == { + "untrusted": "untrusted", + "on_failure": "on-failure", + "on_request": "on-request", + "never": "never", + } + + +def test_sync_api_forces_approval_policy_never_for_started_work() -> None: + """Sync start methods should send never until approval handling exists.""" + captured: list[Any] = [] + + class FakeClient: + def thread_start(self, params: object) -> SimpleNamespace: + captured.append(params) + return SimpleNamespace(thread=SimpleNamespace(id="thread-started")) + + def thread_resume( + self, + _thread_id: str, + params: object, + ) -> SimpleNamespace: + captured.append(params) + return SimpleNamespace(thread=SimpleNamespace(id="thread-resumed")) + + def thread_fork( + self, + _thread_id: str, + params: object, + ) -> SimpleNamespace: + captured.append(params) + return SimpleNamespace(thread=SimpleNamespace(id="thread-forked")) + + def turn_start( + self, + _thread_id: str, + _wire_input: object, + *, + params: object | None = None, + ) -> SimpleNamespace: + captured.append(params) + return SimpleNamespace(turn=SimpleNamespace(id="turn-1")) + + client = FakeClient() + codex = object.__new__(Codex) + codex._client = client + + codex.thread_start(approval_policy=AskForApproval.on_request) + codex.thread_resume("thread-1", approval_policy=AskForApproval.on_request) + codex.thread_fork("thread-1", approval_policy=AskForApproval.on_request) + Thread(client, "thread-1").turn( + TextInput("hello"), + approval_policy=AskForApproval.on_request, + ) + + assert _approval_policy_values(captured) == ["never", "never", "never", "never"] + + +def test_async_api_forces_approval_policy_never_for_started_work() -> None: + """Async start methods should send never until approval handling exists.""" + async def scenario() -> None: + """Exercise the async wrappers without spawning a real app server.""" + captured: list[Any] = [] + + class FakeAsyncClient: + async def thread_start(self, params: object) -> SimpleNamespace: + captured.append(params) + return SimpleNamespace(thread=SimpleNamespace(id="thread-started")) + + async def thread_resume( + self, + _thread_id: str, + params: object, + ) -> SimpleNamespace: + captured.append(params) + return SimpleNamespace(thread=SimpleNamespace(id="thread-resumed")) + + async def thread_fork( + self, + _thread_id: str, + params: object, + ) -> SimpleNamespace: + captured.append(params) + return SimpleNamespace(thread=SimpleNamespace(id="thread-forked")) + + async def turn_start( + self, + _thread_id: str, + _wire_input: object, + *, + params: object | None = None, + ) -> SimpleNamespace: + captured.append(params) + return SimpleNamespace(turn=SimpleNamespace(id="turn-1")) + + codex = AsyncCodex() + codex._client = FakeAsyncClient() + codex._initialized = True + + await codex.thread_start(approval_policy=AskForApproval.on_request) + await codex.thread_resume("thread-1", approval_policy=AskForApproval.on_request) + await codex.thread_fork("thread-1", approval_policy=AskForApproval.on_request) + await AsyncThread(codex, "thread-1").turn( + TextInput("hello"), + approval_policy=AskForApproval.on_request, + ) + + assert _approval_policy_values(captured) == [ + "never", + "never", + "never", + "never", + ] + + asyncio.run(scenario()) + + def test_turn_streams_can_consume_multiple_turns_on_one_client() -> None: """Two sync TurnHandle streams should advance independently on one client.""" client = AppServerClient()