feat: host_type field for OpenRouter / OpenAI-compatible API support

Adds host_type ("openwebui" | "openai") to the host schema so Cortex can
talk to both Open WebUI/Ollama and OpenRouter/standard-OpenAI endpoints.

Path differences per type:
  openwebui (default): /api/chat/completions, /api/models
  openai:              /chat/completions,     /models

model_registry.py:
  - host_type added to host schema (default "openwebui", backward compat)
  - save_host() accepts host_type parameter
  - _resolve_model() passes host_type through with the merged host fields

llm_client._local():
  - Reads host_type from resolved model_cfg
  - Selects correct chat completions path accordingly

routers/local_llm.py:
  - save_host route accepts host_type form field
  - fetch-models uses /models for openai type, /api/models for openwebui
  - Existing host rows show type selector pre-filled from stored value

local_llm.html:
  - "Add host" form includes type selector

To use OpenRouter:
  - Add host: URL = https://openrouter.ai/api/v1, Type = OpenAI-compatible
  - API key from openrouter.ai (store in .env or model_registry.json only)
  - Fetch models or add manually (e.g. anthropic/claude-sonnet-4-5-20251022)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-04-06 21:11:22 -04:00
parent 2dd94696d5
commit a6e404c143
4 changed files with 82 additions and 32 deletions

View File

@@ -54,7 +54,10 @@ def _render(username: str, success: str = "", error: str = "") -> str:
# ── Host rows ─────────────────────────────────────────────────────────────
host_rows = ""
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")
ow_sel = ' selected' if ht == "openwebui" else ''
ai_sel = ' selected' if ht == "openai" else ''
host_rows += f'''
<div class="host-row">
<form method="POST" action="/settings/local/host" class="host-form">
@@ -72,11 +75,20 @@ def _render(username: str, success: str = "", error: str = "") -> str:
autocomplete="off" spellcheck="false" data-form-type="other">
</div>
</div>
<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 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_sel}>Open WebUI / Ollama</option>
<option value="openai"{ai_sel}>OpenAI-compatible (OpenRouter, etc.)</option>
</select>
</div>
</div>
<div class="btn-row">
<button type="submit" class="btn btn-secondary btn-sm">Save host</button>
@@ -197,19 +209,20 @@ async def models_page(request: Request):
@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(""),
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)
logger.info("model registry host saved: %s", username)
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."))
@@ -306,8 +319,10 @@ async def fetch_models(request: Request, host_id: str = "") -> JSONResponse:
if not api_url:
return JSONResponse({"error": "No host configured."}, status_code=400)
url = api_url.rstrip("/") + "/api/models"
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
host_type = host.get("host_type", "openwebui") if host else "openwebui"
models_path = "/models" if host_type == "openai" else "/api/models"
url = api_url.rstrip("/") + models_path
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
try:
async with httpx.AsyncClient(timeout=8) as client: