mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-07-03 14:05:26 +02:00
Add Codex model picker catalog
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user