"""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"