diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fe0eef..c10fa12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.6.1] — 2026-05-08 + +### Fixed + +- **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. +- **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. +- **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. +- **`_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. + ## [1.6.0] — 2026-05-08 ### Added diff --git a/openrouter_pipe.py b/openrouter_pipe.py index 8281ee0..5f76dda 100644 --- a/openrouter_pipe.py +++ b/openrouter_pipe.py @@ -3,7 +3,7 @@ author: Sena Labs author_url: https://github.com/sena-labs funding_url: https://github.com/sponsors/sena-labs -version: 1.6.0 +version: 1.6.1 license: MIT icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImJnIiB4MT0iMCUiIHkxPSIwJSIgeDI9IjEwMCUiIHkyPSIxMDAlIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjNmQyOGQ5Ii8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjYTc4YmZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHJlY3Qgd2lkdGg9IjEwMCIgaGVpZ2h0PSIxMDAiIHJ4PSIyMCIgZmlsbD0idXJsKCNiZykiLz48cGF0aCBkPSJNMjAgNTAgQzIwIDMwLCA0MCAzMCwgNTAgMzAgTDUwIDIyIEw2OCA0MCBMNTAgNTggTDUwIDUwIEM0MCA1MCwgMzUgNDUsIDMwIDUwIEMyNSA1NSwgMjAgNzAsIDIwIDUwIFoiIGZpbGw9IndoaXRlIiBvcGFjaXR5PSIwLjk1Ii8+PGNpcmNsZSBjeD0iNzgiIGN5PSIzMCIgcj0iNyIgZmlsbD0id2hpdGUiIG9wYWNpdHk9IjAuOCIvPjxjaXJjbGUgY3g9IjgyIiBjeT0iNTAiIHI9IjciIGZpbGw9IndoaXRlIiBvcGFjaXR5PSIwLjk1Ii8+PGNpcmNsZSBjeD0iNzgiIGN5PSI3MCIgcj0iNyIgZmlsbD0id2hpdGUiIG9wYWNpdHk9IjAuOCIvPjxsaW5lIHgxPSI2OCIgeTE9IjQwIiB4Mj0iNzYiIHkyPSIzMiIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyIiBvcGFjaXR5PSIwLjUiLz48bGluZSB4MT0iNjgiIHkxPSI0MCIgeDI9Ijc2IiB5Mj0iNTAiIHN0cm9rZT0id2hpdGUiIHN0cm9rZS13aWR0aD0iMiIgb3BhY2l0eT0iMC41Ii8+PGxpbmUgeDE9IjY4IiB5MT0iNDAiIHgyPSI3NiIgeTI9IjY4IiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjIiIG9wYWNpdHk9IjAuNSIvPjwvc3ZnPg== required_open_webui_version: 0.4.0 @@ -50,6 +50,16 @@ # Cache TTL for model list (seconds) _MODELS_CACHE_TTL = 300.0 # 5 minutes +# Cache TTL for the OpenRouter provider registry (seconds). +# Refreshed periodically so transient fetch failures and CDN path changes +# are recovered automatically without restarting the pipe. +_PROVIDER_REGISTRY_TTL = 3600.0 # 1 hour + +# Back-off TTL used when the registry fetch fails or returns non-200. +# Shorter than the success TTL so transient failures are retried sooner +# without hammering the OpenRouter API. +_PROVIDER_REGISTRY_FAIL_TTL = 300.0 # 5 minutes + # OpenRouter's frontend provider registry — gives us icon URLs for ~70 providers # (hosted SVG/PNG when available, gstatic favicons otherwise). Used as a # dynamic fallback when a model's author isn't in _PROVIDER_ICONS. @@ -652,8 +662,9 @@ def __init__(self) -> None: # Track which model IDs already have icons synced (avoids repeated DB writes) self._icons_synced: set = set() # Lazy-loaded mirror of OpenRouter's provider registry (slug → icon URL). - # None = not attempted; {} = attempted but failed/empty (do not retry). + # Refreshed every _PROVIDER_REGISTRY_TTL seconds; None = not yet fetched. self._provider_registry: Optional[dict] = None + self._provider_registry_ts: float = 0.0 # Lazy-loaded set of model IDs that have at least one ZDR endpoint. # None = not attempted; frozenset() = attempted but failed/empty. self._zdr_model_ids: Optional[frozenset] = None @@ -874,6 +885,11 @@ def pipes(self) -> List[dict]: self._models_cache = models self._models_cache_ts = time.monotonic() self._models_cache_key = self._build_cache_key() + # Reset synced-set on every model cache refresh so _sync_model_icons + # re-checks all models. OWUI upserts models with the default data: icon + # after every pipes() call; clearing here ensures any overwritten icon + # is restored on the next sync pass. + self._icons_synced.clear() # Sync provider icons into Open WebUI's Models database if self.valves.SYNC_PROVIDER_ICONS: @@ -1119,17 +1135,29 @@ def get_provider_icon(provider: str) -> Optional[str]: return _PROVIDER_ICONS.get(provider.lower()) def _load_provider_registry(self) -> dict: - """Lazy-load OpenRouter's provider registry, cache for the pipe lifetime. + """Load OpenRouter's provider registry, refreshing every hour. Returns ``{slug: icon_url}`` (with each slug also indexed under its hyphen-stripped variant so e.g. ``x-ai`` resolves to the registry's - ``xai`` entry). Network failures are silent — a single empty dict is - cached and the pipe falls back to the hardcoded ``_PROVIDER_ICONS``. + ``xai`` entry). + + On a successful 200 response the full ``_PROVIDER_REGISTRY_TTL`` + applies. On failure (non-200 or network error) the *existing* cached + registry is preserved so previously-known icons are not lost; a + shorter ``_PROVIDER_REGISTRY_FAIL_TTL`` back-off is applied so we + retry sooner without hammering the API. If no registry has ever been + fetched successfully an empty dict is returned and the caller falls + back to ``_PROVIDER_ICONS``. """ - if self._provider_registry is not None: + now = time.monotonic() + if ( + self._provider_registry is not None + and (now - self._provider_registry_ts) < _PROVIDER_REGISTRY_TTL + ): return self._provider_registry registry: dict = {} + success = False try: resp = self._session.get( _PROVIDER_REGISTRY_URL, @@ -1153,28 +1181,50 @@ def _load_provider_registry(self) -> dict: compact = slug.replace("-", "") if compact and compact != slug: registry.setdefault(compact, icon) + success = True + else: + print( + f"[OpenRouter Pipe] Provider registry returned HTTP " + f"{resp.status_code} — provider icons may be incomplete" + ) finally: resp.close() except Exception as exc: # pragma: no cover print(f"[OpenRouter Pipe] Provider registry fetch failed: {exc}") - self._provider_registry = registry - return registry + if success: + # Successful fetch — update the cached registry and start the full TTL. + self._provider_registry = registry + self._provider_registry_ts = now + else: + # Failed fetch (non-200 or network error): preserve any previously-good + # registry so icons that were already known are not lost. Apply a short + # back-off so we retry in _PROVIDER_REGISTRY_FAIL_TTL seconds instead of + # waiting the full hour. + if self._provider_registry is None: + self._provider_registry = {} + self._provider_registry_ts = ( + now - _PROVIDER_REGISTRY_TTL + _PROVIDER_REGISTRY_FAIL_TTL + ) + return self._provider_registry def _get_provider_icon(self, provider_key: str) -> Optional[str]: """Resolve a provider icon URL using the layered fallback chain. - Order: hardcoded ``_PROVIDER_ICONS`` → registry exact match → - registry hyphen-stripped match. Returns ``None`` if no source has it. + Order: registry exact match → registry hyphen-stripped → + hardcoded ``_PROVIDER_ICONS``. The registry is authoritative because + it always reflects OpenRouter's current CDN paths; the hardcoded dict + is a reliable offline fallback when the registry is unavailable. + Returns ``None`` if no source has it. """ if not provider_key: return None key = provider_key.lower() - icon = _PROVIDER_ICONS.get(key) + registry = self._load_provider_registry() + icon = registry.get(key) or registry.get(key.replace("-", "")) if icon: return icon - registry = self._load_provider_registry() - return registry.get(key) or registry.get(key.replace("-", "")) or None + return _PROVIDER_ICONS.get(key) def _parse_provider_filter(self) -> Optional[set]: """Parse MODEL_PROVIDERS valve into a set of lowercase provider names.""" diff --git a/test_pipe.py b/test_pipe.py index 2f0385f..fdd5a56 100644 --- a/test_pipe.py +++ b/test_pipe.py @@ -44,6 +44,8 @@ _format_citation_list = mod._format_citation_list _OWUI_INTERNAL_KEYS = mod._OWUI_INTERNAL_KEYS _is_owui_managed_icon = mod._is_owui_managed_icon +_PROVIDER_REGISTRY_TTL = mod._PROVIDER_REGISTRY_TTL +_PROVIDER_REGISTRY_FAIL_TTL = mod._PROVIDER_REGISTRY_FAIL_TTL # ── Helpers ─────────────────────────────────────────────────────────────────── _PASS = 0 @@ -2108,11 +2110,12 @@ def _counting_reg_get(url, *args, **kwargs): _pipe_lookup = Pipe() _pipe_lookup.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key") with patch.object(_pipe_lookup._session, "get", side_effect=_counting_reg_get): - # Hardcoded fast path — registry never consulted + # Registry is consulted first; openai is in both registry and hardcoded dict — + # the registry URL wins and matches the hardcoded one. _icon_openai = _pipe_lookup._get_provider_icon("openai") _assert( _icon_openai == "https://openrouter.ai/images/icons/OpenAI.svg", - "_get_provider_icon: hardcoded dict hit returns OpenAI icon", + "_get_provider_icon: registry-first hit returns OpenAI icon", ) # Slug not in dict but in registry (exact) @@ -2139,7 +2142,7 @@ def _counting_reg_get(url, *args, **kwargs): # Empty/None provider key _assert(_pipe_lookup._get_provider_icon("") is None, "_get_provider_icon: empty key → None") -# 25l. Registry network failure → cached empty dict, no retry, dict still works +# 25l. Registry network failure → cached empty dict, no retry within TTL window _pipe_fail = Pipe() _pipe_fail.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key") @@ -2151,9 +2154,9 @@ def _failing_reg_get(*args, **kwargs): with patch.object(_pipe_fail._session, "get", side_effect=_failing_reg_get): _r_fail = _pipe_fail._load_provider_registry() - _r_fail_2 = _pipe_fail._load_provider_registry() -_assert(_r_fail == {}, "registry: network failure → empty dict") -_assert(_fail_call_count == 1, "registry: failure does not retry (cached empty)") + _r_fail_2 = _pipe_fail._load_provider_registry() # within back-off — no second fetch +_assert(_r_fail == {}, "registry: network failure → empty dict (no prior registry)") +_assert(_fail_call_count == 1, "registry: no retry within FAIL_TTL back-off window after failure") # Hardcoded dict still works after registry failure _assert( @@ -2165,16 +2168,101 @@ def _failing_reg_get(*args, **kwargs): "_get_provider_icon: x-ai falls back to None when registry failed", ) -# 25m. Registry HTTP non-200 → empty dict +# 25m. Registry HTTP non-200 → log message; empty dict on first-ever fetch; +# existing registry preserved if one was already loaded. _mock_reg_403 = MagicMock() _mock_reg_403.status_code = 403 _mock_reg_403.json.return_value = {"data": []} +# 25m-a: first fetch fails → empty dict + warning logged _pipe_403 = Pipe() _pipe_403.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key") -with patch.object(_pipe_403._session, "get", return_value=_mock_reg_403): - _r_403 = _pipe_403._load_provider_registry() -_assert(_r_403 == {}, "registry: HTTP 403 → empty dict (no parse, no retry)") +_log_403_msgs = [] +with patch("builtins.print", side_effect=lambda *a, **kw: _log_403_msgs.append(" ".join(str(x) for x in a))): + with patch.object(_pipe_403._session, "get", return_value=_mock_reg_403): + _r_403 = _pipe_403._load_provider_registry() +_assert(_r_403 == {}, "registry: HTTP 403 on first fetch → empty dict") +_assert( + any("403" in m for m in _log_403_msgs), + "registry: HTTP 403 logs a warning message", +) + +# 25m-b: subsequent non-200 with an existing registry → old registry preserved +_pipe_403_preserve = Pipe() +_pipe_403_preserve.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key") +_pipe_403_preserve._provider_registry = {"openai": "https://example.com/openai.svg"} +_pipe_403_preserve._provider_registry_ts = _time_mod.monotonic() - _PROVIDER_REGISTRY_TTL - 1 +with patch.object(_pipe_403_preserve._session, "get", return_value=_mock_reg_403): + _r_403_preserve = _pipe_403_preserve._load_provider_registry() +_assert( + _r_403_preserve == {"openai": "https://example.com/openai.svg"}, + "registry: HTTP 403 preserves existing non-empty registry", +) + +# 25m-c: after FAIL_TTL back-off expires a new fetch is attempted +_pipe_fail_backoff = Pipe() +_pipe_fail_backoff.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key") +_pipe_fail_backoff._provider_registry = {"openai": "https://example.com/openai.svg"} +_pipe_fail_backoff._provider_registry_ts = _time_mod.monotonic() - _PROVIDER_REGISTRY_TTL - 1 + +_backoff_call_count = 0 +def _backoff_403_get(url, *args, **kwargs): + global _backoff_call_count + if "all-providers" in url: + _backoff_call_count += 1 + return _mock_reg_403 + +with patch.object(_pipe_fail_backoff._session, "get", side_effect=_backoff_403_get): + _pipe_fail_backoff._load_provider_registry() # fetch 1 → fail, set backoff ts + # expire the back-off window + _pipe_fail_backoff._provider_registry_ts -= _PROVIDER_REGISTRY_FAIL_TTL + 1 + _pipe_fail_backoff._load_provider_registry() # fetch 2 → retried after backoff +_assert(_backoff_call_count == 2, "registry: re-fetches after FAIL_TTL back-off expires") + +# 25n. Registry TTL expiry forces re-fetch +_pipe_ttl = Pipe() +_pipe_ttl.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key") + +_ttl_call_count = 0 +def _ttl_reg_get(url, *args, **kwargs): + global _ttl_call_count + if "all-providers" in url: + _ttl_call_count += 1 + return _mock_reg_resp # reuse payload mock + +with patch.object(_pipe_ttl._session, "get", side_effect=_ttl_reg_get): + _pipe_ttl._load_provider_registry() # first fetch + _pipe_ttl._provider_registry_ts -= _PROVIDER_REGISTRY_TTL + 1 # expire TTL + _pipe_ttl._load_provider_registry() # should re-fetch +_assert(_ttl_call_count == 2, "registry: re-fetches after TTL expiry") + +# 25o. _icons_synced cleared on model cache refresh +_pipe_sync_clear = Pipe() +_pipe_sync_clear.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key", SYNC_PROVIDER_ICONS=False) +_pipe_sync_clear._models_cache = None + +_mock_models_resp_sc = MagicMock() +_mock_models_resp_sc.status_code = 200 +_mock_models_resp_sc.json.return_value = {"data": [{"id": "openai/gpt-4o", "name": "GPT-4o"}]} + +with patch.object(_pipe_sync_clear._session, "get", return_value=_mock_models_resp_sc): + _pipe_sync_clear.pipes() + +# Populate _icons_synced to simulate prior sync +_pipe_sync_clear._icons_synced.add("openai/gpt-4o") +_assert(len(_pipe_sync_clear._icons_synced) == 1, "_icons_synced: populated before cache expire") + +# Expire cache and call pipes() again — _icons_synced must be cleared. +# Subtract more than the TTL from the stored timestamp so the cache is +# expired regardless of how small time.monotonic() is on a fresh CI runner. +_pipe_sync_clear._models_cache_ts -= mod._MODELS_CACHE_TTL + 1 +with patch.object(_pipe_sync_clear._session, "get", return_value=_mock_models_resp_sc): + _pipe_sync_clear.pipes() + +_assert( + len(_pipe_sync_clear._icons_synced) == 0, + "_icons_synced: cleared on model cache refresh (allows re-sync after OWUI upsert)", +) # ── 26. _stream_response() edge cases ────────────────────────────────────────