Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 94 additions & 42 deletions openrouter_pipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"""

import copy
import hashlib
import json
import os
import random
Expand Down Expand Up @@ -41,29 +42,21 @@
# Provider icons — synced into the Open WebUI Models database by
# _sync_model_icons() so the frontend can serve them via
# /models/model/profile/image. Disable with SYNC_PROVIDER_ICONS = False.
# URLs verified against https://openrouter.ai/images/icons/ (May 2025).
_PROVIDER_ICONS = {
"openai": "https://openrouter.ai/images/models/openai.svg",
"anthropic": "https://openrouter.ai/images/models/anthropic.svg",
"google": "https://openrouter.ai/images/models/google.svg",
"meta-llama": "https://openrouter.ai/images/models/meta.svg",
"mistralai": "https://openrouter.ai/images/models/mistralai.svg",
"amazon": "https://openrouter.ai/images/models/amazon.svg",
"deepseek": "https://openrouter.ai/images/models/deepseek.svg",
"x-ai": "https://openrouter.ai/images/models/xai.svg",
"cohere": "https://openrouter.ai/images/models/cohere.svg",
"perplexity": "https://openrouter.ai/images/models/perplexity.svg",
"allenai": "https://openrouter.ai/images/models/allenai.svg",
"qwen": "https://openrouter.ai/images/models/qwen.svg",
"nvidia": "https://openrouter.ai/images/models/nvidia.svg",
"databricks": "https://openrouter.ai/images/models/databricks.svg",
"microsoft": "https://openrouter.ai/images/models/microsoft.svg",
"together": "https://openrouter.ai/images/models/together.svg",
"fireworks": "https://openrouter.ai/images/models/fireworks.svg",
"sambanova": "https://openrouter.ai/images/models/sambanova.svg",
"cerebras": "https://openrouter.ai/images/models/cerebras.svg",
"groq": "https://openrouter.ai/images/models/groq.svg",
"inflection": "https://openrouter.ai/images/models/inflection.svg",
"01-ai": "https://openrouter.ai/images/models/01ai.svg",
"openai": "https://openrouter.ai/images/icons/OpenAI.svg",
"anthropic": "https://openrouter.ai/images/icons/Anthropic.svg",
"google": "https://openrouter.ai/images/icons/GoogleGemini.svg",
"meta-llama": "https://openrouter.ai/images/icons/Meta.png",
"mistralai": "https://openrouter.ai/images/icons/Mistral.png",
"amazon": "https://openrouter.ai/images/icons/Bedrock.svg",
"deepseek": "https://openrouter.ai/images/icons/DeepSeek.png",
"cohere": "https://openrouter.ai/images/icons/Cohere.png",
"perplexity": "https://openrouter.ai/images/icons/Perplexity.svg",
"qwen": "https://openrouter.ai/images/icons/Qwen.png",
"microsoft": "https://openrouter.ai/images/icons/Microsoft.svg",
"fireworks": "https://openrouter.ai/images/icons/Fireworks.png",
"moonshotai": "https://openrouter.ai/images/icons/MoonshotAI.png",
}


Expand All @@ -76,14 +69,16 @@ def _is_owui_managed_icon(url: str) -> bool:
"""Return True if the icon URL was set by OWUI or our sync logic.

data: URLs are the pipe's own SVG icon that OWUI assigns as default to all
manifold child models. openrouter.ai/images/models/ URLs are the provider
icons we write. Any other URL is assumed to be a user-set custom icon and
must not be overwritten.
manifold child models. openrouter.ai/images/models/ and
openrouter.ai/images/icons/ are the provider icon paths we write (the
former was the old path, now superseded by the latter). Any other URL is
assumed to be a user-set custom icon and must not be overwritten.
"""
return (
not url
or url.startswith("data:")
or url.startswith("https://openrouter.ai/images/models/")
or url.startswith("https://openrouter.ai/images/icons/")
)


Expand Down Expand Up @@ -340,12 +335,28 @@ def chat_url(self) -> str:
"""Return the full URL for the chat completions endpoint."""
return f"{self._base}{_API_PATH_CHAT}"

def _build_cache_key(self) -> str:
"""Build a fingerprint of the valves that affect the model list.

The API key is hashed (not embedded raw) so it doesn't sit in plaintext
in long-lived strings that may end up in logs or memory dumps.
"""
api_key_hash = (
hashlib.sha256(self.valves.OPENROUTER_API_KEY.encode("utf-8")).hexdigest()[:16]
if self.valves.OPENROUTER_API_KEY
else ""
)
return (
f"{api_key_hash}|{self.valves.FREE_ONLY}|"
f"{self.valves.MODEL_PROVIDERS}|{self.valves.INVERT_PROVIDER_LIST}|"
f"{self.valves.MODEL_PREFIX}"
Comment on lines +349 to +352
)

def _models_cache_valid(self) -> bool:
"""Check if the cached model list is still valid."""
if not self._models_cache:
return False
key = f"{self.valves.OPENROUTER_API_KEY}|{self.valves.FREE_ONLY}|{self.valves.MODEL_PROVIDERS}|{self.valves.INVERT_PROVIDER_LIST}|{self.valves.MODEL_PREFIX}"
if key != self._models_cache_key:
if self._build_cache_key() != self._models_cache_key:
return False
return (time.monotonic() - self._models_cache_ts) < _MODELS_CACHE_TTL

Expand Down Expand Up @@ -428,9 +439,11 @@ def pipes(self) -> List[dict]:
if not is_free:
continue

# Split model_id once for provider extraction
# Split model_id once for provider extraction.
# Strip leading '~' (OpenRouter "latest" aliases like ~anthropic/claude-haiku-latest)
# so they match the same provider filter as their base provider.
parts = model_id.split("/", 1)
provider_key = parts[0].lower() if len(parts) > 1 else "openrouter"
provider_key = parts[0].lstrip("~").lower() if len(parts) > 1 else "openrouter"

if provider_filter:
keep = (provider_key in provider_filter) ^ self.valves.INVERT_PROVIDER_LIST
Expand Down Expand Up @@ -459,7 +472,7 @@ def pipes(self) -> List[dict]:
# Store in cache
self._models_cache = models
self._models_cache_ts = time.monotonic()
self._models_cache_key = f"{self.valves.OPENROUTER_API_KEY}|{self.valves.FREE_ONLY}|{self.valves.MODEL_PROVIDERS}|{self.valves.INVERT_PROVIDER_LIST}|{self.valves.MODEL_PREFIX}"
self._models_cache_key = self._build_cache_key()

# Sync provider icons into Open WebUI's Models database
if self.valves.SYNC_PROVIDER_ICONS:
Expand All @@ -469,10 +482,21 @@ def pipes(self) -> List[dict]:

@staticmethod
def _clean_model_id(model_id: str) -> str:
"""Strip the manifold prefix from a model ID."""
if "." in model_id:
return model_id.split(".", 1)[-1]
return model_id
"""Strip the manifold prefix from a model ID.

OpenRouter model IDs use the format ``provider/model`` (e.g.
``anthropic/claude-3.5-sonnet``). The manifold prefix added by Open
WebUI is a function id without ``/`` (e.g. ``openrouter_pipe``). We
only strip when the text before the first ``.`` contains no ``/`` —
otherwise the dot is part of the model version (e.g.
``claude-3.5-sonnet``) and must be preserved.
"""
if "." not in model_id:
return model_id
prefix, rest = model_id.split(".", 1)
if "/" in prefix:
return model_id
return rest

async def pipe(
self,
Expand Down Expand Up @@ -582,17 +606,44 @@ def _sync_model_icons(self, models: List[dict]) -> None:
if model_id in self._icons_synced:
continue

# Determine provider icon
# Determine provider icon. Strip '~' so latest aliases (e.g.
# ~anthropic/claude-haiku-latest) resolve to the correct icon.
parts = model_id.split("/", 1)
provider_key = parts[0].lower() if len(parts) > 1 else ""
provider_key = parts[0].lstrip("~").lower() if len(parts) > 1 else ""
icon_url = _PROVIDER_ICONS.get(provider_key)
# Build the prefixed ID that Open WebUI uses in the frontend
db_model_id = f"{function_id}.{model_id}"

if not icon_url:
# No icon for this provider. If the DB holds one of our old
# broken /images/models/ URLs, clear it so OWUI shows its
# default icon rather than a broken image.
try:
existing = Models.get_model_by_id(db_model_id)
Comment thread
sena-labs marked this conversation as resolved.
if existing and hasattr(existing, "meta") and existing.meta:
stale = getattr(existing.meta, "profile_image_url", "") or ""
if stale.startswith("https://openrouter.ai/images/models/"):
existing_params = ModelParams()
if hasattr(existing, "params") and existing.params:
existing_params = existing.params
Models.update_model_by_id(
db_model_id,
ModelForm(
id=db_model_id,
name=(
existing.name
if hasattr(existing, "name")
else model.get("name", model_id)
),
meta=ModelMeta(profile_image_url=""),
params=existing_params,
),
)
except Exception:
pass
self._icons_synced.add(model_id)
continue
Comment thread
sena-labs marked this conversation as resolved.

# Build the prefixed ID that Open WebUI uses in the frontend
db_model_id = f"{function_id}.{model_id}"

try:
existing = Models.get_model_by_id(db_model_id)
if existing:
Expand Down Expand Up @@ -685,8 +736,7 @@ def _prepare_payload(self, body: dict) -> dict:
payload.pop(key, None)

# Open WebUI sends 'user' as dict; OpenRouter expects a string
user_field = payload.get("user")
if isinstance(user_field, dict):
if isinstance(payload.get("user"), dict):
payload.pop("user", None)

# Fix model ID (strip manifold prefix)
Expand Down Expand Up @@ -960,7 +1010,9 @@ def _close_think_tag():
if response is not None:
response.close()

def _retryable_request(self, headers: dict, payload: dict, stream: bool):
def _retryable_request(
self, headers: dict, payload: dict, stream: bool
) -> requests.Response:
"""Send a POST request with automatic retry and exponential backoff."""
last_exc: Optional[Exception] = None
for attempt in range(self.valves.MAX_RETRIES + 1):
Expand Down
89 changes: 82 additions & 7 deletions test_pipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,48 @@ async def _test_pipe_stream() -> str:
models = pipe.pipes()
_assert(len(models) == 2, "pipes invert: excludes openai → 2 models")

# 15d-2. Provider filter includes tilde (~) latest-alias models for their base provider
_mock_tilde = {
"data": [
{"id": "openai/gpt-4o", "name": "GPT-4o"},
{"id": "~anthropic/claude-haiku-latest", "name": "Claude Haiku (Latest)"},
{"id": "~openai/gpt-latest", "name": "GPT (Latest)"},
{"id": "google/gemini-2.0-flash", "name": "Gemini 2.0 Flash"},
]
}
_mock_resp_tilde = MagicMock()
_mock_resp_tilde.status_code = 200
_mock_resp_tilde.json.return_value = _mock_tilde
_mock_resp_tilde.raise_for_status = MagicMock()

pipe.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key", MODEL_PROVIDERS="openai")
pipe._models_cache = None
with patch.object(pipe._session, "get", return_value=_mock_resp_tilde):
_tilde_models = pipe.pipes()
_tilde_ids = {m["id"] for m in _tilde_models}
_assert("openai/gpt-4o" in _tilde_ids, "pipes tilde: base openai model included")
_assert("~openai/gpt-latest" in _tilde_ids, "pipes tilde: ~openai model included by openai filter")
_assert("~anthropic/claude-haiku-latest" not in _tilde_ids, "pipes tilde: ~anthropic excluded by openai filter")
_assert("google/gemini-2.0-flash" not in _tilde_ids, "pipes tilde: google excluded by openai filter")

pipe.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key", MODEL_PROVIDERS="anthropic")
pipe._models_cache = None
with patch.object(pipe._session, "get", return_value=_mock_resp_tilde):
_tilde_models2 = pipe.pipes()
_tilde_ids2 = {m["id"] for m in _tilde_models2}
_assert("~anthropic/claude-haiku-latest" in _tilde_ids2, "pipes tilde: ~anthropic model included by anthropic filter")
_assert("openai/gpt-4o" not in _tilde_ids2, "pipes tilde: openai excluded by anthropic filter")

pipe.valves = Pipe.Valves(
OPENROUTER_API_KEY="test-key", MODEL_PROVIDERS="anthropic", INVERT_PROVIDER_LIST=True
)
pipe._models_cache = None
with patch.object(pipe._session, "get", return_value=_mock_resp_tilde):
_tilde_models3 = pipe.pipes()
_tilde_ids3 = {m["id"] for m in _tilde_models3}
_assert("~anthropic/claude-haiku-latest" not in _tilde_ids3, "pipes tilde: ~anthropic excluded by inverted anthropic filter")
_assert("openai/gpt-4o" in _tilde_ids3, "pipes tilde: openai kept by inverted anthropic filter")

# 15e. PREFIX
pipe.valves = Pipe.Valves(
OPENROUTER_API_KEY="test-key", MODEL_PREFIX="🔥 "
Expand Down Expand Up @@ -1125,7 +1167,26 @@ async def _test_pipe_stream() -> str:
_assert(Pipe._clean_model_id("openrouter.google/gemini") == "google/gemini", "strips manifold prefix")
_assert(Pipe._clean_model_id("google/gemini") == "google/gemini", "no prefix → unchanged")
_assert(Pipe._clean_model_id("") == "", "empty string → empty")
_assert(Pipe._clean_model_id("a.b.c/d") == "b.c/d", "multiple dots → splits on first")
_assert(Pipe._clean_model_id("a.b.c/d") == "b.c/d", "no '/' before first '.' → strip prefix")
_assert(
Pipe._clean_model_id("anthropic/claude-3.5-sonnet") == "anthropic/claude-3.5-sonnet",
"'/' before '.' → preserve dotted model name",
)
_assert(
Pipe._clean_model_id("openrouter.anthropic/claude-3.5-sonnet")
== "anthropic/claude-3.5-sonnet",
"manifold prefix stripped, dotted model preserved",
)
_assert(
Pipe._clean_model_id("meta-llama/llama-3.1-8b-instruct")
== "meta-llama/llama-3.1-8b-instruct",
"real OpenRouter ID with dots preserved",
)
_assert(
Pipe._clean_model_id("function_xyz.meta-llama/llama-3.3-70b-instruct")
== "meta-llama/llama-3.3-70b-instruct",
"OWUI function_id prefix stripped, dotted model preserved",
)

# ── 19. Model caching ───────────────────────────────────────────────────────

Expand Down Expand Up @@ -1337,7 +1398,7 @@ async def _test_pipe_no_msgs_key():

# 24c. Provider icon utility (static method)
_assert(Pipe.get_provider_icon("openai") is not None, "provider icon: openai icon available")
_assert("openai" in Pipe.get_provider_icon("openai"), "provider icon: openai URL correct")
_assert("images/icons" in Pipe.get_provider_icon("openai"), "provider icon: openai URL uses /images/icons/")
_assert(Pipe.get_provider_icon("unknown-xyz") is None, "provider icon: unknown returns None")

# ── 25. _sync_model_icons ───────────────────────────────────────────────────
Expand Down Expand Up @@ -1532,7 +1593,9 @@ async def _test_pipe_no_msgs_key():
_is_owui = mod._is_owui_managed_icon
_assert(_is_owui(""), "_is_owui_managed_icon: empty string → True (no icon)")
_assert(_is_owui("data:image/svg+xml;base64,ABC"), "_is_owui_managed_icon: data: URL → True")
_assert(_is_owui("https://openrouter.ai/images/models/openai.svg"), "_is_owui_managed_icon: openrouter.ai URL → True")
_assert(_is_owui("https://openrouter.ai/images/models/openai.svg"), "_is_owui_managed_icon: old /images/models/ URL → True")
_assert(_is_owui("https://openrouter.ai/images/icons/OpenAI.svg"), "_is_owui_managed_icon: new /images/icons/ URL → True")
_assert(_is_owui("https://openrouter.ai/images/icons/Anthropic.svg"), "_is_owui_managed_icon: icons path anthropic → True")
_assert(not _is_owui("https://custom-icon.example.com/icon.png"), "_is_owui_managed_icon: external URL → False")
_assert(not _is_owui("https://cdn.openai.com/logo.png"), "_is_owui_managed_icon: other https URL → False")

Expand Down Expand Up @@ -1807,19 +1870,31 @@ async def _test_pipe_no_msgs_key():

_section("31. All provider icons")

# Providers confirmed to have icons at /images/icons/ (verified May 2025)
_ALL_PROVIDER_KEYS = [
"openai", "anthropic", "google", "meta-llama", "mistralai",
"amazon", "deepseek", "x-ai", "cohere", "perplexity",
"allenai", "qwen", "nvidia", "databricks", "microsoft",
"together", "fireworks", "sambanova", "cerebras", "groq",
"inflection", "01-ai",
"amazon", "deepseek", "cohere", "perplexity", "qwen",
"microsoft", "fireworks", "moonshotai",
]
for _prov_key in _ALL_PROVIDER_KEYS:
_prov_icon = Pipe.get_provider_icon(_prov_key)
_assert(
_prov_icon is not None and len(_prov_icon) > 0,
f"provider icon: '{_prov_key}' → non-empty URL",
)
_assert(
"images/icons" in (_prov_icon or ""),
f"provider icon: '{_prov_key}' URL uses /images/icons/",
)

# Providers without icons should return None (no broken-URL fallback)
_NO_ICON_PROVIDERS = ["x-ai", "allenai", "nvidia", "databricks", "together",
"sambanova", "cerebras", "groq", "inflection", "01-ai"]
for _prov_key in _NO_ICON_PROVIDERS:
_assert(
Pipe.get_provider_icon(_prov_key) is None,
f"provider icon: '{_prov_key}' → None (no valid icon available)",
)

_assert(Pipe.get_provider_icon("unknown-provider") is None, "provider icon: unknown → None")
_assert(
Expand Down
Loading