From a6e404c1430b10fcb488466083fa2ca24e37bd6d Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Mon, 6 Apr 2026 21:11:22 -0400 Subject: [PATCH] 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 --- cortex/llm_client.py | 7 ++++-- cortex/model_registry.py | 43 ++++++++++++++++++++++++++-------- cortex/routers/local_llm.py | 45 ++++++++++++++++++++++++------------ cortex/static/local_llm.html | 19 +++++++++++---- 4 files changed, 82 insertions(+), 32 deletions(-) diff --git a/cortex/llm_client.py b/cortex/llm_client.py index f84660e..cc583a3 100644 --- a/cortex/llm_client.py +++ b/cortex/llm_client.py @@ -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}" diff --git a/cortex/model_registry.py b/cortex/model_registry.py index 16eca45..aa92a99 100644 --- a/cortex/model_registry.py +++ b/cortex/model_registry.py @@ -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 diff --git a/cortex/routers/local_llm.py b/cortex/routers/local_llm.py index 8b9e1af..8d04aab 100644 --- a/cortex/routers/local_llm.py +++ b/cortex/routers/local_llm.py @@ -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'''
@@ -72,11 +75,20 @@ def _render(username: str, success: str = "", error: str = "") -> str: autocomplete="off" spellcheck="false" data-form-type="other">
-
- - -

Current: {key_hint}

+
+
+ + +

Current: {key_hint}

+
+
+ + +
@@ -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: diff --git a/cortex/static/local_llm.html b/cortex/static/local_llm.html index 6a0ccab..a37ab36 100644 --- a/cortex/static/local_llm.html +++ b/cortex/static/local_llm.html @@ -243,11 +243,20 @@ autocomplete="off" spellcheck="false" data-form-type="other">
-
- - +
+
+ + +
+
+ + +