Skip to content

Commit 0f31796

Browse files
authored
Merge pull request #8 from sena-labs/claude/laughing-beaver-f67164
fix: correct provider icons, ~ model filtering, and model ID stripping
2 parents 9c068c7 + a7e973d commit 0f31796

2 files changed

Lines changed: 176 additions & 49 deletions

File tree

openrouter_pipe.py

Lines changed: 94 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"""
1313

1414
import copy
15+
import hashlib
1516
import json
1617
import os
1718
import random
@@ -41,29 +42,21 @@
4142
# Provider icons — synced into the Open WebUI Models database by
4243
# _sync_model_icons() so the frontend can serve them via
4344
# /models/model/profile/image. Disable with SYNC_PROVIDER_ICONS = False.
45+
# URLs verified against https://openrouter.ai/images/icons/ (May 2025).
4446
_PROVIDER_ICONS = {
45-
"openai": "https://openrouter.ai/images/models/openai.svg",
46-
"anthropic": "https://openrouter.ai/images/models/anthropic.svg",
47-
"google": "https://openrouter.ai/images/models/google.svg",
48-
"meta-llama": "https://openrouter.ai/images/models/meta.svg",
49-
"mistralai": "https://openrouter.ai/images/models/mistralai.svg",
50-
"amazon": "https://openrouter.ai/images/models/amazon.svg",
51-
"deepseek": "https://openrouter.ai/images/models/deepseek.svg",
52-
"x-ai": "https://openrouter.ai/images/models/xai.svg",
53-
"cohere": "https://openrouter.ai/images/models/cohere.svg",
54-
"perplexity": "https://openrouter.ai/images/models/perplexity.svg",
55-
"allenai": "https://openrouter.ai/images/models/allenai.svg",
56-
"qwen": "https://openrouter.ai/images/models/qwen.svg",
57-
"nvidia": "https://openrouter.ai/images/models/nvidia.svg",
58-
"databricks": "https://openrouter.ai/images/models/databricks.svg",
59-
"microsoft": "https://openrouter.ai/images/models/microsoft.svg",
60-
"together": "https://openrouter.ai/images/models/together.svg",
61-
"fireworks": "https://openrouter.ai/images/models/fireworks.svg",
62-
"sambanova": "https://openrouter.ai/images/models/sambanova.svg",
63-
"cerebras": "https://openrouter.ai/images/models/cerebras.svg",
64-
"groq": "https://openrouter.ai/images/models/groq.svg",
65-
"inflection": "https://openrouter.ai/images/models/inflection.svg",
66-
"01-ai": "https://openrouter.ai/images/models/01ai.svg",
47+
"openai": "https://openrouter.ai/images/icons/OpenAI.svg",
48+
"anthropic": "https://openrouter.ai/images/icons/Anthropic.svg",
49+
"google": "https://openrouter.ai/images/icons/GoogleGemini.svg",
50+
"meta-llama": "https://openrouter.ai/images/icons/Meta.png",
51+
"mistralai": "https://openrouter.ai/images/icons/Mistral.png",
52+
"amazon": "https://openrouter.ai/images/icons/Bedrock.svg",
53+
"deepseek": "https://openrouter.ai/images/icons/DeepSeek.png",
54+
"cohere": "https://openrouter.ai/images/icons/Cohere.png",
55+
"perplexity": "https://openrouter.ai/images/icons/Perplexity.svg",
56+
"qwen": "https://openrouter.ai/images/icons/Qwen.png",
57+
"microsoft": "https://openrouter.ai/images/icons/Microsoft.svg",
58+
"fireworks": "https://openrouter.ai/images/icons/Fireworks.png",
59+
"moonshotai": "https://openrouter.ai/images/icons/MoonshotAI.png",
6760
}
6861

6962

@@ -76,14 +69,16 @@ def _is_owui_managed_icon(url: str) -> bool:
7669
"""Return True if the icon URL was set by OWUI or our sync logic.
7770
7871
data: URLs are the pipe's own SVG icon that OWUI assigns as default to all
79-
manifold child models. openrouter.ai/images/models/ URLs are the provider
80-
icons we write. Any other URL is assumed to be a user-set custom icon and
81-
must not be overwritten.
72+
manifold child models. openrouter.ai/images/models/ and
73+
openrouter.ai/images/icons/ are the provider icon paths we write (the
74+
former was the old path, now superseded by the latter). Any other URL is
75+
assumed to be a user-set custom icon and must not be overwritten.
8276
"""
8377
return (
8478
not url
8579
or url.startswith("data:")
8680
or url.startswith("https://openrouter.ai/images/models/")
81+
or url.startswith("https://openrouter.ai/images/icons/")
8782
)
8883

8984

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

338+
def _build_cache_key(self) -> str:
339+
"""Build a fingerprint of the valves that affect the model list.
340+
341+
The API key is hashed (not embedded raw) so it doesn't sit in plaintext
342+
in long-lived strings that may end up in logs or memory dumps.
343+
"""
344+
api_key_hash = (
345+
hashlib.sha256(self.valves.OPENROUTER_API_KEY.encode("utf-8")).hexdigest()[:16]
346+
if self.valves.OPENROUTER_API_KEY
347+
else ""
348+
)
349+
return (
350+
f"{api_key_hash}|{self.valves.FREE_ONLY}|"
351+
f"{self.valves.MODEL_PROVIDERS}|{self.valves.INVERT_PROVIDER_LIST}|"
352+
f"{self.valves.MODEL_PREFIX}"
353+
)
354+
343355
def _models_cache_valid(self) -> bool:
344356
"""Check if the cached model list is still valid."""
345357
if not self._models_cache:
346358
return False
347-
key = f"{self.valves.OPENROUTER_API_KEY}|{self.valves.FREE_ONLY}|{self.valves.MODEL_PROVIDERS}|{self.valves.INVERT_PROVIDER_LIST}|{self.valves.MODEL_PREFIX}"
348-
if key != self._models_cache_key:
359+
if self._build_cache_key() != self._models_cache_key:
349360
return False
350361
return (time.monotonic() - self._models_cache_ts) < _MODELS_CACHE_TTL
351362

@@ -428,9 +439,11 @@ def pipes(self) -> List[dict]:
428439
if not is_free:
429440
continue
430441

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

435448
if provider_filter:
436449
keep = (provider_key in provider_filter) ^ self.valves.INVERT_PROVIDER_LIST
@@ -459,7 +472,7 @@ def pipes(self) -> List[dict]:
459472
# Store in cache
460473
self._models_cache = models
461474
self._models_cache_ts = time.monotonic()
462-
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}"
475+
self._models_cache_key = self._build_cache_key()
463476

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

470483
@staticmethod
471484
def _clean_model_id(model_id: str) -> str:
472-
"""Strip the manifold prefix from a model ID."""
473-
if "." in model_id:
474-
return model_id.split(".", 1)[-1]
475-
return model_id
485+
"""Strip the manifold prefix from a model ID.
486+
487+
OpenRouter model IDs use the format ``provider/model`` (e.g.
488+
``anthropic/claude-3.5-sonnet``). The manifold prefix added by Open
489+
WebUI is a function id without ``/`` (e.g. ``openrouter_pipe``). We
490+
only strip when the text before the first ``.`` contains no ``/`` —
491+
otherwise the dot is part of the model version (e.g.
492+
``claude-3.5-sonnet``) and must be preserved.
493+
"""
494+
if "." not in model_id:
495+
return model_id
496+
prefix, rest = model_id.split(".", 1)
497+
if "/" in prefix:
498+
return model_id
499+
return rest
476500

477501
async def pipe(
478502
self,
@@ -582,17 +606,44 @@ def _sync_model_icons(self, models: List[dict]) -> None:
582606
if model_id in self._icons_synced:
583607
continue
584608

585-
# Determine provider icon
609+
# Determine provider icon. Strip '~' so latest aliases (e.g.
610+
# ~anthropic/claude-haiku-latest) resolve to the correct icon.
586611
parts = model_id.split("/", 1)
587-
provider_key = parts[0].lower() if len(parts) > 1 else ""
612+
provider_key = parts[0].lstrip("~").lower() if len(parts) > 1 else ""
588613
icon_url = _PROVIDER_ICONS.get(provider_key)
614+
# Build the prefixed ID that Open WebUI uses in the frontend
615+
db_model_id = f"{function_id}.{model_id}"
616+
589617
if not icon_url:
618+
# No icon for this provider. If the DB holds one of our old
619+
# broken /images/models/ URLs, clear it so OWUI shows its
620+
# default icon rather than a broken image.
621+
try:
622+
existing = Models.get_model_by_id(db_model_id)
623+
if existing and hasattr(existing, "meta") and existing.meta:
624+
stale = getattr(existing.meta, "profile_image_url", "") or ""
625+
if stale.startswith("https://openrouter.ai/images/models/"):
626+
existing_params = ModelParams()
627+
if hasattr(existing, "params") and existing.params:
628+
existing_params = existing.params
629+
Models.update_model_by_id(
630+
db_model_id,
631+
ModelForm(
632+
id=db_model_id,
633+
name=(
634+
existing.name
635+
if hasattr(existing, "name")
636+
else model.get("name", model_id)
637+
),
638+
meta=ModelMeta(profile_image_url=""),
639+
params=existing_params,
640+
),
641+
)
642+
except Exception:
643+
pass
590644
self._icons_synced.add(model_id)
591645
continue
592646

593-
# Build the prefixed ID that Open WebUI uses in the frontend
594-
db_model_id = f"{function_id}.{model_id}"
595-
596647
try:
597648
existing = Models.get_model_by_id(db_model_id)
598649
if existing:
@@ -685,8 +736,7 @@ def _prepare_payload(self, body: dict) -> dict:
685736
payload.pop(key, None)
686737

687738
# Open WebUI sends 'user' as dict; OpenRouter expects a string
688-
user_field = payload.get("user")
689-
if isinstance(user_field, dict):
739+
if isinstance(payload.get("user"), dict):
690740
payload.pop("user", None)
691741

692742
# Fix model ID (strip manifold prefix)
@@ -960,7 +1010,9 @@ def _close_think_tag():
9601010
if response is not None:
9611011
response.close()
9621012

963-
def _retryable_request(self, headers: dict, payload: dict, stream: bool):
1013+
def _retryable_request(
1014+
self, headers: dict, payload: dict, stream: bool
1015+
) -> requests.Response:
9641016
"""Send a POST request with automatic retry and exponential backoff."""
9651017
last_exc: Optional[Exception] = None
9661018
for attempt in range(self.valves.MAX_RETRIES + 1):

test_pipe.py

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -907,6 +907,48 @@ async def _test_pipe_stream() -> str:
907907
models = pipe.pipes()
908908
_assert(len(models) == 2, "pipes invert: excludes openai → 2 models")
909909

910+
# 15d-2. Provider filter includes tilde (~) latest-alias models for their base provider
911+
_mock_tilde = {
912+
"data": [
913+
{"id": "openai/gpt-4o", "name": "GPT-4o"},
914+
{"id": "~anthropic/claude-haiku-latest", "name": "Claude Haiku (Latest)"},
915+
{"id": "~openai/gpt-latest", "name": "GPT (Latest)"},
916+
{"id": "google/gemini-2.0-flash", "name": "Gemini 2.0 Flash"},
917+
]
918+
}
919+
_mock_resp_tilde = MagicMock()
920+
_mock_resp_tilde.status_code = 200
921+
_mock_resp_tilde.json.return_value = _mock_tilde
922+
_mock_resp_tilde.raise_for_status = MagicMock()
923+
924+
pipe.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key", MODEL_PROVIDERS="openai")
925+
pipe._models_cache = None
926+
with patch.object(pipe._session, "get", return_value=_mock_resp_tilde):
927+
_tilde_models = pipe.pipes()
928+
_tilde_ids = {m["id"] for m in _tilde_models}
929+
_assert("openai/gpt-4o" in _tilde_ids, "pipes tilde: base openai model included")
930+
_assert("~openai/gpt-latest" in _tilde_ids, "pipes tilde: ~openai model included by openai filter")
931+
_assert("~anthropic/claude-haiku-latest" not in _tilde_ids, "pipes tilde: ~anthropic excluded by openai filter")
932+
_assert("google/gemini-2.0-flash" not in _tilde_ids, "pipes tilde: google excluded by openai filter")
933+
934+
pipe.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key", MODEL_PROVIDERS="anthropic")
935+
pipe._models_cache = None
936+
with patch.object(pipe._session, "get", return_value=_mock_resp_tilde):
937+
_tilde_models2 = pipe.pipes()
938+
_tilde_ids2 = {m["id"] for m in _tilde_models2}
939+
_assert("~anthropic/claude-haiku-latest" in _tilde_ids2, "pipes tilde: ~anthropic model included by anthropic filter")
940+
_assert("openai/gpt-4o" not in _tilde_ids2, "pipes tilde: openai excluded by anthropic filter")
941+
942+
pipe.valves = Pipe.Valves(
943+
OPENROUTER_API_KEY="test-key", MODEL_PROVIDERS="anthropic", INVERT_PROVIDER_LIST=True
944+
)
945+
pipe._models_cache = None
946+
with patch.object(pipe._session, "get", return_value=_mock_resp_tilde):
947+
_tilde_models3 = pipe.pipes()
948+
_tilde_ids3 = {m["id"] for m in _tilde_models3}
949+
_assert("~anthropic/claude-haiku-latest" not in _tilde_ids3, "pipes tilde: ~anthropic excluded by inverted anthropic filter")
950+
_assert("openai/gpt-4o" in _tilde_ids3, "pipes tilde: openai kept by inverted anthropic filter")
951+
910952
# 15e. PREFIX
911953
pipe.valves = Pipe.Valves(
912954
OPENROUTER_API_KEY="test-key", MODEL_PREFIX="🔥 "
@@ -1125,7 +1167,26 @@ async def _test_pipe_stream() -> str:
11251167
_assert(Pipe._clean_model_id("openrouter.google/gemini") == "google/gemini", "strips manifold prefix")
11261168
_assert(Pipe._clean_model_id("google/gemini") == "google/gemini", "no prefix → unchanged")
11271169
_assert(Pipe._clean_model_id("") == "", "empty string → empty")
1128-
_assert(Pipe._clean_model_id("a.b.c/d") == "b.c/d", "multiple dots → splits on first")
1170+
_assert(Pipe._clean_model_id("a.b.c/d") == "b.c/d", "no '/' before first '.' → strip prefix")
1171+
_assert(
1172+
Pipe._clean_model_id("anthropic/claude-3.5-sonnet") == "anthropic/claude-3.5-sonnet",
1173+
"'/' before '.' → preserve dotted model name",
1174+
)
1175+
_assert(
1176+
Pipe._clean_model_id("openrouter.anthropic/claude-3.5-sonnet")
1177+
== "anthropic/claude-3.5-sonnet",
1178+
"manifold prefix stripped, dotted model preserved",
1179+
)
1180+
_assert(
1181+
Pipe._clean_model_id("meta-llama/llama-3.1-8b-instruct")
1182+
== "meta-llama/llama-3.1-8b-instruct",
1183+
"real OpenRouter ID with dots preserved",
1184+
)
1185+
_assert(
1186+
Pipe._clean_model_id("function_xyz.meta-llama/llama-3.3-70b-instruct")
1187+
== "meta-llama/llama-3.3-70b-instruct",
1188+
"OWUI function_id prefix stripped, dotted model preserved",
1189+
)
11291190

11301191
# ── 19. Model caching ───────────────────────────────────────────────────────
11311192

@@ -1337,7 +1398,7 @@ async def _test_pipe_no_msgs_key():
13371398

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

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

@@ -1807,19 +1870,31 @@ async def _test_pipe_no_msgs_key():
18071870

18081871
_section("31. All provider icons")
18091872

1873+
# Providers confirmed to have icons at /images/icons/ (verified May 2025)
18101874
_ALL_PROVIDER_KEYS = [
18111875
"openai", "anthropic", "google", "meta-llama", "mistralai",
1812-
"amazon", "deepseek", "x-ai", "cohere", "perplexity",
1813-
"allenai", "qwen", "nvidia", "databricks", "microsoft",
1814-
"together", "fireworks", "sambanova", "cerebras", "groq",
1815-
"inflection", "01-ai",
1876+
"amazon", "deepseek", "cohere", "perplexity", "qwen",
1877+
"microsoft", "fireworks", "moonshotai",
18161878
]
18171879
for _prov_key in _ALL_PROVIDER_KEYS:
18181880
_prov_icon = Pipe.get_provider_icon(_prov_key)
18191881
_assert(
18201882
_prov_icon is not None and len(_prov_icon) > 0,
18211883
f"provider icon: '{_prov_key}' → non-empty URL",
18221884
)
1885+
_assert(
1886+
"images/icons" in (_prov_icon or ""),
1887+
f"provider icon: '{_prov_key}' URL uses /images/icons/",
1888+
)
1889+
1890+
# Providers without icons should return None (no broken-URL fallback)
1891+
_NO_ICON_PROVIDERS = ["x-ai", "allenai", "nvidia", "databricks", "together",
1892+
"sambanova", "cerebras", "groq", "inflection", "01-ai"]
1893+
for _prov_key in _NO_ICON_PROVIDERS:
1894+
_assert(
1895+
Pipe.get_provider_icon(_prov_key) is None,
1896+
f"provider icon: '{_prov_key}' → None (no valid icon available)",
1897+
)
18231898

18241899
_assert(Pipe.get_provider_icon("unknown-provider") is None, "provider icon: unknown → None")
18251900
_assert(

0 commit comments

Comments
 (0)