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:
|
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}"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user