From f08b033d6c32f875907c1985287cea3944da6987 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Mon, 27 Apr 2026 20:41:06 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20model=20registry=20Phase=202=20?= =?UTF-8?q?=E2=80=94=20cloud=20provider=20UI=20(Anthropic=20+=20Google)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cortex/llm_client.py | 4 +- cortex/model_registry.py | 48 +++ cortex/routers/local_llm.py | 289 ++++++++++----- cortex/static/local_llm.html | 531 ++++++++++++++++++---------- cortex/static/settings.html | 2 +- cortex/tests/test_model_registry.py | 4 +- documentation/ARCH__BACKENDS.md | 2 +- documentation/ARCH__CHANNELS.md | 2 +- 8 files changed, 585 insertions(+), 297 deletions(-) diff --git a/cortex/llm_client.py b/cortex/llm_client.py index b810912..6b140e6 100644 --- a/cortex/llm_client.py +++ b/cortex/llm_client.py @@ -177,9 +177,9 @@ async def _local(system_prompt: str, messages: list[dict], model_cfg: dict | Non model = cfg["model_name"] if not api_url: - raise RuntimeError("local_api_url not configured — set LOCAL_API_URL in .env or add a host at /settings/local") + raise RuntimeError("local_api_url not configured — set LOCAL_API_URL in .env or add a host at /settings/models") if not model: - raise RuntimeError("local_model not configured — add a model at /settings/local") + raise RuntimeError("local_model not configured — add a model at /settings/models") host_type = cfg.get("host_type", "openwebui") # "openwebui" uses Open WebUI/Ollama path layout; "openai" uses standard OpenAI layout diff --git a/cortex/model_registry.py b/cortex/model_registry.py index 5d38267..2620949 100644 --- a/cortex/model_registry.py +++ b/cortex/model_registry.py @@ -296,6 +296,8 @@ def _migrate_from_local_llm(username: str, path: Path) -> dict: active_id = old.get("active_model_id") if active_id and any(m["id"] == active_id for m in data["models"]): data["roles"]["chat"] = {"primary": active_id} + if settings.distill_backend_mid == "local": + data["roles"]["distill"] = {"primary": active_id} # Migrate Gemini key from auth.json data = _migrate_v1_to_v2(username, {"version": 1, **data}) @@ -613,6 +615,52 @@ def save_model(username: str, model_id: str | None, host_id: str, return model_id +def save_cloud_model(username: str, model_id: str | None, + provider: str, model_name: str, label: str, + account_id: str | None = None, + credential_id: str | None = None, + context_k: int = 0, + tags: list[str] | None = None) -> str: + """ + Create or update an Anthropic or Google model entry. Returns the model ID. + + provider: "anthropic" | "google" + account_id: Google only — references providers.google.accounts[].id + credential_id: Anthropic only — e.g. "cli" + """ + _TYPE = {"google": "gemini_api", "anthropic": "claude_cli"} + entry_type = _TYPE.get(provider, "gemini_api") + data = _load(username) + tags = tags or [] + + entry: dict = { + "type": entry_type, + "label": label.strip() or model_name.strip(), + "model_name": model_name.strip(), + "provider": provider, + "context_k": context_k, + "tags": tags, + } + if account_id: + entry["account_id"] = account_id + if credential_id: + entry["credential_id"] = credential_id + + if model_id: + for m in data["models"]: + if m["id"] == model_id: + m.update(entry) + _save(username, data) + return model_id + model_id = None + + model_id = secrets.token_hex(4) + entry["id"] = model_id + data["models"].append(entry) + _save(username, data) + return model_id + + def remove_model(username: str, model_id: str) -> bool: """Remove a model and clear any role assignments pointing to it.""" data = _load(username) diff --git a/cortex/routers/local_llm.py b/cortex/routers/local_llm.py index 8d04aab..acb19d1 100644 --- a/cortex/routers/local_llm.py +++ b/cortex/routers/local_llm.py @@ -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''' + ''' + if not google_account_rows: + google_account_rows = '

No accounts configured yet.

' - # ── 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'''
@@ -66,7 +88,7 @@ def _render(username: str, success: str = "", error: str = "") -> str:
+ placeholder="Gaming Laptop" autocomplete="off" data-form-type="other">
@@ -79,85 +101,90 @@ def _render(username: str, success: str = "", error: str = "") -> str:
+ autocomplete="new-password" data-1p-ignore data-lpignore="true" + data-form-type="other">

Current: {key_hint}

- +
+ onsubmit="return confirm('Remove host and all its models?')" + style="margin-top:0.5rem">
''' - 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 (all providers) ──────────────────────────────────────────── + _PROVIDER_BADGE = { + "claude_cli": ('Anthropic', "Claude CLI"), + "gemini_api": ('Google', ""), + "local_openai": ('Local', ""), + } 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'{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 "" + 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'{m.get("context_k",0)}k' if m.get("context_k") else "" + tags = " ".join(f'{t}' for t in (m.get("tags") or [])) + sec = f'{secondary}' if secondary else "" model_rows += f''' -
+
- {m.get("label") or m.get("model_name","")} +
{badge}{m.get("label") or m.get("model_name","")}{ctx}
{m.get("model_name","")} - {host_html}{ctx_badge} -
{tags_html}
-
-
-
- -
+ {sec} +
{tags}
+
+ +
''' - if not model_rows: model_rows = '

No models added yet.

' # ── 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' + model_opts += '\n' for m in models: lbl = m.get("label") or m.get("model_name", m["id"]) model_opts += f' \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'
{role.title()}
' - for slot in reg.PRIORITY_KEYS[:3]: # primary + backup_1 + backup_2 - current = role_cfg.get(slot) or "" + role_rows += ( + f'
' + f'{role.title()}' + f'
' + ) + for slot in reg.PRIORITY_KEYS[:3]: 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}
' + sel = ( + f'' + ) + role_rows += f'
{slot_label}{sel}
' 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() }) + # ── 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("", f'

{success}

') 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) diff --git a/cortex/static/local_llm.html b/cortex/static/local_llm.html index a37ab36..6b131fe 100644 --- a/cortex/static/local_llm.html +++ b/cortex/static/local_llm.html @@ -9,30 +9,21 @@ @@ -206,53 +220,104 @@ ← Chat Help Settings - Models + Models Sign out - - + - +
-

Hosts

-

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

+

Cloud Providers

+ +
+
+
A
+
+
Anthropic
+
Claude via CLI (OAuth) — no API key needed
+
+
+

+ Claude models are accessed through the Claude CLI using your existing OAuth login. + Run claude auth login to authenticate. +

+
+ +
+
+
G
+
+
Google
+
Gemini models via Gemini API
+
+
+ {{ google_account_rows }} +
+ + Add Google account +
+
+ +
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+
+ + +
+

Local Hosts

+

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

{{ host_rows }} -
- + Add host -
+
+ + Add host +
- - Label +
- - API URL +
- - + +
- - @@ -273,57 +338,92 @@
-
+

Add Model

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

Informational labels — used for display and future filtering.

+ +
+
+ + +

Informational labels — used for display and future filtering.

+
+
- +
@@ -333,9 +433,7 @@

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). + Map each task type to a model. Primary is tried first; backups are used if primary fails or is unavailable.

{{ role_rows }}
@@ -344,19 +442,14 @@
diff --git a/cortex/static/settings.html b/cortex/static/settings.html index f07bedc..fef4f7c 100644 --- a/cortex/static/settings.html +++ b/cortex/static/settings.html @@ -302,7 +302,7 @@

Configure OpenAI-compatible hosts and models (Open WebUI, Ollama, LM Studio, etc.).

- diff --git a/cortex/tests/test_model_registry.py b/cortex/tests/test_model_registry.py index 96c1b23..948186a 100644 --- a/cortex/tests/test_model_registry.py +++ b/cortex/tests/test_model_registry.py @@ -70,7 +70,7 @@ def test_empty_registry_no_files(tmp_path): import model_registry as reg with patch.object(config.settings, "home_dir", home): data = reg._load("scott") - assert data["version"] == 1 + assert data["version"] == 2 assert data["hosts"] == [] assert data["models"] == [] assert data["roles"] == {} @@ -244,7 +244,7 @@ def test_migration_saves_registry_file(tmp_path): data2 = reg._load("scott") assert (home / "scott" / "model_registry.json").exists() - assert data2["version"] == 1 + assert data2["version"] == 2 # --------------------------------------------------------------------------- diff --git a/documentation/ARCH__BACKENDS.md b/documentation/ARCH__BACKENDS.md index f3f2a0e..d4855c9 100644 --- a/documentation/ARCH__BACKENDS.md +++ b/documentation/ARCH__BACKENDS.md @@ -55,7 +55,7 @@ Each response shows a model tag (bottom-right of the message bubble) with the mo Per-user configuration stored in `home/{user}/model_registry.json`. -Managed at **Settings → Model Registry** (`/settings/local`). Full provider UI coming in Phase 2. +Managed at **Settings → Models** (`/settings/models`). Full provider UI coming in Phase 2. ```json { diff --git a/documentation/ARCH__CHANNELS.md b/documentation/ARCH__CHANNELS.md index e8c00e2..9d9588c 100644 --- a/documentation/ARCH__CHANNELS.md +++ b/documentation/ARCH__CHANNELS.md @@ -33,7 +33,7 @@ Single-page app served from `cortex/static/`. All chat happens via `POST /chat` **Files panel:** Browse and edit persona markdown files in-browser. Session search at the bottom. -**Settings:** `/settings` — Gemini API key, Google account, connected status. `/settings/local` — local model hosts and models. +**Settings:** `/settings` — Gemini API key, Google account, connected status. `/settings/models` — model registry (providers, hosts, models, roles). ---