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