mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-07-03 14:05:26 +02:00
Refactor settings schema ownership (#927)
This commit is contained in:
+15
-8
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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, ...]:
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user