diff --git a/.agents/skills/review-doc-style/SKILL.md b/.agents/skills/review-doc-style/SKILL.md index 81d79618c..fdfac5cd8 100644 --- a/.agents/skills/review-doc-style/SKILL.md +++ b/.agents/skills/review-doc-style/SKILL.md @@ -33,7 +33,7 @@ and NeMo Fabric repo conventions. - `README.md` - `docs/index.yml` - Package or crate READMEs - - Adapter and integration READMEs such as `adapters/codex-cli/README.md` or `integrations/harbor/README.md` + - Adapter and integration READMEs such as `adapters/codex/README.md` or `integrations/harbor/README.md` 4. Start with `assets/nvidia-style-guide.md`, then open only the focused support document needed for the issue under review. 5. Scan for high-signal style issues in headings, links, code formatting, terminology, procedures, and plain-English readability. 6. Report findings in severity order with file references and concrete rewrites. diff --git a/ATTRIBUTIONS-Python.md b/ATTRIBUTIONS-Python.md index bbbff62ca..f0da289e8 100644 --- a/ATTRIBUTIONS-Python.md +++ b/ATTRIBUTIONS-Python.md @@ -1281,7 +1281,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -## claude-agent-sdk (0.2.114) +## claude-agent-sdk (0.2.120) ### Licenses License: `MIT` @@ -6225,6 +6225,26 @@ Apache License limitations under the License. ``` +## openai-codex (0.1.0b3) + +### Licenses +License: `Apache-2.0` + + - `LICENSE`: +``` +(No license file found in locked artifact for openai-codex; see package metadata or PyPI.) +``` + +## openai-codex-cli-bin (0.137.0a4) + +### Licenses +License: `Apache-2.0` + + - `LICENSE`: +``` +(No license file found in locked artifact for openai-codex-cli-bin; see package metadata or PyPI.) +``` + ## orjson (3.11.9) ### Licenses diff --git a/README.md b/README.md index f6aad2027..c2fb3719e 100644 --- a/README.md +++ b/README.md @@ -157,8 +157,8 @@ authentication, and execution details. `disallowed_tools`, Deep Agents enforces them with middleware, and adapters without a native deny mechanism route the policy as unsupported. - **Adapters:** harness-specific integrations selected by `harness.adapter_id`. - The Hermes adapter lives under `adapters/hermes/`; the Codex CLI - adapter lives under `adapters/codex-cli/`; the + The Hermes adapter lives under `adapters/hermes/`; the Codex SDK + adapter lives under `adapters/codex/`; the [Claude adapter](adapters/claude/README.md) lives under `adapters/claude/`; the LangChain Deep Agents adapter lives under `adapters/deepagents/`. Harness-specific extensions belong under @@ -192,7 +192,7 @@ the [Python SDK guide](docs/sdk/python.mdx). Exact signatures are in the [multi-harness demo](examples/harbor/demo/README.md): ownership, installation, and complete command matrices. - Adapter guides: [Hermes](adapters/hermes/README.md), - [Codex CLI](adapters/codex-cli/README.md), and + [Codex SDK](adapters/codex/README.md), and [Deep Agents](adapters/deepagents/README.md). ## Tests diff --git a/TODO.md b/TODO.md new file mode 100644 index 000000000..8f3433689 --- /dev/null +++ b/TODO.md @@ -0,0 +1,28 @@ + + +# Temporary Workarounds + +Track repository workarounds that must be removed after an upstream dependency +ships. Each entry must identify an upstream reference, removal condition, and +cleanup validation. + +## NeMo Relay 0.6.x Request Decoding Release + +- **Status:** Waiting for an upstream release +- **Added:** July 16, 2026 +- **Affected documentation:** `adapters/codex/README.md` and + `docs/integrations/codex.mdx` +- **Reason:** Released NeMo Relay versions do not yet decode the + `zstd`-compressed request bodies emitted by the Codex SDK, so semantic Relay + artifacts require a source installation. +- **Upstream resolution:** + [NVIDIA/NeMo-Relay#452](https://github.com/NVIDIA/NeMo-Relay/pull/452), merged + as `fe144d0d23e483c8216537118304e306abc20837` +- **Removal condition:** A published `nemo-relay-cli` version in Fabric's + supported `>=0.6.0,<0.7.0` range contains the merged request-decoding fix. +- **Cleanup:** Replace the pinned source-install instructions with the released + CLI installation, run the Codex Relay end-to-end test, update both affected + documentation files, and remove this entry. diff --git a/adapters/claude/README.md b/adapters/claude/README.md index b2439cad6..bbdef3f73 100644 --- a/adapters/claude/README.md +++ b/adapters/claude/README.md @@ -9,6 +9,10 @@ The `nvidia.fabric.claude` adapter uses the official Claude Agent SDK for Python behind Fabric's normalized invocation contract. The SDK is an implementation detail; consumers select the Claude harness by adapter ID. +This adapter pins `claude-agent-sdk==0.2.120`. The SDK supplies its compatible +Claude Code runtime unless `harness.settings.cli_path` explicitly selects +another executable. + ## Install ```bash @@ -40,8 +44,8 @@ Refer to the [Claude adapter authentication guide](https://nvidia-nemo-fabric.do for mode selection, required WIF variables, and the Relay boundary. Package installation is verified by the adapter wheel and module-entrypoint tests. -Relay-enabled runs also require the external `nemo-relay` CLI. Install the CLI -separately: +Relay-enabled runs also require the external `nemo-relay` CLI. Fabric accepts +CLI versions `>=0.6.0,<0.7.0`. Install the CLI separately: ```bash cargo install nemo-relay-cli @@ -217,3 +221,8 @@ RUN_FABRIC_CLAUDE_RELAY_INTEGRATION=1 uv run --no-sync pytest tests/e2e/test_cla ``` The first command uses the mock Claude client and does not require credentials. +Set `FABRIC_TEST_CLAUDE_MODEL` to override the default live-test model, +`claude-sonnet-4-5`. +The live Relay test applies the same semantic artifact contract as Codex: ATOF +must contain structured LLM requests and token usage, and ATIF must contain the +expected agent response. diff --git a/adapters/claude/pyproject.toml b/adapters/claude/pyproject.toml index 5ca2c65ef..f80d80986 100644 --- a/adapters/claude/pyproject.toml +++ b/adapters/claude/pyproject.toml @@ -26,7 +26,7 @@ readme = "README.md" requires-python = ">=3.11" dependencies = [ "nemo-fabric-adapters-common == 0.1.0", - "claude-agent-sdk==0.2.114", + "claude-agent-sdk==0.2.120", "tomli-w~=1.2", ] diff --git a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py index cf9079067..9e3df040f 100644 --- a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py +++ b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py @@ -7,6 +7,7 @@ import asyncio import json +import logging import math import os import shlex @@ -32,6 +33,8 @@ from nemo_fabric_adapters.common import relay_hooks from nemo_fabric_adapters.common import utils as common_utils +LOGGER = logging.getLogger(__name__) + PERMISSION_MODES = { "default", "acceptEdits", @@ -80,6 +83,7 @@ "TEMP", "TMP", "TMPDIR", + "USER", "USERPROFILE", "XDG_CACHE_HOME", "XDG_CONFIG_HOME", @@ -389,14 +393,12 @@ def prepare_claude_relay(payload: dict[str, Any]) -> ClaudeRelaySettings | None: ) from error try: - observability_version = relay_gateway.relay_cli_observability_version( - executable - ) + relay_contract = relay_gateway.relay_cli_contract(executable) plugin_config = common_utils.load_relay_plugin_config(payload) config_path, plugin_config_path = common_utils.write_relay_configs( relay_config={"agents": {"claude": {"command": "claude"}}}, plugin_config=plugin_config, - observability_version=observability_version, + observability_version=relay_contract.observability_version, ) except ( OSError, @@ -594,9 +596,15 @@ def normalize_message(message: Message) -> dict[str, Any]: return {"type": type(message).__name__, "message": _json_safe(message)} +def _result_failed(result: ResultMessage) -> bool: + return bool(result.is_error) or ( + isinstance(result.subtype, str) and result.subtype.startswith("error_") + ) + + def normalize_result(payload: dict[str, Any], messages: list[Message], result: ResultMessage) -> dict[str, Any]: del payload - failed = bool(result.is_error) or (isinstance(result.subtype, str) and result.subtype.startswith("error_")) + failed = _result_failed(result) error = None if failed: error = { @@ -743,6 +751,14 @@ async def run_claude(payload: dict[str, Any]) -> dict[str, Any]: messages.append(message) except (TimeoutError, ClaudeSDKError) as error: output = sdk_failure(error) + except Exception: + # Claude Agent SDK 0.2.120 can yield an error ResultMessage and then + # raise a plain Exception while closing the query stream. Preserve + # the typed terminal result, but do not hide unrelated exceptions. + if result is None or not _result_failed(result): + raise + LOGGER.exception("Claude SDK stream raised after a failed terminal result") + output = normalize_result(payload, messages, result) else: if result is None: output = _failure( diff --git a/adapters/claude/uv.lock b/adapters/claude/uv.lock index 5f9e4b547..012c18e12 100644 --- a/adapters/claude/uv.lock +++ b/adapters/claude/uv.lock @@ -148,20 +148,20 @@ wheels = [ [[package]] name = "claude-agent-sdk" -version = "0.2.114" +version = "0.2.120" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "mcp" }, { name = "sniffio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/f3/a74b32b52f3c0013e317afe27b44946b9bbceb16b53c2a8d9614bfc0e7ad/claude_agent_sdk-0.2.114.tar.gz", hash = "sha256:53263a25bddd52b354ba2975b348cc978493bf29cb21a8c74a1b92b06f88faa3", size = 268633, upload-time = "2026-07-08T21:35:01.546Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/7f/7b69aed292a4edecae132e4dbe6b6decb4e88ec142fc91d117b19058c9e0/claude_agent_sdk-0.2.120.tar.gz", hash = "sha256:e428552f79a76e0d85789369eeb58249b33f350200124e5fc86b24168bd00805", size = 268639, upload-time = "2026-07-15T23:18:50.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/57/ded5a6e02940b73775e8235cc69c3f1f9aca15871b73951ea184f26b68b7/claude_agent_sdk-0.2.114-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5170a7cf8e5f30cdd59d60505b82e444abd655e8391e92e8390b1d539c100550", size = 69490216, upload-time = "2026-07-08T21:35:05.659Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ee/646435d91960e83cf6989d70adc6e9b47295a1e93bb1c2d7be366c8786c8/claude_agent_sdk-0.2.114-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:42b76e889d8219cf741789205adc9306b62524af205080362eb75b1bc09aa10b", size = 74389245, upload-time = "2026-07-08T21:35:10.367Z" }, - { url = "https://files.pythonhosted.org/packages/38/d0/f0c5ced2a1dcf28ad0da9643d81e535e9fa7c9cf70c09ae7573429e4bcdf/claude_agent_sdk-0.2.114-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:f583f04a8d7db22b5dc826c135225199e99c46092e563492aa2a13d52b81e8ed", size = 79425407, upload-time = "2026-07-08T21:35:15.18Z" }, - { url = "https://files.pythonhosted.org/packages/1d/7a/2dc6f13a153187ec4ca0a2fd76e7e20cd3fb43d72eb8e01401fd4ab7a335/claude_agent_sdk-0.2.114-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:a3ec58cfff233c70782437a900fb152ef971b92887e03b03d218266285ad387a", size = 80450013, upload-time = "2026-07-08T21:35:20.331Z" }, - { url = "https://files.pythonhosted.org/packages/c9/25/3cc0ea8ff94bdbc28cd0da69d4d6b4bd382b6f7d182b94351a313d59ceac/claude_agent_sdk-0.2.114-py3-none-win_amd64.whl", hash = "sha256:60096c88ead0ff7f05ae5cf90d7a75e47a02cbc16a97d0c813bc0409cbb0e334", size = 79985117, upload-time = "2026-07-08T21:35:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/80/85/5e8958704db0f8195e63f8ec4a80c5fb14756edc785bb3535e0dc5d91104/claude_agent_sdk-0.2.120-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ead9fb4bdaf70069978703ec6d74b30bd269e9632a4aea4dc8c4e999ac3a1b", size = 71110966, upload-time = "2026-07-15T23:18:54.743Z" }, + { url = "https://files.pythonhosted.org/packages/c1/53/c6cdad82ac100c8a45887999614e9fe206b77b43790dacc6de42b156ad4c/claude_agent_sdk-0.2.120-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:1248591c7bffeb6e10e8cd169e0766854951fba8816e3e7d81a003f8bfca6f08", size = 76067995, upload-time = "2026-07-15T23:18:58.398Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f8/248e3f58d0f0aa7d76bd34b11b18135cc124f5b9a9b55219cc1ca03d662a/claude_agent_sdk-0.2.120-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:abc73ccdf3decca566cd18084e74bc2f2d10b8b77cc1fd5ed4299d5c15e5078b", size = 81027434, upload-time = "2026-07-15T23:19:03.318Z" }, + { url = "https://files.pythonhosted.org/packages/11/59/6adb0c53534646f1d5ddc41226ff37b2adc413a472e2f87a9011548a137f/claude_agent_sdk-0.2.120-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:888070c246c92e102c52001d26532cd3646a700656d7c368f2f91a1d3c16b534", size = 82084704, upload-time = "2026-07-15T23:19:08.729Z" }, + { url = "https://files.pythonhosted.org/packages/2a/06/036b8dce1e86ecd5e2e1ddc281736cdb33bad6d24e748b9553e235b028fe/claude_agent_sdk-0.2.120-py3-none-win_amd64.whl", hash = "sha256:bc1441c94f60c9e7b4b8c641742fedf68451f573062ef69dd43d86a61b1fb219", size = 81958518, upload-time = "2026-07-15T23:19:13.082Z" }, ] [[package]] @@ -360,7 +360,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "claude-agent-sdk", specifier = "==0.2.114" }, + { name = "claude-agent-sdk", specifier = "==0.2.120" }, { name = "nemo-fabric-adapters-common", editable = "../common" }, { name = "tomli-w", specifier = "~=1.2" }, ] diff --git a/adapters/codex-cli/README.md b/adapters/codex-cli/README.md deleted file mode 100644 index 9b82f1cfe..000000000 --- a/adapters/codex-cli/README.md +++ /dev/null @@ -1,56 +0,0 @@ - - -# Codex CLI Adapter - -Runs an installed Codex CLI through Fabric's Python-adapter lifecycle. One -Fabric runtime maps to one Codex thread. - -Keep `fabric-adapter.json` aligned with the adapter implementation. -`contract_version` must match the adapter contract supported by Fabric core; -`adapter_id` is the stable id selected by `harness.adapter_id`. - -Install Fabric with the adapter dependency before running it: - -```bash -python3 -m pip install -e ".[runtime,codex]" -``` - -## Authentication and Codex Config - -The adapter does not read, copy, or rewrite Codex credentials. The child Codex -process inherits `HOME`, `CODEX_HOME`, platform runtime variables, and proxy or -certificate settings, so an existing `codex login` session remains -authoritative. Additional variables must be provided through -`harness.settings.env`. - -Codex continues to load its system, user, profile, and trusted project config. -Fabric adds only explicitly configured invocation overrides: - -- `models.default.model` selects `--model`; omit it to use Codex's configured - default. -- `environment.workspace` selects the process working directory. -- `harness.settings.sandbox` selects `read-only`, `workspace-write`, or - `danger-full-access`. -- `harness.settings.codex_profile` selects a Codex profile. -- `harness.settings.config_overrides` emits repeated `--config key=value` - arguments. Values may be TOML scalars or arrays; use dotted keys for nested - Codex settings. -- `harness.settings.codex_args` is an escape hatch for additional CLI flags. -- `harness.settings.timeout_seconds` bounds each invocation and defaults to 1800. - -`codex_command`, `codex_state_dir`, `cwd`, `env`, and -`skip_git_repo_check` are available for prepared environments and tests. - -## Execution Paths - -The first invocation records Codex's generated thread ID against the Fabric -runtime ID. Later invocations on the same runtime use -`codex exec resume `. Codex owns its transcript and authentication; -Fabric owns the runtime lifecycle and runtime-to-thread correlation record. -Both `fabric run` and stateful runtime paths accept text input. - -Use `codex_cli_config()` from `examples.code_review_agent` for local one-shot -and multi-turn examples. diff --git a/adapters/codex-cli/src/nemo_fabric_adapters/codex_cli/adapter.py b/adapters/codex-cli/src/nemo_fabric_adapters/codex_cli/adapter.py deleted file mode 100755 index 500fca23c..000000000 --- a/adapters/codex-cli/src/nemo_fabric_adapters/codex_cli/adapter.py +++ /dev/null @@ -1,604 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Map Fabric runtime invocations onto ``codex exec``.""" - -from __future__ import annotations - -import hashlib -import json -import math -import os -import subprocess -import tomllib -from collections.abc import Mapping -from pathlib import Path -from typing import Any -from typing import NamedTuple - -import nemo_fabric_adapters.common.relay_gateway as relay_gateway -import nemo_fabric_adapters.common.relay_hooks as relay_hooks -import nemo_fabric_adapters.common.utils as common_utils -import tomli_w - -SANDBOXES = {"read-only", "workspace-write", "danger-full-access"} -DEFAULT_TIMEOUT_SECONDS = 1800 - - -class CodexRelaySettings(NamedTuple): - """Invocation-scoped Relay state consumed by the Codex adapter.""" - - gateway: relay_gateway.RelayGatewayLaunch - plugin_config: dict[str, Any] - - -class CodexSettings(NamedTuple): - telemetry_provider: str - codex_profile_name: str | None - codex_profile_path: Path | None - relay: CodexRelaySettings | None - - -def state_dir(payload: dict[str, Any]) -> Path: - settings = common_utils.settings_payload(payload) - config_root = Path(common_utils.config_root(payload)).resolve() - configured = settings.get("codex_state_dir") - if configured: - path = Path(str(configured)) - return path if path.is_absolute() else config_root / path - artifacts = common_utils.runtime_context(payload).get("artifacts") or {} - root = artifacts.get("root") or os.environ.get("FABRIC_ARTIFACTS") - if root: - return Path(str(root)).resolve() / ".fabric" / "codex-cli" - return config_root / "artifacts" / "codex-cli" / ".fabric" - - -def runtime_state_path(payload: dict[str, Any], runtime_id: str) -> Path: - key = hashlib.sha256(runtime_id.encode("utf-8")).hexdigest() - return state_dir(payload) / "runtimes" / f"{key}.json" - - -def load_thread_id(payload: dict[str, Any], runtime_id: str) -> str | None: - path = runtime_state_path(payload, runtime_id) - if not path.is_file(): - return None - try: - value = json.loads(path.read_text(encoding="utf-8")) - except json.JSONDecodeError as error: - raise RuntimeError(f"invalid Codex runtime state in {path}") from error - if ( - not isinstance(value, dict) - or value.get("runtime_id") != runtime_id - or not value.get("thread_id") - ): - raise RuntimeError(f"invalid Codex runtime state in {path}") - return str(value["thread_id"]) - - -def save_thread_id(payload: dict[str, Any], runtime_id: str, thread_id: str) -> None: - path = runtime_state_path(payload, runtime_id) - path.parent.mkdir(parents=True, exist_ok=True) - invocation_id = ( - common_utils.runtime_context(payload).get("invocation_id") or "pending" - ) - temporary = path.with_suffix(f".{invocation_id}.tmp") - temporary.write_text( - json.dumps({"runtime_id": runtime_id, "thread_id": thread_id}, indent=2), - encoding="utf-8", - ) - os.replace(temporary, path) - - -def selected_model(payload: dict[str, Any]) -> str | None: - settings = common_utils.settings_payload(payload) - models = common_utils.models_payload(payload) - model_config = models.get(settings.get("model", "default"), {}) - value = settings.get("model_name") - if not value and isinstance(model_config, dict): - value = model_config.get("model") - if not value: - return None - model = str(value) - return model.removeprefix("openai/") - - -def build_command( - payload: dict[str, Any], - *, - thread_id: str | None = None, - codex_settings: CodexSettings, -) -> list[str]: - settings = common_utils.settings_payload(payload) - command = resolve_command(payload, settings.get("codex_command") or "codex") - sandbox = str(settings.get("sandbox") or "read-only") - if sandbox not in SANDBOXES: - raise ValueError( - f"unsupported Codex sandbox {sandbox!r}; expected one of {sorted(SANDBOXES)}" - ) - - args = [command, "exec", "--json"] - - args.extend(["--sandbox", sandbox]) - - if codex_settings.codex_profile_name is not None: - args.extend(("--profile", codex_settings.codex_profile_name)) - - if codex_settings.relay is not None: - # By default Codex will not enable hooks for profiles that are not trusted until the user explicitly - # enables them. This is a problem for Fabric, because we want to be able to use hooks in a non-interactive - # way.So we add the --dangerously-bypass-hook-trust flag to bypass this check. - args.append("--dangerously-bypass-hook-trust") - - model = selected_model(payload) - if model: - args.extend(["--model", model]) - if settings.get("skip_git_repo_check", False): - args.append("--skip-git-repo-check") - args.extend(common_utils.normalize_list(settings.get("codex_args"))) - - if thread_id: - args.extend(["resume", thread_id, "-"]) - else: - args.append("-") - - return args - - -def config_overrides(settings: dict[str, Any]) -> Mapping[str, Any]: - overrides = settings.get("config_overrides") - if overrides is None: - return {} - if not isinstance(overrides, Mapping): - raise ValueError("config_overrides must be a mapping") - return overrides - - -def native_codex_telemetry_config(payload: dict[str, Any]) -> dict[str, Any]: - if "native" not in common_utils.telemetry_providers(payload): - return {} - - telemetry_config = common_utils.native_telemetry_config(payload) - components = telemetry_config.get("components") or [] - for component in components: - if ( - not isinstance(component, dict) - or component.get("kind") != "observability" - or not component.get("enabled", True) - ): - continue - component_config = component.get("config") or {} - opentelemetry = component_config.get("opentelemetry") or {} - if not isinstance(opentelemetry, dict) or not opentelemetry.get("enabled"): - continue - - otel: dict[str, Any] = {} - resource_attributes = opentelemetry.get("resource_attributes") or {} - environment = resource_attributes.get("deployment.environment") - if environment is not None: - otel["environment"] = environment - - endpoint = opentelemetry.get("endpoint") - if endpoint: - transport = opentelemetry.get("transport", "http_binary") - if transport == "http_binary": - exporter = "otlp-http" - protocol = "binary" - elif transport == "grpc": - exporter = "otlp-grpc" - protocol = "grpc" - elif transport == "http_json": - exporter = "otlp-http" - protocol = "json" - else: - raise ValueError( - f"unsupported Codex native OpenTelemetry transport {transport!r}" - ) - otel["trace_exporter"] = { - exporter: { - "endpoint": endpoint, - "protocol": protocol, - } - } - return {"otel": otel} - return {} - - -def apply_config_overrides( - config: dict[str, Any], - overrides: Mapping[str, Any], -) -> None: - for dotted_key, value in sorted(overrides.items()): - toml_value(value) - parts = str(dotted_key).split(".") - if any(not part for part in parts): - raise ValueError(f"invalid Codex config override key {dotted_key!r}") - target = config - for part in parts[:-1]: - existing = target.setdefault(part, {}) - if not isinstance(existing, dict): - raise ValueError( - f"Codex config override {dotted_key!r} conflicts with {part!r}" - ) - target = existing - target[parts[-1]] = value - - -def merge_config(config: dict[str, Any], layer: Mapping[str, Any]) -> None: - for key, value in layer.items(): - existing = config.get(key) - if isinstance(existing, dict) and isinstance(value, Mapping): - merge_config(existing, value) - else: - config[key] = value - - -def codex_home() -> Path: - configured = os.environ.get("CODEX_HOME") - if configured: - return Path(configured).expanduser() - return Path.home() / ".codex" - - -def load_codex_profile(settings: dict[str, Any]) -> dict[str, Any]: - profile = settings.get("codex_profile") - if not profile: - return {} - path = codex_home() / f"{profile}.toml" - with path.open("rb") as profile_file: - return tomllib.load(profile_file) - - -def write_config_files(payload: dict[str, Any]) -> CodexSettings: - settings = common_utils.settings_payload(payload) - telemetry_providers = common_utils.telemetry_providers(payload) - telemetry_provider = telemetry_providers[0] if telemetry_providers else "relay" - relay_enabled = common_utils.relay_enabled(payload) - overrides = config_overrides(settings) - config = load_codex_profile(settings) - if "native" in telemetry_providers: - merge_config(config, native_codex_telemetry_config(payload)) - - codex_profile_name = None - codex_profile_path = None - relay = None - if relay_enabled or bool(config) or bool(overrides): - codex_profile_name, codex_profile_path = get_codex_profile_path(payload) - - if relay_enabled: - relay_gateway_port = relay_gateway.find_available_tcp_port() - relay_gateway_bind = f"127.0.0.1:{relay_gateway_port}" - relay_gateway_url = f"http://{relay_gateway_bind}" - - # nemo-relay infers the plugin config location from the relay config. - relay_plugin_config = common_utils.load_relay_plugin_config(payload) - relay_executable = relay_gateway.resolve_relay_command( - Path(common_utils.config_root(payload)).resolve(), - settings.get("nemo_relay_command") or "nemo-relay", - ) - relay_config_path, _ = common_utils.write_relay_configs( - relay_config={"agents": {"codex": {"command": "codex"}}}, - plugin_config=relay_plugin_config, - observability_version=relay_gateway.relay_cli_observability_version( - relay_executable - ), - ) - if relay_config_path is None: - raise RuntimeError( - "NeMo Relay configuration did not produce a gateway config" - ) - - gateway = relay_gateway.RelayGatewayLaunch( - executable=relay_executable, - config_path=relay_config_path, - bind=relay_gateway_bind, - url=relay_gateway_url, - log_path=relay_config_path.parent / "gateway.log", - ) - relay = CodexRelaySettings( - gateway=gateway, - plugin_config=relay_plugin_config, - ) - - merge_config( - config, - { - "model_provider": "nemo-relay-openai", - "model_providers": { - "nemo-relay-openai": { - "name": "NeMo Relay OpenAI", - "base_url": relay_gateway_url, - "wire_api": "responses", - "requires_openai_auth": True, - "supports_websockets": False, - } - }, - "features": {"hooks": True}, - "hooks": relay_hooks.render_relay_hooks("codex", relay_executable)[ - "hooks" - ], - }, - ) - - apply_config_overrides(config, overrides) - codex_profile_path.parent.mkdir(parents=True, exist_ok=True) - codex_profile_path.write_text( - tomli_w.dumps(config), - encoding="utf-8", - ) - - return CodexSettings( - telemetry_provider=telemetry_provider, - codex_profile_name=codex_profile_name, - codex_profile_path=codex_profile_path, - relay=relay, - ) - - -def get_codex_profile_path(payload: dict[str, Any]) -> tuple[str, Path]: - runtime_id = common_utils.runtime_context(payload).get("runtime_id") - if not runtime_id: - raise RuntimeError( - "runtime_context.runtime_id is required for generated Codex profiles" - ) - - name = f"fabric-{runtime_id}" - return name, codex_home() / f"{name}.config.toml" - - -def resolve_command(payload: dict[str, Any], value: Any) -> str: - command = Path(str(value)) - if command.is_absolute() or len(command.parts) == 1: - return str(command) - config_root = Path(common_utils.config_root(payload)).resolve() - return str((config_root / command).resolve()) - - -def toml_value(value: Any) -> str: - pending = [value] - while pending: - item = pending.pop() - if isinstance(item, float) and not math.isfinite(item): - raise ValueError("Codex config overrides require finite numbers") - if isinstance(item, Mapping): - pending.extend(item.values()) - elif isinstance(item, (list, tuple)): - pending.extend(item) - try: - document = tomli_w.dumps({"value": value}) - except TypeError as error: - raise ValueError( - "Codex config override values must be a TOML scalar or array" - ) from error - prefix = "value = " - if not document.startswith(prefix): - raise ValueError("Codex config override values must be a TOML scalar or array") - return document.removeprefix(prefix).rstrip() - - -def parse_events(contents: str) -> dict[str, Any]: - events: list[dict[str, Any]] = [] - thread_id = None - response = None - usage = None - error = None - for line in contents.splitlines(): - if not line.strip(): - continue - try: - event = json.loads(line) - except json.JSONDecodeError: - continue - if not isinstance(event, dict): - continue - events.append(event) - event_type = event.get("type") - if event_type == "thread.started": - thread_id = event.get("thread_id") - elif event_type == "item.completed": - item = event.get("item") or {} - if item.get("type") == "agent_message": - response = item.get("text") - elif event_type == "turn.completed": - usage = event.get("usage") - elif event_type in {"turn.failed", "error"}: - failure = event.get("error") or event.get("message") or event - error = ( - failure.get("message") if isinstance(failure, dict) else str(failure) - ) - return { - "events": events, - "thread_id": str(thread_id) if thread_id else None, - "response": response, - "usage": usage, - "error": error, - } - - -def request_to_prompt(payload: dict[str, Any]) -> str: - value = (payload.get("request") or {}).get("input", "") - if not isinstance(value, str): - raise ValueError("Codex CLI adapter requires text input") - return value - - -def resolve_cwd(payload: dict[str, Any]) -> Path: - settings = common_utils.settings_payload(payload) - environment = common_utils.environment_payload(payload) - config_root = Path(common_utils.config_root(payload)).resolve() - path = Path(str(settings.get("cwd") or environment.get("workspace") or ".")) - return path.resolve() if path.is_absolute() else (config_root / path).resolve() - - -def build_env( - payload: dict[str, Any], - *, - relay_gateway_url: str | None = None, -) -> dict[str, str]: - env = common_utils.virtualenv_subprocess_env() - - configured = common_utils.settings_payload(payload).get("env") - if configured is None: - configured = {} - - if not isinstance(configured, Mapping): - raise ValueError("env must be a mapping of variable names to values") - - env.update({str(key): str(value) for key, value in configured.items()}) - - if relay_gateway_url is not None: - env["NEMO_RELAY_GATEWAY_URL"] = relay_gateway_url - - return env - - -def process_timeout(payload: dict[str, Any]) -> float: - value = common_utils.settings_payload(payload).get( - "timeout_seconds", DEFAULT_TIMEOUT_SECONDS - ) - if ( - isinstance(value, bool) - or not isinstance(value, (int, float)) - or not math.isfinite(value) - or value <= 0 - ): - raise ValueError("timeout_seconds must be a positive finite number") - return float(value) - - -def exception_output(value: str | bytes | None) -> str: - if value is None: - return "" - return value.decode(errors="replace") if isinstance(value, bytes) else value - - -def run_codex(payload: dict[str, Any]) -> dict[str, Any]: - runtime_id = common_utils.runtime_id(payload) - prior_thread_id = load_thread_id(payload, runtime_id) - cwd = resolve_cwd(payload) - codex_settings = write_config_files(payload) - relay_gateway_process = None - - try: - if codex_settings.relay is not None: - relay_gateway_process = relay_gateway.start_relay_gateway( - launch=codex_settings.relay.gateway, - cwd=cwd, - ) - - command = build_command( - payload, - thread_id=prior_thread_id, - codex_settings=codex_settings, - ) - - timeout = process_timeout(payload) - launch_error = None - try: - completed = subprocess.run( - command, - cwd=cwd, - env=build_env( - payload, - relay_gateway_url=( - codex_settings.relay.gateway.url - if codex_settings.relay is not None - else None - ), - ), - input=request_to_prompt(payload), - text=True, - capture_output=True, - check=False, - timeout=timeout, - ) - except subprocess.TimeoutExpired as error: - completed = subprocess.CompletedProcess( - command, - 124, - exception_output(error.stdout), - exception_output(error.stderr), - ) - launch_error = f"Codex CLI timed out after {timeout:g} seconds" - except OSError as error: - completed = subprocess.CompletedProcess(command, 127, "", str(error)) - launch_error = f"Codex CLI could not start: {error}" - finally: - if codex_settings.codex_profile_path is not None: - codex_settings.codex_profile_path.unlink(missing_ok=True) - - if relay_gateway_process is not None: - relay_gateway.stop_relay_gateway(relay_gateway_process) - - parsed = parse_events(completed.stdout) - thread_id = parsed["thread_id"] or prior_thread_id - error = launch_error or parsed["error"] - if completed.returncode != 0: - error = ( - error - or completed.stderr.strip() - or "Codex CLI exited with a non-zero status" - ) - if parsed["response"] is None: - error = error or "Codex invocation did not return a final agent message" - if not thread_id: - error = error or "Codex runtime invocation did not return a thread identity" - if prior_thread_id and thread_id != prior_thread_id: - error = error or ( - f"Codex resumed thread {thread_id}, expected persisted thread {prior_thread_id}" - ) - if thread_id and not error: - save_thread_id(payload, runtime_id, thread_id) - - output = { - "harness": "codex", - "adapter": "cli", - "mode": "codex_cli_runtime", - "command": redact_command(command), - "cwd": str(cwd), - "model": selected_model(payload), - "thread_id": thread_id, - "response": parsed["response"], - "usage": parsed["usage"], - "returncode": completed.returncode, - "error": error, - "failed": error is not None, - "state_dir": str(state_dir(payload)), - } - - if codex_settings.relay is not None: - relay_artifacts = common_utils.collect_relay_artifacts( - codex_settings.relay.plugin_config - ) - output["relay_runtime"] = { - "enabled": True, - "config_path": os.environ.get("FABRIC_RELAY_CONFIG_PATH"), - "emitter": "nemo-relay", - "gateway_config_path": str(codex_settings.relay.gateway.config_path), - "gateway_log_path": str(codex_settings.relay.gateway.log_path), - } - output["relay_artifacts"] = relay_artifacts - - return output - - -def redact_command(command: list[str]) -> list[str]: - redacted = list(command) - for index, value in enumerate(redacted[:-1]): - if value == "--config" and any( - marker in redacted[index + 1].lower() - for marker in ("key", "token", "secret", "password") - ): - redacted[index + 1] = "" - return redacted - - -def main() -> None: - output = run_codex(common_utils.load_payload()) - print(json.dumps(output, sort_keys=True)) - if output["failed"]: - raise SystemExit(2) - - -if __name__ == "__main__": - main() diff --git a/adapters/codex-cli/uv.lock b/adapters/codex-cli/uv.lock deleted file mode 100644 index 482a014c0..000000000 --- a/adapters/codex-cli/uv.lock +++ /dev/null @@ -1,32 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.11" - -[[package]] -name = "nemo-fabric-adapters-codex-cli" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "nemo-fabric-adapters-common" }, - { name = "tomli-w" }, -] - -[package.metadata] -requires-dist = [ - { name = "nemo-fabric-adapters-common", editable = "../common" }, - { name = "tomli-w", specifier = "~=1.2" }, -] - -[[package]] -name = "nemo-fabric-adapters-common" -version = "0.1.0" -source = { editable = "../common" } - -[[package]] -name = "tomli-w" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, -] diff --git a/adapters/codex-cli/LICENSE b/adapters/codex/LICENSE similarity index 100% rename from adapters/codex-cli/LICENSE rename to adapters/codex/LICENSE diff --git a/adapters/codex/README.md b/adapters/codex/README.md new file mode 100644 index 000000000..990dc8878 --- /dev/null +++ b/adapters/codex/README.md @@ -0,0 +1,176 @@ + + +# Codex Adapter + +The `nvidia.fabric.codex` adapter uses the official Codex Python SDK behind +Fabric's normalized invocation contract. It does not resolve or execute a +separately installed `codex` command. The SDK package owns its pinned +app-server runtime and typed JSON-RPC protocol. + +## Install + +Build the local wheels and install the Codex adapter: + +```bash +just wheels +python -m pip install --find-links dist "nemo-fabric[codex]" +``` + +## Authentication + +Fabric reuses the authentication state that Codex stores under `CODEX_HOME` +(default: `~/.codex`). Fabric does not perform an interactive login, copy +credentials, or mutate the user's Codex configuration. + +Codex supports two OpenAI authentication modes: + +- **ChatGPT login:** Sign in through Codex with a ChatGPT plan. Fabric can then + run without `OPENAI_API_KEY` while that cached login remains valid. +- **API key login:** Provision the same Codex credential store with an OpenAI + API key. This mode uses OpenAI Platform billing rather than ChatGPT plan + credits. + +For a nondefault credential store, set `CODEX_HOME` before both login and the +Fabric invocation. Treat `CODEX_HOME/auth.json` as a secret when Codex uses +file-based credential storage. Refer to the +[Codex authentication documentation](https://developers.openai.com/codex/auth/) +for login, headless setup, and credential-storage options. + +The adapter forwards `OPENAI_API_KEY` and a selected model's `api_key_env` to +the SDK runtime. The current real-agent acceptance path validates an existing +Codex login; it does not yet claim a raw environment variable as a complete +login flow. + +The dependency graph includes `openai-codex-cli-bin`. The Codex SDK owns this +pinned app-server distribution; Fabric does not treat it as a user-installed +command or an adapter descriptor requirement. + +This adapter pins `openai-codex==0.1.0b3`, which pins +`openai-codex-cli-bin==0.137.0a4`. A newer `codex` command on `PATH` is not used +implicitly. When testing a newer compatible runtime, set +`harness.settings.codex_bin` to an app-server path that is absolute or relative +to the Fabric config root. Fabric passes the resolved path through +`CodexConfig.codex_bin`; the SDK remains the execution driver. + +## Execution Model + +Each Fabric invocation starts a fresh SDK client and closes its app-server +transport before returning. The first invocation creates a Codex thread and +persists its ID under the Fabric artifact root. Later invocations for the same +Fabric runtime resume that exact thread. Codex owns the transcript; Fabric owns +runtime-to-thread correlation, timeout, cancellation, and cleanup. + +The result includes the SDK's typed terminal response, turn status, token +usage, timing, and completed thread items. It does not expose CLI commands, +return codes, stdout, or stderr. + +## Configuration + +Use normalized `FabricConfig` fields for portable configuration: + +- `models` selects the Codex model. The adapter requires and explicitly selects + the built-in `openai` provider. +- `environment.workspace` sets the working directory. +- `telemetry` enables native OpenTelemetry or NeMo Relay observability. + +Codex-specific controls belong in `harness.settings`: + +- `sandbox`: `read-only`, `workspace-write`, or `danger-full-access` +- `approval_mode`: `auto_review` or `deny_all` +- `base_instructions` and `developer_instructions` +- `personality`, `reasoning_effort`, `service_name`, and `service_tier` +- `output_schema` for SDK-native structured output +- `codex_bin` for an explicit Codex app-server runtime override +- `config_overrides` as dotted request-scoped Codex configuration keys +- `timeout_seconds`, defaulting to 1800 +- `env` for variables explicitly forwarded to the Codex runtime +- `nemo_relay_command` for the optional external Relay gateway executable + +The removed CLI settings `codex_command`, `codex_args`, `codex_profile`, +`codex_state_dir`, and `skip_git_repo_check` are errors. `model_name` and `cwd` +must use the normalized model and environment fields. + +The adapter filters the inherited environment. It retains portable OS and +Codex state variables, the selected model's `api_key_env`, and explicit +`settings.env` values while clearing unrelated parent-process secrets. + +## Relay Observability + +Enable Relay through Fabric's normalized telemetry configuration. For each +Relay-enabled invocation, Fabric: + +1. Resolves one external `nemo-relay` executable. +2. Generates invocation-scoped gateway and plugin configuration. +3. Starts and health-checks `nemo-relay --config ... --bind ...`. +4. Redirects the built-in OpenAI provider with request-scoped + `openai_base_url` and passes Relay hooks through the Codex SDK's `config` + argument. +5. Interrupts timed-out turns, closes the SDK runtime, and stops the gateway. + +The SDK remains the Codex execution driver. Relay is a supervised sidecar and +hook forwarder; the adapter never invokes a `nemo-relay codex` wrapper. The +result reports the gateway config, URL, log, and collected Relay artifacts. + +Fabric deliberately keeps Codex on its reserved built-in `openai` provider. +Defining Relay as a custom model-provider alias breaks the Python SDK's +ChatGPT-authenticated request path. Redirecting only `openai_base_url` preserves +the SDK's supported authentication and host metadata while allowing Relay to +capture Responses traffic. Fabric does not spoof the Codex CLI identity or fall +back to CLI execution. Relay routes and observes requests; it does not provide +OpenAI credentials or change the selected Codex authentication mode. + +Relay-enabled runs require the external `nemo-relay` CLI in addition to the +Python package dependencies. Fabric accepts CLI versions `>=0.6.0,<0.7.0`. +Until the request-decoding fix is released, install the tested PR revision: + +```bash +git clone https://github.com/NVIDIA/NeMo-Relay.git nemo-relay +git -C nemo-relay fetch origin pull/452/head +git -C nemo-relay checkout --detach 0b02e01ac10d7d678da28830feba0ebf6743a7c0 +cargo install --locked --path nemo-relay/crates/cli +``` + +Removal of this temporary source installation is tracked in +[TODO.md](../../TODO.md#nemo-relay-06x-request-decoding-release). + +The `nemo-relay` Python package does not install this executable. Refer to the +[NeMo Relay installation guide](https://docs.nvidia.com/nemo/relay/getting-started/installation) +for other supported installation methods. + +Relay owns HTTP content decoding at the gateway boundary; Fabric does not +configure Codex request compression. The Relay `0.6.0-alpha.20260716` tag cannot +recover semantic fields from zstd-compressed SDK requests. Until a later `0.6.x` +release contains the fix, use the pinned +[NeMo Relay PR #452](https://github.com/NVIDIA/NeMo-Relay/pull/452) revision +above. + +For Phoenix, native Codex OpenTelemetry targets the OTLP collector at +`http://localhost:4318/v1/traces` and provides low-level app-server spans. +Relay OpenInference provides the semantic chain, LLM, and tool hierarchy with +decoded prompt, response, and token attributes. Prefer Relay OpenInference for +agent-turn inspection. + +## Local Validation + +Run the unit and opt-in real SDK tests separately: + +```bash +uv run pytest tests/adapters/test_codex_adapter.py -q +RUN_FABRIC_CODEX_INTEGRATION=1 uv run pytest tests/e2e/test_codex.py -q +RUN_FABRIC_CODEX_RELAY_INTEGRATION=1 \ + FABRIC_TEST_NEMO_RELAY_COMMAND=/path/to/nemo-relay \ + uv run pytest tests/e2e/test_codex.py -q +``` + +Set `FABRIC_TEST_CODEX_BIN=/path/to/codex` on either opt-in command to validate +an explicit app-server override instead of the SDK-pinned runtime. + +The SDK test uses the current Codex authentication state and exercises both a +one-shot invocation and multi-turn thread resume. The Relay test additionally +requires an external gateway binary and verifies one-shot and resumed model +responses, stable thread identity, ATOF, and ATIF; gateway startup alone is not +a passing result. The semantic regression also requires decoded LLM request +content, a model, token usage, and the expected agent response in ATIF. diff --git a/adapters/codex-cli/fabric-adapter.json b/adapters/codex/fabric-adapter.json similarity index 69% rename from adapters/codex-cli/fabric-adapter.json rename to adapters/codex/fabric-adapter.json index e5d7045d4..9cbc7f147 100644 --- a/adapters/codex-cli/fabric-adapter.json +++ b/adapters/codex/fabric-adapter.json @@ -1,14 +1,11 @@ { "contract_version": "fabric.adapter/v1alpha1", - "adapter_id": "nvidia.fabric.codex.cli", + "adapter_id": "nvidia.fabric.codex", "harness": "codex", "adapter_kind": "python", "runner": { - "module": "nemo_fabric_adapters.codex_cli.adapter", - "callable": "run_codex" - }, - "requirements": { - "binaries": ["codex"] + "module": "nemo_fabric_adapters.codex.adapter", + "callable": "run" }, "config": { "accepts": ["models", "telemetry"] diff --git a/adapters/codex-cli/pyproject.toml b/adapters/codex/pyproject.toml similarity index 69% rename from adapters/codex-cli/pyproject.toml rename to adapters/codex/pyproject.toml index 540793499..693f69727 100644 --- a/adapters/codex-cli/pyproject.toml +++ b/adapters/codex/pyproject.toml @@ -8,9 +8,9 @@ requires = [ build-backend = "setuptools.build_meta" [project] -name = "nemo-fabric-adapters-codex-cli" +name = "nemo-fabric-adapters-codex" version = "0.1.0" -description = "Codex CLI adapter for NeMo Fabric" +description = "Codex SDK adapter for NeMo Fabric" authors = [ { name = "NVIDIA Corporation" }, ] @@ -26,6 +26,7 @@ readme = "README.md" requires-python = ">=3.11" dependencies = [ "nemo-fabric-adapters-common == 0.1.0", + "openai-codex==0.1.0b3", "tomli-w~=1.2", ] @@ -35,10 +36,15 @@ Documentation = "https://nvidia-nemo-fabric.docs.buildwithfern.com/nemo/fabric" [tool.setuptools.packages.find] where = ["src"] -include = ["nemo_fabric_adapters.codex_cli*"] +include = ["nemo_fabric_adapters.codex*"] [tool.setuptools.data-files] -"share/nemo-fabric/adapters/codex-cli" = ["fabric-adapter.json"] +"share/nemo-fabric/adapters/codex" = ["fabric-adapter.json"] [tool.uv.sources] nemo-fabric-adapters-common = { path = "../common", editable = true } + +[tool.uv] +# The SDK pins this prerelease runtime. Constraining that transitive package +# lets uv resolve it without enabling prereleases for unrelated dependencies. +constraint-dependencies = ["openai-codex-cli-bin==0.137.0a4"] diff --git a/adapters/codex-cli/src/nemo_fabric_adapters/codex_cli/__init__.py b/adapters/codex/src/nemo_fabric_adapters/codex/__init__.py similarity index 80% rename from adapters/codex-cli/src/nemo_fabric_adapters/codex_cli/__init__.py rename to adapters/codex/src/nemo_fabric_adapters/codex/__init__.py index 70338898a..098d7aca6 100644 --- a/adapters/codex-cli/src/nemo_fabric_adapters/codex_cli/__init__.py +++ b/adapters/codex/src/nemo_fabric_adapters/codex/__init__.py @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Codex CLI adapter package.""" +"""Codex SDK adapter package.""" diff --git a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py new file mode 100644 index 000000000..17959350c --- /dev/null +++ b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py @@ -0,0 +1,928 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Run Codex through its native Python SDK and the Fabric adapter contract.""" + +from __future__ import annotations + +import asyncio +import json +import math +import os +from dataclasses import asdict, dataclass, is_dataclass +from enum import Enum +from hashlib import sha256 +from pathlib import Path +from typing import Any + +from openai_codex import ( + ApprovalMode, + AsyncCodex, + CodexConfig, + CodexError, + Sandbox, + TransportClosedError, + is_retryable_error, +) +from openai_codex.types import Personality, ReasoningEffort, TurnStatus + +import nemo_fabric_adapters.common.relay_gateway as relay_gateway +import nemo_fabric_adapters.common.relay_hooks as relay_hooks +import nemo_fabric_adapters.common.utils as common_utils + + +DEFAULT_TIMEOUT_SECONDS = 1800.0 +INTERRUPT_TIMEOUT_SECONDS = 5.0 +SANDBOXES = { + "read-only": Sandbox.read_only, + "workspace-write": Sandbox.workspace_write, + "danger-full-access": Sandbox.full_access, +} +APPROVAL_MODES = { + "auto_review": ApprovalMode.auto_review, + "deny_all": ApprovalMode.deny_all, +} +INHERITED_ENV_NAMES = { + "APPDATA", + "CODEX_HOME", + "CODEX_SQLITE_HOME", + "COMSPEC", + "HOME", + "HTTP_PROXY", + "HTTPS_PROXY", + "LANG", + "LC_ALL", + "LC_CTYPE", + "LOCALAPPDATA", + "NO_PROXY", + "OPENAI_API_KEY", + "PATH", + "PATHEXT", + "SHELL", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "SYSTEMROOT", + "TEMP", + "TMP", + "TMPDIR", + "USERPROFILE", + "XDG_CACHE_HOME", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "http_proxy", + "https_proxy", + "no_proxy", +} +REMOVED_CLI_SETTINGS = { + "codex_args", + "codex_command", + "codex_profile", + "codex_state_dir", + "skip_git_repo_check", +} +NORMALIZED_SETTING_FIELDS = { + "cwd": "FabricConfig.environment.workspace", + "model_name": "FabricConfig.models", +} + + +@dataclass(frozen=True) +class CodexRelaySettings: + """Invocation-scoped Relay state consumed by the Codex SDK adapter.""" + + gateway: relay_gateway.RelayGatewayLaunch + plugin_config: dict[str, Any] + + +class CodexAdapterError(Exception): + """Expected adapter error with a stable public code.""" + + def __init__( + self, + code: str, + message: str, + *, + metadata: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.code = code + self.message = message + self.metadata = metadata or {} + + +class AdapterInputError(CodexAdapterError): + """Invalid Fabric invocation input.""" + + +class AdapterConfigError(CodexAdapterError): + """Invalid Codex adapter configuration.""" + + +class AdapterStateError(CodexAdapterError): + """Invalid persisted runtime state.""" + + +class AdapterRelayError(CodexAdapterError): + """NeMo Relay setup or lifecycle failure.""" + + +def _mapping(value: Any, *, name: str) -> dict[str, Any]: + if value is None: + return {} + if not isinstance(value, dict): + raise AdapterConfigError( + "codex_invalid_configuration", f"{name} must be a mapping" + ) + return value + + +def _settings(payload: dict[str, Any]) -> dict[str, Any]: + return _mapping(common_utils.settings_payload(payload), name="harness.settings") + + +def _validate_settings_boundary(settings: dict[str, Any]) -> None: + removed = sorted(REMOVED_CLI_SETTINGS.intersection(settings)) + if removed: + names = ", ".join(f"harness.settings.{name}" for name in removed) + raise AdapterConfigError( + "codex_invalid_configuration", + f"Codex CLI-only settings are not supported by the SDK adapter: {names}", + ) + for name, normalized_field in NORMALIZED_SETTING_FIELDS.items(): + if name in settings: + raise AdapterConfigError( + "codex_invalid_configuration", + f"harness.settings.{name} is not supported; use {normalized_field}", + ) + + +def runtime_id(payload: dict[str, Any]) -> str: + value = common_utils.runtime_context(payload).get("runtime_id") + if not isinstance(value, str) or not value: + raise AdapterInputError( + "codex_invalid_request", "Fabric runtime ID is required" + ) + return value + + +def request_prompt(payload: dict[str, Any]) -> str: + value = (payload.get("request") or {}).get("input") + if not isinstance(value, str): + raise AdapterInputError("codex_invalid_request", "Codex input must be text") + return value + + +def resolve_cwd(payload: dict[str, Any]) -> Path: + environment = _mapping( + common_utils.environment_payload(payload), name="runtime environment" + ) + value = environment.get("workspace") or common_utils.config_root(payload) + path = Path(str(value)) + if not path.is_absolute(): + path = Path(common_utils.config_root(payload)) / path + return path.resolve() + + +def _selected_model_config(payload: dict[str, Any]) -> dict[str, Any]: + settings = _settings(payload) + models = _mapping(common_utils.models_payload(payload), name="models") + selected = models.get(settings.get("model", "default")) or {} + return _mapping(selected, name="selected model") + + +def selected_model(payload: dict[str, Any]) -> str | None: + model_config = _selected_model_config(payload) + value = model_config.get("model") + if value is None: + return None + if model_config.get("provider") != "openai": + raise AdapterConfigError( + "codex_invalid_configuration", + "selected model provider must be openai for the Codex adapter", + ) + if not isinstance(value, str) or not value: + raise AdapterConfigError( + "codex_invalid_configuration", "model must be a non-empty string" + ) + return value.removeprefix("openai/") + + +def sandbox(payload: dict[str, Any]) -> Sandbox: + value = _settings(payload).get("sandbox", "read-only") + try: + return SANDBOXES[value] + except (KeyError, TypeError) as error: + raise AdapterConfigError( + "codex_invalid_configuration", + f"sandbox must be one of: {', '.join(sorted(SANDBOXES))}", + ) from error + + +def approval_mode(payload: dict[str, Any]) -> ApprovalMode: + value = _settings(payload).get("approval_mode", "auto_review") + try: + return APPROVAL_MODES[value] + except (KeyError, TypeError) as error: + raise AdapterConfigError( + "codex_invalid_configuration", + f"approval_mode must be one of: {', '.join(sorted(APPROVAL_MODES))}", + ) from error + + +def timeout_seconds(payload: dict[str, Any]) -> float: + value = _settings(payload).get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS) + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise AdapterConfigError( + "codex_invalid_configuration", "timeout_seconds must be positive" + ) + result = float(value) + if result <= 0 or not math.isfinite(result): + raise AdapterConfigError( + "codex_invalid_configuration", "timeout_seconds must be positive" + ) + return result + + +def _optional_string(settings: dict[str, Any], name: str) -> str | None: + value = settings.get(name) + if value is None: + return None + if not isinstance(value, str) or not value: + raise AdapterConfigError( + "codex_invalid_configuration", + f"harness.settings.{name} must be a non-empty string", + ) + return value + + +def child_environment( + payload: dict[str, Any], *, relay_gateway_url: str | None = None +) -> dict[str, str]: + values = dict.fromkeys(os.environ, "") + values.update( + {name: os.environ[name] for name in INHERITED_ENV_NAMES if name in os.environ} + ) + telemetry = common_utils.runtime_context(payload).get("telemetry") + if telemetry is None: + telemetry = {} + if not isinstance(telemetry, dict): + raise AdapterInputError( + "codex_invalid_request", "runtime_context.telemetry must be a mapping" + ) + telemetry_env = telemetry.get("env") + if telemetry_env is None: + telemetry_env = {} + if not isinstance(telemetry_env, dict) or any( + not isinstance(key, str) or not isinstance(value, str) + for key, value in telemetry_env.items() + ): + raise AdapterInputError( + "codex_invalid_request", + "runtime_context.telemetry.env must contain strings", + ) + values.update(telemetry_env) + model_config = _selected_model_config(payload) + api_key_env = model_config.get("api_key_env") + if isinstance(api_key_env, str) and api_key_env in os.environ: + values[api_key_env] = os.environ[api_key_env] + configured = _mapping(_settings(payload).get("env"), name="harness.settings.env") + if any( + not isinstance(key, str) or not isinstance(value, str) + for key, value in configured.items() + ): + raise AdapterConfigError( + "codex_invalid_configuration", + "harness.settings.env must contain strings", + ) + values.update(configured) + # The SDK overlays this mapping on the parent environment. An empty + # originator is still treated as an override by Codex and produces invalid + # initialize metadata ("/"). Use the official SDK client identity + # without inheriting the identity of a parent Codex process. + values["CODEX_INTERNAL_ORIGINATOR_OVERRIDE"] = "codex_python_sdk" + if relay_gateway_url is not None: + values["NEMO_RELAY_GATEWAY_URL"] = relay_gateway_url + return values + + +def _artifact_root(payload: dict[str, Any]) -> Path: + artifacts = common_utils.runtime_context(payload).get("artifacts") or {} + root = artifacts.get("root") if isinstance(artifacts, dict) else None + if root: + return Path(str(root)) + return Path(common_utils.config_root(payload)) / "artifacts" / "codex" + + +def state_dir(payload: dict[str, Any]) -> Path: + return _artifact_root(payload) / ".fabric" / "codex" + + +def runtime_state_path(payload: dict[str, Any], fabric_runtime_id: str) -> Path: + digest = sha256(fabric_runtime_id.encode("utf-8")).hexdigest() + return state_dir(payload) / "runtimes" / f"{digest}.json" + + +def load_thread_id(payload: dict[str, Any], fabric_runtime_id: str) -> str | None: + path = runtime_state_path(payload, fabric_runtime_id) + if not path.exists(): + return None + try: + state = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(state, dict): + raise ValueError("state must be an object") + if state.get("runtime_id") != fabric_runtime_id: + raise ValueError("runtime mismatch") + thread_id = state.get("codex_thread_id") + if not isinstance(thread_id, str) or not thread_id: + raise ValueError("missing Codex thread") + return thread_id + except (OSError, ValueError, json.JSONDecodeError) as error: + raise AdapterStateError( + "codex_invalid_runtime_state", "Codex runtime state is invalid" + ) from error + + +def save_thread_id( + payload: dict[str, Any], fabric_runtime_id: str, codex_thread_id: str +) -> None: + if not codex_thread_id: + raise AdapterStateError( + "codex_invalid_runtime_state", "Codex thread ID is missing" + ) + path = runtime_state_path(payload, fabric_runtime_id) + path.parent.mkdir(parents=True, exist_ok=True) + invocation_id = ( + common_utils.runtime_context(payload).get("invocation_id") or "invocation" + ) + temporary = path.with_suffix(f".{invocation_id}.tmp") + temporary.write_text( + json.dumps( + {"runtime_id": fabric_runtime_id, "codex_thread_id": codex_thread_id}, + sort_keys=True, + ), + encoding="utf-8", + ) + os.replace(temporary, path) + + +def _merge_config(target: dict[str, Any], layer: dict[str, Any]) -> None: + for key, value in layer.items(): + existing = target.get(key) + if isinstance(existing, dict) and isinstance(value, dict): + _merge_config(existing, value) + else: + target[key] = value + + +def _json_value(value: Any, *, name: str) -> Any: + try: + json.dumps(value, allow_nan=False) + except (TypeError, ValueError) as error: + raise AdapterConfigError( + "codex_invalid_configuration", f"{name} must be JSON-compatible" + ) from error + return value + + +def _apply_config_overrides( + config: dict[str, Any], overrides: dict[str, Any] +) -> None: + for dotted_key, value in sorted(overrides.items()): + if not isinstance(dotted_key, str): + raise AdapterConfigError( + "codex_invalid_configuration", + "config_overrides keys must be strings", + ) + parts = dotted_key.split(".") + if any(not part for part in parts): + raise AdapterConfigError( + "codex_invalid_configuration", + f"invalid Codex config override key {dotted_key!r}", + ) + target = config + for part in parts[:-1]: + existing = target.setdefault(part, {}) + if not isinstance(existing, dict): + raise AdapterConfigError( + "codex_invalid_configuration", + f"Codex config override {dotted_key!r} conflicts with {part!r}", + ) + target = existing + target[parts[-1]] = _json_value( + value, name=f"config_overrides.{dotted_key}" + ) + + +def native_codex_telemetry_config(payload: dict[str, Any]) -> dict[str, Any]: + if "native" not in common_utils.telemetry_providers(payload): + return {} + + telemetry_config = common_utils.native_telemetry_config(payload) + for component in telemetry_config.get("components") or []: + if ( + not isinstance(component, dict) + or component.get("kind") != "observability" + or not component.get("enabled", True) + ): + continue + component_config = component.get("config") or {} + opentelemetry = component_config.get("opentelemetry") or {} + if not isinstance(opentelemetry, dict) or not opentelemetry.get("enabled"): + continue + + otel: dict[str, Any] = {} + resource_attributes = opentelemetry.get("resource_attributes") or {} + environment = resource_attributes.get("deployment.environment") + if environment is not None: + otel["environment"] = environment + + endpoint = opentelemetry.get("endpoint") + if endpoint: + transport = opentelemetry.get("transport", "http_binary") + exporters = { + "http_binary": ("otlp-http", "binary"), + "grpc": ("otlp-grpc", "grpc"), + "http_json": ("otlp-http", "json"), + } + try: + exporter, protocol = exporters[transport] + except (KeyError, TypeError) as error: + raise AdapterConfigError( + "codex_invalid_configuration", + f"unsupported Codex native OpenTelemetry transport {transport!r}", + ) from error + otel["trace_exporter"] = { + exporter: {"endpoint": endpoint, "protocol": protocol} + } + return {"otel": otel} + return {} + + +def prepare_codex_relay(payload: dict[str, Any]) -> CodexRelaySettings | None: + """Generate invocation-scoped Relay gateway configuration.""" + + if not common_utils.relay_enabled(payload): + return None + command = _settings(payload).get("nemo_relay_command") or "nemo-relay" + if not isinstance(command, (str, Path)): + raise AdapterConfigError( + "codex_invalid_configuration", "nemo_relay_command must be a path" + ) + try: + executable = relay_gateway.resolve_relay_command( + Path(common_utils.config_root(payload)).resolve(), command + ) + except FileNotFoundError as error: + raise AdapterRelayError( + "codex_relay_unavailable", "NeMo Relay CLI executable was not found" + ) from error + + try: + relay_contract = relay_gateway.relay_cli_contract(executable) + plugin_config = common_utils.load_relay_plugin_config(payload) + config_path, plugin_config_path = common_utils.write_relay_configs( + # The SDK owns Codex execution. Relay needs only gateway defaults and + # the sibling plugins.toml; configuring an agent command would retain + # a misleading dependency on the removed Codex CLI launch path. + relay_config={}, + plugin_config=plugin_config, + observability_version=relay_contract.observability_version, + ) + except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as error: + raise AdapterRelayError( + "codex_relay_configuration_failed", + "NeMo Relay runtime configuration is unavailable", + ) from error + if config_path is None or plugin_config_path is None: + raise AdapterRelayError( + "codex_relay_configuration_failed", + "NeMo Relay runtime configuration is unavailable", + ) + + port = relay_gateway.find_available_tcp_port() + bind = f"127.0.0.1:{port}" + return CodexRelaySettings( + gateway=relay_gateway.RelayGatewayLaunch( + executable=executable, + config_path=config_path, + bind=bind, + url=f"http://{bind}", + log_path=config_path.parent / "gateway.log", + ), + plugin_config=plugin_config, + ) + + +def thread_config( + payload: dict[str, Any], relay: CodexRelaySettings | None +) -> dict[str, Any]: + """Build request-scoped Codex config without writing a user profile.""" + + config = native_codex_telemetry_config(payload) + overrides = _mapping( + _settings(payload).get("config_overrides"), + name="harness.settings.config_overrides", + ) + _apply_config_overrides(config, overrides) + if relay is not None: + _merge_config( + config, + { + # Keep the SDK-selected built-in provider so Codex retains its + # native API-key and ChatGPT authentication behavior. Relay is + # only the transport endpoint for this invocation. + "openai_base_url": relay.gateway.url, + "features": { + "hooks": True, + # Relay disables delegated multi-agent execution because + # Codex encrypts delegated task content before it reaches + # the gateway, making those spans opaque. + "multi_agent_v2": {"enabled": False}, + }, + "hooks": relay_hooks.render_relay_hooks( + "codex", relay.gateway.executable + )["hooks"], + # This runtime-only request override is the SDK-native equivalent + # of the former non-interactive CLI flag. Fabric generated and + # vetted every hook command above. + "bypass_hook_trust": True, + }, + ) + return config + + +def sdk_config( + payload: dict[str, Any], relay: CodexRelaySettings | None +) -> CodexConfig: + codex_bin = _optional_string(_settings(payload), "codex_bin") + if codex_bin is not None: + path = Path(codex_bin) + if not path.is_absolute(): + path = (Path(common_utils.config_root(payload)) / path).resolve() + codex_bin = str(path) + return CodexConfig( + codex_bin=codex_bin, + cwd=str(resolve_cwd(payload)), + env=child_environment( + payload, + relay_gateway_url=relay.gateway.url if relay is not None else None, + ), + ) + + +def _personality(payload: dict[str, Any]) -> Personality | None: + value = _optional_string(_settings(payload), "personality") + if value is None: + return None + try: + return Personality(value) + except ValueError as error: + raise AdapterConfigError( + "codex_invalid_configuration", "personality is invalid" + ) from error + + +def _reasoning_effort(payload: dict[str, Any]) -> ReasoningEffort | None: + value = _optional_string(_settings(payload), "reasoning_effort") + if value is None: + return None + try: + return ReasoningEffort(value) + except ValueError as error: + raise AdapterConfigError( + "codex_invalid_configuration", "reasoning_effort is invalid" + ) from error + + +def _output_schema(payload: dict[str, Any]) -> dict[str, Any] | None: + value = _settings(payload).get("output_schema") + if value is None: + return None + return _mapping(_json_value(value, name="output_schema"), name="output_schema") + + +def validate_payload(payload: dict[str, Any]) -> str: + """Validate pure invocation inputs before starting SDK or Relay processes.""" + + settings = _settings(payload) + _validate_settings_boundary(settings) + request_prompt(payload) + fabric_runtime_id = runtime_id(payload) + resolve_cwd(payload) + selected_model(payload) + sandbox(payload) + approval_mode(payload) + timeout_seconds(payload) + for name in ( + "base_instructions", + "developer_instructions", + "service_name", + "service_tier", + ): + _optional_string(settings, name) + _personality(payload) + _reasoning_effort(payload) + _output_schema(payload) + child_environment(payload) + thread_config(payload, None) + return fabric_runtime_id + + +def _json_safe(value: Any) -> Any: + if hasattr(value, "model_dump"): + return _json_safe(value.model_dump(mode="json", by_alias=True)) + if is_dataclass(value) and not isinstance(value, type): + return _json_safe(asdict(value)) + if isinstance(value, Enum): + return _json_safe(value.value) + if isinstance(value, dict): + return {str(key): _json_safe(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_json_safe(item) for item in value] + if isinstance(value, Path): + return str(value) + if value is None or isinstance(value, (str, int, float, bool)): + return value + raise AdapterConfigError( + "codex_invalid_configuration", "Codex SDK result is not JSON-safe" + ) + + +def _failure( + code: str, + message: str, + *, + retryable: bool = False, + **metadata: Any, +) -> dict[str, Any]: + error: dict[str, Any] = { + "code": code, + "message": message, + "retryable": retryable, + } + if metadata: + error["metadata"] = metadata + return { + "harness": "codex", + "adapter": "sdk", + "mode": "codex_sdk_runtime", + "response": None, + "completed": False, + "failed": True, + "error": error, + "events": [], + } + + +def adapter_failure(error: CodexAdapterError) -> dict[str, Any]: + return _failure(error.code, error.message, **error.metadata) + + +def sdk_failure(error: BaseException) -> dict[str, Any]: + if isinstance(error, TimeoutError): + return _failure("codex_timed_out", "Codex invocation timed out") + if isinstance(error, TransportClosedError): + return _failure( + "codex_connection_failed", "Codex SDK runtime connection closed" + ) + if isinstance(error, CodexError): + return _failure( + "codex_sdk_failed", + "Codex SDK request failed", + retryable=is_retryable_error(error), + sdk_error=type(error).__name__, + ) + if isinstance(error, OSError): + return _failure( + "codex_runtime_unavailable", "Codex SDK runtime could not start" + ) + return _failure( + "codex_turn_failed", + str(error) or "Codex turn failed", + ) + + +def normalize_result( + payload: dict[str, Any], *, thread_id: str, result: Any +) -> dict[str, Any]: + status = _json_safe(result.status) + completed = result.status == TurnStatus.completed and result.final_response is not None + error = None + if not completed: + message = ( + result.error.message + if result.error is not None + else "Codex invocation did not return a final response" + ) + error = { + "code": "codex_turn_incomplete", + "message": message, + "retryable": False, + "metadata": {"status": status}, + } + return { + "harness": "codex", + "adapter": "sdk", + "mode": "codex_sdk_runtime", + "cwd": str(resolve_cwd(payload)), + "model": selected_model(payload), + "thread_id": thread_id, + "turn_id": result.id, + "turn_status": status, + "response": result.final_response, + "usage": _json_safe(result.usage), + "started_at": result.started_at, + "completed_at": result.completed_at, + "duration_ms": result.duration_ms, + "completed": completed, + "failed": not completed, + "error": error, + "events": [_json_safe(item) for item in result.items], + "state_dir": str(state_dir(payload)), + } + + +async def _interrupt_turn(handle: Any) -> None: + if handle is None: + return + try: + async with asyncio.timeout(INTERRUPT_TIMEOUT_SECONDS): + await handle.interrupt() + except (TimeoutError, CodexError, RuntimeError, OSError): + # The SDK process is closed immediately afterwards, which is the final + # cancellation boundary if the runtime cannot acknowledge interrupt. + pass + + +async def invoke_codex_sdk( + payload: dict[str, Any], + *, + prior_thread_id: str | None, + relay: CodexRelaySettings | None, +) -> tuple[dict[str, Any], str | None]: + """Execute one SDK turn and always close the app-server transport.""" + + settings = _settings(payload) + config = thread_config(payload, relay) + codex = AsyncCodex(config=sdk_config(payload, relay)) + handle = None + output: dict[str, Any] + thread_id: str | None = None + try: + async with asyncio.timeout(timeout_seconds(payload)): + common = { + "approval_mode": approval_mode(payload), + "base_instructions": _optional_string(settings, "base_instructions"), + "config": config or None, + "cwd": str(resolve_cwd(payload)), + "developer_instructions": _optional_string( + settings, "developer_instructions" + ), + "model": selected_model(payload), + "model_provider": "openai", + "personality": _personality(payload), + "sandbox": sandbox(payload), + "service_tier": _optional_string(settings, "service_tier"), + } + if prior_thread_id is None: + thread = await codex.thread_start( + **common, + service_name=_optional_string(settings, "service_name"), + ) + else: + thread = await codex.thread_resume(prior_thread_id, **common) + if thread.id != prior_thread_id: + raise AdapterStateError( + "codex_thread_mismatch", + "Codex thread identity changed during resume", + ) + thread_id = thread.id + handle = await thread.turn( + request_prompt(payload), + effort=_reasoning_effort(payload), + output_schema=_output_schema(payload), + ) + result = await handle.run() + output = normalize_result(payload, thread_id=thread.id, result=result) + except TimeoutError as error: + await _interrupt_turn(handle) + output = sdk_failure(error) + except CodexAdapterError: + raise + except (CodexError, RuntimeError, OSError) as error: + output = sdk_failure(error) + finally: + try: + await codex.close() + except Exception: + output = _failure( + "codex_sdk_stop_failed", "Codex SDK runtime failed to stop" + ) + return output, thread_id + + +def _relay_output( + output: dict[str, Any], relay: CodexRelaySettings +) -> dict[str, Any]: + output["relay_runtime"] = { + "enabled": True, + "emitter": "codex-sdk/nemo-relay", + "config_path": os.environ.get("FABRIC_RELAY_CONFIG_PATH"), + "gateway_config_path": str(relay.gateway.config_path), + "gateway_url": relay.gateway.url, + "gateway_log_path": str(relay.gateway.log_path), + } + output["relay_artifacts"] = common_utils.collect_relay_artifacts( + relay.plugin_config + ) + return output + + +async def run_codex(payload: dict[str, Any]) -> dict[str, Any]: + """Run one Fabric invocation with SDK-owned Codex execution.""" + + fabric_runtime_id = validate_payload(payload) + prior_thread_id = load_thread_id(payload, fabric_runtime_id) + relay = prepare_codex_relay(payload) + gateway_process = None + cleanup_error: AdapterRelayError | None = None + try: + if relay is not None: + try: + gateway_process = relay_gateway.start_relay_gateway( + launch=relay.gateway, cwd=resolve_cwd(payload) + ) + except relay_gateway.RelayGatewayError as error: + raise AdapterRelayError( + "codex_relay_start_failed", + "NeMo Relay gateway failed to start", + metadata={"gateway_log_path": str(relay.gateway.log_path)}, + ) from error + output, thread_id = await invoke_codex_sdk( + payload, prior_thread_id=prior_thread_id, relay=relay + ) + if not output["failed"] and thread_id is not None: + save_thread_id(payload, fabric_runtime_id, thread_id) + finally: + if gateway_process is not None: + try: + relay_gateway.stop_relay_gateway(gateway_process) + except relay_gateway.RelayGatewayError: + cleanup_error = AdapterRelayError( + "codex_relay_stop_failed", + "NeMo Relay gateway failed to stop", + metadata={ + "gateway_log_path": str(relay.gateway.log_path) + if relay is not None + else "" + }, + ) + + if relay is not None: + output = _relay_output(output, relay) + if cleanup_error is not None: + cleanup: dict[str, Any] = { + "code": cleanup_error.code, + "message": cleanup_error.message, + "retryable": False, + } + if cleanup_error.metadata: + cleanup["metadata"] = cleanup_error.metadata + output["relay_runtime"]["cleanup_error"] = cleanup + if not output["failed"]: + output["completed"] = False + output["failed"] = True + output["error"] = cleanup + return output + + +def run(payload: dict[str, Any]) -> dict[str, Any]: + """Run one Fabric invocation from the synchronous adapter boundary.""" + + try: + return asyncio.run(run_codex(payload)) + except CodexAdapterError as error: + return adapter_failure(error) + except Exception: + return _failure( + "codex_adapter_internal_error", "Codex adapter failed unexpectedly" + ) + + +def main() -> None: + try: + payload = common_utils.load_payload() + except Exception: + output = _failure( + "codex_adapter_internal_error", "Codex adapter failed unexpectedly" + ) + else: + output = run(payload) + print(json.dumps(output, sort_keys=True)) + if output.get("failed"): + raise SystemExit(2) + + +if __name__ == "__main__": + main() diff --git a/adapters/codex/uv.lock b/adapters/codex/uv.lock new file mode 100644 index 000000000..7846fbaeb --- /dev/null +++ b/adapters/codex/uv.lock @@ -0,0 +1,212 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[manifest] +constraints = [{ name = "openai-codex-cli-bin", specifier = "==0.137.0a4" }] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "nemo-fabric-adapters-codex" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "nemo-fabric-adapters-common" }, + { name = "openai-codex" }, + { name = "tomli-w" }, +] + +[package.metadata] +requires-dist = [ + { name = "nemo-fabric-adapters-common", editable = "../common" }, + { name = "openai-codex", specifier = "==0.1.0b3" }, + { name = "tomli-w", specifier = "~=1.2" }, +] + +[[package]] +name = "nemo-fabric-adapters-common" +version = "0.1.0" +source = { editable = "../common" } + +[[package]] +name = "openai-codex" +version = "0.1.0b3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openai-codex-cli-bin" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/1c/1e5e8b83ea72164d32b1f4e67fc703c8b83591f498a7aaf96f39d352b453/openai_codex-0.1.0b3.tar.gz", hash = "sha256:b76b7afe97953ac65648e9b8ca116b5ff273de91086549bd7ec88037cdc16cab", size = 58995, upload-time = "2026-06-03T19:17:34.707Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ef/f77037d9ccde80a688a17a06aea5a56813ad9c365d49b3f1c7913422af8b/openai_codex-0.1.0b3-py3-none-any.whl", hash = "sha256:8d1f9d346667aeecb435c6a45d0edb3f016187276ec452cf8094d813896276c4", size = 65639, upload-time = "2026-06-03T19:17:33.208Z" }, +] + +[[package]] +name = "openai-codex-cli-bin" +version = "0.137.0a4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/60/af73ef1676cd477fa83ed4b889bf3b57c63c47dd87025b2cc4262793cff6/openai_codex_cli_bin-0.137.0a4-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:b33c3917e0b58d527ee11a11a78ad390f7d8e6aa25577dd21665ab3c8bf5cf9a", size = 94300191, upload-time = "2026-06-03T18:44:36.312Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/d1a5f8c87176e00ef6a85798794f4530f5eb04e5a1a13468b5b3c3a361f9/openai_codex_cli_bin-0.137.0a4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3d0f0bc5becc88c61952fbfa9bd792ac9d74fa78b3a6bd40f545b612048b07eb", size = 83924479, upload-time = "2026-06-03T18:44:40.854Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3c/fc00bcdc0c302208317d5eb1d0bfaab3024f351cd0121400f19baa6b19aa/openai_codex_cli_bin-0.137.0a4-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:2f1656339e2736868c4cce59f6d9e5c633879123687169b03b1137d42bf2c11a", size = 83363315, upload-time = "2026-06-03T18:44:44.851Z" }, + { url = "https://files.pythonhosted.org/packages/ec/09/39362e944ebeb12fcbfb86881fbb4dd6e806f77f7541c1f1f993bb9351a0/openai_codex_cli_bin-0.137.0a4-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:6454f838d44c56c1ed07a29b391fa412785e5dd2ffd06db0b62e62478c19bb64", size = 90611239, upload-time = "2026-06-03T18:44:49.338Z" }, + { url = "https://files.pythonhosted.org/packages/fa/38/87b1247fdfe95cddce7f7fe8331d6843cf037e14292c0f5004e23247133b/openai_codex_cli_bin-0.137.0a4-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:f5ae7401d00c65d56a75d9645d7bf87d809566a12d238e4b2a8b328a02f2316e", size = 83363315, upload-time = "2026-06-03T18:44:53.428Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c4/3c693ad07e587f6b3a28128c417f2e831d81a40cdbd85c0e5f0f36aaff82/openai_codex_cli_bin-0.137.0a4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:3dcec1e649448be498d6e7ec0e1f71dca83efa76063d90890dafb41e987069b7", size = 90611238, upload-time = "2026-06-03T18:44:57.612Z" }, + { url = "https://files.pythonhosted.org/packages/9e/26/81e037066b9b8d312a6f9e09015e452ce17630d5ab88e02a4c1d9503e4e8/openai_codex_cli_bin-0.137.0a4-py3-none-win_amd64.whl", hash = "sha256:9e13bf68e18e36bd3a0efd51213281c83e9f6ec22bdb7a45bd2e0211822733a9", size = 94744969, upload-time = "2026-06-03T18:45:02.23Z" }, + { url = "https://files.pythonhosted.org/packages/0d/a3/952bc2a5d62373a51fea161effe3b338b3417c2f6e65fe467ed91b205e2b/openai_codex_cli_bin-0.137.0a4-py3-none-win_arm64.whl", hash = "sha256:5ec4303ca2dcb5f838e0de3ca7f44050b6bcdd41d281a178c3a1420a985a515d", size = 86963504, upload-time = "2026-06-03T18:45:07.131Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] diff --git a/adapters/common/src/nemo_fabric_adapters/common/relay_gateway.py b/adapters/common/src/nemo_fabric_adapters/common/relay_gateway.py index b4360031d..0dc1ff585 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/relay_gateway.py +++ b/adapters/common/src/nemo_fabric_adapters/common/relay_gateway.py @@ -20,6 +20,8 @@ RELAY_HEALTH_TIMEOUT_SECONDS = 10.0 RELAY_STOP_TIMEOUT_SECONDS = 5.0 RELAY_VERSION_TIMEOUT_SECONDS = 5.0 +RELAY_MINIMUM_VERSION = (0, 6, 0) +RELAY_MAXIMUM_VERSION = (0, 7, 0) class RelayGatewayError(RuntimeError): @@ -37,6 +39,14 @@ class RelayGatewayLaunch: log_path: Path +@dataclass(frozen=True) +class RelayCliContract: + """Versioned external Relay CLI contract consumed by Fabric adapters.""" + + version: tuple[int, int, int] + observability_version: int + + def resolve_relay_command(config_root: Path, value: str | Path) -> Path: """Resolve the configured Relay CLI to one absolute executable path.""" @@ -59,8 +69,8 @@ def find_available_tcp_port(host: str = "127.0.0.1") -> int: return int(listener.getsockname()[1]) -def relay_cli_observability_version(executable: Path) -> int: - """Return the observability config version accepted by a Relay CLI.""" +def relay_cli_contract(executable: Path) -> RelayCliContract: + """Resolve and validate the external Relay CLI contract once per invocation.""" try: completed = subprocess.run( @@ -78,8 +88,15 @@ def relay_cli_observability_version(executable: Path) -> int: match = re.search(r"\b(\d+)\.(\d+)\.(\d+)", completed.stdout) if completed.returncode != 0 or match is None: raise RelayGatewayError("NeMo Relay CLI version could not be determined") - major, minor, _ = (int(value) for value in match.groups()) - return 2 if (major, minor) >= (0, 6) else 1 + major, minor, patch = (int(value) for value in match.groups()) + version = (major, minor, patch) + if not RELAY_MINIMUM_VERSION <= version < RELAY_MAXIMUM_VERSION: + raise RelayGatewayError( + "unsupported NeMo Relay CLI version " + f"{'.'.join(str(value) for value in version)}; " + "Fabric requires >=0.6.0,<0.7.0" + ) + return RelayCliContract(version=version, observability_version=2) def wait_for_relay_gateway( diff --git a/adapters/common/src/nemo_fabric_adapters/common/relay_hooks.py b/adapters/common/src/nemo_fabric_adapters/common/relay_hooks.py index 8e8faad4d..110bfcfec 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/relay_hooks.py +++ b/adapters/common/src/nemo_fabric_adapters/common/relay_hooks.py @@ -7,38 +7,58 @@ import shlex from pathlib import Path -from typing import Any -from typing import Literal +from typing import Any, Literal RelayHookAgent = Literal["claude", "codex"] -# NeMo Relay currently uses this union for both Claude Code and Codex. Keep the -# snapshot centralized until Relay exposes its hook renderer as a public API. -RELAY_HOOK_EVENTS = ( - "SessionStart", - "UserPromptSubmit", - "UserPromptExpansion", - "PreToolUse", - "PostToolUse", - "PostToolUseFailure", - "PermissionRequest", - "SubagentStart", - "SubagentStop", - "Notification", - "Stop", - "PreCompact", - "PostCompact", - "SessionEnd", -) -RELAY_TOOL_HOOK_EVENTS = frozenset( - { +RELAY_HOOK_EVENTS: dict[RelayHookAgent, tuple[str, ...]] = { + "claude": ( + "SessionStart", + "UserPromptSubmit", + "UserPromptExpansion", "PreToolUse", "PostToolUse", "PostToolUseFailure", "PermissionRequest", - } -) + "SubagentStart", + "SubagentStop", + "Notification", + "Stop", + "PreCompact", + "PostCompact", + "SessionEnd", + ), + "codex": ( + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PermissionRequest", + "SubagentStart", + "SubagentStop", + "Stop", + "PreCompact", + "PostCompact", + ), +} +RELAY_TOOL_HOOK_EVENTS: dict[RelayHookAgent, frozenset[str]] = { + "claude": frozenset( + { + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PermissionRequest", + } + ), + "codex": frozenset( + { + "PreToolUse", + "PostToolUse", + "PermissionRequest", + } + ), +} def render_relay_hooks( @@ -52,7 +72,7 @@ def render_relay_hooks( command = f"{shlex.quote(str(executable))} hook-forward {agent}" hooks: dict[str, list[dict[str, Any]]] = {} - for event in RELAY_HOOK_EVENTS: + for event in RELAY_HOOK_EVENTS[agent]: group: dict[str, Any] = { "hooks": [ { @@ -62,7 +82,7 @@ def render_relay_hooks( } ] } - if event in RELAY_TOOL_HOOK_EVENTS: + if event in RELAY_TOOL_HOOK_EVENTS[agent]: group["matcher"] = "*" hooks[event] = [group] return {"hooks": hooks} diff --git a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py index f35e9c709..fcbe78966 100644 --- a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py +++ b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py @@ -18,10 +18,13 @@ import os import shlex import uuid +from collections.abc import Callable from pathlib import Path from typing import Any from typing import NamedTuple +from langchain.agents.middleware import AgentMiddleware +from langchain_core.messages import ToolMessage import nemo_fabric_adapters.common.utils as common_utils HARNESS = "deepagents" @@ -55,6 +58,36 @@ class AdapterConfigError(RuntimeError): """Raised for invalid Deep Agents adapter configuration (normalized to a failure).""" +class ToolGateMiddleware(AgentMiddleware): # type: ignore[misc] + """Block tool calls selected by an adapter-owned policy.""" + + def __init__( + self, + is_blocked: Callable[[Any], bool], + message: Callable[[Any], str], + ): + self._is_blocked = is_blocked + self._message = message + + def _blocked(self, request: Any) -> ToolMessage: + name = request.tool_call.get("name") + return ToolMessage( + content=self._message(name), + tool_call_id=request.tool_call.get("id", ""), + status="error", + ) + + async def awrap_tool_call(self, request: Any, handler: Any) -> Any: + if self._is_blocked(request.tool_call.get("name")): + return self._blocked(request) + return await handler(request) + + def wrap_tool_call(self, request: Any, handler: Any) -> Any: + if self._is_blocked(request.tool_call.get("name")): + return self._blocked(request) + return handler(request) + + def resolve_api_key_env(settings: dict[str, Any], model_config: dict[str, Any]) -> str: """Resolve the credential env var, defaulting per provider. @@ -211,30 +244,10 @@ def _blocked_tool_names(payload: dict[str, Any]) -> set[str]: return set(common_utils.blocked_tools(payload)) -def _tool_gate_middleware(is_blocked: Any, message: Any) -> Any: - from langchain.agents.middleware import AgentMiddleware - from langchain_core.messages import ToolMessage - - def _blocked(request: Any) -> Any: - name = request.tool_call.get("name") - return ToolMessage( - content=message(name), - tool_call_id=request.tool_call.get("id", ""), - status="error", - ) - - class ToolGateMiddleware(AgentMiddleware): # type: ignore[misc] - async def awrap_tool_call(self, request: Any, handler: Any) -> Any: - if is_blocked(request.tool_call.get("name")): - return _blocked(request) - return await handler(request) - - def wrap_tool_call(self, request: Any, handler: Any) -> Any: - if is_blocked(request.tool_call.get("name")): - return _blocked(request) - return handler(request) - - return ToolGateMiddleware() +def _tool_gate_middleware( + is_blocked: Callable[[Any], bool], message: Callable[[Any], str] +) -> ToolGateMiddleware: + return ToolGateMiddleware(is_blocked, message) def blocked_tools_middleware(blocked: set[str]) -> Any: @@ -290,7 +303,7 @@ def _mcp_connection(name: str, spec: dict[str, Any]) -> dict[str, Any]: # --- runtime / resume state ------------------------------------------------ # # Resume is keyed by the Fabric ``runtime_id`` (stable across ``invoke`` calls in -# a started runtime, fresh for each one-shot ``run``), mirroring the codex-cli +# a started runtime, fresh for each one-shot ``run``), mirroring the Codex # adapter. LangGraph owns the transcript via a persistent SQLite checkpointer; # Fabric owns the runtime-to-LangGraph-thread correlation record. diff --git a/crates/fabric-core/src/config.rs b/crates/fabric-core/src/config.rs index a40d3abce..37d83d06c 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -2333,7 +2333,7 @@ schema_version: fabric.agent/v1alpha1 metadata: name: demo harness: - adapter_id: nvidia.fabric.codex.cli + adapter_id: nvidia.fabric.codex runtime: telemetry: providers: @@ -2344,7 +2344,7 @@ telemetry: .expect("config with relay and native telemetry providers"); let descriptor: AdapterDescriptor = serde_json::from_value(serde_json::json!({ "contract_version": ADAPTER_CONTRACT_VERSION, - "adapter_id": "nvidia.fabric.codex.cli", + "adapter_id": "nvidia.fabric.codex", "harness": "codex", "adapter_kind": "python", "telemetry": { diff --git a/crates/fabric-core/src/doctor.rs b/crates/fabric-core/src/doctor.rs index 7524d2e87..c4a397677 100644 --- a/crates/fabric-core/src/doctor.rs +++ b/crates/fabric-core/src/doctor.rs @@ -537,10 +537,15 @@ mod tests { #[test] fn binary_requirement_can_use_harness_command_setting() { - let mut plan = - resolve_run_plan(file_config_agent_dir(), Some("codex_cli")).expect("run plan"); + let mut plan = resolve_run_plan(file_config_agent_dir(), Some("codex")).expect("run plan"); + plan.adapter_descriptor + .as_mut() + .expect("adapter descriptor") + .descriptor + .requirements + .binaries = vec!["fabric-doctor-test".to_string()]; plan.config.harness.settings.insert( - "codex_command".to_string(), + "fabric_doctor_test_command".to_string(), Value::String( std::env::current_exe() .expect("current executable") @@ -554,7 +559,7 @@ mod tests { assert!(report.checks.iter().any(|check| { check.name == "requirement.binary" && check.status == DoctorStatus::Pass - && check.message.contains("codex_command") + && check.message.contains("fabric_doctor_test_command") })); } diff --git a/docs/getting-started/install.mdx b/docs/getting-started/install.mdx index 540d3eda4..56f8f22bc 100644 --- a/docs/getting-started/install.mdx +++ b/docs/getting-started/install.mdx @@ -61,14 +61,18 @@ uv pip install --find-links nemo-fabric[runtime] ``` ### Installing the Fabric Adapters -This will install the Fabric adapter and it's dependencies, adapters intentionally have minimal dependencies, -and are intended to be installed in the same environment as the agent harness (which may or may not be the same environment as the Fabric runtime). - -At time of writing, the following adapter packages are available: -* adapters-claude -* adapters-codex-cli -* adapters-deepagents -* adapters-hermes + +This command installs a Fabric adapter and its dependencies. Adapters +intentionally have minimal dependencies and belong in the same environment as +the agent harness. That environment can differ from the Fabric runtime +environment. + +The following adapter packages are available: + +- `adapters-claude` +- `adapters-codex` +- `adapters-deepagents` +- `adapters-hermes` ```bash uv pip install --find-links nemo-fabric[] diff --git a/docs/getting-started/overview.mdx b/docs/getting-started/overview.mdx index d79394d44..4312c1fd9 100644 --- a/docs/getting-started/overview.mdx +++ b/docs/getting-started/overview.mdx @@ -10,7 +10,7 @@ SPDX-License-Identifier: Apache-2.0 */} NeMo Fabric is the harness-management layer that turns multiple agent runtimes into one configurable, observable execution surface. Applications use the same versioned config, lifecycle, result, artifact, and telemetry contracts whether -the selected harness is Hermes, Codex CLI, or a custom adapter. +the selected harness is Hermes, Codex SDK, or a custom adapter. Fabric owns the seam between an application and its harness. It resolves configuration, selects an adapter, drives the runtime lifecycle, @@ -51,7 +51,7 @@ NeMo Fabric Rust core | | resolved adapter contract v -Hermes | Codex CLI | custom harness +Hermes | Codex SDK | custom harness | v RunResult + artifacts + events + telemetry references diff --git a/docs/index.yml b/docs/index.yml index ee1ed193b..5370d9a40 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -14,6 +14,8 @@ navigation: path: ./sdk/python.mdx - section: Integrations contents: + - page: Codex + path: ./integrations/codex.mdx - page: Claude path: ./integrations/claude.mdx - page: Harbor diff --git a/docs/integrations/claude.mdx b/docs/integrations/claude.mdx index 00f82bc61..de81e9ac7 100644 --- a/docs/integrations/claude.mdx +++ b/docs/integrations/claude.mdx @@ -12,6 +12,10 @@ Claude Code runtime. Fabric preserves Claude's native credential precedence and forwards only supported operating-system, configuration, and authentication variables plus values explicitly configured in `harness.settings.env`. +The adapter pins `claude-agent-sdk==0.2.120`. The SDK owns its compatible Claude +Code runtime unless `harness.settings.cli_path` explicitly selects another +executable. + ## Choose an Authentication Mode Use the mode that matches the execution environment: @@ -88,3 +92,8 @@ starts the gateway as a supervised sidecar and sets `ANTHROPIC_BASE_URL` for the Claude runtime. Claude still resolves its credential through the selected mode, and Fabric does not write authentication values to Relay configuration or artifacts. + +Fabric supports the external NeMo Relay CLI from `0.6.0` up to, but not +including, `0.7.0`. The Python package named `nemo-relay` does not install this +CLI. Fabric owns sidecar supervision and Claude configuration; Relay owns the +gateway transport and semantic observability pipeline. diff --git a/docs/integrations/codex.mdx b/docs/integrations/codex.mdx new file mode 100644 index 000000000..4401695c4 --- /dev/null +++ b/docs/integrations/codex.mdx @@ -0,0 +1,125 @@ +--- +title: "Authenticate the Codex Adapter" +description: "Configure ChatGPT or API key authentication for the NeMo Fabric Codex SDK adapter." +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +# Authenticate the Codex Adapter + +The `nvidia.fabric.codex` adapter uses the Codex Python SDK and its pinned +app-server runtime. Fabric does not execute `codex` for agent turns or fall back +to the legacy Codex CLI adapter. + +## Select a Codex Runtime + +The adapter pins `openai-codex==0.1.0b3`. That SDK release installs +`openai-codex-cli-bin==0.137.0a4` as its app-server runtime. A newer `codex` +command on `PATH` does not replace the SDK-owned runtime. + +To test a newer compatible app-server explicitly, set an absolute path in +`harness.settings.codex_bin`: + +```python +config.harness.settings["codex_bin"] = "/path/to/codex" +``` + +Fabric passes this path to `CodexConfig.codex_bin`; it does not invoke the +command as a CLI adapter. Pin the override in reproducible environments because +the app-server protocol can change before a matching Python SDK is published. + +## Choose an Authentication Mode + +Codex supports the following OpenAI authentication modes for local work: + +| Mode | Credential Source | Recommended Use | +| --- | --- | --- | +| ChatGPT login | Cached Codex login under `CODEX_HOME` | Local development with an eligible ChatGPT plan | +| API key login | API key provisioned into the Codex credential store | Usage-based or noninteractive OpenAI Platform workloads | + +With ChatGPT login, Fabric can run without `OPENAI_API_KEY`. With API key login, +usage is billed through the OpenAI Platform account instead of ChatGPT plan +credits. Refer to the +[Codex authentication documentation](https://developers.openai.com/codex/auth/) +for current plan availability and login commands. + +## Reuse a Codex Login + +Complete the Codex login outside Fabric. The adapter inherits `CODEX_HOME` +(default: `~/.codex`) and the SDK reuses the cached credential. If you select a +different credential store, use the same value for login and execution: + +```bash +export CODEX_HOME=/path/to/codex-home +codex login +``` + +For API key login, provision the credential store according to the current +Codex authentication procedure: + +```bash +export CODEX_HOME=/path/to/codex-home +printenv OPENAI_API_KEY | codex login --with-api-key +``` + +The login command is a credential-provisioning step. Fabric's runtime path uses +the SDK and does not invoke the command after the credential is cached. The +adapter also forwards `OPENAI_API_KEY` and a selected model's `api_key_env`, but +the current real-agent acceptance path validates cached Codex authentication. + +Treat `CODEX_HOME/auth.json` as a secret when Codex uses file-based credential +storage. Do not commit or copy it into Fabric configuration or artifacts. + +## Use Authentication with Relay + +NeMo Relay does not replace OpenAI authentication. A Relay-enabled invocation +starts the Relay gateway as a supervised sidecar and directs the Codex SDK's +built-in OpenAI provider through that gateway. The SDK still obtains credentials +from the selected Codex authentication mode. + +Fabric supplies Relay configuration to the SDK for only the current request. It +does not copy the Codex credential store into Relay configuration or persist +credentials in Relay artifacts. + +Fabric supports the external NeMo Relay CLI from `0.6.0` up to, but not +including, `0.7.0`. The Python package named `nemo-relay` is a separate library +dependency and does not install the CLI. Fabric owns sidecar supervision and +request-scoped SDK configuration. Relay owns gateway transport behavior, +including decoding `Content-Encoding` before it constructs managed LLM events. +There is no Fabric compression setting. + +The initial NeMo Relay `0.6.0` source tag cannot recover semantic fields from +zstd-compressed Codex SDK requests. Until a later `0.6.x` release contains the +fix, use a build that includes +[NeMo Relay PR #452](https://github.com/NVIDIA/NeMo-Relay/pull/452). Fabric's +opt-in Relay E2E rejects opaque request bodies and missing token usage. + +## Compare Phoenix Trace Modes + +Use native Codex OpenTelemetry when you need low-level app-server diagnostics. +For Phoenix, send native OTLP/HTTP traces to the collector port, not the Phoenix +web port: + +```python +from examples.code_review_agent import codex_config, with_native_otel + +config = with_native_otel(codex_config()) +``` + +The native configuration uses `http://localhost:4318/v1/traces`. Native Codex +spans expose internal operations and can be high-volume; they do not provide an +OpenInference conversation hierarchy or consistently populated prompt and +response fields. + +Use Relay OpenInference for semantic Phoenix inspection: + +```python +from examples.code_review_agent import codex_config, with_relay_openinference + +config = with_relay_openinference(codex_config()) +``` + +Relay produces a chain root with LLM and tool children, decoded request and +response values, and token usage. This is the recommended mode for reviewing an +agent turn in Phoenix. Use native Codex OpenTelemetry only when the raw +app-server trace is the required diagnostic surface. diff --git a/docs/integrations/harbor.mdx b/docs/integrations/harbor.mdx index 99e759adb..e1dcc22da 100644 --- a/docs/integrations/harbor.mdx +++ b/docs/integrations/harbor.mdx @@ -9,7 +9,7 @@ SPDX-License-Identifier: Apache-2.0 */} Use `nemo_fabric.integrations.harbor:FabricAgent` when Harbor owns the evaluation workflow and Fabric owns harness execution. The Harbor agent class -stays fixed while each complete Fabric config selects Hermes, Codex CLI, or +stays fixed while each complete Fabric config selects Hermes, Codex SDK, or another adapter. ```text @@ -70,7 +70,7 @@ config per execution path: | `smoke.yaml` | Deterministic scripted adapter | Credential-free Harbor, Fabric, workspace, and verifier pipeline | | `hermes.yaml` | Hermes | A model-backed harness selected through Fabric | | `hermes-relay.yaml` | Hermes with NeMo Relay | Phoenix traces plus ATOF and ATIF records | -| `codex.yaml` | Codex CLI | An existing Codex login mounted by Harbor | +| `codex.yaml` | Codex SDK | An existing Codex login mounted by Harbor | Harbor's `--model`, MCP servers, and skill directory are applied to an independent copy of the selected config for each run. Config-owned MCP servers diff --git a/docs/sdk/python.mdx b/docs/sdk/python.mdx index 0f635c72f..7689ceebd 100644 --- a/docs/sdk/python.mdx +++ b/docs/sdk/python.mdx @@ -192,7 +192,7 @@ relay_config.enable_relay( The repository's [code-review example](https://github.com/NVIDIA/NeMo-Fabric/tree/main/examples/code_review_agent) -uses this pattern for complete Hermes, Codex CLI, Deep Agents, +uses this pattern for complete Hermes, Codex SDK, Deep Agents, environment, MCP, and telemetry variants. When an in-memory caller needs the same ordered overlay behavior as file-backed @@ -407,7 +407,7 @@ Use normalized Fabric fields for portable behavior: models, runtime, environment, skills, MCP, telemetry, tools, artifacts, and request context. Use `harness.settings` for adapter-owned configuration that the selected adapter -understands. Examples include Hermes-specific launch options, Codex CLI flags, +understands. Examples include Hermes-specific launch options, Codex SDK controls, or adapter-specific config file locations. Use `metadata` for caller-owned annotations that Fabric should preserve and echo diff --git a/examples/code_review_agent/README.md b/examples/code_review_agent/README.md index 070f36237..d6efeb8a6 100644 --- a/examples/code_review_agent/README.md +++ b/examples/code_review_agent/README.md @@ -59,14 +59,15 @@ The entrypoint exposes complete harness configs defined in | Variant | Command option | Additional setup | | --- | --- | --- | | Hermes | `--variant hermes` | Installed [Hermes adapter requirements](../../adapters/hermes/README.md) and `NVIDIA_API_KEY`| -| Codex CLI | `--variant codex-cli` | Installed and authenticated [Codex CLI](../../adapters/codex-cli/README.md) | +| Codex SDK | `--variant codex` | Installed [Codex adapter](../../adapters/codex/README.md) and an existing ChatGPT or API key login | | Claude | `--variant claude` | Installed [Claude adapter requirements](../../adapters/claude/README.md) and `ANTHROPIC_API_KEY` | | Deep Agents | `--variant deepagents` | Installed [Deep Agents adapter requirements](../../adapters/deepagents/README.md) and `NVIDIA_API_KEY` | Add `--relay` to any variant to enable the Relay ATOF and ATIF configuration: -Relay runs require the optional NeMo Relay dependency in the selected adapter -environment. +Relay requirements depend on the selected adapter. The Codex and Claude SDK +adapters require an external `nemo-relay` CLI in the supported `0.6.x` range; +the Python package named `nemo-relay` does not install that command. ```bash .venv/bin/python -m examples.code_review_agent \ diff --git a/examples/code_review_agent/__init__.py b/examples/code_review_agent/__init__.py index fedddd789..8fe0a2c79 100644 --- a/examples/code_review_agent/__init__.py +++ b/examples/code_review_agent/__init__.py @@ -7,7 +7,7 @@ BASE_DIR, base_config, claude_config, - codex_cli_config, + codex_config, deepagents_config, hermes_config, with_fabric_managed_github_mcp, @@ -22,7 +22,7 @@ "BASE_DIR", "base_config", "claude_config", - "codex_cli_config", + "codex_config", "deepagents_config", "hermes_config", "with_fabric_managed_github_mcp", diff --git a/examples/code_review_agent/__main__.py b/examples/code_review_agent/__main__.py index 514bf42ac..38f4aba2c 100644 --- a/examples/code_review_agent/__main__.py +++ b/examples/code_review_agent/__main__.py @@ -15,7 +15,7 @@ from examples.code_review_agent.config import ( BASE_DIR, claude_config, - codex_cli_config, + codex_config, deepagents_config, hermes_config, with_relay, @@ -23,8 +23,8 @@ CONFIG_BUILDERS: dict[str, Callable[[], FabricConfig]] = { "hermes": hermes_config, - "codex-cli": codex_cli_config, "claude": claude_config, + "codex": codex_config, "deepagents": deepagents_config, } diff --git a/examples/code_review_agent/config.py b/examples/code_review_agent/config.py index ad4bca58a..127f545cd 100644 --- a/examples/code_review_agent/config.py +++ b/examples/code_review_agent/config.py @@ -99,29 +99,28 @@ def hermes_config() -> FabricConfig: return config -def codex_cli_config() -> FabricConfig: - """Return the complete Codex CLI variant without inherited capabilities.""" +def codex_config() -> FabricConfig: + """Return the complete Codex SDK variant without inherited capabilities.""" config = base_config().model_copy(deep=True) config.harness = HarnessConfig( - adapter_id="nvidia.fabric.codex.cli", + adapter_id="nvidia.fabric.codex", resolution="preinstalled", settings={ "sandbox": "workspace-write", - "skip_git_repo_check": True, - "config_overrides": {"model_reasoning_effort": "high"}, + "reasoning_effort": "high", }, ) config.models = {"default": ModelConfig(provider="openai", model="openai/gpt-5.4")} config.runtime = RuntimeConfig( input_schema="text", output_schema="message", - artifacts="./artifacts/codex-cli", + artifacts="./artifacts/codex", ) config.environment = EnvironmentConfig( provider="local", workspace=WORKSPACE, - artifacts="./artifacts/codex-cli", + artifacts="./artifacts/codex", ) config.remove_mcp_server("github") config.remove_skill_path(SKILL_PATH) diff --git a/examples/harbor/README.md b/examples/harbor/README.md index 27bbf0525..2ae48d5fe 100644 --- a/examples/harbor/README.md +++ b/examples/harbor/README.md @@ -60,7 +60,7 @@ task image; the [multi-harness demo](demo/README.md) shows one complete setup. ## Prepare a Fabric config Create one complete config for the execution path. Harbor runs one Fabric -harness at a time; to switch from Hermes, Codex CLI, or a +harness at a time; to switch from Hermes, Codex SDK, or a Relay-enabled variant, pass a different complete config through `fabric_config_path`. For example: @@ -191,7 +191,7 @@ harbor view ## Demo and tests The [multi-harness demo](demo/README.md) provides complete configs and commands -for a credential-free smoke run, Hermes, Hermes with Relay, and Codex CLI. +for a credential-free smoke run, Hermes, Hermes with Relay, and Codex SDK. Run the lightweight integration tests with: diff --git a/examples/harbor/demo/README.md b/examples/harbor/demo/README.md index b45f4f8bf..56b648716 100644 --- a/examples/harbor/demo/README.md +++ b/examples/harbor/demo/README.md @@ -140,15 +140,13 @@ find "$RUNS_DIR/fabric-hermes-relay" \ -print -exec python -m json.tool {} \; ``` -## 4. Codex CLI +## 4. Codex SDK Harbor mounts the host Codex login as a read-only secret. The setup command -copies it into a writable container-local `CODEX_HOME`; Fabric only passes that -environment to Codex. +copies it into a writable container-local `CODEX_HOME`; the Codex SDK app-server +uses that environment without requiring a separately installed `codex` command. ```bash -codex login status - CODEX_HOME_DIR="${CODEX_HOME:-$HOME/.codex}" test -f "$CODEX_HOME_DIR/auth.json" CODEX_AUTH_MOUNT="[{\"type\":\"bind\",\"source\":\"$CODEX_HOME_DIR/auth.json\",\"target\":\"/run/secrets/codex-auth.json\",\"read_only\":true}]" @@ -168,9 +166,9 @@ uv run --extra runtime --extra harbor harbor run \ --force-build ``` -The image pins Codex CLI `0.142.4`. The config uses -`danger-full-access` because Harbor's task container is the outer sandbox and -nested Linux namespace creation is unavailable there. +The Fabric Codex adapter pins the Python SDK and its bundled app-server runtime. +The config uses `danger-full-access` because Harbor's task container is the +outer sandbox and nested Linux namespace creation is unavailable there. ## Inspect results diff --git a/examples/harbor/demo/task/environment/Dockerfile b/examples/harbor/demo/task/environment/Dockerfile index 506358209..eb5b8e538 100644 --- a/examples/harbor/demo/task/environment/Dockerfile +++ b/examples/harbor/demo/task/environment/Dockerfile @@ -5,7 +5,7 @@ FROM python:3.12-slim-bookworm RUN apt-get update \ && apt-get install -y --no-install-recommends \ - build-essential curl git nodejs npm pkg-config libssl-dev \ + build-essential curl git pkg-config libssl-dev \ && rm -rf /var/lib/apt/lists/* RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs -o /tmp/rustup-init.sh \ @@ -14,8 +14,7 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs -o /tmp/rustup-ini ENV PATH=/root/.cargo/bin:$PATH COPY vendor/nemo-fabric /opt/nemo-fabric -RUN pip install --no-cache-dir -e "/opt/nemo-fabric[codex,harbor,hermes,relay,runtime]" \ - && npm install --global @openai/codex@0.142.4 +RUN pip install --no-cache-dir -e "/opt/nemo-fabric[codex,harbor,hermes,relay,runtime]" COPY calculator.py /app/calculator.py COPY fabric /opt/fabric-demo diff --git a/examples/harbor/demo/task/environment/fabric/configs/codex.yaml b/examples/harbor/demo/task/environment/fabric/configs/codex.yaml index 40d44b875..212e465b9 100644 --- a/examples/harbor/demo/task/environment/fabric/configs/codex.yaml +++ b/examples/harbor/demo/task/environment/fabric/configs/codex.yaml @@ -5,17 +5,14 @@ schema_version: fabric.agent/v1alpha1 metadata: name: harbor-calculator-demo - description: Codex CLI code-repair example in a Harbor task environment. + description: Codex SDK code-repair example in a Harbor task environment. harness: - adapter_id: nvidia.fabric.codex.cli + adapter_id: nvidia.fabric.codex resolution: preinstalled settings: - cwd: /app sandbox: danger-full-access - skip_git_repo_check: true - config_overrides: - model_reasoning_effort: high + reasoning_effort: high models: default: diff --git a/justfile b/justfile index 32078d92e..c05832e28 100644 --- a/justfile +++ b/justfile @@ -10,7 +10,7 @@ no_uv := "false" # When set, versioning and packaging targets use this exact release version. ref_name := "" -python_projects := ". python adapters/common adapters/claude adapters/codex-cli adapters/deepagents adapters/hermes" +python_projects := ". python adapters/common adapters/claude adapters/codex adapters/deepagents adapters/hermes" bash_helpers := ''' set -euo pipefail diff --git a/pyproject.toml b/pyproject.toml index a8b5fa02d..c6f7abb25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,8 +38,8 @@ adapters-common = [ "nemo-fabric-adapters-common == 0.1.0", ] -adapters-codex-cli = [ - "nemo-fabric-adapters-codex-cli == 0.1.0", +adapters-codex = [ + "nemo-fabric-adapters-codex == 0.1.0", ] adapters-claude = [ @@ -55,7 +55,7 @@ adapters-hermes = [ ] codex = [ - "nemo-fabric-adapters-codex-cli == 0.1.0", + "nemo-fabric-adapters-codex == 0.1.0", ] claude = [ @@ -89,7 +89,7 @@ runtime = [ adapters = [ "nemo-fabric-adapters-common", "nemo-fabric-adapters-claude", - "nemo-fabric-adapters-codex-cli", + "nemo-fabric-adapters-codex", "nemo-fabric-adapters-deepagents", "nemo-fabric-adapters-hermes; python_version < '3.14'", ] @@ -119,12 +119,15 @@ test = [ [tool.uv] default-groups = ["adapters", "dev", "test"] +# openai-codex beta 3 pins this prerelease app-server runtime. Keep the +# exception narrow instead of enabling prereleases for the full environment. +constraint-dependencies = ["openai-codex-cli-bin==0.137.0a4"] [tool.uv.sources] nemo-fabric-runtime = { path = "python", editable = true } nemo-fabric-adapters-common = { path = "adapters/common", editable = true } nemo-fabric-adapters-claude = { path = "adapters/claude", editable = true } -nemo-fabric-adapters-codex-cli = { path = "adapters/codex-cli", editable = true } +nemo-fabric-adapters-codex = { path = "adapters/codex", editable = true } nemo-fabric-adapters-deepagents = { path = "adapters/deepagents", editable = true } nemo-fabric-adapters-hermes = { path = "adapters/hermes", editable = true } diff --git a/tests/_utils/utils.py b/tests/_utils/utils.py index 6917118bb..00c565beb 100644 --- a/tests/_utils/utils.py +++ b/tests/_utils/utils.py @@ -1,8 +1,11 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +import json import subprocess +from collections.abc import Mapping from pathlib import Path +from typing import Any import yaml @@ -10,6 +13,59 @@ FABRIC_COMMAND = ("cargo", "run", "-q", "-p", "nemo-fabric-cli", "--") +def _relay_event_total_tokens(event: dict) -> int: + profile = event.get("category_profile") or {} + annotated = profile.get("annotated_response") or {} + data = event.get("data") or {} + usage = annotated.get("usage") or data.get("usage") or {} + return usage.get("total_tokens", 0) + + +def assert_semantic_relay_artifacts( + output: Mapping[str, Any], expected_response: str +) -> None: + """Assert Relay artifacts contain model, usage, and agent-response semantics.""" + + artifacts = { + item["kind"]: Path(item["path"]) for item in output["relay_artifacts"] + } + events = [ + json.loads(line) + for line in artifacts["atof"].read_text(encoding="utf-8").splitlines() + ] + llm_starts = [ + event + for event in events + if event.get("category") == "llm" and event.get("scope_category") == "start" + ] + assert llm_starts, events + assert all( + isinstance(event.get("data", {}).get("content"), dict) + for event in llm_starts + ) + assert all(event["data"]["content"].get("model") for event in llm_starts) + + llm_ends = [ + event + for event in events + if event.get("category") == "llm" and event.get("scope_category") == "end" + ] + assert llm_ends, events + assert any(_relay_event_total_tokens(event) > 0 for event in llm_ends) + + trajectory = json.loads(artifacts["atif"].read_text(encoding="utf-8")) + agent_messages = [ + message + for step in trajectory.get("steps", []) + if isinstance(step, dict) + if step.get("source") == "agent" + if isinstance(message := step.get("message"), str) + ] + assert any( + expected_response.lower() in message.lower() for message in agent_messages + ), agent_messages + + def run_fabric_cli( *args: object, stdin: str | None = None, diff --git a/tests/adapters/test_adapters_common_relay_gateway.py b/tests/adapters/test_adapters_common_relay_gateway.py index 251dd38bd..58b527d36 100644 --- a/tests/adapters/test_adapters_common_relay_gateway.py +++ b/tests/adapters/test_adapters_common_relay_gateway.py @@ -45,15 +45,14 @@ def test_resolve_relay_command_rejects_missing_executable(monkeypatch, tmp_path) @pytest.mark.parametrize( - ("output", "expected"), + ("output", "expected_version"), [ - ("nemo-relay 0.5.0\n", 1), - ("nemo-relay 0.6.0-alpha.20260714\n", 2), - ("nemo-relay 1.0.0\n", 2), + ("nemo-relay 0.6.0-alpha.20260714\n", (0, 6, 0)), + ("nemo-relay 0.6.99\n", (0, 6, 99)), ], ) -def test_relay_cli_observability_version_selects_compatible_contract( - monkeypatch, tmp_path, output, expected +def test_relay_cli_contract_selects_compatible_contract( + monkeypatch, tmp_path, output, expected_version ): monkeypatch.setattr( relay_gateway.subprocess, @@ -61,13 +60,31 @@ def test_relay_cli_observability_version_selects_compatible_contract( MagicMock(return_value=subprocess.CompletedProcess([], 0, stdout=output)), ) - assert ( - relay_gateway.relay_cli_observability_version(tmp_path / "nemo-relay") - == expected + assert relay_gateway.relay_cli_contract( + tmp_path / "nemo-relay" + ) == relay_gateway.RelayCliContract( + version=expected_version, + observability_version=2, ) -def test_relay_cli_observability_version_rejects_unparseable_output( +@pytest.mark.parametrize("output", ["nemo-relay 0.5.9", "nemo-relay 0.7.0"]) +def test_relay_cli_contract_rejects_unsupported_version( + monkeypatch, tmp_path, output +): + monkeypatch.setattr( + relay_gateway.subprocess, + "run", + MagicMock(return_value=subprocess.CompletedProcess([], 0, stdout=output)), + ) + + with pytest.raises( + relay_gateway.RelayGatewayError, match="Fabric requires >=0.6.0,<0.7.0" + ): + relay_gateway.relay_cli_contract(tmp_path / "nemo-relay") + + +def test_relay_cli_contract_rejects_unparseable_output( monkeypatch, tmp_path ): monkeypatch.setattr( @@ -79,7 +96,7 @@ def test_relay_cli_observability_version_rejects_unparseable_output( with pytest.raises( relay_gateway.RelayGatewayError, match="version could not be determined" ): - relay_gateway.relay_cli_observability_version(tmp_path / "nemo-relay") + relay_gateway.relay_cli_contract(tmp_path / "nemo-relay") def test_start_relay_gateway_captures_logs_and_waits_for_health(monkeypatch, tmp_path): diff --git a/tests/adapters/test_adapters_common_relay_hooks.py b/tests/adapters/test_adapters_common_relay_hooks.py index c4c1b4743..c2c573089 100644 --- a/tests/adapters/test_adapters_common_relay_hooks.py +++ b/tests/adapters/test_adapters_common_relay_hooks.py @@ -9,7 +9,7 @@ import nemo_fabric_adapters.common.relay_hooks as relay_hooks -EXPECTED_EVENTS = ( +CLAUDE_EXPECTED_EVENTS = ( "SessionStart", "UserPromptSubmit", "UserPromptExpansion", @@ -25,15 +25,33 @@ "PostCompact", "SessionEnd", ) +CODEX_EXPECTED_EVENTS = ( + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PermissionRequest", + "SubagentStart", + "SubagentStop", + "Stop", + "PreCompact", + "PostCompact", +) -@pytest.mark.parametrize("agent", ["claude", "codex"]) -def test_render_relay_hooks_matches_relay_agent_contract(agent): +@pytest.mark.parametrize( + ("agent", "expected_events"), + [ + ("claude", CLAUDE_EXPECTED_EVENTS), + ("codex", CODEX_EXPECTED_EVENTS), + ], +) +def test_render_relay_hooks_matches_relay_agent_contract(agent, expected_events): executable = Path("/opt/nvidia relay/bin/nemo-relay") hooks = relay_hooks.render_relay_hooks(agent, executable)["hooks"] - assert tuple(hooks) == EXPECTED_EVENTS + assert tuple(hooks) == expected_events assert hooks["SessionStart"] == [ { "hooks": [ @@ -45,14 +63,14 @@ def test_render_relay_hooks_matches_relay_agent_contract(agent): ] } ] - assert { + expected_matchers = { event for event, groups in hooks.items() if groups[0].get("matcher") == "*" - } == { + } + assert expected_matchers == { "PreToolUse", "PostToolUse", - "PostToolUseFailure", "PermissionRequest", - } + } | ({"PostToolUseFailure"} if agent == "claude" else set()) def test_render_relay_hooks_rejects_unsupported_agent(): diff --git a/tests/adapters/test_claude_adapter.py b/tests/adapters/test_claude_adapter.py index af3fdfcf9..be62d3bd5 100644 --- a/tests/adapters/test_claude_adapter.py +++ b/tests/adapters/test_claude_adapter.py @@ -213,8 +213,12 @@ def test_prepare_claude_relay_writes_gateway_config_and_complete_hook_plugin( ) monkeypatch.setattr( adapter.relay_gateway, - "relay_cli_observability_version", - MagicMock(return_value=2), + "relay_cli_contract", + MagicMock( + return_value=adapter.relay_gateway.RelayCliContract( + version=(0, 6, 0), observability_version=2 + ) + ), ) relay = adapter.prepare_claude_relay(relay_payload) @@ -284,8 +288,12 @@ def test_build_options_adds_relay_plugin_and_gateway_environment( ) monkeypatch.setattr( adapter.relay_gateway, - "relay_cli_observability_version", - MagicMock(return_value=2), + "relay_cli_contract", + MagicMock( + return_value=adapter.relay_gateway.RelayCliContract( + version=(0, 6, 0), observability_version=2 + ) + ), ) relay = adapter.prepare_claude_relay(relay_payload) @@ -716,6 +724,44 @@ async def query_failure(*, prompt, options): assert not relay.plugin_path.exists() +@pytest.mark.parametrize( + ("subtype", "is_error"), + [("success", True), ("error_max_budget_usd", False)], +) +async def test_run_claude_preserves_failed_result_when_sdk_stream_raises( + claude_payload, + monkeypatch, + caplog, + subtype, + is_error, +): + async def query_error_result(**_): + yield ResultMessage( + subtype=subtype, + duration_ms=10, + duration_api_ms=8, + is_error=is_error, + num_turns=1, + session_id="claude-session", + result="Not logged in", + ) + raise RuntimeError("raw SDK stream error") + + monkeypatch.setattr(adapter, "query", MagicMock(side_effect=query_error_result)) + + output = await adapter.run_claude(claude_payload) + + assert output["response"] == "Not logged in" + assert output["error"] == { + "code": "claude_result_failed", + "message": "Claude returned an error result", + "retryable": False, + "metadata": {"subtype": subtype}, + } + assert "raw SDK stream error" not in json.dumps(output) + assert "raw SDK stream error" in caplog.text + + def test_run_reports_relay_start_failure_without_raw_diagnostic( relay_payload, monkeypatch, tmp_path ): @@ -812,6 +858,16 @@ def test_build_options_forwards_anthropic_auth_environment( assert options.env["FABRIC_UNRELATED_SECRET"] == "" +def test_build_options_preserves_unix_user_for_cached_login( + claude_payload, +): + os.environ["USER"] = "fabric-user" + + options = adapter.build_options(claude_payload, resume=None) + + assert options.env["USER"] == "fabric-user" + + @pytest.mark.parametrize( ("error", "code"), [ diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py new file mode 100644 index 000000000..991605de0 --- /dev/null +++ b/tests/adapters/test_codex_adapter.py @@ -0,0 +1,656 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import asyncio +import json +import os +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from nemo_fabric import Fabric +from nemo_fabric_adapters.codex import adapter +from openai_codex import AsyncCodex, AsyncThread, AsyncTurnHandle +from openai_codex.types import TurnStatus + + +@pytest.fixture(name="codex_payload") +def codex_payload_fixture(tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + return { + "effective_config": { + "agent_name": "codex-test", + "config_root": str(tmp_path), + "config": { + "harness": { + "adapter_id": "nvidia.fabric.codex", + "settings": { + "sandbox": "workspace-write", + "config_overrides": { + "features.web_search": False, + "model_reasoning_effort": "high", + }, + }, + }, + "models": { + "default": { + "provider": "openai", + "model": "openai/gpt-5.4", + } + }, + "runtime": {}, + }, + }, + "runtime_context": { + "runtime_id": "runtime-1", + "invocation_id": "invocation-1", + "request_id": "request-1", + "environment": {"workspace": str(workspace)}, + "artifacts": {"root": str(tmp_path / "artifacts")}, + }, + "request": {"input": "Inspect the change."}, + } + + +def successful_result(response="done"): + return SimpleNamespace( + id="turn-1", + status=TurnStatus.completed, + error=None, + started_at=100, + completed_at=101, + duration_ms=1000, + final_response=response, + items=[ + { + "id": "item-1", + "type": "agentMessage", + "phase": "final_answer", + "text": response, + } + ], + usage={"total": {"inputTokens": 10, "outputTokens": 3}}, + ) + + +def mock_turn_handle(result=None): + mock_handle = MagicMock(spec=AsyncTurnHandle) + outcome = successful_result() if result is None else result + if isinstance(outcome, BaseException): + mock_handle.run.side_effect = outcome + else: + mock_handle.run.return_value = outcome + mock_handle.interrupted = False + + async def mark_interrupted(): + mock_handle.interrupted = True + + mock_handle.interrupt.side_effect = mark_interrupted + return mock_handle + + +def mock_thread(thread_id, result=None): + mock_sdk_thread = MagicMock(spec=AsyncThread) + mock_sdk_thread.id = thread_id + mock_sdk_thread.handle = mock_turn_handle(result) + mock_sdk_thread.turn.return_value = mock_sdk_thread.handle + return mock_sdk_thread + + +@pytest.fixture(name="mock_codex") +def mock_codex_fixture(monkeypatch): + mock_codex = MagicMock(spec=AsyncCodex) + mock_codex.instances = [] + mock_codex.next_thread_id = "thread-123" + mock_codex.next_result = None + mock_codex.next_thread = None + mock_codex.resume_thread_id = None + + def build_client(*, config): + mock_client = MagicMock(spec=AsyncCodex) + mock_client.config = config + mock_client.closed = False + mock_client.thread = None + + async def close(): + mock_client.closed = True + + async def thread_start(**_kwargs): + mock_client.thread = ( + mock_codex.next_thread + if mock_codex.next_thread is not None + else mock_thread(mock_codex.next_thread_id, mock_codex.next_result) + ) + return mock_client.thread + + async def thread_resume(thread_id, **_kwargs): + resumed_thread_id = mock_codex.resume_thread_id or thread_id + mock_client.thread = mock_thread( + resumed_thread_id, mock_codex.next_result + ) + return mock_client.thread + + mock_client.close.side_effect = close + mock_client.thread_start.side_effect = thread_start + mock_client.thread_resume.side_effect = thread_resume + mock_codex.instances.append(mock_client) + return mock_client + + mock_codex.side_effect = build_client + monkeypatch.setattr(adapter, "AsyncCodex", mock_codex) + return mock_codex + + +def test_sdk_oneshot_uses_native_thread_and_turn_contract( + codex_payload, mock_codex, tmp_path +): + os.environ["CODEX_HOME"] = str(tmp_path / "codex-home") + os.environ["CODEX_INTERNAL_ORIGINATOR_OVERRIDE"] = "parent-codex" + os.environ["FABRIC_UNRELATED_SECRET"] = "do-not-forward" + codex_payload["effective_config"]["config"]["harness"]["settings"]["env"] = { + "CODEX_EXPLICIT": "forward-me" + } + + output = adapter.run(codex_payload) + + assert output["completed"] is True + assert output["adapter"] == "sdk" + assert output["mode"] == "codex_sdk_runtime" + assert output["thread_id"] == "thread-123" + assert output["turn_id"] == "turn-1" + assert output["response"] == "done" + assert output["events"][0]["type"] == "agentMessage" + assert "command" not in output + assert "returncode" not in output + + client = mock_codex.instances[0] + assert client.closed is True + assert client.config.codex_bin is None + assert client.config.launch_args_override is None + assert client.config.cwd == str( + Path(codex_payload["runtime_context"]["environment"]["workspace"]) + ) + assert client.config.env["CODEX_HOME"] == str(tmp_path / "codex-home") + assert client.config.env["CODEX_EXPLICIT"] == "forward-me" + assert ( + client.config.env["CODEX_INTERNAL_ORIGINATOR_OVERRIDE"] + == "codex_python_sdk" + ) + assert client.config.env["FABRIC_UNRELATED_SECRET"] == "" + start = client.thread_start.await_args.kwargs + assert start["model"] == "gpt-5.4" + assert start["model_provider"] == "openai" + assert start["sandbox"] == adapter.Sandbox.workspace_write + assert start["config"] == { + "features": {"web_search": False}, + "model_reasoning_effort": "high", + } + client.thread.turn.assert_awaited_once_with( + "Inspect the change.", effort=None, output_schema=None + ) + + +def test_sdk_can_use_an_explicit_codex_runtime(codex_payload, mock_codex, tmp_path): + codex_bin = tmp_path / "bin" / "codex" + codex_bin.parent.mkdir() + codex_bin.touch() + codex_payload["effective_config"]["config"]["harness"]["settings"][ + "codex_bin" + ] = str(codex_bin) + + output = adapter.run(codex_payload) + + assert output["completed"] is True + assert mock_codex.instances[0].config.codex_bin == str(codex_bin) + + +@pytest.mark.parametrize("codex_bin", ["bin/codex", "~/bin/codex"]) +def test_sdk_resolves_relative_codex_runtime_from_config_root( + codex_payload, codex_bin +): + codex_payload["effective_config"]["config"]["harness"]["settings"][ + "codex_bin" + ] = codex_bin + + config = adapter.sdk_config(codex_payload, relay=None) + + config_root = Path(codex_payload["effective_config"]["config_root"]) + assert config.codex_bin == str((config_root / codex_bin).resolve()) + + +def test_sdk_keeps_absolute_codex_runtime_path(codex_payload, tmp_path): + codex_bin = tmp_path / "bin" / ".." / "codex" + codex_payload["effective_config"]["config"]["harness"]["settings"][ + "codex_bin" + ] = str(codex_bin) + + config = adapter.sdk_config(codex_payload, relay=None) + + assert config.codex_bin == str(codex_bin) + + +def test_runtime_resumes_sdk_thread_across_invocations( + codex_payload, mock_codex +): + first = adapter.run(codex_payload) + codex_payload["runtime_context"]["invocation_id"] = "invocation-2" + codex_payload["request"]["input"] = "Continue." + second = adapter.run(codex_payload) + + assert first["thread_id"] == second["thread_id"] == "thread-123" + mock_codex.instances[0].thread_start.assert_awaited_once() + assert mock_codex.instances[1].thread_resume.await_args.args[0] == "thread-123" + assert mock_codex.instances[1].thread.turn.await_args.args[0] == "Continue." + state = json.loads( + adapter.runtime_state_path(codex_payload, "runtime-1").read_text( + encoding="utf-8" + ) + ) + assert state == { + "runtime_id": "runtime-1", + "codex_thread_id": "thread-123", + } + + +def test_runtime_rejects_corrupt_thread_state(codex_payload): + state_path = adapter.runtime_state_path(codex_payload, "runtime-1") + state_path.parent.mkdir(parents=True) + state_path.write_text("{", encoding="utf-8") + + output = adapter.run(codex_payload) + + assert output["error"]["code"] == "codex_invalid_runtime_state" + + +def test_failed_sdk_turn_is_normalized_and_transport_is_closed( + codex_payload, mock_codex +): + mock_codex.next_result = RuntimeError("model request failed") + + output = adapter.run(codex_payload) + + assert output["error"] == { + "code": "codex_turn_failed", + "message": "model request failed", + "retryable": False, + } + assert mock_codex.instances[0].closed is True + assert not adapter.runtime_state_path(codex_payload, "runtime-1").exists() + + +def test_incomplete_sdk_turn_is_failed_without_persisting_thread( + codex_payload, mock_codex +): + result = successful_result(response=None) + mock_codex.next_result = result + + output = adapter.run(codex_payload) + + assert output["error"]["code"] == "codex_turn_incomplete" + assert output["turn_status"] == "completed" + assert not adapter.runtime_state_path(codex_payload, "runtime-1").exists() + + +def test_selected_model_rejects_unsupported_provider(codex_payload, mock_codex): + model = codex_payload["effective_config"]["config"]["models"]["default"] + model["provider"] = "nvidia" + + output = adapter.run(codex_payload) + + assert output["error"]["code"] == "codex_invalid_configuration" + assert "provider must be openai" in output["error"]["message"] + mock_codex.assert_not_called() + + +def test_resume_rejects_changed_sdk_thread_identity( + codex_payload, mock_codex +): + adapter.save_thread_id(codex_payload, "runtime-1", "thread-persisted") + mock_codex.resume_thread_id = "thread-replaced" + + output = adapter.run(codex_payload) + + assert output["error"]["code"] == "codex_thread_mismatch" + assert mock_codex.instances[0].closed is True + + +def test_relay_uses_gateway_and_request_scoped_sdk_config( + codex_payload, mock_codex, monkeypatch, tmp_path +): + codex_payload["telemetry_plan"] = { + "providers": ["relay"], + "relay_enabled": True, + } + relay_config_path = tmp_path / "relay-config" / "config.toml" + executable = tmp_path / "bin" / "nemo-relay" + gateway = adapter.relay_gateway.RelayGatewayLaunch( + executable=executable, + config_path=relay_config_path, + bind="127.0.0.1:43210", + url="http://127.0.0.1:43210", + log_path=relay_config_path.parent / "gateway.log", + ) + relay = adapter.CodexRelaySettings( + gateway=gateway, + plugin_config={"version": 1, "components": []}, + ) + process = MagicMock() + start_gateway = MagicMock(return_value=process) + stop_gateway = MagicMock() + monkeypatch.setattr(adapter, "prepare_codex_relay", MagicMock(return_value=relay)) + monkeypatch.setattr( + adapter.relay_gateway, "start_relay_gateway", start_gateway + ) + monkeypatch.setattr(adapter.relay_gateway, "stop_relay_gateway", stop_gateway) + os.environ["FABRIC_RELAY_CONFIG_PATH"] = str(tmp_path / "relay.json") + + output = adapter.run(codex_payload) + + client = mock_codex.instances[0] + start = client.thread_start.await_args.kwargs + config = start["config"] + assert start["model_provider"] == "openai" + assert client.config.env["NEMO_RELAY_GATEWAY_URL"] == gateway.url + assert config["bypass_hook_trust"] is True + assert config["features"]["hooks"] is True + assert config["features"]["multi_agent_v2"]["enabled"] is False + assert config["features"]["web_search"] is False + assert config["openai_base_url"] == gateway.url + assert "model_providers" not in config + assert config["hooks"]["SessionStart"][0]["hooks"][0] == { + "type": "command", + "command": f"{executable} hook-forward codex", + "timeout": 30, + } + assert output["relay_runtime"] == { + "enabled": True, + "emitter": "codex-sdk/nemo-relay", + "config_path": str(tmp_path / "relay.json"), + "gateway_config_path": str(relay_config_path), + "gateway_url": gateway.url, + "gateway_log_path": str(gateway.log_path), + } + assert output["relay_artifacts"] == [] + start_gateway.assert_called_once_with( + launch=gateway, + cwd=Path(codex_payload["runtime_context"]["environment"]["workspace"]), + ) + stop_gateway.assert_called_once_with(process) + + +def test_prepare_relay_reuses_one_resolved_executable( + codex_payload, monkeypatch, tmp_path +): + codex_payload["telemetry_plan"] = { + "providers": ["relay"], + "relay_enabled": True, + } + executable = tmp_path / "nemo-relay" + config_path = tmp_path / "relay-config" / "config.toml" + plugin_path = config_path.parent / "plugins.toml" + resolve = MagicMock(return_value=executable) + contract = MagicMock( + return_value=adapter.relay_gateway.RelayCliContract( + version=(0, 6, 0), observability_version=2 + ) + ) + write = MagicMock(return_value=(config_path, plugin_path)) + monkeypatch.setattr(adapter.relay_gateway, "resolve_relay_command", resolve) + monkeypatch.setattr( + adapter.relay_gateway, "relay_cli_contract", contract + ) + monkeypatch.setattr(adapter.relay_gateway, "find_available_tcp_port", lambda: 43210) + monkeypatch.setattr( + adapter.common_utils, + "load_relay_plugin_config", + MagicMock(return_value={"version": 1, "components": []}), + ) + monkeypatch.setattr(adapter.common_utils, "write_relay_configs", write) + + relay = adapter.prepare_codex_relay(codex_payload) + + assert relay is not None + assert relay.gateway.executable == executable + assert relay.gateway.url == "http://127.0.0.1:43210" + resolve.assert_called_once_with( + Path(codex_payload["effective_config"]["config_root"]).resolve(), + "nemo-relay", + ) + contract.assert_called_once_with(executable) + write.assert_called_once_with( + relay_config={}, + plugin_config={"version": 1, "components": []}, + observability_version=2, + ) + + +@pytest.mark.usefixtures("mock_codex") +def test_relay_cleanup_failure_changes_success_to_failure( + codex_payload, monkeypatch, tmp_path +): + gateway = adapter.relay_gateway.RelayGatewayLaunch( + executable=tmp_path / "nemo-relay", + config_path=tmp_path / "relay" / "config.toml", + bind="127.0.0.1:43210", + url="http://127.0.0.1:43210", + log_path=tmp_path / "relay" / "gateway.log", + ) + relay = adapter.CodexRelaySettings( + gateway=gateway, + plugin_config={"version": 1, "components": []}, + ) + monkeypatch.setattr(adapter, "prepare_codex_relay", lambda _: relay) + monkeypatch.setattr( + adapter.relay_gateway, "start_relay_gateway", lambda **_: MagicMock() + ) + monkeypatch.setattr( + adapter.relay_gateway, + "stop_relay_gateway", + MagicMock(side_effect=adapter.relay_gateway.RelayGatewayError("stuck")), + ) + + output = adapter.run(codex_payload) + + assert output["failed"] is True + assert output["completed"] is False + assert output["error"]["code"] == "codex_relay_stop_failed" + assert output["relay_runtime"]["cleanup_error"] == output["error"] + + +def test_native_sdk_controls_and_telemetry_are_request_scoped( + codex_payload, mock_codex +): + settings = codex_payload["effective_config"]["config"]["harness"]["settings"] + settings.update( + { + "personality": "pragmatic", + "reasoning_effort": "xhigh", + "service_name": "fabric-codex-test", + "output_schema": { + "type": "object", + "properties": {"summary": {"type": "string"}}, + "required": ["summary"], + }, + } + ) + codex_payload["telemetry_plan"] = { + "providers": ["native"], + "relay_enabled": False, + "native_config": { + "components": [ + { + "kind": "observability", + "enabled": True, + "config": { + "opentelemetry": { + "enabled": True, + "endpoint": "http://localhost:4318/v1/traces", + "transport": "http_binary", + "resource_attributes": { + "deployment.environment": "test" + }, + } + }, + } + ] + }, + } + + output = adapter.run(codex_payload) + + assert output["failed"] is False + client = mock_codex.instances[0] + start = client.thread_start.await_args.kwargs + assert start["personality"] == adapter.Personality.pragmatic + assert start["service_name"] == "fabric-codex-test" + assert start["config"]["otel"] == { + "environment": "test", + "trace_exporter": { + "otlp-http": { + "endpoint": "http://localhost:4318/v1/traces", + "protocol": "binary", + } + }, + } + turn = client.thread.turn.await_args.kwargs + assert turn["effort"] == adapter.ReasoningEffort.xhigh + assert turn["output_schema"]["required"] == ["summary"] + + +def test_timeout_interrupts_native_turn_and_closes_sdk( + codex_payload, mock_codex +): + mock_blocking_thread = mock_thread("thread-timeout") + + async def block(): + await asyncio.sleep(60) + + mock_blocking_thread.handle.run.side_effect = block + mock_codex.next_thread = mock_blocking_thread + codex_payload["effective_config"]["config"]["harness"]["settings"][ + "timeout_seconds" + ] = 0.01 + + output = adapter.run(codex_payload) + + client = mock_codex.instances[0] + assert output["error"]["code"] == "codex_timed_out" + assert client.thread.handle.interrupted is True + assert client.closed is True + + +@pytest.mark.parametrize( + "setting", ["codex_command", "codex_args", "codex_profile", "skip_git_repo_check"] +) +def test_cli_only_settings_are_rejected(codex_payload, setting): + codex_payload["effective_config"]["config"]["harness"]["settings"][setting] = ( + "legacy" + ) + + output = adapter.run(codex_payload) + + assert output["error"]["code"] == "codex_invalid_configuration" + assert setting in output["error"]["message"] + + +def test_adapter_rejects_structured_input(codex_payload): + codex_payload["request"]["input"] = { + "messages": [{"role": "user", "content": "Inspect the change."}] + } + + output = adapter.run(codex_payload) + + assert output["error"]["code"] == "codex_invalid_request" + + +def test_descriptor_has_no_codex_binary_requirement(): + descriptor = json.loads( + ( + Path(__file__).parents[2] / "adapters" / "codex" / "fabric-adapter.json" + ).read_text(encoding="utf-8") + ) + + assert descriptor["adapter_id"] == "nvidia.fabric.codex" + assert descriptor["runner"] == { + "module": "nemo_fabric_adapters.codex.adapter", + "callable": "run", + } + assert "requirements" not in descriptor + + +def test_codex_config_resolves_sdk_adapter(): + from examples.code_review_agent import BASE_DIR, codex_config + + plan = Fabric().plan(codex_config(), base_dir=BASE_DIR) + + assert plan.adapter.adapter_id == "nvidia.fabric.codex" + assert plan.adapter.harness == "codex" + assert plan.effective_config.config.runtime.input_schema == "text" + assert plan.effective_config.config.harness.settings["reasoning_effort"] == "high" + unsupported = plan["capability_plan"]["unsupported"] + assert not unsupported.get("skill_paths") + assert not unsupported.get("mcp_servers") + + +def test_environment_does_not_mutate_parent(codex_payload): + os.environ["FABRIC_UNRELATED_SECRET"] = "parent-value" + + child = adapter.child_environment(codex_payload) + + assert child["FABRIC_UNRELATED_SECRET"] == "" + assert os.environ["FABRIC_UNRELATED_SECRET"] == "parent-value" + + +def test_environment_preserves_runtime_telemetry_env(codex_payload): + codex_payload["runtime_context"]["telemetry"] = { + "env": { + "FABRIC_RELAY_ENABLED": "true", + "FABRIC_RELAY_CONFIG_PATH": "/tmp/relay.json", + "CODEX_EXPLICIT": "telemetry", + } + } + codex_payload["effective_config"]["config"]["harness"]["settings"]["env"] = { + "CODEX_EXPLICIT": "configured" + } + os.environ["FABRIC_RELAY_CONFIG_PATH"] = "/tmp/parent-relay.json" + + child = adapter.child_environment(codex_payload) + + assert child["FABRIC_RELAY_ENABLED"] == "true" + assert child["FABRIC_RELAY_CONFIG_PATH"] == "/tmp/relay.json" + assert child["CODEX_EXPLICIT"] == "configured" + + +@pytest.mark.parametrize( + "telemetry_env", + [ + [], + {1: "value"}, + {"OTEL_EXPORTER_OTLP_ENDPOINT": 4318}, + ], +) +def test_environment_rejects_non_string_runtime_telemetry_env( + codex_payload, telemetry_env +): + codex_payload["runtime_context"]["telemetry"] = {"env": telemetry_env} + + with pytest.raises( + adapter.AdapterInputError, + match=r"runtime_context\.telemetry\.env must contain strings", + ): + adapter.child_environment(codex_payload) + + +@pytest.mark.parametrize("telemetry", [[], "invalid"]) +def test_environment_rejects_non_mapping_runtime_telemetry( + codex_payload, telemetry +): + codex_payload["runtime_context"]["telemetry"] = telemetry + + with pytest.raises( + adapter.AdapterInputError, + match=r"runtime_context\.telemetry must be a mapping", + ): + adapter.child_environment(codex_payload) diff --git a/tests/adapters/test_codex_cli.py b/tests/adapters/test_codex_cli.py deleted file mode 100644 index d46f2d734..000000000 --- a/tests/adapters/test_codex_cli.py +++ /dev/null @@ -1,744 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -import json -import os -import subprocess -import tomllib -from pathlib import Path -from unittest.mock import MagicMock - -import pytest -from nemo_fabric import Fabric -from nemo_fabric import FabricConfig -from nemo_fabric_adapters.codex_cli import adapter - - -@pytest.fixture(name="codex_payload") -def codex_payload_fixture(tmp_path): - workspace = tmp_path / "workspace" - workspace.mkdir() - codex_home = tmp_path / "codex-home" - codex_home.mkdir() - (codex_home / "team.toml").write_text("", encoding="utf-8") - os.environ["CODEX_HOME"] = str(codex_home) - return { - "effective_config": { - "agent_name": "codex-test", - "config_root": str(tmp_path), - "config": { - "harness": { - "adapter_id": "nvidia.fabric.codex.cli", - "settings": { - "sandbox": "read-only", - "codex_profile": "team", - "config_overrides": { - "features.web_search": False, - "model_reasoning_effort": "high", - }, - }, - }, - "models": { - "default": { - "provider": "openai", - "model": "openai/gpt-5.4", - } - }, - "runtime": {}, - }, - }, - "runtime_context": { - "runtime_id": "runtime-1", - "invocation_id": "invocation-1", - "request_id": "request-1", - "environment": {"workspace": str(workspace)}, - "artifacts": {"root": str(tmp_path / "artifacts")}, - }, - "request": {"input": "Inspect the change."}, - } - - -def codex_jsonl(thread_id, response, *, usage=None): - events = [ - {"type": "thread.started", "thread_id": thread_id}, - {"type": "turn.started"}, - { - "type": "item.completed", - "item": {"id": "item-1", "type": "agent_message", "text": response}, - }, - { - "type": "turn.completed", - "usage": usage - or { - "input_tokens": 10, - "cached_input_tokens": 2, - "output_tokens": 3, - }, - }, - ] - return "\n".join(json.dumps(event) for event in events) + "\n" - - -def write_mock_codex(path): - path.write_text( - """#!/usr/bin/env python3 -import json -import sys - -args = sys.argv[1:] -thread_id = args[args.index("resume") + 1] if "resume" in args else "thread-fake" -prompt = sys.stdin.read().strip() -events = [ - {"type": "thread.started", "thread_id": thread_id}, - {"type": "turn.started"}, - { - "type": "item.completed", - "item": { - "id": "item-1", - "type": "agent_message", - "text": f"{thread_id}:{prompt}", - }, - }, - { - "type": "turn.completed", - "usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1}, - }, -] -for event in events: - print(json.dumps(event)) -""", - encoding="utf-8", - ) - path.chmod(0o755) - - -def fabric_config(tmp_path, mock_codex): - return FabricConfig.from_mapping( - { - "schema_version": "fabric.agent/v1alpha1", - "metadata": {"name": "codex-runtime-test"}, - "harness": { - "adapter_id": "nvidia.fabric.codex.cli", - "resolution": "preinstalled", - "settings": { - "codex_command": str(mock_codex), - "sandbox": "read-only", - "skip_git_repo_check": True, - }, - }, - "runtime": { - "artifacts": str(tmp_path / "artifacts"), - }, - "environment": { - "provider": "local", - "workspace": str(tmp_path), - "artifacts": str(tmp_path / "artifacts"), - }, - } - ) - - -def test_oneshot_command_uses_fabric_overrides_and_codex_owned_auth( - codex_payload, - tmp_path, -): - codex_settings = adapter.write_config_files(codex_payload) - - command = adapter.build_command( - codex_payload, - codex_settings=codex_settings, - ) - - exec_index = command.index("exec") - assert command[0] == "codex" - assert command[1:exec_index] == [] - assert command[exec_index : exec_index + 2] == ["exec", "--json"] - assert "--ephemeral" not in command - assert ["--sandbox", "read-only"] == command[exec_index + 2 : exec_index + 4] - assert ["--profile", "fabric-runtime-1"] == command[exec_index + 4 : exec_index + 6] - assert "--dangerously-bypass-hook-trust" not in command - assert ["--model", "gpt-5.4"] == command[-3:-1] - assert command[-1] == "-" - assert tomllib.loads( - codex_settings.codex_profile_path.read_text(encoding="utf-8") - ) == { - "features": {"web_search": False}, - "model_reasoning_effort": "high", - } - - -def test_configured_codex_profile_is_base_for_generated_profile(codex_payload): - codex_home = Path(os.environ["CODEX_HOME"]) - source_profile = codex_home / "team.toml" - source_profile.write_text( - """approval_policy = "never" -model_reasoning_effort = "medium" - -[features] -web_search = true -shell_snapshot = true -""", - encoding="utf-8", - ) - - codex_settings = adapter.write_config_files(codex_payload) - - assert codex_settings.codex_profile_name == "fabric-runtime-1" - assert tomllib.loads( - codex_settings.codex_profile_path.read_text(encoding="utf-8") - ) == { - "approval_policy": "never", - "model_reasoning_effort": "high", - "features": { - "web_search": False, - "shell_snapshot": True, - }, - } - assert tomllib.loads(source_profile.read_text(encoding="utf-8")) == { - "approval_policy": "never", - "model_reasoning_effort": "medium", - "features": { - "web_search": True, - "shell_snapshot": True, - }, - } - - -def test_relative_codex_command_resolves_from_config_root(codex_payload): - settings = codex_payload["effective_config"]["config"]["harness"]["settings"] - settings["codex_command"] = "./tools/codex" - settings["config_overrides"] = {} - - command = adapter.build_command( - codex_payload, - codex_settings=adapter.write_config_files(codex_payload), - ) - - config_root = Path(codex_payload["effective_config"]["config_root"]) - assert command[0] == str(config_root / "tools" / "codex") - - -def test_codex_home_uses_environment(tmp_path): - os.environ["CODEX_HOME"] = str(tmp_path / "custom-codex-home") - - name, path = adapter.get_codex_profile_path( - {"runtime_context": {"runtime_id": "runtime-1"}} - ) - - assert name == "fabric-runtime-1" - assert path == tmp_path / "custom-codex-home" / "fabric-runtime-1.config.toml" - - -def test_codex_home_defaults_to_user_codex_directory(): - os.environ.pop("CODEX_HOME", None) - - name, path = adapter.get_codex_profile_path( - {"runtime_context": {"runtime_id": "runtime-1"}} - ) - - assert name == "fabric-runtime-1" - assert path == Path.home() / ".codex" / "fabric-runtime-1.config.toml" - - -def test_relay_routes_codex_through_standalone_gateway( - codex_payload, - monkeypatch, - tmp_path, -): - codex_payload["telemetry_plan"] = { - "providers": ["relay"], - "relay_enabled": True, - } - mock_find_port = MagicMock(return_value=43210) - monkeypatch.setattr( - adapter.relay_gateway, "find_available_tcp_port", mock_find_port - ) - relay_executable = tmp_path / "bin" / "nemo-relay" - relay_executable.parent.mkdir() - relay_executable.touch() - monkeypatch.setattr( - adapter.relay_gateway, - "resolve_relay_command", - MagicMock(return_value=relay_executable), - ) - relay_plugin_config = {"version": 1, "components": []} - relay_config_path = tmp_path / "relay-config" / "config.toml" - mock_load_config = MagicMock(return_value=relay_plugin_config) - mock_write_config = MagicMock( - return_value=(relay_config_path, tmp_path / "plugins.toml") - ) - monkeypatch.setattr( - adapter.common_utils, - "load_relay_plugin_config", - mock_load_config, - ) - monkeypatch.setattr( - adapter.common_utils, - "write_relay_configs", - mock_write_config, - ) - monkeypatch.setattr( - adapter.relay_gateway, - "relay_cli_observability_version", - MagicMock(return_value=2), - ) - codex_settings = adapter.write_config_files(codex_payload) - - command = adapter.build_command( - codex_payload, - codex_settings=codex_settings, - ) - - assert command[0] == "codex" - assert "nemo-relay" not in command - assert "--dangerously-bypass-hook-trust" in command - assert not any(value.startswith("hooks.") for value in command) - assert "--config" not in command - assert command[command.index("--profile") + 1] == "fabric-runtime-1" - - config = tomllib.loads( - codex_settings.codex_profile_path.read_text(encoding="utf-8") - ) - assert config["model_provider"] == "nemo-relay-openai" - assert config["model_providers"]["nemo-relay-openai"] == { - "name": "NeMo Relay OpenAI", - "base_url": "http://127.0.0.1:43210", - "wire_api": "responses", - "requires_openai_auth": True, - "supports_websockets": False, - } - assert config["features"]["hooks"] is True - assert config["features"]["web_search"] is False - assert config["model_reasoning_effort"] == "high" - assert config["hooks"]["SessionStart"][0]["hooks"][0] == { - "type": "command", - "command": f"{relay_executable} hook-forward codex", - "timeout": 30, - } - assert "UserPromptExpansion" in config["hooks"] - mock_load_config.assert_called_once_with(codex_payload) - mock_write_config.assert_called_once_with( - relay_config={"agents": {"codex": {"command": "codex"}}}, - plugin_config=relay_plugin_config, - observability_version=2, - ) - - -@pytest.mark.parametrize( - ("transport", "expected_exporter", "expected_protocol"), - [ - ("http_binary", "otlp-http", "binary"), - ("grpc", "otlp-grpc", "grpc"), - ], -) -def test_native_otel_profile_writes_codex_telemetry_config( - codex_payload, - tmp_path, - transport, - expected_exporter, - expected_protocol, -): - from examples.code_review_agent import codex_cli_config - from examples.code_review_agent import with_native_otel - - config = codex_payload["effective_config"]["config"] - typed = with_native_otel(codex_cli_config()) - assert typed.telemetry is not None - native_config = typed.telemetry.to_mapping()["providers"]["native"]["config"] - native_config["components"][0]["config"]["opentelemetry"]["transport"] = transport - codex_payload["telemetry_plan"] = { - "providers": ["native"], - "relay_enabled": False, - "native_config": native_config, - } - config["harness"]["settings"]["config_overrides"] = {} - codex_settings = adapter.write_config_files(codex_payload) - - command = adapter.build_command( - codex_payload, - codex_settings=codex_settings, - ) - - assert command[command.index("--profile") + 1] == "fabric-runtime-1" - assert "--dangerously-bypass-hook-trust" not in command - assert tomllib.loads( - codex_settings.codex_profile_path.read_text(encoding="utf-8") - ) == { - "otel": { - "environment": "dev", - "trace_exporter": { - expected_exporter: { - "endpoint": "http://localhost:4318/v1/traces", - "protocol": expected_protocol, - } - }, - } - } - - -def test_run_codex_configures_relay(codex_payload, monkeypatch, tmp_path): - relay_plugin_config = {"version": 1, "components": []} - relay_config_path = tmp_path / "relay-config" / "config.toml" - mock_gateway = MagicMock() - gateway_host = "127.0.0.1:43210" - gateway_url = "http://127.0.0.1:43210" - mock_start_gateway = MagicMock(return_value=mock_gateway) - mock_stop_gateway = MagicMock() - codex_home = tmp_path / "codex-home" - profile_name = "fabric-runtime-1" - profile_path = codex_home / f"{profile_name}.config.toml" - gateway = adapter.relay_gateway.RelayGatewayLaunch( - executable=tmp_path / "nemo-relay", - config_path=relay_config_path, - bind=gateway_host, - url=gateway_url, - log_path=relay_config_path.parent / "gateway.log", - ) - codex_settings = adapter.CodexSettings( - telemetry_provider="relay", - codex_profile_name=profile_name, - codex_profile_path=profile_path, - relay=adapter.CodexRelaySettings( - gateway=gateway, - plugin_config=relay_plugin_config, - ), - ) - mock_write_config_files = MagicMock(return_value=codex_settings) - mock_run = MagicMock( - return_value=subprocess.CompletedProcess( - args=[], - returncode=0, - stdout=codex_jsonl("thread-123", "done"), - stderr="", - ) - ) - os.environ["FABRIC_RELAY_ENABLED"] = "true" - os.environ.pop("CODEX_HOME", None) - os.environ["FABRIC_RELAY_CONFIG_PATH"] = str(tmp_path / "relay-config.json") - monkeypatch.setattr( - adapter.relay_gateway, "start_relay_gateway", mock_start_gateway - ) - monkeypatch.setattr(adapter.relay_gateway, "stop_relay_gateway", mock_stop_gateway) - monkeypatch.setattr( - adapter, - "write_config_files", - mock_write_config_files, - ) - monkeypatch.setattr(adapter.subprocess, "run", mock_run) - - result = adapter.run_codex(codex_payload) - - command = mock_run.call_args.args[0] - assert result["relay_runtime"] == { - "enabled": True, - "config_path": os.environ["FABRIC_RELAY_CONFIG_PATH"], - "emitter": "nemo-relay", - "gateway_config_path": str(relay_config_path), - "gateway_log_path": str(gateway.log_path), - } - assert result["relay_artifacts"] == [] - assert command[0] == "codex" - assert "nemo-relay" not in command - assert command[command.index("--profile") + 1] == profile_name - assert mock_run.call_args.kwargs["env"]["NEMO_RELAY_GATEWAY_URL"] == gateway_url - assert "CODEX_HOME" not in mock_run.call_args.kwargs["env"] - assert not profile_path.exists() - mock_write_config_files.assert_called_once_with(codex_payload) - mock_start_gateway.assert_called_once_with( - launch=gateway, - cwd=Path(codex_payload["runtime_context"]["environment"]["workspace"]), - ) - mock_stop_gateway.assert_called_once_with(mock_gateway) - - -def test_reported_command_redacts_secret_config_overrides(): - - command = ["codex", "exec", "--config", 'provider.api_key="secret"', "-"] - - assert adapter.redact_command(command)[-2] == "" - assert command[-2] == 'provider.api_key="secret"' - - -def test_config_override_values_use_tomli_writer(): - - assert adapter.toml_value("café") == '"café"' - encoded = adapter.toml_value([1, "two"]) - assert tomllib.loads(f"value = {encoded}")["value"] == [1, "two"] - with pytest.raises(ValueError, match="scalar or array"): - adapter.toml_value({"nested": True}) - - -@pytest.mark.parametrize("value", [[float("nan")], [1, [float("inf")]]]) -def test_config_override_values_reject_nested_non_finite_numbers(value): - - with pytest.raises(ValueError, match="finite numbers"): - adapter.toml_value(value) - - -def test_runtime_reuses_codex_thread_across_invocations( - codex_payload, monkeypatch, tmp_path -): - mock_run = MagicMock( - side_effect=[ - subprocess.CompletedProcess( - args=[], - returncode=0, - stdout=codex_jsonl("thread-123", "first response"), - stderr="", - ), - subprocess.CompletedProcess( - args=[], - returncode=0, - stdout=codex_jsonl("thread-123", "second response"), - stderr="", - ), - ] - ) - monkeypatch.setattr(adapter.subprocess, "run", mock_run) - os.environ.pop("OPENAI_API_KEY", None) - os.environ["CODEX_HOME"] = str(tmp_path / "codex-home") - os.environ["FABRIC_UNRELATED_SECRET"] = "do-not-forward" - codex_payload["effective_config"]["config"]["harness"]["settings"]["env"] = { - "CODEX_EXPLICIT": "forward-me" - } - - first = adapter.run_codex(codex_payload) - codex_payload["runtime_context"]["invocation_id"] = "invocation-2" - codex_payload["request"]["input"] = "Continue." - second = adapter.run_codex(codex_payload) - - first_command = mock_run.call_args_list[0].args[0] - second_command = mock_run.call_args_list[1].args[0] - assert "--ephemeral" not in first_command - assert "resume" not in first_command - assert second_command[-3:] == ["resume", "thread-123", "-"] - assert first["response"] == "first response" - assert second["response"] == "second response" - assert second["thread_id"] == "thread-123" - assert second["usage"]["cached_input_tokens"] == 2 - child_env = mock_run.call_args_list[0].kwargs["env"] - assert child_env["CODEX_HOME"] == str(tmp_path / "codex-home") - assert child_env["CODEX_EXPLICIT"] == "forward-me" - assert "OPENAI_API_KEY" not in child_env - assert mock_run.call_args_list[0].kwargs["timeout"] == 1800 - - state_path = adapter.runtime_state_path(codex_payload, "runtime-1") - assert json.loads(state_path.read_text(encoding="utf-8")) == { - "runtime_id": "runtime-1", - "thread_id": "thread-123", - } - - -def test_runtime_rejects_corrupt_codex_thread_state(codex_payload): - state_path = adapter.runtime_state_path(codex_payload, "runtime-1") - state_path.parent.mkdir(parents=True) - state_path.write_text("{", encoding="utf-8") - - with pytest.raises(RuntimeError, match="invalid Codex runtime state"): - adapter.load_thread_id(codex_payload, "runtime-1") - - -def test_runtime_persists_codex_thread_state(codex_payload, monkeypatch): - mock_run = MagicMock( - return_value=subprocess.CompletedProcess( - args=[], - returncode=0, - stdout=codex_jsonl("thread-ephemeral", "done"), - stderr="", - ) - ) - monkeypatch.setattr(adapter.subprocess, "run", mock_run) - - output = adapter.run_codex(codex_payload) - - assert output["thread_id"] == "thread-ephemeral" - assert output["response"] == "done" - assert "events" not in output - assert "stdout" not in output - assert "stderr" not in output - assert (Path(output["state_dir"]) / "runtimes").exists() - - -def test_adapter_rejects_structured_input_until_chat_is_supported(codex_payload): - codex_payload["request"]["input"] = { - "messages": [{"role": "user", "content": "Inspect the change."}] - } - - with pytest.raises(ValueError, match="requires text input"): - adapter.request_to_prompt(codex_payload) - - -@pytest.mark.parametrize("env", [[], "CODEX_FLAG=1"]) -def test_adapter_rejects_non_mapping_env(codex_payload, env): - settings = codex_payload["effective_config"]["config"]["harness"]["settings"] - settings["env"] = env - - with pytest.raises(ValueError, match="env must be a mapping"): - adapter.build_env(codex_payload) - - -@pytest.mark.parametrize( - ("error", "message", "returncode"), - [ - (FileNotFoundError("codex not found"), "codex not found", 127), - ( - subprocess.TimeoutExpired(["codex"], 1800), - "timed out after 1800 seconds", - 124, - ), - ], -) -def test_process_launch_failures_return_structured_results( - codex_payload, monkeypatch, error, message, returncode -): - monkeypatch.setattr(adapter.subprocess, "run", MagicMock(side_effect=error)) - - output = adapter.run_codex(codex_payload) - - assert output["failed"] is True - assert output["returncode"] == returncode - assert message in output["error"] - assert "stdout" not in output - assert "stderr" not in output - - -def test_thread_mismatch_preserves_process_error(codex_payload, monkeypatch): - adapter.save_thread_id(codex_payload, "runtime-1", "thread-persisted") - monkeypatch.setattr( - adapter.subprocess, - "run", - MagicMock( - return_value=subprocess.CompletedProcess( - args=[], - returncode=1, - stdout=codex_jsonl("thread-unexpected", "failed response"), - stderr="Codex process failed", - ) - ), - ) - - output = adapter.run_codex(codex_payload) - - assert output["failed"] is True - assert output["error"] == "Codex process failed" - - -@pytest.mark.parametrize("timeout", [0, -1, float("inf"), "30"]) -def test_adapter_rejects_invalid_timeout(codex_payload, timeout): - settings = codex_payload["effective_config"]["config"]["harness"]["settings"] - settings["timeout_seconds"] = timeout - - with pytest.raises(ValueError, match="timeout_seconds"): - adapter.run_codex(codex_payload) - - -def test_runtime_fails_if_codex_does_not_return_thread_identity( - codex_payload, monkeypatch -): - mock_run = MagicMock( - return_value=subprocess.CompletedProcess( - args=[], - returncode=0, - stdout=json.dumps( - { - "type": "item.completed", - "item": {"type": "agent_message", "text": "untracked"}, - } - ), - stderr="", - ) - ) - monkeypatch.setattr(adapter.subprocess, "run", mock_run) - - output = adapter.run_codex(codex_payload) - - assert output["failed"] is True - assert "thread identity" in output["error"] - - -def test_successful_process_without_final_response_is_failed( - codex_payload, monkeypatch -): - mock_run = MagicMock( - return_value=subprocess.CompletedProcess( - args=[], - returncode=0, - stdout="\n".join( - [ - json.dumps({"type": "thread.started", "thread_id": "thread-123"}), - json.dumps({"type": "turn.completed", "usage": {}}), - ] - ), - stderr="", - ) - ) - monkeypatch.setattr(adapter.subprocess, "run", mock_run) - - output = adapter.run_codex(codex_payload) - - assert output["failed"] is True - assert "final agent message" in output["error"] - - -async def test_fabric_runtime_invokes_codex_then_resumes(tmp_path): - mock_codex = tmp_path / "codex" - write_mock_codex(mock_codex) - config = fabric_config(tmp_path, mock_codex) - - async with await Fabric().start_runtime( - config, - base_dir=tmp_path, - ) as runtime: - first = await runtime.invoke(input="first") - second = await runtime.invoke(input="second") - - assert first.runtime_id == second.runtime_id - assert first.output["response"] == "thread-fake:first" - assert second.output["response"] == "thread-fake:second" - assert first.output["thread_id"] == second.output["thread_id"] == "thread-fake" - assert "resume" not in first.output["command"] - assert second.output["command"][-3:] == ["resume", "thread-fake", "-"] - - -async def test_fabric_oneshot_uses_cached_codex_auth(tmp_path): - mock_codex = tmp_path / "codex" - write_mock_codex(mock_codex) - config = fabric_config(tmp_path, mock_codex) - os.environ.pop("OPENAI_API_KEY", None) - - client = Fabric() - report = await client.doctor(config, base_dir=tmp_path) - result = await client.run( - config, - base_dir=tmp_path, - input="inspect", - ) - - assert report.status == "pass" - assert any( - check.name == "requirement.binary" and "codex_command" in check.message - for check in report.checks - ) - assert not any(check.name == "requirement.env" for check in report.checks) - assert result.output["response"] == "thread-fake:inspect" - assert "--ephemeral" not in result.output["command"] - - -def test_codex_profile_resolves_runtime_adapter(): - from examples.code_review_agent import BASE_DIR - from examples.code_review_agent import codex_cli_config - - plan = Fabric().plan( - codex_cli_config(), - base_dir=BASE_DIR, - ) - - assert plan.adapter.adapter_id == "nvidia.fabric.codex.cli" - assert plan.adapter.harness == "codex" - assert "mode" not in plan.effective_config.config.runtime - assert plan.effective_config.config.runtime.input_schema == "text" - settings = plan.effective_config.config.harness.settings - assert settings["config_overrides"]["model_reasoning_effort"] == "high" - unsupported = plan["capability_plan"]["unsupported"] - assert not unsupported.get("skill_paths") - assert not unsupported.get("mcp_servers") diff --git a/tests/adapters/test_deepagents.py b/tests/adapters/test_deepagents.py index 86d486965..40b904828 100644 --- a/tests/adapters/test_deepagents.py +++ b/tests/adapters/test_deepagents.py @@ -603,6 +603,7 @@ def request(name: str) -> types.SimpleNamespace: gates = [create_kwargs["middleware"][-1]] gates.extend(subagent["middleware"][-1] for subagent in subagents) + assert all(type(gate) is adapter.ToolGateMiddleware for gate in gates) for middleware in gates: blocked = await middleware.awrap_tool_call(request("write_file"), handler) assert isinstance(blocked, ToolMessage) @@ -635,6 +636,26 @@ async def test_blocked_tools_reject_unenforceable_subagents(tmp_path, make_paylo await adapter.build_agent_kwargs(payload, MagicMock(), settings) +@pytest.mark.parametrize( + ("subagents", "message"), + [ + ( + {"name": "researcher"}, + "harness.settings.deepagents.subagents must be a list when tools.blocked is configured.", + ), + ( + [{"name": "researcher"}, "invalid"], + "Deep Agents subagents must be mappings when tools.blocked is configured.", + ), + ], +) +def test_gated_subagents_reject_invalid_configuration(subagents, message): + with pytest.raises(adapter.AdapterConfigError) as error: + adapter._gated_subagents(subagents, {"write_file"}) + + assert str(error.value) == message + + async def test_deepagents_passthrough_forwards_supported_options(tmp_path, make_payload, fake_sdks): # Documented JSON-serializable options reach create_deep_agent unchanged. payload = make_payload(tmp_path) diff --git a/tests/e2e/test_claude.py b/tests/e2e/test_claude.py index d007b8953..ee5e5eca8 100644 --- a/tests/e2e/test_claude.py +++ b/tests/e2e/test_claude.py @@ -11,6 +11,7 @@ from pathlib import Path import pytest +from _utils.utils import assert_semantic_relay_artifacts from nemo_fabric import ( EnvironmentConfig, Fabric, @@ -93,7 +94,15 @@ def fabric_config( resolution="preinstalled", settings=settings, ), - models={"default": ModelConfig(provider="anthropic", model="claude-test-model")}, + models={ + "default": ModelConfig( + provider="anthropic", + model=os.environ.get( + "FABRIC_TEST_CLAUDE_MODEL", + "claude-sonnet-4-5", + ), + ) + }, runtime=RuntimeConfig(artifacts=tmp_path / "artifacts"), environment=EnvironmentConfig( provider="local", @@ -254,3 +263,7 @@ async def test_live_claude_relay_one_shot(tmp_path): "atof", "atif", } + assert_semantic_relay_artifacts( + result.output, + "FABRIC_CLAUDE_RELAY_OK", + ) diff --git a/tests/e2e/test_codex.py b/tests/e2e/test_codex.py new file mode 100644 index 000000000..8c4da773f --- /dev/null +++ b/tests/e2e/test_codex.py @@ -0,0 +1,128 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Opt-in real Codex SDK integration gates for Fabric runtime behavior. + +RUN_FABRIC_CODEX_INTEGRATION=1 uv run pytest tests/e2e/test_codex.py +""" + +from __future__ import annotations + +import importlib.util +import os +import shutil +import uuid + +import pytest +from _utils.utils import assert_semantic_relay_artifacts + + +def _select_codex_runtime(config): + codex_bin = os.environ.get("FABRIC_TEST_CODEX_BIN") + if codex_bin: + config.harness.settings["codex_bin"] = codex_bin + return config + + +async def test_codex_sdk(): + if os.environ.get("RUN_FABRIC_CODEX_INTEGRATION") != "1": + pytest.skip("set RUN_FABRIC_CODEX_INTEGRATION=1 to run") + if importlib.util.find_spec("openai_codex") is None: + pytest.fail("the openai-codex Python SDK is required") + if importlib.util.find_spec("nemo_fabric._native") is None: + pytest.fail("the nemo_fabric native extension is required (pip install -e .)") + await _run() + + +async def test_codex_sdk_with_relay(): + if os.environ.get("RUN_FABRIC_CODEX_RELAY_INTEGRATION") != "1": + pytest.skip("set RUN_FABRIC_CODEX_RELAY_INTEGRATION=1 to run") + if importlib.util.find_spec("openai_codex") is None: + pytest.fail("the openai-codex Python SDK is required") + if importlib.util.find_spec("nemo_fabric._native") is None: + pytest.fail("the nemo_fabric native extension is required (pip install -e .)") + relay_command = os.environ.get("FABRIC_TEST_NEMO_RELAY_COMMAND") or shutil.which( + "nemo-relay" + ) + if relay_command is None: + pytest.fail("the nemo-relay CLI is required") + await _run_relay(relay_command) + + +async def _run() -> None: + from examples.code_review_agent import BASE_DIR, codex_config + from nemo_fabric import Fabric + + config = _select_codex_runtime(codex_config()) + nonce = f"fabric-{uuid.uuid4().hex[:8]}" + client = Fabric() + oneshot = await client.run( + config, + base_dir=BASE_DIR, + input="Reply with exactly: FABRIC_CODEX_ONESHOT_OK", + ) + assert oneshot["status"] == "succeeded", oneshot.to_mapping() + assert "fabric_codex_oneshot_ok" in oneshot["output"]["response"].lower(), ( + oneshot.to_mapping() + ) + assert oneshot["output"]["adapter"] == "sdk", oneshot.to_mapping() + assert "command" not in oneshot["output"], oneshot.to_mapping() + + async with await client.start_runtime( + config, + base_dir=BASE_DIR, + ) as runtime: + first = await runtime.invoke(input=f"Remember this value: {nonce}") + second = await runtime.invoke( + input="Reply with only the value I asked you to remember." + ) + + results = (first.to_mapping(), second.to_mapping()) + assert first["status"] == second["status"] == "succeeded", results + assert first["output"]["thread_id"] == second["output"]["thread_id"], results + assert nonce in second["output"]["response"], second.to_mapping() + assert first["output"]["events"], first.to_mapping() + assert second["output"]["usage"] is not None, second.to_mapping() + + +async def _run_relay(relay_command: str) -> None: + from examples.code_review_agent import BASE_DIR, codex_config, with_relay + from nemo_fabric import Fabric + + config = _select_codex_runtime(with_relay(codex_config())) + config.harness.settings["nemo_relay_command"] = relay_command + client = Fabric() + result = await client.run( + config, + base_dir=BASE_DIR, + input="Reply with exactly: FABRIC_CODEX_RELAY_OK", + ) + + mapping = result.to_mapping() + assert result["status"] == "succeeded", mapping + assert "fabric_codex_relay_ok" in result["output"]["response"].lower(), mapping + assert result["output"]["adapter"] == "sdk", mapping + assert result["output"]["relay_runtime"]["enabled"] is True, mapping + assert {item["kind"] for item in result["output"]["relay_artifacts"]} >= { + "atof", + "atif", + }, mapping + assert_semantic_relay_artifacts(result["output"], "FABRIC_CODEX_RELAY_OK") + + nonce = f"fabric-relay-{uuid.uuid4().hex[:8]}" + async with await client.start_runtime(config, base_dir=BASE_DIR) as runtime: + first = await runtime.invoke(input=f"Remember this value: {nonce}") + second = await runtime.invoke( + input="Reply with only the value I asked you to remember." + ) + + results = (first.to_mapping(), second.to_mapping()) + assert first["status"] == second["status"] == "succeeded", results + assert first["output"]["thread_id"] == second["output"]["thread_id"], results + assert nonce in second["output"]["response"], second.to_mapping() + for turn in (first, second): + assert turn["output"]["relay_runtime"]["enabled"] is True, turn.to_mapping() + assert {item["kind"] for item in turn["output"]["relay_artifacts"]} >= { + "atof", + "atif", + }, turn.to_mapping() diff --git a/tests/e2e/test_codex_cli.py b/tests/e2e/test_codex_cli.py deleted file mode 100644 index 5fb154cfe..000000000 --- a/tests/e2e/test_codex_cli.py +++ /dev/null @@ -1,63 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Opt-in real Codex CLI smoke for Fabric one-shot and multi-turn runtimes. - -RUN_FABRIC_CODEX_INTEGRATION=1 pytest tests/e2e/test_codex_cli.py -""" - -from __future__ import annotations - -import importlib.util -import os -import shutil -import uuid - -import pytest - - -async def test_codex_cli(): - if os.environ.get("RUN_FABRIC_CODEX_INTEGRATION") != "1": - pytest.skip("set RUN_FABRIC_CODEX_INTEGRATION=1 to run") - if shutil.which("codex") is None: - pytest.fail("codex CLI is required") - if importlib.util.find_spec("nemo_fabric._native") is None: - pytest.fail("the nemo_fabric native extension is required (pip install -e .)") - await _run() - - -async def _run() -> None: - from examples.code_review_agent import BASE_DIR, codex_cli_config - from nemo_fabric import Fabric - - config = codex_cli_config() - nonce = f"fabric-{uuid.uuid4().hex[:8]}" - client = Fabric() - oneshot = await client.run( - config, - base_dir=BASE_DIR, - input="Reply with exactly: FABRIC_CODEX_ONESHOT_OK", - ) - assert oneshot["status"] == "succeeded", oneshot.to_mapping() - assert "fabric_codex_oneshot_ok" in oneshot["output"]["response"].lower(), ( - oneshot.to_mapping() - ) - assert "--ephemeral" not in oneshot["output"]["command"], oneshot.to_mapping() - - async with await client.start_runtime( - config, - base_dir=BASE_DIR, - ) as runtime: - first = await runtime.invoke(input=f"Remember this value: {nonce}") - second = await runtime.invoke( - input="Reply with only the value I asked you to remember." - ) - - results = (first.to_mapping(), second.to_mapping()) - assert first["status"] == second["status"] == "succeeded", results - assert first["output"]["thread_id"] == second["output"]["thread_id"], results - assert nonce in second["output"]["response"], second.to_mapping() - assert second["output"]["command"][-3:-1] == [ - "resume", - first["output"]["thread_id"], - ], second.to_mapping() diff --git a/tests/fixtures/file-config-agent/profiles/codex-cli.yaml b/tests/fixtures/file-config-agent/profiles/codex.yaml similarity index 66% rename from tests/fixtures/file-config-agent/profiles/codex-cli.yaml rename to tests/fixtures/file-config-agent/profiles/codex.yaml index 4b7582de4..9abca5c06 100644 --- a/tests/fixtures/file-config-agent/profiles/codex-cli.yaml +++ b/tests/fixtures/file-config-agent/profiles/codex.yaml @@ -2,17 +2,15 @@ # SPDX-License-Identifier: Apache-2.0 schema_version: fabric.profile/v1alpha1 -name: codex_cli -description: Test Codex CLI file-profile resolution. +name: codex +description: Test Codex SDK file-profile resolution. harness: - adapter_id: nvidia.fabric.codex.cli + adapter_id: nvidia.fabric.codex resolution: preinstalled settings: sandbox: workspace-write - skip_git_repo_check: true - config_overrides: - model_reasoning_effort: high + reasoning_effort: high models: default: @@ -27,11 +25,11 @@ mcp: null runtime: input_schema: text output_schema: message - artifacts: ./artifacts/codex-cli + artifacts: ./artifacts/codex environment: provider: local workspace: ./repos/my-service - artifacts: ./artifacts/codex-cli + artifacts: ./artifacts/codex telemetry: null diff --git a/tests/integrations/test_harbor_runner.py b/tests/integrations/test_harbor_runner.py index afd53fc8b..c92d91bd0 100644 --- a/tests/integrations/test_harbor_runner.py +++ b/tests/integrations/test_harbor_runner.py @@ -2,10 +2,8 @@ # SPDX-License-Identifier: Apache-2.0 import asyncio -import importlib.util import json import os -import tomllib from pathlib import Path import pytest @@ -28,12 +26,9 @@ pytestmark = pytest.mark.usefixtures("requires_harbor") def load_codex_adapter(): - path = ROOT / "adapters/codex-cli/src/nemo_fabric_adapters/codex_cli/adapter.py" - spec = importlib.util.spec_from_file_location("fabric_codex_adapter", path) - assert spec and spec.loader - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module + from nemo_fabric_adapters.codex import adapter + + return adapter def test_runner_composes_harbor_values_on_an_independent_config(tmp_path): @@ -256,7 +251,7 @@ async def run_specs(): ] -def test_codex_adapter_maps_fabric_request_to_cli(tmp_path): +def test_codex_adapter_maps_fabric_request_to_sdk(tmp_path): adapter = load_codex_adapter() payload = { @@ -266,8 +261,7 @@ def test_codex_adapter_maps_fabric_request_to_cli(tmp_path): "harness": { "settings": { "sandbox": "workspace-write", - "skip_git_repo_check": True, - "config_overrides": {"model_reasoning_effort": "high"}, + "reasoning_effort": "high", } }, "models": { @@ -286,29 +280,10 @@ def test_codex_adapter_maps_fabric_request_to_cli(tmp_path): "request": {"input": "Fix the calculator."}, } - os.environ["CODEX_HOME"] = str(tmp_path) - profile_name = "fabric-harbor-test" - profile_path = tmp_path / f"{profile_name}.config.toml" - codex_settings = adapter.write_config_files(payload) - command = adapter.build_command( - payload, - codex_settings=codex_settings, - ) - - assert command == [ - "codex", - "exec", - "--json", - "--sandbox", - "workspace-write", - "--profile", - profile_name, - "--model", - "gpt-5.4", - "--skip-git-repo-check", - "-", - ] - assert tomllib.loads(profile_path.read_text(encoding="utf-8")) == {"model_reasoning_effort": "high"} + assert adapter.selected_model(payload) == "gpt-5.4" + assert adapter.sandbox(payload) == adapter.Sandbox.workspace_write + assert adapter._reasoning_effort(payload) == adapter.ReasoningEffort.high + assert adapter.thread_config(payload, relay=None) == {} assert adapter.resolve_cwd(payload) == tmp_path @@ -317,13 +292,12 @@ def test_codex_demo_uses_current_adapter_contract(): settings = config["harness"]["settings"] assert config["schema_version"] == "fabric.agent/v1alpha1" - assert config["harness"]["adapter_id"] == "nvidia.fabric.codex.cli" + assert config["harness"]["adapter_id"] == "nvidia.fabric.codex" assert settings["sandbox"] == "danger-full-access" - assert settings["skip_git_repo_check"] is True - assert settings["config_overrides"]["model_reasoning_effort"] == "high" + assert settings["reasoning_effort"] == "high" dockerfile = DEMO_DOCKERFILE.read_text(encoding="utf-8") assert "nemo-fabric[codex,harbor,hermes,relay,runtime]" in dockerfile - assert "@openai/codex@0.142.4" in dockerfile + assert "@openai/codex" not in dockerfile def test_harbor_demo_uses_complete_configs_without_profiles(): diff --git a/tests/python/test_code_review_example.py b/tests/python/test_code_review_example.py index 2293977a3..80d5a80d9 100644 --- a/tests/python/test_code_review_example.py +++ b/tests/python/test_code_review_example.py @@ -13,7 +13,7 @@ from examples.code_review_agent import __main__ as main_module from examples.code_review_agent import base_config from examples.code_review_agent import claude_config -from examples.code_review_agent import codex_cli_config +from examples.code_review_agent import codex_config from examples.code_review_agent import hermes_config from examples.code_review_agent import with_fabric_managed_github_mcp from examples.code_review_agent import with_native_otel @@ -29,7 +29,7 @@ def test_variant_builders_return_independent_complete_configs(): base = base_config() hermes = hermes_config() - codex = codex_cli_config() + codex = codex_config() claude = claude_config() for config in (base, hermes, codex, claude): @@ -40,7 +40,7 @@ def test_variant_builders_return_independent_complete_configs(): assert hermes is not base assert hermes.harness is not base.harness - assert codex.harness.adapter_id == "nvidia.fabric.codex.cli" + assert codex.harness.adapter_id == "nvidia.fabric.codex" assert codex.mcp is None assert codex.skills is None assert claude is not base @@ -86,7 +86,7 @@ def test_capability_and_telemetry_variants_do_not_mutate_their_input(): def test_variants_plan_without_file_profiles(): client = Fabric() - for config in (hermes_config(), codex_cli_config(), claude_config()): + for config in (hermes_config(), codex_config(), claude_config()): plan = client.plan(config, base_dir=BASE_DIR) assert plan.profiles == () assert plan.agent_name == "code-review-agent" @@ -96,7 +96,7 @@ def test_variants_plan_without_file_profiles(): def test_example_entrypoint_plans_without_starting_a_runtime(): cases = ( ([], "nvidia.fabric.hermes", False), - (["--variant", "codex-cli"], "nvidia.fabric.codex.cli", False), + (["--variant", "codex"], "nvidia.fabric.codex", False), (["--variant", "claude"], "nvidia.fabric.claude", False), (["--relay"], "nvidia.fabric.hermes", True), ) diff --git a/uv.lock b/uv.lock index ddef3aee1..ac97192bb 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,9 @@ resolution-markers = [ "python_full_version < '3.12'", ] +[manifest] +constraints = [{ name = "openai-codex-cli-bin", specifier = "==0.137.0a4" }] + [[package]] name = "aiohappyeyeballs" version = "2.6.2" @@ -429,20 +432,20 @@ wheels = [ [[package]] name = "claude-agent-sdk" -version = "0.2.114" +version = "0.2.120" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "mcp" }, { name = "sniffio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/f3/a74b32b52f3c0013e317afe27b44946b9bbceb16b53c2a8d9614bfc0e7ad/claude_agent_sdk-0.2.114.tar.gz", hash = "sha256:53263a25bddd52b354ba2975b348cc978493bf29cb21a8c74a1b92b06f88faa3", size = 268633, upload-time = "2026-07-08T21:35:01.546Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/7f/7b69aed292a4edecae132e4dbe6b6decb4e88ec142fc91d117b19058c9e0/claude_agent_sdk-0.2.120.tar.gz", hash = "sha256:e428552f79a76e0d85789369eeb58249b33f350200124e5fc86b24168bd00805", size = 268639, upload-time = "2026-07-15T23:18:50.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/57/ded5a6e02940b73775e8235cc69c3f1f9aca15871b73951ea184f26b68b7/claude_agent_sdk-0.2.114-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5170a7cf8e5f30cdd59d60505b82e444abd655e8391e92e8390b1d539c100550", size = 69490216, upload-time = "2026-07-08T21:35:05.659Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ee/646435d91960e83cf6989d70adc6e9b47295a1e93bb1c2d7be366c8786c8/claude_agent_sdk-0.2.114-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:42b76e889d8219cf741789205adc9306b62524af205080362eb75b1bc09aa10b", size = 74389245, upload-time = "2026-07-08T21:35:10.367Z" }, - { url = "https://files.pythonhosted.org/packages/38/d0/f0c5ced2a1dcf28ad0da9643d81e535e9fa7c9cf70c09ae7573429e4bcdf/claude_agent_sdk-0.2.114-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:f583f04a8d7db22b5dc826c135225199e99c46092e563492aa2a13d52b81e8ed", size = 79425407, upload-time = "2026-07-08T21:35:15.18Z" }, - { url = "https://files.pythonhosted.org/packages/1d/7a/2dc6f13a153187ec4ca0a2fd76e7e20cd3fb43d72eb8e01401fd4ab7a335/claude_agent_sdk-0.2.114-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:a3ec58cfff233c70782437a900fb152ef971b92887e03b03d218266285ad387a", size = 80450013, upload-time = "2026-07-08T21:35:20.331Z" }, - { url = "https://files.pythonhosted.org/packages/c9/25/3cc0ea8ff94bdbc28cd0da69d4d6b4bd382b6f7d182b94351a313d59ceac/claude_agent_sdk-0.2.114-py3-none-win_amd64.whl", hash = "sha256:60096c88ead0ff7f05ae5cf90d7a75e47a02cbc16a97d0c813bc0409cbb0e334", size = 79985117, upload-time = "2026-07-08T21:35:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/80/85/5e8958704db0f8195e63f8ec4a80c5fb14756edc785bb3535e0dc5d91104/claude_agent_sdk-0.2.120-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ead9fb4bdaf70069978703ec6d74b30bd269e9632a4aea4dc8c4e999ac3a1b", size = 71110966, upload-time = "2026-07-15T23:18:54.743Z" }, + { url = "https://files.pythonhosted.org/packages/c1/53/c6cdad82ac100c8a45887999614e9fe206b77b43790dacc6de42b156ad4c/claude_agent_sdk-0.2.120-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:1248591c7bffeb6e10e8cd169e0766854951fba8816e3e7d81a003f8bfca6f08", size = 76067995, upload-time = "2026-07-15T23:18:58.398Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f8/248e3f58d0f0aa7d76bd34b11b18135cc124f5b9a9b55219cc1ca03d662a/claude_agent_sdk-0.2.120-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:abc73ccdf3decca566cd18084e74bc2f2d10b8b77cc1fd5ed4299d5c15e5078b", size = 81027434, upload-time = "2026-07-15T23:19:03.318Z" }, + { url = "https://files.pythonhosted.org/packages/11/59/6adb0c53534646f1d5ddc41226ff37b2adc413a472e2f87a9011548a137f/claude_agent_sdk-0.2.120-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:888070c246c92e102c52001d26532cd3646a700656d7c368f2f91a1d3c16b534", size = 82084704, upload-time = "2026-07-15T23:19:08.729Z" }, + { url = "https://files.pythonhosted.org/packages/2a/06/036b8dce1e86ecd5e2e1ddc281736cdb33bad6d24e748b9553e235b028fe/claude_agent_sdk-0.2.120-py3-none-win_amd64.whl", hash = "sha256:bc1441c94f60c9e7b4b8c641742fedf68451f573062ef69dd43d86a61b1fb219", size = 81958518, upload-time = "2026-07-15T23:19:13.082Z" }, ] [[package]] @@ -2010,8 +2013,8 @@ source = { editable = "." } adapters-claude = [ { name = "nemo-fabric-adapters-claude" }, ] -adapters-codex-cli = [ - { name = "nemo-fabric-adapters-codex-cli" }, +adapters-codex = [ + { name = "nemo-fabric-adapters-codex" }, ] adapters-common = [ { name = "nemo-fabric-adapters-common" }, @@ -2026,7 +2029,7 @@ claude = [ { name = "nemo-fabric-adapters-claude" }, ] codex = [ - { name = "nemo-fabric-adapters-codex-cli" }, + { name = "nemo-fabric-adapters-codex" }, ] deepagents = [ { name = "nemo-fabric-adapters-deepagents" }, @@ -2050,7 +2053,7 @@ runtime = [ [package.dev-dependencies] adapters = [ { name = "nemo-fabric-adapters-claude" }, - { name = "nemo-fabric-adapters-codex-cli" }, + { name = "nemo-fabric-adapters-codex" }, { name = "nemo-fabric-adapters-common" }, { name = "nemo-fabric-adapters-deepagents" }, { name = "nemo-fabric-adapters-hermes", marker = "python_full_version < '3.14'" }, @@ -2082,8 +2085,8 @@ requires-dist = [ { name = "hermes-agent", marker = "python_full_version < '3.14' and extra == 'hermes'", specifier = ">=0.17.0" }, { name = "nemo-fabric-adapters-claude", marker = "extra == 'adapters-claude'", editable = "adapters/claude" }, { name = "nemo-fabric-adapters-claude", marker = "extra == 'claude'", editable = "adapters/claude" }, - { name = "nemo-fabric-adapters-codex-cli", marker = "extra == 'adapters-codex-cli'", editable = "adapters/codex-cli" }, - { name = "nemo-fabric-adapters-codex-cli", marker = "extra == 'codex'", editable = "adapters/codex-cli" }, + { name = "nemo-fabric-adapters-codex", marker = "extra == 'adapters-codex'", editable = "adapters/codex" }, + { name = "nemo-fabric-adapters-codex", marker = "extra == 'codex'", editable = "adapters/codex" }, { name = "nemo-fabric-adapters-common", marker = "extra == 'adapters-common'", editable = "adapters/common" }, { name = "nemo-fabric-adapters-deepagents", marker = "extra == 'adapters-deepagents'", editable = "adapters/deepagents" }, { name = "nemo-fabric-adapters-deepagents", marker = "extra == 'deepagents'", editable = "adapters/deepagents" }, @@ -2094,12 +2097,12 @@ requires-dist = [ { name = "pyyaml", marker = "extra == 'harbor'", specifier = ">=6.0" }, { name = "tomli-w", marker = "extra == 'relay'", specifier = "~=1.2" }, ] -provides-extras = ["adapters-common", "adapters-codex-cli", "adapters-claude", "adapters-deepagents", "adapters-hermes", "codex", "claude", "deepagents", "harbor", "hermes", "relay", "runtime"] +provides-extras = ["adapters-common", "adapters-codex", "adapters-claude", "adapters-deepagents", "adapters-hermes", "codex", "claude", "deepagents", "harbor", "hermes", "relay", "runtime"] [package.metadata.requires-dev] adapters = [ { name = "nemo-fabric-adapters-claude", editable = "adapters/claude" }, - { name = "nemo-fabric-adapters-codex-cli", editable = "adapters/codex-cli" }, + { name = "nemo-fabric-adapters-codex", editable = "adapters/codex" }, { name = "nemo-fabric-adapters-common", editable = "adapters/common" }, { name = "nemo-fabric-adapters-deepagents", editable = "adapters/deepagents" }, { name = "nemo-fabric-adapters-hermes", marker = "python_full_version < '3.14'", editable = "adapters/hermes" }, @@ -2137,23 +2140,25 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "claude-agent-sdk", specifier = "==0.2.114" }, + { name = "claude-agent-sdk", specifier = "==0.2.120" }, { name = "nemo-fabric-adapters-common", editable = "adapters/common" }, { name = "tomli-w", specifier = "~=1.2" }, ] [[package]] -name = "nemo-fabric-adapters-codex-cli" +name = "nemo-fabric-adapters-codex" version = "0.1.0" -source = { editable = "adapters/codex-cli" } +source = { editable = "adapters/codex" } dependencies = [ { name = "nemo-fabric-adapters-common" }, + { name = "openai-codex" }, { name = "tomli-w" }, ] [package.metadata] requires-dist = [ { name = "nemo-fabric-adapters-common", editable = "adapters/common" }, + { name = "openai-codex", specifier = "==0.1.0b3" }, { name = "tomli-w", specifier = "~=1.2" }, ] @@ -2309,6 +2314,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/30/844dc675ee6902579b8eef01ed23917cc9319a1c9c0c14ec6e39340c96d0/openai-2.24.0-py3-none-any.whl", hash = "sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94", size = 1120122, upload-time = "2026-02-24T20:02:05.669Z" }, ] +[[package]] +name = "openai-codex" +version = "0.1.0b3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openai-codex-cli-bin" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/1c/1e5e8b83ea72164d32b1f4e67fc703c8b83591f498a7aaf96f39d352b453/openai_codex-0.1.0b3.tar.gz", hash = "sha256:b76b7afe97953ac65648e9b8ca116b5ff273de91086549bd7ec88037cdc16cab", size = 58995, upload-time = "2026-06-03T19:17:34.707Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ef/f77037d9ccde80a688a17a06aea5a56813ad9c365d49b3f1c7913422af8b/openai_codex-0.1.0b3-py3-none-any.whl", hash = "sha256:8d1f9d346667aeecb435c6a45d0edb3f016187276ec452cf8094d813896276c4", size = 65639, upload-time = "2026-06-03T19:17:33.208Z" }, +] + +[[package]] +name = "openai-codex-cli-bin" +version = "0.137.0a4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/60/af73ef1676cd477fa83ed4b889bf3b57c63c47dd87025b2cc4262793cff6/openai_codex_cli_bin-0.137.0a4-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:b33c3917e0b58d527ee11a11a78ad390f7d8e6aa25577dd21665ab3c8bf5cf9a", size = 94300191, upload-time = "2026-06-03T18:44:36.312Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/d1a5f8c87176e00ef6a85798794f4530f5eb04e5a1a13468b5b3c3a361f9/openai_codex_cli_bin-0.137.0a4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3d0f0bc5becc88c61952fbfa9bd792ac9d74fa78b3a6bd40f545b612048b07eb", size = 83924479, upload-time = "2026-06-03T18:44:40.854Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3c/fc00bcdc0c302208317d5eb1d0bfaab3024f351cd0121400f19baa6b19aa/openai_codex_cli_bin-0.137.0a4-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:2f1656339e2736868c4cce59f6d9e5c633879123687169b03b1137d42bf2c11a", size = 83363315, upload-time = "2026-06-03T18:44:44.851Z" }, + { url = "https://files.pythonhosted.org/packages/ec/09/39362e944ebeb12fcbfb86881fbb4dd6e806f77f7541c1f1f993bb9351a0/openai_codex_cli_bin-0.137.0a4-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:6454f838d44c56c1ed07a29b391fa412785e5dd2ffd06db0b62e62478c19bb64", size = 90611239, upload-time = "2026-06-03T18:44:49.338Z" }, + { url = "https://files.pythonhosted.org/packages/fa/38/87b1247fdfe95cddce7f7fe8331d6843cf037e14292c0f5004e23247133b/openai_codex_cli_bin-0.137.0a4-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:f5ae7401d00c65d56a75d9645d7bf87d809566a12d238e4b2a8b328a02f2316e", size = 83363315, upload-time = "2026-06-03T18:44:53.428Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c4/3c693ad07e587f6b3a28128c417f2e831d81a40cdbd85c0e5f0f36aaff82/openai_codex_cli_bin-0.137.0a4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:3dcec1e649448be498d6e7ec0e1f71dca83efa76063d90890dafb41e987069b7", size = 90611238, upload-time = "2026-06-03T18:44:57.612Z" }, + { url = "https://files.pythonhosted.org/packages/9e/26/81e037066b9b8d312a6f9e09015e452ce17630d5ab88e02a4c1d9503e4e8/openai_codex_cli_bin-0.137.0a4-py3-none-win_amd64.whl", hash = "sha256:9e13bf68e18e36bd3a0efd51213281c83e9f6ec22bdb7a45bd2e0211822733a9", size = 94744969, upload-time = "2026-06-03T18:45:02.23Z" }, + { url = "https://files.pythonhosted.org/packages/0d/a3/952bc2a5d62373a51fea161effe3b338b3417c2f6e65fe467ed91b205e2b/openai_codex_cli_bin-0.137.0a4-py3-none-win_arm64.whl", hash = "sha256:5ec4303ca2dcb5f838e0de3ca7f44050b6bcdd41d281a178c3a1420a985a515d", size = 86963504, upload-time = "2026-06-03T18:45:07.131Z" }, +] + [[package]] name = "orjson" version = "3.11.9"