Add Codex model picker catalog

This commit is contained in:
Alishahryar1
2026-06-16 18:47:41 -07:00
parent a437df598e
commit fe9d287c23
10 changed files with 541 additions and 6 deletions
+11
View File
@@ -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.
+5 -3
View File
@@ -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:<PORT>/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
+5
View File
@@ -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,
*,
+186
View File
@@ -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
+63 -1
View File
@@ -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
+7
View File
@@ -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
+1 -1
View File
@@ -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"
+171
View File
@@ -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
+91
View File
@@ -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
Generated
+1 -1
View File
@@ -561,7 +561,7 @@ wheels = [
[[package]]
name = "free-claude-code"
version = "2.1.4"
version = "2.2.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },