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)
ZAI_API_KEY=""
ZAI_BASE_URL="https://api.z.ai/api/coding/paas/v4"
# LM Studio Config (local provider, no API key required)
@@ -133,9 +132,7 @@ ALLOWED_DISCORD_CHANNELS=""
# Agent Config
CLAUDE_WORKSPACE=
ALLOWED_DIR=""
CLAUDE_CLI_BIN="claude"
FAST_PREFIX_DETECTION=true
ENABLE_NETWORK_PROBE_MOCK=true
ENABLE_TITLE_GENERATION_SKIP=true
+1 -3
View File
@@ -311,7 +311,6 @@ Discord minimum config:
MESSAGING_PLATFORM="discord"
DISCORD_BOT_TOKEN="your-discord-bot-token"
ALLOWED_DISCORD_CHANNELS="123456789"
CLAUDE_WORKSPACE=
ALLOWED_DIR="C:/Users/yourname/projects"
```
@@ -323,7 +322,6 @@ Telegram minimum config:
MESSAGING_PLATFORM="telegram"
TELEGRAM_BOT_TOKEN="123456789:ABC..."
ALLOWED_TELEGRAM_USER_ID="your-user-id"
CLAUDE_WORKSPACE=
ALLOWED_DIR="C:/Users/yourname/projects"
```
@@ -374,7 +372,7 @@ MODEL="nvidia_nim/z-ai/glm4.7"
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
-25
View File
@@ -176,14 +176,6 @@ FIELDS: tuple[ConfigFieldSpec, ...] = (
secret=True,
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(
"LM_STUDIO_BASE_URL",
"LM Studio Base URL",
@@ -473,15 +465,6 @@ FIELDS: tuple[ConfigFieldSpec, ...] = (
settings_attr="allowed_discord_channels",
session_sensitive=True,
),
ConfigFieldSpec(
"CLAUDE_WORKSPACE",
"Claude Workspace",
"messaging",
settings_attr="claude_workspace",
default="",
session_sensitive=True,
description="Blank uses ~/.fcc/agent_workspace.",
),
ConfigFieldSpec(
"ALLOWED_DIR",
"Allowed Directory",
@@ -489,14 +472,6 @@ FIELDS: tuple[ConfigFieldSpec, ...] = (
settings_attr="allowed_dir",
session_sensitive=True,
),
ConfigFieldSpec(
"CLAUDE_CLI_BIN",
"Claude CLI Binary",
"messaging",
settings_attr="claude_cli_bin",
default="claude",
session_sensitive=True,
),
ConfigFieldSpec(
"MAX_MESSAGE_LOG_ENTRIES_PER_CHAT",
"Max Message Log Entries",
+20 -23
View File
@@ -100,7 +100,7 @@ textarea {
.section-nav {
display: grid;
gap: 6px;
gap: 8px;
}
.nav-link {
@@ -108,14 +108,15 @@ textarea {
justify-content: space-between;
align-items: center;
width: 100%;
min-height: 36px;
min-height: 42px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--text);
padding: 8px 10px;
padding: 10px 12px;
text-align: left;
cursor: pointer;
font-weight: 700;
}
.nav-link:hover,
@@ -124,6 +125,11 @@ textarea {
border-color: var(--line);
}
.nav-link.active {
border-color: rgba(89, 217, 148, 0.34);
color: #ffffff;
}
.main {
min-width: 0;
padding: 28px;
@@ -188,9 +194,18 @@ textarea {
border-color: rgba(255, 116, 108, 0.36);
}
.admin-views,
.admin-view {
display: grid;
gap: 18px;
}
.admin-view[hidden] {
display: none;
}
.provider-strip,
.settings-section,
.env-panel {
.settings-section {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
@@ -378,24 +393,6 @@ textarea {
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 {
position: fixed;
right: 0;
+105 -22
View File
@@ -4,9 +4,33 @@ const state = {
fields: new Map(),
localStatus: new Map(),
modelOptions: [],
activeView: "providers",
};
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);
@@ -15,11 +39,23 @@ function sourceLabel(source) {
default: "default",
template: "template",
repo_env: "repo .env",
managed_env: "managed",
managed_env: "",
explicit_env_file: "FCC_ENV_FILE",
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) {
@@ -70,7 +106,7 @@ async function load() {
state.status = status;
state.fields = new Map(config.fields.map((field) => [field.key, field]));
updateHeader(status);
renderNav(config.sections);
renderNav();
renderProviders(config.provider_status);
renderSections(config.sections, config.fields);
byId("configPath").textContent = config.paths.managed;
@@ -87,23 +123,51 @@ function updateHeader(status) {
byId("modelBadge").textContent = status.model || "";
}
function renderNav(sections) {
function renderNav() {
const nav = byId("sectionNav");
nav.innerHTML = "";
sections.forEach((section, index) => {
VIEW_GROUPS.forEach((view, index) => {
const button = document.createElement("button");
button.type = "button";
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", () => {
document.querySelectorAll(".nav-link").forEach((link) => {
link.classList.remove("active");
});
button.classList.add("active");
byId(`section-${section.id}`).scrollIntoView({ behavior: "smooth" });
setActiveView(view.id, { scroll: true });
});
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) {
@@ -153,8 +217,11 @@ function updateProviderCard(providerId, status, label, metaText) {
}
function renderSections(sections, fields) {
const container = byId("formSections");
container.innerHTML = "";
VIEW_GROUPS.forEach((view) => {
byId(view.containerId).innerHTML = "";
});
const sectionById = new Map(sections.map((section) => [section.id, section]));
const bySection = new Map();
sections.forEach((section) => bySection.set(section.id, []));
fields.forEach((field) => {
@@ -162,7 +229,13 @@ function renderSections(sections, fields) {
bySection.get(field.section).push(field);
});
sections.forEach((section) => {
VIEW_GROUPS.forEach((view) => {
const container = byId(view.containerId);
view.sections.forEach((sectionId) => {
const section = sectionById.get(sectionId);
const sectionFields = bySection.get(sectionId) || [];
if (!section || sectionFields.length === 0) return;
const sectionEl = document.createElement("section");
sectionEl.className = "settings-section";
sectionEl.id = `section-${section.id}`;
@@ -174,12 +247,12 @@ function renderSections(sections, fields) {
const grid = document.createElement("div");
grid.className = "field-grid";
bySection.get(section.id).forEach((field) => {
sectionFields.forEach((field) => {
grid.appendChild(renderField(field));
});
sectionEl.appendChild(grid);
if (bySection.get(section.id).some((field) => field.advanced)) {
if (sectionFields.some((field) => field.advanced)) {
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "ghost-button advanced-toggle";
@@ -193,6 +266,7 @@ function renderSections(sections, fields) {
container.appendChild(sectionEl);
});
});
}
function renderField(field) {
@@ -202,9 +276,17 @@ function renderField(field) {
const label = document.createElement("label");
label.htmlFor = `field-${field.key}`;
label.innerHTML = `<span>${field.label}</span><span class="field-source">${sourceLabel(
field.source,
)}${field.locked ? " locked" : ""}</span>`;
const labelText = document.createElement("span");
labelText.textContent = field.label;
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);
input.id = `field-${field.key}`;
@@ -316,7 +398,6 @@ async function validate(showResult = true) {
method: "POST",
body: JSON.stringify({ values: changedValues() }),
});
byId("envPreview").textContent = result.env_preview || "";
if (showResult) {
showValidationResult(result);
}
@@ -336,7 +417,6 @@ async function apply() {
method: "POST",
body: JSON.stringify({ values: changedValues() }),
});
byId("envPreview").textContent = result.env_preview || "";
if (!result.applied) {
showValidationResult(result);
return;
@@ -388,7 +468,10 @@ async function testProvider(providerId, button) {
result.models.slice(0, 3).join(", ") || "No models returned",
);
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();
syncModelDatalist();
} else {
+29 -14
View File
@@ -17,14 +17,14 @@
<p>Server Control</p>
</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>
<main class="main">
<header class="topbar">
<div>
<p class="eyebrow">Local Admin</p>
<h2>Runtime Config</h2>
<h2 id="pageTitle">Providers</h2>
</div>
<div class="server-state">
<span id="serverStatus" class="status-pill neutral">Loading</span>
@@ -32,25 +32,40 @@
</div>
</header>
<div id="adminViews" class="admin-views">
<section id="view-providers" class="admin-view active" data-view="providers">
<section class="provider-strip" aria-label="Provider status">
<div class="strip-header">
<h3>Providers</h3>
<button id="refreshLocal" class="ghost-button" type="button">Check local</button>
<button id="refreshLocal" class="ghost-button" type="button">
Check local
</button>
</div>
<div id="providerGrid" class="provider-grid"></div>
</section>
<section id="formSections" class="form-sections" aria-label="Configuration"></section>
<section class="env-panel">
<div class="section-heading">
<div>
<h3>Generated Env</h3>
<p>Read-only preview of the managed config file.</p>
</div>
</div>
<pre id="envPreview" class="env-preview"></pre>
<div
id="providersSections"
class="form-sections"
aria-label="Provider configuration"
></div>
</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 id="view-messaging" class="admin-view" data-view="messaging" hidden>
<div
id="messagingSections"
class="form-sections"
aria-label="Messaging configuration"
></div>
</section>
</div>
</main>
<footer class="action-bar">
-1
View File
@@ -142,7 +142,6 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = {
credential_env="ZAI_API_KEY",
credential_attr="zai_api_key",
default_base_url=ZAI_DEFAULT_BASE,
base_url_attr="zai_base_url",
proxy_attr="zai_proxy",
capabilities=("chat", "streaming", "tools", "thinking", "rate_limit"),
),
+10 -14
View File
@@ -124,10 +124,6 @@ class Settings(BaseSettings):
# ==================== Z.ai Config ====================
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 ====================
# Valid: "telegram" | "discord" | "none"
@@ -297,12 +293,7 @@ class Settings(BaseSettings):
allowed_discord_channels: str | None = Field(
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 = ""
claude_cli_bin: str = Field(default="claude", validation_alias="CLAUDE_CLI_BIN")
max_message_log_entries_per_chat: int | None = Field(
default=None, validation_alias="MAX_MESSAGE_LOG_ENTRIES_PER_CHAT"
)
@@ -351,12 +342,17 @@ class Settings(BaseSettings):
return None
return v
@field_validator("claude_workspace", mode="before")
@classmethod
def default_blank_claude_workspace(cls, v: Any) -> Any:
if v == "" or v is None:
@property
def claude_workspace(self) -> str:
"""Return the fixed Claude data 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")
@classmethod
+140
View File
@@ -32,6 +32,9 @@ def _clear_process_config(monkeypatch) -> None:
"HOST",
"PORT",
"LOG_FILE",
"ZAI_BASE_URL",
"CLAUDE_WORKSPACE",
"CLAUDE_CLI_BIN",
):
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
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):
_set_home(monkeypatch, tmp_path)
_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"]}
assert "ANTHROPIC_AUTH_TOKEN" 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
auth_field = next(
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"
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):
_set_home(monkeypatch, tmp_path)
_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):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
+2 -4
View File
@@ -14,13 +14,11 @@ def _launcher_settings(
*,
port: int = 8082,
token: str = "freecc",
claude_bin: str = "claude-test",
) -> Settings:
return Settings.model_construct(
host="0.0.0.0",
port=port,
anthropic_auth_token=token,
claude_cli_bin=claude_bin,
)
@@ -343,7 +341,7 @@ def test_launch_claude_exits_when_command_cannot_be_resolved(
) -> None:
from cli.entrypoints import launch_claude
settings = _launcher_settings(claude_bin="claude-missing")
settings = _launcher_settings()
with (
patch("cli.entrypoints.get_settings", return_value=settings),
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
popen.assert_not_called()
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
+52 -5
View File
@@ -1,5 +1,7 @@
"""Tests for config/settings.py and config/nim.py"""
from typing import Any, cast
import pytest
from pydantic import ValidationError
@@ -45,7 +47,7 @@ class TestSettings:
assert settings.debug_subagent_stack is False
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
monkeypatch.setenv("HOME", str(tmp_path))
@@ -77,8 +79,19 @@ class TestSettings:
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):
"""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
monkeypatch.setenv("HOME", str(tmp_path))
@@ -90,17 +103,51 @@ class TestSettings:
assert settings.claude_workspace == str(tmp_path / ".fcc" / "agent_workspace")
def test_explicit_claude_workspace_is_preserved(self, monkeypatch, tmp_path):
"""Custom CLAUDE_WORKSPACE values are not rewritten."""
def test_explicit_claude_workspace_is_ignored(self, monkeypatch, tmp_path):
"""Custom CLAUDE_WORKSPACE values do not override the fixed workspace."""
from config.settings import Settings
workspace = tmp_path / "custom-workspace"
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.setenv("CLAUDE_WORKSPACE", str(workspace))
monkeypatch.setitem(Settings.model_config, "env_file", ())
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):
"""Test get_settings returns cached instance."""
+20
View File
@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from config.nim import NimSettings
from config.provider_catalog import ZAI_DEFAULT_BASE
from config.provider_ids import SUPPORTED_PROVIDER_IDS
from providers.deepseek import DeepSeekProvider
from providers.exceptions import UnknownProviderTypeError
@@ -17,6 +18,7 @@ from providers.opencode import OpenCodeProvider
from providers.registry import (
PROVIDER_DESCRIPTORS,
ProviderRegistry,
build_provider_config,
create_provider,
)
from providers.wafer import WaferProvider
@@ -89,6 +91,24 @@ def test_ollama_descriptor_uses_native_anthropic_transport():
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():
with patch("httpx.AsyncClient"):
provider = create_provider("open_router", _make_settings())