mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-06-02 06:13:46 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user