Publish Python SDK with Codex-pinned versioning (#18996)

**note**: a large chunk of this diff comes from regenerating Python
types after app-server schema changes on `main`.

This is PR 3 of 3 for the Python SDK PyPI publishing split. PR #18862
refreshed the generated SDK surface, and PR #18865 made the runtime
package publishable as `openai-codex-cli-bin`; this final PR makes the
SDK package publishable as `openai-codex-app-server-sdk` and pins both
packages to the same Codex runtime version.

The key idea is that the published SDK version is the Codex runtime
version. That one version now drives the SDK package version, the exact
runtime dependency, the client version reported by the SDK, and the
bootstrap runtime pin. This keeps release-time versioning in one lane
instead of scattering checked-in literals through the package.

## What changed

- Rename the SDK distribution from `codex-app-server-sdk` to
`openai-codex-app-server-sdk` for conflict-free PyPI publishing.
- Use `stage-sdk --codex-version ...` with one Codex version for both
the SDK package version and exact `openai-codex-cli-bin` dependency.
- Preserve hidden legacy `--runtime-version` / `--sdk-version` args only
to reject mismatched versions during staging.
- Map PEP 440 package versions back to Codex release tags for runtime
setup downloads, e.g. `0.116.0a1` -> `rust-v0.116.0-alpha.1`.
- Derive `codex_app_server.__version__`, the default
`AppServerConfig.client_version`, and
`_runtime_setup.pinned_runtime_version()` from the SDK package/project
version instead of hardcoding duplicate version strings.
- Carry the current generated SDK refresh from `main` so
`generate-types` stays clean after recent app-server schema changes.
- Update `sdk/python/uv.lock` for the renamed editable package.

## Validation

- `uv run --extra dev pytest` in `sdk/python` -> 59 passed, 37 skipped.
- Targeted `uv run ruff check` for the touched SDK files.
- `git diff --check`.
- Staged runtime with `--codex-version rust-v0.116.0-alpha.1
--platform-tag macosx_11_0_arm64`.
- Staged SDK with `--codex-version rust-v0.116.0-alpha.1`.
- Built runtime wheel, SDK wheel, and SDK sdist.
- `twine check /tmp/codex-python-pr3-build/dist/*` -> passed.
- Clean venv smoke installed `openai-codex-app-server-sdk==0.116.0a1`
from local dist and pulled `openai-codex-cli-bin==0.116.0a1`.
- Smoke imports passed for `Codex` and `bundled_codex_path()`.
This commit is contained in:
Steve Coffey
2026-04-27 14:28:46 -07:00
committed by GitHub
Unverified
parent 4ded800374
commit 0f40261e86
16 changed files with 1443 additions and 441 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Codex CLI Runtime for Python SDK
Platform-specific runtime package consumed by the published `codex-app-server-sdk`.
Platform-specific runtime package consumed by the published `openai-codex-app-server-sdk`.
This package is staged during release so the SDK can pin an exact Codex CLI
version without checking platform binaries into the repo.
+20 -14
View File
@@ -12,10 +12,11 @@ uv sync
source .venv/bin/activate
```
Published SDK builds pin an exact `openai-codex-cli-bin` runtime dependency. For local
repo development, either pass `AppServerConfig(codex_bin=...)` to point at a
local build explicitly, or use the repo examples/notebook bootstrap which
installs the pinned runtime package automatically.
Published SDK builds pin an exact `openai-codex-cli-bin` runtime dependency
with the same version as the SDK. For local repo development, either pass
`AppServerConfig(codex_bin=...)` to point at a local build explicitly, or use
the repo examples/notebook bootstrap which installs the pinned runtime package
automatically.
## Quickstart
@@ -54,9 +55,9 @@ python examples/01_quickstart_constructor/async.py
The repo no longer checks `codex` binaries into `sdk/python`.
Published SDK builds are pinned to an exact `openai-codex-cli-bin` package version,
and that runtime package carries the platform-specific binary for the target
wheel.
Published SDK builds are pinned to an exact `openai-codex-cli-bin` package
version, and that runtime package carries the platform-specific binary for the
target wheel. The SDK package version and runtime package version must match.
For local repo development, the checked-in `sdk/python-runtime` package is only
a template for staged release artifacts. Editable installs should use an
@@ -70,30 +71,35 @@ cd sdk/python
python scripts/update_sdk_artifacts.py generate-types
python scripts/update_sdk_artifacts.py \
stage-sdk \
/tmp/codex-python-release/codex-app-server-sdk \
--runtime-version 1.2.3
/tmp/codex-python-release/openai-codex-app-server-sdk \
--codex-version <codex-release-tag-or-pep440-version>
python scripts/update_sdk_artifacts.py \
stage-runtime \
/tmp/codex-python-release/openai-codex-cli-bin \
/path/to/codex \
--runtime-version 1.2.3
--codex-version <codex-release-tag-or-pep440-version>
```
Pass `--platform-tag ...` to `stage-runtime` when the wheel should be tagged for
a Rust target that differs from the Python build host. The intended one-off
matrix is `macosx_11_0_arm64`, `macosx_10_9_x86_64`,
`musllinux_1_1_aarch64`, `musllinux_1_1_x86_64`, `win_arm64`, and
`win_amd64`.
This supports the CI release flow:
- run `generate-types` before packaging
- stage `codex-app-server-sdk` once with an exact `openai-codex-cli-bin==...` dependency
- stage `openai-codex-app-server-sdk` once with an exact `openai-codex-cli-bin==...` dependency
- stage `openai-codex-cli-bin` on each supported platform runner with the same pinned runtime version
- build and publish `openai-codex-cli-bin` as platform wheels only; do not publish an sdist
## Compatibility and versioning
- Package: `codex-app-server-sdk`
- Package: `openai-codex-app-server-sdk`
- Runtime package: `openai-codex-cli-bin`
- Current SDK version in this repo: `0.2.0`
- Python: `>=3.10`
- Target protocol: Codex `app-server` JSON-RPC v2
- Recommendation: keep SDK and `codex` CLI reasonably up to date together
- Versioning rule: the SDK package version is the underlying Codex runtime version
## Notes
+79 -15
View File
@@ -1,10 +1,12 @@
from __future__ import annotations
import importlib
import importlib.metadata
import importlib.util
import json
import os
import platform
import re
import shutil
import subprocess
import sys
@@ -16,7 +18,7 @@ import zipfile
from pathlib import Path
PACKAGE_NAME = "openai-codex-cli-bin"
PINNED_RUNTIME_VERSION = "0.116.0-alpha.1"
SDK_PACKAGE_NAME = "openai-codex-app-server-sdk"
REPO_SLUG = "openai/codex"
@@ -25,7 +27,16 @@ class RuntimeSetupError(RuntimeError):
def pinned_runtime_version() -> str:
return PINNED_RUNTIME_VERSION
source_version = _source_tree_project_version()
if source_version is not None:
return _normalized_package_version(source_version)
try:
return _normalized_package_version(importlib.metadata.version(SDK_PACKAGE_NAME))
except importlib.metadata.PackageNotFoundError as exc:
raise RuntimeSetupError(
f"Unable to resolve {SDK_PACKAGE_NAME} version for runtime pinning."
) from exc
def ensure_runtime_package_installed(
@@ -39,7 +50,10 @@ def ensure_runtime_package_installed(
installed_version = _installed_runtime_version(python_executable)
normalized_requested = _normalized_package_version(requested_version)
if installed_version is not None and _normalized_package_version(installed_version) == normalized_requested:
if (
installed_version is not None
and _normalized_package_version(installed_version) == normalized_requested
):
return requested_version
with tempfile.TemporaryDirectory(prefix="codex-python-runtime-") as temp_root_str:
@@ -61,7 +75,10 @@ def ensure_runtime_package_installed(
importlib.invalidate_caches()
installed_version = _installed_runtime_version(python_executable)
if installed_version is None or _normalized_package_version(installed_version) != normalized_requested:
if (
installed_version is None
or _normalized_package_version(installed_version) != normalized_requested
):
raise RuntimeSetupError(
f"Expected {PACKAGE_NAME} {requested_version} in {python_executable}, "
f"but found {installed_version!r} after installation."
@@ -121,7 +138,8 @@ def _installed_runtime_version(python_executable: str | Path) -> str | None:
def _release_metadata(version: str) -> dict[str, object]:
url = f"https://api.github.com/repos/{REPO_SLUG}/releases/tags/rust-v{version}"
release_tag = _release_tag(version)
url = f"https://api.github.com/repos/{REPO_SLUG}/releases/tags/{release_tag}"
token = _github_token()
attempts = [True, False] if token is not None else [False]
last_error: urllib.error.HTTPError | None = None
@@ -146,7 +164,7 @@ def _release_metadata(version: str) -> dict[str, object]:
assert last_error is not None
raise RuntimeSetupError(
f"Failed to resolve release metadata for rust-v{version} from {REPO_SLUG}: "
f"Failed to resolve release metadata for {release_tag} from {REPO_SLUG}: "
f"{last_error.code} {last_error.reason}"
) from last_error
@@ -154,9 +172,10 @@ def _release_metadata(version: str) -> dict[str, object]:
def _download_release_archive(version: str, temp_root: Path) -> Path:
asset_name = platform_asset_name()
archive_path = temp_root / asset_name
release_tag = _release_tag(version)
browser_download_url = (
f"https://github.com/{REPO_SLUG}/releases/download/rust-v{version}/{asset_name}"
f"https://github.com/{REPO_SLUG}/releases/download/{release_tag}/{asset_name}"
)
request = urllib.request.Request(
browser_download_url,
@@ -172,7 +191,9 @@ def _download_release_archive(version: str, temp_root: Path) -> Path:
metadata = _release_metadata(version)
assets = metadata.get("assets")
if not isinstance(assets, list):
raise RuntimeSetupError(f"Release rust-v{version} returned malformed assets metadata.")
raise RuntimeSetupError(
f"Release {release_tag} returned malformed assets metadata."
)
asset = next(
(
item
@@ -183,7 +204,7 @@ def _download_release_archive(version: str, temp_root: Path) -> Path:
)
if asset is None:
raise RuntimeSetupError(
f"Release rust-v{version} does not contain asset {asset_name} for this platform."
f"Release {release_tag} does not contain asset {asset_name} for this platform."
)
api_url = asset.get("url")
@@ -198,7 +219,10 @@ def _download_release_archive(version: str, temp_root: Path) -> Path:
headers=_github_api_headers("application/octet-stream"),
)
try:
with urllib.request.urlopen(request) as response, archive_path.open("wb") as fh:
with (
urllib.request.urlopen(request) as response,
archive_path.open("wb") as fh,
):
shutil.copyfileobj(response, fh)
return archive_path
except urllib.error.HTTPError:
@@ -216,7 +240,7 @@ def _download_release_archive(version: str, temp_root: Path) -> Path:
"gh",
"release",
"download",
f"rust-v{version}",
release_tag,
"--repo",
REPO_SLUG,
"--pattern",
@@ -230,7 +254,7 @@ def _download_release_archive(version: str, temp_root: Path) -> Path:
)
except subprocess.CalledProcessError as exc:
raise RuntimeSetupError(
f"gh release download failed for rust-v{version} asset {asset_name}.\n"
f"gh release download failed for {release_tag} asset {asset_name}.\n"
f"STDOUT:\n{exc.stdout}\nSTDERR:\n{exc.stderr}"
) from exc
return archive_path
@@ -249,7 +273,9 @@ def _extract_runtime_binary(archive_path: Path, temp_root: Path) -> Path:
with zipfile.ZipFile(archive_path) as zip_file:
zip_file.extractall(extract_dir)
else:
raise RuntimeSetupError(f"Unsupported release archive format: {archive_path.name}")
raise RuntimeSetupError(
f"Unsupported release archive format: {archive_path.name}"
)
binary_name = runtime_binary_name()
archive_stem = archive_path.name.removesuffix(".tar.gz").removesuffix(".zip")
@@ -346,12 +372,50 @@ def _github_token() -> str | None:
def _normalized_package_version(version: str) -> str:
return version.strip().replace("-alpha.", "a").replace("-beta.", "b")
normalized = version.strip()
if normalized.startswith("rust-v"):
normalized = normalized.removeprefix("rust-v")
elif normalized.startswith("v"):
normalized = normalized.removeprefix("v")
normalized = re.sub(r"-alpha\.?([0-9]+)$", r"a\1", normalized)
normalized = re.sub(r"-beta\.?([0-9]+)$", r"b\1", normalized)
normalized = re.sub(r"-rc\.?([0-9]+)$", r"rc\1", normalized)
return normalized
def _codex_release_version(version: str) -> str:
normalized = _normalized_package_version(version)
match = re.fullmatch(r"([0-9]+(?:\.[0-9]+)*)(a|b|rc)([0-9]+)", normalized)
if match is None:
return normalized
base, prerelease, number = match.groups()
prerelease_name = {"a": "alpha", "b": "beta", "rc": "rc"}[prerelease]
return f"{base}-{prerelease_name}.{number}"
def _release_tag(version: str) -> str:
return f"rust-v{_codex_release_version(version)}"
def _source_tree_project_version() -> str | None:
pyproject_path = Path(__file__).resolve().parent / "pyproject.toml"
if not pyproject_path.exists():
return None
match = re.search(
r'(?m)^version = "([^"]+)"$',
pyproject_path.read_text(encoding="utf-8"),
)
if match is None:
return None
return match.group(1)
__all__ = [
"PACKAGE_NAME",
"PINNED_RUNTIME_VERSION",
"SDK_PACKAGE_NAME",
"RuntimeSetupError",
"ensure_runtime_package_installed",
"pinned_runtime_version",
+10 -5
View File
@@ -60,23 +60,28 @@ Common causes:
- incompatible/old app-server
Maintainers stage releases by building the SDK once and the runtime once per
platform with the same pinned runtime version. Publish `openai-codex-cli-bin` as
platform wheels only; do not publish an sdist:
platform with the same pinned runtime version. Publish `openai-codex-cli-bin`
as platform wheels only; do not publish an sdist:
```bash
cd sdk/python
python scripts/update_sdk_artifacts.py generate-types
python scripts/update_sdk_artifacts.py \
stage-sdk \
/tmp/codex-python-release/codex-app-server-sdk \
--runtime-version 1.2.3
/tmp/codex-python-release/openai-codex-app-server-sdk \
--codex-version <codex-release-tag-or-pep440-version>
python scripts/update_sdk_artifacts.py \
stage-runtime \
/tmp/codex-python-release/openai-codex-cli-bin \
/path/to/codex \
--runtime-version 1.2.3
--codex-version <codex-release-tag-or-pep440-version>
```
If you are packaging a binary for a different target than the Python build
host, pass `--platform-tag ...` to `stage-runtime`. The intended one-off matrix
is `macosx_11_0_arm64`, `macosx_10_9_x86_64`, `musllinux_1_1_aarch64`,
`musllinux_1_1_x86_64`, `win_arm64`, and `win_amd64`.
## Why does a turn "hang"?
A turn is complete only when `turn/completed` arrives for that turn ID.
+1 -1
View File
@@ -28,7 +28,7 @@ will download the matching GitHub release artifact, stage a temporary local
`openai-codex-cli-bin` package, install it into your active interpreter, and clean up
the temporary files afterward.
Current pinned runtime version: `0.116.0-alpha.1`
The pinned runtime version comes from the SDK package version.
## Run examples
+2 -2
View File
@@ -3,8 +3,8 @@ requires = ["hatchling>=1.24.0"]
build-backend = "hatchling.build"
[project]
name = "codex-app-server-sdk"
version = "0.2.0"
name = "openai-codex-app-server-sdk"
version = "0.116.0a1"
description = "Python SDK for Codex app-server v2"
readme = "README.md"
requires-python = ">=3.10"
+31 -17
View File
@@ -17,6 +17,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Sequence, get_args, get_origin
SDK_DISTRIBUTION_NAME = "openai-codex-app-server-sdk"
RUNTIME_DISTRIBUTION_NAME = "openai-codex-cli-bin"
@@ -178,15 +179,19 @@ def _rewrite_sdk_runtime_dependency(pyproject_text: str, runtime_version: str) -
)
raw_items = [item.strip() for item in match.group(1).split(",") if item.strip()]
raw_items = [item for item in raw_items if "codex-cli-bin" not in item]
raw_items = [
item
for item in raw_items
if RUNTIME_DISTRIBUTION_NAME.removeprefix("openai-") not in item
and RUNTIME_DISTRIBUTION_NAME not in item
]
raw_items.append(f'"{RUNTIME_DISTRIBUTION_NAME}=={runtime_version}"')
replacement = "dependencies = [\n " + ",\n ".join(raw_items) + ",\n]"
return pyproject_text[: match.start()] + replacement + pyproject_text[match.end() :]
def stage_python_sdk_package(
staging_dir: Path, sdk_version: str, runtime_version: str
) -> Path:
def stage_python_sdk_package(staging_dir: Path, codex_version: str) -> Path:
package_version = normalize_codex_version(codex_version)
_copy_package_tree(sdk_root(), staging_dir)
sdk_bin_dir = staging_dir / "src" / "codex_app_server" / "bin"
if sdk_bin_dir.exists():
@@ -194,8 +199,9 @@ def stage_python_sdk_package(
pyproject_path = staging_dir / "pyproject.toml"
pyproject_text = pyproject_path.read_text()
pyproject_text = _rewrite_project_version(pyproject_text, sdk_version)
pyproject_text = _rewrite_sdk_runtime_dependency(pyproject_text, runtime_version)
pyproject_text = _rewrite_project_name(pyproject_text, SDK_DISTRIBUTION_NAME)
pyproject_text = _rewrite_project_version(pyproject_text, package_version)
pyproject_text = _rewrite_sdk_runtime_dependency(pyproject_text, package_version)
pyproject_path.write_text(pyproject_text)
return staging_dir
@@ -625,7 +631,7 @@ class PublicFieldSpec:
@dataclass(frozen=True)
class CliOps:
generate_types: Callable[[], None]
stage_python_sdk_package: Callable[[Path, str, str], Path]
stage_python_sdk_package: Callable[[Path, str], Path]
stage_python_runtime_package: Callable[[Path, str, Path, str | None], Path]
current_sdk_version: Callable[[], str]
@@ -992,14 +998,21 @@ def build_parser() -> argparse.ArgumentParser:
type=Path,
help="Output directory for the staged SDK package",
)
stage_sdk_parser.add_argument(
"--codex-version",
help=(
"Codex release version to write into the staged SDK package and exact "
f"{RUNTIME_DISTRIBUTION_NAME} dependency. Accepts PEP 440 versions "
"or release tags such as rust-v0.116.0-alpha.1."
),
)
stage_sdk_parser.add_argument(
"--runtime-version",
required=True,
help="Pinned openai-codex-cli-bin version for the staged SDK package",
help=argparse.SUPPRESS,
)
stage_sdk_parser.add_argument(
"--sdk-version",
help="Version to write into the staged SDK package (defaults to sdk/python current version)",
help=argparse.SUPPRESS,
)
stage_runtime_parser = subparsers.add_parser(
@@ -1050,22 +1063,23 @@ def default_cli_ops() -> CliOps:
)
def _resolve_runtime_version(args: argparse.Namespace) -> str:
def _resolve_codex_version(args: argparse.Namespace) -> str:
versions = [
value
for value in (
getattr(args, "codex_version", None),
getattr(args, "runtime_version", None),
getattr(args, "sdk_version", None),
)
if value is not None
]
if not versions:
raise RuntimeError("Pass --codex-version to stage the Python runtime package")
raise RuntimeError("Pass --codex-version to stage Python release artifacts")
normalized_versions = [normalize_codex_version(version) for version in versions]
if len(set(normalized_versions)) != 1:
raise RuntimeError(
"Runtime package versions must match; pass one --codex-version"
"SDK and runtime package versions must match; pass one --codex-version"
)
return normalized_versions[0]
@@ -1074,17 +1088,17 @@ def run_command(args: argparse.Namespace, ops: CliOps) -> None:
if args.command == "generate-types":
ops.generate_types()
elif args.command == "stage-sdk":
codex_version = _resolve_codex_version(args)
ops.generate_types()
ops.stage_python_sdk_package(
args.staging_dir,
args.sdk_version or ops.current_sdk_version(),
args.runtime_version,
codex_version,
)
elif args.command == "stage-runtime":
runtime_version = _resolve_runtime_version(args)
codex_version = _resolve_codex_version(args)
ops.stage_python_runtime_package(
args.staging_dir,
runtime_version,
codex_version,
args.runtime_binary.resolve(),
args.platform_tag,
)
+1 -2
View File
@@ -54,8 +54,7 @@ from .api import (
TurnHandle,
)
from .retry import retry_on_overload
__version__ = "0.2.0"
from ._version import __version__
__all__ = [
"__version__",
@@ -0,0 +1,37 @@
from __future__ import annotations
import re
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as distribution_version
from pathlib import Path
DISTRIBUTION_NAME = "openai-codex-app-server-sdk"
UNKNOWN_VERSION = "0+unknown"
def package_version() -> str:
source_version = _source_tree_project_version()
if source_version is not None:
return source_version
try:
return distribution_version(DISTRIBUTION_NAME)
except PackageNotFoundError:
return UNKNOWN_VERSION
def _source_tree_project_version() -> str | None:
pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
if not pyproject_path.exists():
return None
match = re.search(
r'(?m)^version = "([^"]+)"$',
pyproject_path.read_text(encoding="utf-8"),
)
if match is None:
return None
return match.group(1)
__version__ = package_version()
+38 -2
View File
@@ -10,15 +10,18 @@ from .generated.v2_all import (
ApprovalsReviewer,
AskForApproval,
ModelListResponse,
PermissionProfile,
Personality,
ReasoningEffort,
ReasoningSummary,
SandboxMode,
SandboxPolicy,
ServiceTier,
SortDirection,
ThreadArchiveResponse,
ThreadCompactStartResponse,
ThreadForkParams,
ThreadListCwdFilter,
ThreadListParams,
ThreadListResponse,
ThreadReadResponse,
@@ -26,6 +29,7 @@ from .generated.v2_all import (
ThreadSetNameResponse,
ThreadSortKey,
ThreadSourceKind,
ThreadStartSource,
ThreadStartParams,
Turn as AppServerTurn,
TurnCompletedNotification,
@@ -146,6 +150,7 @@ class Codex:
ephemeral: bool | None = None,
model: str | None = None,
model_provider: str | None = None,
permission_profile: PermissionProfile | None = None,
personality: Personality | None = None,
sandbox: SandboxMode | None = None,
service_name: str | None = None,
@@ -162,6 +167,7 @@ class Codex:
ephemeral=ephemeral,
model=model,
model_provider=model_provider,
permission_profile=permission_profile,
personality=personality,
sandbox=sandbox,
service_name=service_name,
@@ -176,13 +182,14 @@ class Codex:
*,
archived: bool | None = None,
cursor: str | None = None,
cwd: str | None = None,
cwd: ThreadListCwdFilter | None = None,
limit: int | None = None,
model_providers: list[str] | None = None,
search_term: str | None = None,
sort_direction: SortDirection | None = None,
sort_key: ThreadSortKey | None = None,
source_kinds: list[ThreadSourceKind] | None = None,
use_state_db_only: bool | None = None,
) -> ThreadListResponse:
params = ThreadListParams(
archived=archived,
@@ -194,6 +201,7 @@ class Codex:
sort_direction=sort_direction,
sort_key=sort_key,
source_kinds=source_kinds,
use_state_db_only=use_state_db_only,
)
return self._client.thread_list(params)
@@ -207,8 +215,10 @@ class Codex:
config: JsonObject | None = None,
cwd: str | None = None,
developer_instructions: str | None = None,
exclude_turns: bool | None = None,
model: str | None = None,
model_provider: str | None = None,
permission_profile: PermissionProfile | None = None,
personality: Personality | None = None,
sandbox: SandboxMode | None = None,
service_tier: ServiceTier | None = None,
@@ -221,8 +231,10 @@ class Codex:
config=config,
cwd=cwd,
developer_instructions=developer_instructions,
exclude_turns=exclude_turns,
model=model,
model_provider=model_provider,
permission_profile=permission_profile,
personality=personality,
sandbox=sandbox,
service_tier=service_tier,
@@ -241,8 +253,10 @@ class Codex:
cwd: str | None = None,
developer_instructions: str | None = None,
ephemeral: bool | None = None,
exclude_turns: bool | None = None,
model: str | None = None,
model_provider: str | None = None,
permission_profile: PermissionProfile | None = None,
sandbox: SandboxMode | None = None,
service_tier: ServiceTier | None = None,
) -> Thread:
@@ -255,8 +269,10 @@ class Codex:
cwd=cwd,
developer_instructions=developer_instructions,
ephemeral=ephemeral,
exclude_turns=exclude_turns,
model=model,
model_provider=model_provider,
permission_profile=permission_profile,
sandbox=sandbox,
service_tier=service_tier,
)
@@ -340,6 +356,7 @@ class AsyncCodex:
ephemeral: bool | None = None,
model: str | None = None,
model_provider: str | None = None,
permission_profile: PermissionProfile | None = None,
personality: Personality | None = None,
sandbox: SandboxMode | None = None,
service_name: str | None = None,
@@ -357,6 +374,7 @@ class AsyncCodex:
ephemeral=ephemeral,
model=model,
model_provider=model_provider,
permission_profile=permission_profile,
personality=personality,
sandbox=sandbox,
service_name=service_name,
@@ -371,13 +389,14 @@ class AsyncCodex:
*,
archived: bool | None = None,
cursor: str | None = None,
cwd: str | None = None,
cwd: ThreadListCwdFilter | None = None,
limit: int | None = None,
model_providers: list[str] | None = None,
search_term: str | None = None,
sort_direction: SortDirection | None = None,
sort_key: ThreadSortKey | None = None,
source_kinds: list[ThreadSourceKind] | None = None,
use_state_db_only: bool | None = None,
) -> ThreadListResponse:
await self._ensure_initialized()
params = ThreadListParams(
@@ -390,6 +409,7 @@ class AsyncCodex:
sort_direction=sort_direction,
sort_key=sort_key,
source_kinds=source_kinds,
use_state_db_only=use_state_db_only,
)
return await self._client.thread_list(params)
@@ -403,8 +423,10 @@ class AsyncCodex:
config: JsonObject | None = None,
cwd: str | None = None,
developer_instructions: str | None = None,
exclude_turns: bool | None = None,
model: str | None = None,
model_provider: str | None = None,
permission_profile: PermissionProfile | None = None,
personality: Personality | None = None,
sandbox: SandboxMode | None = None,
service_tier: ServiceTier | None = None,
@@ -418,8 +440,10 @@ class AsyncCodex:
config=config,
cwd=cwd,
developer_instructions=developer_instructions,
exclude_turns=exclude_turns,
model=model,
model_provider=model_provider,
permission_profile=permission_profile,
personality=personality,
sandbox=sandbox,
service_tier=service_tier,
@@ -438,8 +462,10 @@ class AsyncCodex:
cwd: str | None = None,
developer_instructions: str | None = None,
ephemeral: bool | None = None,
exclude_turns: bool | None = None,
model: str | None = None,
model_provider: str | None = None,
permission_profile: PermissionProfile | None = None,
sandbox: SandboxMode | None = None,
service_tier: ServiceTier | None = None,
) -> AsyncThread:
@@ -453,8 +479,10 @@ class AsyncCodex:
cwd=cwd,
developer_instructions=developer_instructions,
ephemeral=ephemeral,
exclude_turns=exclude_turns,
model=model,
model_provider=model_provider,
permission_profile=permission_profile,
sandbox=sandbox,
service_tier=service_tier,
)
@@ -491,6 +519,7 @@ class Thread:
effort: ReasoningEffort | None = None,
model: str | None = None,
output_schema: JsonObject | None = None,
permission_profile: PermissionProfile | None = None,
personality: Personality | None = None,
sandbox_policy: SandboxPolicy | None = None,
service_tier: ServiceTier | None = None,
@@ -504,6 +533,7 @@ class Thread:
effort=effort,
model=model,
output_schema=output_schema,
permission_profile=permission_profile,
personality=personality,
sandbox_policy=sandbox_policy,
service_tier=service_tier,
@@ -526,6 +556,7 @@ class Thread:
effort: ReasoningEffort | None = None,
model: str | None = None,
output_schema: JsonObject | None = None,
permission_profile: PermissionProfile | None = None,
personality: Personality | None = None,
sandbox_policy: SandboxPolicy | None = None,
service_tier: ServiceTier | None = None,
@@ -541,6 +572,7 @@ class Thread:
effort=effort,
model=model,
output_schema=output_schema,
permission_profile=permission_profile,
personality=personality,
sandbox_policy=sandbox_policy,
service_tier=service_tier,
@@ -575,6 +607,7 @@ class AsyncThread:
effort: ReasoningEffort | None = None,
model: str | None = None,
output_schema: JsonObject | None = None,
permission_profile: PermissionProfile | None = None,
personality: Personality | None = None,
sandbox_policy: SandboxPolicy | None = None,
service_tier: ServiceTier | None = None,
@@ -588,6 +621,7 @@ class AsyncThread:
effort=effort,
model=model,
output_schema=output_schema,
permission_profile=permission_profile,
personality=personality,
sandbox_policy=sandbox_policy,
service_tier=service_tier,
@@ -610,6 +644,7 @@ class AsyncThread:
effort: ReasoningEffort | None = None,
model: str | None = None,
output_schema: JsonObject | None = None,
permission_profile: PermissionProfile | None = None,
personality: Personality | None = None,
sandbox_policy: SandboxPolicy | None = None,
service_tier: ServiceTier | None = None,
@@ -626,6 +661,7 @@ class AsyncThread:
effort=effort,
model=model,
output_schema=output_schema,
permission_profile=permission_profile,
personality=personality,
sandbox_policy=sandbox_policy,
service_tier=service_tier,
+2 -1
View File
@@ -44,6 +44,7 @@ from .models import (
UnknownNotification,
)
from .retry import retry_on_overload
from ._version import __version__ as SDK_VERSION
ModelT = TypeVar("ModelT", bound=BaseModel)
ApprovalHandler = Callable[[str, JsonObject | None], JsonObject]
@@ -129,7 +130,7 @@ class AppServerConfig:
env: dict[str, str] | None = None
client_name: str = "codex_python_sdk"
client_title: str = "Codex Python SDK"
client_version: str = "0.2.0"
client_version: str = SDK_VERSION
experimental_api: bool = True
@@ -22,6 +22,7 @@ from .v2_all import FileChangePatchUpdatedNotification
from .v2_all import FsChangedNotification
from .v2_all import FuzzyFileSearchSessionCompletedNotification
from .v2_all import FuzzyFileSearchSessionUpdatedNotification
from .v2_all import GuardianWarningNotification
from .v2_all import HookCompletedNotification
from .v2_all import HookStartedNotification
from .v2_all import ItemCompletedNotification
@@ -32,6 +33,7 @@ from .v2_all import McpServerOauthLoginCompletedNotification
from .v2_all import McpServerStatusUpdatedNotification
from .v2_all import McpToolCallProgressNotification
from .v2_all import ModelReroutedNotification
from .v2_all import ModelVerificationNotification
from .v2_all import PlanDeltaNotification
from .v2_all import ReasoningSummaryPartAddedNotification
from .v2_all import ReasoningSummaryTextDeltaNotification
@@ -41,6 +43,8 @@ from .v2_all import SkillsChangedNotification
from .v2_all import TerminalInteractionNotification
from .v2_all import ThreadArchivedNotification
from .v2_all import ThreadClosedNotification
from .v2_all import ThreadGoalClearedNotification
from .v2_all import ThreadGoalUpdatedNotification
from .v2_all import ThreadNameUpdatedNotification
from .v2_all import ThreadRealtimeClosedNotification
from .v2_all import ThreadRealtimeErrorNotification
@@ -75,6 +79,7 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
"fs/changed": FsChangedNotification,
"fuzzyFileSearch/sessionCompleted": FuzzyFileSearchSessionCompletedNotification,
"fuzzyFileSearch/sessionUpdated": FuzzyFileSearchSessionUpdatedNotification,
"guardianWarning": GuardianWarningNotification,
"hook/completed": HookCompletedNotification,
"hook/started": HookStartedNotification,
"item/agentMessage/delta": AgentMessageDeltaNotification,
@@ -94,11 +99,14 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
"mcpServer/oauthLogin/completed": McpServerOauthLoginCompletedNotification,
"mcpServer/startupStatus/updated": McpServerStatusUpdatedNotification,
"model/rerouted": ModelReroutedNotification,
"model/verification": ModelVerificationNotification,
"serverRequest/resolved": ServerRequestResolvedNotification,
"skills/changed": SkillsChangedNotification,
"thread/archived": ThreadArchivedNotification,
"thread/closed": ThreadClosedNotification,
"thread/compacted": ContextCompactedNotification,
"thread/goal/cleared": ThreadGoalClearedNotification,
"thread/goal/updated": ThreadGoalUpdatedNotification,
"thread/name/updated": ThreadNameUpdatedNotification,
"thread/realtime/closed": ThreadRealtimeClosedNotification,
"thread/realtime/error": ThreadRealtimeErrorNotification,
File diff suppressed because it is too large Load Diff
@@ -29,7 +29,9 @@ def _load_runtime_setup_module():
runtime_setup_path = ROOT / "_runtime_setup.py"
spec = importlib.util.spec_from_file_location("_runtime_setup", runtime_setup_path)
if spec is None or spec.loader is None:
raise AssertionError(f"Failed to load runtime setup module: {runtime_setup_path}")
raise AssertionError(
f"Failed to load runtime setup module: {runtime_setup_path}"
)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
@@ -159,29 +161,32 @@ def test_runtime_package_template_has_no_checked_in_binaries() -> None:
) == ["__init__.py"]
def test_examples_readme_matches_pinned_runtime_version() -> None:
runtime_setup = _load_runtime_setup_module()
def test_examples_readme_points_to_runtime_version_source_of_truth() -> None:
readme = (ROOT / "examples" / "README.md").read_text()
assert (
f"Current pinned runtime version: `{runtime_setup.pinned_runtime_version()}`"
in readme
)
assert "The pinned runtime version comes from the SDK package version." in readme
def test_runtime_distribution_name_is_consistent() -> None:
script = _load_update_script_module()
runtime_setup = _load_runtime_setup_module()
from codex_app_server import client as client_module
from codex_app_server import _version
assert script.SDK_DISTRIBUTION_NAME == "openai-codex-app-server-sdk"
assert runtime_setup.SDK_PACKAGE_NAME == "openai-codex-app-server-sdk"
assert _version.DISTRIBUTION_NAME == "openai-codex-app-server-sdk"
assert script.RUNTIME_DISTRIBUTION_NAME == "openai-codex-cli-bin"
assert runtime_setup.PACKAGE_NAME == "openai-codex-cli-bin"
assert client_module.RUNTIME_PKG_NAME == "openai-codex-cli-bin"
assert "importlib.metadata.version('codex-cli-bin')" not in (
ROOT / "_runtime_setup.py"
).read_text()
assert (
"importlib.metadata.version('codex-cli-bin')"
not in (ROOT / "_runtime_setup.py").read_text()
)
def test_release_metadata_retries_without_invalid_auth(monkeypatch: pytest.MonkeyPatch) -> None:
def test_release_metadata_retries_without_invalid_auth(
monkeypatch: pytest.MonkeyPatch,
) -> None:
runtime_setup = _load_runtime_setup_module()
authorizations: list[str | None] = []
@@ -205,6 +210,19 @@ def test_release_metadata_retries_without_invalid_auth(monkeypatch: pytest.Monke
assert authorizations == ["Bearer invalid-token", None]
def test_runtime_setup_uses_pep440_package_version_and_codex_release_tags() -> None:
runtime_setup = _load_runtime_setup_module()
pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text())
assert runtime_setup.PACKAGE_NAME == "openai-codex-cli-bin"
assert runtime_setup.pinned_runtime_version() == pyproject["project"]["version"]
assert (
runtime_setup._normalized_package_version("rust-v0.116.0-alpha.1")
== "0.116.0a1"
)
assert runtime_setup._release_tag("0.116.0a1") == "rust-v0.116.0-alpha.1"
def test_runtime_package_is_wheel_only_and_builds_platform_specific_wheels() -> None:
pyproject = tomllib.loads(
(ROOT.parent / "python-runtime" / "pyproject.toml").read_text()
@@ -334,12 +352,23 @@ def test_stage_runtime_release_can_pin_wheel_platform_tag(tmp_path: Path) -> Non
def test_stage_sdk_release_injects_exact_runtime_pin(tmp_path: Path) -> None:
script = _load_update_script_module()
staged = script.stage_python_sdk_package(tmp_path / "sdk-stage", "0.2.1", "1.2.3")
staged = script.stage_python_sdk_package(
tmp_path / "sdk-stage",
"rust-v0.116.0-alpha.1",
)
pyproject = (staged / "pyproject.toml").read_text()
assert 'version = "0.2.1"' in pyproject
assert '"openai-codex-cli-bin==1.2.3"' in pyproject
assert '"codex-cli-bin==1.2.3"' not in pyproject
assert 'name = "openai-codex-app-server-sdk"' in pyproject
assert 'version = "0.116.0a1"' in pyproject
assert '"openai-codex-cli-bin==0.116.0a1"' in pyproject
assert (
'__version__ = "0.116.0a1"'
not in (staged / "src" / "codex_app_server" / "__init__.py").read_text()
)
assert (
'client_version: str = "0.116.0a1"'
not in (staged / "src" / "codex_app_server" / "client.py").read_text()
)
assert not any((staged / "src" / "codex_app_server").glob("bin/**"))
@@ -350,12 +379,39 @@ def test_stage_sdk_release_replaces_existing_staging_dir(tmp_path: Path) -> None
old_file.parent.mkdir(parents=True)
old_file.write_text("stale")
staged = script.stage_python_sdk_package(staging_dir, "0.2.1", "1.2.3")
staged = script.stage_python_sdk_package(staging_dir, "0.116.0a1")
assert staged == staging_dir
assert not old_file.exists()
def test_staged_sdk_and_runtime_versions_match(tmp_path: Path) -> None:
script = _load_update_script_module()
fake_binary = tmp_path / script.runtime_binary_name()
fake_binary.write_text("fake codex\n")
sdk_stage = script.stage_python_sdk_package(
tmp_path / "sdk-stage",
"rust-v0.116.0-alpha.1",
)
runtime_stage = script.stage_python_runtime_package(
tmp_path / "runtime-stage",
"rust-v0.116.0-alpha.1",
fake_binary,
)
sdk_pyproject = tomllib.loads((sdk_stage / "pyproject.toml").read_text())
runtime_pyproject = tomllib.loads((runtime_stage / "pyproject.toml").read_text())
assert (
sdk_pyproject["project"]["version"] == runtime_pyproject["project"]["version"]
)
assert sdk_pyproject["project"]["dependencies"] == [
"pydantic>=2.12",
"openai-codex-cli-bin==0.116.0a1",
]
def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None:
script = _load_update_script_module()
calls: list[str] = []
@@ -363,18 +419,16 @@ def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None:
[
"stage-sdk",
str(tmp_path / "sdk-stage"),
"--runtime-version",
"1.2.3",
"--codex-version",
"rust-v0.116.0-alpha.1",
]
)
def fake_generate_types() -> None:
calls.append("generate_types")
def fake_stage_sdk_package(
_staging_dir: Path, _sdk_version: str, _runtime_version: str
) -> Path:
calls.append("stage_sdk")
def fake_stage_sdk_package(_staging_dir: Path, codex_version: str) -> Path:
calls.append(f"stage_sdk:{codex_version}")
return tmp_path / "sdk-stage"
def fake_stage_runtime_package(
@@ -386,7 +440,7 @@ def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None:
raise AssertionError("runtime staging should not run for stage-sdk")
def fake_current_sdk_version() -> str:
return "0.2.0"
return "0.116.0a1"
ops = script.CliOps(
generate_types=fake_generate_types,
@@ -397,7 +451,26 @@ def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None:
script.run_command(args, ops)
assert calls == ["generate_types", "stage_sdk"]
assert calls == ["generate_types", "stage_sdk:0.116.0a1"]
def test_stage_sdk_rejects_mismatched_legacy_versions(tmp_path: Path) -> None:
script = _load_update_script_module()
args = script.parse_args(
[
"stage-sdk",
str(tmp_path / "sdk-stage"),
"--codex-version",
"0.116.0a1",
"--runtime-version",
"0.116.0a1",
"--sdk-version",
"0.115.0",
]
)
with pytest.raises(RuntimeError, match="versions must match"):
script.run_command(args, script.default_cli_ops())
def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) -> None:
@@ -420,9 +493,7 @@ def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) ->
def fake_generate_types() -> None:
calls.append("generate_types")
def fake_stage_sdk_package(
_staging_dir: Path, _sdk_version: str, _runtime_version: str
) -> Path:
def fake_stage_sdk_package(_staging_dir: Path, _codex_version: str) -> Path:
raise AssertionError("sdk staging should not run for stage-runtime")
def fake_stage_runtime_package(
@@ -435,7 +506,7 @@ def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) ->
return tmp_path / "runtime-stage"
def fake_current_sdk_version() -> str:
return "0.2.0"
return "0.116.0a1"
ops = script.CliOps(
generate_types=fake_generate_types,
@@ -2,8 +2,11 @@ from __future__ import annotations
import importlib.resources as resources
import inspect
import tomllib
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
@@ -37,6 +40,14 @@ def test_root_exports_run_result() -> None:
assert RunResult.__name__ == "RunResult"
def test_package_and_default_client_versions_follow_project_version() -> None:
pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml"
pyproject = tomllib.loads(pyproject_path.read_text())
assert codex_app_server.__version__ == pyproject["project"]["version"]
assert AppServerConfig().client_version == codex_app_server.__version__
def test_package_includes_py_typed_marker() -> None:
marker = resources.files("codex_app_server").joinpath("py.typed")
assert marker.is_file()
@@ -54,6 +65,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"ephemeral",
"model",
"model_provider",
"permission_profile",
"personality",
"sandbox",
"service_name",
@@ -70,6 +82,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"sort_direction",
"sort_key",
"source_kinds",
"use_state_db_only",
],
Codex.thread_resume: [
"approval_policy",
@@ -78,8 +91,10 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"config",
"cwd",
"developer_instructions",
"exclude_turns",
"model",
"model_provider",
"permission_profile",
"personality",
"sandbox",
"service_tier",
@@ -92,8 +107,10 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"cwd",
"developer_instructions",
"ephemeral",
"exclude_turns",
"model",
"model_provider",
"permission_profile",
"sandbox",
"service_tier",
],
@@ -104,6 +121,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"effort",
"model",
"output_schema",
"permission_profile",
"personality",
"sandbox_policy",
"service_tier",
@@ -116,6 +134,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"effort",
"model",
"output_schema",
"permission_profile",
"personality",
"sandbox_policy",
"service_tier",
@@ -131,6 +150,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"ephemeral",
"model",
"model_provider",
"permission_profile",
"personality",
"sandbox",
"service_name",
@@ -147,6 +167,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"sort_direction",
"sort_key",
"source_kinds",
"use_state_db_only",
],
AsyncCodex.thread_resume: [
"approval_policy",
@@ -155,8 +176,10 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"config",
"cwd",
"developer_instructions",
"exclude_turns",
"model",
"model_provider",
"permission_profile",
"personality",
"sandbox",
"service_tier",
@@ -169,8 +192,10 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"cwd",
"developer_instructions",
"ephemeral",
"exclude_turns",
"model",
"model_provider",
"permission_profile",
"sandbox",
"service_tier",
],
@@ -181,6 +206,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"effort",
"model",
"output_schema",
"permission_profile",
"personality",
"sandbox_policy",
"service_tier",
@@ -193,6 +219,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"effort",
"model",
"output_schema",
"permission_profile",
"personality",
"sandbox_policy",
"service_tier",
+25 -25
View File
@@ -3,7 +3,7 @@ revision = 3
requires-python = ">=3.10"
[options]
exclude-newer = "2026-04-16T16:29:01.461661899Z"
exclude-newer = "2026-04-20T18:19:27.620299Z"
exclude-newer-span = "P7D"
[[package]]
@@ -80,30 +80,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
]
[[package]]
name = "codex-app-server-sdk"
version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },
]
[package.optional-dependencies]
dev = [
{ name = "datamodel-code-generator" },
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "datamodel-code-generator", marker = "extra == 'dev'", specifier = "==0.31.2" },
{ name = "pydantic", specifier = ">=2.12" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11" },
]
provides-extras = ["dev"]
[[package]]
name = "colorama"
version = "0.4.6"
@@ -301,6 +277,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "openai-codex-app-server-sdk"
version = "0.116.0a1"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },
]
[package.optional-dependencies]
dev = [
{ name = "datamodel-code-generator" },
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "datamodel-code-generator", marker = "extra == 'dev'", specifier = "==0.31.2" },
{ name = "pydantic", specifier = ">=2.12" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11" },
]
provides-extras = ["dev"]
[[package]]
name = "packaging"
version = "26.1"