Refactor admin config into catalog-driven package (#926)

## Problem

Admin config was a single responsibility hub with manually duplicated
provider metadata. Provider labels, fields, template loading,
validation, persistence, and status lived in one place.

## Changes

| Before | After |
| --- | --- |
| Admin config lived in one large `api/admin_config.py` module. | Admin
config lives in package modules for manifest, sources, values,
validation, persistence, and status. |
| Provider admin fields and UI labels were manually duplicated. |
Provider admin fields and display names derive from `PROVIDER_CATALOG`
with admin-only help overrides. |
| `fcc-init` and Admin UI loaded `.env.example` separately. | `fcc-init`
and Admin UI use shared `config.env_template` loading. |
| Architecture docs pointed to the old admin config module. |
Architecture docs describe the package owners and catalog-driven
provider manifest. |

<!-- greptile_comment -->

<details open><summary><h3>Greptile Summary</h3></summary>

This PR refactors admin configuration into a catalog-driven package. The
main changes are:

- Split the former monolithic `api/admin_config.py` into manifest,
source loading, value presentation, validation, persistence, and
provider status modules.
- Generate provider admin fields and display names from
`PROVIDER_CATALOG` with admin-specific help overrides.
- Share `.env.example` loading between `fcc-init` and Admin UI defaults
through `config.env_template`.
- Update admin routes, Admin UI provider labels, architecture docs,
version metadata, and contract/API tests for the new module layout.
</details>

<h3>Confidence Score: 5/5</h3>

The refactor appears merge-safe with no code issues identified in the
reviewed changes.

The package split, catalog-driven provider metadata, shared environment
template loading, route updates, and tests/docs changes are cohesive and
covered by corresponding contract/API/CLI test updates.

<details><summary><h3><a href="https://www.greptile.com/trex"><img
alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="20" align="absmiddle"></a> T-Rex Logs</h3></summary>

**What T-Rex did**
- T-Rex ran manifest validation for catalog provider before and after
routes, capturing base and head responses and catalog-alignment checks,
and confirmed the validation completed successfully.
- T-Rex evaluated the shared-env-template scenarios, observing the
before run with no config.env\_template module and the after run with
the module present, with patched loader values and all consistency
checks passing, and the run exited with code 0.
- T-Rex executed the package-admin-workflow validation, verifying the
base and after import paths, the load/validate/write workflow produced
matching outputs, and the run completed with exit code 0.

<a
href="https://app.greptile.com/trex/runs/12529845/artifacts"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=1"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=1"><img
alt="View all artifacts"
src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=1"
height="32"></picture></a>

<sub><a href="https://www.greptile.com/trex"><img alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="14" align="absmiddle"></a> Ran code and verified through
T-Rex</sub>
</details>

<sub>Reviews (1): Last reviewed commit: ["Refactor admin config into
catalog-drive..."](d6239d7953)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=40315222)</sub>

<!-- /greptile_comment -->
This commit is contained in:
Ali Khokhar 2026-06-27 15:37:29 -07:00 committed by GitHub
parent 58aef0dc8a
commit 51157f91bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1416 additions and 1370 deletions

View file

@ -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/).

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
"""Admin configuration internals."""

View file

@ -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)

View file

@ -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"

View file

@ -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

View file

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

View file

@ -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 ""

View file

@ -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

115
api/admin_config/values.py Normal file
View file

@ -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),
}

View file

@ -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()

View file

@ -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 = `<strong>${providerName(provider.provider_id)}</strong>`;
title.innerHTML = `<strong>${provider.display_name || provider.provider_id}</strong>`;
const pill = document.createElement("span");
pill.className = `status-pill ${statusClass(provider.status)}`;

View file

@ -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")

29
config/env_template.py Normal file
View file

@ -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 ""

View file

@ -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,

View file

@ -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"

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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:

View file

@ -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"

2
uv.lock generated
View file

@ -561,7 +561,7 @@ wheels = [
[[package]]
name = "free-claude-code"
version = "2.3.17"
version = "2.3.18"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },