mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-07-09 16:00:45 +00:00
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:
parent
58aef0dc8a
commit
51157f91bd
22 changed files with 1416 additions and 1370 deletions
|
|
@ -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/).
|
||||
|
|
|
|||
1303
api/admin_config.py
1303
api/admin_config.py
File diff suppressed because it is too large
Load diff
1
api/admin_config/__init__.py
Normal file
1
api/admin_config/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Admin configuration internals."""
|
||||
649
api/admin_config/manifest.py
Normal file
649
api/admin_config/manifest.py
Normal 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)
|
||||
149
api/admin_config/persistence.py
Normal file
149
api/admin_config/persistence.py
Normal 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"
|
||||
142
api/admin_config/provider_manifest.py
Normal file
142
api/admin_config/provider_manifest.py
Normal 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
|
||||
81
api/admin_config/sources.py
Normal file
81
api/admin_config/sources.py
Normal 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"}
|
||||
61
api/admin_config/status.py
Normal file
61
api/admin_config/status.py
Normal 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 ""
|
||||
40
api/admin_config/validation.py
Normal file
40
api/admin_config/validation.py
Normal 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
115
api/admin_config/values.py
Normal 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),
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
|
|
|
|||
|
|
@ -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
29
config/env_template.py
Normal 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 ""
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
2
uv.lock
generated
|
|
@ -561,7 +561,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "free-claude-code"
|
||||
version = "2.3.17"
|
||||
version = "2.3.18"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue