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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 63 additions & 13 deletions openrouter_pipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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."""
Expand Down
108 changes: 98 additions & 10 deletions test_pipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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")

Expand All @@ -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(
Expand All @@ -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 ────────────────────────────────────────

Expand Down
Loading