mirror of
https://github.com/openai/codex.git
synced 2026-05-17 17:53:06 +00:00
Add high-level Python SDK approval mode
Expose approval_mode with deny_all and auto_review options on the high-level Python SDK, and map those choices to generated app-server approval params internally. Update examples, docs, notebooks, and public API tests to use the new mode instead of raw generated approval fields. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -20,6 +20,7 @@ from openai_codex.generated.v2_all import (
|
||||
)
|
||||
from openai_codex.models import InitializeResponse, Notification
|
||||
from openai_codex.api import (
|
||||
ApprovalMode,
|
||||
AsyncCodex,
|
||||
AsyncThread,
|
||||
AsyncTurnHandle,
|
||||
@@ -34,10 +35,18 @@ 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."""
|
||||
def _approval_settings(params: list[Any]) -> list[dict[str, object]]:
|
||||
"""Return serialized approval settings from captured Pydantic params."""
|
||||
return [
|
||||
param.model_dump(by_alias=True, mode="json").get("approvalPolicy")
|
||||
{
|
||||
key: value
|
||||
for key, value in param.model_dump(
|
||||
by_alias=True,
|
||||
exclude_none=True,
|
||||
mode="json",
|
||||
).items()
|
||||
if key in {"approvalPolicy", "approvalsReviewer"}
|
||||
}
|
||||
for param in params
|
||||
]
|
||||
|
||||
@@ -255,8 +264,8 @@ def test_ask_for_approval_exposes_simple_policy_constants() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_sync_api_forces_approval_policy_never_for_started_work() -> None:
|
||||
"""Sync start methods should send never until approval handling exists."""
|
||||
def test_sync_api_maps_approval_modes_for_started_work() -> None:
|
||||
"""Sync start methods should serialize only supported approval modes."""
|
||||
captured: list[Any] = []
|
||||
|
||||
class FakeClient:
|
||||
@@ -294,19 +303,33 @@ def test_sync_api_forces_approval_policy_never_for_started_work() -> None:
|
||||
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)
|
||||
codex.thread_start()
|
||||
codex.thread_resume("thread-1")
|
||||
codex.thread_fork("thread-1")
|
||||
Thread(client, "thread-1").turn(TextInput("hello"))
|
||||
codex.thread_start(approval_mode=ApprovalMode.auto_review)
|
||||
codex.thread_resume("thread-1", approval_mode=ApprovalMode.auto_review)
|
||||
codex.thread_fork("thread-1", approval_mode=ApprovalMode.auto_review)
|
||||
Thread(client, "thread-1").turn(
|
||||
TextInput("hello"),
|
||||
approval_policy=AskForApproval.on_request,
|
||||
approval_mode=ApprovalMode.auto_review,
|
||||
)
|
||||
|
||||
assert _approval_policy_values(captured) == ["never", "never", "never", "never"]
|
||||
assert _approval_settings(captured) == [
|
||||
{"approvalPolicy": "never"},
|
||||
{"approvalPolicy": "never"},
|
||||
{"approvalPolicy": "never"},
|
||||
{"approvalPolicy": "never"},
|
||||
{"approvalPolicy": "on-request", "approvalsReviewer": "auto_review"},
|
||||
{"approvalPolicy": "on-request", "approvalsReviewer": "auto_review"},
|
||||
{"approvalPolicy": "on-request", "approvalsReviewer": "auto_review"},
|
||||
{"approvalPolicy": "on-request", "approvalsReviewer": "auto_review"},
|
||||
]
|
||||
|
||||
|
||||
def test_async_api_forces_approval_policy_never_for_started_work() -> None:
|
||||
"""Async start methods should send never until approval handling exists."""
|
||||
def test_async_api_maps_approval_modes_for_started_work() -> None:
|
||||
"""Async start methods should serialize only supported approval modes."""
|
||||
|
||||
async def scenario() -> None:
|
||||
"""Exercise the async wrappers without spawning a real app server."""
|
||||
captured: list[Any] = []
|
||||
@@ -346,19 +369,30 @@ def test_async_api_forces_approval_policy_never_for_started_work() -> None:
|
||||
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 codex.thread_start()
|
||||
await codex.thread_resume("thread-1")
|
||||
await codex.thread_fork("thread-1")
|
||||
await AsyncThread(codex, "thread-1").turn(TextInput("hello"))
|
||||
await codex.thread_start(approval_mode=ApprovalMode.auto_review)
|
||||
await codex.thread_resume(
|
||||
"thread-1",
|
||||
approval_mode=ApprovalMode.auto_review,
|
||||
)
|
||||
await codex.thread_fork("thread-1", approval_mode=ApprovalMode.auto_review)
|
||||
await AsyncThread(codex, "thread-1").turn(
|
||||
TextInput("hello"),
|
||||
approval_policy=AskForApproval.on_request,
|
||||
approval_mode=ApprovalMode.auto_review,
|
||||
)
|
||||
|
||||
assert _approval_policy_values(captured) == [
|
||||
"never",
|
||||
"never",
|
||||
"never",
|
||||
"never",
|
||||
assert _approval_settings(captured) == [
|
||||
{"approvalPolicy": "never"},
|
||||
{"approvalPolicy": "never"},
|
||||
{"approvalPolicy": "never"},
|
||||
{"approvalPolicy": "never"},
|
||||
{"approvalPolicy": "on-request", "approvalsReviewer": "auto_review"},
|
||||
{"approvalPolicy": "on-request", "approvalsReviewer": "auto_review"},
|
||||
{"approvalPolicy": "on-request", "approvalsReviewer": "auto_review"},
|
||||
{"approvalPolicy": "on-request", "approvalsReviewer": "auto_review"},
|
||||
]
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -397,6 +431,7 @@ def test_turn_streams_can_consume_multiple_turns_on_one_client() -> None:
|
||||
|
||||
def test_async_turn_streams_can_consume_multiple_turns_on_one_client() -> None:
|
||||
"""Two async TurnHandle streams should advance independently on one client."""
|
||||
|
||||
async def scenario() -> None:
|
||||
"""Interleave two async streams backed by separate per-turn queues."""
|
||||
codex = AsyncCodex()
|
||||
@@ -479,14 +514,25 @@ def test_thread_run_accepts_string_input_and_returns_run_result() -> None:
|
||||
|
||||
client.turn_start = fake_turn_start # type: ignore[method-assign]
|
||||
|
||||
result = Thread(client, "thread-1").run("hello")
|
||||
result = Thread(client, "thread-1").run(
|
||||
"hello",
|
||||
approval_mode=ApprovalMode.auto_review,
|
||||
)
|
||||
|
||||
assert seen["thread_id"] == "thread-1"
|
||||
assert seen["wire_input"] == [{"type": "text", "text": "hello"}]
|
||||
assert result == RunResult(
|
||||
final_response="Hello.",
|
||||
items=[item_notification.payload.item],
|
||||
usage=usage_notification.payload.token_usage,
|
||||
assert (
|
||||
seen["thread_id"],
|
||||
seen["wire_input"],
|
||||
_approval_settings([seen["params"]]),
|
||||
result,
|
||||
) == (
|
||||
"thread-1",
|
||||
[{"type": "text", "text": "hello"}],
|
||||
[{"approvalPolicy": "on-request", "approvalsReviewer": "auto_review"}],
|
||||
RunResult(
|
||||
final_response="Hello.",
|
||||
items=[item_notification.payload.item],
|
||||
usage=usage_notification.payload.token_usage,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -658,6 +704,7 @@ def test_stream_text_registers_and_consumes_turn_notifications() -> None:
|
||||
|
||||
def test_async_thread_run_accepts_string_input_and_returns_run_result() -> None:
|
||||
"""Async Thread.run should normalize string input and collect routed results."""
|
||||
|
||||
async def scenario() -> None:
|
||||
"""Feed item, usage, and completion events through the async turn stream."""
|
||||
codex = AsyncCodex()
|
||||
@@ -692,14 +739,25 @@ def test_async_thread_run_accepts_string_input_and_returns_run_result() -> None:
|
||||
codex._client.turn_start = fake_turn_start # type: ignore[method-assign]
|
||||
codex._client.next_turn_notification = fake_next_notification # type: ignore[method-assign]
|
||||
|
||||
result = await AsyncThread(codex, "thread-1").run("hello")
|
||||
result = await AsyncThread(codex, "thread-1").run(
|
||||
"hello",
|
||||
approval_mode=ApprovalMode.auto_review,
|
||||
)
|
||||
|
||||
assert seen["thread_id"] == "thread-1"
|
||||
assert seen["wire_input"] == [{"type": "text", "text": "hello"}]
|
||||
assert result == RunResult(
|
||||
final_response="Hello async.",
|
||||
items=[item_notification.payload.item],
|
||||
usage=usage_notification.payload.token_usage,
|
||||
assert (
|
||||
seen["thread_id"],
|
||||
seen["wire_input"],
|
||||
_approval_settings([seen["params"]]),
|
||||
result,
|
||||
) == (
|
||||
"thread-1",
|
||||
[{"type": "text", "text": "hello"}],
|
||||
[{"approvalPolicy": "on-request", "approvalsReviewer": "auto_review"}],
|
||||
RunResult(
|
||||
final_response="Hello async.",
|
||||
items=[item_notification.payload.item],
|
||||
usage=usage_notification.payload.token_usage,
|
||||
),
|
||||
)
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -709,6 +767,7 @@ def test_async_thread_run_uses_last_completed_assistant_message_as_final_respons
|
||||
None
|
||||
):
|
||||
"""Async run should use the last final assistant message as the response text."""
|
||||
|
||||
async def scenario() -> None:
|
||||
"""Feed two completed agent messages through the async per-turn stream."""
|
||||
codex = AsyncCodex()
|
||||
@@ -756,6 +815,7 @@ def test_async_thread_run_uses_last_completed_assistant_message_as_final_respons
|
||||
|
||||
def test_async_thread_run_returns_none_when_only_commentary_messages_complete() -> None:
|
||||
"""Async Thread.run should ignore commentary-only messages for final text."""
|
||||
|
||||
async def scenario() -> None:
|
||||
"""Feed a commentary item and completion through the async turn stream."""
|
||||
codex = AsyncCodex()
|
||||
|
||||
@@ -10,6 +10,7 @@ import openai_codex
|
||||
import openai_codex.types as public_types
|
||||
from openai_codex import (
|
||||
AppServerConfig,
|
||||
ApprovalMode,
|
||||
AsyncCodex,
|
||||
AsyncThread,
|
||||
Codex,
|
||||
@@ -23,6 +24,7 @@ EXPECTED_ROOT_EXPORTS = [
|
||||
"AppServerConfig",
|
||||
"Codex",
|
||||
"AsyncCodex",
|
||||
"ApprovalMode",
|
||||
"Thread",
|
||||
"AsyncThread",
|
||||
"TurnHandle",
|
||||
@@ -117,6 +119,11 @@ def test_root_exports_run_result() -> None:
|
||||
assert RunResult.__name__ == "RunResult"
|
||||
|
||||
|
||||
def test_root_exports_approval_mode() -> None:
|
||||
"""The root package should expose the high-level approval mode enum."""
|
||||
assert ApprovalMode.deny_all.value == "deny_all"
|
||||
|
||||
|
||||
def test_package_and_default_client_versions_follow_project_version() -> None:
|
||||
"""The importable package version should stay aligned with pyproject metadata."""
|
||||
pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
||||
@@ -135,18 +142,16 @@ def test_package_includes_py_typed_marker() -> None:
|
||||
def test_package_root_exports_only_public_api() -> None:
|
||||
"""The package root should expose the supported SDK surface, not internals."""
|
||||
assert openai_codex.__all__ == EXPECTED_ROOT_EXPORTS
|
||||
assert {
|
||||
name: hasattr(openai_codex, name) for name in EXPECTED_ROOT_EXPORTS
|
||||
} == {name: True for name in EXPECTED_ROOT_EXPORTS}
|
||||
assert {name: hasattr(openai_codex, name) for name in EXPECTED_ROOT_EXPORTS} == {
|
||||
name: True for name in EXPECTED_ROOT_EXPORTS
|
||||
}
|
||||
assert {
|
||||
"AppServerClient": hasattr(openai_codex, "AppServerClient"),
|
||||
"AsyncAppServerClient": hasattr(openai_codex, "AsyncAppServerClient"),
|
||||
"InitializeResponse": hasattr(openai_codex, "InitializeResponse"),
|
||||
"ThreadStartParams": hasattr(openai_codex, "ThreadStartParams"),
|
||||
"TurnStartParams": hasattr(openai_codex, "TurnStartParams"),
|
||||
"TurnCompletedNotification": hasattr(
|
||||
openai_codex, "TurnCompletedNotification"
|
||||
),
|
||||
"TurnCompletedNotification": hasattr(openai_codex, "TurnCompletedNotification"),
|
||||
"TurnStatus": hasattr(openai_codex, "TurnStatus"),
|
||||
} == {
|
||||
"AppServerClient": False,
|
||||
@@ -210,8 +215,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
|
||||
"""Generated convenience methods should expose typed Pythonic keyword names."""
|
||||
expected = {
|
||||
Codex.thread_start: [
|
||||
"approval_policy",
|
||||
"approvals_reviewer",
|
||||
"approval_mode",
|
||||
"base_instructions",
|
||||
"config",
|
||||
"cwd",
|
||||
@@ -239,8 +243,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
|
||||
"use_state_db_only",
|
||||
],
|
||||
Codex.thread_resume: [
|
||||
"approval_policy",
|
||||
"approvals_reviewer",
|
||||
"approval_mode",
|
||||
"base_instructions",
|
||||
"config",
|
||||
"cwd",
|
||||
@@ -252,8 +255,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
|
||||
"service_tier",
|
||||
],
|
||||
Codex.thread_fork: [
|
||||
"approval_policy",
|
||||
"approvals_reviewer",
|
||||
"approval_mode",
|
||||
"base_instructions",
|
||||
"config",
|
||||
"cwd",
|
||||
@@ -266,8 +268,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
|
||||
"thread_source",
|
||||
],
|
||||
Thread.turn: [
|
||||
"approval_policy",
|
||||
"approvals_reviewer",
|
||||
"approval_mode",
|
||||
"cwd",
|
||||
"effort",
|
||||
"model",
|
||||
@@ -278,8 +279,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
|
||||
"summary",
|
||||
],
|
||||
Thread.run: [
|
||||
"approval_policy",
|
||||
"approvals_reviewer",
|
||||
"approval_mode",
|
||||
"cwd",
|
||||
"effort",
|
||||
"model",
|
||||
@@ -290,8 +290,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
|
||||
"summary",
|
||||
],
|
||||
AsyncCodex.thread_start: [
|
||||
"approval_policy",
|
||||
"approvals_reviewer",
|
||||
"approval_mode",
|
||||
"base_instructions",
|
||||
"config",
|
||||
"cwd",
|
||||
@@ -319,8 +318,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
|
||||
"use_state_db_only",
|
||||
],
|
||||
AsyncCodex.thread_resume: [
|
||||
"approval_policy",
|
||||
"approvals_reviewer",
|
||||
"approval_mode",
|
||||
"base_instructions",
|
||||
"config",
|
||||
"cwd",
|
||||
@@ -332,8 +330,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
|
||||
"service_tier",
|
||||
],
|
||||
AsyncCodex.thread_fork: [
|
||||
"approval_policy",
|
||||
"approvals_reviewer",
|
||||
"approval_mode",
|
||||
"base_instructions",
|
||||
"config",
|
||||
"cwd",
|
||||
@@ -346,8 +343,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
|
||||
"thread_source",
|
||||
],
|
||||
AsyncThread.turn: [
|
||||
"approval_policy",
|
||||
"approvals_reviewer",
|
||||
"approval_mode",
|
||||
"cwd",
|
||||
"effort",
|
||||
"model",
|
||||
@@ -358,8 +354,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
|
||||
"summary",
|
||||
],
|
||||
AsyncThread.run: [
|
||||
"approval_policy",
|
||||
"approvals_reviewer",
|
||||
"approval_mode",
|
||||
"cwd",
|
||||
"effort",
|
||||
"model",
|
||||
|
||||
Reference in New Issue
Block a user