feat: model registry Phase 2 — cloud provider UI (Anthropic + Google)
Adds cloud provider management to /settings/models: - Google Accounts section: add/remove Gemini API keys with labels - Add Model form: provider tabs (Local / Google / Anthropic) with catalog dropdowns that auto-fill label and context_k - Provider badges on model rows (Anthropic / Google / Local) - /settings/local now redirects to /settings/models (canonical URL) - save_cloud_model() in model_registry for Anthropic/Google entries - Distill role migration restored in _migrate_from_local_llm - Test fixes: version assertions updated to V2 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,19 @@
|
||||
"""
|
||||
Model Registry settings — hosts, models, and role assignments.
|
||||
Model Registry settings — providers, hosts, models, and role assignments.
|
||||
|
||||
Routes:
|
||||
GET /settings/local → settings page
|
||||
POST /settings/local/host → save/create a host
|
||||
POST /settings/local/host/{id}/remove → remove a host (and its models)
|
||||
POST /settings/local/models/add → add a model entry
|
||||
POST /settings/local/models/{id}/remove → remove a model
|
||||
POST /api/models/role → AJAX: set a role assignment
|
||||
GET /api/local-llm/fetch-models → proxy to host /api/models (JSON)
|
||||
GET /settings/models → settings page (canonical)
|
||||
GET /settings/local → redirect to /settings/models
|
||||
POST /settings/local/host → save/create a local host
|
||||
POST /settings/local/host/{id}/remove → remove a host (and its models)
|
||||
POST /settings/local/google-account → save/create a Google account
|
||||
POST /settings/local/google-account/{id}/remove → remove a Google account
|
||||
POST /settings/local/models/add → add a model (any provider)
|
||||
POST /settings/local/models/{id}/remove → remove a model
|
||||
POST /api/models/role → AJAX: set a role assignment
|
||||
GET /api/local-llm/fetch-models → proxy to host /api/models (JSON)
|
||||
"""
|
||||
import json as _json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
@@ -43,21 +47,39 @@ def _get_user(request: Request) -> str | None:
|
||||
# ── Page renderer ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _render(username: str, success: str = "", error: str = "") -> str:
|
||||
registry = reg.get_registry(username)
|
||||
hosts = registry.get("hosts", [])
|
||||
models = registry.get("models", [])
|
||||
roles = registry.get("roles", {})
|
||||
builtins = reg._builtins()
|
||||
registry = reg.get_registry(username)
|
||||
hosts = registry.get("hosts", [])
|
||||
models = registry.get("models", [])
|
||||
roles = registry.get("roles", {})
|
||||
builtins = reg._builtins()
|
||||
host_by_id = {h["id"]: h for h in hosts}
|
||||
goog_accts = registry.get("providers", {}).get("google", {}).get("accounts", [])
|
||||
|
||||
host_by_id = {h["id"]: h for h in hosts}
|
||||
# ── Google account rows ───────────────────────────────────────────────────
|
||||
google_account_rows = ""
|
||||
for a in goog_accts:
|
||||
hint = (a.get("api_key") or "")[:10] + "…" if a.get("api_key") else "no key"
|
||||
google_account_rows += f'''
|
||||
<div class="account-row">
|
||||
<div>
|
||||
<span class="account-label">{a.get("label") or "Unnamed"}</span>
|
||||
<span class="account-hint">{hint}</span>
|
||||
</div>
|
||||
<form method="POST" action="/settings/local/google-account/{a["id"]}/remove"
|
||||
onsubmit="return confirm('Remove this Google account?')">
|
||||
<button type="submit" class="btn-link danger">Remove</button>
|
||||
</form>
|
||||
</div>'''
|
||||
if not google_account_rows:
|
||||
google_account_rows = '<p class="empty-note">No accounts configured yet.</p>'
|
||||
|
||||
# ── Host rows ─────────────────────────────────────────────────────────────
|
||||
# ── Local host rows ───────────────────────────────────────────────────────
|
||||
host_rows = ""
|
||||
for h in hosts:
|
||||
key_hint = f"…{h['api_key'][-4:]}" if h.get("api_key") else "not set"
|
||||
ht = h.get("host_type", "openwebui")
|
||||
ow_sel = ' selected' if ht == "openwebui" else ''
|
||||
ai_sel = ' selected' if ht == "openai" else ''
|
||||
key_hint = f"…{h['api_key'][-4:]}" if h.get("api_key") else "not set"
|
||||
ht = h.get("host_type", "openwebui")
|
||||
ow = ' selected' if ht == "openwebui" else ''
|
||||
ai = ' selected' if ht == "openai" else ''
|
||||
host_rows += f'''
|
||||
<div class="host-row">
|
||||
<form method="POST" action="/settings/local/host" class="host-form">
|
||||
@@ -66,7 +88,7 @@ def _render(username: str, success: str = "", error: str = "") -> str:
|
||||
<div class="field">
|
||||
<label>Label</label>
|
||||
<input type="text" name="label" value="{h.get("label","")}"
|
||||
placeholder="Home ML Laptop" autocomplete="off" data-form-type="other">
|
||||
placeholder="Gaming Laptop" autocomplete="off" data-form-type="other">
|
||||
</div>
|
||||
<div class="field" style="flex:2">
|
||||
<label>API URL</label>
|
||||
@@ -79,85 +101,90 @@ def _render(username: str, success: str = "", error: str = "") -> str:
|
||||
<div class="field">
|
||||
<label>API Key</label>
|
||||
<input type="password" name="api_key" placeholder="Leave blank to keep existing"
|
||||
autocomplete="new-password" data-1p-ignore data-lpignore="true" data-form-type="other">
|
||||
autocomplete="new-password" data-1p-ignore data-lpignore="true"
|
||||
data-form-type="other">
|
||||
<p class="key-status">Current: {key_hint}</p>
|
||||
</div>
|
||||
<div class="field" style="flex:0 0 auto">
|
||||
<label>Type</label>
|
||||
<select name="host_type">
|
||||
<option value="openwebui"{ow_sel}>Open WebUI / Ollama</option>
|
||||
<option value="openai"{ai_sel}>OpenAI-compatible (OpenRouter, etc.)</option>
|
||||
<option value="openwebui"{ow}>Open WebUI / Ollama</option>
|
||||
<option value="openai"{ai}>OpenAI-compatible (OpenRouter, etc.)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn btn-secondary btn-sm">Save host</button>
|
||||
<button type="submit" class="btn btn-secondary btn-sm">Save</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm fetch-btn"
|
||||
data-host-id="{h["id"]}">Fetch models</button>
|
||||
<span class="fetch-status" id="fetch-{h["id"]}"></span>
|
||||
</div>
|
||||
</form>
|
||||
<form method="POST" action="/settings/local/host/{h["id"]}/remove"
|
||||
onsubmit="return confirm('Remove host and all its models?')" style="margin-top:0.5rem">
|
||||
onsubmit="return confirm('Remove host and all its models?')"
|
||||
style="margin-top:0.5rem">
|
||||
<button type="submit" class="btn-link danger">Remove host</button>
|
||||
</form>
|
||||
</div>'''
|
||||
|
||||
if not host_rows:
|
||||
host_rows = '<p class="empty-note">No hosts configured yet. Add one below.</p>'
|
||||
|
||||
# ── Host options for add-model form ───────────────────────────────────────
|
||||
host_options = "".join(
|
||||
f'<option value="{h["id"]}">{h.get("label") or h["api_url"]}</option>'
|
||||
for h in hosts
|
||||
)
|
||||
add_model_hidden = "" if hosts else ' style="display:none"'
|
||||
|
||||
# ── Model rows ────────────────────────────────────────────────────────────
|
||||
# ── Model rows (all providers) ────────────────────────────────────────────
|
||||
_PROVIDER_BADGE = {
|
||||
"claude_cli": ('<span class="pbadge pb-anthropic">Anthropic</span>', "Claude CLI"),
|
||||
"gemini_api": ('<span class="pbadge pb-google">Google</span>', ""),
|
||||
"local_openai": ('<span class="pbadge pb-local">Local</span>', ""),
|
||||
}
|
||||
model_rows = ""
|
||||
for m in models:
|
||||
resolved = reg._resolve_model(registry, m["id"])
|
||||
if not resolved:
|
||||
continue
|
||||
host_name = ""
|
||||
if m.get("type") == "local_openai" and m.get("host_id"):
|
||||
h = host_by_id.get(m["host_id"], {})
|
||||
host_name = h.get("label") or h.get("api_url", "")
|
||||
mtype = m.get("type", "local_openai")
|
||||
badge, default_secondary = _PROVIDER_BADGE.get(mtype, ("", ""))
|
||||
|
||||
ctx_badge = f'<span class="ctx-badge">{m.get("context_k",0)}k ctx</span>' if m.get("context_k") else ""
|
||||
tags_html = " ".join(
|
||||
f'<span class="tag">{t}</span>' for t in (m.get("tags") or [])
|
||||
)
|
||||
host_html = f'<span class="model-host">{host_name}</span>' if host_name else ""
|
||||
if mtype == "local_openai":
|
||||
h = host_by_id.get(m.get("host_id", ""), {})
|
||||
secondary = h.get("label") or h.get("api_url", "")
|
||||
elif mtype == "gemini_api":
|
||||
acct = next((a for a in goog_accts if a["id"] == m.get("account_id")), None)
|
||||
secondary = acct["label"] if acct else ""
|
||||
else:
|
||||
secondary = default_secondary
|
||||
|
||||
ctx = f'<span class="ctx-badge">{m.get("context_k",0)}k</span>' if m.get("context_k") else ""
|
||||
tags = " ".join(f'<span class="tag">{t}</span>' for t in (m.get("tags") or []))
|
||||
sec = f'<span class="model-host">{secondary}</span>' if secondary else ""
|
||||
|
||||
model_rows += f'''
|
||||
<div class="model-row" id="model-{m["id"]}">
|
||||
<div class="model-row">
|
||||
<div class="model-info">
|
||||
<span class="model-label">{m.get("label") or m.get("model_name","")}</span>
|
||||
<div>{badge}<span class="model-label">{m.get("label") or m.get("model_name","")}</span>{ctx}</div>
|
||||
<span class="model-name">{m.get("model_name","")}</span>
|
||||
{host_html}{ctx_badge}
|
||||
<div class="tag-row">{tags_html}</div>
|
||||
</div>
|
||||
<div class="model-actions">
|
||||
<form method="POST" action="/settings/local/models/{m["id"]}/remove"
|
||||
onsubmit="return confirm('Remove this model?')" style="display:inline">
|
||||
<button type="submit" class="row-btn danger">Remove</button>
|
||||
</form>
|
||||
{sec}
|
||||
<div class="tag-row">{tags}</div>
|
||||
</div>
|
||||
<form method="POST" action="/settings/local/models/{m["id"]}/remove"
|
||||
onsubmit="return confirm('Remove this model?')" style="display:inline">
|
||||
<button type="submit" class="row-btn danger">Remove</button>
|
||||
</form>
|
||||
</div>'''
|
||||
|
||||
if not model_rows:
|
||||
model_rows = '<p class="empty-note">No models added yet.</p>'
|
||||
|
||||
# ── Role assignment rows ──────────────────────────────────────────────────
|
||||
# Build option list: (none) + built-ins + user models
|
||||
model_opts = '<option value="">— .env default —</option>\n'
|
||||
model_opts += '<optgroup label="Built-in">\n'
|
||||
for bid, bm in builtins.items():
|
||||
model_opts += f' <option value="{bid}">{bm["label"]}</option>\n'
|
||||
model_opts += '</optgroup>\n'
|
||||
if models:
|
||||
model_opts += '<optgroup label="Local models">\n'
|
||||
model_opts += '<optgroup label="Configured models">\n'
|
||||
for m in models:
|
||||
lbl = m.get("label") or m.get("model_name", m["id"])
|
||||
model_opts += f' <option value="{m["id"]}">{lbl}</option>\n'
|
||||
@@ -166,30 +193,47 @@ def _render(username: str, success: str = "", error: str = "") -> str:
|
||||
role_rows = ""
|
||||
for role in app_settings.get_defined_roles():
|
||||
role_cfg = roles.get(role, {})
|
||||
role_rows += f'<div class="role-row" data-role="{role}"><span class="role-name">{role.title()}</span><div class="role-slots">'
|
||||
for slot in reg.PRIORITY_KEYS[:3]: # primary + backup_1 + backup_2
|
||||
current = role_cfg.get(slot) or ""
|
||||
role_rows += (
|
||||
f'<div class="role-row" data-role="{role}">'
|
||||
f'<span class="role-name">{role.title()}</span>'
|
||||
f'<div class="role-slots">'
|
||||
)
|
||||
for slot in reg.PRIORITY_KEYS[:3]:
|
||||
slot_label = slot.replace("_", " ").title()
|
||||
sel_html = f'<select class="role-select" data-role="{role}" data-slot="{slot}" title="{slot_label}">\n{model_opts}\n</select>'
|
||||
# Pre-select current value via JS (simpler than string-building selected attrs)
|
||||
role_rows += f'<div class="role-slot"><span class="slot-label">{slot_label}</span>{sel_html}</div>'
|
||||
sel = (
|
||||
f'<select class="role-select" data-role="{role}" '
|
||||
f'data-slot="{slot}" title="{slot_label}">\n{model_opts}\n</select>'
|
||||
)
|
||||
role_rows += f'<div class="role-slot"><span class="slot-label">{slot_label}</span>{sel}</div>'
|
||||
role_rows += '</div></div>'
|
||||
|
||||
# JS data for pre-selecting current role values
|
||||
import json as _json
|
||||
role_data_js = _json.dumps({
|
||||
role: {slot: (roles.get(role, {}).get(slot) or "") for slot in reg.PRIORITY_KEYS[:3]}
|
||||
for role in app_settings.get_defined_roles()
|
||||
})
|
||||
|
||||
# ── Catalog data + Google accounts for JS ─────────────────────────────────
|
||||
google_accounts_js = _json.dumps(reg.get_google_accounts(username))
|
||||
google_catalog_js = _json.dumps(reg.get_catalog("google"))
|
||||
anthropic_catalog_js = _json.dumps(reg.get_catalog("anthropic"))
|
||||
has_hosts = "true" if hosts else "false"
|
||||
|
||||
html = (_STATIC / "local_llm.html").read_text()
|
||||
html = html.replace("{{ username }}", username)
|
||||
html = html.replace("{{ host_rows }}", host_rows)
|
||||
html = html.replace("{{ model_rows }}", model_rows)
|
||||
html = html.replace("{{ host_options }}", host_options)
|
||||
html = html.replace("{{ add_model_hidden }}", add_model_hidden)
|
||||
html = html.replace("{{ role_rows }}", role_rows)
|
||||
html = html.replace("{{ role_data_js }}", role_data_js)
|
||||
replacements = {
|
||||
"{{ username }}": username,
|
||||
"{{ google_account_rows }}": google_account_rows,
|
||||
"{{ host_rows }}": host_rows,
|
||||
"{{ model_rows }}": model_rows,
|
||||
"{{ host_options }}": host_options,
|
||||
"{{ role_rows }}": role_rows,
|
||||
"{{ role_data_js }}": role_data_js,
|
||||
"{{ google_accounts_js }}": google_accounts_js,
|
||||
"{{ google_catalog_js }}": google_catalog_js,
|
||||
"{{ anthropic_catalog_js }}": anthropic_catalog_js,
|
||||
"{{ has_hosts }}": has_hosts,
|
||||
}
|
||||
for key, val in replacements.items():
|
||||
html = html.replace(key, val)
|
||||
if success:
|
||||
html = html.replace("<!-- SUCCESS -->", f'<p class="msg success">{success}</p>')
|
||||
if error:
|
||||
@@ -199,14 +243,44 @@ def _render(username: str, success: str = "", error: str = "") -> str:
|
||||
|
||||
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/settings/local", include_in_schema=False)
|
||||
async def models_page(request: Request):
|
||||
@router.get("/settings/models", include_in_schema=False)
|
||||
async def models_page_canonical(request: Request):
|
||||
username = _get_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
return HTMLResponse(_render(username))
|
||||
|
||||
|
||||
@router.get("/settings/local", include_in_schema=False)
|
||||
async def models_page_legacy(request: Request):
|
||||
return RedirectResponse("/settings/models", status_code=301)
|
||||
|
||||
|
||||
@router.post("/settings/local/google-account", include_in_schema=False)
|
||||
async def save_google_account(
|
||||
request: Request,
|
||||
account_id: str = Form(""),
|
||||
label: str = Form(""),
|
||||
api_key: str = Form(""),
|
||||
):
|
||||
username = _get_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
if not api_key.strip() and not account_id.strip():
|
||||
return HTMLResponse(_render(username, error="API key is required."))
|
||||
reg.save_google_account(username, account_id or None, label, api_key)
|
||||
return HTMLResponse(_render(username, success="Google account saved."))
|
||||
|
||||
|
||||
@router.post("/settings/local/google-account/{account_id}/remove", include_in_schema=False)
|
||||
async def remove_google_account(request: Request, account_id: str):
|
||||
username = _get_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
reg.remove_google_account(username, account_id)
|
||||
return HTMLResponse(_render(username, success="Google account removed."))
|
||||
|
||||
|
||||
@router.post("/settings/local/host", include_in_schema=False)
|
||||
async def save_host(
|
||||
request: Request,
|
||||
@@ -222,7 +296,6 @@ async def save_host(
|
||||
if not api_url.strip():
|
||||
return HTMLResponse(_render(username, error="API URL is required."))
|
||||
reg.save_host(username, host_id or None, label, api_url, api_key, host_type)
|
||||
logger.info("model registry host saved: %s (%s)", username, host_type)
|
||||
return HTMLResponse(_render(username, success="Host saved."))
|
||||
|
||||
|
||||
@@ -237,22 +310,50 @@ async def remove_host(request: Request, host_id: str):
|
||||
|
||||
@router.post("/settings/local/models/add", include_in_schema=False)
|
||||
async def add_model(
|
||||
request: Request,
|
||||
host_id: str = Form(...),
|
||||
label: str = Form(""),
|
||||
model_name: str = Form(...),
|
||||
context_k: int = Form(0),
|
||||
tags: str = Form(""),
|
||||
request: Request,
|
||||
provider: str = Form("local"),
|
||||
label: str = Form(""),
|
||||
context_k: int = Form(0),
|
||||
tags: str = Form(""),
|
||||
# local-only fields
|
||||
host_id: str = Form(""),
|
||||
model_name: str = Form(""),
|
||||
# cloud-only fields
|
||||
cloud_model_name: str = Form(""),
|
||||
account_id: str = Form(""),
|
||||
credential_id: str = Form("cli"),
|
||||
):
|
||||
username = _get_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
if not model_name.strip():
|
||||
return HTMLResponse(_render(username, error="Model name is required."))
|
||||
|
||||
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
reg.save_model(username, None, host_id, label, model_name, context_k, tag_list)
|
||||
logger.info("model added to registry: %s / %s", username, model_name)
|
||||
return HTMLResponse(_render(username, success=f'Model "{label or model_name}" added.'))
|
||||
|
||||
if provider == "local":
|
||||
if not model_name.strip():
|
||||
return HTMLResponse(_render(username, error="Model name is required."))
|
||||
if not host_id.strip():
|
||||
return HTMLResponse(_render(username, error="Select a host."))
|
||||
reg.save_model(username, None, host_id, label, model_name, context_k, tag_list)
|
||||
display = label or model_name
|
||||
|
||||
elif provider in ("google", "anthropic"):
|
||||
if not cloud_model_name.strip():
|
||||
return HTMLResponse(_render(username, error="Select a model from the catalog."))
|
||||
if provider == "google" and not account_id.strip():
|
||||
return HTMLResponse(_render(username, error="Select a Google account."))
|
||||
reg.save_cloud_model(
|
||||
username, None, provider, cloud_model_name, label,
|
||||
account_id=account_id or None,
|
||||
credential_id=credential_id or None,
|
||||
context_k=context_k, tags=tag_list,
|
||||
)
|
||||
display = label or cloud_model_name
|
||||
else:
|
||||
return HTMLResponse(_render(username, error=f"Unknown provider: {provider}"))
|
||||
|
||||
logger.info("model added: %s / %s (%s)", username, display, provider)
|
||||
return HTMLResponse(_render(username, success=f'Model "{display}" added.'))
|
||||
|
||||
|
||||
@router.post("/settings/local/models/{model_id}/remove", include_in_schema=False)
|
||||
@@ -287,7 +388,7 @@ async def set_role(request: Request) -> JSONResponse:
|
||||
|
||||
ok = reg.set_role(username, role, slot, model_id)
|
||||
if not ok:
|
||||
return JSONResponse({"error": f"Invalid slot or model_id not found"}, status_code=400)
|
||||
return JSONResponse({"error": "Invalid slot or model_id not found"}, status_code=400)
|
||||
|
||||
logger.info("role set: %s %s.%s = %s", username, role, slot, model_id)
|
||||
return JSONResponse({"ok": True})
|
||||
@@ -295,31 +396,24 @@ async def set_role(request: Request) -> JSONResponse:
|
||||
|
||||
@router.get("/api/local-llm/fetch-models")
|
||||
async def fetch_models(request: Request, host_id: str = "") -> JSONResponse:
|
||||
"""Proxy to the host's /api/models endpoint. host_id selects which host."""
|
||||
"""Proxy to the host's models endpoint. host_id selects which host."""
|
||||
username = _get_user(request)
|
||||
if not username:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
|
||||
registry = reg.get_registry(username)
|
||||
hosts = registry.get("hosts", [])
|
||||
hosts = registry.get("hosts", [])
|
||||
|
||||
if host_id:
|
||||
host = next((h for h in hosts if h["id"] == host_id), None)
|
||||
else:
|
||||
host = hosts[0] if hosts else None
|
||||
host = next((h for h in hosts if h["id"] == host_id), None) if host_id else (hosts[0] if hosts else None)
|
||||
|
||||
# Fall back to .env
|
||||
if host:
|
||||
api_url = host.get("api_url", "")
|
||||
api_key = host.get("api_key", "")
|
||||
api_url, api_key, host_type = host.get("api_url",""), host.get("api_key",""), host.get("host_type","openwebui")
|
||||
else:
|
||||
api_url = app_settings.local_api_url
|
||||
api_key = app_settings.local_api_key
|
||||
api_url, api_key, host_type = app_settings.local_api_url, app_settings.local_api_key, "openwebui"
|
||||
|
||||
if not api_url:
|
||||
return JSONResponse({"error": "No host configured."}, status_code=400)
|
||||
|
||||
host_type = host.get("host_type", "openwebui") if host else "openwebui"
|
||||
models_path = "/models" if host_type == "openai" else "/api/models"
|
||||
url = api_url.rstrip("/") + models_path
|
||||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||
@@ -329,11 +423,10 @@ async def fetch_models(request: Request, host_id: str = "") -> JSONResponse:
|
||||
resp = await client.get(url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
models = [
|
||||
{"id": m["id"], "name": m.get("name") or m["id"]}
|
||||
for m in data.get("data", [])
|
||||
]
|
||||
models.sort(key=lambda m: m["name"].lower())
|
||||
models = sorted(
|
||||
[{"id": m["id"], "name": m.get("name") or m["id"]} for m in data.get("data", [])],
|
||||
key=lambda m: m["name"].lower(),
|
||||
)
|
||||
return JSONResponse({"models": models})
|
||||
except httpx.HTTPStatusError as e:
|
||||
return JSONResponse({"error": f"Host returned {e.response.status_code}"}, status_code=502)
|
||||
|
||||
Reference in New Issue
Block a user