Files
Cortex-Inara/cortex/routers/local_llm.py
Scott Idem 6ad7597db8 feat: per-role inject_datetime toggle for system prompt
Each role can now disable the current date/time header injected into the
system prompt. Default is true (all existing roles unchanged). Useful for
pure processing roles (summarizer, classifier, translator) where temporal
context is irrelevant or could cause unexpected model behavior.

Changes:
- model_registry: set_role_config/get_role_config gain inject_datetime field
- context_loader: load_context gains inject_datetime param (default True)
- orchestrator router: passes inject_datetime from role_cfg to load_context
- local_llm router: reads inject_datetime from POST body, passes to registry;
  role_config_data_js includes the field
- local_llm.html: checkbox in role config panel; populate on open, save on submit

Session logs still timestamp every turn (HH:MM header in YYYY-MM-DD.md files)
regardless of this setting — the toggle only affects the system prompt header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:53:35 -04:00

654 lines
30 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/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 /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
from config import settings as app_settings
import model_registry as reg
from tools import TOOL_CATEGORIES
logger = logging.getLogger(__name__)
router = APIRouter()
_STATIC = Path(__file__).parent.parent / "static"
# ── 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, 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>'
# ── Local host rows ───────────────────────────────────────────────────────
host_rows = ""
for h in hosts:
key_hint = f"{h['api_key'][-4:]}" if h.get("api_key") else "not set"
ht = h.get("host_type", "openwebui")
ow = ' selected' if ht == "openwebui" else ''
ai = ' selected' if ht == "openai" else ''
host_rows += f'''
<div class="host-row">
<form method="POST" action="/settings/local/host" class="host-form">
<input type="hidden" name="host_id" value="{h["id"]}">
<div class="field-row">
<div class="field">
<label>Label</label>
<input type="text" name="label" value="{h.get("label","")}"
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="{h.get("api_url","")}"
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 (OpenRouter, etc.)</option>
</select>
</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="{h["id"]}">Fetch models</button>
<span class="fetch-status" id="fetch-{h["id"]}"></span>
</div>
</form>
<form method="POST" action="/settings/local/host/{h["id"]}/remove"
onsubmit="return confirm('Remove host and all its models?')"
style="margin-top:0.5rem">
<button type="submit" class="btn-link danger">Remove host</button>
</form>
</div>'''
if not host_rows:
host_rows = '<p class="empty-note">No hosts configured yet. Add one below.</p>'
host_options = "".join(
f'<option value="{h["id"]}">{h.get("label") or h["api_url"]}</option>'
for h in hosts
)
# ── Model rows (all providers) ────────────────────────────────────────────
_PROVIDER_BADGE = {
"claude_cli": ('<span class="pbadge pb-anthropic">Anthropic</span>', "Claude CLI"),
"gemini_api": ('<span class="pbadge pb-google">Google</span>', ""),
"local_openai": ('<span class="pbadge pb-local">Local</span>', ""),
}
model_rows = ""
for m in models:
resolved = reg._resolve_model(registry, m["id"])
if not resolved:
continue
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>'
)
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 [])
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="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'
role_rows = ""
for role in app_settings.get_defined_roles():
role_cfg = roles.get(role, {})
role_rows += (
f'<div class="role-row" data-role="{role}">'
f'<span class="role-name">{role.title()}</span>'
f'<div class="role-slots">'
)
for slot in reg.PRIORITY_KEYS[:3]:
slot_label = slot.replace("_", " ").title()
sel = (
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 persona and tools">⚙</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 rcp-field-inline">'
f'<label class="rcp-check">'
f'<input type="checkbox" class="rcp-datetime-cb" data-role="{role}" checked>'
f' Inject current date &amp; time into system prompt'
f'</label>'
f'<span class="rcp-hint" style="display:block;margin-top:0.2rem">'
f'Disable for pure processing roles (summarizer, classifier, translator)</span>'
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'</div>'
)
role_data_js = _json.dumps({
role: {slot: (roles.get(role, {}).get(slot) or "") for slot in reg.PRIORITY_KEYS[:3]}
for role in app_settings.get_defined_roles()
})
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),
}
for role in app_settings.get_defined_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"))
has_hosts = "true" if hosts else "false"
html = (_STATIC / "local_llm.html").read_text()
replacements = {
"{{ username }}": username,
"{{ google_account_rows }}": google_account_rows,
"{{ host_rows }}": host_rows,
"{{ model_rows }}": model_rows,
"{{ host_options }}": host_options,
"{{ role_rows }}": role_rows,
"{{ role_data_js }}": role_data_js,
"{{ role_config_data_js }}": role_config_data_js,
"{{ tool_categories_js }}": tool_categories_js,
"{{ google_accounts_js }}": google_accounts_js,
"{{ google_catalog_js }}": google_catalog_js,
"{{ anthropic_catalog_js }}": anthropic_catalog_js,
"{{ has_hosts }}": has_hosts,
}
for key, val in replacements.items():
html = html.replace(key, val)
if success:
html = html.replace("<!-- SUCCESS -->", f'<p class="msg success">{success}</p>')
if error:
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))
@router.get("/settings/local", include_in_schema=False)
async def models_page_legacy(request: Request):
return RedirectResponse("/settings/models", status_code=301)
@router.post("/settings/local/google-account", include_in_schema=False)
async def save_google_account(
request: Request,
account_id: str = Form(""),
label: str = Form(""),
api_key: str = Form(""),
):
username = _get_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
if not api_key.strip() and not account_id.strip():
return HTMLResponse(_render(username, error="API key is required."))
reg.save_google_account(username, account_id or None, label, api_key)
return HTMLResponse(_render(username, success="Google account saved."))
@router.post("/settings/local/google-account/{account_id}/remove", include_in_schema=False)
async def remove_google_account(request: Request, account_id: str):
username = _get_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
reg.remove_google_account(username, account_id)
return HTMLResponse(_render(username, success="Google account removed."))
@router.post("/settings/local/host", include_in_schema=False)
async def save_host(
request: Request,
host_id: str = Form(""),
label: str = Form(""),
api_url: str = Form(""),
api_key: str = Form(""),
host_type: str = Form("openwebui"),
):
username = _get_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
if not api_url.strip():
return HTMLResponse(_render(username, error="API URL is required."))
reg.save_host(username, host_id or None, label, api_url, api_key, host_type)
return HTMLResponse(_render(username, success="Host saved."))
@router.post("/settings/local/host/{host_id}/remove", include_in_schema=False)
async def remove_host(request: Request, host_id: str):
username = _get_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
reg.remove_host(username, host_id)
return HTMLResponse(_render(username, success="Host removed."))
@router.post("/settings/local/models/add", include_in_schema=False)
async def add_model(
request: Request,
provider: str = Form("local"),
label: str = Form(""),
context_k: int = Form(0),
max_rounds: int = Form(0),
tools: int = Form(1),
tags: str = Form(""),
# local-only fields
host_id: str = Form(""),
model_name: str = Form(""),
# cloud-only fields
cloud_model_name: str = Form(""),
account_id: str = Form(""),
credential_id: str = Form("cli"),
):
username = _get_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
max_rounds_ = max_rounds or None
tools_bool = tools != 0
if provider == "local":
if not model_name.strip():
return HTMLResponse(_render(username, error="Model name is required."))
if not host_id.strip():
return HTMLResponse(_render(username, error="Select a host."))
reg.save_model(username, None, host_id, label, model_name, context_k, tag_list,
max_rounds=max_rounds_, tools=tools_bool)
display = label or model_name
elif provider in ("google", "anthropic"):
if not cloud_model_name.strip():
return HTMLResponse(_render(username, error="Select a model from the catalog."))
if provider == "google" and not account_id.strip():
return HTMLResponse(_render(username, error="Select a Google account."))
reg.save_cloud_model(
username, None, provider, cloud_model_name, label,
account_id=account_id or None,
credential_id=credential_id or None,
context_k=context_k, tags=tag_list,
max_rounds=max_rounds_, tools=tools_bool,
)
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}/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(""),
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, 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
if mtype == "local_openai":
if not host_id.strip():
return HTMLResponse(_render(username, 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)
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 == "claude_cli":
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, 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, 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, success="Model removed."))
@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)
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))
logger.info("role config saved: %s %s (tools=%s inject_datetime=%s)",
username, role, len(tools) if tools is not None else "all", inject_datetime)
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)