项目文件夹

文件
Ali Khokhar 1a476562fc Make provider model discovery the sole catalog owner (#1221)
## Problem

Startup model-list I/O was split between a non-enforcing
configured-model validator and the real discovery path. Both queried
providers and populated the same cache even though synchronous cache
warm-up was the validator's only required effect.

## Changes

| Before | After |
| --- | --- |
| Validation and discovery independently resolved providers, queried
model lists, and cached results. | `ProviderModelDiscovery` solely owns
model-list queries, failure reporting, and cache population. |
| Startup ran configured-model validation and then launched discovery. |
Startup synchronously warms referenced providers through discovery, then
launches the existing missing-provider background pass. |
| Absence from a provider catalog produced a non-enforcing missing-model
warning. | Provider catalogs remain discovery metadata and provider
execution remains authoritative. |
| Configured model references retained environment-source metadata for
validator diagnostics. | Configured model references retain only routing
data. |
| Successful startup providers could be represented by two separate
subsystems. | Focused tests enforce concurrent warm-up, single
successful queries, failed-query eligibility, and warm-before-background
ordering. |
| The package version was `4.11.5`. | The package version is `4.11.6`
with an updated lockfile. |

<!-- greptile_comment -->

<details open><summary><h3>Greptile Summary</h3></summary>

This PR makes provider discovery the sole owner of model catalogs. The
main changes are:

- Warms routed provider catalogs before background discovery starts.
- Reuses successful warm results while retrying failed providers.
- Removes configured-model catalog validation and source metadata.
- Moves query-failure reporting into the discovery module.
- Updates focused tests, architecture docs, and package version.
</details>

<h3>Confidence Score: 5/5</h3>

This looks safe to merge.

No blocking issues found in the changed code. Provider failures remain
isolated and eligible for background retry. Successful warm results are
not queried again by the missing-provider pass. Lease release and
startup cleanup remain protected by existing control flow.

None.

<details><summary><h3><a href="https://www.greptile.com/trex"><img
alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="20" align="absmiddle"></a> T-Rex Logs</h3></summary>

**What T-Rex did**
- \`test\_runtime\_warm\_queries\_referenced\_providers\_concurrently\`,
\`test\_startup\_discovery\_queries\_each\_successful\_provider\_once\`,
\`test\_failed\_startup\_warm\_remains\_eligible\_for\_background\_refresh\`,
\`test\_runtime\_warm\_caches\_all\_referenced\_provider\_models\`, and
\`test\_runtime\_startup\_warms\_catalog\_before\_background\_refresh\`
all passed.
- The complete verbose HEAD run, including command, working directory,
exit code, test nodes, and summary, is preserved in
\`trex-artifacts/provider-discovery-startup-validation.log\` and the
paired after artifact.

<a
href="https://app.greptile.com/trex/runs/15203372/artifacts"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img
alt="View all artifacts"
src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a>

<sub><a href="https://www.greptile.com/trex"><img alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="14" align="absmiddle"></a> Ran code and verified through
T-Rex</sub>
</details>

<details open><summary><h3>Important Files Changed</h3></summary>

| Filename | Overview |
|----------|----------|
| src/free_claude_code/providers/runtime/discovery.py | Centralizes
catalog queries, failure reporting, referenced-provider warming, and
cache population. |
| src/free_claude_code/runtime/provider_manager.py | Replaces validation
with discovery-based warming under a generation lease. |
| src/free_claude_code/runtime/application.py | Warms referenced
catalogs before launching the missing-provider background pass. |
| src/free_claude_code/config/model_refs.py | Removes validation-only
source metadata while preserving deterministic deduplication. |
| tests/providers/test_model_discovery.py | Covers concurrent warming,
partial failures, retry eligibility, and single successful queries. |

</details>

<details open><summary><h3>Sequence Diagram</h3></summary>

<a href="#gh-light-mode-only">

```mermaid
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant App as ApplicationRuntime
participant Manager as ProviderRuntimeManager
participant Discovery as ProviderModelDiscovery
participant Provider
participant Cache as ProviderModelCache

App->>Manager: warm_referenced_model_cache()
Manager->>Discovery: warm referenced providers
par Provider queries
    Discovery->>Provider: list_model_infos()
end
Provider-->>Discovery: metadata or failure
Discovery->>Cache: cache successful results
Discovery-->>Manager: refresh result
Manager-->>App: warm complete
App->>Manager: start_model_list_refresh()
Manager->>Discovery: refresh only missing providers
Discovery->>Cache: cache remaining catalogs
```

</a>
<a href="#gh-dark-mode-only">

```mermaid
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant App as ApplicationRuntime
participant Manager as ProviderRuntimeManager
participant Discovery as ProviderModelDiscovery
participant Provider
participant Cache as ProviderModelCache

App->>Manager: warm_referenced_model_cache()
Manager->>Discovery: warm referenced providers
par Provider queries
    Discovery->>Provider: list_model_infos()
end
Provider-->>Discovery: metadata or failure
Discovery->>Cache: cache successful results
Discovery-->>Manager: refresh result
Manager-->>App: warm complete
App->>Manager: start_model_list_refresh()
Manager->>Discovery: refresh only missing providers
Discovery->>Cache: cache remaining catalogs
```

</a>
</details>

<sub>Reviews (1): Last reviewed commit: ["Unify provider model discovery
ownership"](https://github.com/alishahryar1/free-claude-code/commit/33f68e4338fd2326a7d8fd3e7e28246b47fa4210)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=45862060)</sub>

<!-- /greptile_comment -->
2026-07-20 23:53:55 -07:00

1163 行
47 KiB
Python

"""Tests for config/settings.py and config/nim.py"""
from typing import Any, cast
import pytest
from pydantic import ValidationError
from free_claude_code.config.constants import (
ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS,
HTTP_CONNECT_TIMEOUT_DEFAULT,
)
from free_claude_code.config.env_files import (
ANTHROPIC_AUTH_TOKEN_ENV,
process_env_key_is_effective,
)
from free_claude_code.config.model_refs import (
configured_chat_model_refs,
parse_model_name,
parse_provider_type,
)
from free_claude_code.config.nim import NimSettings
from free_claude_code.config.paths import messaging_state_dir_path
from free_claude_code.config.reasoning import ReasoningPreference
class TestSettings:
"""Test Settings configuration."""
def test_settings_loads(self):
"""Ensure Settings can be instantiated."""
from free_claude_code.config.settings import Settings
settings = Settings()
assert settings is not None
def test_default_values(self, monkeypatch):
"""Test default values are set and have correct types."""
from free_claude_code.config.settings import Settings
monkeypatch.delenv("CLAUDE_WORKSPACE", raising=False)
monkeypatch.delenv("MODEL", raising=False)
monkeypatch.delenv("VERTEX_LOCATION", raising=False)
monkeypatch.delenv("HTTP_READ_TIMEOUT", raising=False)
monkeypatch.delenv("HTTP_CONNECT_TIMEOUT", raising=False)
monkeypatch.setitem(Settings.model_config, "env_file", ())
settings = Settings()
assert settings.model == "nvidia_nim/nvidia/nemotron-3-super-120b-a12b"
assert isinstance(settings.provider_rate_limit, int)
assert isinstance(settings.provider_rate_window, int)
assert isinstance(settings.nim.temperature, float)
assert isinstance(settings.fast_prefix_detection, bool)
assert settings.reasoning_policy is ReasoningPreference.CLIENT
assert settings.http_read_timeout == 120.0
assert settings.http_connect_timeout == HTTP_CONNECT_TIMEOUT_DEFAULT
assert settings.enable_web_server_tools is False
assert settings.log_raw_api_payloads is False
assert settings.log_raw_sse_events is False
assert settings.debug_platform_edits is False
assert settings.debug_subagent_stack is False
assert settings.log_level == "INFO"
assert settings.open_admin_browser is True
assert settings.vertex_location == "global"
def test_open_admin_browser_loads_from_environment(self, monkeypatch):
from free_claude_code.config.settings import Settings
monkeypatch.setenv("FCC_OPEN_BROWSER", "false")
monkeypatch.setitem(Settings.model_config, "env_file", ())
assert Settings().open_admin_browser is False
def test_default_claude_workspace_uses_fcc_home(self, monkeypatch, tmp_path):
"""Unset CLAUDE_WORKSPACE stores agent data under the fixed path helper."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("CLAUDE_WORKSPACE", raising=False)
monkeypatch.setitem(Settings.model_config, "env_file", ())
settings = Settings()
assert messaging_state_dir_path() == tmp_path / ".fcc" / "agent_workspace"
assert not hasattr(settings, "claude_workspace")
def test_server_log_path_uses_fcc_home(self, monkeypatch, tmp_path):
"""The server log location is fixed under ~/.fcc."""
from free_claude_code.config.paths import server_log_path
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
assert server_log_path() == tmp_path / ".fcc" / "logs" / "server.log"
def test_removed_log_file_env_is_ignored(self, monkeypatch):
"""Legacy LOG_FILE values do not affect Settings or block startup."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("LOG_FILE", "custom/server.log")
monkeypatch.setitem(Settings.model_config, "env_file", ())
settings = Settings()
assert not hasattr(settings, "log_file")
def test_stale_zai_base_url_env_is_ignored(self, monkeypatch):
"""Cloud Z.ai endpoint is fixed in provider metadata, not settings."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("ZAI_BASE_URL", "https://custom.zai.invalid/v1")
monkeypatch.setitem(Settings.model_config, "env_file", ())
settings = Settings()
assert not hasattr(settings, "zai_base_url")
def test_blank_claude_workspace_uses_fcc_home(self, monkeypatch, tmp_path):
"""An explicit blank env value does not affect the fixed workspace helper."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.setenv("CLAUDE_WORKSPACE", "")
monkeypatch.setitem(Settings.model_config, "env_file", ())
settings = Settings()
assert messaging_state_dir_path() == tmp_path / ".fcc" / "agent_workspace"
assert not hasattr(settings, "claude_workspace")
def test_explicit_claude_workspace_is_ignored(self, monkeypatch, tmp_path):
"""Custom CLAUDE_WORKSPACE values do not override the fixed workspace helper."""
from free_claude_code.config.settings import Settings
workspace = tmp_path / "custom-workspace"
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.setenv("CLAUDE_WORKSPACE", str(workspace))
monkeypatch.setitem(Settings.model_config, "env_file", ())
settings = Settings()
assert messaging_state_dir_path() == tmp_path / ".fcc" / "agent_workspace"
assert not hasattr(settings, "claude_workspace")
def test_explicit_claude_cli_bin_is_ignored(self, monkeypatch):
"""Custom CLAUDE_CLI_BIN values do not become Settings fields."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("CLAUDE_CLI_BIN", "claude-custom")
monkeypatch.setitem(Settings.model_config, "env_file", ())
settings = Settings()
assert not hasattr(settings, "claude_cli_bin")
assert not hasattr(settings, "codex_cli_bin")
def test_direct_claude_runtime_overrides_are_ignored(self, monkeypatch, tmp_path):
"""Constructor extras cannot add fixed Claude runtime settings."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.setitem(Settings.model_config, "env_file", ())
settings = Settings(
**cast(
Any,
{
"claude_workspace": str(tmp_path / "custom-workspace"),
"claude_cli_bin": "claude-custom",
},
)
)
assert messaging_state_dir_path() == tmp_path / ".fcc" / "agent_workspace"
assert not hasattr(settings, "claude_workspace")
assert not hasattr(settings, "claude_cli_bin")
def test_get_settings_cached(self):
"""Test get_settings returns cached instance."""
from free_claude_code.config.settings import get_settings
s1 = get_settings()
s2 = get_settings()
assert s1 is s2 # Same object (cached)
def test_empty_string_to_none_for_optional_int(self):
"""Test that empty string converts to None for optional int fields."""
from free_claude_code.config.settings import Settings
# Settings should handle NVIDIA_NIM_SEED="" gracefully
settings = Settings()
assert settings.nim.seed is None or isinstance(settings.nim.seed, int)
def test_model_setting(self):
"""Test model setting exists and is a string."""
from free_claude_code.config.settings import Settings
settings = Settings()
assert isinstance(settings.model, str)
assert len(settings.model) > 0
def test_base_url_constant(self):
"""Test NVIDIA_NIM_DEFAULT_BASE is a constant."""
from free_claude_code.config.provider_catalog import NVIDIA_NIM_DEFAULT_BASE
assert NVIDIA_NIM_DEFAULT_BASE == "https://integrate.api.nvidia.com/v1"
def test_lm_studio_base_url_from_env(self, monkeypatch):
"""LM_STUDIO_BASE_URL env var is loaded into settings."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("LM_STUDIO_BASE_URL", "http://custom:5678/v1")
settings = Settings()
assert settings.lm_studio_base_url == "http://custom:5678/v1"
def test_ollama_base_url_defaults_to_root(self, monkeypatch):
"""OLLAMA_BASE_URL keeps the customer-facing Ollama root default."""
from free_claude_code.config.settings import Settings
monkeypatch.delenv("OLLAMA_BASE_URL", raising=False)
monkeypatch.setitem(Settings.model_config, "env_file", ())
settings = Settings()
assert settings.ollama_base_url == "http://localhost:11434"
def test_ollama_base_url_accepts_v1_suffix(self, monkeypatch):
"""The adapter accepts either the root URL or the explicit OpenAI path."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("OLLAMA_BASE_URL", "http://localhost:11434/v1")
assert Settings().ollama_base_url == "http://localhost:11434/v1"
def test_ollama_cloud_api_key_from_env(self, monkeypatch):
from free_claude_code.config.settings import Settings
monkeypatch.setenv("OLLAMA_API_KEY", "ollama-cloud-key")
assert Settings().ollama_api_key == "ollama-cloud-key"
def test_provider_rate_limit_from_env(self, monkeypatch):
"""PROVIDER_RATE_LIMIT env var is loaded into settings."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("PROVIDER_RATE_LIMIT", "20")
settings = Settings()
assert settings.provider_rate_limit == 20
def test_provider_rate_window_from_env(self, monkeypatch):
"""PROVIDER_RATE_WINDOW env var is loaded into settings."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("PROVIDER_RATE_WINDOW", "30")
settings = Settings()
assert settings.provider_rate_window == 30
def test_http_read_timeout_from_env(self, monkeypatch):
"""HTTP_READ_TIMEOUT env var is loaded into settings."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("HTTP_READ_TIMEOUT", "600")
settings = Settings()
assert settings.http_read_timeout == 600.0
def test_http_write_timeout_from_env(self, monkeypatch):
"""HTTP_WRITE_TIMEOUT env var is loaded into settings."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("HTTP_WRITE_TIMEOUT", "20")
settings = Settings()
assert settings.http_write_timeout == 20.0
def test_http_connect_timeout_from_env(self, monkeypatch):
"""HTTP_CONNECT_TIMEOUT env var is loaded into settings."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("HTTP_CONNECT_TIMEOUT", "5")
settings = Settings()
assert settings.http_connect_timeout == 5.0
def test_http_connect_timeout_default_matches_shared_constant(
self, monkeypatch
) -> None:
"""Default must match config.constants (and README / .env.example)."""
from free_claude_code.config.settings import Settings
monkeypatch.delenv("HTTP_CONNECT_TIMEOUT", raising=False)
monkeypatch.setitem(Settings.model_config, "env_file", ())
settings = Settings()
assert settings.http_connect_timeout == HTTP_CONNECT_TIMEOUT_DEFAULT
assert HTTP_CONNECT_TIMEOUT_DEFAULT == 10.0
def test_reasoning_policy_from_env(self, monkeypatch):
"""REASONING_POLICY is loaded as a typed preference."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("REASONING_POLICY", "off")
settings = Settings()
assert settings.reasoning_policy is ReasoningPreference.OFF
def test_root_reasoning_policy_cannot_inherit(self, monkeypatch):
"""Only route overrides may inherit."""
from pydantic import ValidationError
from free_claude_code.config.settings import Settings
monkeypatch.setenv("REASONING_POLICY", "inherit")
with pytest.raises(ValidationError, match="cannot inherit"):
Settings()
def test_wafer_api_key_from_env(self, monkeypatch):
"""WAFER_API_KEY env var is loaded into settings."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("WAFER_API_KEY", "wafer-key")
settings = Settings()
assert settings.wafer_api_key == "wafer-key"
def test_minimax_settings_from_env(self, monkeypatch):
"""MiniMax key and proxy env vars load into settings."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("MINIMAX_API_KEY", "minimax-key")
monkeypatch.setenv("MINIMAX_PROXY", "http://proxy.test:8080")
settings = Settings()
assert settings.minimax_api_key == "minimax-key"
assert settings.minimax_proxy == "http://proxy.test:8080"
def test_cloudflare_settings_from_env(self, monkeypatch):
"""Cloudflare token, account, and proxy env vars load into settings."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("CLOUDFLARE_API_TOKEN", "cf-token")
monkeypatch.setenv("CLOUDFLARE_ACCOUNT_ID", "cf-account")
monkeypatch.setenv("CLOUDFLARE_PROXY", "http://proxy.test:8080")
settings = Settings()
assert settings.cloudflare_api_token == "cf-token"
assert settings.cloudflare_account_id == "cf-account"
assert settings.cloudflare_proxy == "http://proxy.test:8080"
def test_vertex_settings_from_env(self, monkeypatch):
"""Vertex project, location, and proxy env vars load into settings."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("VERTEX_PROJECT_ID", "vertex-project")
monkeypatch.setenv("VERTEX_LOCATION", "us-central1")
monkeypatch.setenv("VERTEX_PROXY", "http://proxy.test:8080")
settings = Settings()
assert settings.vertex_project_id == "vertex-project"
assert settings.vertex_location == "us-central1"
assert settings.vertex_proxy == "http://proxy.test:8080"
def test_vercel_settings_from_env(self, monkeypatch):
"""Vercel AI Gateway key and proxy env vars load into settings."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("AI_GATEWAY_API_KEY", "vercel-key")
monkeypatch.setenv("VERCEL_AI_GATEWAY_PROXY", "http://proxy.test:8080")
settings = Settings()
assert settings.vercel_ai_gateway_api_key == "vercel-key"
assert settings.vercel_ai_gateway_proxy == "http://proxy.test:8080"
def test_bedrock_settings_from_official_environment(self, monkeypatch):
"""Bedrock key, regional base URL, and proxy load into settings."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "bedrock-key")
monkeypatch.setenv(
"BEDROCK_BASE_URL", "https://bedrock-mantle.us-west-2.api.aws/v1"
)
monkeypatch.setenv("BEDROCK_PROXY", "http://proxy.test:8080")
settings = Settings()
assert settings.bedrock_api_key == "bedrock-key"
assert settings.bedrock_base_url == (
"https://bedrock-mantle.us-west-2.api.aws/v1"
)
assert settings.bedrock_proxy == "http://proxy.test:8080"
def test_huggingface_settings_from_env(self, monkeypatch):
"""Hugging Face key and proxy env vars load into settings."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("HUGGINGFACE_API_KEY", "hf-key")
monkeypatch.setenv("HUGGINGFACE_PROXY", "http://proxy.test:8080")
settings = Settings()
assert settings.huggingface_api_key == "hf-key"
assert settings.huggingface_proxy == "http://proxy.test:8080"
assert not hasattr(settings, "hf_token")
def test_cohere_settings_from_env(self, monkeypatch):
"""Cohere key and proxy env vars load into settings."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("COHERE_API_KEY", "cohere-key")
monkeypatch.setenv("COHERE_PROXY", "http://proxy.test:8080")
settings = Settings()
assert settings.cohere_api_key == "cohere-key"
assert settings.cohere_proxy == "http://proxy.test:8080"
def test_github_models_settings_from_env(self, monkeypatch):
"""GitHub Models token and proxy env vars load into settings."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("GITHUB_MODELS_TOKEN", "github-token")
monkeypatch.setenv("GITHUB_MODELS_PROXY", "http://proxy.test:8080")
settings = Settings()
assert settings.github_models_token == "github-token"
assert settings.github_models_proxy == "http://proxy.test:8080"
def test_sambanova_settings_from_env(self, monkeypatch):
"""SambaNova key and proxy env vars load into settings."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("SAMBANOVA_API_KEY", "sambanova-key")
monkeypatch.setenv("SAMBANOVA_PROXY", "http://proxy.test:8080")
settings = Settings()
assert settings.sambanova_api_key == "sambanova-key"
assert settings.sambanova_proxy == "http://proxy.test:8080"
def test_legacy_hf_token_env_is_ignored(self, monkeypatch):
"""HF_TOKEN is migrated by startup config migration, not read by Settings."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("HF_TOKEN", "legacy-token")
monkeypatch.delenv("HUGGINGFACE_API_KEY", raising=False)
settings = Settings()
assert settings.huggingface_api_key == ""
assert not hasattr(settings, "hf_token")
def test_route_reasoning_from_env(self, monkeypatch):
"""Route reasoning preferences are loaded into settings."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("REASONING_FABLE", "high")
monkeypatch.setenv("REASONING_OPUS", "max")
monkeypatch.setenv("REASONING_SONNET", "client")
monkeypatch.setenv("REASONING_HAIKU", "off")
settings = Settings()
assert settings.reasoning_fable is ReasoningPreference.HIGH
assert settings.reasoning_opus is ReasoningPreference.MAX
assert settings.reasoning_sonnet is ReasoningPreference.CLIENT
assert settings.reasoning_haiku is ReasoningPreference.OFF
def test_route_reasoning_inherits_root_policy(self, monkeypatch):
"""Inherit defers route reasoning to the root preference."""
from free_claude_code.application.routing import ModelRouter
from free_claude_code.config.settings import Settings
monkeypatch.setenv("REASONING_POLICY", "off")
monkeypatch.setenv("REASONING_OPUS", "inherit")
settings = Settings()
assert settings.reasoning_opus is ReasoningPreference.INHERIT
assert (
ModelRouter(settings).resolve("claude-opus-4-20250514").reasoning_preference
is ReasoningPreference.OFF
)
def test_resolve_reasoning_uses_routes(self, monkeypatch):
"""ModelRouter applies route preference then root fallback."""
from free_claude_code.application.routing import ModelRouter
from free_claude_code.config.settings import Settings
monkeypatch.setenv("REASONING_POLICY", "off")
monkeypatch.setenv("REASONING_FABLE", "high")
monkeypatch.setenv("REASONING_OPUS", "max")
monkeypatch.setenv("REASONING_HAIKU", "off")
settings = Settings()
router = ModelRouter(settings)
assert (
router.resolve("claude-fable-5").reasoning_preference
is ReasoningPreference.HIGH
)
assert (
router.resolve("claude-opus-4-20250514").reasoning_preference
is ReasoningPreference.MAX
)
assert (
router.resolve("claude-sonnet-4-20250514").reasoning_preference
is ReasoningPreference.OFF
)
assert (
router.resolve("claude-haiku-4-20250514").reasoning_preference
is ReasoningPreference.OFF
)
assert (
router.resolve("unknown-model").reasoning_preference
is ReasoningPreference.OFF
)
def test_anthropic_auth_token_from_env_without_dotenv_key(self, monkeypatch):
"""ANTHROPIC_AUTH_TOKEN env var is loaded when dotenv does not define it."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "process-token")
monkeypatch.setitem(Settings.model_config, "env_file", ())
settings = Settings()
assert settings.anthropic_auth_token == "process-token"
assert (
process_env_key_is_effective(
Settings.model_config, ANTHROPIC_AUTH_TOKEN_ENV
)
is True
)
def test_empty_dotenv_anthropic_auth_token_overrides_process_env(
self, monkeypatch, tmp_path
):
"""An explicit empty .env token disables auth despite stale shell tokens."""
from free_claude_code.config.settings import Settings
env_file = tmp_path / ".env"
env_file.write_text("ANTHROPIC_AUTH_TOKEN=\n", encoding="utf-8")
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "stale-client-token")
monkeypatch.setitem(Settings.model_config, "env_file", (env_file,))
settings = Settings()
assert settings.anthropic_auth_token == ""
assert (
process_env_key_is_effective(
Settings.model_config, ANTHROPIC_AUTH_TOKEN_ENV
)
is False
)
def test_dotenv_anthropic_auth_token_overrides_process_env(
self, monkeypatch, tmp_path
):
"""A configured .env token is the server token even with a stale shell token."""
from free_claude_code.config.settings import Settings
env_file = tmp_path / ".env"
env_file.write_text(
'ANTHROPIC_AUTH_TOKEN="server-token"\n',
encoding="utf-8",
)
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "stale-client-token")
monkeypatch.setitem(Settings.model_config, "env_file", (env_file,))
settings = Settings()
assert settings.anthropic_auth_token == "server-token"
assert (
process_env_key_is_effective(
Settings.model_config, ANTHROPIC_AUTH_TOKEN_ENV
)
is False
)
@pytest.mark.parametrize("removed_key", ["NIM_ENABLE_THINKING", "ENABLE_THINKING"])
def test_removed_thinking_env_keys_are_ignored(self, monkeypatch, removed_key):
"""Stale thinking env keys do not block startup or affect settings."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv(removed_key, "false")
monkeypatch.setitem(Settings.model_config, "env_file", ())
settings = Settings()
assert settings.reasoning_policy is ReasoningPreference.CLIENT
@pytest.mark.parametrize("removed_key", ["NIM_ENABLE_THINKING", "ENABLE_THINKING"])
@pytest.mark.parametrize("value", ["false", ""])
def test_removed_thinking_dotenv_keys_are_ignored(
self, monkeypatch, tmp_path, removed_key, value
):
"""Stale thinking dotenv keys do not block startup or affect settings."""
from free_claude_code.config.settings import Settings
env_file = tmp_path / ".env"
env_file.write_text(f"{removed_key}={value}\n", encoding="utf-8")
monkeypatch.delenv(removed_key, raising=False)
monkeypatch.setitem(Settings.model_config, "env_file", (env_file,))
settings = Settings()
assert settings.reasoning_policy is ReasoningPreference.CLIENT
# --- NimSettings Validation Tests ---
class TestNimSettingsValidBounds:
"""Test that valid values within bounds are accepted."""
@pytest.mark.parametrize("top_k", [-1, 0, 1, 100])
def test_top_k_valid(self, top_k):
"""top_k >= -1 should be accepted."""
s = NimSettings(top_k=top_k)
assert s.top_k == top_k
@pytest.mark.parametrize("temp", [0.0, 0.5, 1.0, 2.0])
def test_temperature_valid(self, temp):
s = NimSettings(temperature=temp)
assert s.temperature == temp
@pytest.mark.parametrize("top_p", [0.0, 0.5, 1.0])
def test_top_p_valid(self, top_p):
s = NimSettings(top_p=top_p)
assert s.top_p == top_p
def test_max_tokens_valid(self):
s = NimSettings(max_tokens=1)
assert s.max_tokens == 1
def test_min_tokens_valid(self):
s = NimSettings(min_tokens=0)
assert s.min_tokens == 0
@pytest.mark.parametrize("penalty", [-2.0, 0.0, 2.0])
def test_presence_penalty_valid(self, penalty):
s = NimSettings(presence_penalty=penalty)
assert s.presence_penalty == penalty
@pytest.mark.parametrize("penalty", [-2.0, 0.0, 2.0])
def test_frequency_penalty_valid(self, penalty):
s = NimSettings(frequency_penalty=penalty)
assert s.frequency_penalty == penalty
@pytest.mark.parametrize("min_p", [0.0, 0.5, 1.0])
def test_min_p_valid(self, min_p):
s = NimSettings(min_p=min_p)
assert s.min_p == min_p
class TestNimSettingsInvalidBounds:
"""Test that out-of-range values raise ValidationError."""
@pytest.mark.parametrize("top_k", [-2, -100])
def test_top_k_below_lower_bound(self, top_k):
with pytest.raises((ValidationError, ValueError)):
NimSettings(top_k=top_k)
def test_temperature_negative(self):
with pytest.raises(ValidationError):
NimSettings(temperature=-0.1)
@pytest.mark.parametrize("top_p", [-0.1, 1.1])
def test_top_p_out_of_range(self, top_p):
with pytest.raises(ValidationError):
NimSettings(top_p=top_p)
@pytest.mark.parametrize("penalty", [-2.1, 2.1])
def test_presence_penalty_out_of_range(self, penalty):
with pytest.raises(ValidationError):
NimSettings(presence_penalty=penalty)
@pytest.mark.parametrize("penalty", [-2.1, 2.1])
def test_frequency_penalty_out_of_range(self, penalty):
with pytest.raises(ValidationError):
NimSettings(frequency_penalty=penalty)
@pytest.mark.parametrize("min_p", [-0.1, 1.1])
def test_min_p_out_of_range(self, min_p):
with pytest.raises(ValidationError):
NimSettings(min_p=min_p)
@pytest.mark.parametrize("max_tokens", [0, -1])
def test_max_tokens_too_low(self, max_tokens):
with pytest.raises(ValidationError):
NimSettings(max_tokens=max_tokens)
def test_min_tokens_negative(self):
with pytest.raises(ValidationError):
NimSettings(min_tokens=-1)
class TestNimSettingsValidators:
"""Test custom field validators in NimSettings."""
def test_default_max_tokens_matches_shared_constant(self):
assert NimSettings().max_tokens == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
@pytest.mark.parametrize(
"seed_val,expected",
[("", None), (None, None), ("42", 42), (42, 42)],
ids=["empty_str", "none", "str_42", "int_42"],
)
def test_parse_optional_int(self, seed_val, expected):
s = NimSettings(seed=seed_val)
assert s.seed == expected
@pytest.mark.parametrize(
"stop_val,expected",
[("", None), ("STOP", "STOP"), (None, None)],
ids=["empty_str", "valid", "none"],
)
def test_parse_optional_str_stop(self, stop_val, expected):
s = NimSettings(stop=stop_val)
assert s.stop == expected
@pytest.mark.parametrize(
"chat_template_val,expected",
[("", None), ("template", "template")],
ids=["empty_str", "valid"],
)
def test_parse_optional_str_chat_template(self, chat_template_val, expected):
s = NimSettings(chat_template=chat_template_val)
assert s.chat_template == expected
def test_extra_forbid_rejects_unknown_field(self):
"""NimSettings with extra='forbid' rejects unknown fields."""
from typing import Any, cast
with pytest.raises(ValidationError):
NimSettings(**cast(Any, {"unknown_field": "value"}))
def test_enable_thinking_field_removed(self):
"""NimSettings no longer accepts the removed thinking toggle."""
from typing import Any, cast
with pytest.raises(ValidationError):
NimSettings(**cast(Any, {"enable_thinking": True}))
class TestSettingsOptionalStr:
"""Test Settings parse_optional_str validator."""
def test_empty_telegram_token_to_none(self, monkeypatch):
from free_claude_code.config.settings import Settings
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "")
s = Settings()
assert s.telegram_bot_token is None
def test_valid_telegram_token_preserved(self, monkeypatch):
from free_claude_code.config.settings import Settings
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "abc123")
s = Settings()
assert s.telegram_bot_token == "abc123"
def test_empty_allowed_user_id_to_none(self, monkeypatch):
from free_claude_code.config.settings import Settings
monkeypatch.setenv("ALLOWED_TELEGRAM_USER_ID", "")
s = Settings()
assert s.allowed_telegram_user_id is None
def test_discord_bot_token_from_env(self, monkeypatch):
from free_claude_code.config.settings import Settings
monkeypatch.setenv("DISCORD_BOT_TOKEN", "discord_token_123")
s = Settings()
assert s.discord_bot_token == "discord_token_123"
def test_empty_discord_bot_token_to_none(self, monkeypatch):
from free_claude_code.config.settings import Settings
monkeypatch.setenv("DISCORD_BOT_TOKEN", "")
s = Settings()
assert s.discord_bot_token is None
def test_allowed_discord_channels_from_env(self, monkeypatch):
from free_claude_code.config.settings import Settings
monkeypatch.setenv("ALLOWED_DISCORD_CHANNELS", "111,222,333")
s = Settings()
assert s.allowed_discord_channels == "111,222,333"
def test_messaging_platform_from_env(self, monkeypatch):
from free_claude_code.config.settings import Settings
monkeypatch.setenv("MESSAGING_PLATFORM", "discord")
s = Settings()
assert s.messaging_platform == "discord"
def test_whisper_device_auto_rejected(self, monkeypatch):
"""WHISPER_DEVICE=auto raises ValidationError (auto removed)."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("WHISPER_DEVICE", "auto")
with pytest.raises(ValidationError, match="whisper_device"):
Settings()
@pytest.mark.parametrize("device", ["cpu", "cuda"])
def test_whisper_device_valid(self, monkeypatch, device):
"""Valid whisper_device values are accepted."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("WHISPER_DEVICE", device)
s = Settings()
assert s.whisper_device == device
class TestPerModelMapping:
"""Test per-model settings and model-ref helpers."""
def test_model_fields_default_none(self):
"""Per-model fields default to None."""
from free_claude_code.config.settings import Settings
s = Settings()
assert s.model_fable is None
assert s.model_opus is None
assert s.model_sonnet is None
assert s.model_haiku is None
def test_model_opus_from_env(self, monkeypatch):
"""MODEL_OPUS env var is loaded."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("MODEL_OPUS", "open_router/deepseek/deepseek-r1")
s = Settings()
assert s.model_opus == "open_router/deepseek/deepseek-r1"
def test_model_fable_from_env(self, monkeypatch):
"""MODEL_FABLE env var is loaded."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("MODEL_FABLE", "open_router/anthropic/claude-fable-5")
s = Settings()
assert s.model_fable == "open_router/anthropic/claude-fable-5"
@pytest.mark.parametrize(
"env_var", ["MODEL_FABLE", "MODEL_OPUS", "MODEL_SONNET", "MODEL_HAIKU"]
)
def test_empty_model_override_env_is_unset(self, monkeypatch, env_var):
"""Empty per-model override env vars are treated as unset."""
from free_claude_code.application.routing import ModelRouter
from free_claude_code.config.settings import Settings
monkeypatch.setenv(env_var, "")
s = Settings()
assert getattr(s, env_var.lower()) is None
assert (
ModelRouter(s)
.resolve(f"claude-{env_var.removeprefix('MODEL_').lower()}-4")
.provider_model_ref
== s.model
)
@pytest.mark.parametrize(
"env_vars,expected_model,expected_haiku",
[
(
{"MODEL": "nvidia_nim/meta/llama3-70b-instruct"},
"nvidia_nim/meta/llama3-70b-instruct",
None,
),
(
{
"MODEL": "open_router/anthropic/claude-3-opus",
"MODEL_HAIKU": "open_router/anthropic/claude-3-haiku",
},
"open_router/anthropic/claude-3-opus",
"open_router/anthropic/claude-3-haiku",
),
({"MODEL": "deepseek/deepseek-chat"}, "deepseek/deepseek-chat", None),
({"MODEL": "wafer/DeepSeek-V4-Pro"}, "wafer/DeepSeek-V4-Pro", None),
(
{"MODEL": "cloudflare/@cf/moonshotai/kimi-k2.6"},
"cloudflare/@cf/moonshotai/kimi-k2.6",
None,
),
(
{"MODEL": "github_models/openai/gpt-4.1"},
"github_models/openai/gpt-4.1",
None,
),
(
{"MODEL": "sambanova/Meta-Llama-3.3-70B-Instruct"},
"sambanova/Meta-Llama-3.3-70B-Instruct",
None,
),
({"MODEL": "lmstudio/qwen2.5-7b"}, "lmstudio/qwen2.5-7b", None),
({"MODEL": "llamacpp/local-model"}, "llamacpp/local-model", None),
({"MODEL": "ollama/llama3.1"}, "ollama/llama3.1", None),
(
{"MODEL": "ollama_cloud/qwen3-coder:480b"},
"ollama_cloud/qwen3-coder:480b",
None,
),
],
)
def test_settings_models_from_env(
self, env_vars, expected_model, expected_haiku, monkeypatch
):
"""Test environment variables override model defaults."""
from free_claude_code.config.settings import Settings
for k, v in env_vars.items():
monkeypatch.setenv(k, v)
s = Settings()
assert s.model == expected_model
assert s.model_haiku == expected_haiku
def test_model_sonnet_from_env(self, monkeypatch):
"""MODEL_SONNET env var is loaded."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("MODEL_SONNET", "nvidia_nim/meta/llama-3.3-70b-instruct")
s = Settings()
assert s.model_sonnet == "nvidia_nim/meta/llama-3.3-70b-instruct"
def test_model_haiku_from_env(self, monkeypatch):
"""MODEL_HAIKU env var is loaded."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("MODEL_HAIKU", "lmstudio/qwen2.5-7b")
s = Settings()
assert s.model_haiku == "lmstudio/qwen2.5-7b"
def test_model_opus_invalid_provider_raises(self, monkeypatch):
"""MODEL_OPUS with invalid provider prefix raises ValidationError."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("MODEL_OPUS", "bad_provider/some-model")
with pytest.raises(ValidationError, match="Invalid provider"):
Settings()
def test_model_opus_no_slash_raises(self, monkeypatch):
"""MODEL_OPUS without provider prefix raises ValidationError."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("MODEL_OPUS", "noprefix")
with pytest.raises(ValidationError, match="provider type"):
Settings()
def test_model_haiku_invalid_provider_raises(self, monkeypatch):
"""MODEL_HAIKU with invalid provider prefix raises ValidationError."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("MODEL_HAIKU", "invalid/model")
with pytest.raises(ValidationError, match="Invalid provider"):
Settings()
def test_model_fable_invalid_provider_raises(self, monkeypatch):
"""MODEL_FABLE with invalid provider prefix raises ValidationError."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("MODEL_FABLE", "invalid/model")
with pytest.raises(ValidationError, match="Invalid provider"):
Settings()
def test_resolve_model_fable_override(self):
"""ModelRouter returns model_fable for Fable model names."""
from free_claude_code.application.routing import ModelRouter
from free_claude_code.config.settings import Settings
s = Settings()
s.model_fable = "open_router/anthropic/claude-fable-5"
assert (
ModelRouter(s).resolve("claude-fable-5").provider_model_ref
== "open_router/anthropic/claude-fable-5"
)
def test_resolve_model_opus_override(self):
"""ModelRouter returns model_opus for opus model names."""
from free_claude_code.application.routing import ModelRouter
from free_claude_code.config.settings import Settings
s = Settings()
s.model_opus = "open_router/deepseek/deepseek-r1"
router = ModelRouter(s)
assert (
router.resolve("claude-opus-4-20250514").provider_model_ref
== "open_router/deepseek/deepseek-r1"
)
assert (
router.resolve("claude-3-opus").provider_model_ref
== "open_router/deepseek/deepseek-r1"
)
assert (
router.resolve("claude-3-opus-20240229").provider_model_ref
== "open_router/deepseek/deepseek-r1"
)
def test_resolve_model_sonnet_override(self):
"""ModelRouter returns model_sonnet for sonnet model names."""
from free_claude_code.application.routing import ModelRouter
from free_claude_code.config.settings import Settings
s = Settings()
s.model_sonnet = "nvidia_nim/meta/llama-3.3-70b-instruct"
router = ModelRouter(s)
assert (
router.resolve("claude-sonnet-4-20250514").provider_model_ref
== "nvidia_nim/meta/llama-3.3-70b-instruct"
)
assert (
router.resolve("claude-3-5-sonnet-20241022").provider_model_ref
== "nvidia_nim/meta/llama-3.3-70b-instruct"
)
def test_resolve_model_haiku_override(self):
"""ModelRouter returns model_haiku for haiku model names."""
from free_claude_code.application.routing import ModelRouter
from free_claude_code.config.settings import Settings
s = Settings()
s.model_haiku = "lmstudio/qwen2.5-7b"
router = ModelRouter(s)
assert (
router.resolve("claude-3-haiku-20240307").provider_model_ref
== "lmstudio/qwen2.5-7b"
)
assert (
router.resolve("claude-3-5-haiku-20241022").provider_model_ref
== "lmstudio/qwen2.5-7b"
)
assert (
router.resolve("claude-haiku-4-20250514").provider_model_ref
== "lmstudio/qwen2.5-7b"
)
def test_resolve_model_fallback_when_override_not_set(self):
"""ModelRouter falls back to MODEL when model override is None."""
from free_claude_code.application.routing import ModelRouter
from free_claude_code.config.settings import Settings
s = Settings()
s.model = "nvidia_nim/fallback-model"
router = ModelRouter(s)
assert (
router.resolve("claude-fable-5").provider_model_ref
== "nvidia_nim/fallback-model"
)
assert (
router.resolve("claude-opus-4-20250514").provider_model_ref
== "nvidia_nim/fallback-model"
)
assert (
router.resolve("claude-sonnet-4-20250514").provider_model_ref
== "nvidia_nim/fallback-model"
)
assert (
router.resolve("claude-3-haiku-20240307").provider_model_ref
== "nvidia_nim/fallback-model"
)
def test_resolve_model_unknown_model_falls_back(self):
"""ModelRouter falls back to MODEL for unrecognized model names."""
from free_claude_code.application.routing import ModelRouter
from free_claude_code.config.settings import Settings
s = Settings()
s.model = "nvidia_nim/fallback-model"
s.model_opus = "open_router/opus-model"
router = ModelRouter(s)
assert router.resolve("claude-2.1").provider_model_ref == (
"nvidia_nim/fallback-model"
)
assert router.resolve("some-unknown-model").provider_model_ref == (
"nvidia_nim/fallback-model"
)
def test_resolve_model_case_insensitive(self):
"""Model classification is case-insensitive."""
from free_claude_code.application.routing import ModelRouter
from free_claude_code.config.settings import Settings
s = Settings()
s.model_opus = "open_router/opus-model"
assert (
ModelRouter(s).resolve("Claude-OPUS-4").provider_model_ref
== "open_router/opus-model"
)
def test_parse_provider_type(self):
"""parse_provider_type extracts provider from model string."""
assert parse_provider_type("nvidia_nim/meta/llama") == "nvidia_nim"
assert parse_provider_type("open_router/deepseek/r1") == "open_router"
assert parse_provider_type("mistral/devstral-small-latest") == "mistral"
assert (
parse_provider_type("mistral_codestral/codestral-latest")
== "mistral_codestral"
)
assert parse_provider_type("deepseek/deepseek-chat") == "deepseek"
assert parse_provider_type("lmstudio/qwen") == "lmstudio"
assert parse_provider_type("llamacpp/model") == "llamacpp"
assert parse_provider_type("ollama/llama3.1") == "ollama"
assert parse_provider_type("ollama_cloud/qwen3-coder:480b") == "ollama_cloud"
assert parse_provider_type("wafer/DeepSeek-V4-Pro") == "wafer"
assert parse_provider_type("minimax/MiniMax-M3") == "minimax"
assert (
parse_provider_type("cloudflare/@cf/moonshotai/kimi-k2.6") == "cloudflare"
)
assert parse_provider_type("vercel/openai/gpt-5.5") == "vercel"
assert (
parse_provider_type("huggingface/openai/gpt-oss-120b:fastest")
== "huggingface"
)
assert parse_provider_type("cohere/command-a-plus-05-2026") == "cohere"
assert parse_provider_type("github_models/openai/gpt-4.1") == ("github_models")
assert parse_provider_type("gemini/models/gemini-3.1-flash-lite") == "gemini"
assert parse_provider_type("groq/llama-3.3-70b-versatile") == "groq"
assert (
parse_provider_type("sambanova/Meta-Llama-3.3-70B-Instruct") == "sambanova"
)
assert parse_provider_type("cerebras/llama3.1-8b") == "cerebras"
def test_parse_model_name(self):
"""parse_model_name extracts model name from model string."""
assert parse_model_name("nvidia_nim/meta/llama") == "meta/llama"
assert parse_model_name("mistral/devstral-small-latest") == (
"devstral-small-latest"
)
assert (
parse_model_name("mistral_codestral/codestral-latest") == "codestral-latest"
)
assert parse_model_name("deepseek/deepseek-chat") == "deepseek-chat"
assert parse_model_name("lmstudio/qwen") == "qwen"
assert parse_model_name("llamacpp/model") == "model"
assert parse_model_name("ollama/llama3.1") == "llama3.1"
assert parse_model_name("ollama_cloud/qwen3-coder:480b") == "qwen3-coder:480b"
assert parse_model_name("wafer/DeepSeek-V4-Pro") == "DeepSeek-V4-Pro"
assert parse_model_name("minimax/MiniMax-M3") == "MiniMax-M3"
assert (
parse_model_name("cloudflare/@cf/moonshotai/kimi-k2.6")
== "@cf/moonshotai/kimi-k2.6"
)
assert parse_model_name("vercel/openai/gpt-5.5") == "openai/gpt-5.5"
assert (
parse_model_name("huggingface/openai/gpt-oss-120b:fastest")
== "openai/gpt-oss-120b:fastest"
)
assert parse_model_name("cohere/command-a-plus-05-2026") == (
"command-a-plus-05-2026"
)
assert parse_model_name("github_models/openai/gpt-4.1") == "openai/gpt-4.1"
assert (
parse_model_name("gemini/models/gemini-3.1-flash-lite")
== "models/gemini-3.1-flash-lite"
)
assert (
parse_model_name("groq/llama-3.3-70b-versatile")
== "llama-3.3-70b-versatile"
)
assert (
parse_model_name("sambanova/Meta-Llama-3.3-70B-Instruct")
== "Meta-Llama-3.3-70B-Instruct"
)
assert parse_model_name("cerebras/llama3.1-8b") == "llama3.1-8b"
def test_configured_chat_model_refs_collects_unique_models(self, monkeypatch):
"""Model discovery is limited to configured chat references."""
from free_claude_code.config.settings import Settings
monkeypatch.setenv("FCC_SMOKE_MODEL_NVIDIA_NIM", "nvidia_nim/smoke")
monkeypatch.setenv("WHISPER_MODEL", "openai/whisper-large-v3")
s = Settings()
s.model = "nvidia_nim/fallback"
s.model_fable = "open_router/anthropic/claude-fable-5"
s.model_opus = "open_router/anthropic/claude-opus"
s.model_sonnet = "nvidia_nim/fallback"
s.model_haiku = None
refs = configured_chat_model_refs(s)
assert [ref.model_ref for ref in refs] == [
"nvidia_nim/fallback",
"open_router/anthropic/claude-fable-5",
"open_router/anthropic/claude-opus",
]
assert refs[0].provider_id == "nvidia_nim"
assert refs[0].model_id == "fallback"
assert refs[1].provider_id == "open_router"
assert refs[1].model_id == "anthropic/claude-fable-5"
assert refs[2].provider_id == "open_router"
assert refs[2].model_id == "anthropic/claude-opus"