Model Registry: - Add per-user custom roles (add/remove via UI); required roles chat/orchestrator/distill are always present and cannot be removed - Auto-migrate legacy .env-defined roles to custom_roles on first access - Role config panel (gear): Remove role button moved inside panel; required badge below name - Role select: Primary + Backup slots only (was three) Settings pages — Tailwind CSS migration (CDN, preflight: false): - local_llm.html, settings.html, help.html, notifications.html, tools_settings.html, crons.html, integrations.html all migrated; pg-* color tokens; dark/light via data-theme pg.css fixes: - input[type=checkbox/radio]: width: auto — prevents pg.css width:100% from stretching checkboxes - btn-submit: responsive sizing via Tailwind w-full md:w-96 on each button (no longer full-width on desktop) Documentation: - MASTER.md, TODO__Agents.md: remove "/ Inara" from titles; description updated to "per-user AI personas" - HELP.md: persona-agnostic language throughout (NC Talk, Google Chat, push, schedules, HA sections); roles section restructured to show required vs. custom roles with examples - notifications.html: subtitle and HA description use "your persona" not "Inara" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
850 lines
40 KiB
Python
850 lines
40 KiB
Python
"""
|
|
Model Registry settings — providers, hosts, models, and role assignments.
|
|
|
|
Routes:
|
|
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/anthropic-key → save/create an Anthropic API key
|
|
POST /settings/local/anthropic-key/{id}/remove → remove an Anthropic API key
|
|
POST /settings/local/models/add → add a model (any provider)
|
|
POST /settings/local/models/{id}/edit → edit an existing model entry
|
|
POST /settings/local/models/{id}/remove → remove a model
|
|
POST /settings/local/roles/add → add a custom role (redirects to #roles)
|
|
POST /settings/local/roles/remove → remove a custom role (redirects to #roles)
|
|
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
|
|
|
|
import httpx
|
|
import jwt
|
|
from fastapi import APIRouter, Form, Request
|
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
|
|
from auth_utils import COOKIE_NAME, decode_token, _read_auth
|
|
from config import settings as app_settings
|
|
from persona import list_user_personas
|
|
import model_registry as reg
|
|
from tools import TOOL_CATEGORIES
|
|
|
|
_LAST_PERSONA_COOKIE = "cx_last_persona"
|
|
|
|
|
|
def _preferred_persona(request: Request, username: str) -> str:
|
|
names = list_user_personas(username)
|
|
if not names:
|
|
return ""
|
|
cookie_val = request.cookies.get(_LAST_PERSONA_COOKIE, "")
|
|
if cookie_val in names:
|
|
return cookie_val
|
|
return names[0]
|
|
|
|
|
|
def _integrations_nav(username: str) -> str:
|
|
role = _read_auth(username).get("role", "user")
|
|
if role == "admin":
|
|
return '<a href="/settings/integrations" class="nav-link">Integrations</a>'
|
|
return ""
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
_STATIC = Path(__file__).parent.parent / "static"
|
|
|
|
|
|
def _host_row_html(h: dict) -> str:
|
|
"""Return the HTML for one host config row (edit form + remove link)."""
|
|
api_key = h.get("api_key", "")
|
|
key_hint = f"…{api_key[-4:]}" if api_key else "not set"
|
|
ht = h.get("host_type", "openwebui")
|
|
ow = ' selected' if ht == "openwebui" else ''
|
|
ai = ' selected' if ht == "openai" else ''
|
|
hid = h["id"]
|
|
hlbl = h.get("label", "")
|
|
hurl = h.get("api_url", "")
|
|
maxc = h.get("max_concurrent", 3)
|
|
return f'''
|
|
<div class="host-row">
|
|
<form method="POST" action="/settings/local/host" class="host-form">
|
|
<input type="hidden" name="host_id" value="{hid}">
|
|
<div class="field-row">
|
|
<div class="field">
|
|
<label>Label</label>
|
|
<input type="text" name="label" value="{hlbl}"
|
|
placeholder="Gaming Laptop" autocomplete="off" data-form-type="other">
|
|
</div>
|
|
<div class="field" style="flex:2">
|
|
<label>API URL</label>
|
|
<input type="text" name="api_url" value="{hurl}"
|
|
placeholder="http://192.168.x.x:3000"
|
|
autocomplete="off" spellcheck="false" data-form-type="other">
|
|
</div>
|
|
</div>
|
|
<div class="field-row">
|
|
<div class="field">
|
|
<label>API Key</label>
|
|
<input type="password" name="api_key" placeholder="Leave blank to keep existing"
|
|
autocomplete="new-password" data-1p-ignore data-lpignore="true"
|
|
data-form-type="other">
|
|
<p class="key-status">Current: {key_hint}</p>
|
|
</div>
|
|
<div class="field" style="flex:0 0 auto">
|
|
<label>Type</label>
|
|
<select name="host_type">
|
|
<option value="openwebui"{ow}>Open WebUI / Ollama</option>
|
|
<option value="openai"{ai}>OpenAI-compatible API</option>
|
|
</select>
|
|
</div>
|
|
<div class="field" style="flex:0 0 auto; width:6rem">
|
|
<label>Max parallel</label>
|
|
<input type="number" name="max_concurrent" min="1" max="20"
|
|
value="{maxc}" style="width:100%">
|
|
</div>
|
|
</div>
|
|
<div class="btn-row">
|
|
<button type="submit" class="btn btn-secondary btn-sm">Save</button>
|
|
<button type="button" class="btn btn-secondary btn-sm fetch-btn"
|
|
data-host-id="{hid}">Fetch models</button>
|
|
<span class="fetch-status" id="fetch-{hid}"></span>
|
|
</div>
|
|
</form>
|
|
<form method="POST" action="/settings/local/host/{hid}/remove"
|
|
onsubmit="return confirm('Remove host and all its models?')"
|
|
style="margin-top:0.5rem">
|
|
<button type="submit" class="btn-link danger">Remove host</button>
|
|
</form>
|
|
</div>'''
|
|
|
|
|
|
# ── Auth helper ───────────────────────────────────────────────────────────────
|
|
|
|
def _get_user(request: Request) -> str | None:
|
|
token = request.cookies.get(COOKIE_NAME)
|
|
if not token:
|
|
return None
|
|
try:
|
|
return decode_token(token)
|
|
except jwt.InvalidTokenError:
|
|
return None
|
|
|
|
|
|
# ── Page renderer ─────────────────────────────────────────────────────────────
|
|
|
|
def _render(username: str, request: Request | None = None, 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()
|
|
host_by_id = {h["id"]: h for h in hosts}
|
|
goog_accts = registry.get("providers", {}).get("google", {}).get("accounts", [])
|
|
|
|
# ── Google account rows ───────────────────────────────────────────────────
|
|
google_account_rows = ""
|
|
for a in goog_accts:
|
|
hint = (a.get("api_key") or "")[:10] + "…" if a.get("api_key") else "no key"
|
|
google_account_rows += f'''
|
|
<div class="account-row">
|
|
<div>
|
|
<span class="account-label">{a.get("label") or "Unnamed"}</span>
|
|
<span class="account-hint">{hint}</span>
|
|
</div>
|
|
<form method="POST" action="/settings/local/google-account/{a["id"]}/remove"
|
|
onsubmit="return confirm('Remove this Google account?')">
|
|
<button type="submit" class="btn-link danger">Remove</button>
|
|
</form>
|
|
</div>'''
|
|
if not google_account_rows:
|
|
google_account_rows = '<p class="empty-note">No accounts configured yet.</p>'
|
|
|
|
# ── Host rows — split cloud (openai) vs local (openwebui) ─────────────────
|
|
cloud_hosts = [h for h in hosts if h.get("host_type") == "openai"]
|
|
local_hosts = [h for h in hosts if h.get("host_type", "openwebui") != "openai"]
|
|
|
|
cloud_host_rows = "".join(_host_row_html(h) for h in cloud_hosts)
|
|
local_host_rows = "".join(_host_row_html(h) for h in local_hosts)
|
|
if not cloud_host_rows:
|
|
cloud_host_rows = '<p class="empty-note">No cloud API services configured yet. Add one below.</p>'
|
|
if not local_host_rows:
|
|
local_host_rows = '<p class="empty-note">No local hosts configured yet. Add one below.</p>'
|
|
|
|
host_options = "".join(
|
|
f'<option value="{h["id"]}">{h.get("label") or h["api_url"]}</option>'
|
|
for h in hosts
|
|
)
|
|
|
|
# ── Anthropic API key rows ────────────────────────────────────────────────
|
|
anthropic_api_keys = reg.get_anthropic_api_keys(username)
|
|
anthropic_keys_js = _json.dumps(anthropic_api_keys)
|
|
anthropic_key_rows = ""
|
|
for c in anthropic_api_keys:
|
|
hint = c.get("hint", "no key")
|
|
anthropic_key_rows += f'''
|
|
<div class="account-row">
|
|
<div>
|
|
<span class="account-label">{c.get("label") or "API Key"}</span>
|
|
<span class="account-hint">{hint}</span>
|
|
</div>
|
|
<form method="POST" action="/settings/local/anthropic-key/{c["id"]}/remove"
|
|
onsubmit="return confirm('Remove this Anthropic API key?')">
|
|
<button type="submit" class="btn-link danger">Remove</button>
|
|
</form>
|
|
</div>'''
|
|
if not anthropic_key_rows:
|
|
anthropic_key_rows = '<p class="empty-note">No API keys configured. Add one below or use Claude CLI (OAuth).</p>'
|
|
|
|
# ── Model rows (all providers) ────────────────────────────────────────────
|
|
_PROVIDER_BADGE = {
|
|
"claude_cli": ('<span class="pbadge pb-anthropic">Anthropic</span>', "Claude CLI"),
|
|
"anthropic_api": ('<span class="pbadge pb-anthropic">Anthropic</span>', "API Key"),
|
|
"gemini_api": ('<span class="pbadge pb-google">Google</span>', ""),
|
|
"local_openai": ('<span class="pbadge pb-local">Local</span>', ""),
|
|
}
|
|
model_rows = ""
|
|
for m in models:
|
|
resolved = reg._resolve_model(registry, m["id"])
|
|
if not resolved:
|
|
continue
|
|
mtype = m.get("type", "local_openai")
|
|
badge, default_secondary = _PROVIDER_BADGE.get(mtype, ("", ""))
|
|
|
|
if mtype == "local_openai":
|
|
h = host_by_id.get(m.get("host_id", ""), {})
|
|
secondary = h.get("label") or h.get("api_url", "")
|
|
elif mtype == "gemini_api":
|
|
acct = next((a for a in goog_accts if a["id"] == m.get("account_id")), None)
|
|
secondary = acct["label"] if acct else ""
|
|
else:
|
|
secondary = default_secondary
|
|
|
|
ctx = f'<span class="ctx-badge">{m.get("context_k",0)}k</span>' if m.get("context_k") else ""
|
|
no_tools = '' if m.get("tools", True) else '<span class="pbadge pb-notools">no tools</span>'
|
|
tags_html = " ".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 ""
|
|
|
|
# ── Inline edit form fields (type-specific) ───────────────────────────
|
|
if mtype == "local_openai":
|
|
host_opts = "".join(
|
|
f'<option value="{h["id"]}"'
|
|
f'{" selected" if h["id"] == m.get("host_id") else ""}>'
|
|
f'{h.get("label") or h.get("api_url","")}</option>'
|
|
for h in hosts
|
|
)
|
|
mid = m["id"]
|
|
extra_fields = (
|
|
f'<div class="field"><label>Host</label>'
|
|
f'<select name="host_id" id="edit-host-{mid}">{host_opts}</select></div>'
|
|
f'<div class="btn-row" style="margin-bottom:0.75rem">'
|
|
f'<button type="button" class="btn btn-secondary btn-sm edit-fetch-btn" data-id="{mid}">Fetch models</button>'
|
|
f'<span class="fetch-status" id="edit-fetch-status-{mid}"></span>'
|
|
f'</div>'
|
|
f'<div id="edit-model-select-wrap-{mid}" style="display:none; margin-bottom:0.75rem">'
|
|
f'<label>Pick from host</label>'
|
|
f'<select id="edit-model-picker-{mid}"><option value="">— fetch first —</option></select>'
|
|
f'</div>'
|
|
)
|
|
elif mtype == "gemini_api":
|
|
acct_opts = "".join(
|
|
f'<option value="{a["id"]}"'
|
|
f'{" selected" if a["id"] == m.get("account_id") else ""}>'
|
|
f'{a.get("label","Unnamed")}</option>'
|
|
for a in goog_accts
|
|
)
|
|
extra_fields = (
|
|
f'<div class="field"><label>Google Account</label>'
|
|
f'<select name="account_id">{acct_opts}</select></div>'
|
|
)
|
|
elif mtype == "anthropic_api":
|
|
key_opts = "".join(
|
|
f'<option value="{c["id"]}"'
|
|
f'{" selected" if c["id"] == m.get("credential_id") else ""}>'
|
|
f'{c.get("label","API Key")} ({c.get("hint","")})</option>'
|
|
for c in anthropic_api_keys
|
|
)
|
|
extra_fields = (
|
|
f'<div class="field"><label>API Key</label>'
|
|
f'<select name="credential_id">{key_opts or "<option value=\"\">No API keys configured</option>"}</select></div>'
|
|
)
|
|
else:
|
|
extra_fields = '<input type="hidden" name="credential_id" value="cli">'
|
|
|
|
cur_label = m.get("label", "")
|
|
cur_model_name = m.get("model_name", "")
|
|
cur_ctx = m.get("context_k", 0) or 0
|
|
cur_max_rounds = m.get("max_rounds") or 0
|
|
cur_tools = m.get("tools", True)
|
|
cur_tags = ", ".join(m.get("tags") or [])
|
|
cur_reasoning_budget = m.get("reasoning_budget_tokens") or 0
|
|
_rb_levels = [(0, "Off — Non-think"), (1024, "Light"), (4096, "Moderate"), (8192, "High"), (32768, "Max")]
|
|
reasoning_opts = "".join(
|
|
f'<option value="{v}" {"selected" if cur_reasoning_budget == v else ""}>{lbl}</option>'
|
|
for v, lbl in _rb_levels
|
|
)
|
|
|
|
model_rows += f'''
|
|
<div class="model-row" id="model-{m["id"]}">
|
|
<div class="model-row-header">
|
|
<div class="model-info">
|
|
<div>{badge}<span class="model-label">{m.get("label") or m.get("model_name","")}</span>{ctx}{no_tools}</div>
|
|
<span class="model-name">{m.get("model_name","")}</span>
|
|
{sec}
|
|
<div class="tag-row">{tags_html}</div>
|
|
</div>
|
|
<div class="model-btns">
|
|
<button type="button" class="row-btn model-edit-btn" data-id="{m["id"]}">Edit</button>
|
|
<form method="POST" action="/settings/local/models/{m["id"]}/remove"
|
|
onsubmit="return confirm('Remove this model?')" style="margin:0">
|
|
<button type="submit" class="row-btn danger">Remove</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
<form class="model-edit-form" id="edit-form-{m["id"]}" style="display:none"
|
|
method="POST" action="/settings/local/models/{m["id"]}/edit">
|
|
<input type="hidden" name="mtype" value="{mtype}">
|
|
<div class="field-row">
|
|
<div class="field">
|
|
<label>Display label</label>
|
|
<input type="text" name="label" value="{cur_label}"
|
|
placeholder="My Model" autocomplete="off" data-form-type="other">
|
|
</div>
|
|
<div class="field">
|
|
<label>Model name / ID</label>
|
|
<input type="text" name="model_name" value="{cur_model_name}"
|
|
placeholder="provider/model-name" autocomplete="off"
|
|
spellcheck="false" data-form-type="other" required>
|
|
</div>
|
|
</div>
|
|
{extra_fields}
|
|
<div class="field-row">
|
|
<div class="field" style="flex:0 0 auto">
|
|
<label title="Context window size in thousands of tokens. 0 = assume 32k.">Context (k)</label>
|
|
<input type="number" name="context_k" value="{cur_ctx}" min="0"
|
|
title="Context window size in thousands of tokens. 0 = assume 32k (compaction budget ~24k tokens).">
|
|
</div>
|
|
<div class="field" style="flex:0 0 auto">
|
|
<label title="Per-model tool loop cap. 0 = use the global default (orchestrator_max_rounds).">Max rounds</label>
|
|
<input type="number" name="max_rounds" value="{cur_max_rounds}" min="0"
|
|
title="Per-model tool loop cap. 0 = use the global default (orchestrator_max_rounds).">
|
|
</div>
|
|
<div class="field" style="flex:0 0 auto">
|
|
<label title="Reasoning depth via OpenRouter's reasoning.budget_tokens. Off = Non-think. Light ~1k, Moderate ~4k, High ~8k, Max ~32k tokens.">Reasoning</label>
|
|
<select name="reasoning_budget_tokens"
|
|
title="Reasoning depth via OpenRouter's reasoning.budget_tokens. Off = Non-think. Light ~1k, Moderate ~4k, High ~8k, Max ~32k tokens.">
|
|
{reasoning_opts}
|
|
</select>
|
|
</div>
|
|
<div class="field" style="flex:0 0 auto">
|
|
<label title="Whether this model supports tool calling. If not supported, requests skip the tool loop entirely.">Tool calling</label>
|
|
<select name="tools"
|
|
title="Whether this model supports tool calling. If not supported, requests skip the tool loop entirely.">
|
|
<option value="1" {'selected' if cur_tools else ''}>Supported</option>
|
|
<option value="0" {'' if cur_tools else 'selected'}>Not supported</option>
|
|
</select>
|
|
</div>
|
|
<div class="field">
|
|
<label>Tags</label>
|
|
<input type="text" name="tags" value="{cur_tags}"
|
|
placeholder="fast, code, vision" autocomplete="off" data-form-type="other">
|
|
</div>
|
|
</div>
|
|
<div class="btn-row" style="margin-top:0.5rem">
|
|
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
|
<button type="button" class="model-edit-cancel btn btn-secondary btn-sm"
|
|
data-id="{m["id"]}">Cancel</button>
|
|
</div>
|
|
</form>
|
|
</div>'''
|
|
if not model_rows:
|
|
model_rows = '<p class="empty-note">No models added yet.</p>'
|
|
|
|
# ── Role assignment rows ──────────────────────────────────────────────────
|
|
model_opts = '<option value="">— .env default —</option>\n'
|
|
model_opts += '<optgroup label="Built-in">\n'
|
|
for bid, bm in builtins.items():
|
|
model_opts += f' <option value="{bid}">{bm["label"]}</option>\n'
|
|
model_opts += '</optgroup>\n'
|
|
if models:
|
|
model_opts += '<optgroup label="Configured models">\n'
|
|
for m in models:
|
|
lbl = m.get("label") or m.get("model_name", m["id"])
|
|
model_opts += f' <option value="{m["id"]}">{lbl}</option>\n'
|
|
model_opts += '</optgroup>\n'
|
|
|
|
all_roles = reg.get_all_roles(username)
|
|
|
|
role_rows = ""
|
|
for role in all_roles:
|
|
is_required = role in reg.REQUIRED_ROLES
|
|
role_cfg = roles.get(role, {})
|
|
role_title = role.replace("_", " ").title()
|
|
required_badge = (
|
|
'<span class="required-badge">required</span>'
|
|
if is_required else ''
|
|
)
|
|
rcp_danger = (
|
|
'' if is_required else
|
|
f'<div class="rcp-danger">'
|
|
f'<form method="POST" action="/settings/local/roles/remove" class="remove-role-form">'
|
|
f'<input type="hidden" name="role_name" value="{role}">'
|
|
f'<button type="submit" class="btn-link danger" data-role="{role}">Remove this role…</button>'
|
|
f'</form>'
|
|
f'</div>'
|
|
)
|
|
role_rows += (
|
|
f'<div class="role-row" data-role="{role}">'
|
|
f'<div class="role-name-col">'
|
|
f'<span class="role-name">{role_title}</span>'
|
|
f'{required_badge}'
|
|
f'</div>'
|
|
f'<div class="role-slots">'
|
|
)
|
|
for slot in reg.PRIORITY_KEYS[:2]:
|
|
slot_label = slot.replace("_", " ").title()
|
|
sel = (
|
|
f'<select class="role-select" data-role="{role}" '
|
|
f'data-slot="{slot}" title="{slot_label}">\n{model_opts}\n</select>'
|
|
)
|
|
role_rows += f'<div class="role-slot"><span class="slot-label">{slot_label}</span>{sel}</div>'
|
|
role_rows += (
|
|
f'</div>'
|
|
f'<button class="role-cfg-btn" data-role="{role}" title="Configure">⚙</button>'
|
|
f'</div>'
|
|
f'<div class="role-config-panel" id="rcp-{role}">'
|
|
f'<div class="rcp-field">'
|
|
f'<label class="rcp-label">System prompt addition</label>'
|
|
f'<textarea class="rcp-textarea" data-role="{role}" rows="3" '
|
|
f'placeholder="Extra instructions injected into the system prompt when this role is active…"></textarea>'
|
|
f'</div>'
|
|
f'<div class="rcp-field">'
|
|
f'<div style="display:flex;flex-direction:column;gap:0.3rem">'
|
|
f'<label class="rcp-check">'
|
|
f'<input type="checkbox" class="rcp-datetime-cb" data-role="{role}" checked>'
|
|
f'<span>Inject current date & time into system prompt</span>'
|
|
f'</label>'
|
|
f'<label class="rcp-check">'
|
|
f'<input type="checkbox" class="rcp-mode-cb" data-role="{role}" checked>'
|
|
f'<span>Inject session mode (Chat / Off The Record) into system prompt</span>'
|
|
f'</label>'
|
|
f'</div>'
|
|
f'<p class="rcp-hint" style="margin-top:0.4rem">Disable both for pure processing roles (summarizer, classifier, translator).</p>'
|
|
f'</div>'
|
|
f'<div class="rcp-field">'
|
|
f'<label class="rcp-label">Tool allow-list '
|
|
f'<span class="rcp-hint">— all checked means no restriction (use all accessible tools)</span></label>'
|
|
f'<div class="rcp-tools" id="rcp-tools-{role}"></div>'
|
|
f'</div>'
|
|
f'<div class="rcp-actions">'
|
|
f'<button class="btn btn-primary btn-sm rcp-save" data-role="{role}">Save</button>'
|
|
f'<button class="btn btn-secondary btn-sm rcp-cancel" data-role="{role}">Cancel</button>'
|
|
f'</div>'
|
|
f'{rcp_danger}'
|
|
f'</div>'
|
|
)
|
|
|
|
role_data_js = _json.dumps({
|
|
role: {slot: (roles.get(role, {}).get(slot) or "") for slot in reg.PRIORITY_KEYS[:2]}
|
|
for role in all_roles
|
|
})
|
|
|
|
role_config_data_js = _json.dumps({
|
|
role: {
|
|
"system_append": roles.get(role, {}).get("system_append", ""),
|
|
"tools": roles.get(role, {}).get("tools") or None,
|
|
"inject_datetime": roles.get(role, {}).get("inject_datetime", True),
|
|
"inject_mode": roles.get(role, {}).get("inject_mode", True),
|
|
}
|
|
for role in all_roles
|
|
})
|
|
tool_categories_js = _json.dumps(TOOL_CATEGORIES)
|
|
|
|
# ── 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"))
|
|
cloud_catalog_js = _json.dumps(reg.get_catalog("cloud"))
|
|
has_hosts = "true" if hosts else "false"
|
|
|
|
html = (_STATIC / "local_llm.html").read_text()
|
|
replacements = {
|
|
"{{ username }}": username,
|
|
"{{ google_account_rows }}": google_account_rows,
|
|
"{{ anthropic_key_rows }}": anthropic_key_rows,
|
|
"{{ cloud_host_rows }}": cloud_host_rows,
|
|
"{{ local_host_rows }}": local_host_rows,
|
|
"{{ model_rows }}": model_rows,
|
|
"{{ host_options }}": host_options,
|
|
"{{ role_rows }}": role_rows,
|
|
"{{ role_data_js }}": role_data_js,
|
|
"{{ role_config_data_js }}": role_config_data_js,
|
|
"{{ tool_categories_js }}": tool_categories_js,
|
|
"{{ google_accounts_js }}": google_accounts_js,
|
|
"{{ anthropic_keys_js }}": anthropic_keys_js,
|
|
"{{ google_catalog_js }}": google_catalog_js,
|
|
"{{ anthropic_catalog_js }}": anthropic_catalog_js,
|
|
"{{ cloud_catalog_js }}": cloud_catalog_js,
|
|
"{{ has_hosts }}": has_hosts,
|
|
}
|
|
for key, val in replacements.items():
|
|
html = html.replace(key, val)
|
|
|
|
back_persona = _preferred_persona(request, username) if request else ""
|
|
html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/")
|
|
html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help")
|
|
html = html.replace("{{ integrations_nav }}", _integrations_nav(username))
|
|
|
|
if success:
|
|
html = html.replace("<!-- SUCCESS -->", f'<p class="msg success">{success}</p>')
|
|
if error:
|
|
html = html.replace("<!-- ERROR -->", f'<p class="msg error">{error}</p>')
|
|
return html
|
|
|
|
|
|
# ── Routes ────────────────────────────────────────────────────────────────────
|
|
|
|
@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, request))
|
|
|
|
|
|
@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, request, error="API key is required."))
|
|
reg.save_google_account(username, account_id or None, label, api_key)
|
|
return HTMLResponse(_render(username, request, 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, request, success="Google account removed."))
|
|
|
|
|
|
@router.post("/settings/local/anthropic-key", include_in_schema=False)
|
|
async def save_anthropic_api_key(
|
|
request: Request,
|
|
key_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 key_id.strip():
|
|
return HTMLResponse(_render(username, request, error="API key is required."))
|
|
reg.save_anthropic_api_key(username, key_id or None, label, api_key)
|
|
return HTMLResponse(_render(username, request, success="Anthropic API key saved."))
|
|
|
|
|
|
@router.post("/settings/local/anthropic-key/{key_id}/remove", include_in_schema=False)
|
|
async def remove_anthropic_api_key(request: Request, key_id: str):
|
|
username = _get_user(request)
|
|
if not username:
|
|
return RedirectResponse("/login", status_code=302)
|
|
reg.remove_anthropic_api_key(username, key_id)
|
|
return HTMLResponse(_render(username, request, success="Anthropic API key removed."))
|
|
|
|
|
|
@router.post("/settings/local/host", include_in_schema=False)
|
|
async def save_host(
|
|
request: Request,
|
|
host_id: str = Form(""),
|
|
label: str = Form(""),
|
|
api_url: str = Form(""),
|
|
api_key: str = Form(""),
|
|
host_type: str = Form("openwebui"),
|
|
max_concurrent: int = Form(3),
|
|
):
|
|
username = _get_user(request)
|
|
if not username:
|
|
return RedirectResponse("/login", status_code=302)
|
|
if not api_url.strip():
|
|
return HTMLResponse(_render(username, request, error="API URL is required."))
|
|
reg.save_host(username, host_id or None, label, api_url, api_key, host_type, max_concurrent)
|
|
return HTMLResponse(_render(username, request, success="Host saved."))
|
|
|
|
|
|
@router.post("/settings/local/host/{host_id}/remove", include_in_schema=False)
|
|
async def remove_host(request: Request, host_id: str):
|
|
username = _get_user(request)
|
|
if not username:
|
|
return RedirectResponse("/login", status_code=302)
|
|
reg.remove_host(username, host_id)
|
|
return HTMLResponse(_render(username, request, success="Host removed."))
|
|
|
|
|
|
@router.post("/settings/local/models/add", include_in_schema=False)
|
|
async def add_model(
|
|
request: Request,
|
|
provider: str = Form("local"),
|
|
label: str = Form(""),
|
|
context_k: int = Form(0),
|
|
max_rounds: int = Form(0),
|
|
tools: int = Form(1),
|
|
tags: str = Form(""),
|
|
reasoning_budget_tokens: int = Form(0),
|
|
# 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)
|
|
|
|
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
|
max_rounds_ = max_rounds or None
|
|
tools_bool = tools != 0
|
|
reasoning_budget_ = reasoning_budget_tokens or None
|
|
|
|
if provider == "local":
|
|
if not model_name.strip():
|
|
return HTMLResponse(_render(username, request, error="Model name is required."))
|
|
if not host_id.strip():
|
|
return HTMLResponse(_render(username, request, error="Select a host."))
|
|
reg.save_model(username, None, host_id, label, model_name, context_k, tag_list,
|
|
max_rounds=max_rounds_, tools=tools_bool,
|
|
reasoning_budget_tokens=reasoning_budget_)
|
|
display = label or model_name
|
|
|
|
elif provider in ("google", "anthropic"):
|
|
if not cloud_model_name.strip():
|
|
return HTMLResponse(_render(username, request, error="Select a model from the catalog."))
|
|
if provider == "google" and not account_id.strip():
|
|
return HTMLResponse(_render(username, request, 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,
|
|
max_rounds=max_rounds_, tools=tools_bool,
|
|
)
|
|
display = label or cloud_model_name
|
|
else:
|
|
return HTMLResponse(_render(username, request, error=f"Unknown provider: {provider}"))
|
|
|
|
logger.info("model added: %s / %s (%s)", username, display, provider)
|
|
return HTMLResponse(_render(username, request, success=f'Model "{display}" added.'))
|
|
|
|
|
|
@router.post("/settings/local/models/{model_id}/edit", include_in_schema=False)
|
|
async def edit_model(
|
|
request: Request,
|
|
model_id: str,
|
|
mtype: str = Form(""),
|
|
label: str = Form(""),
|
|
model_name: str = Form(""),
|
|
context_k: int = Form(0),
|
|
max_rounds: int = Form(0),
|
|
tools: int = Form(1),
|
|
tags: str = Form(""),
|
|
reasoning_budget_tokens: int = Form(0),
|
|
host_id: 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, request, error="Model name is required."))
|
|
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
|
max_rounds_ = max_rounds or None
|
|
tools_bool = tools != 0
|
|
reasoning_budget_ = reasoning_budget_tokens or None
|
|
if mtype == "local_openai":
|
|
if not host_id.strip():
|
|
return HTMLResponse(_render(username, request, error="Select a host for this model."))
|
|
reg.save_model(username, model_id, host_id, label, model_name, context_k, tag_list,
|
|
max_rounds=max_rounds_, tools=tools_bool,
|
|
reasoning_budget_tokens=reasoning_budget_)
|
|
elif mtype == "gemini_api":
|
|
reg.save_cloud_model(username, model_id, "google", model_name, label,
|
|
account_id=account_id or None, context_k=context_k, tags=tag_list,
|
|
max_rounds=max_rounds_, tools=tools_bool)
|
|
elif mtype in ("claude_cli", "anthropic_api"):
|
|
reg.save_cloud_model(username, model_id, "anthropic", model_name, label,
|
|
credential_id=credential_id or "cli", context_k=context_k, tags=tag_list,
|
|
max_rounds=max_rounds_, tools=tools_bool)
|
|
else:
|
|
return HTMLResponse(_render(username, request, error=f"Unknown model type: {mtype}"))
|
|
display = label.strip() or model_name.strip()
|
|
logger.info("model edited: %s / %s (%s)", username, display, mtype)
|
|
return HTMLResponse(_render(username, request, success=f'Model "{display}" updated.'))
|
|
|
|
|
|
@router.post("/settings/local/models/{model_id}/remove", include_in_schema=False)
|
|
async def remove_model(request: Request, model_id: str):
|
|
username = _get_user(request)
|
|
if not username:
|
|
return RedirectResponse("/login", status_code=302)
|
|
reg.remove_model(username, model_id)
|
|
return HTMLResponse(_render(username, request, success="Model removed."))
|
|
|
|
|
|
@router.post("/settings/local/roles/add", include_in_schema=False)
|
|
async def add_custom_role_route(
|
|
request: Request,
|
|
role_name: str = Form(""),
|
|
):
|
|
username = _get_user(request)
|
|
if not username:
|
|
return RedirectResponse("/login", status_code=302)
|
|
name = role_name.strip().lower()
|
|
if not name or not name[0].isalpha():
|
|
return HTMLResponse(_render(username, request, error="Role name must start with a letter."))
|
|
ok = reg.add_custom_role(username, name)
|
|
if not ok:
|
|
return HTMLResponse(_render(username, request, error=f'"{name}" is a required role and cannot be re-added.'))
|
|
logger.info("custom role added: %s / %s", username, name)
|
|
return RedirectResponse("/settings/models#roles", status_code=303)
|
|
|
|
|
|
@router.post("/settings/local/roles/remove", include_in_schema=False)
|
|
async def remove_custom_role_route(
|
|
request: Request,
|
|
role_name: str = Form(""),
|
|
):
|
|
username = _get_user(request)
|
|
if not username:
|
|
return RedirectResponse("/login", status_code=302)
|
|
name = role_name.strip()
|
|
ok = reg.remove_custom_role(username, name)
|
|
if not ok:
|
|
return HTMLResponse(_render(username, request, error=f'"{name}" is a required role and cannot be removed.'))
|
|
logger.info("custom role removed: %s / %s", username, name)
|
|
return RedirectResponse("/settings/models#roles", status_code=303)
|
|
|
|
|
|
@router.post("/api/models/role")
|
|
async def set_role(request: Request) -> JSONResponse:
|
|
"""AJAX: assign a model to a role priority slot.
|
|
|
|
Body: {"role": "chat", "slot": "primary", "model_id": "abc123" | ""}
|
|
"""
|
|
username = _get_user(request)
|
|
if not username:
|
|
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
|
|
|
role = body.get("role", "").strip()
|
|
slot = body.get("slot", "").strip()
|
|
model_id = body.get("model_id", "").strip() or None
|
|
|
|
if not role or not slot:
|
|
return JSONResponse({"error": "role and slot are required"}, status_code=400)
|
|
|
|
ok = reg.set_role(username, role, slot, model_id)
|
|
if not ok:
|
|
return JSONResponse({"error": "Invalid slot or model_id not found"}, status_code=400)
|
|
|
|
logger.info("role set: %s %s.%s = %s", username, role, slot, model_id)
|
|
return JSONResponse({"ok": True})
|
|
|
|
|
|
@router.post("/api/models/role-config")
|
|
async def set_role_config(request: Request) -> JSONResponse:
|
|
"""AJAX: save system_append, tool allow-list, and inject_datetime flag for a role.
|
|
|
|
Body: {"role": "coder", "system_append": "...", "tools": [...] | null, "inject_datetime": true}
|
|
tools=null clears the allow-list (role uses all accessible tools).
|
|
inject_datetime=false suppresses the date/time header for pure processing roles.
|
|
"""
|
|
username = _get_user(request)
|
|
if not username:
|
|
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
|
|
|
role = body.get("role", "").strip()
|
|
system_append = body.get("system_append", "")
|
|
tools = body.get("tools") # list[str] or None
|
|
inject_datetime = body.get("inject_datetime", True)
|
|
inject_mode = body.get("inject_mode", True)
|
|
|
|
if not role:
|
|
return JSONResponse({"error": "role is required"}, status_code=400)
|
|
if tools is not None and not isinstance(tools, list):
|
|
return JSONResponse({"error": "tools must be a list or null"}, status_code=400)
|
|
|
|
reg.set_role_config(username, role, system_append, tools,
|
|
inject_datetime=bool(inject_datetime),
|
|
inject_mode=bool(inject_mode))
|
|
logger.info("role config saved: %s %s (tools=%s inject_datetime=%s inject_mode=%s)",
|
|
username, role, len(tools) if tools is not None else "all",
|
|
inject_datetime, inject_mode)
|
|
return JSONResponse({"ok": True})
|
|
|
|
|
|
@router.get("/api/local-llm/fetch-models")
|
|
async def fetch_models(request: Request, host_id: str = "") -> JSONResponse:
|
|
"""Proxy to the host's 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", [])
|
|
|
|
host = next((h for h in hosts if h["id"] == host_id), None) if host_id else (hosts[0] if hosts else None)
|
|
|
|
if host:
|
|
api_url, api_key, host_type = host.get("api_url",""), host.get("api_key",""), host.get("host_type","openwebui")
|
|
else:
|
|
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)
|
|
|
|
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 {}
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=8) as client:
|
|
resp = await client.get(url, headers=headers)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
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)
|
|
except Exception as e:
|
|
return JSONResponse({"error": str(e)}, status_code=502)
|