mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-06-01 22:09:04 +02:00
Initial admin impl
This commit is contained in:
+1104
File diff suppressed because it is too large
Load Diff
@@ -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__,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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)"
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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"
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user