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

@@ -175,14 +175,17 @@ async def _local(system_prompt: str, messages: list[dict], model_cfg: dict | Non
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/local")
logger.info("local backend: %s @ %s", model, api_url) host_type = cfg.get("host_type", "openwebui")
# "openwebui" uses Open WebUI/Ollama path layout; "openai" uses standard OpenAI layout
chat_path = "/chat/completions" if host_type == "openai" else "/api/chat/completions"
logger.info("local backend (%s): %s @ %s", host_type, model, api_url)
msgs: list[dict] = [] msgs: list[dict] = []
if system_prompt: if system_prompt:
msgs.append({"role": "system", "content": system_prompt}) msgs.append({"role": "system", "content": system_prompt})
msgs.extend(messages) msgs.extend(messages)
url = api_url.rstrip("/") + "/api/chat/completions" url = api_url.rstrip("/") + chat_path
headers: dict[str, str] = {} headers: dict[str, str] = {}
if api_key: if api_key:
headers["Authorization"] = f"Bearer {api_key}" headers["Authorization"] = f"Bearer {api_key}"

View File

@@ -6,7 +6,18 @@ Stored in: home/{user}/model_registry.json
Schema: Schema:
{ {
"version": 1, "version": 1,
"hosts": [{"id", "label", "api_url", "api_key"}, ...], "hosts": [{"id", "label", "api_url", "api_key",
"host_type": "openwebui" | "openai"}, ...],
#
# host_type controls the API path layout:
# "openwebui" (default) — Open WebUI / Ollama:
# chat: POST {url}/api/chat/completions
# models: GET {url}/api/models
# "openai" — OpenRouter, LiteLLM, Anthropic-compatible, etc.:
# chat: POST {url}/chat/completions
# models: GET {url}/models
# Set api_url to the base path that ends just before /chat/completions,
# e.g. https://openrouter.ai/api/v1 for OpenRouter.
"models": [ "models": [
{ {
"id": str, # unique within this registry "id": str, # unique within this registry
@@ -212,7 +223,12 @@ def _resolve_model(registry: dict, model_id: str) -> dict | None:
if not host: if not host:
logger.warning("model %s references missing host_id %s", model_id, host_id) logger.warning("model %s references missing host_id %s", model_id, host_id)
return None return None
return {**model, "api_url": host.get("api_url", ""), "api_key": host.get("api_key", "")} return {
**model,
"api_url": host.get("api_url", ""),
"api_key": host.get("api_key", ""),
"host_type": host.get("host_type", "openwebui"),
}
return dict(model) return dict(model)
@@ -308,15 +324,21 @@ def get_defined_roles(username: str) -> dict[str, dict]:
# ── Write API (CRUD) ────────────────────────────────────────────────────────── # ── Write API (CRUD) ──────────────────────────────────────────────────────────
def save_host(username: str, host_id: str | None, def save_host(username: str, host_id: str | None,
label: str, api_url: str, api_key: str) -> str: label: str, api_url: str, api_key: str,
"""Create or update a host. Returns the host ID.""" host_type: str = "openwebui") -> str:
"""Create or update a host. Returns the host ID.
host_type: "openwebui" (default) or "openai" (OpenRouter, LiteLLM, etc.)
"""
data = _load(username) data = _load(username)
host_type = host_type if host_type in ("openwebui", "openai") else "openwebui"
if host_id: if host_id:
for h in data["hosts"]: for h in data["hosts"]:
if h["id"] == host_id: if h["id"] == host_id:
h["label"] = label.strip() h["label"] = label.strip()
h["api_url"] = api_url.strip() h["api_url"] = api_url.strip()
h["host_type"] = host_type
if api_key.strip(): if api_key.strip():
h["api_key"] = api_key.strip() h["api_key"] = api_key.strip()
_save(username, data) _save(username, data)
@@ -325,10 +347,11 @@ def save_host(username: str, host_id: str | None,
host_id = secrets.token_hex(4) host_id = secrets.token_hex(4)
data["hosts"].append({ data["hosts"].append({
"id": host_id, "id": host_id,
"label": label.strip(), "label": label.strip(),
"api_url": api_url.strip(), "api_url": api_url.strip(),
"api_key": api_key.strip(), "api_key": api_key.strip(),
"host_type": host_type,
}) })
_save(username, data) _save(username, data)
return host_id return host_id

View File

@@ -54,7 +54,10 @@ def _render(username: str, success: str = "", error: str = "") -> str:
# ── Host rows ───────────────────────────────────────────────────────────── # ── 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")
ow_sel = ' selected' if ht == "openwebui" else ''
ai_sel = ' 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">
@@ -72,11 +75,20 @@ def _render(username: str, success: str = "", error: str = "") -> str:
autocomplete="off" spellcheck="false" data-form-type="other"> autocomplete="off" spellcheck="false" data-form-type="other">
</div> </div>
</div> </div>
<div class="field"> <div class="field-row">
<label>API Key</label> <div class="field">
<input type="password" name="api_key" placeholder="Leave blank to keep existing" <label>API Key</label>
autocomplete="new-password" data-1p-ignore data-lpignore="true" data-form-type="other"> <input type="password" name="api_key" placeholder="Leave blank to keep existing"
<p class="key-status">Current: {key_hint}</p> 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>
<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 host</button>
@@ -197,19 +209,20 @@ async def models_page(request: Request):
@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,
host_id: str = Form(""), host_id: str = Form(""),
label: str = Form(""), label: str = Form(""),
api_url: str = Form(""), api_url: str = Form(""),
api_key: str = Form(""), api_key: str = Form(""),
host_type: str = Form("openwebui"),
): ):
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)
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) reg.save_host(username, host_id or None, label, api_url, api_key, host_type)
logger.info("model registry host saved: %s", username) 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."))
@@ -306,8 +319,10 @@ async def fetch_models(request: Request, host_id: str = "") -> JSONResponse:
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)
url = api_url.rstrip("/") + "/api/models" host_type = host.get("host_type", "openwebui") if host else "openwebui"
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} 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: try:
async with httpx.AsyncClient(timeout=8) as client: async with httpx.AsyncClient(timeout=8) as client:

View File

@@ -243,11 +243,20 @@
autocomplete="off" spellcheck="false" data-form-type="other"> autocomplete="off" spellcheck="false" data-form-type="other">
</div> </div>
</div> </div>
<div class="field"> <div class="field-row">
<label for="new-host-key">API Key</label> <div class="field">
<input type="password" id="new-host-key" name="api_key" <label for="new-host-key">API Key</label>
placeholder="sk-… (leave blank if not required)" <input type="password" id="new-host-key" name="api_key"
autocomplete="new-password" data-1p-ignore data-lpignore="true" data-form-type="other"> placeholder="sk-… (leave blank if not required)"
autocomplete="new-password" data-1p-ignore data-lpignore="true" data-form-type="other">
</div>
<div class="field" style="flex:0 0 auto">
<label for="new-host-type">Type</label>
<select id="new-host-type" name="host_type">
<option value="openwebui">Open WebUI / Ollama</option>
<option value="openai">OpenAI-compatible (OpenRouter, etc.)</option>
</select>
</div>
</div> </div>
<div class="btn-row"> <div class="btn-row">
<button type="submit" class="btn btn-primary btn-sm">Add Host</button> <button type="submit" class="btn btn-primary btn-sm">Add Host</button>