Refactor settings schema ownership (#927)

This commit is contained in:
Ali Khokhar
2026-06-27 16:21:30 -07:00
committed by GitHub
parent 51157f91bd
commit cdeb1aa9e2
29 changed files with 496 additions and 331 deletions
+15 -8
View File
@@ -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:
+17 -12
View File
@@ -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]:
+2 -1
View File
@@ -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,
+4 -2
View File
@@ -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(
+2 -1
View File
@@ -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(
+30 -5
View File
@@ -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:
+2 -1
View File
@@ -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,
}
+6 -6
View File
@@ -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,
+8
View File
@@ -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
+1
View File
@@ -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"
+10 -7
View File
@@ -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(
+4 -4
View File
@@ -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(
+6 -2
View File
@@ -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 = ""
+3 -1
View File
@@ -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,
+93
View File
@@ -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
+63
View File
@@ -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()
)
+8 -168
View File
@@ -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",
)
+2 -1
View File
@@ -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, ...]:
+3 -2
View File
@@ -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)
+1 -1
View File
@@ -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"
+4 -3
View File
@@ -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:
@@ -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),
+4 -1
View File
@@ -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
)
+21 -11
View File
@@ -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)
+3 -2
View File
@@ -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)
+139 -76
View File
@@ -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",
+25
View File
@@ -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"})
+3 -1
View File
@@ -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"
Generated
+11 -11
View File
@@ -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" },