Skip to content

Commit f05ab4a

Browse files
sena-labsclaude
andcommitted
fix: resolve provider icon sync failures (v1.6.1)
Three bugs prevented provider icons from reliably appearing in the OpenWebUI model selector: 1. Wrong lookup order in _get_provider_icon: the hardcoded _PROVIDER_ICONS dict was always consulted before the dynamic OpenRouter registry. If OpenRouter changed any CDN path, the hardcoded (stale) URL was returned and served 404s while the registry — which always has current paths — was ignored for those providers. Fix: registry-first, hardcoded as fallback. 2. _provider_registry cached forever: a transient fetch failure at startup (network not ready, rate-limited, API hiccup) would leave the registry permanently empty until the pipe restarted. Non-200 responses were also silently ignored. Fix: added a 1h TTL (_PROVIDER_REGISTRY_TTL) and a log message for non-200s. 3. _icons_synced never reset: the set tracking "already synced" model IDs was never cleared between 5-minute model-cache refreshes. OWUI upserts models with the default data: icon after every pipes() call; the permanent _icons_synced state meant that any OWUI-overwritten icon was never restored. Fix: clear _icons_synced whenever the model cache is refreshed. Tests: 555 passed (6 new assertions covering the three fixes). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 19d6abb commit f05ab4a

3 files changed

Lines changed: 107 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.6.1] — 2026-05-08
11+
12+
### Fixed
13+
14+
- **Provider icon lookup order**`_get_provider_icon` now consults the dynamic OpenRouter registry first and falls back to the hardcoded `_PROVIDER_ICONS` dict only when the registry is unavailable. Previously the hardcoded dict always took priority, meaning any CDN path change on OpenRouter's side would silently serve 404 URLs for the 13 built-in providers while the registry (which always has correct current URLs) was ignored for them.
15+
- **Provider registry now refreshes hourly**`_load_provider_registry` previously cached the result for the entire lifetime of the `Pipe` instance. A transient network failure at startup (API down, rate-limited, not yet reachable) would permanently leave the registry empty until the pipe was restarted. Now the cache expires after `_PROVIDER_REGISTRY_TTL` (1 hour) and a fresh fetch is attempted automatically.
16+
- **Non-200 registry responses now logged** — HTTP 4xx/5xx responses from `GET /api/frontend/all-providers` were previously swallowed silently, making it impossible to diagnose why icons were missing. A `[OpenRouter Pipe] Provider registry returned HTTP {status}` message is now printed.
17+
- **`_icons_synced` cleared on model cache refresh** — the set of "already synced" model IDs was never reset between 5-minute model-cache cycles. OWUI upserts models (resetting their `profile_image_url` to the default `data:` icon) after every `pipes()` call; the permanent `_icons_synced` state meant the corrective re-sync was never retried. The set is now cleared whenever the model cache is refreshed, so any OWUI-overwritten icon is restored on the next sync pass.
18+
1019
## [1.6.0] — 2026-05-08
1120

1221
### Added

openrouter_pipe.py

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
author: Sena Labs
44
author_url: https://github.com/sena-labs
55
funding_url: https://github.com/sponsors/sena-labs
6-
version: 1.6.0
6+
version: 1.6.1
77
license: MIT
88
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImJnIiB4MT0iMCUiIHkxPSIwJSIgeDI9IjEwMCUiIHkyPSIxMDAlIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjNmQyOGQ5Ii8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjYTc4YmZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHJlY3Qgd2lkdGg9IjEwMCIgaGVpZ2h0PSIxMDAiIHJ4PSIyMCIgZmlsbD0idXJsKCNiZykiLz48cGF0aCBkPSJNMjAgNTAgQzIwIDMwLCA0MCAzMCwgNTAgMzAgTDUwIDIyIEw2OCA0MCBMNTAgNTggTDUwIDUwIEM0MCA1MCwgMzUgNDUsIDMwIDUwIEMyNSA1NSwgMjAgNzAsIDIwIDUwIFoiIGZpbGw9IndoaXRlIiBvcGFjaXR5PSIwLjk1Ii8+PGNpcmNsZSBjeD0iNzgiIGN5PSIzMCIgcj0iNyIgZmlsbD0id2hpdGUiIG9wYWNpdHk9IjAuOCIvPjxjaXJjbGUgY3g9IjgyIiBjeT0iNTAiIHI9IjciIGZpbGw9IndoaXRlIiBvcGFjaXR5PSIwLjk1Ii8+PGNpcmNsZSBjeD0iNzgiIGN5PSI3MCIgcj0iNyIgZmlsbD0id2hpdGUiIG9wYWNpdHk9IjAuOCIvPjxsaW5lIHgxPSI2OCIgeTE9IjQwIiB4Mj0iNzYiIHkyPSIzMiIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyIiBvcGFjaXR5PSIwLjUiLz48bGluZSB4MT0iNjgiIHkxPSI0MCIgeDI9Ijc2IiB5Mj0iNTAiIHN0cm9rZT0id2hpdGUiIHN0cm9rZS13aWR0aD0iMiIgb3BhY2l0eT0iMC41Ii8+PGxpbmUgeDE9IjY4IiB5MT0iNDAiIHgyPSI3NiIgeTI9IjY4IiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjIiIG9wYWNpdHk9IjAuNSIvPjwvc3ZnPg==
99
required_open_webui_version: 0.4.0
@@ -50,6 +50,11 @@
5050
# Cache TTL for model list (seconds)
5151
_MODELS_CACHE_TTL = 300.0 # 5 minutes
5252

53+
# Cache TTL for the OpenRouter provider registry (seconds).
54+
# Refreshed periodically so transient fetch failures and CDN path changes
55+
# are recovered automatically without restarting the pipe.
56+
_PROVIDER_REGISTRY_TTL = 3600.0 # 1 hour
57+
5358
# OpenRouter's frontend provider registry — gives us icon URLs for ~70 providers
5459
# (hosted SVG/PNG when available, gstatic favicons otherwise). Used as a
5560
# dynamic fallback when a model's author isn't in _PROVIDER_ICONS.
@@ -652,8 +657,9 @@ def __init__(self) -> None:
652657
# Track which model IDs already have icons synced (avoids repeated DB writes)
653658
self._icons_synced: set = set()
654659
# Lazy-loaded mirror of OpenRouter's provider registry (slug → icon URL).
655-
# None = not attempted; {} = attempted but failed/empty (do not retry).
660+
# Refreshed every _PROVIDER_REGISTRY_TTL seconds; None = not yet fetched.
656661
self._provider_registry: Optional[dict] = None
662+
self._provider_registry_ts: float = 0.0
657663
# Lazy-loaded set of model IDs that have at least one ZDR endpoint.
658664
# None = not attempted; frozenset() = attempted but failed/empty.
659665
self._zdr_model_ids: Optional[frozenset] = None
@@ -874,6 +880,11 @@ def pipes(self) -> List[dict]:
874880
self._models_cache = models
875881
self._models_cache_ts = time.monotonic()
876882
self._models_cache_key = self._build_cache_key()
883+
# Reset synced-set on every model cache refresh so _sync_model_icons
884+
# re-checks all models. OWUI upserts models with the default data: icon
885+
# after every pipes() call; clearing here ensures any overwritten icon
886+
# is restored on the next sync pass.
887+
self._icons_synced.clear()
877888

878889
# Sync provider icons into Open WebUI's Models database
879890
if self.valves.SYNC_PROVIDER_ICONS:
@@ -1119,14 +1130,21 @@ def get_provider_icon(provider: str) -> Optional[str]:
11191130
return _PROVIDER_ICONS.get(provider.lower())
11201131

11211132
def _load_provider_registry(self) -> dict:
1122-
"""Lazy-load OpenRouter's provider registry, cache for the pipe lifetime.
1133+
"""Load OpenRouter's provider registry, refreshing every hour.
11231134
11241135
Returns ``{slug: icon_url}`` (with each slug also indexed under its
11251136
hyphen-stripped variant so e.g. ``x-ai`` resolves to the registry's
1126-
``xai`` entry). Network failures are silent — a single empty dict is
1127-
cached and the pipe falls back to the hardcoded ``_PROVIDER_ICONS``.
1137+
``xai`` entry). Network failures are logged and an empty dict is
1138+
returned; the pipe falls back to ``_PROVIDER_ICONS`` in that case.
1139+
The cache is automatically refreshed after ``_PROVIDER_REGISTRY_TTL``
1140+
seconds so transient failures and CDN path changes are recovered
1141+
without restarting the pipe.
11281142
"""
1129-
if self._provider_registry is not None:
1143+
now = time.monotonic()
1144+
if (
1145+
self._provider_registry is not None
1146+
and (now - self._provider_registry_ts) < _PROVIDER_REGISTRY_TTL
1147+
):
11301148
return self._provider_registry
11311149

11321150
registry: dict = {}
@@ -1153,28 +1171,37 @@ def _load_provider_registry(self) -> dict:
11531171
compact = slug.replace("-", "")
11541172
if compact and compact != slug:
11551173
registry.setdefault(compact, icon)
1174+
else:
1175+
print(
1176+
f"[OpenRouter Pipe] Provider registry returned HTTP "
1177+
f"{resp.status_code} — provider icons may be incomplete"
1178+
)
11561179
finally:
11571180
resp.close()
11581181
except Exception as exc: # pragma: no cover
11591182
print(f"[OpenRouter Pipe] Provider registry fetch failed: {exc}")
11601183

11611184
self._provider_registry = registry
1185+
self._provider_registry_ts = now
11621186
return registry
11631187

11641188
def _get_provider_icon(self, provider_key: str) -> Optional[str]:
11651189
"""Resolve a provider icon URL using the layered fallback chain.
11661190
1167-
Order: hardcoded ``_PROVIDER_ICONS`` → registry exact match →
1168-
registry hyphen-stripped match. Returns ``None`` if no source has it.
1191+
Order: registry exact match → registry hyphen-stripped →
1192+
hardcoded ``_PROVIDER_ICONS``. The registry is authoritative because
1193+
it always reflects OpenRouter's current CDN paths; the hardcoded dict
1194+
is a reliable offline fallback when the registry is unavailable.
1195+
Returns ``None`` if no source has it.
11691196
"""
11701197
if not provider_key:
11711198
return None
11721199
key = provider_key.lower()
1173-
icon = _PROVIDER_ICONS.get(key)
1200+
registry = self._load_provider_registry()
1201+
icon = registry.get(key) or registry.get(key.replace("-", ""))
11741202
if icon:
11751203
return icon
1176-
registry = self._load_provider_registry()
1177-
return registry.get(key) or registry.get(key.replace("-", "")) or None
1204+
return _PROVIDER_ICONS.get(key)
11781205

11791206
def _parse_provider_filter(self) -> Optional[set]:
11801207
"""Parse MODEL_PROVIDERS valve into a set of lowercase provider names."""

test_pipe.py

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
_format_citation_list = mod._format_citation_list
4545
_OWUI_INTERNAL_KEYS = mod._OWUI_INTERNAL_KEYS
4646
_is_owui_managed_icon = mod._is_owui_managed_icon
47+
_PROVIDER_REGISTRY_TTL = mod._PROVIDER_REGISTRY_TTL
4748

4849
# ── Helpers ───────────────────────────────────────────────────────────────────
4950
_PASS = 0
@@ -2108,11 +2109,12 @@ def _counting_reg_get(url, *args, **kwargs):
21082109
_pipe_lookup = Pipe()
21092110
_pipe_lookup.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key")
21102111
with patch.object(_pipe_lookup._session, "get", side_effect=_counting_reg_get):
2111-
# Hardcoded fast path — registry never consulted
2112+
# Registry is consulted first; openai is in both registry and hardcoded dict —
2113+
# the registry URL wins and matches the hardcoded one.
21122114
_icon_openai = _pipe_lookup._get_provider_icon("openai")
21132115
_assert(
21142116
_icon_openai == "https://openrouter.ai/images/icons/OpenAI.svg",
2115-
"_get_provider_icon: hardcoded dict hit returns OpenAI icon",
2117+
"_get_provider_icon: registry-first hit returns OpenAI icon",
21162118
)
21172119

21182120
# Slug not in dict but in registry (exact)
@@ -2139,7 +2141,7 @@ def _counting_reg_get(url, *args, **kwargs):
21392141
# Empty/None provider key
21402142
_assert(_pipe_lookup._get_provider_icon("") is None, "_get_provider_icon: empty key → None")
21412143

2142-
# 25l. Registry network failure → cached empty dict, no retry, dict still works
2144+
# 25l. Registry network failure → cached empty dict, no retry within TTL window
21432145
_pipe_fail = Pipe()
21442146
_pipe_fail.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key")
21452147

@@ -2151,9 +2153,9 @@ def _failing_reg_get(*args, **kwargs):
21512153

21522154
with patch.object(_pipe_fail._session, "get", side_effect=_failing_reg_get):
21532155
_r_fail = _pipe_fail._load_provider_registry()
2154-
_r_fail_2 = _pipe_fail._load_provider_registry()
2156+
_r_fail_2 = _pipe_fail._load_provider_registry() # within TTL — no second fetch
21552157
_assert(_r_fail == {}, "registry: network failure → empty dict")
2156-
_assert(_fail_call_count == 1, "registry: failure does not retry (cached empty)")
2158+
_assert(_fail_call_count == 1, "registry: no retry within TTL window after failure")
21572159

21582160
# Hardcoded dict still works after registry failure
21592161
_assert(
@@ -2165,16 +2167,65 @@ def _failing_reg_get(*args, **kwargs):
21652167
"_get_provider_icon: x-ai falls back to None when registry failed",
21662168
)
21672169

2168-
# 25m. Registry HTTP non-200 → empty dict
2170+
# 25m. Registry HTTP non-200 → empty dict + log message
21692171
_mock_reg_403 = MagicMock()
21702172
_mock_reg_403.status_code = 403
21712173
_mock_reg_403.json.return_value = {"data": []}
21722174

21732175
_pipe_403 = Pipe()
21742176
_pipe_403.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key")
2175-
with patch.object(_pipe_403._session, "get", return_value=_mock_reg_403):
2176-
_r_403 = _pipe_403._load_provider_registry()
2177-
_assert(_r_403 == {}, "registry: HTTP 403 → empty dict (no parse, no retry)")
2177+
_log_403_msgs = []
2178+
with patch("builtins.print", side_effect=lambda *a, **kw: _log_403_msgs.append(" ".join(str(x) for x in a))):
2179+
with patch.object(_pipe_403._session, "get", return_value=_mock_reg_403):
2180+
_r_403 = _pipe_403._load_provider_registry()
2181+
_assert(_r_403 == {}, "registry: HTTP 403 → empty dict")
2182+
_assert(
2183+
any("403" in m for m in _log_403_msgs),
2184+
"registry: HTTP 403 logs a warning message",
2185+
)
2186+
2187+
# 25n. Registry TTL expiry forces re-fetch
2188+
_pipe_ttl = Pipe()
2189+
_pipe_ttl.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key")
2190+
2191+
_ttl_call_count = 0
2192+
def _ttl_reg_get(url, *args, **kwargs):
2193+
global _ttl_call_count
2194+
if "all-providers" in url:
2195+
_ttl_call_count += 1
2196+
return _mock_reg_resp # reuse payload mock
2197+
2198+
with patch.object(_pipe_ttl._session, "get", side_effect=_ttl_reg_get):
2199+
_pipe_ttl._load_provider_registry() # first fetch
2200+
_pipe_ttl._provider_registry_ts -= _PROVIDER_REGISTRY_TTL + 1 # expire TTL
2201+
_pipe_ttl._load_provider_registry() # should re-fetch
2202+
_assert(_ttl_call_count == 2, "registry: re-fetches after TTL expiry")
2203+
2204+
# 25o. _icons_synced cleared on model cache refresh
2205+
_pipe_sync_clear = Pipe()
2206+
_pipe_sync_clear.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key", SYNC_PROVIDER_ICONS=False)
2207+
_pipe_sync_clear._models_cache = None
2208+
2209+
_mock_models_resp_sc = MagicMock()
2210+
_mock_models_resp_sc.status_code = 200
2211+
_mock_models_resp_sc.json.return_value = {"data": [{"id": "openai/gpt-4o", "name": "GPT-4o"}]}
2212+
2213+
with patch.object(_pipe_sync_clear._session, "get", return_value=_mock_models_resp_sc):
2214+
_pipe_sync_clear.pipes()
2215+
2216+
# Populate _icons_synced to simulate prior sync
2217+
_pipe_sync_clear._icons_synced.add("openai/gpt-4o")
2218+
_assert(len(_pipe_sync_clear._icons_synced) == 1, "_icons_synced: populated before cache expire")
2219+
2220+
# Expire cache and call pipes() again — _icons_synced must be cleared
2221+
_pipe_sync_clear._models_cache_ts = 0.0
2222+
with patch.object(_pipe_sync_clear._session, "get", return_value=_mock_models_resp_sc):
2223+
_pipe_sync_clear.pipes()
2224+
2225+
_assert(
2226+
len(_pipe_sync_clear._icons_synced) == 0,
2227+
"_icons_synced: cleared on model cache refresh (allows re-sync after OWUI upsert)",
2228+
)
21782229

21792230
# ── 26. _stream_response() edge cases ────────────────────────────────────────
21802231

0 commit comments

Comments
 (0)