Improve admin UX settings (#471)

## Summary
- split the admin UI into Providers, Model Config, and Messaging views
- remove generated env, diagnostics, smoke, managed-label, and fixed
cloud/runtime settings from the visible admin UX
- make Z.ai base URL, Claude workspace, and Claude CLI binary fixed
app-level behavior instead of managed env fields

## Verification
- uv run ruff format
- uv run ruff check
- uv run ty check
- uv run pytest
This commit is contained in:
Ali Khokhar
2026-05-17 12:36:43 -07:00
committed by GitHub
parent fc3ef0b5cc
commit 37974db1ab
12 changed files with 407 additions and 142 deletions
-3
View File
@@ -24,7 +24,6 @@ OPENCODE_API_KEY=""
# Z.ai Config (Anthropic-compatible Messages at api.z.ai/api/anthropic) # Z.ai Config (Anthropic-compatible Messages at api.z.ai/api/anthropic)
ZAI_API_KEY="" ZAI_API_KEY=""
ZAI_BASE_URL="https://api.z.ai/api/coding/paas/v4"
# LM Studio Config (local provider, no API key required) # LM Studio Config (local provider, no API key required)
@@ -133,9 +132,7 @@ ALLOWED_DISCORD_CHANNELS=""
# Agent Config # Agent Config
CLAUDE_WORKSPACE=
ALLOWED_DIR="" ALLOWED_DIR=""
CLAUDE_CLI_BIN="claude"
FAST_PREFIX_DETECTION=true FAST_PREFIX_DETECTION=true
ENABLE_NETWORK_PROBE_MOCK=true ENABLE_NETWORK_PROBE_MOCK=true
ENABLE_TITLE_GENERATION_SKIP=true ENABLE_TITLE_GENERATION_SKIP=true
+1 -3
View File
@@ -311,7 +311,6 @@ Discord minimum config:
MESSAGING_PLATFORM="discord" MESSAGING_PLATFORM="discord"
DISCORD_BOT_TOKEN="your-discord-bot-token" DISCORD_BOT_TOKEN="your-discord-bot-token"
ALLOWED_DISCORD_CHANNELS="123456789" ALLOWED_DISCORD_CHANNELS="123456789"
CLAUDE_WORKSPACE=
ALLOWED_DIR="C:/Users/yourname/projects" ALLOWED_DIR="C:/Users/yourname/projects"
``` ```
@@ -323,7 +322,6 @@ Telegram minimum config:
MESSAGING_PLATFORM="telegram" MESSAGING_PLATFORM="telegram"
TELEGRAM_BOT_TOKEN="123456789:ABC..." TELEGRAM_BOT_TOKEN="123456789:ABC..."
ALLOWED_TELEGRAM_USER_ID="your-user-id" ALLOWED_TELEGRAM_USER_ID="your-user-id"
CLAUDE_WORKSPACE=
ALLOWED_DIR="C:/Users/yourname/projects" ALLOWED_DIR="C:/Users/yourname/projects"
``` ```
@@ -374,7 +372,7 @@ MODEL="nvidia_nim/z-ai/glm4.7"
ANTHROPIC_AUTH_TOKEN="freecc" ANTHROPIC_AUTH_TOKEN="freecc"
``` ```
Config precedence is repo `.env`, then `~/.fcc/.env`, then `FCC_ENV_FILE` when set. Blank `CLAUDE_WORKSPACE` uses `~/.fcc/agent_workspace`. `ANTHROPIC_AUTH_TOKEN` can be any local secret; pass the same value to Claude Code. Config precedence is repo `.env`, then `~/.fcc/.env`, then `FCC_ENV_FILE` when set. Claude agent data is always stored under `~/.fcc/agent_workspace`. `ANTHROPIC_AUTH_TOKEN` can be any local secret; pass the same value to Claude Code.
### 2. Model Routing ### 2. Model Routing
-25
View File
@@ -176,14 +176,6 @@ FIELDS: tuple[ConfigFieldSpec, ...] = (
secret=True, secret=True,
description="Z.ai Coding Plan API key.", description="Z.ai Coding Plan API key.",
), ),
ConfigFieldSpec(
"ZAI_BASE_URL",
"Z.ai Base URL",
"providers",
settings_attr="zai_base_url",
default="https://api.z.ai/api/coding/paas/v4",
description="Z.ai OpenAI-compatible Coding Plan endpoint.",
),
ConfigFieldSpec( ConfigFieldSpec(
"LM_STUDIO_BASE_URL", "LM_STUDIO_BASE_URL",
"LM Studio Base URL", "LM Studio Base URL",
@@ -473,15 +465,6 @@ FIELDS: tuple[ConfigFieldSpec, ...] = (
settings_attr="allowed_discord_channels", settings_attr="allowed_discord_channels",
session_sensitive=True, session_sensitive=True,
), ),
ConfigFieldSpec(
"CLAUDE_WORKSPACE",
"Claude Workspace",
"messaging",
settings_attr="claude_workspace",
default="",
session_sensitive=True,
description="Blank uses ~/.fcc/agent_workspace.",
),
ConfigFieldSpec( ConfigFieldSpec(
"ALLOWED_DIR", "ALLOWED_DIR",
"Allowed Directory", "Allowed Directory",
@@ -489,14 +472,6 @@ FIELDS: tuple[ConfigFieldSpec, ...] = (
settings_attr="allowed_dir", settings_attr="allowed_dir",
session_sensitive=True, session_sensitive=True,
), ),
ConfigFieldSpec(
"CLAUDE_CLI_BIN",
"Claude CLI Binary",
"messaging",
settings_attr="claude_cli_bin",
default="claude",
session_sensitive=True,
),
ConfigFieldSpec( ConfigFieldSpec(
"MAX_MESSAGE_LOG_ENTRIES_PER_CHAT", "MAX_MESSAGE_LOG_ENTRIES_PER_CHAT",
"Max Message Log Entries", "Max Message Log Entries",
+20 -23
View File
@@ -100,7 +100,7 @@ textarea {
.section-nav { .section-nav {
display: grid; display: grid;
gap: 6px; gap: 8px;
} }
.nav-link { .nav-link {
@@ -108,14 +108,15 @@ textarea {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
width: 100%; width: 100%;
min-height: 36px; min-height: 42px;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 8px; border-radius: 8px;
background: transparent; background: transparent;
color: var(--text); color: var(--text);
padding: 8px 10px; padding: 10px 12px;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
font-weight: 700;
} }
.nav-link:hover, .nav-link:hover,
@@ -124,6 +125,11 @@ textarea {
border-color: var(--line); border-color: var(--line);
} }
.nav-link.active {
border-color: rgba(89, 217, 148, 0.34);
color: #ffffff;
}
.main { .main {
min-width: 0; min-width: 0;
padding: 28px; padding: 28px;
@@ -188,9 +194,18 @@ textarea {
border-color: rgba(255, 116, 108, 0.36); border-color: rgba(255, 116, 108, 0.36);
} }
.admin-views,
.admin-view {
display: grid;
gap: 18px;
}
.admin-view[hidden] {
display: none;
}
.provider-strip, .provider-strip,
.settings-section, .settings-section {
.env-panel {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
background: var(--panel); background: var(--panel);
@@ -378,24 +393,6 @@ textarea {
margin-top: 14px; margin-top: 14px;
} }
.env-panel {
margin-top: 18px;
padding: 18px;
}
.env-preview {
overflow: auto;
max-height: 360px;
margin: 14px 0 0;
border: 1px solid var(--line);
border-radius: 8px;
background: #0b0f0d;
color: #c9f4dc;
padding: 14px;
font-size: 12px;
line-height: 1.45;
}
.action-bar { .action-bar {
position: fixed; position: fixed;
right: 0; right: 0;
+127 -44
View File
@@ -4,9 +4,33 @@ const state = {
fields: new Map(), fields: new Map(),
localStatus: new Map(), localStatus: new Map(),
modelOptions: [], modelOptions: [],
activeView: "providers",
}; };
const MASKED_SECRET = "********"; const MASKED_SECRET = "********";
const VIEW_GROUPS = [
{
id: "providers",
label: "Providers",
title: "Providers",
sections: ["providers", "runtime"],
containerId: "providersSections",
},
{
id: "model_config",
label: "Model Config",
title: "Model Config",
sections: ["models", "thinking", "web_tools"],
containerId: "modelConfigSections",
},
{
id: "messaging",
label: "Messaging",
title: "Messaging",
sections: ["messaging", "voice"],
containerId: "messagingSections",
},
];
const byId = (id) => document.getElementById(id); const byId = (id) => document.getElementById(id);
@@ -15,11 +39,23 @@ function sourceLabel(source) {
default: "default", default: "default",
template: "template", template: "template",
repo_env: "repo .env", repo_env: "repo .env",
managed_env: "managed", managed_env: "",
explicit_env_file: "FCC_ENV_FILE", explicit_env_file: "FCC_ENV_FILE",
process: "process env", process: "process env",
}; };
return labels[source] || source; return Object.prototype.hasOwnProperty.call(labels, source) ? labels[source] : source;
}
function sourceText(field) {
const parts = [];
const label = sourceLabel(field.source);
if (label) {
parts.push(label);
}
if (field.locked) {
parts.push("locked");
}
return parts.join(" ");
} }
function providerName(providerId) { function providerName(providerId) {
@@ -70,7 +106,7 @@ async function load() {
state.status = status; state.status = status;
state.fields = new Map(config.fields.map((field) => [field.key, field])); state.fields = new Map(config.fields.map((field) => [field.key, field]));
updateHeader(status); updateHeader(status);
renderNav(config.sections); renderNav();
renderProviders(config.provider_status); renderProviders(config.provider_status);
renderSections(config.sections, config.fields); renderSections(config.sections, config.fields);
byId("configPath").textContent = config.paths.managed; byId("configPath").textContent = config.paths.managed;
@@ -87,23 +123,51 @@ function updateHeader(status) {
byId("modelBadge").textContent = status.model || ""; byId("modelBadge").textContent = status.model || "";
} }
function renderNav(sections) { function renderNav() {
const nav = byId("sectionNav"); const nav = byId("sectionNav");
nav.innerHTML = ""; nav.innerHTML = "";
sections.forEach((section, index) => { VIEW_GROUPS.forEach((view, index) => {
const button = document.createElement("button"); const button = document.createElement("button");
button.type = "button"; button.type = "button";
button.className = `nav-link${index === 0 ? " active" : ""}`; button.className = `nav-link${index === 0 ? " active" : ""}`;
button.textContent = section.label; button.dataset.view = view.id;
button.textContent = view.label;
if (index === 0) {
button.setAttribute("aria-current", "page");
}
button.addEventListener("click", () => { button.addEventListener("click", () => {
document.querySelectorAll(".nav-link").forEach((link) => { setActiveView(view.id, { scroll: true });
link.classList.remove("active");
});
button.classList.add("active");
byId(`section-${section.id}`).scrollIntoView({ behavior: "smooth" });
}); });
nav.appendChild(button); nav.appendChild(button);
}); });
setActiveView(state.activeView, { scroll: false });
}
function setActiveView(viewId, { scroll = false } = {}) {
const activeView =
VIEW_GROUPS.find((view) => view.id === viewId) || VIEW_GROUPS[0];
state.activeView = activeView.id;
byId("pageTitle").textContent = activeView.title;
document.querySelectorAll(".nav-link").forEach((link) => {
const selected = link.dataset.view === activeView.id;
link.classList.toggle("active", selected);
if (selected) {
link.setAttribute("aria-current", "page");
} else {
link.removeAttribute("aria-current");
}
});
document.querySelectorAll(".admin-view").forEach((view) => {
const selected = view.dataset.view === activeView.id;
view.classList.toggle("active", selected);
view.hidden = !selected;
});
if (scroll) {
window.scrollTo({ top: 0, behavior: "smooth" });
}
} }
function renderProviders(providerStatus) { function renderProviders(providerStatus) {
@@ -153,8 +217,11 @@ function updateProviderCard(providerId, status, label, metaText) {
} }
function renderSections(sections, fields) { function renderSections(sections, fields) {
const container = byId("formSections"); VIEW_GROUPS.forEach((view) => {
container.innerHTML = ""; byId(view.containerId).innerHTML = "";
});
const sectionById = new Map(sections.map((section) => [section.id, section]));
const bySection = new Map(); const bySection = new Map();
sections.forEach((section) => bySection.set(section.id, [])); sections.forEach((section) => bySection.set(section.id, []));
fields.forEach((field) => { fields.forEach((field) => {
@@ -162,36 +229,43 @@ function renderSections(sections, fields) {
bySection.get(field.section).push(field); bySection.get(field.section).push(field);
}); });
sections.forEach((section) => { VIEW_GROUPS.forEach((view) => {
const sectionEl = document.createElement("section"); const container = byId(view.containerId);
sectionEl.className = "settings-section"; view.sections.forEach((sectionId) => {
sectionEl.id = `section-${section.id}`; const section = sectionById.get(sectionId);
const sectionFields = bySection.get(sectionId) || [];
if (!section || sectionFields.length === 0) return;
const heading = document.createElement("div"); const sectionEl = document.createElement("section");
heading.className = "section-heading"; sectionEl.className = "settings-section";
heading.innerHTML = `<div><h3>${section.label}</h3><p>${section.description}</p></div>`; sectionEl.id = `section-${section.id}`;
sectionEl.appendChild(heading);
const grid = document.createElement("div"); const heading = document.createElement("div");
grid.className = "field-grid"; heading.className = "section-heading";
bySection.get(section.id).forEach((field) => { heading.innerHTML = `<div><h3>${section.label}</h3><p>${section.description}</p></div>`;
grid.appendChild(renderField(field)); sectionEl.appendChild(heading);
});
sectionEl.appendChild(grid);
if (bySection.get(section.id).some((field) => field.advanced)) { const grid = document.createElement("div");
const toggle = document.createElement("button"); grid.className = "field-grid";
toggle.type = "button"; sectionFields.forEach((field) => {
toggle.className = "ghost-button advanced-toggle"; grid.appendChild(renderField(field));
toggle.textContent = "Show advanced";
toggle.addEventListener("click", () => {
const showing = sectionEl.classList.toggle("show-advanced");
toggle.textContent = showing ? "Hide advanced" : "Show advanced";
}); });
sectionEl.appendChild(toggle); sectionEl.appendChild(grid);
}
container.appendChild(sectionEl); if (sectionFields.some((field) => field.advanced)) {
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "ghost-button advanced-toggle";
toggle.textContent = "Show advanced";
toggle.addEventListener("click", () => {
const showing = sectionEl.classList.toggle("show-advanced");
toggle.textContent = showing ? "Hide advanced" : "Show advanced";
});
sectionEl.appendChild(toggle);
}
container.appendChild(sectionEl);
});
}); });
} }
@@ -202,9 +276,17 @@ function renderField(field) {
const label = document.createElement("label"); const label = document.createElement("label");
label.htmlFor = `field-${field.key}`; label.htmlFor = `field-${field.key}`;
label.innerHTML = `<span>${field.label}</span><span class="field-source">${sourceLabel( const labelText = document.createElement("span");
field.source, labelText.textContent = field.label;
)}${field.locked ? " locked" : ""}</span>`; label.appendChild(labelText);
const source = sourceText(field);
if (source) {
const sourceEl = document.createElement("span");
sourceEl.className = "field-source";
sourceEl.textContent = source;
label.appendChild(sourceEl);
}
const input = inputForField(field); const input = inputForField(field);
input.id = `field-${field.key}`; input.id = `field-${field.key}`;
@@ -316,7 +398,6 @@ async function validate(showResult = true) {
method: "POST", method: "POST",
body: JSON.stringify({ values: changedValues() }), body: JSON.stringify({ values: changedValues() }),
}); });
byId("envPreview").textContent = result.env_preview || "";
if (showResult) { if (showResult) {
showValidationResult(result); showValidationResult(result);
} }
@@ -336,7 +417,6 @@ async function apply() {
method: "POST", method: "POST",
body: JSON.stringify({ values: changedValues() }), body: JSON.stringify({ values: changedValues() }),
}); });
byId("envPreview").textContent = result.env_preview || "";
if (!result.applied) { if (!result.applied) {
showValidationResult(result); showValidationResult(result);
return; return;
@@ -388,7 +468,10 @@ async function testProvider(providerId, button) {
result.models.slice(0, 3).join(", ") || "No models returned", result.models.slice(0, 3).join(", ") || "No models returned",
); );
state.modelOptions = Array.from( state.modelOptions = Array.from(
new Set([...state.modelOptions, ...result.models.map((model) => `${providerId}/${model}`)]), new Set([
...state.modelOptions,
...result.models.map((model) => `${providerId}/${model}`),
]),
).sort(); ).sort();
syncModelDatalist(); syncModelDatalist();
} else { } else {
+34 -19
View File
@@ -17,14 +17,14 @@
<p>Server Control</p> <p>Server Control</p>
</div> </div>
</div> </div>
<nav id="sectionNav" class="section-nav" aria-label="Settings sections"></nav> <nav id="sectionNav" class="section-nav" aria-label="Admin views"></nav>
</aside> </aside>
<main class="main"> <main class="main">
<header class="topbar"> <header class="topbar">
<div> <div>
<p class="eyebrow">Local Admin</p> <p class="eyebrow">Local Admin</p>
<h2>Runtime Config</h2> <h2 id="pageTitle">Providers</h2>
</div> </div>
<div class="server-state"> <div class="server-state">
<span id="serverStatus" class="status-pill neutral">Loading</span> <span id="serverStatus" class="status-pill neutral">Loading</span>
@@ -32,25 +32,40 @@
</div> </div>
</header> </header>
<section class="provider-strip" aria-label="Provider status"> <div id="adminViews" class="admin-views">
<div class="strip-header"> <section id="view-providers" class="admin-view active" data-view="providers">
<h3>Providers</h3> <section class="provider-strip" aria-label="Provider status">
<button id="refreshLocal" class="ghost-button" type="button">Check local</button> <div class="strip-header">
</div> <h3>Providers</h3>
<div id="providerGrid" class="provider-grid"></div> <button id="refreshLocal" class="ghost-button" type="button">
</section> Check local
</button>
</div>
<div id="providerGrid" class="provider-grid"></div>
</section>
<div
id="providersSections"
class="form-sections"
aria-label="Provider configuration"
></div>
</section>
<section id="formSections" class="form-sections" aria-label="Configuration"></section> <section id="view-model_config" class="admin-view" data-view="model_config" hidden>
<div
id="modelConfigSections"
class="form-sections"
aria-label="Model configuration"
></div>
</section>
<section class="env-panel"> <section id="view-messaging" class="admin-view" data-view="messaging" hidden>
<div class="section-heading"> <div
<div> id="messagingSections"
<h3>Generated Env</h3> class="form-sections"
<p>Read-only preview of the managed config file.</p> aria-label="Messaging configuration"
</div> ></div>
</div> </section>
<pre id="envPreview" class="env-preview"></pre> </div>
</section>
</main> </main>
<footer class="action-bar"> <footer class="action-bar">
-1
View File
@@ -142,7 +142,6 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = {
credential_env="ZAI_API_KEY", credential_env="ZAI_API_KEY",
credential_attr="zai_api_key", credential_attr="zai_api_key",
default_base_url=ZAI_DEFAULT_BASE, default_base_url=ZAI_DEFAULT_BASE,
base_url_attr="zai_base_url",
proxy_attr="zai_proxy", proxy_attr="zai_proxy",
capabilities=("chat", "streaming", "tools", "thinking", "rate_limit"), capabilities=("chat", "streaming", "tools", "thinking", "rate_limit"),
), ),
+11 -15
View File
@@ -124,10 +124,6 @@ class Settings(BaseSettings):
# ==================== Z.ai Config ==================== # ==================== Z.ai Config ====================
zai_api_key: str = Field(default="", validation_alias="ZAI_API_KEY") zai_api_key: str = Field(default="", validation_alias="ZAI_API_KEY")
zai_base_url: str = Field(
default="https://api.z.ai/api/coding/paas/v4",
validation_alias="ZAI_BASE_URL",
)
# ==================== Messaging Platform Selection ==================== # ==================== Messaging Platform Selection ====================
# Valid: "telegram" | "discord" | "none" # Valid: "telegram" | "discord" | "none"
@@ -297,12 +293,7 @@ class Settings(BaseSettings):
allowed_discord_channels: str | None = Field( allowed_discord_channels: str | None = Field(
default=None, validation_alias="ALLOWED_DISCORD_CHANNELS" default=None, validation_alias="ALLOWED_DISCORD_CHANNELS"
) )
claude_workspace: str = Field(
default_factory=lambda: str(default_claude_workspace_path()),
validation_alias="CLAUDE_WORKSPACE",
)
allowed_dir: str = "" allowed_dir: str = ""
claude_cli_bin: str = Field(default="claude", validation_alias="CLAUDE_CLI_BIN")
max_message_log_entries_per_chat: int | None = Field( max_message_log_entries_per_chat: int | None = Field(
default=None, validation_alias="MAX_MESSAGE_LOG_ENTRIES_PER_CHAT" default=None, validation_alias="MAX_MESSAGE_LOG_ENTRIES_PER_CHAT"
) )
@@ -351,12 +342,17 @@ class Settings(BaseSettings):
return None return None
return v return v
@field_validator("claude_workspace", mode="before") @property
@classmethod def claude_workspace(self) -> str:
def default_blank_claude_workspace(cls, v: Any) -> Any: """Return the fixed Claude data workspace path."""
if v == "" or v is None:
return str(default_claude_workspace_path()) return str(default_claude_workspace_path())
return v
@property
def claude_cli_bin(self) -> str:
"""Return the fixed Claude Code binary name."""
return "claude"
@field_validator("whisper_device") @field_validator("whisper_device")
@classmethod @classmethod
+140
View File
@@ -32,6 +32,9 @@ def _clear_process_config(monkeypatch) -> None:
"HOST", "HOST",
"PORT", "PORT",
"LOG_FILE", "LOG_FILE",
"ZAI_BASE_URL",
"CLAUDE_WORKSPACE",
"CLAUDE_CLI_BIN",
): ):
monkeypatch.delenv(key, raising=False) monkeypatch.delenv(key, raising=False)
@@ -45,6 +48,26 @@ def test_admin_page_is_loopback_only(monkeypatch, tmp_path):
assert remote_client.get("/admin").status_code == 403 assert remote_client.get("/admin").status_code == 403
def test_admin_page_no_longer_renders_generated_env_panel(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
app = create_app(lifespan_enabled=False)
response = _local_client(app).get("/admin")
assert response.status_code == 200
assert "Generated Env" not in response.text
assert "envPreview" not in response.text
def test_admin_static_hides_managed_source_label():
script = Path("api/admin_static/admin.js").read_text(encoding="utf-8")
assert 'managed_env: "",' in script
assert "hasOwnProperty.call(labels, source)" in script
assert 'parts.push("locked")' in script
assert "sourceEl.textContent = source" in script
def test_admin_config_masks_secrets_and_exposes_manifest(monkeypatch, tmp_path): def test_admin_config_masks_secrets_and_exposes_manifest(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path) _set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch) _clear_process_config(monkeypatch)
@@ -57,6 +80,9 @@ def test_admin_config_masks_secrets_and_exposes_manifest(monkeypatch, tmp_path):
keys = {field["key"] for field in body["fields"]} keys = {field["key"] for field in body["fields"]}
assert "ANTHROPIC_AUTH_TOKEN" in keys assert "ANTHROPIC_AUTH_TOKEN" in keys
assert "OPENROUTER_API_KEY" in keys assert "OPENROUTER_API_KEY" in keys
assert "ZAI_BASE_URL" not in keys
assert "CLAUDE_WORKSPACE" not in keys
assert "CLAUDE_CLI_BIN" not in keys
assert "LOG_FILE" not in keys assert "LOG_FILE" not in keys
auth_field = next( auth_field = next(
field for field in body["fields"] if field["key"] == "ANTHROPIC_AUTH_TOKEN" field for field in body["fields"] if field["key"] == "ANTHROPIC_AUTH_TOKEN"
@@ -66,6 +92,23 @@ def test_admin_config_masks_secrets_and_exposes_manifest(monkeypatch, tmp_path):
assert auth_field["source"] == "template" assert auth_field["source"] == "template"
def test_admin_config_preserves_managed_env_source_contract(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
env_file = tmp_path / ".fcc" / ".env"
env_file.parent.mkdir(parents=True)
env_file.write_text("MODEL=open_router/managed-model\n", encoding="utf-8")
app = create_app(lifespan_enabled=False)
response = _local_client(app).get("/admin/api/config")
assert response.status_code == 200
body = response.json()
model_field = next(field for field in body["fields"] if field["key"] == "MODEL")
assert model_field["source"] == "managed_env"
assert model_field["locked"] is False
def test_admin_validate_rejects_bad_model_shape(monkeypatch, tmp_path): def test_admin_validate_rejects_bad_model_shape(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path) _set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch) _clear_process_config(monkeypatch)
@@ -116,6 +159,103 @@ def test_admin_apply_writes_complete_managed_env_and_masks_preview(
} }
def test_admin_apply_preserves_hidden_diagnostics_and_smoke_values(
monkeypatch, tmp_path
):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
env_file = tmp_path / ".fcc" / ".env"
env_file.parent.mkdir(parents=True)
env_file.write_text(
"\n".join(
[
"MODEL=nvidia_nim/old-model",
"LOG_RAW_API_PAYLOADS=true",
"FCC_SMOKE_MODEL_ZAI=zai/smoke-model",
"",
]
),
encoding="utf-8",
)
app = create_app(lifespan_enabled=False)
response = _local_client(app).post(
"/admin/api/config/apply",
json={"values": {"MODEL": "open_router/test-model"}},
)
assert response.status_code == 200
body = response.json()
assert body["applied"] is True
text = env_file.read_text("utf-8")
assert "MODEL=open_router/test-model" in text
assert "LOG_RAW_API_PAYLOADS=true" in text
assert "FCC_SMOKE_MODEL_ZAI=zai/smoke-model" in text
def test_admin_apply_omits_stale_zai_base_url(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
env_file = tmp_path / ".fcc" / ".env"
env_file.parent.mkdir(parents=True)
env_file.write_text(
"\n".join(
[
"MODEL=zai/glm-5.1",
"ZAI_API_KEY=zai-secret",
"ZAI_BASE_URL=https://custom.zai.invalid/v1",
"",
]
),
encoding="utf-8",
)
app = create_app(lifespan_enabled=False)
response = _local_client(app).post(
"/admin/api/config/apply",
json={"values": {"MODEL": "zai/glm-5.1"}},
)
assert response.status_code == 200
body = response.json()
assert body["applied"] is True
text = env_file.read_text("utf-8")
assert "ZAI_API_KEY=zai-secret" in text
assert "ZAI_BASE_URL" not in text
def test_admin_apply_omits_stale_fixed_claude_runtime_settings(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
env_file = tmp_path / ".fcc" / ".env"
env_file.parent.mkdir(parents=True)
env_file.write_text(
"\n".join(
[
"MODEL=open_router/test-model",
"CLAUDE_WORKSPACE=C:/custom/workspace",
"CLAUDE_CLI_BIN=claude-custom",
"",
]
),
encoding="utf-8",
)
app = create_app(lifespan_enabled=False)
response = _local_client(app).post(
"/admin/api/config/apply",
json={"values": {"MODEL": "open_router/test-model"}},
)
assert response.status_code == 200
body = response.json()
assert body["applied"] is True
text = env_file.read_text("utf-8")
assert "MODEL=open_router/test-model" in text
assert "CLAUDE_WORKSPACE" not in text
assert "CLAUDE_CLI_BIN" not in text
def test_admin_apply_restart_required_reports_automatic_restart(monkeypatch, tmp_path): def test_admin_apply_restart_required_reports_automatic_restart(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path) _set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch) _clear_process_config(monkeypatch)
+2 -4
View File
@@ -14,13 +14,11 @@ def _launcher_settings(
*, *,
port: int = 8082, port: int = 8082,
token: str = "freecc", token: str = "freecc",
claude_bin: str = "claude-test",
) -> Settings: ) -> Settings:
return Settings.model_construct( return Settings.model_construct(
host="0.0.0.0", host="0.0.0.0",
port=port, port=port,
anthropic_auth_token=token, anthropic_auth_token=token,
claude_cli_bin=claude_bin,
) )
@@ -343,7 +341,7 @@ def test_launch_claude_exits_when_command_cannot_be_resolved(
) -> None: ) -> None:
from cli.entrypoints import launch_claude from cli.entrypoints import launch_claude
settings = _launcher_settings(claude_bin="claude-missing") settings = _launcher_settings()
with ( with (
patch("cli.entrypoints.get_settings", return_value=settings), patch("cli.entrypoints.get_settings", return_value=settings),
patch("cli.entrypoints._preflight_proxy", return_value=None), patch("cli.entrypoints._preflight_proxy", return_value=None),
@@ -356,7 +354,7 @@ def test_launch_claude_exits_when_command_cannot_be_resolved(
assert exc_info.value.code == 127 assert exc_info.value.code == 127
popen.assert_not_called() popen.assert_not_called()
captured = capsys.readouterr() captured = capsys.readouterr()
assert "Could not find Claude Code command: claude-missing" in captured.err assert "Could not find Claude Code command: claude" in captured.err
assert "npm install -g @anthropic-ai/claude-code" in captured.err assert "npm install -g @anthropic-ai/claude-code" in captured.err
+52 -5
View File
@@ -1,5 +1,7 @@
"""Tests for config/settings.py and config/nim.py""" """Tests for config/settings.py and config/nim.py"""
from typing import Any, cast
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
@@ -45,7 +47,7 @@ class TestSettings:
assert settings.debug_subagent_stack is False assert settings.debug_subagent_stack is False
def test_default_claude_workspace_uses_fcc_home(self, monkeypatch, tmp_path): def test_default_claude_workspace_uses_fcc_home(self, monkeypatch, tmp_path):
"""Blank/unset CLAUDE_WORKSPACE stores agent data under ~/.fcc.""" """Unset CLAUDE_WORKSPACE stores agent data under ~/.fcc."""
from config.settings import Settings from config.settings import Settings
monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("HOME", str(tmp_path))
@@ -77,8 +79,19 @@ class TestSettings:
assert not hasattr(settings, "log_file") assert not hasattr(settings, "log_file")
def test_stale_zai_base_url_env_is_ignored(self, monkeypatch):
"""Cloud Z.ai endpoint is fixed in provider metadata, not settings."""
from config.settings import Settings
monkeypatch.setenv("ZAI_BASE_URL", "https://custom.zai.invalid/v1")
monkeypatch.setitem(Settings.model_config, "env_file", ())
settings = Settings()
assert not hasattr(settings, "zai_base_url")
def test_blank_claude_workspace_uses_fcc_home(self, monkeypatch, tmp_path): def test_blank_claude_workspace_uses_fcc_home(self, monkeypatch, tmp_path):
"""An explicit blank env value keeps the default workspace path.""" """An explicit blank env value does not affect the fixed workspace path."""
from config.settings import Settings from config.settings import Settings
monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("HOME", str(tmp_path))
@@ -90,17 +103,51 @@ class TestSettings:
assert settings.claude_workspace == str(tmp_path / ".fcc" / "agent_workspace") assert settings.claude_workspace == str(tmp_path / ".fcc" / "agent_workspace")
def test_explicit_claude_workspace_is_preserved(self, monkeypatch, tmp_path): def test_explicit_claude_workspace_is_ignored(self, monkeypatch, tmp_path):
"""Custom CLAUDE_WORKSPACE values are not rewritten.""" """Custom CLAUDE_WORKSPACE values do not override the fixed workspace."""
from config.settings import Settings from config.settings import Settings
workspace = tmp_path / "custom-workspace" workspace = tmp_path / "custom-workspace"
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.setenv("CLAUDE_WORKSPACE", str(workspace)) monkeypatch.setenv("CLAUDE_WORKSPACE", str(workspace))
monkeypatch.setitem(Settings.model_config, "env_file", ()) monkeypatch.setitem(Settings.model_config, "env_file", ())
settings = Settings() settings = Settings()
assert settings.claude_workspace == str(workspace) assert settings.claude_workspace == str(tmp_path / ".fcc" / "agent_workspace")
def test_explicit_claude_cli_bin_is_ignored(self, monkeypatch):
"""Custom CLAUDE_CLI_BIN values do not override the fixed binary."""
from config.settings import Settings
monkeypatch.setenv("CLAUDE_CLI_BIN", "claude-custom")
monkeypatch.setitem(Settings.model_config, "env_file", ())
settings = Settings()
assert settings.claude_cli_bin == "claude"
def test_direct_claude_runtime_overrides_are_ignored(self, monkeypatch, tmp_path):
"""Constructor extras cannot override fixed Claude runtime settings."""
from config.settings import Settings
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.setitem(Settings.model_config, "env_file", ())
settings = Settings(
**cast(
Any,
{
"claude_workspace": str(tmp_path / "custom-workspace"),
"claude_cli_bin": "claude-custom",
},
)
)
assert settings.claude_workspace == str(tmp_path / ".fcc" / "agent_workspace")
assert settings.claude_cli_bin == "claude"
def test_get_settings_cached(self): def test_get_settings_cached(self):
"""Test get_settings returns cached instance.""" """Test get_settings returns cached instance."""
+20
View File
@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from config.nim import NimSettings from config.nim import NimSettings
from config.provider_catalog import ZAI_DEFAULT_BASE
from config.provider_ids import SUPPORTED_PROVIDER_IDS from config.provider_ids import SUPPORTED_PROVIDER_IDS
from providers.deepseek import DeepSeekProvider from providers.deepseek import DeepSeekProvider
from providers.exceptions import UnknownProviderTypeError from providers.exceptions import UnknownProviderTypeError
@@ -17,6 +18,7 @@ from providers.opencode import OpenCodeProvider
from providers.registry import ( from providers.registry import (
PROVIDER_DESCRIPTORS, PROVIDER_DESCRIPTORS,
ProviderRegistry, ProviderRegistry,
build_provider_config,
create_provider, create_provider,
) )
from providers.wafer import WaferProvider from providers.wafer import WaferProvider
@@ -89,6 +91,24 @@ def test_ollama_descriptor_uses_native_anthropic_transport():
assert "native_anthropic" in descriptor.capabilities assert "native_anthropic" in descriptor.capabilities
def test_zai_descriptor_uses_fixed_cloud_base_url():
descriptor = PROVIDER_DESCRIPTORS["zai"]
assert descriptor.default_base_url == ZAI_DEFAULT_BASE
assert descriptor.base_url_attr is None
def test_zai_provider_config_ignores_stale_base_url_setting():
descriptor = PROVIDER_DESCRIPTORS["zai"]
config = build_provider_config(
descriptor,
_make_settings(zai_base_url="https://custom.zai.invalid/v1"),
)
assert config.base_url == ZAI_DEFAULT_BASE
def test_create_provider_uses_native_openrouter_by_default(): def test_create_provider_uses_native_openrouter_by_default():
with patch("httpx.AsyncClient"): with patch("httpx.AsyncClient"):
provider = create_provider("open_router", _make_settings()) provider = create_provider("open_router", _make_settings())