From 608e1de2469303fbd606d7f976aea59f645acd00 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Sun, 5 Apr 2026 21:31:32 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20model=20registry=20UI=20=E2=80=94=20hos?= =?UTF-8?q?ts,=20models,=20role=20assignments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single-host local model settings page with a full model registry interface at /settings/local. Hosts section: - List existing hosts with inline edit + save + remove - Collapsible "Add host" form - Per-host "Fetch models" button Models section: - List all models with label, model name, host, context_k badge, tags - Remove button Add Model section: - Host dropdown, label, model name, context_k, tags (comma-separated) - "Fetch models from host" with auto-fill picker Role Assignments section: - One row per defined role (chat, orchestrator, distill, coder, research) - Primary + backup_1 + backup_2 dropdowns per role - Dropdowns pre-filled from registry on load - AJAX save on change (POST /api/models/role) with toast confirmation - Built-in models (claude_cli, gemini_cli, gemini_api) always available in dropdowns Backend: - All user_settings references replaced with model_registry - host/{id}/remove route added - fetch-models now accepts host_id query param - POST /api/models/role for AJAX role assignment Co-Authored-By: Claude Sonnet 4.6 --- cortex/routers/local_llm.py | 280 +++++++++++++++--------- cortex/static/local_llm.html | 401 +++++++++++++++++++++++++---------- 2 files changed, 466 insertions(+), 215 deletions(-) diff --git a/cortex/routers/local_llm.py b/cortex/routers/local_llm.py index be0e62a..8b9e1af 100644 --- a/cortex/routers/local_llm.py +++ b/cortex/routers/local_llm.py @@ -1,13 +1,14 @@ """ -Local LLM settings — per-user host and model configuration. +Model Registry settings — hosts, models, and role assignments. Routes: - GET /settings/local → settings page - POST /settings/local/host → save/create host - POST /settings/local/models/add → add model entry - POST /settings/local/models/{id}/activate → set active model - POST /settings/local/models/{id}/remove → remove model entry - GET /api/local-llm/fetch-models → proxy to host /api/models (JSON) + 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) """ import logging from pathlib import Path @@ -19,7 +20,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from auth_utils import COOKIE_NAME, decode_token from config import settings as app_settings -import user_settings as us +import model_registry as reg logger = logging.getLogger(__name__) router = APIRouter() @@ -42,81 +43,141 @@ def _get_user(request: Request) -> str | None: # ── Page renderer ───────────────────────────────────────────────────────────── def _render(username: str, success: str = "", error: str = "") -> str: - cfg = us.get_config(username) - hosts = cfg["hosts"] - models = cfg["models"] - active = cfg.get("active_model_id") + registry = reg.get_registry(username) + hosts = registry.get("hosts", []) + models = registry.get("models", []) + roles = registry.get("roles", {}) + builtins = reg._builtins() - # Build a host lookup for model rows host_by_id = {h["id"]: h for h in hosts} - # ── Host section ────────────────────────────────────────────────────────── - if hosts: - h = hosts[0] # one host for now - host_id_val = h["id"] - host_label = h.get("label", "") - host_url = h.get("api_url", "") - host_key_hint = f"…{h['api_key'][-4:]}" if h.get("api_key") else "not set" - else: - host_id_val = "" - host_label = "" - host_url = app_settings.local_api_url - host_key_hint = f"server default (…{app_settings.local_api_key[-4:]})" \ - if app_settings.local_api_key else "not set" + # ── Host rows ───────────────────────────────────────────────────────────── + host_rows = "" + for h in hosts: + key_hint = f"…{h['api_key'][-4:]}" if h.get("api_key") else "not set" + host_rows += f''' +
+
+ +
+
+ + +
+
+ + +
+
+
+ + +

Current: {key_hint}

+
+
+ + + +
+
+
+ +
+
''' + + if not host_rows: + host_rows = '

No hosts configured yet. Add one below.

' + + # ── Host options for add-model form ─────────────────────────────────────── + host_options = "".join( + f'' + for h in hosts + ) + add_model_hidden = "" if hosts else ' style="display:none"' # ── Model rows ──────────────────────────────────────────────────────────── model_rows = "" for m in models: - is_active = m["id"] == active - host = host_by_id.get(m["host_id"], {}) - host_name = host.get("label") or host.get("api_url") or "unknown host" - badge = 'active' if is_active else "" - activate_btn = ( - '✓ Active' - if is_active else - f'''
- -
''' + 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", "") + + ctx_badge = f'{m.get("context_k",0)}k ctx' if m.get("context_k") else "" + tags_html = " ".join( + f'{t}' for t in (m.get("tags") or []) ) + host_html = f'{host_name}' if host_name else "" + model_rows += f''' -
+
- {m.get("label") or m["model_name"]}{badge} - {m["model_name"]} - {host_name} + {m.get("label") or m.get("model_name","")} + {m.get("model_name","")} + {host_html}{ctx_badge} +
{tags_html}
- {activate_btn} -
+
''' if not model_rows: - model_rows = '

No models added yet. Use "Add Model" below.

' + model_rows = '

No models added yet.

' - # ── Host select for Add Model ───────────────────────────────────────────── - host_options = "".join( - f'' - for h in hosts - ) - add_section_hidden = "" if hosts else ' style="display:none"' + # ── Role assignment rows ────────────────────────────────────────────────── + # Build option list: (none) + built-ins + user models + model_opts = '\n' + model_opts += '\n' + for bid, bm in builtins.items(): + model_opts += f' \n' + model_opts += '\n' + if models: + model_opts += '\n' + for m in models: + lbl = m.get("label") or m.get("model_name", m["id"]) + model_opts += f' \n' + model_opts += '\n' + + role_rows = "" + for role in app_settings.get_defined_roles(): + role_cfg = roles.get(role, {}) + role_rows += f'
{role.title()}
' + for slot in reg.PRIORITY_KEYS[:3]: # primary + backup_1 + backup_2 + current = role_cfg.get(slot) or "" + slot_label = slot.replace("_", " ").title() + sel_html = f'' + # Pre-select current value via JS (simpler than string-building selected attrs) + role_rows += f'
{slot_label}{sel_html}
' + role_rows += '
' + + # 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() + }) html = (_STATIC / "local_llm.html").read_text() - first_host_id = hosts[0]["id"] if hosts else "" - html = html.replace("{{ username }}", username) - html = html.replace("{{ host_id }}", host_id_val) - html = html.replace("{{ host_label }}", host_label) - html = html.replace("{{ host_url }}", host_url) - html = html.replace("{{ host_key_hint }}", host_key_hint) - html = html.replace("{{ model_rows }}", model_rows) - html = html.replace("{{ host_options }}", host_options) - html = html.replace("{{ first_host_id }}", first_host_id) - html = html.replace("{{ add_section_hidden }}", add_section_hidden) - html = html.replace("{{ has_host }}", "true" if hosts else "false") + 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) if success: html = html.replace("", f'

{success}

') if error: @@ -127,7 +188,7 @@ def _render(username: str, success: str = "", error: str = "") -> str: # ── Routes ──────────────────────────────────────────────────────────────────── @router.get("/settings/local", include_in_schema=False) -async def local_llm_page(request: Request): +async def models_page(request: Request): username = _get_user(request) if not username: return RedirectResponse("/login", status_code=302) @@ -145,45 +206,40 @@ async def save_host( username = _get_user(request) if not username: return RedirectResponse("/login", status_code=302) - if not api_url.strip(): return HTMLResponse(_render(username, error="API URL is required.")) - - us.save_host(username, host_id or None, label, api_url, api_key) - logger.info("local LLM host saved: %s", username) + reg.save_host(username, host_id or None, label, api_url, api_key) + logger.info("model registry host saved: %s", username) return HTMLResponse(_render(username, success="Host saved.")) +@router.post("/settings/local/host/{host_id}/remove", include_in_schema=False) +async def remove_host(request: Request, host_id: str): + username = _get_user(request) + if not username: + return RedirectResponse("/login", status_code=302) + reg.remove_host(username, host_id) + return HTMLResponse(_render(username, success="Host removed.")) + + @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(""), ): 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.")) - - us.add_model(username, host_id, label, model_name) - logger.info("local model added: %s / %s", username, model_name) - return HTMLResponse(_render(username, success=f"Model \"{label or model_name}\" added.")) - - -@router.post("/settings/local/models/{model_id}/activate", include_in_schema=False) -async def activate_model(request: Request, model_id: str): - username = _get_user(request) - if not username: - return RedirectResponse("/login", status_code=302) - - if not us.set_active_model(username, model_id): - return HTMLResponse(_render(username, error="Model not found.")) - - logger.info("active local model set: %s / %s", username, model_id) - return HTMLResponse(_render(username, success="Active model updated.")) + 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.')) @router.post("/settings/local/models/{model_id}/remove", include_in_schema=False) @@ -191,30 +247,58 @@ async def remove_model(request: Request, model_id: str): username = _get_user(request) if not username: return RedirectResponse("/login", status_code=302) - - us.remove_model(username, model_id) - logger.info("local model removed: %s / %s", username, model_id) + reg.remove_model(username, model_id) return HTMLResponse(_render(username, success="Model removed.")) -@router.get("/api/local-llm/fetch-models") -async def fetch_models(request: Request) -> JSONResponse: - """Proxy to the configured host's /api/models endpoint. +@router.post("/api/models/role") +async def set_role(request: Request) -> JSONResponse: + """AJAX: assign a model to a role priority slot. - Returns [{id, name}] sorted by name, or an error dict. + Body: {"role": "chat", "slot": "primary", "model_id": "abc123" | ""} """ username = _get_user(request) if not username: return JSONResponse({"error": "Not authenticated"}, status_code=401) + try: + body = await request.json() + except Exception: + return JSONResponse({"error": "Invalid JSON"}, status_code=400) - cfg = us.get_config(username) - hosts = cfg.get("hosts", []) + role = body.get("role", "").strip() + slot = body.get("slot", "").strip() + model_id = body.get("model_id", "").strip() or None - # Fall back to .env if no host configured yet - if hosts: - h = hosts[0] - api_url = h.get("api_url", "") - api_key = h.get("api_key", "") + if not role or not slot: + return JSONResponse({"error": "role and slot are required"}, status_code=400) + + 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) + + logger.info("role set: %s %s.%s = %s", username, role, slot, model_id) + return JSONResponse({"ok": True}) + + +@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.""" + username = _get_user(request) + if not username: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + registry = reg.get_registry(username) + 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 + + # Fall back to .env + if host: + api_url = host.get("api_url", "") + api_key = host.get("api_key", "") else: api_url = app_settings.local_api_url api_key = app_settings.local_api_key diff --git a/cortex/static/local_llm.html b/cortex/static/local_llm.html index b2b4f86..6a0ccab 100644 --- a/cortex/static/local_llm.html +++ b/cortex/static/local_llm.html @@ -3,7 +3,7 @@ - Cortex — Local Models + Cortex — Model Registry @@ -20,7 +20,7 @@ padding: 2rem 1.5rem 4rem; } - .page { max-width: 640px; margin: 0 auto; } + .page { max-width: 700px; margin: 0 auto; } /* ── Nav ── */ .page-nav { @@ -56,6 +56,9 @@ margin-bottom: 1.1rem; padding-bottom: 0.5rem; border-bottom: 1px solid #2d3148; } + .section-note { + font-size: 0.8rem; color: #64748b; margin-bottom: 1rem; line-height: 1.5; + } /* ── Form elements ── */ .field { margin-bottom: 0.9rem; } @@ -63,7 +66,8 @@ display: block; font-size: 0.78rem; font-weight: 500; color: #94a3b8; margin-bottom: 0.35rem; } - input[type="text"], input[type="password"], input[type="url"], select { + input[type="text"], input[type="password"], input[type="url"], + input[type="number"], select { width: 100%; padding: 0.6rem 0.8rem; background: #0f1117; border: 1px solid #2d3148; border-radius: 6px; color: #e2e8f0; font-size: 0.9rem; font-family: inherit; @@ -71,11 +75,12 @@ } input:focus, select:focus { border-color: #7c3aed; } select { cursor: pointer; } + input[type="number"] { width: 6rem; } .field-row { display: flex; gap: 0.75rem; } .field-row .field { flex: 1; margin-bottom: 0; } - .hint { font-size: 0.75rem; color: #94a3b8; margin-top: 0.35rem; } + .key-status { font-size: 0.75rem; color: #94a3b8; margin-top: 0.35rem; } /* ── Buttons ── */ .btn { @@ -91,28 +96,49 @@ } .btn-secondary:hover { border-color: #94a3b8; color: #e2e8f0; } .btn-sm { padding: 0.35rem 0.7rem; font-size: 0.8rem; font-weight: 500; } - .btn-row { display: flex; gap: 0.6rem; align-items: center; margin-top: 0.5rem; } + .btn-row { display: flex; gap: 0.6rem; align-items: center; margin-top: 0.75rem; flex-wrap: wrap; } + .btn-link { + background: none; border: none; cursor: pointer; font-family: inherit; + font-size: 0.78rem; color: #64748b; padding: 0; text-decoration: underline; + text-underline-offset: 2px; + } + .btn-link:hover { color: #94a3b8; } + .btn-link.danger { color: #7f1d1d; } + .btn-link.danger:hover { color: #f87171; } - /* ── Model list ── */ + /* ── Host rows ── */ + .host-row { + background: #0f1117; border: 1px solid #2d3148; border-radius: 8px; + padding: 1rem; margin-bottom: 0.75rem; + } + .host-form .field-row { margin-bottom: 0.6rem; } + .fetch-status { font-size: 0.78rem; color: #94a3b8; } + .fetch-status.ok { color: #4ade80; } + .fetch-status.err { color: #f87171; } + + /* ── Model rows ── */ .model-row { - display: flex; align-items: center; justify-content: space-between; + display: flex; align-items: flex-start; justify-content: space-between; gap: 0.75rem; padding: 0.75rem 0.9rem; background: #0f1117; border: 1px solid #2d3148; border-radius: 8px; margin-bottom: 0.5rem; } - .model-row.model-active { border-color: #7c3aed; background: #13102a; } .model-info { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; } .model-label { font-size: 0.9rem; font-weight: 600; color: #e2e8f0; } .model-name { font-size: 0.75rem; color: #64748b; font-family: monospace; word-break: break-all; } .model-host { font-size: 0.72rem; color: #475569; } - .active-badge { - display: inline-block; margin-left: 0.5rem; - padding: 0.1rem 0.45rem; border-radius: 3px; - background: #4c1d95; color: #c4b5fd; - font-size: 0.68rem; font-weight: 600; text-transform: uppercase; - vertical-align: middle; + .ctx-badge { + display: inline-block; margin-left: 0.4rem; + padding: 0.1rem 0.35rem; border-radius: 3px; + background: #1e293b; color: #64748b; + font-size: 0.67rem; font-weight: 600; + } + .tag-row { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.2rem; } + .tag { + padding: 0.1rem 0.4rem; border-radius: 3px; + background: #1e1b4b; color: #818cf8; + font-size: 0.68rem; font-weight: 500; } - .active-label { font-size: 0.8rem; color: #a78bfa; font-weight: 500; } .model-actions { display: flex; gap: 0.4rem; flex-shrink: 0; } .row-btn { padding: 0.3rem 0.65rem; border-radius: 5px; font-size: 0.78rem; @@ -120,16 +146,37 @@ border: 1px solid #2d3148; background: #1a1d27; color: #94a3b8; transition: border-color 0.15s, color 0.15s; } - .row-btn:hover { border-color: #7c3aed; color: #a78bfa; } - .row-btn.danger { color: #f87171; border-color: #2d3148; } + .row-btn.danger { color: #f87171; } .row-btn.danger:hover { border-color: #f87171; } - .empty-note { font-size: 0.85rem; color: #475569; padding: 0.5rem 0; } - /* ── Fetch models ── */ - #fetch-status { font-size: 0.8rem; color: #94a3b8; margin-top: 0.5rem; min-height: 1.2rem; } - #fetch-status.ok { color: #4ade80; } - #fetch-status.err { color: #f87171; } - #model-select-wrap { display: none; margin-top: 0.75rem; } + /* ── Role assignment rows ── */ + .role-row { + display: flex; align-items: flex-start; gap: 1rem; + padding: 0.6rem 0; border-bottom: 1px solid #1e2030; + } + .role-row:last-child { border-bottom: none; } + .role-name { + font-size: 0.82rem; font-weight: 600; color: #a78bfa; + min-width: 6rem; padding-top: 0.45rem; + } + .role-slots { display: flex; flex-wrap: wrap; gap: 0.5rem; flex: 1; } + .role-slot { display: flex; flex-direction: column; gap: 0.2rem; flex: 1; min-width: 8rem; } + .slot-label { font-size: 0.68rem; color: #475569; font-weight: 500; text-transform: uppercase; letter-spacing: 0.04em; } + .role-select { + padding: 0.4rem 0.6rem; font-size: 0.8rem; + background: #0f1117; border: 1px solid #2d3148; border-radius: 6px; + color: #e2e8f0; font-family: inherit; cursor: pointer; outline: none; + transition: border-color 0.15s; + } + .role-select:focus { border-color: #7c3aed; } + .role-select.saved { border-color: #166534; } + .role-select.saving { border-color: #92400e; } + .role-select.err { border-color: #7f1d1d; } + + /* ── Add model section ── */ + #add-section .field-row { margin-bottom: 0.5rem; } + #model-select-wrap { display: none; margin-bottom: 0.75rem; } + .tags-hint { font-size: 0.72rem; color: #475569; margin-top: 0.3rem; } /* ── Messages ── */ .msg { @@ -139,8 +186,18 @@ .msg.success { color: #4ade80; background: #052e16; border: 1px solid #166534; } .msg.error { color: #f87171; background: #2d0a0a; border: 1px solid #7f1d1d; } - /* ── Key hint ── */ - .key-status { font-size: 0.75rem; color: #94a3b8; margin-top: 0.35rem; } + /* ── Toast ── */ + #toast { + position: fixed; bottom: 1.5rem; right: 1.5rem; + background: #1a1d27; border: 1px solid #166534; color: #4ade80; + padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.82rem; + opacity: 0; transition: opacity 0.2s; pointer-events: none; + z-index: 100; + } + #toast.show { opacity: 1; } + #toast.err { border-color: #7f1d1d; color: #f87171; } + + .empty-note { font-size: 0.85rem; color: #475569; padding: 0.3rem 0; } @@ -149,159 +206,269 @@ ← Chat Help Settings - Local Models + Models Sign out - +
-

Host

-

- The API server that hosts your local models. Leave the key blank to keep the existing one. -

-
- -
-
- - -
-
- - -
+

Hosts

+

OpenAI-compatible API servers (Open WebUI, Ollama, LM Studio, etc.)

+ {{ host_rows }} +
+ + Add host +
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
-
- - -

Current: {{ host_key_hint }}

-
-
- - - -
- +
- +

Models

{{ model_rows }}
- -
+ +

Add Model

-
-
- + +
+ + +
- +
- +
+
+ + +
+
+ + +

Informational labels — used for display and future filtering.

+
+
+
+ + + +
+ + +
+

Role Assignments

+

+ Choose which model handles each task type. + Backups are tried in order if the primary fails or is unavailable. + Leave a slot empty to use the server default (.env). +

+ {{ role_rows }} +
+
+