From fe9d287c239529ab62dc000aec464fb85f2526f4 Mon Sep 17 00:00:00 2001 From: Alishahryar1 Date: Tue, 16 Jun 2026 18:47:41 -0700 Subject: [PATCH] Add Codex model picker catalog --- ARCHITECTURE.md | 11 ++ README.md | 8 +- cli/adapters/codex.py | 5 + cli/codex_model_catalog.py | 186 ++++++++++++++++++++++++++ cli/entrypoints.py | 64 ++++++++- config/paths.py | 7 + pyproject.toml | 2 +- tests/cli/test_codex_model_catalog.py | 171 +++++++++++++++++++++++ tests/cli/test_entrypoints.py | 91 +++++++++++++ uv.lock | 2 +- 10 files changed, 541 insertions(+), 6 deletions(-) create mode 100644 cli/codex_model_catalog.py create mode 100644 tests/cli/test_codex_model_catalog.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b7e47b4c..65e7a1df 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -139,6 +139,7 @@ defines it, that dotenv value replaces a stale inherited shell token. - config directory: `~/.fcc`; - managed env file: `~/.fcc/.env`; +- generated Codex model catalog: `~/.fcc/codex-model-catalog.json`; - agent workspace: `~/.fcc/agent_workspace`; - server log: `~/.fcc/logs/server.log`. @@ -239,6 +240,12 @@ Provider model discovery is app-scoped through `ProviderRegistry`, which caches model IDs and optional thinking capability metadata for the model-list route and admin status. +Codex-specific model picker shaping stays out of this route. `fcc-codex` fetches +the same `/v1/models` response at launch, converts FCC gateway IDs into +provider-selectable Codex slugs, writes `~/.fcc/codex-model-catalog.json`, and +passes it as `model_catalog_json`. Codex users open the native picker with +`/model`; FCC does not implement a proxy-level `/models` alias. + ## Provider Architecture Provider metadata is neutral and centralized in @@ -378,6 +385,10 @@ adapter: - `fcc-codex` strips official OpenAI and Codex credential variables. - It creates an ephemeral `fcc` model provider with `wire_api = "responses"` and a base URL pointing at the local proxy `/v1` path. +- After proxy health succeeds, it fetches `/v1/models`, writes a generated Codex + `model_catalog_json` file under `~/.fcc/`, and injects that path so Codex's + native `/model` picker lists FCC provider slugs. Catalog generation is + fail-open: launch continues with a warning if the catalog cannot be prepared. - It stores the proxy auth token in `FCC_CODEX_API_KEY` for Codex to read. - Managed task invocations use Codex JSON output and map Responses events into the messaging parser event shape. diff --git a/README.md b/README.md index a4001739..44885893 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ Free Claude Code routes Anthropic Messages API traffic from Claude Code (CLI and - 17 provider backends: NVIDIA NIM, OpenRouter, Google AI Studio (Gemini), DeepSeek, Mistral La Plateforme, Mistral Codestral, OpenCode Zen, OpenCode Go, Wafer, Kimi, Cerebras Inference, Groq, Fireworks AI, Z.ai, LM Studio, llama.cpp, and Ollama. - Per-model routing for Claude Code: send Opus, Sonnet, Haiku, and fallback traffic to different providers. - Native Claude Code `/model` picker support through the proxy's `/v1/models` endpoint (see [Model Picker](#model-picker)). +- Native Codex `/model` picker support when launched through `fcc-codex`, using a generated local model catalog. - Streaming, tool use, reasoning/thinking block handling, and local request optimizations. - Optional Discord or Telegram bot wrapper for remote Claude Code sessions. - Optional Usage through the Claude Code VS Code extension. @@ -123,7 +124,7 @@ fcc-claude fcc-codex ``` -`fcc-codex` reads the same port and auth token, registers an ephemeral `fcc` model provider that points at the local proxy's `/v1/responses` endpoint, sets `FCC_CODEX_API_KEY` from the Admin UI auth token, strips official `OPENAI_*` credentials from the child environment, and then launches the real `codex` command. Pass through Codex args as usual, for example `fcc-codex exec "hello"`. +`fcc-codex` reads the same port and auth token, registers an ephemeral `fcc` model provider that points at the local proxy's `/v1/responses` endpoint, generates a Codex model catalog from the proxy's `/v1/models` response, sets `FCC_CODEX_API_KEY` from the Admin UI auth token, strips official `OPENAI_*` credentials from the child environment, and then launches the real `codex` command. Type `/model` inside Codex to open its native picker. Pass through Codex args as usual, for example `fcc-codex exec "hello"`. ## Choose A Provider @@ -357,8 +358,9 @@ The installer provisions Codex when it is missing (`npm install -g @openai/codex - `model_providers.fcc.base_url=http://127.0.0.1:/v1` - `model_providers.fcc.env_key=FCC_CODEX_API_KEY` - `model_providers.fcc.wire_api=responses` +- `model_catalog_json=~/.fcc/codex-model-catalog.json` -The Admin UI auth token is reused as `FCC_CODEX_API_KEY`. Official OpenAI credentials are stripped from the child environment so traffic stays on the local proxy. +The Admin UI auth token is reused as `FCC_CODEX_API_KEY`. Official OpenAI credentials are stripped from the child environment so traffic stays on the local proxy. The generated model catalog lets Codex's native `/model` picker list provider-selectable FCC model slugs. If the catalog cannot be fetched or written, `fcc-codex` warns and still launches without picker injection. **Advanced manual setup** @@ -586,7 +588,7 @@ Run them in that order before pushing. CI enforces the same checks. - `fcc-server`: starts the proxy with configured host and port. - `fcc-init`: optional advanced scaffold for `~/.fcc/.env`; prefer the **Admin UI** for normal configuration. - `fcc-claude`: launches Claude Code with the configured local proxy URL, auth token, model discovery flag, and a 190k `CLAUDE_CODE_AUTO_COMPACT_WINDOW` for auto-compaction. -- `fcc-codex`: launches Codex with ephemeral `fcc` provider config pointing at the local proxy's `/v1/responses` endpoint and `FCC_CODEX_API_KEY` from the Admin UI auth token. +- `fcc-codex`: launches Codex with ephemeral `fcc` provider config pointing at the local proxy's `/v1/responses` endpoint, a generated native `/model` picker catalog, and `FCC_CODEX_API_KEY` from the Admin UI auth token. - `free-claude-code`: compatibility alias for `fcc-server`. ### 5. Extending diff --git a/cli/adapters/codex.py b/cli/adapters/codex.py index 13f083d9..25d8adad 100644 --- a/cli/adapters/codex.py +++ b/cli/adapters/codex.py @@ -163,6 +163,11 @@ class CodexCliAdapter: env[_CODEX_AUTH_ENV_KEY] = auth_token.strip() or "fcc-no-auth" return env + def build_model_catalog_config_args(self, catalog_path: str) -> list[str]: + """Return Codex config args for a generated model catalog.""" + + return ["-c", _toml_assignment("model_catalog_json", catalog_path)] + def _task_env( self, *, diff --git a/cli/codex_model_catalog.py b/cli/codex_model_catalog.py new file mode 100644 index 00000000..604bdd00 --- /dev/null +++ b/cli/codex_model_catalog.py @@ -0,0 +1,186 @@ +"""Build Codex model catalogs from the FCC model-list route.""" + +from __future__ import annotations + +import json +import uuid +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from api.gateway_model_ids import ( + GATEWAY_MODEL_ID_PREFIX, + NO_THINKING_GATEWAY_MODEL_ID_PREFIX, +) +from config.provider_ids import SUPPORTED_PROVIDER_IDS + +SUPPORTED_REASONING_LEVELS = [ + {"effort": "low", "description": "Fast responses with lighter reasoning"}, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks", + }, + {"effort": "high", "description": "Greater reasoning depth for complex problems"}, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems", + }, +] + +CODEX_BASE_INSTRUCTIONS = ( + "You are Codex, a coding agent. Help the user understand, modify, test, " + "and review code in their workspace. Follow the user's instructions, use " + "tools when needed, and communicate concise progress and verification." +) + + +@dataclass(frozen=True, slots=True) +class _CatalogCandidate: + slug: str + provider_model_ref: str + display_name: str + force_no_thinking: bool + + +def build_codex_model_catalog(models_response: Mapping[str, Any]) -> dict[str, Any]: + """Convert FCC `/v1/models` data into Codex `model_catalog_json` payload.""" + + candidates = list(_catalog_candidates(models_response)) + normal_provider_refs = { + candidate.provider_model_ref + for candidate in candidates + if not candidate.force_no_thinking + } + models: list[dict[str, Any]] = [] + seen_slugs: set[str] = set() + + for candidate in candidates: + if ( + candidate.force_no_thinking + and candidate.provider_model_ref in normal_provider_refs + ): + continue + if candidate.slug in seen_slugs: + continue + seen_slugs.add(candidate.slug) + models.append(_codex_catalog_entry(candidate, priority=len(models))) + + return {"models": models} + + +def write_codex_model_catalog(catalog_path: Path, catalog: Mapping[str, Any]) -> None: + """Atomically write a Codex model catalog JSON file.""" + + catalog_path.parent.mkdir(parents=True, exist_ok=True) + temp_path = catalog_path.with_name(f".{catalog_path.name}.{uuid.uuid4().hex}.tmp") + temp_path.write_text( + json.dumps(catalog, ensure_ascii=True, indent=2) + "\n", + encoding="utf-8", + ) + temp_path.replace(catalog_path) + + +def _catalog_candidates( + models_response: Mapping[str, Any], +) -> list[_CatalogCandidate]: + data = models_response.get("data") + if not isinstance(data, list): + return [] + + candidates: list[_CatalogCandidate] = [] + for item in data: + if not isinstance(item, Mapping): + continue + model_id = _string_value(item.get("id")) + if model_id is None: + continue + candidate = _candidate_from_model_id( + model_id, + display_name=_string_value(item.get("display_name")) or model_id, + ) + if candidate is not None: + candidates.append(candidate) + return candidates + + +def _candidate_from_model_id( + model_id: str, *, display_name: str +) -> _CatalogCandidate | None: + prefix, separator, remainder = model_id.partition("/") + if not separator: + return None + + if prefix == GATEWAY_MODEL_ID_PREFIX: + if not _is_provider_model_ref(remainder): + return None + return _CatalogCandidate( + slug=remainder, + provider_model_ref=remainder, + display_name=display_name, + force_no_thinking=False, + ) + + if prefix == NO_THINKING_GATEWAY_MODEL_ID_PREFIX: + if not _is_provider_model_ref(remainder): + return None + return _CatalogCandidate( + slug=model_id, + provider_model_ref=remainder, + display_name=display_name, + force_no_thinking=True, + ) + + if prefix in SUPPORTED_PROVIDER_IDS and remainder: + return _CatalogCandidate( + slug=model_id, + provider_model_ref=model_id, + display_name=display_name, + force_no_thinking=False, + ) + + return None + + +def _codex_catalog_entry( + candidate: _CatalogCandidate, *, priority: int +) -> dict[str, Any]: + return { + "slug": candidate.slug, + "display_name": candidate.display_name, + "description": "Free Claude Code provider model", + "default_reasoning_level": "medium", + "supported_reasoning_levels": SUPPORTED_REASONING_LEVELS, + "shell_type": "shell_command", + "visibility": "list", + "supported_in_api": True, + "priority": priority, + "additional_speed_tiers": [], + "service_tiers": [], + "base_instructions": CODEX_BASE_INSTRUCTIONS, + "supports_reasoning_summaries": True, + "default_reasoning_summary": "none", + "support_verbosity": True, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "truncation_policy": {"mode": "tokens", "limit": 10000}, + "supports_parallel_tool_calls": True, + "supports_image_detail_original": True, + "context_window": 200000, + "max_context_window": 200000, + "effective_context_window_percent": 95, + "experimental_supported_tools": [], + "input_modalities": ["text"], + "supports_search_tool": True, + "use_responses_lite": False, + } + + +def _is_provider_model_ref(value: str) -> bool: + provider_id, separator, provider_model = value.partition("/") + return bool(separator and provider_model and provider_id in SUPPORTED_PROVIDER_IDS) + + +def _string_value(value: Any) -> str | None: + return value if isinstance(value, str) else None diff --git a/cli/entrypoints.py b/cli/entrypoints.py index d1cc823d..dbbf1ddf 100644 --- a/cli/entrypoints.py +++ b/cli/entrypoints.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import os import shutil import subprocess @@ -21,13 +22,22 @@ from api.app import GracefulLifespanApp, create_app from cli.adapters.base import ClientCliAdapter from cli.adapters.claude import CLAUDE_CLI_ADAPTER from cli.adapters.codex import CODEX_CLI_ADAPTER +from cli.codex_model_catalog import ( + build_codex_model_catalog, + write_codex_model_catalog, +) from cli.process_registry import ( kill_all_best_effort, kill_pid_tree_best_effort, register_pid, unregister_pid, ) -from config.paths import config_dir_path, legacy_env_paths, managed_env_path +from config.paths import ( + codex_model_catalog_path, + config_dir_path, + legacy_env_paths, + managed_env_path, +) from config.settings import Settings, get_settings PROXY_PREFLIGHT_PATH = "/health" @@ -248,6 +258,9 @@ def _launch_client_cli( settings=settings, proxy_root_url=proxy_root_url, ) + catalog_args = _codex_model_catalog_config_args(adapter, proxy_root_url, settings) + if catalog_args: + command = [command[0], *catalog_args, *command[1:]] env = adapter.build_launcher_env( proxy_root_url=proxy_root_url, auth_token=settings.anthropic_auth_token, @@ -276,3 +289,52 @@ def _launch_client_cli( unregister_pid(process.pid) raise SystemExit(return_code) + + +def _codex_model_catalog_config_args( + adapter: ClientCliAdapter, proxy_root_url: str, settings: Settings +) -> list[str]: + if adapter.id != CODEX_CLI_ADAPTER.id: + return [] + + try: + models_response = _fetch_proxy_models_response( + proxy_root_url, settings.anthropic_auth_token + ) + catalog = build_codex_model_catalog(models_response) + models = catalog.get("models") + if not isinstance(models, list) or not models: + print( + "Free Claude Code warning: Codex model catalog is empty; " + "launching without model picker catalog.", + file=sys.stderr, + ) + return [] + catalog_path = codex_model_catalog_path() + write_codex_model_catalog(catalog_path, catalog) + except Exception as exc: + print( + "Free Claude Code warning: could not prepare Codex model catalog " + f"({exc}); launching without model picker catalog.", + file=sys.stderr, + ) + return [] + + return CODEX_CLI_ADAPTER.build_model_catalog_config_args(str(catalog_path)) + + +def _fetch_proxy_models_response( + proxy_root_url: str, auth_token: str +) -> dict[str, object]: + url = f"{proxy_root_url.rstrip('/')}/v1/models" + headers: dict[str, str] = {} + if token := auth_token.strip(): + headers["X-API-Key"] = token + + request = Request(url, headers=headers, method="GET") + with urlopen(request, timeout=PROXY_PREFLIGHT_TIMEOUT_SECONDS) as response: + payload = json.loads(response.read().decode("utf-8")) + + if not isinstance(payload, dict): + raise ValueError("model list response was not a JSON object") + return payload diff --git a/config/paths.py b/config/paths.py index 4cf79464..02bb216c 100644 --- a/config/paths.py +++ b/config/paths.py @@ -9,6 +9,7 @@ LEGACY_XDG_CONFIG_DIRNAME = ".config" CLAUDE_WORKSPACE_DIRNAME = "agent_workspace" FCC_LOGS_DIRNAME = "logs" SERVER_LOG_FILENAME = "server.log" +CODEX_MODEL_CATALOG_FILENAME = "codex-model-catalog.json" def config_dir_path() -> Path: @@ -43,3 +44,9 @@ def server_log_path() -> Path: """Return the canonical server log path.""" return config_dir_path() / FCC_LOGS_DIRNAME / SERVER_LOG_FILENAME + + +def codex_model_catalog_path() -> Path: + """Return the generated Codex model catalog path.""" + + return config_dir_path() / CODEX_MODEL_CATALOG_FILENAME diff --git a/pyproject.toml b/pyproject.toml index b3604143..a9fea79d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "free-claude-code" -version = "2.1.4" +version = "2.2.0" description = "Middleware between Claude Code CLI (Anthropic API) and NVIDIA NIM" readme = "README.md" requires-python = ">=3.14.0" diff --git a/tests/cli/test_codex_model_catalog.py b/tests/cli/test_codex_model_catalog.py new file mode 100644 index 00000000..391a60ed --- /dev/null +++ b/tests/cli/test_codex_model_catalog.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +from collections.abc import Mapping +from pathlib import Path +from typing import Any, cast + +import pytest + +from cli.codex_model_catalog import ( + build_codex_model_catalog, + write_codex_model_catalog, +) + + +def _models_payload(*model_ids: str) -> dict[str, Any]: + return { + "data": [ + { + "id": model_id, + "display_name": model_id.replace("anthropic/", ""), + } + for model_id in model_ids + ] + } + + +def _catalog_models(catalog: Mapping[str, Any]) -> list[Mapping[str, Any]]: + models = catalog["models"] + assert isinstance(models, list) + catalog_models: list[Mapping[str, Any]] = [] + for model in models: + assert isinstance(model, Mapping) + catalog_models.append(cast(Mapping[str, Any], model)) + return catalog_models + + +def _slugs(catalog: Mapping[str, Any]) -> list[str]: + slugs: list[str] = [] + for model in _catalog_models(catalog): + slug = model["slug"] + assert isinstance(slug, str) + slugs.append(slug) + return slugs + + +def test_codex_catalog_converts_configured_and_cached_models_to_direct_slugs() -> None: + catalog = build_codex_model_catalog( + _models_payload( + "anthropic/nvidia_nim/nvidia/nemotron-3-super", + "claude-3-freecc-no-thinking/nvidia_nim/nvidia/nemotron-3-super", + "anthropic/open_router/meta-llama/llama-3.3-70b", + "claude-3-freecc-no-thinking/open_router/meta-llama/llama-3.3-70b", + ) + ) + + assert _slugs(catalog) == [ + "nvidia_nim/nvidia/nemotron-3-super", + "open_router/meta-llama/llama-3.3-70b", + ] + model = _catalog_models(catalog)[0] + assert { + "slug", + "display_name", + "description", + "default_reasoning_level", + "supported_reasoning_levels", + "shell_type", + "visibility", + "supported_in_api", + "priority", + "additional_speed_tiers", + "service_tiers", + } <= set(model) + + +def test_codex_catalog_excludes_claude_compatibility_model_ids() -> None: + catalog = build_codex_model_catalog( + _models_payload( + "claude-opus-4-20250514", + "claude-3-haiku-20240307", + "anthropic/nvidia_nim/provider-model", + ) + ) + + assert _slugs(catalog) == ["nvidia_nim/provider-model"] + + +def test_codex_catalog_skips_no_thinking_duplicate_when_normal_slug_exists() -> None: + catalog = build_codex_model_catalog( + _models_payload( + "claude-3-freecc-no-thinking/nvidia_nim/provider-model", + "anthropic/nvidia_nim/provider-model", + ) + ) + + assert _slugs(catalog) == ["nvidia_nim/provider-model"] + + +def test_codex_catalog_preserves_no_thinking_only_entries_for_routing() -> None: + catalog = build_codex_model_catalog( + _models_payload("claude-3-freecc-no-thinking/open_router/plain-model") + ) + + assert _slugs(catalog) == ["claude-3-freecc-no-thinking/open_router/plain-model"] + + +def test_codex_catalog_ordering_and_priorities_are_deterministic() -> None: + catalog = build_codex_model_catalog( + _models_payload( + "anthropic/gemini/models/gemini-test", + "anthropic/nvidia_nim/nvidia/test", + "anthropic/gemini/models/gemini-test", + "anthropic/open_router/provider/test", + ) + ) + + models = _catalog_models(catalog) + assert _slugs(catalog) == [ + "gemini/models/gemini-test", + "nvidia_nim/nvidia/test", + "open_router/provider/test", + ] + assert [model["priority"] for model in models] == [0, 1, 2] + + +def test_codex_catalog_accepts_future_direct_provider_slugs() -> None: + catalog = build_codex_model_catalog( + _models_payload( + "nvidia_nim/provider-model", + "anthropic/open_router/provider-model", + ) + ) + + assert _slugs(catalog) == [ + "nvidia_nim/provider-model", + "open_router/provider-model", + ] + + +def test_generated_catalog_schema_is_accepted_by_installed_codex( + tmp_path: Path, +) -> None: + codex_binary = shutil.which("codex") + if codex_binary is None: + pytest.skip("Codex CLI is not installed") + + catalog_path = tmp_path / "codex-model-catalog.json" + write_codex_model_catalog( + catalog_path, + build_codex_model_catalog(_models_payload("anthropic/nvidia_nim/test-model")), + ) + + result = subprocess.run( + [ + codex_binary, + "debug", + "models", + "-c", + f"model_catalog_json={json.dumps(str(catalog_path))}", + ], + capture_output=True, + check=False, + text=True, + timeout=10, + ) + + assert result.returncode == 0, result.stderr + assert "nvidia_nim/test-model" in result.stdout diff --git a/tests/cli/test_entrypoints.py b/tests/cli/test_entrypoints.py index 534790a6..383d99cb 100644 --- a/tests/cli/test_entrypoints.py +++ b/tests/cli/test_entrypoints.py @@ -1,9 +1,12 @@ """Tests for cli/entrypoints.py — fcc-init scaffolding logic.""" +import json import tomllib from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock, patch +from urllib.error import URLError +from urllib.request import Request import pytest @@ -42,6 +45,20 @@ def _run_init(tmp_home: Path) -> tuple[str, Path]: return "\n".join(printed), env_file +class _JsonResponse: + def __init__(self, payload: dict[str, object]) -> None: + self._payload = payload + + def __enter__(self) -> _JsonResponse: + return self + + def __exit__(self, *_args: object) -> None: + return None + + def read(self) -> bytes: + return json.dumps(self._payload).encode("utf-8") + + def test_init_creates_env_file(tmp_path: Path) -> None: """init() creates .env from the bundled template when it doesn't exist yet.""" output, env_file = _run_init(tmp_path) @@ -341,6 +358,7 @@ def test_launch_claude_passes_args_and_child_env( patch("cli.entrypoints.get_settings", return_value=settings), patch("cli.entrypoints._preflight_proxy", return_value=None), patch("cli.entrypoints.shutil.which", return_value="resolved-claude.cmd"), + patch("cli.entrypoints.urlopen") as urlopen, patch("cli.entrypoints.subprocess.Popen") as popen, patch("cli.entrypoints.register_pid") as register_pid, patch("cli.entrypoints.unregister_pid") as unregister_pid, @@ -362,10 +380,12 @@ def test_launch_claude_passes_args_and_child_env( assert child_env["KEEP_ME"] == "yes" register_pid.assert_called_once_with(12345) unregister_pid.assert_called_once_with(12345) + urlopen.assert_not_called() def test_launch_codex_passes_responses_config_and_child_env( monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, ) -> None: from cli.entrypoints import launch_codex @@ -373,11 +393,37 @@ def test_launch_codex_passes_responses_config_and_child_env( monkeypatch.setenv("OPENAI_BASE_URL", "https://api.openai.com/v1") monkeypatch.setenv("CODEX_HOME", "keep-home") settings = _launcher_settings(port=9191, token="proxy-token") + catalog_path = tmp_path / "codex-model-catalog.json" + requests: list[Request] = [] + + def fake_urlopen(request: Request, *, timeout: float) -> _JsonResponse: + requests.append(request) + assert timeout == 1.5 + return _JsonResponse( + { + "data": [ + { + "id": "anthropic/nvidia_nim/provider-model", + "display_name": "NVIDIA model", + }, + { + "id": ("claude-3-freecc-no-thinking/nvidia_nim/provider-model"), + "display_name": "NVIDIA model (no thinking)", + }, + { + "id": "claude-opus-4-20250514", + "display_name": "Claude Opus 4", + }, + ] + } + ) with ( patch("cli.entrypoints.get_settings", return_value=settings), patch("cli.entrypoints._preflight_proxy", return_value=None), patch("cli.entrypoints.shutil.which", return_value="resolved-codex.cmd"), + patch("cli.entrypoints.codex_model_catalog_path", return_value=catalog_path), + patch("cli.entrypoints.urlopen", side_effect=fake_urlopen), patch("cli.entrypoints.subprocess.Popen") as popen, patch("cli.entrypoints.register_pid") as register_pid, patch("cli.entrypoints.unregister_pid") as unregister_pid, @@ -394,7 +440,17 @@ def test_launch_codex_passes_responses_config_and_child_env( assert 'model_provider="fcc"' in command assert 'model_providers.fcc.base_url="http://127.0.0.1:9191/v1"' in command assert 'model_providers.fcc.wire_api="responses"' in command + assert f"model_catalog_json={json.dumps(str(catalog_path))}" in command assert command[-2:] == ["exec", "hello"] + assert len(requests) == 1 + request = requests[0] + assert request.full_url == "http://127.0.0.1:9191/v1/models" + headers = {key.lower(): value for key, value in request.header_items()} + assert headers["x-api-key"] == "proxy-token" + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + assert [model["slug"] for model in catalog["models"]] == [ + "nvidia_nim/provider-model" + ] child_env = popen.call_args.kwargs["env"] assert child_env["FCC_CODEX_API_KEY"] == "proxy-token" assert child_env["CODEX_HOME"] == "keep-home" @@ -404,6 +460,41 @@ def test_launch_codex_passes_responses_config_and_child_env( unregister_pid.assert_called_once_with(12345) +def test_launch_codex_catalog_failure_warns_and_continues( + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + from cli.entrypoints import launch_codex + + settings = _launcher_settings(port=9191, token="proxy-token") + + with ( + patch("cli.entrypoints.get_settings", return_value=settings), + patch("cli.entrypoints._preflight_proxy", return_value=None), + patch("cli.entrypoints.shutil.which", return_value="resolved-codex.cmd"), + patch( + "cli.entrypoints.codex_model_catalog_path", + return_value=tmp_path / "codex-model-catalog.json", + ), + patch("cli.entrypoints.urlopen", side_effect=URLError("boom")), + patch("cli.entrypoints.subprocess.Popen") as popen, + patch("cli.entrypoints.register_pid"), + patch("cli.entrypoints.unregister_pid"), + pytest.raises(SystemExit) as exc_info, + ): + process = popen.return_value + process.pid = 12345 + process.wait.return_value = 0 + launch_codex(["exec", "hello"]) + + assert exc_info.value.code == 0 + command = popen.call_args.args[0] + assert not any("model_catalog_json=" in arg for arg in command) + captured = capsys.readouterr() + assert "could not prepare Codex model catalog" in captured.err + assert "launching without model picker catalog" in captured.err + + def test_launch_claude_keyboard_interrupt_kills_child_tree() -> None: from cli.entrypoints import launch_claude diff --git a/uv.lock b/uv.lock index 4687c7f8..b149199d 100644 --- a/uv.lock +++ b/uv.lock @@ -561,7 +561,7 @@ wheels = [ [[package]] name = "free-claude-code" -version = "2.1.4" +version = "2.2.0" source = { editable = "." } dependencies = [ { name = "aiohttp" },