diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index af3a2e03..0643d09d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -121,9 +121,9 @@ new places to add unrelated behavior: dependencies. Inbound turn intake, queued node execution, slash command dependencies, and tree queue internals live in separate modules so new behavior has one owner instead of growing the workflow object. -- [api/admin_config.py](api/admin_config.py) owns the admin config manifest, - validation, env rendering, and status metadata. Keep it data-driven, and split - only around cohesive admin responsibilities. +- [api/admin_config/](api/admin_config/) owns Admin UI config behavior. Keep + provider fields catalog-driven, and keep manifest, source loading, validation, + env rendering, value presentation, and status metadata in their package owners. ## Runtime Startup And Lifecycle @@ -194,12 +194,19 @@ Model routing configuration is tiered: - `ENABLE_OPUS_THINKING`, `ENABLE_SONNET_THINKING`, and `ENABLE_HAIKU_THINKING` optionally override thinking by tier. -[api/admin_config.py](api/admin_config.py) defines the Admin UI config manifest -and writes managed env updates. [api/admin_routes.py](api/admin_routes.py) -exposes local-only admin endpoints that load, validate, apply, and test config. -After an apply, settings are cache-cleared. Depending on the changed fields, the -server either replaces the app provider runtime or asks the supervised server to -restart. +[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); +admin-only help text stays beside the admin manifest. The package splits source +loading, value presentation, validation, persistence, and provider status into +separate modules. [api/admin_routes.py](api/admin_routes.py) exposes local-only +admin endpoints that load, validate, apply, and test config. After an apply, +settings are cache-cleared. Depending on the changed fields, the server either +replaces the app provider runtime or asks the supervised server to restart. + +[.env.example](.env.example) is the single install/init/admin template source. +It is packaged as a [config/](config/) resource for `fcc-init` and Admin UI +template defaults; runtime settings do not read it as a live config file. Admin routes call `require_loopback_admin()`, which rejects non-loopback clients and non-local origins. @@ -340,8 +347,10 @@ where supported, and returning Anthropic SSE strings to the service layer. 1. Add provider metadata to [config/provider_catalog.py](config/provider_catalog.py). 2. Add credentials and related settings to [config/settings.py](config/settings.py) and [.env.example](.env.example) when user configurable. -3. Add admin manifest fields in [api/admin_config.py](api/admin_config.py) when - the setting should be editable in the Admin UI. +3. Add provider metadata to [config/provider_catalog.py](config/provider_catalog.py); + Admin UI provider fields are generated from the catalog. Add admin-only help + text under [api/admin_config/](api/admin_config/) only when the generated + labels/descriptions are insufficient. 4. Implement the provider under [providers/](providers/) using the appropriate shared transport family. 5. Add a factory in [providers/runtime/factory.py](providers/runtime/factory.py). @@ -644,7 +653,9 @@ when maintainers want branch-level assurance. 1. Add or expose the setting in [config/settings.py](config/settings.py). 2. Add the template key to [.env.example](.env.example) if users configure it. -3. Add a `ConfigFieldSpec` in [api/admin_config.py](api/admin_config.py). +3. Add a `ConfigFieldSpec` under [api/admin_config/](api/admin_config/), or add + provider catalog metadata when the setting is provider credential, local URL, + proxy, or display-name metadata. 4. Mark `restart_required` or `session_sensitive` when runtime state cannot be updated in place. 5. Add tests under [tests/api/](tests/api/) or [tests/config/](tests/config/). diff --git a/api/admin_config.py b/api/admin_config.py deleted file mode 100644 index e416aa3e..00000000 --- a/api/admin_config.py +++ /dev/null @@ -1,1303 +0,0 @@ -"""Admin UI configuration manifest and managed env persistence.""" - -from __future__ import annotations - -import os -from collections.abc import Iterable, Mapping -from dataclasses import dataclass -from io import StringIO -from pathlib import Path -from typing import Any, Literal - -from dotenv import dotenv_values -from pydantic import ValidationError - -from config.paths import managed_env_path -from config.provider_catalog import PROVIDER_CATALOG -from config.settings import Settings - -FieldType = Literal[ - "text", - "secret", - "number", - "boolean", - "tri_boolean", - "select", - "textarea", -] -SourceType = Literal[ - "default", - "template", - "repo_env", - "managed_env", - "explicit_env_file", - "process", -] - -MASKED_SECRET = "********" - - -@dataclass(frozen=True, slots=True) -class ConfigSectionSpec: - """A group of config fields rendered together in the admin UI.""" - - section_id: str - label: str - description: str - advanced: bool = False - - -@dataclass(frozen=True, slots=True) -class ConfigFieldSpec: - """Typed metadata for one env-backed admin setting.""" - - key: str - label: str - section_id: str - field_type: FieldType = "text" - settings_attr: str | None = None - default: str = "" - options: tuple[str, ...] = () - secret: bool = False - advanced: bool = False - restart_required: bool = False - session_sensitive: bool = False - description: str = "" - - -SECTIONS: tuple[ConfigSectionSpec, ...] = ( - ConfigSectionSpec( - "providers", - "Providers", - "Provider keys, local endpoints, and proxy settings.", - ), - ConfigSectionSpec( - "models", - "Model Routing", - "Provider-prefixed models used for Claude model tiers.", - ), - ConfigSectionSpec( - "thinking", - "Thinking", - "Global and tier-specific thinking behavior.", - ), - ConfigSectionSpec( - "runtime", - "Runtime", - "Server API token, rate limits, timeouts, and process settings.", - ), - ConfigSectionSpec( - "messaging", - "Messaging", - "Discord, Telegram, CLI workspace, and session settings.", - ), - ConfigSectionSpec( - "voice", - "Voice", - "Voice note transcription settings.", - ), - ConfigSectionSpec( - "web_tools", - "Web Tools", - "Local Anthropic web_search and web_fetch behavior.", - ), - ConfigSectionSpec( - "diagnostics", - "Diagnostics", - "Logging and debugging flags.", - advanced=True, - ), - ConfigSectionSpec( - "smoke", - "Smoke Tests", - "Optional live smoke-test model overrides.", - advanced=True, - ), -) - - -FIELDS: tuple[ConfigFieldSpec, ...] = ( - ConfigFieldSpec( - "NVIDIA_NIM_API_KEY", - "NVIDIA NIM API Key", - "providers", - "secret", - settings_attr="nvidia_nim_api_key", - secret=True, - description="Used by NVIDIA NIM chat and optional NIM voice transcription.", - ), - ConfigFieldSpec( - "OPENROUTER_API_KEY", - "OpenRouter API Key", - "providers", - "secret", - settings_attr="open_router_api_key", - secret=True, - ), - ConfigFieldSpec( - "MISTRAL_API_KEY", - "Mistral API Key", - "providers", - "secret", - settings_attr="mistral_api_key", - secret=True, - description=( - "Mistral La Plateforme (api.mistral.ai); Experiment plan is free tier with rate limits." - ), - ), - ConfigFieldSpec( - "CODESTRAL_API_KEY", - "Codestral API Key", - "providers", - "secret", - settings_attr="codestral_api_key", - secret=True, - description=( - "Mistral Codestral endpoint (codestral.mistral.ai); distinct from Mistral " - "La Plateforme ``MISTRAL_API_KEY``. See Mistral docs for coding/FIM domains." - ), - ), - ConfigFieldSpec( - "DEEPSEEK_API_KEY", - "DeepSeek API Key", - "providers", - "secret", - settings_attr="deepseek_api_key", - secret=True, - ), - ConfigFieldSpec( - "KIMI_API_KEY", - "Kimi API Key", - "providers", - "secret", - settings_attr="kimi_api_key", - secret=True, - ), - ConfigFieldSpec( - "WAFER_API_KEY", - "Wafer API Key", - "providers", - "secret", - settings_attr="wafer_api_key", - secret=True, - ), - ConfigFieldSpec( - "OPENCODE_API_KEY", - "OpenCode API Key", - "providers", - "secret", - settings_attr="opencode_api_key", - secret=True, - description=( - "OpenCode Zen curated gateway (opencode.ai/zen/v1) and OpenCode Go subscription " - "gateway (opencode.ai/zen/go/v1); single key from opencode.ai/auth." - ), - ), - ConfigFieldSpec( - "ZAI_API_KEY", - "Z.ai API Key", - "providers", - "secret", - settings_attr="zai_api_key", - secret=True, - description="Z.ai Coding Plan API key.", - ), - ConfigFieldSpec( - "FIREWORKS_API_KEY", - "Fireworks API Key", - "providers", - "secret", - settings_attr="fireworks_api_key", - secret=True, - description="Fireworks AI inference API key.", - ), - ConfigFieldSpec( - "GEMINI_API_KEY", - "Gemini API Key", - "providers", - "secret", - settings_attr="gemini_api_key", - secret=True, - description=( - "Google AI Studio Gemini API key (Google AI Studio / Gemini API " - "[OpenAI-compatible](https://ai.google.dev/gemini-api/docs/openai)); " - "free tier has per-model rate limits and data may be used for improvement " - "outside the UK/CH/EEA/EU." - ), - ), - ConfigFieldSpec( - "GROQ_API_KEY", - "Groq API Key", - "providers", - "secret", - settings_attr="groq_api_key", - secret=True, - description=( - "GroqCloud OpenAI-compatible API key ([console.groq.com/keys](" - "https://console.groq.com/keys)); see Groq " - "[OpenAI compatibility docs](https://console.groq.com/docs/openai)." - ), - ), - ConfigFieldSpec( - "CEREBRAS_API_KEY", - "Cerebras API Key", - "providers", - "secret", - settings_attr="cerebras_api_key", - secret=True, - description=( - "Cerebras Inference API key (create in [Cloud Console](https://cloud.cerebras.ai)); " - "see [Quickstart](https://inference-docs.cerebras.ai/quickstart) and " - "[OpenAI compatibility](https://inference-docs.cerebras.ai/resources/openai)." - ), - ), - ConfigFieldSpec( - "LM_STUDIO_BASE_URL", - "LM Studio Base URL", - "providers", - settings_attr="lm_studio_base_url", - default="http://localhost:1234/v1", - ), - ConfigFieldSpec( - "LLAMACPP_BASE_URL", - "llama.cpp Base URL", - "providers", - settings_attr="llamacpp_base_url", - default="http://localhost:8080/v1", - ), - ConfigFieldSpec( - "OLLAMA_BASE_URL", - "Ollama Base URL", - "providers", - settings_attr="ollama_base_url", - default="http://localhost:11434", - ), - ConfigFieldSpec( - "NVIDIA_NIM_PROXY", - "NVIDIA NIM Proxy", - "providers", - "secret", - settings_attr="nvidia_nim_proxy", - secret=True, - advanced=True, - ), - ConfigFieldSpec( - "OPENROUTER_PROXY", - "OpenRouter Proxy", - "providers", - "secret", - settings_attr="open_router_proxy", - secret=True, - advanced=True, - ), - ConfigFieldSpec( - "MISTRAL_PROXY", - "Mistral Proxy", - "providers", - "secret", - settings_attr="mistral_proxy", - secret=True, - advanced=True, - ), - ConfigFieldSpec( - "CODESTRAL_PROXY", - "Codestral Proxy", - "providers", - "secret", - settings_attr="codestral_proxy", - secret=True, - advanced=True, - ), - ConfigFieldSpec( - "LMSTUDIO_PROXY", - "LM Studio Proxy", - "providers", - "secret", - settings_attr="lmstudio_proxy", - secret=True, - advanced=True, - ), - ConfigFieldSpec( - "LLAMACPP_PROXY", - "llama.cpp Proxy", - "providers", - "secret", - settings_attr="llamacpp_proxy", - secret=True, - advanced=True, - ), - ConfigFieldSpec( - "KIMI_PROXY", - "Kimi Proxy", - "providers", - "secret", - settings_attr="kimi_proxy", - secret=True, - advanced=True, - ), - ConfigFieldSpec( - "WAFER_PROXY", - "Wafer Proxy", - "providers", - "secret", - settings_attr="wafer_proxy", - secret=True, - advanced=True, - ), - ConfigFieldSpec( - "OPENCODE_PROXY", - "OpenCode Zen Proxy", - "providers", - "secret", - settings_attr="opencode_proxy", - secret=True, - advanced=True, - ), - ConfigFieldSpec( - "OPENCODE_GO_PROXY", - "OpenCode Go Proxy", - "providers", - "secret", - settings_attr="opencode_go_proxy", - secret=True, - advanced=True, - ), - ConfigFieldSpec( - "ZAI_PROXY", - "Z.ai Proxy", - "providers", - "secret", - settings_attr="zai_proxy", - secret=True, - advanced=True, - ), - ConfigFieldSpec( - "FIREWORKS_PROXY", - "Fireworks Proxy", - "providers", - "secret", - settings_attr="fireworks_proxy", - secret=True, - advanced=True, - ), - ConfigFieldSpec( - "GEMINI_PROXY", - "Gemini Proxy", - "providers", - "secret", - settings_attr="gemini_proxy", - secret=True, - advanced=True, - ), - ConfigFieldSpec( - "GROQ_PROXY", - "Groq Proxy", - "providers", - "secret", - settings_attr="groq_proxy", - secret=True, - advanced=True, - ), - ConfigFieldSpec( - "CEREBRAS_PROXY", - "Cerebras Proxy", - "providers", - "secret", - settings_attr="cerebras_proxy", - secret=True, - advanced=True, - ), - ConfigFieldSpec( - "MODEL", - "Default Model", - "models", - settings_attr="model", - default="nvidia_nim/nvidia/nemotron-3-super-120b-a12b", - description="Fallback provider/model route for all Claude model names.", - ), - ConfigFieldSpec( - "MODEL_OPUS", - "Opus Override", - "models", - settings_attr="model_opus", - description="Optional provider/model route for Opus requests.", - ), - ConfigFieldSpec( - "MODEL_SONNET", - "Sonnet Override", - "models", - settings_attr="model_sonnet", - description="Optional provider/model route for Sonnet requests.", - ), - ConfigFieldSpec( - "MODEL_HAIKU", - "Haiku Override", - "models", - settings_attr="model_haiku", - description="Optional provider/model route for Haiku requests.", - ), - ConfigFieldSpec( - "ENABLE_MODEL_THINKING", - "Enable Thinking", - "thinking", - "boolean", - settings_attr="enable_model_thinking", - default="true", - ), - ConfigFieldSpec( - "ENABLE_OPUS_THINKING", - "Opus Thinking", - "thinking", - "tri_boolean", - settings_attr="enable_opus_thinking", - description="Blank inherits Enable Thinking.", - ), - ConfigFieldSpec( - "ENABLE_SONNET_THINKING", - "Sonnet Thinking", - "thinking", - "tri_boolean", - settings_attr="enable_sonnet_thinking", - description="Blank inherits Enable Thinking.", - ), - ConfigFieldSpec( - "ENABLE_HAIKU_THINKING", - "Haiku Thinking", - "thinking", - "tri_boolean", - settings_attr="enable_haiku_thinking", - description="Blank inherits Enable Thinking.", - ), - ConfigFieldSpec( - "ANTHROPIC_AUTH_TOKEN", - "API/CLI Auth Token", - "runtime", - "secret", - settings_attr="anthropic_auth_token", - default="freecc", - secret=True, - description="Protects Claude/API access. It is not admin-page login.", - ), - ConfigFieldSpec( - "PROVIDER_RATE_LIMIT", - "Provider Rate Limit", - "runtime", - "number", - settings_attr="provider_rate_limit", - default="1", - ), - ConfigFieldSpec( - "PROVIDER_RATE_WINDOW", - "Provider Rate Window", - "runtime", - "number", - settings_attr="provider_rate_window", - default="3", - ), - ConfigFieldSpec( - "PROVIDER_MAX_CONCURRENCY", - "Provider Max Concurrency", - "runtime", - "number", - settings_attr="provider_max_concurrency", - default="5", - ), - ConfigFieldSpec( - "HTTP_READ_TIMEOUT", - "HTTP Read Timeout", - "runtime", - "number", - settings_attr="http_read_timeout", - default="300", - ), - ConfigFieldSpec( - "HTTP_WRITE_TIMEOUT", - "HTTP Write Timeout", - "runtime", - "number", - settings_attr="http_write_timeout", - default="60", - ), - ConfigFieldSpec( - "HTTP_CONNECT_TIMEOUT", - "HTTP Connect Timeout", - "runtime", - "number", - settings_attr="http_connect_timeout", - default="60", - ), - ConfigFieldSpec( - "HOST", - "Server Host", - "runtime", - settings_attr="host", - default="0.0.0.0", - restart_required=True, - ), - ConfigFieldSpec( - "PORT", - "Server Port", - "runtime", - "number", - settings_attr="port", - default="8082", - restart_required=True, - ), - ConfigFieldSpec( - "MESSAGING_PLATFORM", - "Messaging Platform", - "messaging", - "select", - settings_attr="messaging_platform", - default="discord", - options=("telegram", "discord", "none"), - session_sensitive=True, - ), - ConfigFieldSpec( - "MESSAGING_RATE_LIMIT", - "Messaging Rate Limit", - "messaging", - "number", - settings_attr="messaging_rate_limit", - default="1", - session_sensitive=True, - ), - ConfigFieldSpec( - "MESSAGING_RATE_WINDOW", - "Messaging Rate Window", - "messaging", - "number", - settings_attr="messaging_rate_window", - default="1", - session_sensitive=True, - ), - ConfigFieldSpec( - "TELEGRAM_BOT_TOKEN", - "Telegram Bot Token", - "messaging", - "secret", - settings_attr="telegram_bot_token", - secret=True, - session_sensitive=True, - ), - ConfigFieldSpec( - "ALLOWED_TELEGRAM_USER_ID", - "Allowed Telegram User ID", - "messaging", - settings_attr="allowed_telegram_user_id", - session_sensitive=True, - ), - ConfigFieldSpec( - "DISCORD_BOT_TOKEN", - "Discord Bot Token", - "messaging", - "secret", - settings_attr="discord_bot_token", - secret=True, - session_sensitive=True, - ), - ConfigFieldSpec( - "ALLOWED_DISCORD_CHANNELS", - "Allowed Discord Channels", - "messaging", - settings_attr="allowed_discord_channels", - session_sensitive=True, - ), - ConfigFieldSpec( - "ALLOWED_DIR", - "Allowed Directory", - "messaging", - settings_attr="allowed_dir", - session_sensitive=True, - ), - ConfigFieldSpec( - "MAX_MESSAGE_LOG_ENTRIES_PER_CHAT", - "Max Message Log Entries", - "messaging", - "number", - settings_attr="max_message_log_entries_per_chat", - advanced=True, - session_sensitive=True, - ), - ConfigFieldSpec( - "VOICE_NOTE_ENABLED", - "Voice Notes", - "voice", - "boolean", - settings_attr="voice_note_enabled", - default="false", - session_sensitive=True, - ), - ConfigFieldSpec( - "WHISPER_DEVICE", - "Whisper Device", - "voice", - "select", - settings_attr="whisper_device", - default="nvidia_nim", - options=("cpu", "cuda", "nvidia_nim"), - session_sensitive=True, - ), - ConfigFieldSpec( - "WHISPER_MODEL", - "Whisper Model", - "voice", - settings_attr="whisper_model", - default="openai/whisper-large-v3", - session_sensitive=True, - ), - ConfigFieldSpec( - "HF_TOKEN", - "Hugging Face Token", - "voice", - "secret", - settings_attr="hf_token", - secret=True, - session_sensitive=True, - ), - ConfigFieldSpec( - "FAST_PREFIX_DETECTION", - "Fast Prefix Detection", - "runtime", - "boolean", - settings_attr="fast_prefix_detection", - default="true", - advanced=True, - ), - ConfigFieldSpec( - "ENABLE_NETWORK_PROBE_MOCK", - "Network Probe Mock", - "runtime", - "boolean", - settings_attr="enable_network_probe_mock", - default="true", - advanced=True, - ), - ConfigFieldSpec( - "ENABLE_TITLE_GENERATION_SKIP", - "Title Generation Skip", - "runtime", - "boolean", - settings_attr="enable_title_generation_skip", - default="true", - advanced=True, - ), - ConfigFieldSpec( - "ENABLE_SUGGESTION_MODE_SKIP", - "Suggestion Mode Skip", - "runtime", - "boolean", - settings_attr="enable_suggestion_mode_skip", - default="true", - advanced=True, - ), - ConfigFieldSpec( - "ENABLE_FILEPATH_EXTRACTION_MOCK", - "Filepath Extraction Mock", - "runtime", - "boolean", - settings_attr="enable_filepath_extraction_mock", - default="true", - advanced=True, - ), - ConfigFieldSpec( - "ENABLE_WEB_SERVER_TOOLS", - "Web Server Tools", - "web_tools", - "boolean", - settings_attr="enable_web_server_tools", - default="true", - ), - ConfigFieldSpec( - "WEB_FETCH_ALLOWED_SCHEMES", - "Allowed Web Fetch Schemes", - "web_tools", - settings_attr="web_fetch_allowed_schemes", - default="http,https", - ), - ConfigFieldSpec( - "WEB_FETCH_ALLOW_PRIVATE_NETWORKS", - "Allow Private Networks", - "web_tools", - "boolean", - settings_attr="web_fetch_allow_private_networks", - default="false", - ), - ConfigFieldSpec( - "DEBUG_PLATFORM_EDITS", - "Debug Platform Edits", - "diagnostics", - "boolean", - settings_attr="debug_platform_edits", - default="false", - advanced=True, - ), - ConfigFieldSpec( - "DEBUG_SUBAGENT_STACK", - "Debug Subagent Stack", - "diagnostics", - "boolean", - settings_attr="debug_subagent_stack", - default="false", - advanced=True, - ), - ConfigFieldSpec( - "LOG_RAW_API_PAYLOADS", - "Log Raw API Payloads", - "diagnostics", - "boolean", - settings_attr="log_raw_api_payloads", - default="false", - advanced=True, - ), - ConfigFieldSpec( - "LOG_RAW_SSE_EVENTS", - "Log Raw SSE Events", - "diagnostics", - "boolean", - settings_attr="log_raw_sse_events", - default="false", - advanced=True, - ), - ConfigFieldSpec( - "LOG_API_ERROR_TRACEBACKS", - "Log API Error Tracebacks", - "diagnostics", - "boolean", - settings_attr="log_api_error_tracebacks", - default="false", - advanced=True, - ), - ConfigFieldSpec( - "LOG_RAW_MESSAGING_CONTENT", - "Log Raw Messaging Content", - "diagnostics", - "boolean", - settings_attr="log_raw_messaging_content", - default="false", - advanced=True, - ), - ConfigFieldSpec( - "LOG_RAW_CLI_DIAGNOSTICS", - "Log Raw CLI Diagnostics", - "diagnostics", - "boolean", - settings_attr="log_raw_cli_diagnostics", - default="false", - advanced=True, - ), - ConfigFieldSpec( - "LOG_MESSAGING_ERROR_DETAILS", - "Log Messaging Error Details", - "diagnostics", - "boolean", - settings_attr="log_messaging_error_details", - default="false", - advanced=True, - ), - ConfigFieldSpec( - "FCC_SMOKE_MODEL_NVIDIA_NIM", - "Smoke NVIDIA NIM Model", - "smoke", - advanced=True, - ), - ConfigFieldSpec( - "FCC_SMOKE_MODEL_OPEN_ROUTER", - "Smoke OpenRouter Model", - "smoke", - advanced=True, - ), - ConfigFieldSpec( - "FCC_SMOKE_MODEL_MISTRAL", - "Smoke Mistral Model", - "smoke", - advanced=True, - ), - ConfigFieldSpec( - "FCC_SMOKE_MODEL_MISTRAL_CODESTRAL", - "Smoke Mistral Codestral Model", - "smoke", - advanced=True, - ), - ConfigFieldSpec( - "FCC_SMOKE_MODEL_DEEPSEEK", - "Smoke DeepSeek Model", - "smoke", - advanced=True, - ), - ConfigFieldSpec( - "FCC_SMOKE_MODEL_LMSTUDIO", - "Smoke LM Studio Model", - "smoke", - advanced=True, - ), - ConfigFieldSpec( - "FCC_SMOKE_MODEL_LLAMACPP", - "Smoke llama.cpp Model", - "smoke", - advanced=True, - ), - ConfigFieldSpec( - "FCC_SMOKE_MODEL_OLLAMA", - "Smoke Ollama Model", - "smoke", - advanced=True, - ), - ConfigFieldSpec( - "FCC_SMOKE_MODEL_KIMI", - "Smoke Kimi Model", - "smoke", - advanced=True, - ), - ConfigFieldSpec( - "FCC_SMOKE_MODEL_WAFER", - "Smoke Wafer Model", - "smoke", - advanced=True, - ), - ConfigFieldSpec( - "FCC_SMOKE_MODEL_OPENCODE", - "Smoke OpenCode Zen Model", - "smoke", - advanced=True, - ), - ConfigFieldSpec( - "FCC_SMOKE_MODEL_OPENCODE_GO", - "Smoke OpenCode Go Model", - "smoke", - advanced=True, - ), - ConfigFieldSpec( - "FCC_SMOKE_MODEL_ZAI", - "Smoke Z.ai Model", - "smoke", - advanced=True, - ), - ConfigFieldSpec( - "FCC_SMOKE_MODEL_FIREWORKS", - "Smoke Fireworks Model", - "smoke", - advanced=True, - ), - ConfigFieldSpec( - "FCC_SMOKE_MODEL_GEMINI", - "Smoke Gemini Model", - "smoke", - advanced=True, - ), - ConfigFieldSpec( - "FCC_SMOKE_MODEL_GROQ", - "Smoke Groq Model", - "smoke", - advanced=True, - ), - ConfigFieldSpec( - "FCC_SMOKE_MODEL_CEREBRAS", - "Smoke Cerebras Model", - "smoke", - advanced=True, - ), - ConfigFieldSpec( - "FCC_SMOKE_NIM_MODELS", - "Smoke NIM Models", - "smoke", - advanced=True, - ), - ConfigFieldSpec( - "FCC_SMOKE_NIM_EXTRA_MODELS", - "Smoke NIM Extra Models", - "smoke", - advanced=True, - ), - ConfigFieldSpec( - "FCC_SMOKE_OPENROUTER_FREE_MODELS", - "Smoke OpenRouter Free Models", - "smoke", - advanced=True, - ), - ConfigFieldSpec( - "FCC_SMOKE_OPENROUTER_FREE_EXTRA_MODELS", - "Smoke OpenRouter Free Extra Models", - "smoke", - advanced=True, - ), -) - -FIELD_BY_KEY = {field.key: field for field in FIELDS} - - -def repo_env_path() -> Path: - """Return the repo-local env path.""" - - return Path(".env") - - -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 - - -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) - - -def _template_text() -> str: - import importlib.resources - - packaged = importlib.resources.files("cli").joinpath("env.example") - if packaged.is_file(): - return packaged.read_text("utf-8") - - source_template = Path(__file__).resolve().parents[1] / ".env.example" - if source_template.is_file(): - return source_template.read_text(encoding="utf-8") - - return "" - - -def _dotenv_values_from_text(text: str) -> dict[str, str]: - values = dotenv_values(stream=StringIO(text)) - return {key: "" if value is None else value for key, value in values.items()} - - -def template_values() -> dict[str, str]: - """Return .env.example values plus manifest defaults for newer fields.""" - - values = _dotenv_values_from_text(_template_text()) - for field in FIELDS: - values.setdefault(field.key, field.default) - return values - - -def _dotenv_values_from_file(path: Path) -> dict[str, str]: - if not path.is_file(): - return {} - values = dotenv_values(path) - return {key: "" if value is None else value for key, value in values.items()} - - -def _field_input_key(field: ConfigFieldSpec) -> str | None: - if field.settings_attr is None: - return None - model_field = Settings.model_fields[field.settings_attr] - alias = model_field.validation_alias - if alias is None: - return field.settings_attr - return str(alias) - - -def _is_locked_source(source: SourceType) -> bool: - return source in {"process", "explicit_env_file"} - - -def _normalize_for_env(value: Any) -> str: - if value is None: - return "" - if isinstance(value, bool): - return "true" if value else "false" - return str(value) - - -def _display_value(field: ConfigFieldSpec, value: str) -> str: - if field.secret and value: - return MASKED_SECRET - return value - - -def _load_value_state() -> dict[str, dict[str, Any]]: - values = template_values() - sources: dict[str, SourceType] = { - key: "template" if key in values else "default" for key in FIELD_BY_KEY - } - - for source, path in configured_env_files(): - file_values = _dotenv_values_from_file(path) - for key, value in file_values.items(): - if key in FIELD_BY_KEY: - values[key] = value - sources[key] = source - - for key in FIELD_BY_KEY: - if key in os.environ: - values[key] = os.environ[key] - sources[key] = "process" - - return { - key: { - "value": values.get(key, ""), - "source": sources.get(key, "default"), - } - for key in FIELD_BY_KEY - } - - -def load_config_response() -> dict[str, Any]: - """Return manifest and current config values for the admin UI.""" - - state = _load_value_state() - fields: list[dict[str, Any]] = [] - for field in FIELDS: - entry = state[field.key] - source = entry["source"] - raw_value = entry["value"] - fields.append( - { - "key": field.key, - "label": field.label, - "section": field.section_id, - "type": field.field_type, - "value": _display_value(field, raw_value), - "configured": bool(str(raw_value).strip()), - "source": source, - "locked": _is_locked_source(source), - "secret": field.secret, - "advanced": field.advanced, - "restart_required": field.restart_required, - "session_sensitive": field.session_sensitive, - "options": list(field.options), - "description": field.description, - } - ) - - return { - "sections": [ - { - "id": section.section_id, - "label": section.label, - "description": section.description, - "advanced": section.advanced, - } - for section in SECTIONS - ], - "fields": fields, - "paths": { - "managed": str(managed_env_path()), - "repo": str(repo_env_path()), - "explicit": str(explicit_env_path()) if explicit_env_path() else None, - }, - "provider_status": provider_config_status(state), - } - - -def _target_values_with_updates(updates: Mapping[str, Any]) -> dict[str, str]: - state = _load_value_state() - values = template_values() - - # Preserve existing managed values when present. If no managed config exists, - # seed the first write from effective repo values to migrate legacy setups. - managed_values = _dotenv_values_from_file(managed_env_path()) - if managed_values: - values.update( - {key: val for key, val in managed_values.items() if key in values} - ) - else: - for key, entry in state.items(): - if entry["source"] in {"repo_env", "template", "default"}: - values[key] = str(entry["value"]) - - for key, value in updates.items(): - field = FIELD_BY_KEY.get(key) - if field is None: - continue - if _is_locked_source(state[key]["source"]): - continue - if field.secret and value == MASKED_SECRET: - continue - values[key] = _normalize_for_env(value) - - for field in FIELDS: - values.setdefault(field.key, field.default) - return values - - -def _effective_values_for_validation( - target_values: Mapping[str, str], -) -> dict[str, str]: - values = dict(target_values) - for key, entry in _load_value_state().items(): - if _is_locked_source(entry["source"]): - values[key] = str(entry["value"]) - return values - - -def validate_values(values: Mapping[str, str]) -> tuple[bool, list[str]]: - """Validate proposed env values against the Settings model.""" - - kwargs: dict[str, Any] = {"_env_file": None} - for field in FIELDS: - input_key = _field_input_key(field) - if input_key is None: - continue - kwargs[input_key] = values.get(field.key, "") - - try: - Settings(**kwargs) - except ValidationError as exc: - return False, _format_validation_errors(exc) - return True, [] - - -def _format_validation_errors(exc: ValidationError) -> list[str]: - errors: list[str] = [] - for error in exc.errors(): - loc = ".".join(str(part) for part in error.get("loc", ())) - message = str(error.get("msg", "Invalid value")) - errors.append(f"{loc}: {message}" if loc else message) - return errors - - -def validate_updates(updates: Mapping[str, Any]) -> dict[str, Any]: - """Validate partial admin updates and return a masked generated env preview.""" - - target_values = _target_values_with_updates(updates) - effective_values = _effective_values_for_validation(target_values) - valid, errors = validate_values(effective_values) - return { - "valid": valid, - "errors": errors, - "env_preview": render_env_file(target_values, mask_secrets=True), - } - - -def changed_pending_fields(updates: Mapping[str, Any]) -> list[str]: - """Return changed fields that require manual runtime action.""" - - state = _load_value_state() - pending: list[str] = [] - for key, value in updates.items(): - field = FIELD_BY_KEY.get(key) - if field is None or not (field.restart_required or field.session_sensitive): - continue - if _normalize_for_env(value) == str(state[key]["value"]): - continue - pending.append(key) - return pending - - -def write_managed_env(updates: Mapping[str, Any]) -> dict[str, Any]: - """Validate and atomically write the admin-managed env file.""" - - validation = validate_updates(updates) - if not validation["valid"]: - return validation | {"applied": False, "pending_fields": []} - - target_values = _target_values_with_updates(updates) - pending_fields = changed_pending_fields(updates) - path = managed_env_path() - path.parent.mkdir(parents=True, exist_ok=True) - temp_path = path.with_suffix(path.suffix + ".tmp") - temp_path.write_text(render_env_file(target_values), encoding="utf-8") - os.replace(temp_path, path) - return { - "applied": True, - "valid": True, - "errors": [], - "env_preview": render_env_file(target_values, mask_secrets=True), - "path": str(path), - "pending_fields": pending_fields, - } - - -def _quote_env_value(value: str) -> str: - if value == "": - return "" - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - if any(char.isspace() for char in value) or any( - char in value for char in ('"', "#", "=", "$") - ): - return f'"{escaped}"' - return value - - -def render_env_file(values: Mapping[str, str], *, mask_secrets: bool = False) -> str: - """Render a complete grouped env file.""" - - lines: list[str] = [ - "# Managed by Free Claude Code /admin.", - "# Edit in the server UI when possible.", - "", - ] - fields_by_section: dict[str, list[ConfigFieldSpec]] = { - section.section_id: [] for section in SECTIONS - } - for field in FIELDS: - fields_by_section.setdefault(field.section_id, []).append(field) - - for section in SECTIONS: - lines.append(f"# {section.label}") - for field in fields_by_section.get(section.section_id, []): - value = values.get(field.key, field.default) - if mask_secrets and field.secret and value: - value = MASKED_SECRET - lines.append(f"{field.key}={_quote_env_value(value)}") - lines.append("") - return "\n".join(lines).rstrip() + "\n" - - -def provider_config_status( - state: Mapping[str, Mapping[str, Any]] | None = None, -) -> list[dict[str, Any]]: - """Return provider configuration status without making network calls.""" - - state = state or _load_value_state() - statuses: list[dict[str, Any]] = [] - for provider_id, descriptor in PROVIDER_CATALOG.items(): - if descriptor.credential_env is None: - base_url = "" - if descriptor.base_url_attr is not None: - base_url = _value_for_settings_attr(state, descriptor.base_url_attr) - statuses.append( - { - "provider_id": provider_id, - "kind": "local", - "status": "missing_url" if not base_url.strip() else "unknown", - "label": "Missing URL" if not base_url.strip() else "Not checked", - "base_url": base_url or descriptor.default_base_url or "", - } - ) - continue - - value = str(state.get(descriptor.credential_env, {}).get("value", "")) - configured = bool(value.strip()) - statuses.append( - { - "provider_id": provider_id, - "kind": "remote", - "status": "configured" if configured else "missing_key", - "label": "Configured" if configured else "Missing key", - "credential_env": descriptor.credential_env, - } - ) - return statuses - - -def _value_for_settings_attr( - state: Mapping[str, Mapping[str, Any]], settings_attr: str -) -> str: - for field in FIELDS: - if field.settings_attr == settings_attr: - return str(state.get(field.key, {}).get("value", field.default)) - return "" - - -def env_keys() -> frozenset[str]: - """Return env keys owned by the admin manifest.""" - - return frozenset(field.key for field in FIELDS) - - -def fields_with_attrs() -> Iterable[ConfigFieldSpec]: - """Yield fields that validate through Settings.""" - - return (field for field in FIELDS if field.settings_attr is not None) diff --git a/api/admin_config/__init__.py b/api/admin_config/__init__.py new file mode 100644 index 00000000..cdcdf2a3 --- /dev/null +++ b/api/admin_config/__init__.py @@ -0,0 +1 @@ +"""Admin configuration internals.""" diff --git a/api/admin_config/manifest.py b/api/admin_config/manifest.py new file mode 100644 index 00000000..7fbb5208 --- /dev/null +++ b/api/admin_config/manifest.py @@ -0,0 +1,649 @@ +"""Admin UI configuration manifest.""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +from typing import Literal + +from config.settings import Settings + +from .provider_manifest import provider_field_specs + +FieldType = Literal[ + "text", + "secret", + "number", + "boolean", + "tri_boolean", + "select", + "textarea", +] + + +@dataclass(frozen=True, slots=True) +class ConfigSectionSpec: + """A group of config fields rendered together in the admin UI.""" + + section_id: str + label: str + description: str + advanced: bool = False + + +@dataclass(frozen=True, slots=True) +class ConfigFieldSpec: + """Typed metadata for one env-backed admin setting.""" + + key: str + label: str + section_id: str + field_type: FieldType = "text" + settings_attr: str | None = None + default: str = "" + options: tuple[str, ...] = () + secret: bool = False + advanced: bool = False + restart_required: bool = False + session_sensitive: bool = False + description: str = "" + + +SECTIONS: tuple[ConfigSectionSpec, ...] = ( + ConfigSectionSpec( + "providers", + "Providers", + "Provider keys, local endpoints, and proxy settings.", + ), + ConfigSectionSpec( + "models", + "Model Routing", + "Provider-prefixed models used for Claude model tiers.", + ), + ConfigSectionSpec( + "thinking", + "Thinking", + "Global and tier-specific thinking behavior.", + ), + ConfigSectionSpec( + "runtime", + "Runtime", + "Server API token, rate limits, timeouts, and process settings.", + ), + ConfigSectionSpec( + "messaging", + "Messaging", + "Discord, Telegram, CLI workspace, and session settings.", + ), + ConfigSectionSpec( + "voice", + "Voice", + "Voice note transcription settings.", + ), + ConfigSectionSpec( + "web_tools", + "Web Tools", + "Local Anthropic web_search and web_fetch behavior.", + ), + ConfigSectionSpec( + "diagnostics", + "Diagnostics", + "Logging and debugging flags.", + advanced=True, + ), + ConfigSectionSpec( + "smoke", + "Smoke Tests", + "Optional live smoke-test model overrides.", + advanced=True, + ), +) + + +_NON_PROVIDER_FIELDS: tuple[ConfigFieldSpec, ...] = ( + ConfigFieldSpec( + "MODEL", + "Default Model", + "models", + settings_attr="model", + default="nvidia_nim/nvidia/nemotron-3-super-120b-a12b", + description="Fallback provider/model route for all Claude model names.", + ), + ConfigFieldSpec( + "MODEL_OPUS", + "Opus Override", + "models", + settings_attr="model_opus", + description="Optional provider/model route for Opus requests.", + ), + ConfigFieldSpec( + "MODEL_SONNET", + "Sonnet Override", + "models", + settings_attr="model_sonnet", + description="Optional provider/model route for Sonnet requests.", + ), + ConfigFieldSpec( + "MODEL_HAIKU", + "Haiku Override", + "models", + settings_attr="model_haiku", + description="Optional provider/model route for Haiku requests.", + ), + ConfigFieldSpec( + "ENABLE_MODEL_THINKING", + "Enable Thinking", + "thinking", + "boolean", + settings_attr="enable_model_thinking", + default="true", + ), + ConfigFieldSpec( + "ENABLE_OPUS_THINKING", + "Opus Thinking", + "thinking", + "tri_boolean", + settings_attr="enable_opus_thinking", + description="Blank inherits Enable Thinking.", + ), + ConfigFieldSpec( + "ENABLE_SONNET_THINKING", + "Sonnet Thinking", + "thinking", + "tri_boolean", + settings_attr="enable_sonnet_thinking", + description="Blank inherits Enable Thinking.", + ), + ConfigFieldSpec( + "ENABLE_HAIKU_THINKING", + "Haiku Thinking", + "thinking", + "tri_boolean", + settings_attr="enable_haiku_thinking", + description="Blank inherits Enable Thinking.", + ), + ConfigFieldSpec( + "ANTHROPIC_AUTH_TOKEN", + "API/CLI Auth Token", + "runtime", + "secret", + settings_attr="anthropic_auth_token", + default="freecc", + secret=True, + description="Protects Claude/API access. It is not admin-page login.", + ), + ConfigFieldSpec( + "PROVIDER_RATE_LIMIT", + "Provider Rate Limit", + "runtime", + "number", + settings_attr="provider_rate_limit", + default="1", + ), + ConfigFieldSpec( + "PROVIDER_RATE_WINDOW", + "Provider Rate Window", + "runtime", + "number", + settings_attr="provider_rate_window", + default="3", + ), + ConfigFieldSpec( + "PROVIDER_MAX_CONCURRENCY", + "Provider Max Concurrency", + "runtime", + "number", + settings_attr="provider_max_concurrency", + default="5", + ), + ConfigFieldSpec( + "HTTP_READ_TIMEOUT", + "HTTP Read Timeout", + "runtime", + "number", + settings_attr="http_read_timeout", + default="300", + ), + ConfigFieldSpec( + "HTTP_WRITE_TIMEOUT", + "HTTP Write Timeout", + "runtime", + "number", + settings_attr="http_write_timeout", + default="60", + ), + ConfigFieldSpec( + "HTTP_CONNECT_TIMEOUT", + "HTTP Connect Timeout", + "runtime", + "number", + settings_attr="http_connect_timeout", + default="60", + ), + ConfigFieldSpec( + "HOST", + "Server Host", + "runtime", + settings_attr="host", + default="0.0.0.0", + restart_required=True, + ), + ConfigFieldSpec( + "PORT", + "Server Port", + "runtime", + "number", + settings_attr="port", + default="8082", + restart_required=True, + ), + ConfigFieldSpec( + "MESSAGING_PLATFORM", + "Messaging Platform", + "messaging", + "select", + settings_attr="messaging_platform", + default="discord", + options=("telegram", "discord", "none"), + session_sensitive=True, + ), + ConfigFieldSpec( + "MESSAGING_RATE_LIMIT", + "Messaging Rate Limit", + "messaging", + "number", + settings_attr="messaging_rate_limit", + default="1", + session_sensitive=True, + ), + ConfigFieldSpec( + "MESSAGING_RATE_WINDOW", + "Messaging Rate Window", + "messaging", + "number", + settings_attr="messaging_rate_window", + default="1", + session_sensitive=True, + ), + ConfigFieldSpec( + "TELEGRAM_BOT_TOKEN", + "Telegram Bot Token", + "messaging", + "secret", + settings_attr="telegram_bot_token", + secret=True, + session_sensitive=True, + ), + ConfigFieldSpec( + "ALLOWED_TELEGRAM_USER_ID", + "Allowed Telegram User ID", + "messaging", + settings_attr="allowed_telegram_user_id", + session_sensitive=True, + ), + ConfigFieldSpec( + "DISCORD_BOT_TOKEN", + "Discord Bot Token", + "messaging", + "secret", + settings_attr="discord_bot_token", + secret=True, + session_sensitive=True, + ), + ConfigFieldSpec( + "ALLOWED_DISCORD_CHANNELS", + "Allowed Discord Channels", + "messaging", + settings_attr="allowed_discord_channels", + session_sensitive=True, + ), + ConfigFieldSpec( + "ALLOWED_DIR", + "Allowed Directory", + "messaging", + settings_attr="allowed_dir", + session_sensitive=True, + ), + ConfigFieldSpec( + "MAX_MESSAGE_LOG_ENTRIES_PER_CHAT", + "Max Message Log Entries", + "messaging", + "number", + settings_attr="max_message_log_entries_per_chat", + advanced=True, + session_sensitive=True, + ), + ConfigFieldSpec( + "VOICE_NOTE_ENABLED", + "Voice Notes", + "voice", + "boolean", + settings_attr="voice_note_enabled", + default="false", + session_sensitive=True, + ), + ConfigFieldSpec( + "WHISPER_DEVICE", + "Whisper Device", + "voice", + "select", + settings_attr="whisper_device", + default="nvidia_nim", + options=("cpu", "cuda", "nvidia_nim"), + session_sensitive=True, + ), + ConfigFieldSpec( + "WHISPER_MODEL", + "Whisper Model", + "voice", + settings_attr="whisper_model", + default="openai/whisper-large-v3", + session_sensitive=True, + ), + ConfigFieldSpec( + "HF_TOKEN", + "Hugging Face Token", + "voice", + "secret", + settings_attr="hf_token", + secret=True, + session_sensitive=True, + ), + ConfigFieldSpec( + "FAST_PREFIX_DETECTION", + "Fast Prefix Detection", + "runtime", + "boolean", + settings_attr="fast_prefix_detection", + default="true", + advanced=True, + ), + ConfigFieldSpec( + "ENABLE_NETWORK_PROBE_MOCK", + "Network Probe Mock", + "runtime", + "boolean", + settings_attr="enable_network_probe_mock", + default="true", + advanced=True, + ), + ConfigFieldSpec( + "ENABLE_TITLE_GENERATION_SKIP", + "Title Generation Skip", + "runtime", + "boolean", + settings_attr="enable_title_generation_skip", + default="true", + advanced=True, + ), + ConfigFieldSpec( + "ENABLE_SUGGESTION_MODE_SKIP", + "Suggestion Mode Skip", + "runtime", + "boolean", + settings_attr="enable_suggestion_mode_skip", + default="true", + advanced=True, + ), + ConfigFieldSpec( + "ENABLE_FILEPATH_EXTRACTION_MOCK", + "Filepath Extraction Mock", + "runtime", + "boolean", + settings_attr="enable_filepath_extraction_mock", + default="true", + advanced=True, + ), + ConfigFieldSpec( + "ENABLE_WEB_SERVER_TOOLS", + "Web Server Tools", + "web_tools", + "boolean", + settings_attr="enable_web_server_tools", + default="true", + ), + ConfigFieldSpec( + "WEB_FETCH_ALLOWED_SCHEMES", + "Allowed Web Fetch Schemes", + "web_tools", + settings_attr="web_fetch_allowed_schemes", + default="http,https", + ), + ConfigFieldSpec( + "WEB_FETCH_ALLOW_PRIVATE_NETWORKS", + "Allow Private Networks", + "web_tools", + "boolean", + settings_attr="web_fetch_allow_private_networks", + default="false", + ), + ConfigFieldSpec( + "DEBUG_PLATFORM_EDITS", + "Debug Platform Edits", + "diagnostics", + "boolean", + settings_attr="debug_platform_edits", + default="false", + advanced=True, + ), + ConfigFieldSpec( + "DEBUG_SUBAGENT_STACK", + "Debug Subagent Stack", + "diagnostics", + "boolean", + settings_attr="debug_subagent_stack", + default="false", + advanced=True, + ), + ConfigFieldSpec( + "LOG_RAW_API_PAYLOADS", + "Log Raw API Payloads", + "diagnostics", + "boolean", + settings_attr="log_raw_api_payloads", + default="false", + advanced=True, + ), + ConfigFieldSpec( + "LOG_RAW_SSE_EVENTS", + "Log Raw SSE Events", + "diagnostics", + "boolean", + settings_attr="log_raw_sse_events", + default="false", + advanced=True, + ), + ConfigFieldSpec( + "LOG_API_ERROR_TRACEBACKS", + "Log API Error Tracebacks", + "diagnostics", + "boolean", + settings_attr="log_api_error_tracebacks", + default="false", + advanced=True, + ), + ConfigFieldSpec( + "LOG_RAW_MESSAGING_CONTENT", + "Log Raw Messaging Content", + "diagnostics", + "boolean", + settings_attr="log_raw_messaging_content", + default="false", + advanced=True, + ), + ConfigFieldSpec( + "LOG_RAW_CLI_DIAGNOSTICS", + "Log Raw CLI Diagnostics", + "diagnostics", + "boolean", + settings_attr="log_raw_cli_diagnostics", + default="false", + advanced=True, + ), + ConfigFieldSpec( + "LOG_MESSAGING_ERROR_DETAILS", + "Log Messaging Error Details", + "diagnostics", + "boolean", + settings_attr="log_messaging_error_details", + default="false", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_NVIDIA_NIM", + "Smoke NVIDIA NIM Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_OPEN_ROUTER", + "Smoke OpenRouter Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_MISTRAL", + "Smoke Mistral Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_MISTRAL_CODESTRAL", + "Smoke Mistral Codestral Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_DEEPSEEK", + "Smoke DeepSeek Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_LMSTUDIO", + "Smoke LM Studio Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_LLAMACPP", + "Smoke llama.cpp Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_OLLAMA", + "Smoke Ollama Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_KIMI", + "Smoke Kimi Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_WAFER", + "Smoke Wafer Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_OPENCODE", + "Smoke OpenCode Zen Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_OPENCODE_GO", + "Smoke OpenCode Go Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_ZAI", + "Smoke Z.ai Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_FIREWORKS", + "Smoke Fireworks Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_GEMINI", + "Smoke Gemini Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_GROQ", + "Smoke Groq Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_CEREBRAS", + "Smoke Cerebras Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_NIM_MODELS", + "Smoke NIM Models", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_NIM_EXTRA_MODELS", + "Smoke NIM Extra Models", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_OPENROUTER_FREE_MODELS", + "Smoke OpenRouter Free Models", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_OPENROUTER_FREE_EXTRA_MODELS", + "Smoke OpenRouter Free Extra Models", + "smoke", + advanced=True, + ), +) + + +FIELDS: tuple[ConfigFieldSpec, ...] = ( + *(ConfigFieldSpec(**spec) for spec in provider_field_specs()), + *_NON_PROVIDER_FIELDS, +) +FIELD_BY_KEY = {field.key: field for field in FIELDS} + + +def field_input_key(field: ConfigFieldSpec) -> str | None: + """Return the Settings input key used for a manifest field.""" + + if field.settings_attr is None: + return None + model_field = Settings.model_fields[field.settings_attr] + alias = model_field.validation_alias + if alias is None: + return field.settings_attr + return str(alias) + + +def env_keys() -> frozenset[str]: + """Return env keys owned by the admin manifest.""" + + return frozenset(field.key for field in FIELDS) + + +def fields_with_attrs() -> Iterable[ConfigFieldSpec]: + """Yield fields that validate through Settings.""" + + return (field for field in FIELDS if field.settings_attr is not None) diff --git a/api/admin_config/persistence.py b/api/admin_config/persistence.py new file mode 100644 index 00000000..a2919fe3 --- /dev/null +++ b/api/admin_config/persistence.py @@ -0,0 +1,149 @@ +"""Managed env persistence, validation preview, and rendering.""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from typing import Any + +from config.paths import managed_env_path + +from .manifest import FIELD_BY_KEY, FIELDS, SECTIONS, ConfigFieldSpec +from .sources import dotenv_values_from_file, is_locked_source, template_values +from .validation import validate_values +from .values import MASKED_SECRET, load_value_state, normalize_for_env + + +def target_values_with_updates(updates: Mapping[str, Any]) -> dict[str, str]: + """Return managed env values after applying admin updates.""" + + state = load_value_state() + values = template_values() + + # Preserve existing managed values when present. If no managed config exists, + # seed the first write from effective repo values to migrate legacy setups. + managed_values = dotenv_values_from_file(managed_env_path()) + if managed_values: + values.update( + {key: val for key, val in managed_values.items() if key in values} + ) + else: + for key, entry in state.items(): + if entry["source"] in {"repo_env", "template", "default"}: + values[key] = str(entry["value"]) + + for key, value in updates.items(): + field = FIELD_BY_KEY.get(key) + if field is None: + continue + if is_locked_source(state[key]["source"]): + continue + if field.secret and value == MASKED_SECRET: + continue + values[key] = normalize_for_env(value) + + for field in FIELDS: + values.setdefault(field.key, field.default) + return values + + +def effective_values_for_validation( + target_values: Mapping[str, str], +) -> dict[str, str]: + """Return values validated after preserving locked external sources.""" + + values = dict(target_values) + for key, entry in load_value_state().items(): + if is_locked_source(entry["source"]): + values[key] = str(entry["value"]) + return values + + +def validate_updates(updates: Mapping[str, Any]) -> dict[str, Any]: + """Validate partial admin updates and return a masked generated env preview.""" + + target_values = target_values_with_updates(updates) + effective_values = effective_values_for_validation(target_values) + valid, errors = validate_values(effective_values) + return { + "valid": valid, + "errors": errors, + "env_preview": render_env_file(target_values, mask_secrets=True), + } + + +def changed_pending_fields(updates: Mapping[str, Any]) -> list[str]: + """Return changed fields that require manual runtime action.""" + + state = load_value_state() + pending: list[str] = [] + for key, value in updates.items(): + field = FIELD_BY_KEY.get(key) + if field is None or not (field.restart_required or field.session_sensitive): + continue + if normalize_for_env(value) == str(state[key]["value"]): + continue + pending.append(key) + return pending + + +def write_managed_env(updates: Mapping[str, Any]) -> dict[str, Any]: + """Validate and atomically write the admin-managed env file.""" + + validation = validate_updates(updates) + if not validation["valid"]: + return validation | {"applied": False, "pending_fields": []} + + target_values = target_values_with_updates(updates) + pending_fields = changed_pending_fields(updates) + path = managed_env_path() + path.parent.mkdir(parents=True, exist_ok=True) + temp_path = path.with_suffix(path.suffix + ".tmp") + temp_path.write_text(render_env_file(target_values), encoding="utf-8") + os.replace(temp_path, path) + return { + "applied": True, + "valid": True, + "errors": [], + "env_preview": render_env_file(target_values, mask_secrets=True), + "path": str(path), + "pending_fields": pending_fields, + } + + +def quote_env_value(value: str) -> str: + """Quote a value when dotenv syntax requires it.""" + + if value == "": + return "" + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + if any(char.isspace() for char in value) or any( + char in value for char in ('"', "#", "=", "$") + ): + return f'"{escaped}"' + return value + + +def render_env_file(values: Mapping[str, str], *, mask_secrets: bool = False) -> str: + """Render a complete grouped env file.""" + + lines: list[str] = [ + "# Managed by Free Claude Code /admin.", + "# Edit in the server UI when possible.", + "", + ] + fields_by_section: dict[str, list[ConfigFieldSpec]] = { + section.section_id: [] for section in SECTIONS + } + for field in FIELDS: + fields_by_section.setdefault(field.section_id, []).append(field) + + for section in SECTIONS: + lines.append(f"# {section.label}") + for field in fields_by_section.get(section.section_id, []): + value = values.get(field.key, field.default) + if mask_secrets and field.secret and value: + value = MASKED_SECRET + lines.append(f"{field.key}={quote_env_value(value)}") + lines.append("") + return "\n".join(lines).rstrip() + "\n" diff --git a/api/admin_config/provider_manifest.py b/api/admin_config/provider_manifest.py new file mode 100644 index 00000000..eceb11a3 --- /dev/null +++ b/api/admin_config/provider_manifest.py @@ -0,0 +1,142 @@ +"""Catalog-derived Admin UI provider fields.""" + +from __future__ import annotations + +from typing import Any + +from config.provider_catalog import PROVIDER_CATALOG +from config.settings import Settings + +_PROVIDER_FIELD_OVERRIDES: dict[str, dict[str, Any]] = { + "NVIDIA_NIM_API_KEY": { + "label": "NVIDIA NIM API Key", + "description": "Used by NVIDIA NIM chat and optional NIM voice transcription.", + }, + "MISTRAL_API_KEY": { + "label": "Mistral API Key", + "description": ( + "Mistral La Plateforme (api.mistral.ai); Experiment plan is free tier with rate limits." + ), + }, + "CODESTRAL_API_KEY": { + "label": "Codestral API Key", + "description": ( + "Mistral Codestral endpoint (codestral.mistral.ai); distinct from Mistral " + "La Plateforme ``MISTRAL_API_KEY``. See Mistral docs for coding/FIM domains." + ), + }, + "OPENCODE_API_KEY": { + "label": "OpenCode API Key", + "description": ( + "OpenCode Zen curated gateway (opencode.ai/zen/v1) and OpenCode Go subscription " + "gateway (opencode.ai/zen/go/v1); single key from opencode.ai/auth." + ), + }, + "ZAI_API_KEY": { + "label": "Z.ai API Key", + "description": "Z.ai Coding Plan API key.", + }, + "FIREWORKS_API_KEY": { + "label": "Fireworks API Key", + "description": "Fireworks AI inference API key.", + }, + "GEMINI_API_KEY": { + "label": "Gemini API Key", + "description": ( + "Google AI Studio Gemini API key (Google AI Studio / Gemini API " + "[OpenAI-compatible](https://ai.google.dev/gemini-api/docs/openai)); " + "free tier has per-model rate limits and data may be used for improvement " + "outside the UK/CH/EEA/EU." + ), + }, + "GROQ_API_KEY": { + "label": "Groq API Key", + "description": ( + "GroqCloud OpenAI-compatible API key ([console.groq.com/keys](" + "https://console.groq.com/keys)); see Groq " + "[OpenAI compatibility docs](https://console.groq.com/docs/openai)." + ), + }, + "CEREBRAS_API_KEY": { + "label": "Cerebras API Key", + "description": ( + "Cerebras Inference API key (create in [Cloud Console](https://cloud.cerebras.ai)); " + "see [Quickstart](https://inference-docs.cerebras.ai/quickstart) and " + "[OpenAI compatibility](https://inference-docs.cerebras.ai/resources/openai)." + ), + }, +} + + +def provider_field_specs() -> tuple[dict[str, Any], ...]: + """Return provider fields generated from the provider catalog.""" + + return ( + *_credential_field_specs(), + *_local_base_url_field_specs(), + *_proxy_field_specs(), + ) + + +def _credential_field_specs() -> tuple[dict[str, Any], ...]: + specs: list[dict[str, Any]] = [] + seen_env_keys: set[str] = set() + for descriptor in PROVIDER_CATALOG.values(): + if descriptor.credential_env is None: + continue + if descriptor.credential_env in seen_env_keys: + continue + seen_env_keys.add(descriptor.credential_env) + spec = { + "key": descriptor.credential_env, + "label": f"{descriptor.display_name} API Key", + "section_id": "providers", + "field_type": "secret", + "settings_attr": descriptor.credential_attr, + "secret": True, + } + spec.update(_PROVIDER_FIELD_OVERRIDES.get(descriptor.credential_env, {})) + specs.append(spec) + return tuple(specs) + + +def _local_base_url_field_specs() -> tuple[dict[str, Any], ...]: + specs: list[dict[str, Any]] = [] + for descriptor in PROVIDER_CATALOG.values(): + if descriptor.base_url_attr is None: + continue + specs.append( + { + "key": _settings_env_key(descriptor.base_url_attr), + "label": f"{descriptor.display_name} Base URL", + "section_id": "providers", + "settings_attr": descriptor.base_url_attr, + "default": descriptor.default_base_url or "", + } + ) + return tuple(specs) + + +def _proxy_field_specs() -> tuple[dict[str, Any], ...]: + specs: list[dict[str, Any]] = [] + for descriptor in PROVIDER_CATALOG.values(): + if descriptor.proxy_attr is None: + continue + specs.append( + { + "key": _settings_env_key(descriptor.proxy_attr), + "label": f"{descriptor.display_name} Proxy", + "section_id": "providers", + "field_type": "secret", + "settings_attr": descriptor.proxy_attr, + "secret": True, + "advanced": True, + } + ) + return tuple(specs) + + +def _settings_env_key(settings_attr: str) -> str: + model_field = Settings.model_fields[settings_attr] + alias = model_field.validation_alias + return str(alias) if alias is not None else settings_attr diff --git a/api/admin_config/sources.py b/api/admin_config/sources.py new file mode 100644 index 00000000..8c03f7ee --- /dev/null +++ b/api/admin_config/sources.py @@ -0,0 +1,81 @@ +"""Admin config source loading and source precedence.""" + +from __future__ import annotations + +import os +from io import StringIO +from pathlib import Path +from typing import Literal + +from dotenv import dotenv_values + +from config.env_template import load_env_template_or_empty +from config.paths import managed_env_path + +from .manifest import FIELDS + +SourceType = Literal[ + "default", + "template", + "repo_env", + "managed_env", + "explicit_env_file", + "process", +] + + +def repo_env_path() -> Path: + """Return the repo-local env path.""" + + return Path(".env") + + +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 + + +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) + + +def dotenv_values_from_text(text: str) -> dict[str, str]: + """Parse dotenv text into string values.""" + + values = dotenv_values(stream=StringIO(text)) + return {key: "" if value is None else value for key, value in values.items()} + + +def template_values() -> dict[str, str]: + """Return .env.example values plus manifest defaults for newer fields.""" + + values = dotenv_values_from_text(load_env_template_or_empty()) + for field in FIELDS: + values.setdefault(field.key, field.default) + return values + + +def dotenv_values_from_file(path: Path) -> dict[str, str]: + """Return dotenv values from a file, or an empty mapping when absent.""" + + if not path.is_file(): + return {} + values = dotenv_values(path) + return {key: "" if value is None else value for key, value in values.items()} + + +def is_locked_source(source: SourceType) -> bool: + """Return whether an admin value source must not be overwritten.""" + + return source in {"process", "explicit_env_file"} diff --git a/api/admin_config/status.py b/api/admin_config/status.py new file mode 100644 index 00000000..f442bd97 --- /dev/null +++ b/api/admin_config/status.py @@ -0,0 +1,61 @@ +"""Provider configuration status for the Admin UI.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from config.provider_catalog import PROVIDER_CATALOG + +from .manifest import FIELDS + + +def provider_config_status( + state: Mapping[str, Mapping[str, Any]] | None = None, +) -> list[dict[str, Any]]: + """Return provider configuration status without making network calls.""" + + if state is None: + from .values import load_value_state + + state = load_value_state() + statuses: list[dict[str, Any]] = [] + for provider_id, descriptor in PROVIDER_CATALOG.items(): + if descriptor.credential_env is None: + base_url = "" + if descriptor.base_url_attr is not None: + base_url = _value_for_settings_attr(state, descriptor.base_url_attr) + statuses.append( + { + "provider_id": provider_id, + "display_name": descriptor.display_name, + "kind": "local", + "status": "missing_url" if not base_url.strip() else "unknown", + "label": "Missing URL" if not base_url.strip() else "Not checked", + "base_url": base_url or descriptor.default_base_url or "", + } + ) + continue + + value = str(state.get(descriptor.credential_env, {}).get("value", "")) + configured = bool(value.strip()) + statuses.append( + { + "provider_id": provider_id, + "display_name": descriptor.display_name, + "kind": "remote", + "status": "configured" if configured else "missing_key", + "label": "Configured" if configured else "Missing key", + "credential_env": descriptor.credential_env, + } + ) + return statuses + + +def _value_for_settings_attr( + state: Mapping[str, Mapping[str, Any]], settings_attr: str +) -> str: + for field in FIELDS: + if field.settings_attr == settings_attr: + return str(state.get(field.key, {}).get("value", field.default)) + return "" diff --git a/api/admin_config/validation.py b/api/admin_config/validation.py new file mode 100644 index 00000000..ae25c9df --- /dev/null +++ b/api/admin_config/validation.py @@ -0,0 +1,40 @@ +"""Settings-backed Admin UI config validation.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from pydantic import ValidationError + +from config.settings import Settings + +from .manifest import FIELDS, field_input_key + + +def validate_values(values: Mapping[str, str]) -> tuple[bool, list[str]]: + """Validate proposed env values against the Settings model.""" + + kwargs: dict[str, Any] = {"_env_file": None} + for field in FIELDS: + input_key = field_input_key(field) + if input_key is None: + continue + kwargs[input_key] = values.get(field.key, "") + + try: + Settings(**kwargs) + except ValidationError as exc: + return False, format_validation_errors(exc) + return True, [] + + +def format_validation_errors(exc: ValidationError) -> list[str]: + """Return user-readable validation errors from a Pydantic exception.""" + + errors: list[str] = [] + for error in exc.errors(): + loc = ".".join(str(part) for part in error.get("loc", ())) + message = str(error.get("msg", "Invalid value")) + errors.append(f"{loc}: {message}" if loc else message) + return errors diff --git a/api/admin_config/values.py b/api/admin_config/values.py new file mode 100644 index 00000000..b2ace6ce --- /dev/null +++ b/api/admin_config/values.py @@ -0,0 +1,115 @@ +"""Admin config value state and API response assembly.""" + +from __future__ import annotations + +import os +from typing import Any + +from config.paths import managed_env_path + +from .manifest import FIELD_BY_KEY, FIELDS, SECTIONS, ConfigFieldSpec +from .sources import ( + configured_env_files, + dotenv_values_from_file, + explicit_env_path, + is_locked_source, + repo_env_path, + template_values, +) +from .status import provider_config_status + +MASKED_SECRET = "********" +ValueState = dict[str, dict[str, Any]] + + +def normalize_for_env(value: Any) -> str: + """Normalize a submitted admin value for dotenv persistence.""" + + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + +def display_value(field: ConfigFieldSpec, value: str) -> str: + """Return the Admin UI display value for a raw config value.""" + + if field.secret and value: + return MASKED_SECRET + return value + + +def load_value_state() -> ValueState: + """Load effective admin field values and their sources.""" + + values = template_values() + sources = {key: "template" if key in values else "default" for key in FIELD_BY_KEY} + + for source, path in configured_env_files(): + file_values = dotenv_values_from_file(path) + for key, value in file_values.items(): + if key in FIELD_BY_KEY: + values[key] = value + sources[key] = source + + for key in FIELD_BY_KEY: + if key in os.environ: + values[key] = os.environ[key] + sources[key] = "process" + + return { + key: { + "value": values.get(key, ""), + "source": sources.get(key, "default"), + } + for key in FIELD_BY_KEY + } + + +def load_config_response() -> dict[str, Any]: + """Return manifest and current config values for the admin UI.""" + + state = load_value_state() + fields: list[dict[str, Any]] = [] + for field in FIELDS: + entry = state[field.key] + source = entry["source"] + raw_value = entry["value"] + fields.append( + { + "key": field.key, + "label": field.label, + "section": field.section_id, + "type": field.field_type, + "value": display_value(field, raw_value), + "configured": bool(str(raw_value).strip()), + "source": source, + "locked": is_locked_source(source), + "secret": field.secret, + "advanced": field.advanced, + "restart_required": field.restart_required, + "session_sensitive": field.session_sensitive, + "options": list(field.options), + "description": field.description, + } + ) + + return { + "sections": [ + { + "id": section.section_id, + "label": section.label, + "description": section.description, + "advanced": section.advanced, + } + for section in SECTIONS + ], + "fields": fields, + "paths": { + "managed": str(managed_env_path()), + "repo": str(repo_env_path()), + "explicit": str(explicit_env_path()) if explicit_env_path() else None, + }, + "provider_status": provider_config_status(state), + } diff --git a/api/admin_routes.py b/api/admin_routes.py index 61f54d6a..310489dc 100644 --- a/api/admin_routes.py +++ b/api/admin_routes.py @@ -17,13 +17,10 @@ from config.settings import Settings from config.settings import get_settings as get_cached_settings from providers.runtime import ProviderRuntime -from .admin_config import ( - FIELD_BY_KEY, - load_config_response, - provider_config_status, - validate_updates, - write_managed_env, -) +from .admin_config.manifest import FIELD_BY_KEY +from .admin_config.persistence import validate_updates, write_managed_env +from .admin_config.status import provider_config_status +from .admin_config.values import load_config_response from .admin_urls import local_admin_url router = APIRouter() diff --git a/api/admin_static/admin.js b/api/admin_static/admin.js index 603293ce..3de3f0f4 100644 --- a/api/admin_static/admin.js +++ b/api/admin_static/admin.js @@ -57,28 +57,6 @@ function sourceText(field) { return parts.join(" "); } -function providerName(providerId) { - const names = { - nvidia_nim: "NVIDIA NIM", - open_router: "OpenRouter", - mistral_codestral: "Mistral Codestral", - deepseek: "DeepSeek", - lmstudio: "LM Studio", - llamacpp: "llama.cpp", - ollama: "Ollama", - kimi: "Kimi", - wafer: "Wafer", - opencode: "OpenCode Zen", - opencode_go: "OpenCode Go", - zai: "Z.ai", - }; - if (names[providerId]) return names[providerId]; - return providerId - .split("_") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} - function statusClass(status) { if (["configured", "reachable", "running"].includes(status)) return "ok"; if (["missing_key", "missing_url", "unknown"].includes(status)) return "warn"; @@ -169,7 +147,7 @@ function renderProviders(providerStatus) { const title = document.createElement("div"); title.className = "provider-title"; - title.innerHTML = `${providerName(provider.provider_id)}`; + title.innerHTML = `${provider.display_name || provider.provider_id}`; const pill = document.createElement("span"); pill.className = `status-pill ${statusClass(provider.status)}`; diff --git a/cli/entrypoints.py b/cli/entrypoints.py index 29d16a44..ae1b36c4 100644 --- a/cli/entrypoints.py +++ b/cli/entrypoints.py @@ -17,6 +17,7 @@ from cli.launchers.common import preflight_proxy from cli.process_registry import ( kill_all_best_effort, ) +from config.env_template import load_env_template from config.paths import ( config_dir_path, legacy_env_paths, @@ -27,21 +28,6 @@ from config.settings import Settings, get_settings SERVER_GRACEFUL_SHUTDOWN_SECONDS = 5 -def _load_env_template() -> str: - """Load the canonical root env template from package resources or source.""" - import importlib.resources - - packaged = importlib.resources.files("cli").joinpath("env.example") - if packaged.is_file(): - return packaged.read_text("utf-8") - - source_template = Path(__file__).resolve().parents[1] / ".env.example" - if source_template.is_file(): - return source_template.read_text(encoding="utf-8") - - raise FileNotFoundError("Could not find bundled or source .env.example template.") - - def serve() -> None: """Start the FastAPI server (registered as `fcc-server` script).""" opened_admin_browser = False @@ -140,7 +126,7 @@ def init() -> None: return config_dir.mkdir(parents=True, exist_ok=True) - template = _load_env_template() + template = load_env_template() env_file.write_text(template, encoding="utf-8") print(f"Config created at {env_file}") print("Edit it to set your API keys and model preferences, then run: fcc-server") diff --git a/config/env_template.py b/config/env_template.py new file mode 100644 index 00000000..78040369 --- /dev/null +++ b/config/env_template.py @@ -0,0 +1,29 @@ +"""Canonical env template loading for init and Admin UI defaults.""" + +from __future__ import annotations + +import importlib.resources +from pathlib import Path + + +def load_env_template() -> str: + """Load the root ``.env.example`` template from wheel resources or checkout.""" + + packaged = importlib.resources.files("config").joinpath("env.example") + if packaged.is_file(): + return packaged.read_text("utf-8") + + source_template = Path(__file__).resolve().parents[1] / ".env.example" + if source_template.is_file(): + return source_template.read_text(encoding="utf-8") + + raise FileNotFoundError("Could not find bundled or source .env.example template.") + + +def load_env_template_or_empty() -> str: + """Return the env template, or an empty template when unavailable.""" + + try: + return load_env_template() + except FileNotFoundError: + return "" diff --git a/config/provider_catalog.py b/config/provider_catalog.py index 99d4bf51..424ffd62 100644 --- a/config/provider_catalog.py +++ b/config/provider_catalog.py @@ -43,6 +43,7 @@ class ProviderDescriptor: """Metadata for building :class:`~providers.base.ProviderConfig` and factory wiring.""" provider_id: str + display_name: str transport_type: TransportType capabilities: tuple[str, ...] credential_env: str | None = None @@ -57,6 +58,7 @@ class ProviderDescriptor: PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { "nvidia_nim": ProviderDescriptor( provider_id="nvidia_nim", + display_name="NVIDIA NIM", transport_type="openai_chat", credential_env="NVIDIA_NIM_API_KEY", credential_url="https://build.nvidia.com/settings/api-keys", @@ -67,6 +69,7 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { ), "open_router": ProviderDescriptor( provider_id="open_router", + display_name="OpenRouter", transport_type="anthropic_messages", credential_env="OPENROUTER_API_KEY", credential_url="https://openrouter.ai/keys", @@ -77,6 +80,7 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { ), "gemini": ProviderDescriptor( provider_id="gemini", + display_name="Gemini", transport_type="openai_chat", credential_env="GEMINI_API_KEY", credential_url="https://aistudio.google.com/apikey", @@ -87,6 +91,7 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { ), "deepseek": ProviderDescriptor( provider_id="deepseek", + display_name="DeepSeek", transport_type="anthropic_messages", credential_env="DEEPSEEK_API_KEY", credential_url="https://platform.deepseek.com/api_keys", @@ -96,6 +101,7 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { ), "mistral": ProviderDescriptor( provider_id="mistral", + display_name="Mistral", transport_type="openai_chat", credential_env="MISTRAL_API_KEY", credential_url="https://console.mistral.ai/", @@ -106,6 +112,7 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { ), "mistral_codestral": ProviderDescriptor( provider_id="mistral_codestral", + display_name="Mistral Codestral", transport_type="openai_chat", credential_env="CODESTRAL_API_KEY", credential_url="https://console.mistral.ai/", @@ -116,6 +123,7 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { ), "opencode": ProviderDescriptor( provider_id="opencode", + display_name="OpenCode Zen", transport_type="openai_chat", credential_env="OPENCODE_API_KEY", credential_url="https://opencode.ai/auth", @@ -126,6 +134,7 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { ), "opencode_go": ProviderDescriptor( provider_id="opencode_go", + display_name="OpenCode Go", transport_type="openai_chat", credential_env="OPENCODE_API_KEY", credential_url="https://opencode.ai/auth", @@ -136,6 +145,7 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { ), "wafer": ProviderDescriptor( provider_id="wafer", + display_name="Wafer", transport_type="anthropic_messages", credential_env="WAFER_API_KEY", credential_url="https://www.wafer.ai/pass", @@ -146,6 +156,7 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { ), "kimi": ProviderDescriptor( provider_id="kimi", + display_name="Kimi", transport_type="anthropic_messages", credential_env="KIMI_API_KEY", credential_url="https://platform.moonshot.cn/console/api-keys", @@ -162,6 +173,7 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { ), "cerebras": ProviderDescriptor( provider_id="cerebras", + display_name="Cerebras", transport_type="openai_chat", credential_env="CEREBRAS_API_KEY", credential_url="https://cloud.cerebras.ai", @@ -172,6 +184,7 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { ), "groq": ProviderDescriptor( provider_id="groq", + display_name="Groq", transport_type="openai_chat", credential_env="GROQ_API_KEY", credential_url="https://console.groq.com/keys", @@ -182,6 +195,7 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { ), "fireworks": ProviderDescriptor( provider_id="fireworks", + display_name="Fireworks", transport_type="anthropic_messages", credential_env="FIREWORKS_API_KEY", credential_url="https://fireworks.ai/account/api-keys", @@ -199,6 +213,7 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { ), "zai": ProviderDescriptor( provider_id="zai", + display_name="Z.ai", transport_type="anthropic_messages", credential_env="ZAI_API_KEY", credential_attr="zai_api_key", @@ -215,6 +230,7 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { ), "lmstudio": ProviderDescriptor( provider_id="lmstudio", + display_name="LM Studio", transport_type="anthropic_messages", static_credential="lm-studio", default_base_url=LMSTUDIO_DEFAULT_BASE, @@ -224,6 +240,7 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { ), "llamacpp": ProviderDescriptor( provider_id="llamacpp", + display_name="llama.cpp", transport_type="anthropic_messages", static_credential="llamacpp", default_base_url=LLAMACPP_DEFAULT_BASE, @@ -233,6 +250,7 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { ), "ollama": ProviderDescriptor( provider_id="ollama", + display_name="Ollama", transport_type="anthropic_messages", static_credential="ollama", default_base_url=OLLAMA_DEFAULT_BASE, diff --git a/pyproject.toml b/pyproject.toml index 7a9355c7..1c7f26fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "free-claude-code" -version = "2.3.17" +version = "2.3.18" description = "Middleware between Claude Code CLI (Anthropic API) and NVIDIA NIM" readme = "README.md" requires-python = ">=3.14.0" @@ -49,7 +49,7 @@ voice_local = [ packages = ["api", "cli", "config", "core", "messaging", "providers"] [tool.hatch.build.targets.wheel.force-include] -".env.example" = "cli/env.example" +".env.example" = "config/env.example" [tool.uv] required-version = ">=0.11.0" diff --git a/tests/api/test_admin.py b/tests/api/test_admin.py index 7ccfd483..8b5789c4 100644 --- a/tests/api/test_admin.py +++ b/tests/api/test_admin.py @@ -6,7 +6,7 @@ from unittest.mock import patch import httpx from fastapi.testclient import TestClient -from api.admin_config import MASKED_SECRET +from api.admin_config.values import MASKED_SECRET from api.admin_urls import local_admin_url from api.app import create_app from config.settings import Settings diff --git a/tests/cli/test_entrypoints.py b/tests/cli/test_entrypoints.py index a473bf6f..8b6d8408 100644 --- a/tests/cli/test_entrypoints.py +++ b/tests/cli/test_entrypoints.py @@ -124,13 +124,13 @@ def test_legacy_env_migration_does_not_overwrite_managed_env( def test_env_template_loader_uses_root_template_in_source_checkout() -> None: """Source checkout fallback uses the root .env.example as the single source.""" - from cli.entrypoints import _load_env_template + from config.env_template import load_env_template template = (Path(__file__).resolve().parents[2] / ".env.example").read_text( encoding="utf-8" ) - assert _load_env_template() == template + assert load_env_template() == template def test_init_creates_parent_directories(tmp_path: Path) -> None: diff --git a/tests/contracts/test_admin_provider_manifest.py b/tests/contracts/test_admin_provider_manifest.py index e4ef741c..8eb76bda 100644 --- a/tests/contracts/test_admin_provider_manifest.py +++ b/tests/contracts/test_admin_provider_manifest.py @@ -2,7 +2,7 @@ from __future__ import annotations -from api.admin_config import FIELD_BY_KEY +from api.admin_config.manifest import FIELD_BY_KEY from config.provider_catalog import PROVIDER_CATALOG from config.settings import Settings @@ -35,6 +35,37 @@ def test_provider_catalog_remote_credentials_in_admin_manifest() -> None: assert not missing and not wrong_attr, "\n".join(missing + wrong_attr) +def test_provider_catalog_local_base_urls_in_admin_manifest() -> None: + missing_key: list[str] = [] + wrong_attr: list[str] = [] + + for provider_id, desc in PROVIDER_CATALOG.items(): + if desc.base_url_attr is None: + continue + mf = Settings.model_fields[desc.base_url_attr] + alias = mf.validation_alias + if alias is None: + missing_key.append( + f"{provider_id}: {desc.base_url_attr} has no validation_alias " + "(admin manifest expects env-backed base URL)" + ) + continue + env_key = str(alias) + entry = FIELD_BY_KEY.get(env_key) + if entry is None: + missing_key.append( + f"{provider_id}: base URL env {env_key} not in FIELD_BY_KEY" + ) + continue + if entry.settings_attr != desc.base_url_attr: + wrong_attr.append( + f"{provider_id}: {env_key} maps settings_attr=" + f"{entry.settings_attr!r}, catalog expects {desc.base_url_attr!r}" + ) + + assert not missing_key and not wrong_attr, "\n".join(missing_key + wrong_attr) + + def test_provider_catalog_proxy_attrs_in_admin_manifest() -> None: missing_key: list[str] = [] wrong_attr: list[str] = [] @@ -64,3 +95,15 @@ def test_provider_catalog_proxy_attrs_in_admin_manifest() -> None: ) assert not missing_key and not wrong_attr, "\n".join(missing_key + wrong_attr) + + +def test_provider_catalog_display_names_are_admin_status_source() -> None: + from api.admin_config.status import provider_config_status + + status_by_provider = { + entry["provider_id"]: entry for entry in provider_config_status() + } + + assert set(status_by_provider) == set(PROVIDER_CATALOG) + for provider_id, desc in PROVIDER_CATALOG.items(): + assert status_by_provider[provider_id]["display_name"] == desc.display_name diff --git a/tests/contracts/test_architecture_contracts.py b/tests/contracts/test_architecture_contracts.py index fb2c1bdb..81a08ebb 100644 --- a/tests/contracts/test_architecture_contracts.py +++ b/tests/contracts/test_architecture_contracts.py @@ -55,7 +55,7 @@ def test_root_env_example_is_the_single_template_source() -> None: assert not duplicate_example.exists() -def test_root_env_example_is_packaged_for_fcc_init() -> None: +def test_root_env_example_is_packaged_for_config_template_loader() -> None: repo_root = Path(__file__).resolve().parents[2] pyproject = tomllib.loads((repo_root / "pyproject.toml").read_text("utf-8")) @@ -63,7 +63,7 @@ def test_root_env_example_is_packaged_for_fcc_init() -> None: "force-include" ] - assert force_include[".env.example"] == "cli/env.example" + assert force_include[".env.example"] == "config/env.example" def test_pyproject_first_party_packages_match_packaged_roots() -> None: diff --git a/tests/contracts/test_import_boundaries.py b/tests/contracts/test_import_boundaries.py index 3b45aaae..fa691964 100644 --- a/tests/contracts/test_import_boundaries.py +++ b/tests/contracts/test_import_boundaries.py @@ -298,6 +298,55 @@ def test_openai_responses_uses_adapter_boundary() -> None: assert deleted_api not in adapter_text +def test_admin_config_uses_package_owners_and_catalog_manifest() -> None: + repo_root = Path(__file__).resolve().parents[2] + api_root = repo_root / "api" + admin_config_root = api_root / "admin_config" + + assert not (api_root / "admin_config.py").exists() + for filename in { + "__init__.py", + "manifest.py", + "provider_manifest.py", + "sources.py", + "values.py", + "validation.py", + "persistence.py", + "status.py", + }: + assert (admin_config_root / filename).exists() + + init_text = (admin_config_root / "__init__.py").read_text(encoding="utf-8") + assert "from " not in init_text + assert "__all__" not in init_text + + routes_imports = set(_imports_from(api_root / "admin_routes.py", repo_root)) + assert "api.admin_config" not in routes_imports + for expected in { + "api.admin_config.manifest", + "api.admin_config.persistence", + "api.admin_config.status", + "api.admin_config.values", + }: + assert expected in routes_imports + + provider_manifest_text = (admin_config_root / "provider_manifest.py").read_text( + encoding="utf-8" + ) + assert "PROVIDER_CATALOG" in provider_manifest_text + admin_js = (api_root / "admin_static" / "admin.js").read_text(encoding="utf-8") + assert "function providerName" not in admin_js + assert "display_name || provider.provider_id" in admin_js + + entrypoints_imports = set( + _imports_from(repo_root / "cli" / "entrypoints.py", repo_root) + ) + assert "config.env_template" in entrypoints_imports + assert "_load_env_template" not in (repo_root / "cli" / "entrypoints.py").read_text( + encoding="utf-8" + ) + + def test_messaging_workflow_uses_split_runtime_owners() -> None: repo_root = Path(__file__).resolve().parents[2] messaging_root = repo_root / "messaging" diff --git a/uv.lock b/uv.lock index c665ade8..c662c005 100644 --- a/uv.lock +++ b/uv.lock @@ -561,7 +561,7 @@ wheels = [ [[package]] name = "free-claude-code" -version = "2.3.17" +version = "2.3.18" source = { editable = "." } dependencies = [ { name = "aiohttp" },