Skip to content

Commit 8988643

Browse files
authored
Merge pull request #12 from sena-labs/claude/sharp-shamir-6cd1c8
fix: resolve provider icon sync failures (v1.6.1)
2 parents 19d6abb + 7c888df commit 8988643

3 files changed

Lines changed: 170 additions & 23 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: 63 additions & 13 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,16 @@
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+
58+
# Back-off TTL used when the registry fetch fails or returns non-200.
59+
# Shorter than the success TTL so transient failures are retried sooner
60+
# without hammering the OpenRouter API.
61+
_PROVIDER_REGISTRY_FAIL_TTL = 300.0 # 5 minutes
62+
5363
# OpenRouter's frontend provider registry — gives us icon URLs for ~70 providers
5464
# (hosted SVG/PNG when available, gstatic favicons otherwise). Used as a
5565
# dynamic fallback when a model's author isn't in _PROVIDER_ICONS.
@@ -652,8 +662,9 @@ def __init__(self) -> None:
652662
# Track which model IDs already have icons synced (avoids repeated DB writes)
653663
self._icons_synced: set = set()
654664
# Lazy-loaded mirror of OpenRouter's provider registry (slug → icon URL).
655-
# None = not attempted; {} = attempted but failed/empty (do not retry).
665+
# Refreshed every _PROVIDER_REGISTRY_TTL seconds; None = not yet fetched.
656666
self._provider_registry: Optional[dict] = None
667+
self._provider_registry_ts: float = 0.0
657668
# Lazy-loaded set of model IDs that have at least one ZDR endpoint.
658669
# None = not attempted; frozenset() = attempted but failed/empty.
659670
self._zdr_model_ids: Optional[frozenset] = None
@@ -874,6 +885,11 @@ def pipes(self) -> List[dict]:
874885
self._models_cache = models
875886
self._models_cache_ts = time.monotonic()
876887
self._models_cache_key = self._build_cache_key()
888+
# Reset synced-set on every model cache refresh so _sync_model_icons
889+
# re-checks all models. OWUI upserts models with the default data: icon
890+
# after every pipes() call; clearing here ensures any overwritten icon
891+
# is restored on the next sync pass.
892+
self._icons_synced.clear()
877893

878894
# Sync provider icons into Open WebUI's Models database
879895
if self.valves.SYNC_PROVIDER_ICONS:
@@ -1119,17 +1135,29 @@ def get_provider_icon(provider: str) -> Optional[str]:
11191135
return _PROVIDER_ICONS.get(provider.lower())
11201136

11211137
def _load_provider_registry(self) -> dict:
1122-
"""Lazy-load OpenRouter's provider registry, cache for the pipe lifetime.
1138+
"""Load OpenRouter's provider registry, refreshing every hour.
11231139
11241140
Returns ``{slug: icon_url}`` (with each slug also indexed under its
11251141
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``.
1142+
``xai`` entry).
1143+
1144+
On a successful 200 response the full ``_PROVIDER_REGISTRY_TTL``
1145+
applies. On failure (non-200 or network error) the *existing* cached
1146+
registry is preserved so previously-known icons are not lost; a
1147+
shorter ``_PROVIDER_REGISTRY_FAIL_TTL`` back-off is applied so we
1148+
retry sooner without hammering the API. If no registry has ever been
1149+
fetched successfully an empty dict is returned and the caller falls
1150+
back to ``_PROVIDER_ICONS``.
11281151
"""
1129-
if self._provider_registry is not None:
1152+
now = time.monotonic()
1153+
if (
1154+
self._provider_registry is not None
1155+
and (now - self._provider_registry_ts) < _PROVIDER_REGISTRY_TTL
1156+
):
11301157
return self._provider_registry
11311158

11321159
registry: dict = {}
1160+
success = False
11331161
try:
11341162
resp = self._session.get(
11351163
_PROVIDER_REGISTRY_URL,
@@ -1153,28 +1181,50 @@ def _load_provider_registry(self) -> dict:
11531181
compact = slug.replace("-", "")
11541182
if compact and compact != slug:
11551183
registry.setdefault(compact, icon)
1184+
success = True
1185+
else:
1186+
print(
1187+
f"[OpenRouter Pipe] Provider registry returned HTTP "
1188+
f"{resp.status_code} — provider icons may be incomplete"
1189+
)
11561190
finally:
11571191
resp.close()
11581192
except Exception as exc: # pragma: no cover
11591193
print(f"[OpenRouter Pipe] Provider registry fetch failed: {exc}")
11601194

1161-
self._provider_registry = registry
1162-
return registry
1195+
if success:
1196+
# Successful fetch — update the cached registry and start the full TTL.
1197+
self._provider_registry = registry
1198+
self._provider_registry_ts = now
1199+
else:
1200+
# Failed fetch (non-200 or network error): preserve any previously-good
1201+
# registry so icons that were already known are not lost. Apply a short
1202+
# back-off so we retry in _PROVIDER_REGISTRY_FAIL_TTL seconds instead of
1203+
# waiting the full hour.
1204+
if self._provider_registry is None:
1205+
self._provider_registry = {}
1206+
self._provider_registry_ts = (
1207+
now - _PROVIDER_REGISTRY_TTL + _PROVIDER_REGISTRY_FAIL_TTL
1208+
)
1209+
return self._provider_registry
11631210

11641211
def _get_provider_icon(self, provider_key: str) -> Optional[str]:
11651212
"""Resolve a provider icon URL using the layered fallback chain.
11661213
1167-
Order: hardcoded ``_PROVIDER_ICONS`` → registry exact match →
1168-
registry hyphen-stripped match. Returns ``None`` if no source has it.
1214+
Order: registry exact match → registry hyphen-stripped →
1215+
hardcoded ``_PROVIDER_ICONS``. The registry is authoritative because
1216+
it always reflects OpenRouter's current CDN paths; the hardcoded dict
1217+
is a reliable offline fallback when the registry is unavailable.
1218+
Returns ``None`` if no source has it.
11691219
"""
11701220
if not provider_key:
11711221
return None
11721222
key = provider_key.lower()
1173-
icon = _PROVIDER_ICONS.get(key)
1223+
registry = self._load_provider_registry()
1224+
icon = registry.get(key) or registry.get(key.replace("-", ""))
11741225
if icon:
11751226
return icon
1176-
registry = self._load_provider_registry()
1177-
return registry.get(key) or registry.get(key.replace("-", "")) or None
1227+
return _PROVIDER_ICONS.get(key)
11781228

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

test_pipe.py

Lines changed: 98 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
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
48+
_PROVIDER_REGISTRY_FAIL_TTL = mod._PROVIDER_REGISTRY_FAIL_TTL
4749

4850
# ── Helpers ───────────────────────────────────────────────────────────────────
4951
_PASS = 0
@@ -2108,11 +2110,12 @@ def _counting_reg_get(url, *args, **kwargs):
21082110
_pipe_lookup = Pipe()
21092111
_pipe_lookup.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key")
21102112
with patch.object(_pipe_lookup._session, "get", side_effect=_counting_reg_get):
2111-
# Hardcoded fast path — registry never consulted
2113+
# Registry is consulted first; openai is in both registry and hardcoded dict —
2114+
# the registry URL wins and matches the hardcoded one.
21122115
_icon_openai = _pipe_lookup._get_provider_icon("openai")
21132116
_assert(
21142117
_icon_openai == "https://openrouter.ai/images/icons/OpenAI.svg",
2115-
"_get_provider_icon: hardcoded dict hit returns OpenAI icon",
2118+
"_get_provider_icon: registry-first hit returns OpenAI icon",
21162119
)
21172120

21182121
# Slug not in dict but in registry (exact)
@@ -2139,7 +2142,7 @@ def _counting_reg_get(url, *args, **kwargs):
21392142
# Empty/None provider key
21402143
_assert(_pipe_lookup._get_provider_icon("") is None, "_get_provider_icon: empty key → None")
21412144

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

@@ -2151,9 +2154,9 @@ def _failing_reg_get(*args, **kwargs):
21512154

21522155
with patch.object(_pipe_fail._session, "get", side_effect=_failing_reg_get):
21532156
_r_fail = _pipe_fail._load_provider_registry()
2154-
_r_fail_2 = _pipe_fail._load_provider_registry()
2155-
_assert(_r_fail == {}, "registry: network failure → empty dict")
2156-
_assert(_fail_call_count == 1, "registry: failure does not retry (cached empty)")
2157+
_r_fail_2 = _pipe_fail._load_provider_registry() # within back-off — no second fetch
2158+
_assert(_r_fail == {}, "registry: network failure → empty dict (no prior registry)")
2159+
_assert(_fail_call_count == 1, "registry: no retry within FAIL_TTL back-off window after failure")
21572160

21582161
# Hardcoded dict still works after registry failure
21592162
_assert(
@@ -2165,16 +2168,101 @@ def _failing_reg_get(*args, **kwargs):
21652168
"_get_provider_icon: x-ai falls back to None when registry failed",
21662169
)
21672170

2168-
# 25m. Registry HTTP non-200 → empty dict
2171+
# 25m. Registry HTTP non-200 → log message; empty dict on first-ever fetch;
2172+
# existing registry preserved if one was already loaded.
21692173
_mock_reg_403 = MagicMock()
21702174
_mock_reg_403.status_code = 403
21712175
_mock_reg_403.json.return_value = {"data": []}
21722176

2177+
# 25m-a: first fetch fails → empty dict + warning logged
21732178
_pipe_403 = Pipe()
21742179
_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)")
2180+
_log_403_msgs = []
2181+
with patch("builtins.print", side_effect=lambda *a, **kw: _log_403_msgs.append(" ".join(str(x) for x in a))):
2182+
with patch.object(_pipe_403._session, "get", return_value=_mock_reg_403):
2183+
_r_403 = _pipe_403._load_provider_registry()
2184+
_assert(_r_403 == {}, "registry: HTTP 403 on first fetch → empty dict")
2185+
_assert(
2186+
any("403" in m for m in _log_403_msgs),
2187+
"registry: HTTP 403 logs a warning message",
2188+
)
2189+
2190+
# 25m-b: subsequent non-200 with an existing registry → old registry preserved
2191+
_pipe_403_preserve = Pipe()
2192+
_pipe_403_preserve.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key")
2193+
_pipe_403_preserve._provider_registry = {"openai": "https://example.com/openai.svg"}
2194+
_pipe_403_preserve._provider_registry_ts = _time_mod.monotonic() - _PROVIDER_REGISTRY_TTL - 1
2195+
with patch.object(_pipe_403_preserve._session, "get", return_value=_mock_reg_403):
2196+
_r_403_preserve = _pipe_403_preserve._load_provider_registry()
2197+
_assert(
2198+
_r_403_preserve == {"openai": "https://example.com/openai.svg"},
2199+
"registry: HTTP 403 preserves existing non-empty registry",
2200+
)
2201+
2202+
# 25m-c: after FAIL_TTL back-off expires a new fetch is attempted
2203+
_pipe_fail_backoff = Pipe()
2204+
_pipe_fail_backoff.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key")
2205+
_pipe_fail_backoff._provider_registry = {"openai": "https://example.com/openai.svg"}
2206+
_pipe_fail_backoff._provider_registry_ts = _time_mod.monotonic() - _PROVIDER_REGISTRY_TTL - 1
2207+
2208+
_backoff_call_count = 0
2209+
def _backoff_403_get(url, *args, **kwargs):
2210+
global _backoff_call_count
2211+
if "all-providers" in url:
2212+
_backoff_call_count += 1
2213+
return _mock_reg_403
2214+
2215+
with patch.object(_pipe_fail_backoff._session, "get", side_effect=_backoff_403_get):
2216+
_pipe_fail_backoff._load_provider_registry() # fetch 1 → fail, set backoff ts
2217+
# expire the back-off window
2218+
_pipe_fail_backoff._provider_registry_ts -= _PROVIDER_REGISTRY_FAIL_TTL + 1
2219+
_pipe_fail_backoff._load_provider_registry() # fetch 2 → retried after backoff
2220+
_assert(_backoff_call_count == 2, "registry: re-fetches after FAIL_TTL back-off expires")
2221+
2222+
# 25n. Registry TTL expiry forces re-fetch
2223+
_pipe_ttl = Pipe()
2224+
_pipe_ttl.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key")
2225+
2226+
_ttl_call_count = 0
2227+
def _ttl_reg_get(url, *args, **kwargs):
2228+
global _ttl_call_count
2229+
if "all-providers" in url:
2230+
_ttl_call_count += 1
2231+
return _mock_reg_resp # reuse payload mock
2232+
2233+
with patch.object(_pipe_ttl._session, "get", side_effect=_ttl_reg_get):
2234+
_pipe_ttl._load_provider_registry() # first fetch
2235+
_pipe_ttl._provider_registry_ts -= _PROVIDER_REGISTRY_TTL + 1 # expire TTL
2236+
_pipe_ttl._load_provider_registry() # should re-fetch
2237+
_assert(_ttl_call_count == 2, "registry: re-fetches after TTL expiry")
2238+
2239+
# 25o. _icons_synced cleared on model cache refresh
2240+
_pipe_sync_clear = Pipe()
2241+
_pipe_sync_clear.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key", SYNC_PROVIDER_ICONS=False)
2242+
_pipe_sync_clear._models_cache = None
2243+
2244+
_mock_models_resp_sc = MagicMock()
2245+
_mock_models_resp_sc.status_code = 200
2246+
_mock_models_resp_sc.json.return_value = {"data": [{"id": "openai/gpt-4o", "name": "GPT-4o"}]}
2247+
2248+
with patch.object(_pipe_sync_clear._session, "get", return_value=_mock_models_resp_sc):
2249+
_pipe_sync_clear.pipes()
2250+
2251+
# Populate _icons_synced to simulate prior sync
2252+
_pipe_sync_clear._icons_synced.add("openai/gpt-4o")
2253+
_assert(len(_pipe_sync_clear._icons_synced) == 1, "_icons_synced: populated before cache expire")
2254+
2255+
# Expire cache and call pipes() again — _icons_synced must be cleared.
2256+
# Subtract more than the TTL from the stored timestamp so the cache is
2257+
# expired regardless of how small time.monotonic() is on a fresh CI runner.
2258+
_pipe_sync_clear._models_cache_ts -= mod._MODELS_CACHE_TTL + 1
2259+
with patch.object(_pipe_sync_clear._session, "get", return_value=_mock_models_resp_sc):
2260+
_pipe_sync_clear.pipes()
2261+
2262+
_assert(
2263+
len(_pipe_sync_clear._icons_synced) == 0,
2264+
"_icons_synced: cleared on model cache refresh (allows re-sync after OWUI upsert)",
2265+
)
21782266

21792267
# ── 26. _stream_response() edge cases ────────────────────────────────────────
21802268

0 commit comments

Comments
 (0)