Initial admin impl

This commit is contained in:
Alishahryar1
2026-05-10 01:21:16 -07:00
parent de8e902899
commit 8ee72968ed
12 changed files with 2579 additions and 15 deletions
+1104
View File
File diff suppressed because it is too large Load Diff
+244
View File
@@ -0,0 +1,244 @@
"""Local admin UI routes and APIs."""
from __future__ import annotations
import ipaddress
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from config.settings import get_settings as get_cached_settings
from providers.registry import ProviderRegistry
from .admin_config import (
FIELD_BY_KEY,
load_config_response,
provider_config_status,
validate_updates,
write_managed_env,
)
router = APIRouter()
STATIC_DIR = Path(__file__).resolve().parent / "admin_static"
LOCAL_PROVIDER_PATHS = {
"lmstudio": "/models",
"llamacpp": "/models",
"ollama": "/api/tags",
}
class AdminConfigPayload(BaseModel):
"""Partial config update submitted by the admin UI."""
values: dict[str, Any] = Field(default_factory=dict)
def _is_loopback_host(host: str | None) -> bool:
if host is None:
return False
normalized = host.strip().strip("[]").lower()
if normalized == "localhost":
return True
try:
return ipaddress.ip_address(normalized).is_loopback
except ValueError:
return False
def _origin_is_local(origin: str | None) -> bool:
if not origin:
return True
parsed = urlsplit(origin)
return _is_loopback_host(parsed.hostname)
def require_loopback_admin(request: Request) -> None:
"""Allow admin access only from the local machine."""
client_host = request.client.host if request.client else None
if not _is_loopback_host(client_host):
raise HTTPException(status_code=403, detail="Admin UI is local-only")
origin = request.headers.get("origin")
if not _origin_is_local(origin):
raise HTTPException(status_code=403, detail="Admin UI is local-only")
def _asset_response(filename: str) -> FileResponse:
path = STATIC_DIR / filename
if not path.is_file():
raise HTTPException(status_code=404, detail="Admin asset not found")
return FileResponse(path)
@router.get("/admin", include_in_schema=False)
async def admin_page(request: Request):
require_loopback_admin(request)
return _asset_response("index.html")
@router.get("/admin/assets/{filename}", include_in_schema=False)
async def admin_asset(filename: str, request: Request):
require_loopback_admin(request)
if filename not in {"admin.css", "admin.js"}:
raise HTTPException(status_code=404, detail="Admin asset not found")
return _asset_response(filename)
@router.get("/admin/api/config")
async def get_admin_config(request: Request):
require_loopback_admin(request)
return load_config_response()
@router.post("/admin/api/config/validate")
async def validate_admin_config(payload: AdminConfigPayload, request: Request):
require_loopback_admin(request)
return validate_updates(_filtered_values(payload.values))
@router.post("/admin/api/config/apply")
async def apply_admin_config(payload: AdminConfigPayload, request: Request):
require_loopback_admin(request)
result = write_managed_env(_filtered_values(payload.values))
if not result["applied"]:
return result
get_cached_settings.cache_clear()
old_registry = getattr(request.app.state, "provider_registry", None)
if isinstance(old_registry, ProviderRegistry):
await old_registry.cleanup()
request.app.state.provider_registry = ProviderRegistry()
request.app.state.admin_pending_fields = result["pending_fields"]
return result
@router.get("/admin/api/status")
async def admin_status(request: Request):
require_loopback_admin(request)
settings = get_cached_settings()
registry = getattr(request.app.state, "provider_registry", None)
cached_models: dict[str, list[str]] = {}
if isinstance(registry, ProviderRegistry):
cached_models = {
provider_id: sorted(model_ids)
for provider_id, model_ids in registry.cached_model_ids().items()
}
return {
"status": "running",
"host": settings.host,
"port": settings.port,
"model": settings.model,
"provider": settings.provider_type,
"pending_fields": getattr(request.app.state, "admin_pending_fields", []),
"provider_status": provider_config_status(),
"cached_models": cached_models,
}
@router.get("/admin/api/providers/local-status")
async def local_provider_status(request: Request):
require_loopback_admin(request)
config = load_config_response()
values = {field["key"]: field["value"] for field in config["fields"]}
checks = []
for provider_id, path in LOCAL_PROVIDER_PATHS.items():
base_url = _local_provider_url(provider_id, values)
checks.append(await _check_local_provider(provider_id, base_url, path))
return {"providers": checks}
@router.post("/admin/api/providers/{provider_id}/test")
async def test_provider(provider_id: str, request: Request):
require_loopback_admin(request)
settings = get_cached_settings()
registry = getattr(request.app.state, "provider_registry", None)
if not isinstance(registry, ProviderRegistry):
registry = ProviderRegistry()
request.app.state.provider_registry = registry
try:
provider = registry.get(provider_id, settings)
infos = await provider.list_model_infos()
except Exception as exc:
return {
"provider_id": provider_id,
"ok": False,
"error_type": type(exc).__name__,
}
registry.cache_model_infos(provider_id, infos)
return {
"provider_id": provider_id,
"ok": True,
"models": sorted(info.model_id for info in infos),
}
@router.post("/admin/api/models/refresh")
async def refresh_models(request: Request):
require_loopback_admin(request)
settings = get_cached_settings()
registry = getattr(request.app.state, "provider_registry", None)
if not isinstance(registry, ProviderRegistry):
registry = ProviderRegistry()
request.app.state.provider_registry = registry
await registry.refresh_model_list_cache(settings)
return {
"cached_models": {
provider_id: sorted(model_ids)
for provider_id, model_ids in registry.cached_model_ids().items()
}
}
def _filtered_values(values: dict[str, Any]) -> dict[str, Any]:
return {key: value for key, value in values.items() if key in FIELD_BY_KEY}
def _local_provider_url(provider_id: str, values: dict[str, str]) -> str:
if provider_id == "lmstudio":
return values.get("LM_STUDIO_BASE_URL", "")
if provider_id == "llamacpp":
return values.get("LLAMACPP_BASE_URL", "")
if provider_id == "ollama":
return values.get("OLLAMA_BASE_URL", "")
return ""
async def _check_local_provider(
provider_id: str, base_url: str, path: str
) -> dict[str, Any]:
clean_url = base_url.strip().rstrip("/")
if not clean_url:
return {
"provider_id": provider_id,
"status": "missing_url",
"label": "Missing URL",
"base_url": base_url,
}
url = f"{clean_url}{path}"
try:
async with httpx.AsyncClient(timeout=1.5) as client:
response = await client.get(url)
ok = 200 <= response.status_code < 300
return {
"provider_id": provider_id,
"status": "reachable" if ok else "offline",
"label": "Reachable" if ok else "Offline",
"base_url": base_url,
"status_code": response.status_code,
}
except Exception as exc:
return {
"provider_id": provider_id,
"status": "offline",
"label": "Offline",
"base_url": base_url,
"error_type": type(exc).__name__,
}
+492
View File
@@ -0,0 +1,492 @@
:root {
color-scheme: dark;
--bg: #11100e;
--panel: #1a1815;
--panel-strong: #25211c;
--card: #201d19;
--input: #12110f;
--text: #f3eee7;
--muted: #aaa197;
--line: #373129;
--line-strong: #4d4439;
--accent: #2fb984;
--accent-dark: #24946b;
--warn: #f5b74f;
--error: #ff746c;
--ok: #59d994;
--info: #7cc7ff;
--shadow: 0 14px 34px rgba(0, 0, 0, 0.38);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
background: var(--bg);
color: var(--text);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
letter-spacing: 0;
}
button,
input,
select,
textarea {
font: inherit;
}
.app-shell {
display: grid;
grid-template-columns: 268px minmax(0, 1fr);
min-height: 100vh;
padding-bottom: 86px;
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
border-right: 1px solid var(--line);
background: #171511;
padding: 20px 16px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 28px;
}
.brand-mark {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border-radius: 8px;
background: var(--accent);
color: #ffffff;
font-weight: 800;
font-size: 14px;
}
.brand h1,
.brand p,
.topbar h2,
.topbar p,
.section-heading h3,
.section-heading p,
.strip-header h3 {
margin: 0;
}
.brand h1 {
font-size: 15px;
line-height: 1.2;
}
.brand p,
.eyebrow,
.section-heading p,
.action-meta span {
color: var(--muted);
font-size: 12px;
}
.section-nav {
display: grid;
gap: 6px;
}
.nav-link {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
min-height: 36px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--text);
padding: 8px 10px;
text-align: left;
cursor: pointer;
}
.nav-link:hover,
.nav-link.active {
background: var(--panel-strong);
border-color: var(--line);
}
.main {
min-width: 0;
padding: 28px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 20px;
}
.eyebrow {
margin-bottom: 4px;
text-transform: uppercase;
font-weight: 700;
}
.topbar h2 {
font-size: 28px;
line-height: 1.15;
}
.server-state {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.status-pill,
.model-badge {
display: inline-flex;
align-items: center;
min-height: 30px;
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 10px;
background: var(--panel-strong);
color: var(--muted);
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.status-pill.ok {
color: var(--ok);
background: rgba(47, 185, 132, 0.13);
border-color: rgba(89, 217, 148, 0.36);
}
.status-pill.warn {
color: var(--warn);
background: rgba(245, 183, 79, 0.13);
border-color: rgba(245, 183, 79, 0.38);
}
.status-pill.error {
color: var(--error);
background: rgba(255, 116, 108, 0.12);
border-color: rgba(255, 116, 108, 0.36);
}
.provider-strip,
.settings-section,
.env-panel {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
box-shadow: var(--shadow);
}
.provider-strip {
margin-bottom: 18px;
padding: 16px;
}
.strip-header,
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.strip-header {
margin-bottom: 12px;
}
.strip-header h3,
.section-heading h3 {
font-size: 16px;
}
.provider-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
}
.provider-card {
display: grid;
gap: 8px;
min-height: 108px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
background: var(--card);
}
.provider-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.provider-title strong {
font-size: 14px;
}
.provider-meta {
color: var(--muted);
font-size: 12px;
word-break: break-word;
}
.test-button,
.ghost-button,
.secondary-button,
.primary-button {
min-height: 34px;
border-radius: 8px;
border: 1px solid var(--line-strong);
padding: 7px 12px;
cursor: pointer;
font-weight: 700;
}
.ghost-button,
.secondary-button,
.test-button {
background: var(--panel-strong);
color: var(--text);
}
.ghost-button:hover,
.secondary-button:hover,
.test-button:hover {
border-color: var(--accent);
}
.primary-button {
border-color: var(--accent);
background: var(--accent);
color: #06100b;
}
.primary-button:hover {
background: var(--accent-dark);
}
.primary-button:disabled {
cursor: not-allowed;
border-color: var(--line);
background: #34302a;
color: #797067;
}
.form-sections {
display: grid;
gap: 18px;
}
.settings-section {
padding: 18px;
scroll-margin-top: 20px;
}
.section-heading {
margin-bottom: 16px;
}
.field-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 14px;
}
.field {
display: grid;
gap: 7px;
align-content: start;
}
.field label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 13px;
font-weight: 700;
}
.field-source {
color: var(--muted);
font-size: 11px;
font-weight: 600;
}
.field input,
.field select,
.field textarea {
width: 100%;
min-height: 38px;
border: 1px solid var(--line-strong);
border-radius: 8px;
background: var(--input);
color: var(--text);
padding: 8px 10px;
}
.field textarea {
min-height: 90px;
resize: vertical;
}
.field input:disabled,
.field select:disabled,
.field textarea:disabled {
background: #26231f;
color: #82796f;
}
.field-description {
color: var(--muted);
font-size: 12px;
line-height: 1.35;
}
.field.advanced-field {
display: none;
}
.settings-section.show-advanced .advanced-field {
display: grid;
}
.advanced-toggle {
justify-self: start;
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;
bottom: 0;
left: 268px;
z-index: 10;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(180px, auto) auto;
gap: 14px;
align-items: center;
min-height: 72px;
border-top: 1px solid var(--line);
background: rgba(26, 24, 21, 0.94);
padding: 12px 28px;
backdrop-filter: blur(12px);
}
.action-meta {
display: grid;
gap: 3px;
min-width: 0;
}
.action-meta strong,
.action-meta span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-area {
min-width: 0;
color: var(--muted);
font-size: 13px;
}
.message-area.error {
color: var(--error);
}
.message-area.ok {
color: var(--ok);
}
.action-buttons {
display: flex;
gap: 8px;
}
@media (max-width: 900px) {
.app-shell {
display: block;
padding-bottom: 122px;
}
.sidebar {
position: relative;
height: auto;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.section-nav {
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
}
.main {
padding: 18px;
}
.topbar {
align-items: flex-start;
flex-direction: column;
}
.server-state {
justify-content: flex-start;
}
.action-bar {
left: 0;
grid-template-columns: 1fr;
align-items: stretch;
padding: 12px 18px;
}
.action-buttons {
justify-content: stretch;
}
.action-buttons button {
flex: 1;
}
}
+417
View File
@@ -0,0 +1,417 @@
const state = {
config: null,
status: null,
fields: new Map(),
localStatus: new Map(),
modelOptions: [],
};
const MASKED_SECRET = "********";
const byId = (id) => document.getElementById(id);
function sourceLabel(source) {
const labels = {
default: "default",
template: "template",
repo_env: "repo .env",
managed_env: "managed",
explicit_env_file: "FCC_ENV_FILE",
process: "process env",
};
return labels[source] || source;
}
function providerName(providerId) {
const names = {
nvidia_nim: "NVIDIA NIM",
open_router: "OpenRouter",
deepseek: "DeepSeek",
lmstudio: "LM Studio",
llamacpp: "llama.cpp",
ollama: "Ollama",
kimi: "Kimi",
wafer: "Wafer",
};
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";
if (["offline", "error"].includes(status)) return "error";
return "neutral";
}
async function api(path, options = {}) {
const response = await fetch(path, {
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
...options,
});
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
return response.json();
}
async function load() {
showMessage("Loading admin config");
const [config, status] = await Promise.all([
api("/admin/api/config"),
api("/admin/api/status"),
]);
state.config = config;
state.status = status;
state.fields = new Map(config.fields.map((field) => [field.key, field]));
updateHeader(status);
renderNav(config.sections);
renderProviders(config.provider_status);
renderSections(config.sections, config.fields);
byId("configPath").textContent = config.paths.managed;
await validate(false);
await refreshLocalStatus();
updateDirtyState();
showMessage("");
}
function updateHeader(status) {
const serverStatus = byId("serverStatus");
serverStatus.textContent = "Running";
serverStatus.className = "status-pill ok";
byId("modelBadge").textContent = status.model || "";
}
function renderNav(sections) {
const nav = byId("sectionNav");
nav.innerHTML = "";
sections.forEach((section, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = `nav-link${index === 0 ? " active" : ""}`;
button.textContent = section.label;
button.addEventListener("click", () => {
document.querySelectorAll(".nav-link").forEach((link) => {
link.classList.remove("active");
});
button.classList.add("active");
byId(`section-${section.id}`).scrollIntoView({ behavior: "smooth" });
});
nav.appendChild(button);
});
}
function renderProviders(providerStatus) {
const grid = byId("providerGrid");
grid.innerHTML = "";
providerStatus.forEach((provider) => {
const card = document.createElement("article");
card.className = "provider-card";
card.dataset.provider = provider.provider_id;
const title = document.createElement("div");
title.className = "provider-title";
title.innerHTML = `<strong>${providerName(provider.provider_id)}</strong>`;
const pill = document.createElement("span");
pill.className = `status-pill ${statusClass(provider.status)}`;
pill.textContent = provider.label;
title.appendChild(pill);
const meta = document.createElement("div");
meta.className = "provider-meta";
meta.textContent =
provider.kind === "local"
? provider.base_url || "No local URL configured"
: provider.credential_env;
const button = document.createElement("button");
button.type = "button";
button.className = "test-button";
button.textContent = provider.kind === "local" ? "Test" : "Refresh models";
button.addEventListener("click", () => testProvider(provider.provider_id, button));
card.append(title, meta, button);
grid.appendChild(card);
});
}
function updateProviderCard(providerId, status, label, metaText) {
const card = document.querySelector(`[data-provider="${providerId}"]`);
if (!card) return;
const pill = card.querySelector(".status-pill");
pill.className = `status-pill ${statusClass(status)}`;
pill.textContent = label;
if (metaText) {
card.querySelector(".provider-meta").textContent = metaText;
}
}
function renderSections(sections, fields) {
const container = byId("formSections");
container.innerHTML = "";
const bySection = new Map();
sections.forEach((section) => bySection.set(section.id, []));
fields.forEach((field) => {
if (!bySection.has(field.section)) bySection.set(field.section, []);
bySection.get(field.section).push(field);
});
sections.forEach((section) => {
const sectionEl = document.createElement("section");
sectionEl.className = "settings-section";
sectionEl.id = `section-${section.id}`;
const heading = document.createElement("div");
heading.className = "section-heading";
heading.innerHTML = `<div><h3>${section.label}</h3><p>${section.description}</p></div>`;
sectionEl.appendChild(heading);
const grid = document.createElement("div");
grid.className = "field-grid";
bySection.get(section.id).forEach((field) => {
grid.appendChild(renderField(field));
});
sectionEl.appendChild(grid);
if (bySection.get(section.id).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);
});
}
function renderField(field) {
const wrapper = document.createElement("div");
wrapper.className = `field${field.advanced ? " advanced-field" : ""}`;
wrapper.dataset.key = field.key;
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 input = inputForField(field);
input.id = `field-${field.key}`;
input.dataset.key = field.key;
input.dataset.original = field.value || "";
input.dataset.secret = field.secret ? "true" : "false";
input.dataset.configured = field.configured ? "true" : "false";
input.disabled = field.locked;
input.addEventListener("input", updateDirtyState);
input.addEventListener("change", updateDirtyState);
wrapper.append(label, input);
if (field.description) {
const description = document.createElement("div");
description.className = "field-description";
description.textContent = field.description;
wrapper.appendChild(description);
}
return wrapper;
}
function inputForField(field) {
if (field.type === "boolean") {
const input = document.createElement("input");
input.type = "checkbox";
input.checked = String(field.value).toLowerCase() === "true";
input.dataset.original = input.checked ? "true" : "false";
return input;
}
if (field.type === "tri_boolean") {
const select = document.createElement("select");
[
["", "Inherit"],
["true", "Enabled"],
["false", "Disabled"],
].forEach(([value, label]) => select.appendChild(option(value, label)));
select.value = field.value || "";
return select;
}
if (field.type === "select") {
const select = document.createElement("select");
field.options.forEach((value) => select.appendChild(option(value, value)));
select.value = field.value || field.options[0] || "";
return select;
}
if (field.type === "textarea") {
const textarea = document.createElement("textarea");
textarea.value = field.value || "";
return textarea;
}
const input = document.createElement("input");
input.type = field.type === "number" ? "number" : "text";
if (field.type === "secret") {
input.type = "password";
input.placeholder = field.configured
? "Configured - enter a new value to replace"
: "Not configured";
input.value = "";
input.autocomplete = "off";
} else {
input.value = field.value || "";
}
if (field.key.startsWith("MODEL")) {
input.setAttribute("list", "model-options");
}
return input;
}
function option(value, label) {
const optionEl = document.createElement("option");
optionEl.value = value;
optionEl.textContent = label;
return optionEl;
}
function readFieldValue(input) {
if (input.type === "checkbox") return input.checked ? "true" : "false";
if (input.dataset.secret === "true" && input.dataset.configured === "true") {
return input.value ? input.value : MASKED_SECRET;
}
return input.value;
}
function changedValues() {
const values = {};
document.querySelectorAll("[data-key]").forEach((input) => {
if (input.disabled || !input.matches("input, select, textarea")) return;
const value = readFieldValue(input);
if (value !== input.dataset.original) {
values[input.dataset.key] = value;
}
});
return values;
}
function updateDirtyState() {
const count = Object.keys(changedValues()).length;
byId("dirtyState").textContent =
count === 0 ? "No changes" : `${count} unsaved change${count === 1 ? "" : "s"}`;
byId("applyButton").disabled = count === 0;
}
async function validate(showResult = true) {
const result = await api("/admin/api/config/validate", {
method: "POST",
body: JSON.stringify({ values: changedValues() }),
});
byId("envPreview").textContent = result.env_preview || "";
if (showResult) {
showValidationResult(result);
}
return result;
}
function showValidationResult(result) {
if (result.valid) {
showMessage("Config shape is valid", "ok");
} else {
showMessage(result.errors.join("; "), "error");
}
}
async function apply() {
const result = await api("/admin/api/config/apply", {
method: "POST",
body: JSON.stringify({ values: changedValues() }),
});
byId("envPreview").textContent = result.env_preview || "";
if (!result.applied) {
showValidationResult(result);
return;
}
const pending = result.pending_fields || [];
await load();
showMessage(
pending.length
? `Applied. Pending manual runtime action: ${pending.join(", ")}`
: "Applied",
"ok",
);
}
async function refreshLocalStatus() {
const result = await api("/admin/api/providers/local-status");
result.providers.forEach((provider) => {
state.localStatus.set(provider.provider_id, provider);
const meta = provider.status_code
? `${provider.base_url} returned HTTP ${provider.status_code}`
: provider.base_url;
updateProviderCard(provider.provider_id, provider.status, provider.label, meta);
});
}
async function testProvider(providerId, button) {
const original = button.textContent;
button.disabled = true;
button.textContent = "Testing";
try {
const result = await api(`/admin/api/providers/${providerId}/test`, {
method: "POST",
body: "{}",
});
if (result.ok) {
updateProviderCard(
providerId,
"reachable",
`${result.models.length} models`,
result.models.slice(0, 3).join(", ") || "No models returned",
);
state.modelOptions = Array.from(
new Set([...state.modelOptions, ...result.models.map((model) => `${providerId}/${model}`)]),
).sort();
syncModelDatalist();
} else {
updateProviderCard(providerId, "offline", result.error_type, result.error_type);
}
} finally {
button.disabled = false;
button.textContent = original;
}
}
function syncModelDatalist() {
let datalist = byId("model-options");
if (!datalist) {
datalist = document.createElement("datalist");
datalist.id = "model-options";
document.body.appendChild(datalist);
}
datalist.innerHTML = "";
state.modelOptions.forEach((model) => datalist.appendChild(option(model, model)));
}
function showMessage(message, kind = "") {
const area = byId("messageArea");
area.textContent = message;
area.className = `message-area ${kind}`.trim();
}
byId("validateButton").addEventListener("click", () => validate(true));
byId("applyButton").addEventListener("click", apply);
byId("refreshLocal").addEventListener("click", refreshLocalStatus);
load().catch((error) => {
byId("serverStatus").textContent = "Error";
byId("serverStatus").className = "status-pill error";
showMessage(error.message, "error");
});
+70
View File
@@ -0,0 +1,70 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Free Claude Code Admin</title>
<link rel="icon" href="data:," />
<link rel="stylesheet" href="/admin/assets/admin.css" />
</head>
<body>
<div class="app-shell">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark">FC</div>
<div>
<h1>Free Claude Code</h1>
<p>Server Control</p>
</div>
</div>
<nav id="sectionNav" class="section-nav" aria-label="Settings sections"></nav>
</aside>
<main class="main">
<header class="topbar">
<div>
<p class="eyebrow">Local Admin</p>
<h2>Runtime Config</h2>
</div>
<div class="server-state">
<span id="serverStatus" class="status-pill neutral">Loading</span>
<span id="modelBadge" class="model-badge"></span>
</div>
</header>
<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>
</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>
</section>
</main>
<footer class="action-bar">
<div class="action-meta">
<strong id="dirtyState">No changes</strong>
<span id="configPath"></span>
</div>
<div id="messageArea" class="message-area"></div>
<div class="action-buttons">
<button id="validateButton" class="secondary-button" type="button">Validate</button>
<button id="applyButton" class="primary-button" type="button" disabled>Apply</button>
</div>
</footer>
</div>
<script src="/admin/assets/admin.js"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
"""Helpers for presenting local admin URLs."""
from __future__ import annotations
from config.settings import Settings
def local_admin_url(settings: Settings) -> str:
"""Return a browser-friendly URL for the localhost-only admin UI."""
host = settings.host.strip() if settings.host else "127.0.0.1"
if host in {"0.0.0.0", "::", "[::]"}:
host = "127.0.0.1"
if ":" in host and not host.startswith("["):
host = f"[{host}]"
return f"http://{host}:{settings.port}/admin"
def admin_launch_message(settings: Settings) -> str:
"""Return the startup message shown by supported launch commands."""
return f"Admin UI: {local_admin_url(settings)} (local-only)"
+2
View File
@@ -15,6 +15,7 @@ from config.logging_config import configure_logging
from config.settings import get_settings
from providers.exceptions import ProviderError
from .admin_routes import router as admin_router
from .routes import router
from .runtime import AppRuntime, startup_failure_message
from .validation_log import summarize_request_validation_body
@@ -95,6 +96,7 @@ def create_app(*, lifespan_enabled: bool = True) -> FastAPI:
app = FastAPI(**app_kwargs)
# Register routes
app.include_router(admin_router)
app.include_router(router)
# Exception handlers
+18 -1
View File
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any
from fastapi import FastAPI
from loguru import logger
from api.admin_urls import local_admin_url
from config.settings import Settings, get_settings
from providers.exceptions import ServiceUnavailableError
from providers.registry import ProviderRegistry
@@ -100,11 +101,12 @@ class AppRuntime:
async def startup(self) -> None:
logger.info("Starting Claude Code Proxy...")
logger.info("Admin UI: {} (local-only)", local_admin_url(self.settings))
self._provider_registry = ProviderRegistry()
self.app.state.provider_registry = self._provider_registry
try:
warn_if_process_auth_token(self.settings)
await self._provider_registry.validate_configured_models(self.settings)
await self._validate_configured_models_best_effort()
self._provider_registry.start_model_list_refresh(self.settings)
await self._start_messaging_if_configured()
self._publish_state()
@@ -117,6 +119,21 @@ class AppRuntime:
)
raise
async def _validate_configured_models_best_effort(self) -> None:
"""Warm validation status without blocking first-run/admin access."""
if self._provider_registry is None:
return
try:
await self._provider_registry.validate_configured_models(self.settings)
except ServiceUnavailableError as exc:
self.app.state.startup_validation_error = exc.message
logger.warning(
"Configured provider model validation failed during startup; "
"server will continue and requests will fail at provider resolution "
"when config is incomplete. {}",
exc.message,
)
async def shutdown(self) -> None:
verbose = self.settings.log_api_error_tracebacks
if self.message_handler is not None:
+1 -1
View File
@@ -29,8 +29,8 @@ class ConfiguredChatModelRef:
def _env_files() -> tuple[Path, ...]:
"""Return env file paths in priority order (later overrides earlier)."""
files: list[Path] = [
Path.home() / ".config" / "free-claude-code" / ".env",
Path(".env"),
Path.home() / ".config" / "free-claude-code" / ".env",
]
if explicit := os.environ.get("FCC_ENV_FILE"):
files.append(Path(explicit))
+1
View File
@@ -46,6 +46,7 @@ packages = ["api", "cli", "config", "core", "messaging", "providers"]
[tool.hatch.build.targets.wheel.force-include]
".env.example" = "cli/env.example"
"api/admin_static" = "api/admin_static"
[tool.uv.sources]
torch = { index = "pytorch-cu130" }
+186
View File
@@ -0,0 +1,186 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import httpx
from fastapi.testclient import TestClient
from api.admin_config import MASKED_SECRET
from api.admin_urls import local_admin_url
from api.app import create_app
from config.settings import Settings
def _local_client(app):
return TestClient(app, client=("127.0.0.1", 50000))
def _set_home(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
def _clear_process_config(monkeypatch) -> None:
for key in (
"MODEL",
"NVIDIA_NIM_API_KEY",
"OPENROUTER_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"FCC_ENV_FILE",
):
monkeypatch.delenv(key, raising=False)
def test_admin_page_is_loopback_only(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
app = create_app(lifespan_enabled=False)
assert _local_client(app).get("/admin").status_code == 200
remote_client = TestClient(app, client=("203.0.113.10", 50000))
assert remote_client.get("/admin").status_code == 403
def test_admin_config_masks_secrets_and_exposes_manifest(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
app = create_app(lifespan_enabled=False)
response = _local_client(app).get("/admin/api/config")
assert response.status_code == 200
body = response.json()
keys = {field["key"] for field in body["fields"]}
assert "ANTHROPIC_AUTH_TOKEN" in keys
assert "OPENROUTER_API_KEY" in keys
auth_field = next(
field for field in body["fields"] if field["key"] == "ANTHROPIC_AUTH_TOKEN"
)
assert auth_field["secret"] is True
assert auth_field["value"] == MASKED_SECRET
assert auth_field["source"] == "template"
def test_admin_validate_rejects_bad_model_shape(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
app = create_app(lifespan_enabled=False)
response = _local_client(app).post(
"/admin/api/config/validate",
json={"values": {"MODEL": "missing-provider-prefix"}},
)
assert response.status_code == 200
body = response.json()
assert body["valid"] is False
assert any("provider type" in error for error in body["errors"])
def test_admin_apply_writes_complete_managed_env_and_masks_preview(
monkeypatch, tmp_path
):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
app = create_app(lifespan_enabled=False)
response = _local_client(app).post(
"/admin/api/config/apply",
json={
"values": {
"MODEL": "open_router/test-model",
"OPENROUTER_API_KEY": "router-secret",
}
},
)
assert response.status_code == 200
body = response.json()
assert body["applied"] is True
assert "OPENROUTER_API_KEY=********" in body["env_preview"]
env_file = tmp_path / ".config" / "free-claude-code" / ".env"
text = env_file.read_text("utf-8")
assert "MODEL=open_router/test-model" in text
assert "OPENROUTER_API_KEY=router-secret" in text
assert "ANTHROPIC_AUTH_TOKEN=" in text
def test_admin_process_env_values_are_locked_and_not_written(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
monkeypatch.setenv("MODEL", "open_router/process-model")
app = create_app(lifespan_enabled=False)
config = _local_client(app).get("/admin/api/config").json()
model_field = next(field for field in config["fields"] if field["key"] == "MODEL")
assert model_field["locked"] is True
assert model_field["source"] == "process"
response = _local_client(app).post(
"/admin/api/config/apply",
json={"values": {"MODEL": "deepseek/managed-model"}},
)
assert response.status_code == 200
env_file = tmp_path / ".config" / "free-claude-code" / ".env"
assert "deepseek/managed-model" not in env_file.read_text("utf-8")
def test_admin_first_apply_migrates_repo_env(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
monkeypatch.chdir(tmp_path)
(tmp_path / ".env").write_text(
"MODEL=deepseek/deepseek-chat\nDEEPSEEK_API_KEY=deepseek-secret\n",
encoding="utf-8",
)
app = create_app(lifespan_enabled=False)
config = _local_client(app).get("/admin/api/config").json()
model_field = next(field for field in config["fields"] if field["key"] == "MODEL")
assert model_field["value"] == "deepseek/deepseek-chat"
assert model_field["source"] == "repo_env"
response = _local_client(app).post(
"/admin/api/config/apply",
json={"values": {}},
)
assert response.status_code == 200
managed_text = (tmp_path / ".config" / "free-claude-code" / ".env").read_text(
"utf-8"
)
assert "MODEL=deepseek/deepseek-chat" in managed_text
assert "DEEPSEEK_API_KEY=deepseek-secret" in managed_text
def test_admin_local_provider_status_reports_reachable(monkeypatch, tmp_path):
_set_home(monkeypatch, tmp_path)
_clear_process_config(monkeypatch)
app = create_app(lifespan_enabled=False)
class FakeAsyncClient:
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return None
async def get(self, url: str):
return httpx.Response(200, json={"data": []})
with patch("api.admin_routes.httpx.AsyncClient", FakeAsyncClient):
response = _local_client(app).get("/admin/api/providers/local-status")
assert response.status_code == 200
providers = response.json()["providers"]
assert {provider["status"] for provider in providers} == {"reachable"}
def test_admin_launch_url_uses_loopback_for_wildcard_host():
settings = Settings.model_construct(host="0.0.0.0", port=8082)
assert local_admin_url(settings) == "http://127.0.0.1:8082/admin"
+22 -13
View File
@@ -353,13 +353,13 @@ def test_app_lifespan_cleanup_continues_if_platform_stop_raises(tmp_path):
@pytest.mark.asyncio
async def test_runtime_startup_validation_blocks_messaging_and_cleans_up(tmp_path):
async def test_runtime_startup_validation_failure_does_not_block_server(tmp_path):
import api.runtime as api_runtime_mod
settings = _app_settings(
messaging_platform="telegram",
telegram_bot_token="token",
allowed_telegram_user_id="123",
messaging_platform="none",
telegram_bot_token=None,
allowed_telegram_user_id=None,
discord_bot_token=None,
allowed_discord_channels=None,
allowed_dir=str(tmp_path / "workspace"),
@@ -379,27 +379,29 @@ async def test_runtime_startup_validation_blocks_messaging_and_cleans_up(tmp_pat
with (
patch.object(ProviderRegistry, "validate_configured_models", new=validation),
patch.object(ProviderRegistry, "cleanup", new=cleanup),
patch.object(api_runtime_mod.logger, "error") as log_error,
patch.object(api_runtime_mod.logger, "warning") as log_warning,
patch(
"messaging.platforms.factory.create_messaging_platform"
"messaging.platforms.factory.create_messaging_platform",
return_value=None,
) as create_platform,
pytest.raises(ServiceUnavailableError, match="bad model"),
):
await runtime.startup()
await runtime.shutdown()
validation.assert_awaited_once_with(settings)
cleanup.assert_awaited_once()
create_platform.assert_not_called()
create_platform.assert_called_once()
logged = " ".join(
str(arg) for call in log_error.call_args_list for arg in call.args
str(arg) for call in log_warning.call_args_list for arg in call.args
)
assert "Startup failed" in logged
assert "validation failed" in logged
assert "bad model" in logged
assert "Traceback" not in logged
assert app.state.startup_validation_error == "bad model"
@pytest.mark.asyncio
async def test_graceful_asgi_lifespan_failure_sends_no_traceback(tmp_path):
async def test_graceful_asgi_lifespan_model_validation_failure_starts(tmp_path):
import api.app as api_app_mod
settings = _app_settings(
@@ -416,9 +418,13 @@ async def test_graceful_asgi_lifespan_failure_sends_no_traceback(tmp_path):
)
app = api_app_mod.GracefulLifespanApp(FastAPI())
sent: list[MutableMapping[str, Any]] = []
received = [
{"type": "lifespan.startup"},
{"type": "lifespan.shutdown"},
]
async def receive() -> MutableMapping[str, Any]:
return {"type": "lifespan.startup"}
return received.pop(0)
async def send(message: MutableMapping[str, Any]) -> None:
sent.append(message)
@@ -432,7 +438,10 @@ async def test_graceful_asgi_lifespan_failure_sends_no_traceback(tmp_path):
):
await app({"type": "lifespan"}, receive, send)
assert sent == [{"type": "lifespan.startup.failed", "message": "bad model"}]
assert sent == [
{"type": "lifespan.startup.complete"},
{"type": "lifespan.shutdown.complete"},
]
def test_app_lifespan_messaging_import_error_no_crash(tmp_path, caplog):