Compare commits

...

5 Commits

Author SHA1 Message Date
Ahmed Ibrahim
9306e60848 Define Python SDK public type surface
Co-authored-by: Codex <noreply@openai.com>
2026-05-09 11:34:46 +03:00
Ahmed Ibrahim
8d7a5c27c1 Keep Python SDK type exports
Co-authored-by: Codex <noreply@openai.com>
2026-05-09 10:39:52 +03:00
Ahmed Ibrahim
692c08faf9 Narrow Python SDK root exports
Co-authored-by: Codex <noreply@openai.com>
2026-05-09 10:35:44 +03:00
Ahmed Ibrahim
8b8e868140 Document Python SDK CI job
Co-authored-by: Codex <noreply@openai.com>
2026-05-09 10:24:29 +03:00
Ahmed Ibrahim
2654cc299e Run Python SDK tests in CI
Add a separate Python SDK runner that installs the pinned musl runtime wheel in an Alpine Python container and runs the SDK pytest suite in parallel with existing SDK checks.

Co-authored-by: Codex <noreply@openai.com>
2026-05-09 10:24:17 +03:00
17 changed files with 307 additions and 78 deletions

View File

@@ -6,6 +6,39 @@ on:
pull_request: {}
jobs:
python-sdk:
runs-on:
group: codex-runners
labels: codex-linux-x64
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- name: Test Python SDK
shell: bash
run: |
set -euo pipefail
# Run inside Alpine so dependency resolution exercises the pinned
# runtime wheel on the same Linux wheel family that CI installs.
docker run --rm \
--user "$(id -u):$(id -g)" \
-e HOME=/tmp/codex-python-sdk-home \
-e UV_LINK_MODE=copy \
-v "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}" \
-w "${GITHUB_WORKSPACE}/sdk/python" \
python:3.12-alpine \
sh -euxc '
python -m venv /tmp/uv
/tmp/uv/bin/python -m pip install uv==0.11.3
/tmp/uv/bin/uv sync --extra dev --frozen
/tmp/uv/bin/uv run --extra dev pytest
'
sdks:
runs-on:
group: codex-runners

View File

@@ -5,6 +5,8 @@ Experimental Python SDK for `codex app-server` JSON-RPC v2 over stdio, with a sm
The generated wire-model layer is sourced from the pinned `openai-codex-cli-bin`
runtime package and exposed as Pydantic models with snake_case Python fields
that serialize back to the app-servers camelCase wire format.
The package root exports the ergonomic client API; public app-server value and
event types live in `codex_app_server.types`.
## Install
@@ -110,4 +112,4 @@ This supports the CI release flow:
- Use context managers (`with Codex() as codex:`) to ensure shutdown.
- Prefer `thread.run("...")` for the common case. Use `thread.turn(...)` when
you need streaming, steering, or interrupt control.
- For transient overload, use `codex_app_server.retry.retry_on_overload`.
- For transient overload, use `retry_on_overload` from the package root.

View File

@@ -15,7 +15,6 @@ from codex_app_server import (
AsyncThread,
TurnHandle,
AsyncTurnHandle,
InitializeResponse,
Input,
InputItem,
TextInput,
@@ -23,14 +22,18 @@ from codex_app_server import (
LocalImageInput,
SkillInput,
MentionInput,
)
from codex_app_server.types import (
InitializeResponse,
ThreadItem,
ThreadTokenUsage,
TurnStatus,
)
from codex_app_server.generated.v2_all import ThreadItem, ThreadTokenUsage
```
- Version: `codex_app_server.__version__`
- Requires Python >= 3.10
- Canonical generated app-server models live in `codex_app_server.generated.v2_all`
- Public app-server value and event types live in `codex_app_server.types`
## Codex (sync)
@@ -124,7 +127,7 @@ object with:
phase-less assistant message item.
Use `turn(...)` when you need low-level turn control (`stream()`, `steer()`,
`interrupt()`) or the canonical generated `Turn` from `TurnHandle.run()`.
`interrupt()`) or the public `Turn` model from `TurnHandle.run()`.
## TurnHandle / AsyncTurnHandle
@@ -133,7 +136,7 @@ Use `turn(...)` when you need low-level turn control (`stream()`, `steer()`,
- `steer(input: Input) -> TurnSteerResponse`
- `interrupt() -> TurnInterruptResponse`
- `stream() -> Iterator[Notification]`
- `run() -> codex_app_server.generated.v2_all.Turn`
- `run() -> codex_app_server.types.Turn`
Behavior notes:
@@ -145,7 +148,7 @@ Behavior notes:
- `steer(input: Input) -> Awaitable[TurnSteerResponse]`
- `interrupt() -> Awaitable[TurnInterruptResponse]`
- `stream() -> AsyncIterator[Notification]`
- `run() -> Awaitable[codex_app_server.generated.v2_all.Turn]`
- `run() -> Awaitable[codex_app_server.types.Turn]`
Behavior notes:
@@ -165,16 +168,15 @@ InputItem = TextInput | ImageInput | LocalImageInput | SkillInput | MentionInput
Input = list[InputItem] | InputItem
```
## Generated Models
## Public Types
The SDK wrappers return and accept canonical generated app-server models wherever possible:
The SDK wrappers return and accept public app-server models wherever possible:
```python
from codex_app_server.generated.v2_all import (
from codex_app_server.types import (
AskForApproval,
ThreadReadResponse,
Turn,
TurnStartParams,
TurnStatus,
)
```

View File

@@ -8,7 +8,7 @@
## `run()` vs `stream()`
- `TurnHandle.run()` / `AsyncTurnHandle.run()` is the easiest path. It consumes events until completion and returns the canonical generated app-server `Turn` model.
- `TurnHandle.run()` / `AsyncTurnHandle.run()` is the easiest path. It consumes events until completion and returns the public app-server `Turn` model from `codex_app_server.types`.
- `TurnHandle.stream()` / `AsyncTurnHandle.stream()` yields raw notifications (`Notification`) so you can react event-by-event.
Choose `run()` for most apps. Choose `stream()` for progress UIs, custom timeout logic, or custom parsing.
@@ -99,5 +99,5 @@ Do not blindly retry all errors. For `InvalidParamsError` or `MethodNotFoundErro
- Starting a new thread for every prompt when you wanted continuity.
- Forgetting to `close()` (or not using context managers).
- Assuming `run()` returns extra SDK-only fields instead of the generated `Turn` model.
- Assuming `run()` returns extra SDK-only fields instead of the public `Turn` model.
- Mixing SDK input classes with raw dicts incorrectly.

View File

@@ -95,12 +95,13 @@ with Codex() as codex:
print(result.final_response)
```
## 6) Generated models
## 6) Public app-server types
The convenience wrappers live at the package root, but the canonical app-server models live under:
The convenience wrappers live at the package root. Public app-server value and
event types live under:
```python
from codex_app_server.generated.v2_all import Turn, TurnStatus, ThreadReadResponse
from codex_app_server.types import ThreadReadResponse, Turn, TurnStatus
```
## 7) Next stops

View File

@@ -24,9 +24,9 @@ from codex_app_server import (
JsonRpcError,
ServerBusyError,
TextInput,
TurnStatus,
is_retryable_error,
)
from codex_app_server.types import TurnStatus
ResultT = TypeVar("ResultT")

View File

@@ -19,9 +19,9 @@ from codex_app_server import (
JsonRpcError,
ServerBusyError,
TextInput,
TurnStatus,
retry_on_overload,
)
from codex_app_server.types import TurnStatus
with Codex(config=runtime_config()) as codex:
thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})

View File

@@ -14,6 +14,8 @@ import asyncio
from codex_app_server import (
AsyncCodex,
TextInput,
)
from codex_app_server.types import (
ThreadTokenUsageUpdatedNotification,
TurnCompletedNotification,
)

View File

@@ -12,6 +12,8 @@ ensure_local_sdk_src()
from codex_app_server import (
Codex,
TextInput,
)
from codex_app_server.types import (
ThreadTokenUsageUpdatedNotification,
TurnCompletedNotification,
)

View File

@@ -18,11 +18,13 @@ ensure_local_sdk_src()
import asyncio
from codex_app_server import (
AskForApproval,
AsyncCodex,
TextInput,
)
from codex_app_server.types import (
AskForApproval,
Personality,
ReasoningSummary,
TextInput,
)
OUTPUT_SCHEMA = {

View File

@@ -16,11 +16,13 @@ from _bootstrap import (
ensure_local_sdk_src()
from codex_app_server import (
AskForApproval,
Codex,
TextInput,
)
from codex_app_server.types import (
AskForApproval,
Personality,
ReasoningSummary,
TextInput,
)
OUTPUT_SCHEMA = {

View File

@@ -12,13 +12,15 @@ ensure_local_sdk_src()
import asyncio
from codex_app_server import (
AskForApproval,
AsyncCodex,
TextInput,
)
from codex_app_server.types import (
AskForApproval,
Personality,
ReasoningEffort,
ReasoningSummary,
SandboxPolicy,
TextInput,
)
REASONING_RANK = {

View File

@@ -10,13 +10,15 @@ from _bootstrap import assistant_text_from_turn, ensure_local_sdk_src, find_turn
ensure_local_sdk_src()
from codex_app_server import (
AskForApproval,
Codex,
TextInput,
)
from codex_app_server.types import (
AskForApproval,
Personality,
ReasoningEffort,
ReasoningSummary,
SandboxPolicy,
TextInput,
)
REASONING_RANK = {

View File

@@ -5,7 +5,8 @@ Each example folder contains runnable versions:
- `sync.py` (public sync surface: `Codex`)
- `async.py` (public async surface: `AsyncCodex`)
All examples intentionally use only public SDK exports from `codex_app_server`.
All examples intentionally use only public SDK exports from `codex_app_server`
and `codex_app_server.types`.
## Prerequisites

View File

@@ -1,5 +1,4 @@
from .async_client import AsyncAppServerClient
from .client import AppServerClient, AppServerConfig
from .client import AppServerConfig
from .errors import (
AppServerError,
AppServerRpcError,
@@ -14,29 +13,6 @@ from .errors import (
TransportClosedError,
is_retryable_error,
)
from .generated.v2_all import (
AskForApproval,
Personality,
PlanType,
ReasoningEffort,
ReasoningSummary,
SandboxMode,
SandboxPolicy,
ThreadItem,
ThreadForkParams,
ThreadListParams,
ThreadResumeParams,
ThreadSortKey,
ThreadSource,
ThreadSourceKind,
ThreadStartParams,
ThreadTokenUsageUpdatedNotification,
TurnCompletedNotification,
TurnStartParams,
TurnStatus,
TurnSteerParams,
)
from .models import InitializeResponse
from .api import (
AsyncCodex,
AsyncThread,
@@ -58,8 +34,6 @@ from ._version import __version__
__all__ = [
"__version__",
"AppServerClient",
"AsyncAppServerClient",
"AppServerConfig",
"Codex",
"AsyncCodex",
@@ -67,7 +41,6 @@ __all__ = [
"AsyncThread",
"TurnHandle",
"AsyncTurnHandle",
"InitializeResponse",
"RunResult",
"Input",
"InputItem",
@@ -76,26 +49,6 @@ __all__ = [
"LocalImageInput",
"SkillInput",
"MentionInput",
"ThreadItem",
"ThreadTokenUsageUpdatedNotification",
"TurnCompletedNotification",
"AskForApproval",
"Personality",
"PlanType",
"ReasoningEffort",
"ReasoningSummary",
"SandboxMode",
"SandboxPolicy",
"ThreadStartParams",
"ThreadResumeParams",
"ThreadListParams",
"ThreadSortKey",
"ThreadSource",
"ThreadSourceKind",
"ThreadForkParams",
"TurnStatus",
"TurnStartParams",
"TurnSteerParams",
"retry_on_overload",
"AppServerError",
"TransportClosedError",

View File

@@ -0,0 +1,69 @@
"""Public generated app-server model exports for type annotations and matching."""
from __future__ import annotations
from .generated.v2_all import (
ApprovalsReviewer,
AskForApproval,
ModelListResponse,
Personality,
PlanType,
ReasoningEffort,
ReasoningSummary,
SandboxMode,
SandboxPolicy,
SortDirection,
ThreadArchiveResponse,
ThreadCompactStartResponse,
ThreadItem,
ThreadListCwdFilter,
ThreadListResponse,
ThreadReadResponse,
ThreadSetNameResponse,
ThreadSortKey,
ThreadSource,
ThreadSourceKind,
ThreadStartSource,
ThreadTokenUsage,
ThreadTokenUsageUpdatedNotification,
Turn,
TurnCompletedNotification,
TurnInterruptResponse,
TurnStatus,
TurnSteerResponse,
)
from .models import InitializeResponse, JsonObject, Notification
__all__ = [
"ApprovalsReviewer",
"AskForApproval",
"InitializeResponse",
"JsonObject",
"ModelListResponse",
"Notification",
"Personality",
"PlanType",
"ReasoningEffort",
"ReasoningSummary",
"SandboxMode",
"SandboxPolicy",
"SortDirection",
"ThreadArchiveResponse",
"ThreadCompactStartResponse",
"ThreadItem",
"ThreadListCwdFilter",
"ThreadListResponse",
"ThreadReadResponse",
"ThreadSetNameResponse",
"ThreadSortKey",
"ThreadSource",
"ThreadSourceKind",
"ThreadStartSource",
"ThreadTokenUsage",
"ThreadTokenUsageUpdatedNotification",
"Turn",
"TurnCompletedNotification",
"TurnInterruptResponse",
"TurnStatus",
"TurnSteerResponse",
]

View File

@@ -7,12 +7,86 @@ from pathlib import Path
from typing import Any
import codex_app_server
from codex_app_server import AppServerConfig, RunResult
from codex_app_server.models import InitializeResponse
from codex_app_server.api import AsyncCodex, AsyncThread, Codex, Thread
import codex_app_server.types as public_types
from codex_app_server import (
AppServerConfig,
AsyncCodex,
AsyncThread,
Codex,
RunResult,
Thread,
)
from codex_app_server.types import InitializeResponse
EXPECTED_ROOT_EXPORTS = [
"__version__",
"AppServerConfig",
"Codex",
"AsyncCodex",
"Thread",
"AsyncThread",
"TurnHandle",
"AsyncTurnHandle",
"RunResult",
"Input",
"InputItem",
"TextInput",
"ImageInput",
"LocalImageInput",
"SkillInput",
"MentionInput",
"retry_on_overload",
"AppServerError",
"TransportClosedError",
"JsonRpcError",
"AppServerRpcError",
"ParseError",
"InvalidRequestError",
"MethodNotFoundError",
"InvalidParamsError",
"InternalRpcError",
"ServerBusyError",
"RetryLimitExceededError",
"is_retryable_error",
]
EXPECTED_TYPES_EXPORTS = [
"ApprovalsReviewer",
"AskForApproval",
"InitializeResponse",
"JsonObject",
"ModelListResponse",
"Notification",
"Personality",
"PlanType",
"ReasoningEffort",
"ReasoningSummary",
"SandboxMode",
"SandboxPolicy",
"SortDirection",
"ThreadArchiveResponse",
"ThreadCompactStartResponse",
"ThreadItem",
"ThreadListCwdFilter",
"ThreadListResponse",
"ThreadReadResponse",
"ThreadSetNameResponse",
"ThreadSortKey",
"ThreadSource",
"ThreadSourceKind",
"ThreadStartSource",
"ThreadTokenUsage",
"ThreadTokenUsageUpdatedNotification",
"Turn",
"TurnCompletedNotification",
"TurnInterruptResponse",
"TurnStatus",
"TurnSteerResponse",
]
def _keyword_only_names(fn: object) -> list[str]:
"""Return only user-facing keyword-only parameter names for a public method."""
signature = inspect.signature(fn)
return [
param.name
@@ -22,6 +96,7 @@ def _keyword_only_names(fn: object) -> list[str]:
def _assert_no_any_annotations(fn: object) -> None:
"""Reject loose annotations on public wrapper methods."""
signature = inspect.signature(fn)
for param in signature.parameters.values():
if param.annotation is Any:
@@ -33,14 +108,17 @@ def _assert_no_any_annotations(fn: object) -> None:
def test_root_exports_app_server_config() -> None:
"""The root package should expose the process configuration object."""
assert AppServerConfig.__name__ == "AppServerConfig"
def test_root_exports_run_result() -> None:
"""The root package should expose the common-case run result wrapper."""
assert RunResult.__name__ == "RunResult"
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"
pyproject = tomllib.loads(pyproject_path.read_text())
@@ -49,10 +127,85 @@ def test_package_and_default_client_versions_follow_project_version() -> None:
def test_package_includes_py_typed_marker() -> None:
"""The wheel should advertise that inline type information is available."""
marker = resources.files("codex_app_server").joinpath("py.typed")
assert marker.is_file()
def test_package_root_exports_only_public_api() -> None:
"""The package root should expose the supported SDK surface, not internals."""
assert codex_app_server.__all__ == EXPECTED_ROOT_EXPORTS
assert {
name: hasattr(codex_app_server, name) for name in EXPECTED_ROOT_EXPORTS
} == {name: True for name in EXPECTED_ROOT_EXPORTS}
assert {
"AppServerClient": hasattr(codex_app_server, "AppServerClient"),
"AsyncAppServerClient": hasattr(codex_app_server, "AsyncAppServerClient"),
"InitializeResponse": hasattr(codex_app_server, "InitializeResponse"),
"ThreadStartParams": hasattr(codex_app_server, "ThreadStartParams"),
"TurnStartParams": hasattr(codex_app_server, "TurnStartParams"),
"TurnCompletedNotification": hasattr(
codex_app_server, "TurnCompletedNotification"
),
"TurnStatus": hasattr(codex_app_server, "TurnStatus"),
} == {
"AppServerClient": False,
"AsyncAppServerClient": False,
"InitializeResponse": False,
"ThreadStartParams": False,
"TurnStartParams": False,
"TurnCompletedNotification": False,
"TurnStatus": False,
}
def test_package_star_import_matches_public_api() -> None:
"""Star imports should follow the same explicit public API list."""
namespace: dict[str, object] = {}
exec("from codex_app_server import *", namespace)
exported = set(namespace) - {"__builtins__"}
assert exported == set(EXPECTED_ROOT_EXPORTS)
def test_types_module_exports_curated_public_types() -> None:
"""The public type module should be the supported place for app-server models."""
assert public_types.__all__ == EXPECTED_TYPES_EXPORTS
assert {name: hasattr(public_types, name) for name in EXPECTED_TYPES_EXPORTS} == {
name: True for name in EXPECTED_TYPES_EXPORTS
}
def test_types_star_import_matches_public_types() -> None:
"""Star imports from the type module should match its explicit export list."""
namespace: dict[str, object] = {}
exec("from codex_app_server.types import *", namespace)
exported = set(namespace) - {"__builtins__"}
assert exported == set(EXPECTED_TYPES_EXPORTS)
def test_examples_use_public_import_surfaces() -> None:
"""Examples should teach users the public root and type-module imports only."""
examples_root = Path(__file__).resolve().parents[1] / "examples"
private_import_markers = [
"codex_app_server.api",
"codex_app_server.client",
"codex_app_server.generated",
"codex_app_server.models",
"codex_app_server.retry",
]
offenders = {
str(path.relative_to(examples_root)): marker
for path in examples_root.rglob("*.py")
for marker in private_import_markers
if marker in path.read_text()
}
assert offenders == {}
def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"""Generated convenience methods should expose typed Pythonic keyword names."""
expected = {
@@ -228,6 +381,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
def test_lifecycle_methods_are_codex_scoped() -> None:
"""Lifecycle operations should hang off the client rather than thread objects."""
assert hasattr(Codex, "thread_resume")
assert hasattr(Codex, "thread_fork")
assert hasattr(Codex, "thread_archive")
@@ -258,6 +412,7 @@ def test_lifecycle_methods_are_codex_scoped() -> None:
def test_initialize_metadata_parses_user_agent_shape() -> None:
"""Initialize metadata should accept the legacy user-agent-only payload shape."""
payload = InitializeResponse.model_validate({"userAgent": "codex-cli/1.2.3"})
parsed = Codex._validate_initialize(payload)
assert parsed is payload
@@ -268,6 +423,7 @@ def test_initialize_metadata_parses_user_agent_shape() -> None:
def test_initialize_metadata_requires_non_empty_information() -> None:
"""Initialize metadata should fail when the runtime gives no identity signal."""
try:
Codex._validate_initialize(InitializeResponse.model_validate({}))
except RuntimeError as exc: