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:
@@ -175,14 +175,17 @@ async def _local(system_prompt: str, messages: list[dict], model_cfg: dict | Non
|
||||
if not model:
|
||||
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] = []
|
||||
if system_prompt:
|
||||
msgs.append({"role": "system", "content": system_prompt})
|
||||
msgs.extend(messages)
|
||||
|
||||
url = api_url.rstrip("/") + "/api/chat/completions"
|
||||
url = api_url.rstrip("/") + chat_path
|
||||
headers: dict[str, str] = {}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
@@ -6,7 +6,18 @@ Stored in: home/{user}/model_registry.json
|
||||
Schema:
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"id": str, # unique within this registry
|
||||
@@ -212,7 +223,12 @@ def _resolve_model(registry: dict, model_id: str) -> dict | None:
|
||||
if not host:
|
||||
logger.warning("model %s references missing host_id %s", model_id, host_id)
|
||||
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)
|
||||
|
||||
@@ -308,15 +324,21 @@ def get_defined_roles(username: str) -> dict[str, dict]:
|
||||
# ── Write API (CRUD) ──────────────────────────────────────────────────────────
|
||||
|
||||
def save_host(username: str, host_id: str | None,
|
||||
label: str, api_url: str, api_key: str) -> str:
|
||||
"""Create or update a host. Returns the host ID."""
|
||||
label: str, api_url: str, api_key: str,
|
||||
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)
|
||||
host_type = host_type if host_type in ("openwebui", "openai") else "openwebui"
|
||||
|
||||
if host_id:
|
||||
for h in data["hosts"]:
|
||||
if h["id"] == host_id:
|
||||
h["label"] = label.strip()
|
||||
h["api_url"] = api_url.strip()
|
||||
h["label"] = label.strip()
|
||||
h["api_url"] = api_url.strip()
|
||||
h["host_type"] = host_type
|
||||
if api_key.strip():
|
||||
h["api_key"] = api_key.strip()
|
||||
_save(username, data)
|
||||
@@ -325,10 +347,11 @@ def save_host(username: str, host_id: str | None,
|
||||
|
||||
host_id = secrets.token_hex(4)
|
||||
data["hosts"].append({
|
||||
"id": host_id,
|
||||
"label": label.strip(),
|
||||
"api_url": api_url.strip(),
|
||||
"api_key": api_key.strip(),
|
||||
"id": host_id,
|
||||
"label": label.strip(),
|
||||
"api_url": api_url.strip(),
|
||||
"api_key": api_key.strip(),
|
||||
"host_type": host_type,
|
||||
})
|
||||
_save(username, data)
|
||||
return host_id
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -243,11 +243,20 @@
|
||||
autocomplete="off" spellcheck="false" data-form-type="other">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-host-key">API Key</label>
|
||||
<input type="password" id="new-host-key" name="api_key"
|
||||
placeholder="sk-… (leave blank if not required)"
|
||||
autocomplete="new-password" data-1p-ignore data-lpignore="true" data-form-type="other">
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="new-host-key">API Key</label>
|
||||
<input type="password" id="new-host-key" name="api_key"
|
||||
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 class="btn-row">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Add Host</button>
|
||||
|
||||
Reference in New Issue
Block a user