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:
@@ -177,9 +177,9 @@ async def _local(system_prompt: str, messages: list[dict], model_cfg: dict | Non
|
|||||||
model = cfg["model_name"]
|
model = cfg["model_name"]
|
||||||
|
|
||||||
if not api_url:
|
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:
|
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")
|
host_type = cfg.get("host_type", "openwebui")
|
||||||
# "openwebui" uses Open WebUI/Ollama path layout; "openai" uses standard OpenAI layout
|
# "openwebui" uses Open WebUI/Ollama path layout; "openai" uses standard OpenAI layout
|
||||||
|
|||||||
@@ -296,6 +296,8 @@ def _migrate_from_local_llm(username: str, path: Path) -> dict:
|
|||||||
active_id = old.get("active_model_id")
|
active_id = old.get("active_model_id")
|
||||||
if active_id and any(m["id"] == active_id for m in data["models"]):
|
if active_id and any(m["id"] == active_id for m in data["models"]):
|
||||||
data["roles"]["chat"] = {"primary": active_id}
|
data["roles"]["chat"] = {"primary": active_id}
|
||||||
|
if settings.distill_backend_mid == "local":
|
||||||
|
data["roles"]["distill"] = {"primary": active_id}
|
||||||
|
|
||||||
# Migrate Gemini key from auth.json
|
# Migrate Gemini key from auth.json
|
||||||
data = _migrate_v1_to_v2(username, {"version": 1, **data})
|
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
|
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:
|
def remove_model(username: str, model_id: str) -> bool:
|
||||||
"""Remove a model and clear any role assignments pointing to it."""
|
"""Remove a model and clear any role assignments pointing to it."""
|
||||||
data = _load(username)
|
data = _load(username)
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
"""
|
"""
|
||||||
Model Registry settings — hosts, models, and role assignments.
|
Model Registry settings — providers, hosts, models, and role assignments.
|
||||||
|
|
||||||
Routes:
|
Routes:
|
||||||
GET /settings/local → settings page
|
GET /settings/models → settings page (canonical)
|
||||||
POST /settings/local/host → save/create a host
|
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/host/{id}/remove → remove a host (and its models)
|
||||||
POST /settings/local/models/add → add a model entry
|
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 /settings/local/models/{id}/remove → remove a model
|
||||||
POST /api/models/role → AJAX: set a role assignment
|
POST /api/models/role → AJAX: set a role assignment
|
||||||
GET /api/local-llm/fetch-models → proxy to host /api/models (JSON)
|
GET /api/local-llm/fetch-models → proxy to host /api/models (JSON)
|
||||||
"""
|
"""
|
||||||
|
import json as _json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -48,16 +52,34 @@ def _render(username: str, success: str = "", error: str = "") -> str:
|
|||||||
models = registry.get("models", [])
|
models = registry.get("models", [])
|
||||||
roles = registry.get("roles", {})
|
roles = registry.get("roles", {})
|
||||||
builtins = reg._builtins()
|
builtins = reg._builtins()
|
||||||
|
|
||||||
host_by_id = {h["id"]: h for h in hosts}
|
host_by_id = {h["id"]: h for h in hosts}
|
||||||
|
goog_accts = registry.get("providers", {}).get("google", {}).get("accounts", [])
|
||||||
|
|
||||||
# ── Host rows ─────────────────────────────────────────────────────────────
|
# ── 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>'
|
||||||
|
|
||||||
|
# ── Local host rows ───────────────────────────────────────────────────────
|
||||||
host_rows = ""
|
host_rows = ""
|
||||||
for h in hosts:
|
for h in hosts:
|
||||||
key_hint = f"…{h['api_key'][-4:]}" if h.get("api_key") else "not set"
|
key_hint = f"…{h['api_key'][-4:]}" if h.get("api_key") else "not set"
|
||||||
ht = h.get("host_type", "openwebui")
|
ht = h.get("host_type", "openwebui")
|
||||||
ow_sel = ' selected' if ht == "openwebui" else ''
|
ow = ' selected' if ht == "openwebui" else ''
|
||||||
ai_sel = ' selected' if ht == "openai" else ''
|
ai = ' selected' if ht == "openai" else ''
|
||||||
host_rows += f'''
|
host_rows += f'''
|
||||||
<div class="host-row">
|
<div class="host-row">
|
||||||
<form method="POST" action="/settings/local/host" class="host-form">
|
<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">
|
<div class="field">
|
||||||
<label>Label</label>
|
<label>Label</label>
|
||||||
<input type="text" name="label" value="{h.get("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>
|
||||||
<div class="field" style="flex:2">
|
<div class="field" style="flex:2">
|
||||||
<label>API URL</label>
|
<label>API URL</label>
|
||||||
@@ -79,85 +101,90 @@ def _render(username: str, success: str = "", error: str = "") -> str:
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label>API Key</label>
|
<label>API Key</label>
|
||||||
<input type="password" name="api_key" placeholder="Leave blank to keep existing"
|
<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>
|
<p class="key-status">Current: {key_hint}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field" style="flex:0 0 auto">
|
<div class="field" style="flex:0 0 auto">
|
||||||
<label>Type</label>
|
<label>Type</label>
|
||||||
<select name="host_type">
|
<select name="host_type">
|
||||||
<option value="openwebui"{ow_sel}>Open WebUI / Ollama</option>
|
<option value="openwebui"{ow}>Open WebUI / Ollama</option>
|
||||||
<option value="openai"{ai_sel}>OpenAI-compatible (OpenRouter, etc.)</option>
|
<option value="openai"{ai}>OpenAI-compatible (OpenRouter, etc.)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-row">
|
<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"
|
<button type="button" class="btn btn-secondary btn-sm fetch-btn"
|
||||||
data-host-id="{h["id"]}">Fetch models</button>
|
data-host-id="{h["id"]}">Fetch models</button>
|
||||||
<span class="fetch-status" id="fetch-{h["id"]}"></span>
|
<span class="fetch-status" id="fetch-{h["id"]}"></span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/settings/local/host/{h["id"]}/remove"
|
<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>
|
<button type="submit" class="btn-link danger">Remove host</button>
|
||||||
</form>
|
</form>
|
||||||
</div>'''
|
</div>'''
|
||||||
|
|
||||||
if not host_rows:
|
if not host_rows:
|
||||||
host_rows = '<p class="empty-note">No hosts configured yet. Add one below.</p>'
|
host_rows = '<p class="empty-note">No hosts configured yet. Add one below.</p>'
|
||||||
|
|
||||||
# ── Host options for add-model form ───────────────────────────────────────
|
|
||||||
host_options = "".join(
|
host_options = "".join(
|
||||||
f'<option value="{h["id"]}">{h.get("label") or h["api_url"]}</option>'
|
f'<option value="{h["id"]}">{h.get("label") or h["api_url"]}</option>'
|
||||||
for h in hosts
|
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 = ""
|
model_rows = ""
|
||||||
for m in models:
|
for m in models:
|
||||||
resolved = reg._resolve_model(registry, m["id"])
|
resolved = reg._resolve_model(registry, m["id"])
|
||||||
if not resolved:
|
if not resolved:
|
||||||
continue
|
continue
|
||||||
host_name = ""
|
mtype = m.get("type", "local_openai")
|
||||||
if m.get("type") == "local_openai" and m.get("host_id"):
|
badge, default_secondary = _PROVIDER_BADGE.get(mtype, ("", ""))
|
||||||
h = host_by_id.get(m["host_id"], {})
|
|
||||||
host_name = h.get("label") or h.get("api_url", "")
|
|
||||||
|
|
||||||
ctx_badge = f'<span class="ctx-badge">{m.get("context_k",0)}k ctx</span>' if m.get("context_k") else ""
|
if mtype == "local_openai":
|
||||||
tags_html = " ".join(
|
h = host_by_id.get(m.get("host_id", ""), {})
|
||||||
f'<span class="tag">{t}</span>' for t in (m.get("tags") or [])
|
secondary = h.get("label") or h.get("api_url", "")
|
||||||
)
|
elif mtype == "gemini_api":
|
||||||
host_html = f'<span class="model-host">{host_name}</span>' if host_name else ""
|
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'''
|
model_rows += f'''
|
||||||
<div class="model-row" id="model-{m["id"]}">
|
<div class="model-row">
|
||||||
<div class="model-info">
|
<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>
|
<span class="model-name">{m.get("model_name","")}</span>
|
||||||
{host_html}{ctx_badge}
|
{sec}
|
||||||
<div class="tag-row">{tags_html}</div>
|
<div class="tag-row">{tags}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="model-actions">
|
|
||||||
<form method="POST" action="/settings/local/models/{m["id"]}/remove"
|
<form method="POST" action="/settings/local/models/{m["id"]}/remove"
|
||||||
onsubmit="return confirm('Remove this model?')" style="display:inline">
|
onsubmit="return confirm('Remove this model?')" style="display:inline">
|
||||||
<button type="submit" class="row-btn danger">Remove</button>
|
<button type="submit" class="row-btn danger">Remove</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
</div>'''
|
</div>'''
|
||||||
|
|
||||||
if not model_rows:
|
if not model_rows:
|
||||||
model_rows = '<p class="empty-note">No models added yet.</p>'
|
model_rows = '<p class="empty-note">No models added yet.</p>'
|
||||||
|
|
||||||
# ── Role assignment rows ──────────────────────────────────────────────────
|
# ── Role assignment rows ──────────────────────────────────────────────────
|
||||||
# Build option list: (none) + built-ins + user models
|
|
||||||
model_opts = '<option value="">— .env default —</option>\n'
|
model_opts = '<option value="">— .env default —</option>\n'
|
||||||
model_opts += '<optgroup label="Built-in">\n'
|
model_opts += '<optgroup label="Built-in">\n'
|
||||||
for bid, bm in builtins.items():
|
for bid, bm in builtins.items():
|
||||||
model_opts += f' <option value="{bid}">{bm["label"]}</option>\n'
|
model_opts += f' <option value="{bid}">{bm["label"]}</option>\n'
|
||||||
model_opts += '</optgroup>\n'
|
model_opts += '</optgroup>\n'
|
||||||
if models:
|
if models:
|
||||||
model_opts += '<optgroup label="Local models">\n'
|
model_opts += '<optgroup label="Configured models">\n'
|
||||||
for m in models:
|
for m in models:
|
||||||
lbl = m.get("label") or m.get("model_name", m["id"])
|
lbl = m.get("label") or m.get("model_name", m["id"])
|
||||||
model_opts += f' <option value="{m["id"]}">{lbl}</option>\n'
|
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 = ""
|
role_rows = ""
|
||||||
for role in app_settings.get_defined_roles():
|
for role in app_settings.get_defined_roles():
|
||||||
role_cfg = roles.get(role, {})
|
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">'
|
role_rows += (
|
||||||
for slot in reg.PRIORITY_KEYS[:3]: # primary + backup_1 + backup_2
|
f'<div class="role-row" data-role="{role}">'
|
||||||
current = role_cfg.get(slot) or ""
|
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()
|
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>'
|
sel = (
|
||||||
# Pre-select current value via JS (simpler than string-building selected attrs)
|
f'<select class="role-select" data-role="{role}" '
|
||||||
role_rows += f'<div class="role-slot"><span class="slot-label">{slot_label}</span>{sel_html}</div>'
|
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>'
|
role_rows += '</div></div>'
|
||||||
|
|
||||||
# JS data for pre-selecting current role values
|
|
||||||
import json as _json
|
|
||||||
role_data_js = _json.dumps({
|
role_data_js = _json.dumps({
|
||||||
role: {slot: (roles.get(role, {}).get(slot) or "") for slot in reg.PRIORITY_KEYS[:3]}
|
role: {slot: (roles.get(role, {}).get(slot) or "") for slot in reg.PRIORITY_KEYS[:3]}
|
||||||
for role in app_settings.get_defined_roles()
|
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 = (_STATIC / "local_llm.html").read_text()
|
||||||
html = html.replace("{{ username }}", username)
|
replacements = {
|
||||||
html = html.replace("{{ host_rows }}", host_rows)
|
"{{ username }}": username,
|
||||||
html = html.replace("{{ model_rows }}", model_rows)
|
"{{ google_account_rows }}": google_account_rows,
|
||||||
html = html.replace("{{ host_options }}", host_options)
|
"{{ host_rows }}": host_rows,
|
||||||
html = html.replace("{{ add_model_hidden }}", add_model_hidden)
|
"{{ model_rows }}": model_rows,
|
||||||
html = html.replace("{{ role_rows }}", role_rows)
|
"{{ host_options }}": host_options,
|
||||||
html = html.replace("{{ role_data_js }}", role_data_js)
|
"{{ 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:
|
if success:
|
||||||
html = html.replace("<!-- SUCCESS -->", f'<p class="msg success">{success}</p>')
|
html = html.replace("<!-- SUCCESS -->", f'<p class="msg success">{success}</p>')
|
||||||
if error:
|
if error:
|
||||||
@@ -199,14 +243,44 @@ def _render(username: str, success: str = "", error: str = "") -> str:
|
|||||||
|
|
||||||
# ── Routes ────────────────────────────────────────────────────────────────────
|
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/settings/local", include_in_schema=False)
|
@router.get("/settings/models", include_in_schema=False)
|
||||||
async def models_page(request: Request):
|
async def models_page_canonical(request: Request):
|
||||||
username = _get_user(request)
|
username = _get_user(request)
|
||||||
if not username:
|
if not username:
|
||||||
return RedirectResponse("/login", status_code=302)
|
return RedirectResponse("/login", status_code=302)
|
||||||
return HTMLResponse(_render(username))
|
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)
|
@router.post("/settings/local/host", include_in_schema=False)
|
||||||
async def save_host(
|
async def save_host(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -222,7 +296,6 @@ async def save_host(
|
|||||||
if not api_url.strip():
|
if not api_url.strip():
|
||||||
return HTMLResponse(_render(username, error="API URL is required."))
|
return HTMLResponse(_render(username, error="API URL is required."))
|
||||||
reg.save_host(username, host_id or None, label, api_url, api_key, host_type)
|
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."))
|
return HTMLResponse(_render(username, success="Host saved."))
|
||||||
|
|
||||||
|
|
||||||
@@ -238,21 +311,49 @@ async def remove_host(request: Request, host_id: str):
|
|||||||
@router.post("/settings/local/models/add", include_in_schema=False)
|
@router.post("/settings/local/models/add", include_in_schema=False)
|
||||||
async def add_model(
|
async def add_model(
|
||||||
request: Request,
|
request: Request,
|
||||||
host_id: str = Form(...),
|
provider: str = Form("local"),
|
||||||
label: str = Form(""),
|
label: str = Form(""),
|
||||||
model_name: str = Form(...),
|
|
||||||
context_k: int = Form(0),
|
context_k: int = Form(0),
|
||||||
tags: str = Form(""),
|
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)
|
username = _get_user(request)
|
||||||
if not username:
|
if not username:
|
||||||
return RedirectResponse("/login", status_code=302)
|
return RedirectResponse("/login", status_code=302)
|
||||||
|
|
||||||
|
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
||||||
|
|
||||||
|
if provider == "local":
|
||||||
if not model_name.strip():
|
if not model_name.strip():
|
||||||
return HTMLResponse(_render(username, error="Model name is required."))
|
return HTMLResponse(_render(username, error="Model name is required."))
|
||||||
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
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)
|
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)
|
display = label or model_name
|
||||||
return HTMLResponse(_render(username, success=f'Model "{label or model_name}" added.'))
|
|
||||||
|
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)
|
@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)
|
ok = reg.set_role(username, role, slot, model_id)
|
||||||
if not ok:
|
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)
|
logger.info("role set: %s %s.%s = %s", username, role, slot, model_id)
|
||||||
return JSONResponse({"ok": True})
|
return JSONResponse({"ok": True})
|
||||||
@@ -295,7 +396,7 @@ async def set_role(request: Request) -> JSONResponse:
|
|||||||
|
|
||||||
@router.get("/api/local-llm/fetch-models")
|
@router.get("/api/local-llm/fetch-models")
|
||||||
async def fetch_models(request: Request, host_id: str = "") -> JSONResponse:
|
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)
|
username = _get_user(request)
|
||||||
if not username:
|
if not username:
|
||||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||||
@@ -303,23 +404,16 @@ async def fetch_models(request: Request, host_id: str = "") -> JSONResponse:
|
|||||||
registry = reg.get_registry(username)
|
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) if host_id else (hosts[0] if hosts else None)
|
||||||
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:
|
if host:
|
||||||
api_url = host.get("api_url", "")
|
api_url, api_key, host_type = host.get("api_url",""), host.get("api_key",""), host.get("host_type","openwebui")
|
||||||
api_key = host.get("api_key", "")
|
|
||||||
else:
|
else:
|
||||||
api_url = app_settings.local_api_url
|
api_url, api_key, host_type = app_settings.local_api_url, app_settings.local_api_key, "openwebui"
|
||||||
api_key = app_settings.local_api_key
|
|
||||||
|
|
||||||
if not api_url:
|
if not api_url:
|
||||||
return JSONResponse({"error": "No host configured."}, status_code=400)
|
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"
|
models_path = "/models" if host_type == "openai" else "/api/models"
|
||||||
url = api_url.rstrip("/") + models_path
|
url = api_url.rstrip("/") + models_path
|
||||||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
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 = await client.get(url, headers=headers)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
models = [
|
models = sorted(
|
||||||
{"id": m["id"], "name": m.get("name") or m["id"]}
|
[{"id": m["id"], "name": m.get("name") or m["id"]} for m in data.get("data", [])],
|
||||||
for m in data.get("data", [])
|
key=lambda m: m["name"].lower(),
|
||||||
]
|
)
|
||||||
models.sort(key=lambda m: m["name"].lower())
|
|
||||||
return JSONResponse({"models": models})
|
return JSONResponse({"models": models})
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
return JSONResponse({"error": f"Host returned {e.response.status_code}"}, status_code=502)
|
return JSONResponse({"error": f"Host returned {e.response.status_code}"}, status_code=502)
|
||||||
|
|||||||
@@ -9,30 +9,21 @@
|
|||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh; background: #0f1117;
|
||||||
background: #0f1117;
|
font-family: 'Inter', system-ui, sans-serif; font-weight: 450;
|
||||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
-webkit-font-smoothing: antialiased; color: #e2e8f0;
|
||||||
font-weight: 450;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
color: #e2e8f0;
|
|
||||||
padding: 2rem 1.5rem 4rem;
|
padding: 2rem 1.5rem 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page { max-width: 700px; margin: 0 auto; }
|
.page { max-width: 700px; margin: 0 auto; }
|
||||||
|
|
||||||
/* ── Nav ── */
|
/* Nav */
|
||||||
.page-nav {
|
.page-nav { display: flex; align-items: center; gap: 0.25rem; margin-bottom: 1.75rem; flex-wrap: wrap; }
|
||||||
display: flex; align-items: center; gap: 0.25rem;
|
|
||||||
margin-bottom: 1.75rem; flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
display: inline-flex; align-items: center;
|
display: inline-flex; align-items: center;
|
||||||
padding: 0.3rem 0.6rem; border-radius: 6px;
|
padding: 0.3rem 0.6rem; border-radius: 6px;
|
||||||
font-size: 0.8rem; font-weight: 500; color: #64748b;
|
font-size: 0.8rem; font-weight: 500; color: #64748b;
|
||||||
text-decoration: none; transition: color 0.15s, background 0.15s;
|
text-decoration: none; transition: color 0.15s, background 0.15s; white-space: nowrap;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
.nav-link:hover { color: #cbd5e1; background: rgba(255,255,255,0.05); }
|
.nav-link:hover { color: #cbd5e1; background: rgba(255,255,255,0.05); }
|
||||||
.nav-link.active { color: #a78bfa; }
|
.nav-link.active { color: #a78bfa; }
|
||||||
@@ -40,12 +31,12 @@
|
|||||||
.nav-link.nav-logout { color: #475569; }
|
.nav-link.nav-logout { color: #475569; }
|
||||||
.nav-link.nav-logout:hover { color: #94a3b8; background: none; }
|
.nav-link.nav-logout:hover { color: #94a3b8; background: none; }
|
||||||
|
|
||||||
/* ── Page header ── */
|
/* Page header */
|
||||||
.page-header { margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 1px solid #2d3148; }
|
.page-header { margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 1px solid #2d3148; }
|
||||||
.page-header h1 { font-size: 1.4rem; font-weight: 700; color: #a78bfa; }
|
.page-header h1 { font-size: 1.4rem; font-weight: 700; color: #a78bfa; }
|
||||||
.page-header p { font-size: 0.82rem; color: #94a3b8; margin-top: 0.25rem; }
|
.page-header p { font-size: 0.82rem; color: #94a3b8; margin-top: 0.25rem; }
|
||||||
|
|
||||||
/* ── Section cards ── */
|
/* Section cards */
|
||||||
.section {
|
.section {
|
||||||
background: #1a1d27; border: 1px solid #2d3148;
|
background: #1a1d27; border: 1px solid #2d3148;
|
||||||
border-radius: 10px; padding: 1.5rem; margin-bottom: 1.25rem;
|
border-radius: 10px; padding: 1.5rem; margin-bottom: 1.25rem;
|
||||||
@@ -56,16 +47,37 @@
|
|||||||
margin-bottom: 1.1rem; padding-bottom: 0.5rem;
|
margin-bottom: 1.1rem; padding-bottom: 0.5rem;
|
||||||
border-bottom: 1px solid #2d3148;
|
border-bottom: 1px solid #2d3148;
|
||||||
}
|
}
|
||||||
.section-note {
|
.section-note { font-size: 0.8rem; color: #64748b; margin-bottom: 1rem; line-height: 1.5; }
|
||||||
font-size: 0.8rem; color: #64748b; margin-bottom: 1rem; line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Form elements ── */
|
/* Provider sub-sections */
|
||||||
.field { margin-bottom: 0.9rem; }
|
.provider-block { margin-bottom: 1.25rem; }
|
||||||
label {
|
.provider-block:last-child { margin-bottom: 0; }
|
||||||
display: block; font-size: 0.78rem; font-weight: 500;
|
.provider-header {
|
||||||
color: #94a3b8; margin-bottom: 0.35rem;
|
display: flex; align-items: center; gap: 0.6rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
.provider-icon {
|
||||||
|
width: 1.6rem; height: 1.6rem; border-radius: 5px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 0.75rem; font-weight: 700; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pi-anthropic { background: #1e1b4b; color: #818cf8; }
|
||||||
|
.pi-google { background: #042f2e; color: #34d399; }
|
||||||
|
.provider-title { font-size: 0.9rem; font-weight: 600; color: #e2e8f0; }
|
||||||
|
.provider-subtitle { font-size: 0.78rem; color: #64748b; }
|
||||||
|
|
||||||
|
/* Account rows */
|
||||||
|
.account-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 0.6rem 0.9rem; background: #0f1117;
|
||||||
|
border: 1px solid #2d3148; border-radius: 8px; margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.account-label { font-size: 0.88rem; font-weight: 600; color: #e2e8f0; }
|
||||||
|
.account-hint { font-size: 0.73rem; color: #475569; margin-left: 0.5rem; font-family: monospace; }
|
||||||
|
|
||||||
|
/* Form elements */
|
||||||
|
.field { margin-bottom: 0.9rem; }
|
||||||
|
label { display: block; font-size: 0.78rem; font-weight: 500; color: #94a3b8; margin-bottom: 0.35rem; }
|
||||||
input[type="text"], input[type="password"], input[type="url"],
|
input[type="text"], input[type="password"], input[type="url"],
|
||||||
input[type="number"], select {
|
input[type="number"], select {
|
||||||
width: 100%; padding: 0.6rem 0.8rem;
|
width: 100%; padding: 0.6rem 0.8rem;
|
||||||
@@ -76,13 +88,11 @@
|
|||||||
input:focus, select:focus { border-color: #7c3aed; }
|
input:focus, select:focus { border-color: #7c3aed; }
|
||||||
select { cursor: pointer; }
|
select { cursor: pointer; }
|
||||||
input[type="number"] { width: 6rem; }
|
input[type="number"] { width: 6rem; }
|
||||||
|
|
||||||
.field-row { display: flex; gap: 0.75rem; }
|
.field-row { display: flex; gap: 0.75rem; }
|
||||||
.field-row .field { flex: 1; margin-bottom: 0; }
|
.field-row .field { flex: 1; margin-bottom: 0; }
|
||||||
|
|
||||||
.key-status { font-size: 0.75rem; color: #94a3b8; margin-top: 0.35rem; }
|
.key-status { font-size: 0.75rem; color: #94a3b8; margin-top: 0.35rem; }
|
||||||
|
|
||||||
/* ── Buttons ── */
|
/* Buttons */
|
||||||
.btn {
|
.btn {
|
||||||
padding: 0.6rem 1.1rem; border: none; border-radius: 6px;
|
padding: 0.6rem 1.1rem; border: none; border-radius: 6px;
|
||||||
font-size: 0.88rem; font-weight: 600; cursor: pointer;
|
font-size: 0.88rem; font-weight: 600; cursor: pointer;
|
||||||
@@ -90,23 +100,41 @@
|
|||||||
}
|
}
|
||||||
.btn-primary { background: #7c3aed; color: #fff; }
|
.btn-primary { background: #7c3aed; color: #fff; }
|
||||||
.btn-primary:hover { background: #6d28d9; }
|
.btn-primary:hover { background: #6d28d9; }
|
||||||
.btn-secondary {
|
.btn-secondary { background: #1a1d27; color: #94a3b8; border: 1px solid #2d3148; }
|
||||||
background: #1a1d27; color: #94a3b8;
|
|
||||||
border: 1px solid #2d3148;
|
|
||||||
}
|
|
||||||
.btn-secondary:hover { border-color: #94a3b8; color: #e2e8f0; }
|
.btn-secondary:hover { border-color: #94a3b8; color: #e2e8f0; }
|
||||||
.btn-sm { padding: 0.35rem 0.7rem; font-size: 0.8rem; font-weight: 500; }
|
.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.75rem; flex-wrap: wrap; }
|
.btn-row { display: flex; gap: 0.6rem; align-items: center; margin-top: 0.75rem; flex-wrap: wrap; }
|
||||||
.btn-link {
|
.btn-link {
|
||||||
background: none; border: none; cursor: pointer; font-family: inherit;
|
background: none; border: none; cursor: pointer; font-family: inherit;
|
||||||
font-size: 0.78rem; color: #64748b; padding: 0; text-decoration: underline;
|
font-size: 0.78rem; color: #64748b; padding: 0;
|
||||||
text-underline-offset: 2px;
|
text-decoration: underline; text-underline-offset: 2px;
|
||||||
}
|
}
|
||||||
.btn-link:hover { color: #94a3b8; }
|
.btn-link:hover { color: #94a3b8; }
|
||||||
.btn-link.danger { color: #7f1d1d; }
|
.btn-link.danger { color: #7f1d1d; }
|
||||||
.btn-link.danger:hover { color: #f87171; }
|
.btn-link.danger:hover { color: #f87171; }
|
||||||
|
|
||||||
/* ── Host rows ── */
|
/* Provider tabs */
|
||||||
|
.ptabs { display: flex; gap: 0; margin-bottom: 1.1rem; border-bottom: 1px solid #2d3148; }
|
||||||
|
.ptab {
|
||||||
|
padding: 0.45rem 0.9rem; font-size: 0.82rem; font-weight: 500;
|
||||||
|
background: none; border: none; cursor: pointer; color: #64748b;
|
||||||
|
border-bottom: 2px solid transparent; margin-bottom: -1px;
|
||||||
|
transition: color 0.15s, border-color 0.15s; font-family: inherit;
|
||||||
|
}
|
||||||
|
.ptab.active { color: #a78bfa; border-bottom-color: #a78bfa; }
|
||||||
|
.ptab:hover:not(.active) { color: #cbd5e1; }
|
||||||
|
|
||||||
|
/* Provider badges on model rows */
|
||||||
|
.pbadge {
|
||||||
|
display: inline-block; padding: 0.1rem 0.35rem; border-radius: 3px;
|
||||||
|
font-size: 0.65rem; font-weight: 600; margin-right: 0.35rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.pb-anthropic { background: #1e1b4b; color: #818cf8; }
|
||||||
|
.pb-google { background: #042f2e; color: #34d399; }
|
||||||
|
.pb-local { background: #1e293b; color: #64748b; }
|
||||||
|
|
||||||
|
/* Host & model rows */
|
||||||
.host-row {
|
.host-row {
|
||||||
background: #0f1117; border: 1px solid #2d3148; border-radius: 8px;
|
background: #0f1117; border: 1px solid #2d3148; border-radius: 8px;
|
||||||
padding: 1rem; margin-bottom: 0.75rem;
|
padding: 1rem; margin-bottom: 0.75rem;
|
||||||
@@ -116,7 +144,6 @@
|
|||||||
.fetch-status.ok { color: #4ade80; }
|
.fetch-status.ok { color: #4ade80; }
|
||||||
.fetch-status.err { color: #f87171; }
|
.fetch-status.err { color: #f87171; }
|
||||||
|
|
||||||
/* ── Model rows ── */
|
|
||||||
.model-row {
|
.model-row {
|
||||||
display: flex; align-items: flex-start; justify-content: space-between;
|
display: flex; align-items: flex-start; justify-content: space-between;
|
||||||
gap: 0.75rem; padding: 0.75rem 0.9rem;
|
gap: 0.75rem; padding: 0.75rem 0.9rem;
|
||||||
@@ -128,37 +155,29 @@
|
|||||||
.model-name { font-size: 0.75rem; color: #64748b; font-family: monospace; word-break: break-all; }
|
.model-name { font-size: 0.75rem; color: #64748b; font-family: monospace; word-break: break-all; }
|
||||||
.model-host { font-size: 0.72rem; color: #475569; }
|
.model-host { font-size: 0.72rem; color: #475569; }
|
||||||
.ctx-badge {
|
.ctx-badge {
|
||||||
display: inline-block; margin-left: 0.4rem;
|
display: inline-block; margin-left: 0.35rem;
|
||||||
padding: 0.1rem 0.35rem; border-radius: 3px;
|
padding: 0.1rem 0.3rem; border-radius: 3px;
|
||||||
background: #1e293b; color: #64748b;
|
background: #1e293b; color: #64748b; font-size: 0.65rem; font-weight: 600;
|
||||||
font-size: 0.67rem; font-weight: 600;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
.tag-row { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.2rem; }
|
.tag-row { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.2rem; }
|
||||||
.tag {
|
.tag { padding: 0.1rem 0.4rem; border-radius: 3px; background: #1e1b4b; color: #818cf8; font-size: 0.68rem; font-weight: 500; }
|
||||||
padding: 0.1rem 0.4rem; border-radius: 3px;
|
|
||||||
background: #1e1b4b; color: #818cf8;
|
|
||||||
font-size: 0.68rem; font-weight: 500;
|
|
||||||
}
|
|
||||||
.model-actions { display: flex; gap: 0.4rem; flex-shrink: 0; }
|
|
||||||
.row-btn {
|
.row-btn {
|
||||||
padding: 0.3rem 0.65rem; border-radius: 5px; font-size: 0.78rem;
|
padding: 0.3rem 0.65rem; border-radius: 5px; font-size: 0.78rem;
|
||||||
font-weight: 500; cursor: pointer; font-family: inherit;
|
font-weight: 500; cursor: pointer; font-family: inherit;
|
||||||
border: 1px solid #2d3148; background: #1a1d27; color: #94a3b8;
|
border: 1px solid #2d3148; background: #1a1d27; color: #94a3b8;
|
||||||
transition: border-color 0.15s, color 0.15s;
|
transition: border-color 0.15s, color 0.15s; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.row-btn.danger { color: #f87171; }
|
.row-btn.danger { color: #f87171; }
|
||||||
.row-btn.danger:hover { border-color: #f87171; }
|
.row-btn.danger:hover { border-color: #f87171; }
|
||||||
|
|
||||||
/* ── Role assignment rows ── */
|
/* Role assignments */
|
||||||
.role-row {
|
.role-row {
|
||||||
display: flex; align-items: flex-start; gap: 1rem;
|
display: flex; align-items: flex-start; gap: 1rem;
|
||||||
padding: 0.6rem 0; border-bottom: 1px solid #1e2030;
|
padding: 0.6rem 0; border-bottom: 1px solid #1e2030;
|
||||||
}
|
}
|
||||||
.role-row:last-child { border-bottom: none; }
|
.role-row:last-child { border-bottom: none; }
|
||||||
.role-name {
|
.role-name { font-size: 0.82rem; font-weight: 600; color: #a78bfa; min-width: 6rem; padding-top: 0.45rem; }
|
||||||
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-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; }
|
.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; }
|
.slot-label { font-size: 0.68rem; color: #475569; font-weight: 500; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
@@ -173,31 +192,26 @@
|
|||||||
.role-select.saving { border-color: #92400e; }
|
.role-select.saving { border-color: #92400e; }
|
||||||
.role-select.err { border-color: #7f1d1d; }
|
.role-select.err { border-color: #7f1d1d; }
|
||||||
|
|
||||||
/* ── Add model section ── */
|
/* Model select picker */
|
||||||
#add-section .field-row { margin-bottom: 0.5rem; }
|
|
||||||
#model-select-wrap { display: none; margin-bottom: 0.75rem; }
|
#model-select-wrap { display: none; margin-bottom: 0.75rem; }
|
||||||
.tags-hint { font-size: 0.72rem; color: #475569; margin-top: 0.3rem; }
|
.tags-hint { font-size: 0.72rem; color: #475569; margin-top: 0.3rem; }
|
||||||
|
|
||||||
/* ── Messages ── */
|
/* Messages & Toast */
|
||||||
.msg {
|
.msg { font-size: 0.85rem; text-align: center; padding: 0.6rem 1rem; border-radius: 6px; margin-bottom: 1rem; }
|
||||||
font-size: 0.85rem; text-align: center;
|
|
||||||
padding: 0.6rem 1rem; border-radius: 6px; margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.msg.success { color: #4ade80; background: #052e16; border: 1px solid #166534; }
|
.msg.success { color: #4ade80; background: #052e16; border: 1px solid #166534; }
|
||||||
.msg.error { color: #f87171; background: #2d0a0a; border: 1px solid #7f1d1d; }
|
.msg.error { color: #f87171; background: #2d0a0a; border: 1px solid #7f1d1d; }
|
||||||
|
|
||||||
/* ── Toast ── */
|
|
||||||
#toast {
|
#toast {
|
||||||
position: fixed; bottom: 1.5rem; right: 1.5rem;
|
position: fixed; bottom: 1.5rem; right: 1.5rem;
|
||||||
background: #1a1d27; border: 1px solid #166534; color: #4ade80;
|
background: #1a1d27; border: 1px solid #166534; color: #4ade80;
|
||||||
padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.82rem;
|
padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.82rem;
|
||||||
opacity: 0; transition: opacity 0.2s; pointer-events: none;
|
opacity: 0; transition: opacity 0.2s; pointer-events: none; z-index: 100;
|
||||||
z-index: 100;
|
|
||||||
}
|
}
|
||||||
#toast.show { opacity: 1; }
|
#toast.show { opacity: 1; }
|
||||||
#toast.err { border-color: #7f1d1d; color: #f87171; }
|
#toast.err { border-color: #7f1d1d; color: #f87171; }
|
||||||
|
|
||||||
.empty-note { font-size: 0.85rem; color: #475569; padding: 0.3rem 0; }
|
.empty-note { font-size: 0.85rem; color: #475569; padding: 0.3rem 0; }
|
||||||
|
details summary { font-size: 0.82rem; color: #64748b; cursor: pointer; user-select: none; margin-top: 0.75rem; }
|
||||||
|
details > div { margin-top: 0.75rem; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -206,53 +220,104 @@
|
|||||||
<a href="/" class="nav-link">← Chat</a>
|
<a href="/" class="nav-link">← Chat</a>
|
||||||
<a href="/help" class="nav-link">Help</a>
|
<a href="/help" class="nav-link">Help</a>
|
||||||
<a href="/settings" class="nav-link">Settings</a>
|
<a href="/settings" class="nav-link">Settings</a>
|
||||||
<a href="/settings/local" class="nav-link active">Models</a>
|
<a href="/settings/models" class="nav-link active">Models</a>
|
||||||
<span class="nav-spacer"></span>
|
<span class="nav-spacer"></span>
|
||||||
<a href="/logout" class="nav-link nav-logout">Sign out</a>
|
<a href="/logout" class="nav-link nav-logout">Sign out</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>Model Registry</h1>
|
<h1>Model Registry</h1>
|
||||||
<p>Configure hosts, models, and which model handles each task type.</p>
|
<p>Configure providers, hosts, and model assignments.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SUCCESS -->
|
<!-- SUCCESS --><!-- ERROR -->
|
||||||
<!-- ERROR -->
|
|
||||||
|
|
||||||
<!-- ── Hosts ── -->
|
<!-- ── Cloud Providers ── -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Hosts</h2>
|
<h2>Cloud Providers</h2>
|
||||||
<p class="section-note">OpenAI-compatible API servers (Open WebUI, Ollama, LM Studio, etc.)</p>
|
|
||||||
|
<div class="provider-block">
|
||||||
|
<div class="provider-header">
|
||||||
|
<div class="provider-icon pi-anthropic">A</div>
|
||||||
|
<div>
|
||||||
|
<div class="provider-title">Anthropic</div>
|
||||||
|
<div class="provider-subtitle">Claude via CLI (OAuth) — no API key needed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="section-note" style="margin-bottom:0">
|
||||||
|
Claude models are accessed through the Claude CLI using your existing OAuth login.
|
||||||
|
Run <code style="font-family:monospace;color:#94a3b8">claude auth login</code> to authenticate.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="provider-block" style="border-top:1px solid #2d3148; padding-top:1.25rem">
|
||||||
|
<div class="provider-header">
|
||||||
|
<div class="provider-icon pi-google">G</div>
|
||||||
|
<div>
|
||||||
|
<div class="provider-title">Google</div>
|
||||||
|
<div class="provider-subtitle">Gemini models via Gemini API</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ google_account_rows }}
|
||||||
|
<details>
|
||||||
|
<summary>+ Add Google account</summary>
|
||||||
|
<div>
|
||||||
|
<form method="POST" action="/settings/local/google-account">
|
||||||
|
<input type="hidden" name="account_id" value="">
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Label <span style="color:#475569;font-weight:400">(e.g. Work, Personal)</span></label>
|
||||||
|
<input type="text" name="label" placeholder="One Sky IT"
|
||||||
|
autocomplete="off" data-form-type="other">
|
||||||
|
</div>
|
||||||
|
<div class="field" style="flex:2">
|
||||||
|
<label>API Key</label>
|
||||||
|
<input type="password" name="api_key" placeholder="AIza…"
|
||||||
|
autocomplete="new-password" data-1p-ignore data-lpignore="true"
|
||||||
|
data-form-type="other">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Add Account</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Local Hosts ── -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Local Hosts</h2>
|
||||||
|
<p class="section-note">OpenAI-compatible API servers (Open WebUI, Ollama, LM Studio, OpenRouter, etc.)</p>
|
||||||
{{ host_rows }}
|
{{ host_rows }}
|
||||||
<details style="margin-top:0.75rem">
|
<details>
|
||||||
<summary style="font-size:0.82rem; color:#64748b; cursor:pointer; user-select:none">+ Add host</summary>
|
<summary>+ Add host</summary>
|
||||||
<div style="margin-top:0.75rem">
|
<div>
|
||||||
<form method="POST" action="/settings/local/host">
|
<form method="POST" action="/settings/local/host">
|
||||||
<input type="hidden" name="host_id" value="">
|
<input type="hidden" name="host_id" value="">
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="new-host-label">Label</label>
|
<label>Label</label>
|
||||||
<input type="text" id="new-host-label" name="label"
|
<input type="text" name="label" placeholder="Gaming Laptop"
|
||||||
placeholder="e.g. Gaming Laptop"
|
|
||||||
autocomplete="off" data-form-type="other">
|
autocomplete="off" data-form-type="other">
|
||||||
</div>
|
</div>
|
||||||
<div class="field" style="flex:2">
|
<div class="field" style="flex:2">
|
||||||
<label for="new-host-url">API URL</label>
|
<label>API URL</label>
|
||||||
<input type="text" id="new-host-url" name="api_url"
|
<input type="text" name="api_url" placeholder="http://192.168.x.x:3000"
|
||||||
placeholder="http://192.168.x.x:3000"
|
|
||||||
autocomplete="off" spellcheck="false" data-form-type="other">
|
autocomplete="off" spellcheck="false" data-form-type="other">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="new-host-key">API Key</label>
|
<label>API Key</label>
|
||||||
<input type="password" id="new-host-key" name="api_key"
|
<input type="password" name="api_key" placeholder="sk-… (leave blank if not required)"
|
||||||
placeholder="sk-… (leave blank if not required)"
|
autocomplete="new-password" data-1p-ignore data-lpignore="true"
|
||||||
autocomplete="new-password" data-1p-ignore data-lpignore="true" data-form-type="other">
|
data-form-type="other">
|
||||||
</div>
|
</div>
|
||||||
<div class="field" style="flex:0 0 auto">
|
<div class="field" style="flex:0 0 auto">
|
||||||
<label for="new-host-type">Type</label>
|
<label>Type</label>
|
||||||
<select id="new-host-type" name="host_type">
|
<select name="host_type">
|
||||||
<option value="openwebui">Open WebUI / Ollama</option>
|
<option value="openwebui">Open WebUI / Ollama</option>
|
||||||
<option value="openai">OpenAI-compatible (OpenRouter, etc.)</option>
|
<option value="openai">OpenAI-compatible (OpenRouter, etc.)</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -273,57 +338,92 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Add Model ── -->
|
<!-- ── Add Model ── -->
|
||||||
<div class="section" id="add-section"{{ add_model_hidden }}>
|
<div class="section">
|
||||||
<h2>Add Model</h2>
|
<h2>Add Model</h2>
|
||||||
|
|
||||||
|
<div class="ptabs" id="provider-tabs">
|
||||||
|
<button type="button" class="ptab active" data-p="local">Local</button>
|
||||||
|
<button type="button" class="ptab" data-p="google">Google</button>
|
||||||
|
<button type="button" class="ptab" data-p="anthropic">Anthropic</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="/settings/local/models/add" id="add-form">
|
||||||
|
<input type="hidden" name="provider" id="add-provider-val" value="local">
|
||||||
|
|
||||||
|
<!-- LOCAL fields -->
|
||||||
|
<div id="pf-local">
|
||||||
<div id="model-select-wrap">
|
<div id="model-select-wrap">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="model-picker">Available on host</label>
|
<label>Available on host</label>
|
||||||
<select id="model-picker">
|
<select id="model-picker">
|
||||||
<option value="">— select to auto-fill —</option>
|
<option value="">— select to auto-fill —</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" action="/settings/local/models/add" id="add-form">
|
|
||||||
<input type="hidden" name="host_id" id="add-host-id" value="">
|
|
||||||
<div class="field">
|
|
||||||
<label for="add-host-select">Host</label>
|
|
||||||
<select id="add-host-select" onchange="document.getElementById('add-host-id').value=this.value">
|
|
||||||
{{ host_options }}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<div class="field">
|
<div class="field" style="flex:0 0 auto">
|
||||||
<label for="add-label">Label</label>
|
<label>Host</label>
|
||||||
<input type="text" id="add-label" name="label"
|
<select id="add-host-select" name="host_id"></select>
|
||||||
placeholder="e.g. Gemma 4 E4B"
|
|
||||||
autocomplete="off" data-form-type="other">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field" style="flex:2">
|
<div class="field" style="flex:2">
|
||||||
<label for="add-model-name">Model name</label>
|
<label>Model name / ID</label>
|
||||||
<input type="text" id="add-model-name" name="model_name"
|
<input type="text" id="add-model-name" name="model_name"
|
||||||
placeholder="e.g. gemma4:e4b"
|
placeholder="e.g. gemma4:e4b"
|
||||||
autocomplete="off" spellcheck="false" data-form-type="other">
|
autocomplete="off" spellcheck="false" data-form-type="other">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GOOGLE fields -->
|
||||||
|
<div id="pf-google" style="display:none">
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<div class="field" style="flex:0 0 auto">
|
<div class="field">
|
||||||
<label for="add-context-k">Context (k tokens)</label>
|
<label>Gemini model</label>
|
||||||
<input type="number" id="add-context-k" name="context_k"
|
<select id="add-gemini-model"></select>
|
||||||
value="0" min="0" max="10000">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="add-tags">Tags <span style="color:#475569; font-weight:400">(comma-separated)</span></label>
|
<label>Account</label>
|
||||||
<input type="text" id="add-tags" name="tags"
|
<select id="add-google-account" name="account_id"></select>
|
||||||
placeholder="fast, distill, coding"
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ANTHROPIC fields -->
|
||||||
|
<div id="pf-anthropic" style="display:none">
|
||||||
|
<div class="field">
|
||||||
|
<label>Claude model</label>
|
||||||
|
<select id="add-claude-model"></select>
|
||||||
|
</div>
|
||||||
|
<p class="section-note" style="margin-top:-0.25rem">Uses Claude CLI (OAuth)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden: cloud model name (set by JS from catalog pickers) -->
|
||||||
|
<input type="hidden" id="cloud-model-name" name="cloud_model_name" value="">
|
||||||
|
<input type="hidden" name="credential_id" value="cli">
|
||||||
|
|
||||||
|
<!-- Shared fields -->
|
||||||
|
<div class="field-row" style="margin-top:0.75rem">
|
||||||
|
<div class="field">
|
||||||
|
<label>Label</label>
|
||||||
|
<input type="text" id="add-label" name="label"
|
||||||
|
placeholder="e.g. Gemma 4 E4B"
|
||||||
|
autocomplete="off" data-form-type="other">
|
||||||
|
</div>
|
||||||
|
<div class="field" style="flex:0 0 auto">
|
||||||
|
<label>Context (k tokens)</label>
|
||||||
|
<input type="number" id="add-context-k" name="context_k" value="0" min="0" max="10000">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Tags <span style="color:#475569;font-weight:400">(comma-separated)</span></label>
|
||||||
|
<input type="text" name="tags" placeholder="fast, distill, coding"
|
||||||
autocomplete="off" data-form-type="other">
|
autocomplete="off" data-form-type="other">
|
||||||
<p class="tags-hint">Informational labels — used for display and future filtering.</p>
|
<p class="tags-hint">Informational labels — used for display and future filtering.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button type="submit" class="btn btn-primary btn-sm">Add Model</button>
|
<button type="submit" class="btn btn-primary btn-sm">Add Model</button>
|
||||||
<button type="button" id="fetch-btn" class="btn btn-secondary btn-sm">
|
<button type="button" id="fetch-btn" class="btn btn-secondary btn-sm">Fetch from host</button>
|
||||||
Fetch models from host
|
|
||||||
</button>
|
|
||||||
<span id="fetch-status" class="fetch-status"></span>
|
<span id="fetch-status" class="fetch-status"></span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -333,9 +433,7 @@
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Role Assignments</h2>
|
<h2>Role Assignments</h2>
|
||||||
<p class="section-note">
|
<p class="section-note">
|
||||||
Choose which model handles each task type.
|
Map each task type to a model. Primary is tried first; backups are used if primary fails or is unavailable.
|
||||||
Backups are tried in order if the primary fails or is unavailable.
|
|
||||||
Leave a slot empty to use the server default (.env).
|
|
||||||
</p>
|
</p>
|
||||||
{{ role_rows }}
|
{{ role_rows }}
|
||||||
</div>
|
</div>
|
||||||
@@ -344,19 +442,14 @@
|
|||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// ── Pre-fill role selects ─────────────────────────────────────────────────
|
// ── Injected data ─────────────────────────────────────────────────────────
|
||||||
const ROLE_DATA = {{ role_data_js }};
|
const ROLE_DATA = {{ role_data_js }};
|
||||||
|
const GOOGLE_ACCOUNTS = {{ google_accounts_js }};
|
||||||
|
const GOOGLE_CATALOG = {{ google_catalog_js }};
|
||||||
|
const ANTHROPIC_CATALOG = {{ anthropic_catalog_js }};
|
||||||
|
const HAS_HOSTS = {{ has_hosts }};
|
||||||
|
|
||||||
document.querySelectorAll('.role-select').forEach(sel => {
|
// ── Role selects: pre-fill + AJAX save ────────────────────────────────────
|
||||||
const role = sel.dataset.role;
|
|
||||||
const slot = sel.dataset.slot;
|
|
||||||
const val = (ROLE_DATA[role] || {})[slot] || '';
|
|
||||||
for (const opt of sel.options) {
|
|
||||||
if (opt.value === val) { opt.selected = true; break; }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Role select change → AJAX save ───────────────────────────────────────
|
|
||||||
const toast = document.getElementById('toast');
|
const toast = document.getElementById('toast');
|
||||||
let toastTimer = null;
|
let toastTimer = null;
|
||||||
|
|
||||||
@@ -364,20 +457,21 @@
|
|||||||
toast.textContent = msg;
|
toast.textContent = msg;
|
||||||
toast.className = 'show' + (err ? ' err' : '');
|
toast.className = 'show' + (err ? ' err' : '');
|
||||||
clearTimeout(toastTimer);
|
clearTimeout(toastTimer);
|
||||||
toastTimer = setTimeout(() => { toast.className = ''; }, 2000);
|
toastTimer = setTimeout(() => { toast.className = ''; }, 2500);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll('.role-select').forEach(sel => {
|
document.querySelectorAll('.role-select').forEach(sel => {
|
||||||
|
const val = (ROLE_DATA[sel.dataset.role] || {})[sel.dataset.slot] || '';
|
||||||
|
for (const opt of sel.options) {
|
||||||
|
if (opt.value === val) { opt.selected = true; break; }
|
||||||
|
}
|
||||||
sel.addEventListener('change', async () => {
|
sel.addEventListener('change', async () => {
|
||||||
const role = sel.dataset.role;
|
const { role, slot } = sel.dataset;
|
||||||
const slot = sel.dataset.slot;
|
|
||||||
const model_id = sel.value || null;
|
const model_id = sel.value || null;
|
||||||
|
|
||||||
sel.classList.add('saving');
|
sel.classList.add('saving');
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/models/role', {
|
const res = await fetch('/api/models/role', {
|
||||||
method: 'POST',
|
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({role, slot, model_id}),
|
body: JSON.stringify({role, slot, model_id}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -397,27 +491,90 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Fetch models from host ────────────────────────────────────────────────
|
// ── Provider tabs ─────────────────────────────────────────────────────────
|
||||||
// Per-host "Fetch models" buttons in the host rows
|
const providerVal = document.getElementById('add-provider-val');
|
||||||
|
const pfields = {
|
||||||
|
local: document.getElementById('pf-local'),
|
||||||
|
google: document.getElementById('pf-google'),
|
||||||
|
anthropic: document.getElementById('pf-anthropic'),
|
||||||
|
};
|
||||||
|
const fetchBtn = document.getElementById('fetch-btn');
|
||||||
|
|
||||||
|
document.querySelectorAll('.ptab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.ptab').forEach(t => t.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
const p = tab.dataset.p;
|
||||||
|
providerVal.value = p;
|
||||||
|
for (const [key, el] of Object.entries(pfields)) {
|
||||||
|
el.style.display = key === p ? '' : 'none';
|
||||||
|
}
|
||||||
|
fetchBtn.style.display = p === 'local' ? '' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Populate catalog dropdowns ────────────────────────────────────────────
|
||||||
|
function populateSelect(selEl, items, valKey, labelKey) {
|
||||||
|
selEl.innerHTML = '<option value="">— select —</option>';
|
||||||
|
items.forEach(item => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = item[valKey];
|
||||||
|
opt.textContent = item[labelKey];
|
||||||
|
opt.dataset.label = item.label || '';
|
||||||
|
opt.dataset.ctx = item.context_k || 0;
|
||||||
|
selEl.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const geminiSel = document.getElementById('add-gemini-model');
|
||||||
|
const claudeSel = document.getElementById('add-claude-model');
|
||||||
|
const gAcctSel = document.getElementById('add-google-account');
|
||||||
|
|
||||||
|
populateSelect(geminiSel, GOOGLE_CATALOG, 'id', 'label');
|
||||||
|
populateSelect(claudeSel, ANTHROPIC_CATALOG, 'id', 'label');
|
||||||
|
|
||||||
|
if (GOOGLE_ACCOUNTS.length) {
|
||||||
|
gAcctSel.innerHTML = '<option value="">— select account —</option>';
|
||||||
|
GOOGLE_ACCOUNTS.forEach(a => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = a.id;
|
||||||
|
opt.textContent = a.label || a.hint;
|
||||||
|
gAcctSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
gAcctSel.innerHTML = '<option value="">No accounts configured — add one above</option>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCatalogChange(sel) {
|
||||||
|
const opt = sel.options[sel.selectedIndex];
|
||||||
|
if (!opt.value) return;
|
||||||
|
document.getElementById('cloud-model-name').value = opt.value;
|
||||||
|
document.getElementById('add-context-k').value = opt.dataset.ctx || 0;
|
||||||
|
if (!document.getElementById('add-label').value) {
|
||||||
|
document.getElementById('add-label').value = opt.dataset.label || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
geminiSel.addEventListener('change', () => onCatalogChange(geminiSel));
|
||||||
|
claudeSel.addEventListener('change', () => onCatalogChange(claudeSel));
|
||||||
|
|
||||||
|
// ── Host select + fetch (local) ───────────────────────────────────────────
|
||||||
|
const hostSel = document.getElementById('add-host-select');
|
||||||
|
const hostOpts = `{{ host_options }}`;
|
||||||
|
hostSel.innerHTML = hostOpts || '<option value="">No hosts configured</option>';
|
||||||
|
|
||||||
|
// Per-host "Fetch" buttons
|
||||||
document.querySelectorAll('.fetch-btn').forEach(btn => {
|
document.querySelectorAll('.fetch-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => fetchModels(btn.dataset.hostId, btn));
|
btn.addEventListener('click', () => fetchModels(btn.dataset.hostId, btn));
|
||||||
});
|
});
|
||||||
|
|
||||||
// "Fetch models from host" in Add Model section (uses selected host)
|
fetchBtn.addEventListener('click', () => {
|
||||||
const globalFetchBtn = document.getElementById('fetch-btn');
|
|
||||||
if (globalFetchBtn) {
|
|
||||||
globalFetchBtn.addEventListener('click', () => {
|
|
||||||
const hostSel = document.getElementById('add-host-select');
|
|
||||||
const hostId = hostSel ? hostSel.value : '';
|
const hostId = hostSel ? hostSel.value : '';
|
||||||
fetchModels(hostId, globalFetchBtn, true);
|
fetchModels(hostId, fetchBtn, true);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchModels(hostId, btn, fillAddForm = false) {
|
async function fetchModels(hostId, btn, fillPicker = false) {
|
||||||
const statusEl = fillAddForm
|
const statusEl = fillPicker ? document.getElementById('fetch-status')
|
||||||
? document.getElementById('fetch-status')
|
|
||||||
: document.getElementById('fetch-' + hostId);
|
: document.getElementById('fetch-' + hostId);
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
if (statusEl) { statusEl.textContent = 'Fetching…'; statusEl.className = 'fetch-status'; }
|
if (statusEl) { statusEl.textContent = 'Fetching…'; statusEl.className = 'fetch-status'; }
|
||||||
|
|
||||||
@@ -425,27 +582,24 @@
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
if (statusEl) { statusEl.textContent = '✗ ' + data.error; statusEl.className = 'fetch-status err'; }
|
if (statusEl) { statusEl.textContent = '✗ ' + data.error; statusEl.className = 'fetch-status err'; }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (fillPicker) {
|
||||||
if (fillAddForm) {
|
|
||||||
const picker = document.getElementById('model-picker');
|
const picker = document.getElementById('model-picker');
|
||||||
const wrap = document.getElementById('model-select-wrap');
|
const wrap = document.getElementById('model-select-wrap');
|
||||||
picker.innerHTML = '<option value="">— select to auto-fill —</option>';
|
picker.innerHTML = '<option value="">— select to auto-fill —</option>';
|
||||||
for (const m of data.models) {
|
data.models.forEach(m => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = m.id;
|
opt.value = m.id;
|
||||||
opt.textContent = m.name !== m.id ? `${m.name} (${m.id})` : m.id;
|
opt.textContent = m.name !== m.id ? `${m.name} (${m.id})` : m.id;
|
||||||
opt.dataset.id = m.id;
|
opt.dataset.id = m.id;
|
||||||
opt.dataset.name = m.name;
|
opt.dataset.name = m.name;
|
||||||
picker.appendChild(opt);
|
picker.appendChild(opt);
|
||||||
}
|
});
|
||||||
wrap.style.display = 'block';
|
wrap.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statusEl) {
|
if (statusEl) {
|
||||||
statusEl.textContent = `✓ ${data.models.length} model${data.models.length !== 1 ? 's' : ''}`;
|
statusEl.textContent = `✓ ${data.models.length} model${data.models.length !== 1 ? 's' : ''}`;
|
||||||
statusEl.className = 'fetch-status ok';
|
statusEl.className = 'fetch-status ok';
|
||||||
@@ -457,27 +611,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-fill label + model name when a model is selected from the picker
|
|
||||||
const picker = document.getElementById('model-picker');
|
const picker = document.getElementById('model-picker');
|
||||||
if (picker) {
|
|
||||||
picker.addEventListener('change', () => {
|
picker.addEventListener('change', () => {
|
||||||
const opt = picker.options[picker.selectedIndex];
|
const opt = picker.options[picker.selectedIndex];
|
||||||
if (!opt.value) return;
|
if (!opt.value) return;
|
||||||
const nameInput = document.getElementById('add-model-name');
|
document.getElementById('add-model-name').value = opt.dataset.id || opt.value;
|
||||||
const labelInput = document.getElementById('add-label');
|
if (!document.getElementById('add-label').value) {
|
||||||
nameInput.value = opt.dataset.id || opt.value;
|
const n = opt.dataset.name;
|
||||||
labelInput.value = (opt.dataset.name && opt.dataset.name !== opt.dataset.id)
|
document.getElementById('add-label').value = (n && n !== opt.dataset.id) ? n : '';
|
||||||
? opt.dataset.name : '';
|
}
|
||||||
nameInput.focus();
|
document.getElementById('add-model-name').focus();
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Sync hidden host_id input from the visible select
|
// Hide fetch button initially if no hosts
|
||||||
const addHostSel = document.getElementById('add-host-select');
|
if (!HAS_HOSTS) fetchBtn.style.display = 'none';
|
||||||
const addHostId = document.getElementById('add-host-id');
|
|
||||||
if (addHostSel && addHostId) {
|
|
||||||
addHostId.value = addHostSel.value;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -302,7 +302,7 @@
|
|||||||
<p style="font-size:0.8rem; color:#94a3b8; margin-bottom:0.85rem; line-height:1.55;">
|
<p style="font-size:0.8rem; color:#94a3b8; margin-bottom:0.85rem; line-height:1.55;">
|
||||||
Configure OpenAI-compatible hosts and models (Open WebUI, Ollama, LM Studio, etc.).
|
Configure OpenAI-compatible hosts and models (Open WebUI, Ollama, LM Studio, etc.).
|
||||||
</p>
|
</p>
|
||||||
<a href="/settings/local"
|
<a href="/settings/models"
|
||||||
style="display:inline-block; padding:0.55rem 1rem; background:#7c3aed; border-radius:6px;
|
style="display:inline-block; padding:0.55rem 1rem; background:#7c3aed; border-radius:6px;
|
||||||
color:#fff; font-size:0.88rem; font-weight:600; text-decoration:none;
|
color:#fff; font-size:0.88rem; font-weight:600; text-decoration:none;
|
||||||
transition:background 0.15s;">
|
transition:background 0.15s;">
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ def test_empty_registry_no_files(tmp_path):
|
|||||||
import model_registry as reg
|
import model_registry as reg
|
||||||
with patch.object(config.settings, "home_dir", home):
|
with patch.object(config.settings, "home_dir", home):
|
||||||
data = reg._load("scott")
|
data = reg._load("scott")
|
||||||
assert data["version"] == 1
|
assert data["version"] == 2
|
||||||
assert data["hosts"] == []
|
assert data["hosts"] == []
|
||||||
assert data["models"] == []
|
assert data["models"] == []
|
||||||
assert data["roles"] == {}
|
assert data["roles"] == {}
|
||||||
@@ -244,7 +244,7 @@ def test_migration_saves_registry_file(tmp_path):
|
|||||||
data2 = reg._load("scott")
|
data2 = reg._load("scott")
|
||||||
|
|
||||||
assert (home / "scott" / "model_registry.json").exists()
|
assert (home / "scott" / "model_registry.json").exists()
|
||||||
assert data2["version"] == 1
|
assert data2["version"] == 2
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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`.
|
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
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.
|
**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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user