From cdeb1aa9e29f3c80ef17e242fb77d8a499b1acfc Mon Sep 17 00:00:00 2001 From: Ali Khokhar Date: Sat, 27 Jun 2026 16:21:30 -0700 Subject: [PATCH] Refactor settings schema ownership (#927) --- ARCHITECTURE.md | 23 +- api/admin_config/sources.py | 29 ++- api/admin_routes.py | 3 +- api/handlers/messages.py | 6 +- api/model_catalog.py | 3 +- api/model_router.py | 35 ++- api/routes.py | 3 +- api/runtime.py | 12 +- api/web_tools/egress.py | 8 + cli/claude_env.py | 1 + cli/launchers/claude.py | 17 +- cli/launchers/codex.py | 8 +- cli/managed/claude.py | 8 +- cli/managed/manager.py | 4 +- config/env_files.py | 93 ++++++++ config/model_refs.py | 63 +++++ config/settings.py | 176 +------------- providers/runtime/discovery.py | 3 +- providers/runtime/validation.py | 5 +- pyproject.toml | 2 +- smoke/lib/config.py | 7 +- .../test_config_extensibility_product_live.py | 10 +- smoke/product/test_provider_product_live.py | 5 +- tests/api/test_app_lifespan_and_errors.py | 32 ++- tests/api/test_dependencies.py | 5 +- tests/config/test_config.py | 215 +++++++++++------- tests/contracts/test_import_boundaries.py | 25 ++ tests/providers/test_provider_runtime.py | 4 +- uv.lock | 22 +- 29 files changed, 496 insertions(+), 331 deletions(-) create mode 100644 config/env_files.py create mode 100644 config/model_refs.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0643d09d..6102b48b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -166,8 +166,10 @@ reported without noisy Starlette tracebacks. ## Configuration Model -[config/settings.py](config/settings.py) centralizes configuration with Pydantic -Settings. Dotenv files are configured in this order: +[config/settings.py](config/settings.py) owns the flat Pydantic Settings schema: +raw env fields, validation, and `get_settings()`. It should not own routing, +model-ref parsing, launcher defaults, or web-tool policy. Dotenv discovery lives +in [config/env_files.py](config/env_files.py) and uses this order: 1. repo-local `.env`; 2. managed `~/.fcc/.env`; @@ -176,7 +178,8 @@ Settings. Dotenv files are configured in this order: Later dotenv files override earlier dotenv files. Process environment variables also participate through Pydantic settings resolution. `ANTHROPIC_AUTH_TOKEN` has an extra guard after settings are built: if any configured dotenv file -defines it, that dotenv value replaces a stale inherited shell token. +defines it, that dotenv value replaces a stale inherited shell token. Auth-token +source detection for startup warnings also belongs to `config/env_files.py`. [config/paths.py](config/paths.py) defines managed paths: @@ -194,6 +197,10 @@ Model routing configuration is tiered: - `ENABLE_OPUS_THINKING`, `ENABLE_SONNET_THINKING`, and `ENABLE_HAIKU_THINKING` optionally override thinking by tier. +[config/model_refs.py](config/model_refs.py) owns provider-prefixed model ref +parsing and configured `MODEL*` inventory. API routing and provider validation +depend on those helpers instead of adding behavior methods to Settings. + [api/admin_config/](api/admin_config/) owns the Admin UI config manifest and managed env writes. Provider credential, local URL, proxy, and display-name metadata is generated from [config/provider_catalog.py](config/provider_catalog.py); @@ -279,13 +286,13 @@ It supports two forms: - Direct provider model refs such as `nvidia_nim/nvidia/model-name`. - Gateway model IDs decoded by [api/gateway_model_ids.py](api/gateway_model_ids.py). -If the incoming model is not direct, `Settings.resolve_model()` maps it by Claude -tier. Names containing `opus`, `sonnet`, or `haiku` use the matching tier override -when set, otherwise they fall back to `MODEL`. +If the incoming model is not direct, `ModelRouter` maps it by Claude tier. Names +containing `opus`, `sonnet`, or `haiku` use the matching tier override when set, +otherwise they fall back to `MODEL`. The router also resolves thinking. Gateway model IDs can force thinking on or -off; otherwise `Settings.resolve_thinking()` applies tier-specific thinking -overrides or the global setting. +off; otherwise `ModelRouter` applies tier-specific thinking overrides or the +global setting. `GET /v1/models` advertises: diff --git a/api/admin_config/sources.py b/api/admin_config/sources.py index 8c03f7ee..0cf72300 100644 --- a/api/admin_config/sources.py +++ b/api/admin_config/sources.py @@ -9,8 +9,16 @@ from typing import Literal from dotenv import dotenv_values +from config.env_files import ( + explicit_env_path as configured_explicit_env_path, +) +from config.env_files import ( + repo_env_path as configured_repo_env_path, +) +from config.env_files import ( + settings_env_files, +) from config.env_template import load_env_template_or_empty -from config.paths import managed_env_path from .manifest import FIELDS @@ -27,27 +35,24 @@ SourceType = Literal[ def repo_env_path() -> Path: """Return the repo-local env path.""" - return Path(".env") + return configured_repo_env_path() def explicit_env_path() -> Path | None: """Return the explicit FCC_ENV_FILE path, when configured.""" - if explicit := os.environ.get("FCC_ENV_FILE"): - return Path(explicit) - return None + return configured_explicit_env_path(os.environ) def configured_env_files() -> tuple[tuple[SourceType, Path], ...]: """Return dotenv files in low-to-high precedence order.""" - files: list[tuple[SourceType, Path]] = [ - ("repo_env", repo_env_path()), - ("managed_env", managed_env_path()), - ] - if explicit := explicit_env_path(): - files.append(("explicit_env_file", explicit)) - return tuple(files) + source_names: tuple[SourceType, ...] = ( + "repo_env", + "managed_env", + "explicit_env_file", + ) + return tuple(zip(source_names, settings_env_files(), strict=False)) def dotenv_values_from_text(text: str) -> dict[str, str]: diff --git a/api/admin_routes.py b/api/admin_routes.py index 310489dc..834dc2d2 100644 --- a/api/admin_routes.py +++ b/api/admin_routes.py @@ -13,6 +13,7 @@ from fastapi import APIRouter, BackgroundTasks, HTTPException, Request from fastapi.responses import FileResponse from pydantic import BaseModel, Field +from config.model_refs import parse_provider_type from config.settings import Settings from config.settings import get_settings as get_cached_settings from providers.runtime import ProviderRuntime @@ -147,7 +148,7 @@ async def admin_status(request: Request): "host": settings.host, "port": settings.port, "model": settings.model, - "provider": settings.provider_type, + "provider": parse_provider_type(settings.model), "pending_fields": getattr(request.app.state, "admin_pending_fields", []), "provider_status": provider_config_status(), "cached_models": cached_models, diff --git a/api/handlers/messages.py b/api/handlers/messages.py index a20b47af..45895589 100644 --- a/api/handlers/messages.py +++ b/api/handlers/messages.py @@ -14,7 +14,7 @@ from api.optimization_handlers import try_optimizations from api.provider_execution import ProviderExecutionService, TokenCounter from api.request_errors import require_non_empty_messages, unexpected_http_exception from api.response_streams import anthropic_sse_streaming_response -from api.web_tools.egress import WebFetchEgressPolicy +from api.web_tools.egress import WebFetchEgressPolicy, web_fetch_allowed_scheme_set from api.web_tools.request import ( is_web_server_tool_request, openai_chat_upstream_server_tool_error, @@ -146,7 +146,9 @@ class MessagesHandler: ) egress = WebFetchEgressPolicy( allow_private_network_targets=self._settings.web_fetch_allow_private_networks, - allowed_schemes=self._settings.web_fetch_allowed_scheme_set(), + allowed_schemes=web_fetch_allowed_scheme_set( + self._settings.web_fetch_allowed_schemes + ), ) return anthropic_sse_streaming_response( stream_web_server_tool_response( diff --git a/api/model_catalog.py b/api/model_catalog.py index b0382881..60caea50 100644 --- a/api/model_catalog.py +++ b/api/model_catalog.py @@ -2,6 +2,7 @@ from __future__ import annotations +from config.model_refs import configured_chat_model_refs from config.settings import Settings from providers.runtime import ProviderRuntime @@ -57,7 +58,7 @@ def build_models_list_response( models: list[ModelResponse] = [] seen: set[str] = set() - for ref in settings.configured_chat_model_refs(): + for ref in configured_chat_model_refs(settings): supports_thinking = None if provider_runtime is not None: supports_thinking = provider_runtime.cached_model_supports_thinking( diff --git a/api/model_router.py b/api/model_router.py index 687407f4..efb3aecd 100644 --- a/api/model_router.py +++ b/api/model_router.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from loguru import logger +from config.model_refs import parse_model_name, parse_provider_type from config.provider_ids import SUPPORTED_PROVIDER_IDS from config.settings import Settings @@ -50,7 +51,7 @@ class ModelRouter: thinking_enabled = ( force_thinking_enabled if force_thinking_enabled is not None - else self._settings.resolve_thinking(direct_provider_model) + else self._resolve_thinking(direct_provider_model) ) logger.debug( "MODEL DIRECT: '{}' -> provider='{}' model='{}' thinking={}", @@ -67,10 +68,10 @@ class ModelRouter: thinking_enabled=thinking_enabled, ) - provider_model_ref = self._settings.resolve_model(claude_model_name) - thinking_enabled = self._settings.resolve_thinking(claude_model_name) - provider_id = Settings.parse_provider_type(provider_model_ref) - provider_model = Settings.parse_model_name(provider_model_ref) + provider_model_ref = self._resolve_model_ref(claude_model_name) + thinking_enabled = self._resolve_thinking(claude_model_name) + provider_id = parse_provider_type(provider_model_ref) + provider_model = parse_model_name(provider_model_ref) if provider_model != claude_model_name: logger.debug( "MODEL MAPPING: '{}' -> '{}'", claude_model_name, provider_model @@ -105,6 +106,30 @@ class ModelRouter: return None, None, None return provider_id, provider_model, None + def _resolve_model_ref(self, claude_model_name: str) -> str: + """Resolve a Claude model name to the configured provider/model ref.""" + + name_lower = claude_model_name.lower() + if "opus" in name_lower and self._settings.model_opus is not None: + return self._settings.model_opus + if "haiku" in name_lower and self._settings.model_haiku is not None: + return self._settings.model_haiku + if "sonnet" in name_lower and self._settings.model_sonnet is not None: + return self._settings.model_sonnet + return self._settings.model + + def _resolve_thinking(self, claude_model_name: str) -> bool: + """Resolve whether thinking is enabled for an incoming Claude model name.""" + + name_lower = claude_model_name.lower() + if "opus" in name_lower and self._settings.enable_opus_thinking is not None: + return self._settings.enable_opus_thinking + if "haiku" in name_lower and self._settings.enable_haiku_thinking is not None: + return self._settings.enable_haiku_thinking + if "sonnet" in name_lower and self._settings.enable_sonnet_thinking is not None: + return self._settings.enable_sonnet_thinking + return self._settings.enable_model_thinking + def resolve_messages_request( self, request: MessagesRequest ) -> RoutedMessagesRequest: diff --git a/api/routes.py b/api/routes.py index 66ca7bf1..a815c89f 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response from loguru import logger +from config.model_refs import parse_provider_type from config.settings import Settings from core.anthropic import get_token_count from core.trace import trace_event @@ -117,7 +118,7 @@ async def root( """Root endpoint.""" return { "status": "ok", - "provider": settings.provider_type, + "provider": parse_provider_type(settings.model), "model": settings.model, } diff --git a/api/runtime.py b/api/runtime.py index 2ce78e51..c4d8dc73 100644 --- a/api/runtime.py +++ b/api/runtime.py @@ -12,6 +12,8 @@ from fastapi import FastAPI from loguru import logger from api.admin_urls import local_admin_url +from config.env_files import ANTHROPIC_AUTH_TOKEN_ENV, process_env_key_is_effective +from config.paths import default_claude_workspace_path from config.settings import Settings, get_settings from providers.exceptions import ServiceUnavailableError from providers.runtime import ProviderRuntime @@ -55,7 +57,8 @@ async def best_effort( def warn_if_process_auth_token(settings: Settings) -> None: """Warn when server auth was implicitly inherited from the shell.""" - if settings.uses_process_anthropic_auth_token(): + model_config = getattr(settings, "model_config", Settings.model_config) + if process_env_key_is_effective(model_config, ANTHROPIC_AUTH_TOKEN_ENV): logger.warning( "ANTHROPIC_AUTH_TOKEN is set in the process environment but not in " "a configured .env file. The proxy will require that token. Add " @@ -237,21 +240,18 @@ class AppRuntime: ) os.makedirs(workspace, exist_ok=True) - data_path = os.path.abspath(self.settings.claude_workspace) + data_path = os.path.abspath(default_claude_workspace_path()) os.makedirs(data_path, exist_ok=True) api_url = f"http://{self.settings.host}:{self.settings.port}/v1" allowed_dirs = [workspace] if self.settings.allowed_dir else [] - plans_dir_abs = os.path.abspath( - os.path.join(self.settings.claude_workspace, "plans") - ) + plans_dir_abs = os.path.abspath(os.path.join(data_path, "plans")) plans_directory = os.path.relpath(plans_dir_abs, workspace) self.cli_manager = ManagedClaudeSessionManager( workspace_path=workspace, api_url=api_url, allowed_dirs=allowed_dirs, plans_directory=plans_directory, - claude_bin=self.settings.claude_cli_bin, auth_token=getattr(self.settings, "anthropic_auth_token", ""), log_raw_cli_diagnostics=self.settings.log_raw_cli_diagnostics, log_messaging_error_details=self.settings.log_messaging_error_details, diff --git a/api/web_tools/egress.py b/api/web_tools/egress.py index 30b29d5b..2f2a7d32 100644 --- a/api/web_tools/egress.py +++ b/api/web_tools/egress.py @@ -20,6 +20,14 @@ class WebFetchEgressViolation(ValueError): """Raised when a web_fetch URL is rejected by egress policy (SSRF guard).""" +def web_fetch_allowed_scheme_set(raw_schemes: str) -> frozenset[str]: + """Return normalized schemes allowed for web_fetch.""" + + return frozenset( + part.strip().lower() for part in raw_schemes.split(",") if part.strip() + ) + + def _port_for_url(parsed) -> int: if parsed.port is not None: return parsed.port diff --git a/cli/claude_env.py b/cli/claude_env.py index f942c0c8..97827f9a 100644 --- a/cli/claude_env.py +++ b/cli/claude_env.py @@ -3,6 +3,7 @@ from __future__ import annotations CLAUDE_CODE_AUTO_COMPACT_WINDOW = "190000" +CLAUDE_BINARY_NAME = "claude" CLAUDE_NO_AUTH_SENTINEL = "fcc-no-auth" diff --git a/cli/launchers/claude.py b/cli/launchers/claude.py index 33943f14..9dba9d64 100644 --- a/cli/launchers/claude.py +++ b/cli/launchers/claude.py @@ -7,13 +7,16 @@ import sys from collections.abc import Mapping, Sequence from api.admin_urls import local_proxy_root_url -from cli.claude_env import CLAUDE_CODE_AUTO_COMPACT_WINDOW, claude_auth_token -from config.settings import Settings, get_settings +from cli.claude_env import ( + CLAUDE_BINARY_NAME, + CLAUDE_CODE_AUTO_COMPACT_WINDOW, + claude_auth_token, +) +from config.settings import get_settings from .common import preflight_proxy, resolve_client_binary, run_client_process _DISPLAY_NAME = "Claude Code" -_DEFAULT_BINARY = "claude" _INSTALL_HINT = "Install Claude Code with: npm install -g @anthropic-ai/claude-code" @@ -30,7 +33,7 @@ def launch(argv: Sequence[str] | None = None) -> None: print("Start it in another terminal with: fcc-server", file=sys.stderr) raise SystemExit(1) - binary_name = claude_binary_name(settings) + binary_name = claude_binary_name() binary_path = resolve_client_binary( binary_name=binary_name, display_name=_DISPLAY_NAME, @@ -50,10 +53,10 @@ def launch(argv: Sequence[str] | None = None) -> None: ) -def claude_binary_name(settings: Settings) -> str: - """Return the configured Claude Code binary name.""" +def claude_binary_name() -> str: + """Return the Claude Code binary name.""" - return settings.claude_cli_bin or _DEFAULT_BINARY + return CLAUDE_BINARY_NAME def build_claude_launcher_command( diff --git a/cli/launchers/codex.py b/cli/launchers/codex.py index ed8c5ef4..2ad15195 100644 --- a/cli/launchers/codex.py +++ b/cli/launchers/codex.py @@ -50,7 +50,7 @@ def launch(argv: Sequence[str] | None = None) -> None: print("Start it in another terminal with: fcc-server", file=sys.stderr) raise SystemExit(1) - binary_name = codex_binary_name(settings) + binary_name = codex_binary_name() binary_path = resolve_client_binary( binary_name=binary_name, display_name=_DISPLAY_NAME, @@ -76,10 +76,10 @@ def launch(argv: Sequence[str] | None = None) -> None: ) -def codex_binary_name(settings: Settings) -> str: - """Return the configured Codex binary name.""" +def codex_binary_name() -> str: + """Return the Codex CLI binary name.""" - return settings.codex_cli_bin or _DEFAULT_BINARY + return _DEFAULT_BINARY def build_codex_launcher_command( diff --git a/cli/managed/claude.py b/cli/managed/claude.py index 69445291..72908af4 100644 --- a/cli/managed/claude.py +++ b/cli/managed/claude.py @@ -9,7 +9,11 @@ from typing import Any from loguru import logger -from cli.claude_env import CLAUDE_CODE_AUTO_COMPACT_WINDOW, claude_auth_token +from cli.claude_env import ( + CLAUDE_BINARY_NAME, + CLAUDE_CODE_AUTO_COMPACT_WINDOW, + claude_auth_token, +) @dataclass(frozen=True, slots=True) @@ -39,7 +43,7 @@ class ManagedClaudeConfig: api_url: str allowed_dirs: list[str] = field(default_factory=list) plans_directory: str | None = None - claude_bin: str = "claude" + claude_bin: str = CLAUDE_BINARY_NAME auth_token: str = "" diff --git a/cli/managed/manager.py b/cli/managed/manager.py index b006ee40..df1ff793 100644 --- a/cli/managed/manager.py +++ b/cli/managed/manager.py @@ -5,6 +5,8 @@ import uuid from loguru import logger +from cli.claude_env import CLAUDE_BINARY_NAME + from .session import ManagedClaudeSession @@ -22,7 +24,7 @@ class ManagedClaudeSessionManager: api_url: str, allowed_dirs: list[str] | None = None, plans_directory: str | None = None, - claude_bin: str = "claude", + claude_bin: str = CLAUDE_BINARY_NAME, auth_token: str = "", *, log_raw_cli_diagnostics: bool = False, diff --git a/config/env_files.py b/config/env_files.py new file mode 100644 index 00000000..98b274d7 --- /dev/null +++ b/config/env_files.py @@ -0,0 +1,93 @@ +"""Dotenv file discovery and explicit dotenv override helpers.""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from dotenv import dotenv_values + +from .paths import managed_env_path + +ANTHROPIC_AUTH_TOKEN_ENV = "ANTHROPIC_AUTH_TOKEN" + + +def repo_env_path() -> Path: + """Return the repo-local env path.""" + + return Path(".env") + + +def explicit_env_path(env: Mapping[str, str] | None = None) -> Path | None: + """Return the explicit FCC_ENV_FILE path, when configured.""" + + source = env if env is not None else os.environ + if explicit := source.get("FCC_ENV_FILE"): + return Path(explicit) + return None + + +def settings_env_files(env: Mapping[str, str] | None = None) -> tuple[Path, ...]: + """Return Settings dotenv files in low-to-high precedence order.""" + + files: list[Path] = [ + repo_env_path(), + managed_env_path(), + ] + if explicit := explicit_env_path(env): + files.append(explicit) + return tuple(files) + + +def configured_env_files(model_config: Mapping[str, Any]) -> tuple[Path, ...]: + """Return the env files currently configured for a Settings model.""" + + configured = model_config.get("env_file") + if configured is None: + return () + if isinstance(configured, (str, Path)): + return (Path(configured),) + return tuple(Path(item) for item in configured) + + +def env_file_value(path: Path, key: str) -> str | None: + """Return a dotenv value when the file explicitly defines the key.""" + + if not path.is_file(): + return None + + try: + values = dotenv_values(path) + except OSError: + return None + + if key not in values: + return None + value = values[key] + return "" if value is None else value + + +def env_file_override(model_config: Mapping[str, Any], key: str) -> str | None: + """Return the last configured dotenv value that explicitly defines a key.""" + + configured_value: str | None = None + for env_file in configured_env_files(model_config): + value = env_file_value(env_file, key) + if value is not None: + configured_value = value + return configured_value + + +def process_env_key_is_effective( + model_config: Mapping[str, Any], + key: str, + env: Mapping[str, str] | None = None, +) -> bool: + """Return whether a key is coming from process env instead of configured dotenv.""" + + source = env if env is not None else os.environ + if env_file_override(model_config, key) is not None: + return False + return key in source diff --git a/config/model_refs.py b/config/model_refs.py new file mode 100644 index 00000000..019496a8 --- /dev/null +++ b/config/model_refs.py @@ -0,0 +1,63 @@ +"""Provider-prefixed model reference helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + + +@dataclass(frozen=True, slots=True) +class ConfiguredChatModelRef: + """A unique configured chat model reference and the env keys that set it.""" + + model_ref: str + provider_id: str + model_id: str + sources: tuple[str, ...] + + +class ChatModelConfig(Protocol): + model: str + model_opus: str | None + model_sonnet: str | None + model_haiku: str | None + + +def parse_provider_type(model_ref: str) -> str: + """Extract provider type from any 'provider/model' string.""" + + return model_ref.split("/", 1)[0] + + +def parse_model_name(model_ref: str) -> str: + """Extract model name from any 'provider/model' string.""" + + return model_ref.split("/", 1)[1] + + +def configured_chat_model_refs( + settings: ChatModelConfig, +) -> tuple[ConfiguredChatModelRef, ...]: + """Return unique configured chat provider/model refs with source env keys.""" + + candidates = ( + ("MODEL", settings.model), + ("MODEL_OPUS", settings.model_opus), + ("MODEL_SONNET", settings.model_sonnet), + ("MODEL_HAIKU", settings.model_haiku), + ) + sources_by_ref: dict[str, list[str]] = {} + for source, model_ref in candidates: + if model_ref is None: + continue + sources_by_ref.setdefault(model_ref, []).append(source) + + return tuple( + ConfiguredChatModelRef( + model_ref=model_ref, + provider_id=parse_provider_type(model_ref), + model_id=parse_model_name(model_ref), + sources=tuple(sources), + ) + for model_ref, sources in sources_by_ref.items() + ) diff --git a/config/settings.py b/config/settings.py index ddd20268..90a6a3e3 100644 --- a/config/settings.py +++ b/config/settings.py @@ -1,79 +1,21 @@ -"""Centralized configuration using Pydantic Settings.""" +"""Flat application settings schema loaded by Pydantic Settings.""" -import os -from collections.abc import Mapping -from dataclasses import dataclass from functools import lru_cache -from pathlib import Path from typing import Any -from dotenv import dotenv_values from pydantic import Field, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from .constants import HTTP_CONNECT_TIMEOUT_DEFAULT +from .env_files import ( + ANTHROPIC_AUTH_TOKEN_ENV, + env_file_override, + settings_env_files, +) from .nim import NimSettings -from .paths import default_claude_workspace_path, managed_env_path from .provider_ids import SUPPORTED_PROVIDER_IDS -@dataclass(frozen=True, slots=True) -class ConfiguredChatModelRef: - """A unique configured chat model reference and the env keys that set it.""" - - model_ref: str - provider_id: str - model_id: str - sources: tuple[str, ...] - - -def _env_files() -> tuple[Path, ...]: - """Return env file paths in priority order (later overrides earlier).""" - files: list[Path] = [ - Path(".env"), - managed_env_path(), - ] - if explicit := os.environ.get("FCC_ENV_FILE"): - files.append(Path(explicit)) - return tuple(files) - - -def _configured_env_files(model_config: Mapping[str, Any]) -> tuple[Path, ...]: - """Return the currently configured env files for Settings.""" - configured = model_config.get("env_file") - if configured is None: - return () - if isinstance(configured, (str, Path)): - return (Path(configured),) - return tuple(Path(item) for item in configured) - - -def _env_file_value(path: Path, key: str) -> str | None: - """Return a dotenv value when the file explicitly defines the key.""" - if not path.is_file(): - return None - - try: - values = dotenv_values(path) - except OSError: - return None - - if key not in values: - return None - value = values[key] - return "" if value is None else value - - -def _env_file_override(model_config: Mapping[str, Any], key: str) -> str | None: - """Return the last configured dotenv value that explicitly defines a key.""" - configured_value: str | None = None - for env_file in _configured_env_files(model_config): - value = _env_file_value(env_file, key) - if value is not None: - configured_value = value - return configured_value - - class Settings(BaseSettings): """Application settings loaded from environment variables.""" @@ -330,24 +272,6 @@ class Settings(BaseSettings): return None return v - @property - def claude_workspace(self) -> str: - """Return the fixed Claude data workspace path.""" - - return str(default_claude_workspace_path()) - - @property - def claude_cli_bin(self) -> str: - """Return the fixed Claude Code binary name.""" - - return "claude" - - @property - def codex_cli_bin(self) -> str: - """Return the fixed Codex CLI binary name.""" - - return "codex" - @field_validator("whisper_device") @classmethod def validate_whisper_device(cls, v: str) -> str: @@ -436,97 +360,13 @@ class Settings(BaseSettings): @model_validator(mode="after") def prefer_dotenv_anthropic_auth_token(self) -> Settings: """Let explicit .env auth config override stale shell/client tokens.""" - dotenv_value = _env_file_override(self.model_config, "ANTHROPIC_AUTH_TOKEN") + dotenv_value = env_file_override(self.model_config, ANTHROPIC_AUTH_TOKEN_ENV) if dotenv_value is not None: self.anthropic_auth_token = dotenv_value return self - def uses_process_anthropic_auth_token(self) -> bool: - """Return whether proxy auth came from process env, not dotenv config.""" - if _env_file_override(self.model_config, "ANTHROPIC_AUTH_TOKEN") is not None: - return False - return bool(os.environ.get("ANTHROPIC_AUTH_TOKEN")) - - @property - def provider_type(self) -> str: - """Extract provider type from the default model string.""" - return Settings.parse_provider_type(self.model) - - @property - def model_name(self) -> str: - """Extract the actual model name from the default model string.""" - return Settings.parse_model_name(self.model) - - def resolve_model(self, claude_model_name: str) -> str: - """Resolve a Claude model name to the configured provider/model string. - - Classifies the incoming Claude model (opus/sonnet/haiku) and - returns the model-specific override if configured, otherwise the fallback MODEL. - """ - name_lower = claude_model_name.lower() - if "opus" in name_lower and self.model_opus is not None: - return self.model_opus - if "haiku" in name_lower and self.model_haiku is not None: - return self.model_haiku - if "sonnet" in name_lower and self.model_sonnet is not None: - return self.model_sonnet - return self.model - - def configured_chat_model_refs(self) -> tuple[ConfiguredChatModelRef, ...]: - """Return unique configured chat provider/model refs with source env keys.""" - candidates = ( - ("MODEL", self.model), - ("MODEL_OPUS", self.model_opus), - ("MODEL_SONNET", self.model_sonnet), - ("MODEL_HAIKU", self.model_haiku), - ) - sources_by_ref: dict[str, list[str]] = {} - for source, model_ref in candidates: - if model_ref is None: - continue - sources_by_ref.setdefault(model_ref, []).append(source) - - return tuple( - ConfiguredChatModelRef( - model_ref=model_ref, - provider_id=Settings.parse_provider_type(model_ref), - model_id=Settings.parse_model_name(model_ref), - sources=tuple(sources), - ) - for model_ref, sources in sources_by_ref.items() - ) - - def resolve_thinking(self, claude_model_name: str) -> bool: - """Resolve whether thinking is enabled for an incoming Claude model name.""" - name_lower = claude_model_name.lower() - if "opus" in name_lower and self.enable_opus_thinking is not None: - return self.enable_opus_thinking - if "haiku" in name_lower and self.enable_haiku_thinking is not None: - return self.enable_haiku_thinking - if "sonnet" in name_lower and self.enable_sonnet_thinking is not None: - return self.enable_sonnet_thinking - return self.enable_model_thinking - - def web_fetch_allowed_scheme_set(self) -> frozenset[str]: - """Return normalized schemes allowed for web_fetch.""" - return frozenset( - part.strip().lower() - for part in self.web_fetch_allowed_schemes.split(",") - if part.strip() - ) - - @staticmethod - def parse_provider_type(model_string: str) -> str: - """Extract provider type from any 'provider/model' string.""" - return model_string.split("/", 1)[0] - - @staticmethod - def parse_model_name(model_string: str) -> str: - """Extract model name from any 'provider/model' string.""" - return model_string.split("/", 1)[1] - model_config = SettingsConfigDict( - env_file=_env_files(), + env_file=settings_env_files(), env_file_encoding="utf-8", extra="ignore", ) diff --git a/providers/runtime/discovery.py b/providers/runtime/discovery.py index f50c82a5..e425d1a8 100644 --- a/providers/runtime/discovery.py +++ b/providers/runtime/discovery.py @@ -8,6 +8,7 @@ from contextlib import suppress from loguru import logger +from config.model_refs import configured_chat_model_refs from config.provider_catalog import PROVIDER_CATALOG from config.settings import Settings from providers.base import BaseProvider @@ -22,7 +23,7 @@ ProviderResolver = Callable[[str], BaseProvider] def referenced_provider_ids(settings: Settings) -> frozenset[str]: """Return provider ids referenced by configured chat model refs.""" - return frozenset(ref.provider_id for ref in settings.configured_chat_model_refs()) + return frozenset(ref.provider_id for ref in configured_chat_model_refs(settings)) def model_list_provider_ids_for_settings(settings: Settings) -> tuple[str, ...]: diff --git a/providers/runtime/validation.py b/providers/runtime/validation.py index 22586ba9..327b2749 100644 --- a/providers/runtime/validation.py +++ b/providers/runtime/validation.py @@ -9,7 +9,8 @@ from collections.abc import Callable import httpx from loguru import logger -from config.settings import ConfiguredChatModelRef, Settings +from config.model_refs import ConfiguredChatModelRef, configured_chat_model_refs +from config.settings import Settings from providers.base import BaseProvider from providers.exceptions import ( AuthenticationError, @@ -52,7 +53,7 @@ class ConfiguredModelValidator: async def validate_configured_models(self) -> None: """Fail unless every configured chat model exists upstream.""" - refs = self._settings.configured_chat_model_refs() + refs = configured_chat_model_refs(self._settings) refs_by_provider: dict[str, list[ConfiguredChatModelRef]] = defaultdict(list) for ref in refs: refs_by_provider[ref.provider_id].append(ref) diff --git a/pyproject.toml b/pyproject.toml index 1c7f26fb..de9aeebe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "free-claude-code" -version = "2.3.18" +version = "2.3.19" description = "Middleware between Claude Code CLI (Anthropic API) and NVIDIA NIM" readme = "README.md" requires-python = ">=3.14.0" diff --git a/smoke/lib/config.py b/smoke/lib/config.py index 0b58fddb..571e559b 100644 --- a/smoke/lib/config.py +++ b/smoke/lib/config.py @@ -7,6 +7,7 @@ from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path +from config.model_refs import parse_model_name, parse_provider_type from config.provider_catalog import PROVIDER_CATALOG, SUPPORTED_PROVIDER_IDS from config.settings import Settings, get_settings @@ -117,7 +118,7 @@ class ProviderModel: @property def model_name(self) -> str: - return Settings.parse_model_name(self.full_model) + return parse_model_name(self.full_model) @dataclass(frozen=True, slots=True) @@ -168,7 +169,7 @@ class SmokeConfig: for source, model in candidates: if not model or model in seen: continue - provider = Settings.parse_provider_type(model) + provider = parse_provider_type(model) if self.provider_matrix and provider not in self.provider_matrix: continue if not self.has_provider_configuration(provider): @@ -300,7 +301,7 @@ def _normalize_provider_model(provider: str, raw_model: str) -> str: raise ValueError(msg) if "/" not in model: return f"{provider}/{model}" - prefix = Settings.parse_provider_type(model) + prefix = parse_provider_type(model) if prefix == provider: return model if prefix in SUPPORTED_PROVIDER_IDS: diff --git a/smoke/product/test_config_extensibility_product_live.py b/smoke/product/test_config_extensibility_product_live.py index 667cada7..046c478f 100644 --- a/smoke/product/test_config_extensibility_product_live.py +++ b/smoke/product/test_config_extensibility_product_live.py @@ -77,12 +77,14 @@ def test_per_model_thinking_config_e2e(smoke_config: SmokeConfig, tmp_path) -> N env = os.environ.copy() env["FCC_ENV_FILE"] = str(env_file) script = ( + "from api.model_router import ModelRouter; " "from config.settings import Settings; " "s=Settings(); " - "print(s.resolve_thinking('claude-opus-4-20250514')); " - "print(s.resolve_thinking('claude-sonnet-4-20250514')); " - "print(s.resolve_thinking('claude-haiku-4-20250514')); " - "print(s.resolve_thinking('unknown-model'))" + "r=ModelRouter(s); " + "print(r.resolve('claude-opus-4-20250514').thinking_enabled); " + "print(r.resolve('claude-sonnet-4-20250514').thinking_enabled); " + "print(r.resolve('claude-haiku-4-20250514').thinking_enabled); " + "print(r.resolve('unknown-model').thinking_enabled)" ) result = subprocess.run( cmd_python_c(script), diff --git a/smoke/product/test_provider_product_live.py b/smoke/product/test_provider_product_live.py index 61882652..2d2f6e12 100644 --- a/smoke/product/test_provider_product_live.py +++ b/smoke/product/test_provider_product_live.py @@ -5,6 +5,7 @@ from typing import Any import httpx import pytest +from api.model_router import ModelRouter from config.provider_catalog import PROVIDER_CATALOG from core.anthropic.stream_contracts import ( SSEEvent, @@ -243,7 +244,9 @@ def _provider_smoke_thinking_enabled( descriptor = PROVIDER_CATALOG[provider_model.provider] return ( "thinking" in descriptor.capabilities - and smoke_config.settings.resolve_thinking("claude-sonnet-4-5-20250929") + and ModelRouter(smoke_config.settings) + .resolve("claude-sonnet-4-5-20250929") + .thinking_enabled ) diff --git a/tests/api/test_app_lifespan_and_errors.py b/tests/api/test_app_lifespan_and_errors.py index 894bf8c9..83fa8a00 100644 --- a/tests/api/test_app_lifespan_and_errors.py +++ b/tests/api/test_app_lifespan_and_errors.py @@ -13,13 +13,15 @@ from providers.exceptions import ServiceUnavailableError from providers.runtime import ProviderRuntime _RUNTIME_EXTRAS = { + "model": "nvidia_nim/test-model", + "model_opus": None, + "model_sonnet": None, + "model_haiku": None, "voice_note_enabled": True, "whisper_model": "base", "whisper_device": "cpu", "hf_token": "", "nvidia_nim_api_key": "", - "claude_cli_bin": "claude", - "uses_process_anthropic_auth_token": lambda: False, "messaging_rate_limit": 1, "messaging_rate_window": 1.0, "max_message_log_entries_per_chat": None, @@ -29,7 +31,6 @@ _RUNTIME_EXTRAS = { "log_raw_messaging_content": False, "log_raw_cli_diagnostics": False, "log_messaging_error_details": False, - "configured_chat_model_refs": lambda: (), } @@ -59,11 +60,18 @@ def _fake_messaging_components(runtime: MagicMock | None = None) -> SimpleNamesp ) -def test_warn_if_process_auth_token_logs_warning(): +@pytest.fixture(autouse=True) +def _redirect_fcc_home(monkeypatch, tmp_path): + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + + +def test_warn_if_process_auth_token_logs_warning(monkeypatch): api_runtime_mod = importlib.import_module("api.runtime") - settings = cast( - Settings, SimpleNamespace(uses_process_anthropic_auth_token=lambda: True) - ) + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "process-token") + monkeypatch.setitem(Settings.model_config, "env_file", ()) + settings = Settings.model_construct() with patch.object(api_runtime_mod.logger, "warning") as warning: api_runtime_mod.warn_if_process_auth_token(settings) @@ -72,11 +80,13 @@ def test_warn_if_process_auth_token_logs_warning(): assert "ANTHROPIC_AUTH_TOKEN" in warning.call_args.args[0] -def test_warn_if_process_auth_token_skips_explicit_dotenv_config(): +def test_warn_if_process_auth_token_skips_explicit_dotenv_config(monkeypatch, tmp_path): api_runtime_mod = importlib.import_module("api.runtime") - settings = cast( - Settings, SimpleNamespace(uses_process_anthropic_auth_token=lambda: False) - ) + env_file = tmp_path / ".env" + env_file.write_text("ANTHROPIC_AUTH_TOKEN=\n", encoding="utf-8") + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "process-token") + monkeypatch.setitem(Settings.model_config, "env_file", (env_file,)) + settings = Settings.model_construct() with patch.object(api_runtime_mod.logger, "warning") as warning: api_runtime_mod.warn_if_process_auth_token(settings) diff --git a/tests/api/test_dependencies.py b/tests/api/test_dependencies.py index b3a74536..0691cca2 100644 --- a/tests/api/test_dependencies.py +++ b/tests/api/test_dependencies.py @@ -24,7 +24,9 @@ def _make_mock_settings(**overrides): """Create a mock settings object with provider runtime fields.""" mock = MagicMock() mock.model = "nvidia_nim/meta/llama3" - mock.provider_type = "nvidia_nim" + mock.model_opus = None + mock.model_sonnet = None + mock.model_haiku = None mock.nvidia_nim_api_key = "test_key" mock.open_router_api_key = "test_openrouter_key" mock.mistral_api_key = "test_mistral_key" @@ -65,7 +67,6 @@ def _make_mock_settings(**overrides): mock.enable_model_thinking = True mock.log_raw_sse_events = False mock.log_api_error_tracebacks = False - mock.configured_chat_model_refs.return_value = () mock.nim = NimSettings() for key, value in overrides.items(): setattr(mock, key, value) diff --git a/tests/config/test_config.py b/tests/config/test_config.py index c0f31cd4..f8bb7f81 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -9,7 +9,14 @@ from config.constants import ( ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, HTTP_CONNECT_TIMEOUT_DEFAULT, ) +from config.env_files import ANTHROPIC_AUTH_TOKEN_ENV, process_env_key_is_effective +from config.model_refs import ( + configured_chat_model_refs, + parse_model_name, + parse_provider_type, +) from config.nim import NimSettings +from config.paths import default_claude_workspace_path class TestSettings: @@ -47,7 +54,7 @@ class TestSettings: assert settings.debug_subagent_stack is False def test_default_claude_workspace_uses_fcc_home(self, monkeypatch, tmp_path): - """Unset CLAUDE_WORKSPACE stores agent data under ~/.fcc.""" + """Unset CLAUDE_WORKSPACE stores agent data under the fixed path helper.""" from config.settings import Settings monkeypatch.setenv("HOME", str(tmp_path)) @@ -57,7 +64,8 @@ class TestSettings: settings = Settings() - assert settings.claude_workspace == str(tmp_path / ".fcc" / "agent_workspace") + assert default_claude_workspace_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.""" @@ -91,7 +99,7 @@ class TestSettings: 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 path.""" + """An explicit blank env value does not affect the fixed workspace helper.""" from config.settings import Settings monkeypatch.setenv("HOME", str(tmp_path)) @@ -101,10 +109,11 @@ class TestSettings: settings = Settings() - assert settings.claude_workspace == str(tmp_path / ".fcc" / "agent_workspace") + assert default_claude_workspace_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.""" + """Custom CLAUDE_WORKSPACE values do not override the fixed workspace helper.""" from config.settings import Settings workspace = tmp_path / "custom-workspace" @@ -115,10 +124,11 @@ class TestSettings: settings = Settings() - assert settings.claude_workspace == str(tmp_path / ".fcc" / "agent_workspace") + assert default_claude_workspace_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 override the fixed binary.""" + """Custom CLAUDE_CLI_BIN values do not become Settings fields.""" from config.settings import Settings monkeypatch.setenv("CLAUDE_CLI_BIN", "claude-custom") @@ -126,10 +136,11 @@ class TestSettings: settings = Settings() - assert settings.claude_cli_bin == "claude" + 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 override fixed Claude runtime settings.""" + """Constructor extras cannot add fixed Claude runtime settings.""" from config.settings import Settings monkeypatch.setenv("HOME", str(tmp_path)) @@ -146,8 +157,9 @@ class TestSettings: ) ) - assert settings.claude_workspace == str(tmp_path / ".fcc" / "agent_workspace") - assert settings.claude_cli_bin == "claude" + assert default_claude_workspace_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.""" @@ -286,26 +298,32 @@ class TestSettings: def test_empty_per_model_thinking_inherits_model_default(self, monkeypatch): """Blank per-model thinking env vars are treated as unset.""" + from api.model_router import ModelRouter from config.settings import Settings monkeypatch.setenv("ENABLE_MODEL_THINKING", "false") monkeypatch.setenv("ENABLE_OPUS_THINKING", "") settings = Settings() assert settings.enable_opus_thinking is None - assert settings.resolve_thinking("claude-opus-4-20250514") is False + assert ( + ModelRouter(settings).resolve("claude-opus-4-20250514").thinking_enabled + is False + ) def test_resolve_thinking_uses_model_tiers(self, monkeypatch): - """resolve_thinking applies tier override then fallback.""" + """ModelRouter applies tier thinking override then fallback.""" + from api.model_router import ModelRouter from config.settings import Settings monkeypatch.setenv("ENABLE_MODEL_THINKING", "false") monkeypatch.setenv("ENABLE_OPUS_THINKING", "true") monkeypatch.setenv("ENABLE_HAIKU_THINKING", "false") settings = Settings() - assert settings.resolve_thinking("claude-opus-4-20250514") is True - assert settings.resolve_thinking("claude-sonnet-4-20250514") is False - assert settings.resolve_thinking("claude-haiku-4-20250514") is False - assert settings.resolve_thinking("unknown-model") is False + router = ModelRouter(settings) + assert router.resolve("claude-opus-4-20250514").thinking_enabled is True + assert router.resolve("claude-sonnet-4-20250514").thinking_enabled is False + assert router.resolve("claude-haiku-4-20250514").thinking_enabled is False + assert router.resolve("unknown-model").thinking_enabled is False 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.""" @@ -315,7 +333,12 @@ class TestSettings: monkeypatch.setitem(Settings.model_config, "env_file", ()) settings = Settings() assert settings.anthropic_auth_token == "process-token" - assert settings.uses_process_anthropic_auth_token() is True + 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 @@ -330,7 +353,12 @@ class TestSettings: settings = Settings() assert settings.anthropic_auth_token == "" - assert settings.uses_process_anthropic_auth_token() is False + 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 @@ -348,7 +376,12 @@ class TestSettings: settings = Settings() assert settings.anthropic_auth_token == "server-token" - assert settings.uses_process_anthropic_auth_token() is False + 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): @@ -585,7 +618,7 @@ class TestSettingsOptionalStr: class TestPerModelMapping: - """Test per-model fields and resolve_model().""" + """Test per-model settings and model-ref helpers.""" def test_model_fields_default_none(self): """Per-model fields default to None.""" @@ -607,13 +640,16 @@ class TestPerModelMapping: @pytest.mark.parametrize("env_var", ["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 api.model_router import ModelRouter from config.settings import Settings monkeypatch.setenv(env_var, "") s = Settings() assert getattr(s, env_var.lower()) is None assert ( - s.resolve_model(f"claude-{env_var.removeprefix('MODEL_').lower()}-4") + ModelRouter(s) + .resolve(f"claude-{env_var.removeprefix('MODEL_').lower()}-4") + .provider_model_ref == s.model ) @@ -694,129 +730,156 @@ class TestPerModelMapping: Settings() def test_resolve_model_opus_override(self): - """resolve_model returns model_opus for opus model names.""" + """ModelRouter returns model_opus for opus model names.""" + from api.model_router import ModelRouter from config.settings import Settings s = Settings() s.model_opus = "open_router/deepseek/deepseek-r1" + router = ModelRouter(s) assert ( - s.resolve_model("claude-opus-4-20250514") + router.resolve("claude-opus-4-20250514").provider_model_ref == "open_router/deepseek/deepseek-r1" ) - assert s.resolve_model("claude-3-opus") == "open_router/deepseek/deepseek-r1" assert ( - s.resolve_model("claude-3-opus-20240229") + 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): - """resolve_model returns model_sonnet for sonnet model names.""" + """ModelRouter returns model_sonnet for sonnet model names.""" + from api.model_router import ModelRouter from config.settings import Settings s = Settings() s.model_sonnet = "nvidia_nim/meta/llama-3.3-70b-instruct" + router = ModelRouter(s) assert ( - s.resolve_model("claude-sonnet-4-20250514") + router.resolve("claude-sonnet-4-20250514").provider_model_ref == "nvidia_nim/meta/llama-3.3-70b-instruct" ) assert ( - s.resolve_model("claude-3-5-sonnet-20241022") + 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): - """resolve_model returns model_haiku for haiku model names.""" + """ModelRouter returns model_haiku for haiku model names.""" + from api.model_router import ModelRouter from config.settings import Settings s = Settings() s.model_haiku = "lmstudio/qwen2.5-7b" - assert s.resolve_model("claude-3-haiku-20240307") == "lmstudio/qwen2.5-7b" - assert s.resolve_model("claude-3-5-haiku-20241022") == "lmstudio/qwen2.5-7b" - assert s.resolve_model("claude-haiku-4-20250514") == "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): - """resolve_model falls back to MODEL when model override is None.""" + """ModelRouter falls back to MODEL when model override is None.""" + from api.model_router import ModelRouter from config.settings import Settings s = Settings() s.model = "nvidia_nim/fallback-model" - # No model overrides set - assert s.resolve_model("claude-opus-4-20250514") == "nvidia_nim/fallback-model" + router = ModelRouter(s) assert ( - s.resolve_model("claude-sonnet-4-20250514") == "nvidia_nim/fallback-model" + 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" ) - assert s.resolve_model("claude-3-haiku-20240307") == "nvidia_nim/fallback-model" def test_resolve_model_unknown_model_falls_back(self): - """resolve_model falls back to MODEL for unrecognized model names.""" + """ModelRouter falls back to MODEL for unrecognized model names.""" + from api.model_router import ModelRouter from config.settings import Settings s = Settings() s.model = "nvidia_nim/fallback-model" s.model_opus = "open_router/opus-model" - assert s.resolve_model("claude-2.1") == "nvidia_nim/fallback-model" - assert s.resolve_model("some-unknown-model") == "nvidia_nim/fallback-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 api.model_router import ModelRouter from config.settings import Settings s = Settings() s.model_opus = "open_router/opus-model" - assert s.resolve_model("Claude-OPUS-4") == "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.""" - from config.settings import Settings - assert Settings.parse_provider_type("nvidia_nim/meta/llama") == "nvidia_nim" - assert Settings.parse_provider_type("open_router/deepseek/r1") == "open_router" + 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 ( - Settings.parse_provider_type("mistral/devstral-small-latest") == "mistral" - ) - assert ( - Settings.parse_provider_type("mistral_codestral/codestral-latest") + parse_provider_type("mistral_codestral/codestral-latest") == "mistral_codestral" ) - assert Settings.parse_provider_type("deepseek/deepseek-chat") == "deepseek" - assert Settings.parse_provider_type("lmstudio/qwen") == "lmstudio" - assert Settings.parse_provider_type("llamacpp/model") == "llamacpp" - assert Settings.parse_provider_type("ollama/llama3.1") == "ollama" - assert Settings.parse_provider_type("wafer/DeepSeek-V4-Pro") == "wafer" - assert ( - Settings.parse_provider_type("gemini/models/gemini-3.1-flash-lite") - == "gemini" - ) - assert Settings.parse_provider_type("groq/llama-3.3-70b-versatile") == "groq" - assert Settings.parse_provider_type("cerebras/llama3.1-8b") == "cerebras" + 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("wafer/DeepSeek-V4-Pro") == "wafer" + 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("cerebras/llama3.1-8b") == "cerebras" def test_parse_model_name(self): """parse_model_name extracts model name from model string.""" - from config.settings import Settings - assert Settings.parse_model_name("nvidia_nim/meta/llama") == "meta/llama" - assert ( - Settings.parse_model_name("mistral/devstral-small-latest") - == "devstral-small-latest" + assert parse_model_name("nvidia_nim/meta/llama") == "meta/llama" + assert parse_model_name("mistral/devstral-small-latest") == ( + "devstral-small-latest" ) assert ( - Settings.parse_model_name("mistral_codestral/codestral-latest") - == "codestral-latest" + parse_model_name("mistral_codestral/codestral-latest") == "codestral-latest" ) - assert Settings.parse_model_name("deepseek/deepseek-chat") == "deepseek-chat" - assert Settings.parse_model_name("lmstudio/qwen") == "qwen" - assert Settings.parse_model_name("llamacpp/model") == "model" - assert Settings.parse_model_name("ollama/llama3.1") == "llama3.1" - assert Settings.parse_model_name("wafer/DeepSeek-V4-Pro") == "DeepSeek-V4-Pro" + 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("wafer/DeepSeek-V4-Pro") == "DeepSeek-V4-Pro" assert ( - Settings.parse_model_name("gemini/models/gemini-3.1-flash-lite") + parse_model_name("gemini/models/gemini-3.1-flash-lite") == "models/gemini-3.1-flash-lite" ) assert ( - Settings.parse_model_name("groq/llama-3.3-70b-versatile") + parse_model_name("groq/llama-3.3-70b-versatile") == "llama-3.3-70b-versatile" ) - assert Settings.parse_model_name("cerebras/llama3.1-8b") == "llama3.1-8b" + assert parse_model_name("cerebras/llama3.1-8b") == "llama3.1-8b" def test_configured_chat_model_refs_collects_unique_models_with_sources( self, monkeypatch @@ -832,7 +895,7 @@ class TestPerModelMapping: s.model_sonnet = "nvidia_nim/fallback" s.model_haiku = None - refs = s.configured_chat_model_refs() + refs = configured_chat_model_refs(s) assert [ref.model_ref for ref in refs] == [ "nvidia_nim/fallback", diff --git a/tests/contracts/test_import_boundaries.py b/tests/contracts/test_import_boundaries.py index fa691964..2bcaad69 100644 --- a/tests/contracts/test_import_boundaries.py +++ b/tests/contracts/test_import_boundaries.py @@ -94,6 +94,31 @@ def test_config_does_not_import_non_config_packages() -> None: assert offenders == [] +def test_settings_stays_schema_only() -> None: + repo_root = Path(__file__).resolve().parents[2] + config_root = repo_root / "config" + + assert (config_root / "env_files.py").exists() + assert (config_root / "model_refs.py").exists() + + settings_text = (config_root / "settings.py").read_text(encoding="utf-8") + for removed_api in { + "def resolve_model", + "def resolve_thinking", + "def configured_chat_model_refs", + "def web_fetch_allowed_scheme_set", + "def parse_provider_type", + "def parse_model_name", + "def uses_process_anthropic_auth_token", + "def claude_workspace", + "def claude_cli_bin", + "def codex_cli_bin", + "def provider_type", + "def model_name", + }: + assert removed_api not in settings_text + + _MESSAGING_ALLOWED_PROVIDER_MODULES = frozenset({"providers.nvidia_nim.voice"}) diff --git a/tests/providers/test_provider_runtime.py b/tests/providers/test_provider_runtime.py index c2706a6a..424a0cbb 100644 --- a/tests/providers/test_provider_runtime.py +++ b/tests/providers/test_provider_runtime.py @@ -30,7 +30,9 @@ from providers.zai import ZaiProvider def _make_settings(**overrides): mock = MagicMock() mock.model = "nvidia_nim/meta/llama3" - mock.provider_type = "nvidia_nim" + mock.model_opus = None + mock.model_sonnet = None + mock.model_haiku = None mock.nvidia_nim_api_key = "test_key" mock.open_router_api_key = "test_openrouter_key" mock.mistral_api_key = "test_mistral_key" diff --git a/uv.lock b/uv.lock index c662c005..0d7e2ba0 100644 --- a/uv.lock +++ b/uv.lock @@ -350,34 +350,34 @@ wheels = [ [package.optional-dependencies] cudart = [ - { name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cuda-runtime" }, ] cufft = [ - { name = "nvidia-cufft", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cufft" }, ] cufile = [ - { name = "nvidia-cufile", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cufile" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cuda-cupti" }, ] curand = [ - { name = "nvidia-curand", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-curand" }, ] cusolver = [ - { name = "nvidia-cusolver", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cusolver" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cusparse" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-nvjitlink" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cuda-nvrtc" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-nvtx" }, ] [[package]] @@ -561,7 +561,7 @@ wheels = [ [[package]] name = "free-claude-code" -version = "2.3.18" +version = "2.3.19" source = { editable = "." } dependencies = [ { name = "aiohttp" },