Skip to content

Commit 30b9413

Browse files
sena-labsclaude
andcommitted
fix: resolve 5 bugs preventing provider icons from appearing in Open WebUI
_sync_model_icons() was silently failing to set provider icons due to a chain of five bugs: 1. Wrong skip condition: `if existing_icon: continue` treated OWUI's default data: SVG icon (assigned to all manifold models on load) as a user-set custom icon, so provider icons were never applied after the first pipe load. Fixed via new _is_owui_managed_icon() helper that distinguishes OWUI/our URLs from genuine user customisations. 2. Race condition: _sync_model_icons() ran before pipes() returned, meaning OWUI had not yet inserted the models into its DB. OWUI then inserted them with its default icon, overwriting any early record. Fixed by also calling _sync_model_icons() on cache-hit paths until all models are confirmed synced (second call arrives after OWUI registration). 3. Exception handler blocked retry: DB errors added the model_id to _icons_synced anyway, permanently preventing retry. Removed the erroneous add. 4. Insert prematurely marked synced: after insert_new_model the model was added to _icons_synced even though OWUI could overwrite it immediately after. Icon confirmation now requires a successful update_model_by_id. 5. User params clobbered: update_model_by_id passed an empty ModelParams(), erasing user-configured temperature/system-prompt/etc. The update now preserves existing.params. Also caches function_id in __init__ (was re-evaluated on every call). Tests: updated 25e/25f/25g for new semantics; added 25h (update path when OWUI default icon present) and 25i (_is_owui_managed_icon unit tests). All 262 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9f977da commit 30b9413

3 files changed

Lines changed: 147 additions & 53 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- **Automatic provider-icon sync** — new `_sync_model_icons()` method writes provider icons directly into Open WebUI's Models database so they appear in the UI; controlled by the `SYNC_PROVIDER_ICONS` valve (default: enabled). Models with a manually-set icon are never overwritten
13+
- **`_is_owui_managed_icon()` helper** — distinguishes OWUI-default icons (`data:` URLs) and our own provider icons from user-set custom icons, enabling safe icon updates without clobbering user customisations
1314

1415
### Fixed
1516

1617
- **Icon sync: correct prefixed model IDs**`_sync_model_icons()` now discovers the pipe's `function_id` via `type(self).__module__` and writes DB records with the full prefixed ID (e.g. `openrouter_pipe.openai/gpt-4o`) matching what Open WebUI's frontend requests at `/models/model/profile/image`
18+
- **Icon sync: icons now actually appear in the UI** — five bugs prevented provider icons from ever showing after the first pipe load:
19+
- *Wrong skip condition*`if existing_icon:` skipped any model with *any* icon (including the generic `data:` SVG that OWUI assigns by default), so provider icons were never applied; fixed to skip only user-set custom URLs
20+
- *Race condition*`_sync_model_icons()` was called before `pipes()` returned, i.e. before OWUI registered the models; OWUI then overwrote the early insert with its own default icon; fixed by also calling `_sync_model_icons()` on cache-hit paths (until all models are confirmed synced)
21+
- *Exception swallowed retry* — DB errors added the model to `_icons_synced` anyway, permanently preventing retry; removed the erroneous add
22+
- *Insert marked as synced prematurely* — after `insert_new_model` the model was marked synced even though OWUI could overwrite it; the insert path no longer updates `_icons_synced`
23+
- *User params clobbered*`update_model_by_id` used an empty `ModelParams()`, erasing user-configured temperature/system-prompt/etc.; now preserves `existing.params`
24+
- **Icon sync: `function_id` cached at init**`type(self).__module__` is evaluated once in `__init__` instead of on every `_sync_model_icons()` call
1725
- **Streaming status event** — the "done" status event is now correctly emitted at the end of streaming responses (async generator wrapper replaces sync generator that could not `await`)
1826
- **Dead provider-icon code removed**`info.meta.profile_image_url` was included in model dicts returned by `pipes()` but Open WebUI ignores all fields except `id` and `name`; the field has been removed in favour of the new DB-sync approach
1927
- **`pipes()` response always closed** — added `finally: response.close()` to guarantee HTTP connections are returned to the session pool in all code paths (auth errors, JSON decode failures, unexpected exceptions)

openrouter_pipe.py

Lines changed: 74 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,21 @@ def _is_safe_url(url: str) -> bool:
7272
return isinstance(url, str) and url.lower().startswith(("http://", "https://"))
7373

7474

75+
def _is_owui_managed_icon(url: str) -> bool:
76+
"""Return True if the icon URL was set by OWUI or our sync logic.
77+
78+
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.
82+
"""
83+
return (
84+
not url
85+
or url.startswith("data:")
86+
or url.startswith("https://openrouter.ai/images/models/")
87+
)
88+
89+
7590
def _insert_citations(text: str, citations: Optional[List[str]]) -> str:
7691
"""Replace [n] references with markdown links (only safe HTTP URLs)."""
7792
if not citations or not text:
@@ -240,6 +255,11 @@ def __init__(self) -> None:
240255
self._models_cache_key: str = ""
241256
# Track which model IDs already have icons synced (avoids repeated DB writes)
242257
self._icons_synced: set = set()
258+
# Cache function_id once: OWUI sets __module__ to "function_{id}" at load time
259+
_fm = type(self).__module__ or ""
260+
self._function_id: Optional[str] = (
261+
_fm[len("function_"):] if _fm.startswith("function_") else None
262+
)
243263
if not self.valves.OPENROUTER_API_KEY:
244264
print("[OpenRouter Pipe] Warning: OPENROUTER_API_KEY not set")
245265

@@ -274,6 +294,11 @@ def pipes(self) -> List[dict]:
274294

275295
# Return cached models if still valid
276296
if self._models_cache_valid() and self._models_cache is not None:
297+
# Continue syncing icons on cache hits until all models are confirmed.
298+
# This resolves the race condition where OWUI registers models (and may
299+
# overwrite icons) only after the first pipes() call returns.
300+
if self.valves.SYNC_PROVIDER_ICONS and len(self._icons_synced) < len(self._models_cache):
301+
self._sync_model_icons(self._models_cache)
277302
return self._models_cache
278303

279304
headers = self._build_headers(include_content_type=False)
@@ -456,14 +481,18 @@ def _sync_model_icons(self, models: List[dict]) -> None:
456481
"""Write provider icons into Open WebUI's Models DB.
457482
458483
Open WebUI serves model icons from its database, not from the dicts
459-
returned by ``pipes()``. Crucially, Open WebUI prefixes every pipe
460-
model ID with ``{function_id}.`` (e.g. ``openrouter_pipe.openai/gpt-4o``)
461-
and the frontend requests icons using that prefixed ID. This method
462-
discovers the pipe's own *function_id* from ``type(self).__module__``
463-
(set to ``function_{id}`` by Open WebUI's module loader) and writes
464-
DB records keyed by the full prefixed ID.
465-
466-
Models that already have a custom icon in the DB are skipped.
484+
returned by ``pipes()``. OWUI prefixes every pipe model ID with
485+
``{function_id}.`` (e.g. ``openrouter_pipe.openai/gpt-4o``) and the
486+
frontend requests icons using that prefixed ID.
487+
488+
Called both on cache miss and on subsequent cache hits (until all
489+
models are confirmed synced). The cache-hit path is needed because
490+
OWUI registers models *after* ``pipes()`` returns, potentially
491+
overwriting any early insert with its own default icon; the second
492+
call finds the models already in DB and updates them correctly.
493+
494+
User-set custom icons (any URL that is not a ``data:`` URL and does not
495+
start with ``https://openrouter.ai/images/models/``) are preserved.
467496
This is a best-effort operation — failures are silently logged.
468497
"""
469498
try:
@@ -477,14 +506,10 @@ def _sync_model_icons(self, models: List[dict]) -> None:
477506
# Running outside Open WebUI (e.g. standalone tests) — skip silently
478507
return
479508

480-
# Discover the pipe's function_id. Open WebUI loads pipe modules as
481-
# ``function_{function_id}`` so type(self).__module__ exposes it.
482-
func_module = type(self).__module__ or ""
483-
if func_module.startswith("function_"):
484-
function_id = func_module[len("function_"):]
485-
else:
486-
# Cannot determine the pipe's function_id — skip icon sync
509+
# function_id was resolved once in __init__ from type(self).__module__
510+
if not self._function_id:
487511
return
512+
function_id = self._function_id
488513

489514
for model in models:
490515
model_id = model.get("id", "")
@@ -509,17 +534,28 @@ def _sync_model_icons(self, models: List[dict]) -> None:
509534
try:
510535
existing = Models.get_model_by_id(db_model_id)
511536
if existing:
512-
# If model already has a custom icon, don't overwrite it
513537
existing_icon = ""
514538
if hasattr(existing, "meta") and existing.meta:
515539
existing_icon = (
516540
getattr(existing.meta, "profile_image_url", "") or ""
517541
)
518-
if existing_icon:
542+
543+
# Skip if icon is already the correct provider URL
544+
if existing_icon == icon_url:
545+
self._icons_synced.add(model_id)
546+
continue
547+
548+
# Skip if icon was set by the user (not by OWUI or our sync).
549+
# data: URLs are OWUI defaults; openrouter.ai URLs are ours.
550+
if existing_icon and not _is_owui_managed_icon(existing_icon):
519551
self._icons_synced.add(model_id)
520552
continue
521553

522-
# Update existing model with icon
554+
# Proceed: icon is empty, an OWUI default, or one of our URLs
555+
# Update existing model with icon, preserving user-set params
556+
existing_params = ModelParams()
557+
if hasattr(existing, "params") and existing.params:
558+
existing_params = existing.params
523559
Models.update_model_by_id(
524560
db_model_id,
525561
ModelForm(
@@ -530,26 +566,34 @@ def _sync_model_icons(self, models: List[dict]) -> None:
530566
else model.get("name", model_id)
531567
),
532568
meta=ModelMeta(profile_image_url=icon_url),
533-
params=ModelParams(),
569+
params=existing_params,
534570
),
535571
)
536572
else:
537-
# Insert new model record with icon
538-
Models.insert_new_model(
539-
ModelForm(
540-
id=db_model_id,
541-
name=model.get("name", model_id),
542-
meta=ModelMeta(profile_image_url=icon_url),
543-
params=ModelParams(),
544-
),
545-
user_id="pipe:openrouter",
546-
)
573+
# Model not yet in DB — best-effort early insert.
574+
# OWUI will register models after pipes() returns and may
575+
# overwrite this record, so do NOT mark as synced here.
576+
# The next cache-hit call to _sync_model_icons will find the
577+
# model in DB and update it correctly.
578+
try:
579+
Models.insert_new_model(
580+
ModelForm(
581+
id=db_model_id,
582+
name=model.get("name", model_id),
583+
meta=ModelMeta(profile_image_url=icon_url),
584+
params=ModelParams(),
585+
),
586+
user_id="pipe:openrouter",
587+
)
588+
except Exception:
589+
pass
590+
continue # do not add to _icons_synced yet
547591

548592
self._icons_synced.add(model_id)
549593
except Exception as exc:
550594
# Best-effort — don't let icon sync break model listing
595+
# Do NOT add to _icons_synced: allow retry on next call
551596
print(f"[OpenRouter Pipe] Icon sync failed for {db_model_id}: {exc}")
552-
self._icons_synced.add(model_id) # Don't retry on every refresh
553597

554598
@staticmethod
555599
def get_provider_icon(provider: str) -> Optional[str]:

test_pipe.py

Lines changed: 65 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
_insert_citations = mod._insert_citations
4444
_format_citation_list = mod._format_citation_list
4545
_OWUI_INTERNAL_KEYS = mod._OWUI_INTERNAL_KEYS
46+
_is_owui_managed_icon = mod._is_owui_managed_icon
4647

4748
# ── Helpers ───────────────────────────────────────────────────────────────────
4849
_PASS = 0
@@ -1380,7 +1381,9 @@ async def _test_pipe_no_msgs_key():
13801381
# 25d. Valve default is True
13811382
_assert(Pipe.Valves(OPENROUTER_API_KEY="k").SYNC_PROVIDER_ICONS is True, "SYNC_PROVIDER_ICONS default is True")
13821383

1383-
# 25e. Non-function module → returns early without DB calls
1384+
# 25e. No function_id → returns early without DB calls
1385+
# function_id is cached in __init__; pipes created in tests have _function_id=None
1386+
# because the test module name doesn't start with "function_".
13841387
_pipe_nofunc = Pipe()
13851388
_pipe_nofunc.valves = Pipe.Valves(OPENROUTER_API_KEY="k", SYNC_PROVIDER_ICONS=True)
13861389
_mock_Models_nf = MagicMock()
@@ -1389,25 +1392,25 @@ async def _test_pipe_no_msgs_key():
13891392
_fake_owui_nf.ModelForm = MagicMock()
13901393
_fake_owui_nf.ModelMeta = MagicMock()
13911394
_fake_owui_nf.ModelParams = MagicMock()
1392-
_orig_module_nf = Pipe.__module__
13931395
try:
13941396
sys.modules["open_webui.models.models"] = _fake_owui_nf
1395-
# Module name doesn't start with "function_" → should skip
1396-
Pipe.__module__ = "openrouter_pipe"
1397+
# _pipe_nofunc._function_id is None (no "function_" prefix at init time) → skip
1398+
_assert(_pipe_nofunc._function_id is None, "_sync_model_icons: _function_id is None outside OWUI")
13971399
_pipe_nofunc._sync_model_icons([{"id": "openai/gpt-4o", "name": "GPT-4o"}])
13981400
_assert(
13991401
not _mock_Models_nf.get_model_by_id.called,
1400-
"_sync_model_icons: skips DB when module is not function_*",
1402+
"_sync_model_icons: skips DB when _function_id is None",
14011403
)
14021404
finally:
1403-
Pipe.__module__ = _orig_module_nf
14041405
sys.modules.pop("open_webui.models.models", None)
14051406

1406-
# 25f. With function_ module → uses prefixed IDs for DB operations
1407+
# 25f. With function_id set → uses prefixed IDs; does NOT add to _icons_synced after insert
1408+
# (OWUI may overwrite the record after pipes() returns; confirmed on next call)
14071409
_pipe_func = Pipe()
14081410
_pipe_func.valves = Pipe.Valves(OPENROUTER_API_KEY="k", SYNC_PROVIDER_ICONS=True)
1411+
_pipe_func._function_id = "openrouter_pipe" # simulate OWUI module naming
14091412
_mock_Models_f = MagicMock()
1410-
_mock_Models_f.get_model_by_id.return_value = None # No existing record
1413+
_mock_Models_f.get_model_by_id.return_value = None # No existing record yet
14111414
_mock_ModelForm_f = MagicMock()
14121415
_mock_ModelMeta_f = MagicMock()
14131416
_mock_ModelParams_f = MagicMock()
@@ -1416,10 +1419,8 @@ async def _test_pipe_no_msgs_key():
14161419
_fake_owui_f.ModelForm = _mock_ModelForm_f
14171420
_fake_owui_f.ModelMeta = _mock_ModelMeta_f
14181421
_fake_owui_f.ModelParams = _mock_ModelParams_f
1419-
_orig_module_f = Pipe.__module__
14201422
try:
14211423
sys.modules["open_webui.models.models"] = _fake_owui_f
1422-
Pipe.__module__ = "function_openrouter_pipe"
14231424
_pipe_func._sync_model_icons([
14241425
{"id": "openai/gpt-4o", "name": "GPT-4o"},
14251426
{"id": "anthropic/claude-3.5-sonnet", "name": "Claude 3.5"},
@@ -1440,9 +1441,6 @@ async def _test_pipe_no_msgs_key():
14401441
"_sync_model_icons: insert_new_model called for new models",
14411442
)
14421443
# Verify the ModelForm ID is prefixed
1443-
_insert_form = _mock_Models_f.insert_new_model.call_args_list[0].args[0]
1444-
_insert_id = _mock_ModelForm_f.call_args_list[0].kwargs.get("id", _mock_ModelForm_f.call_args_list[0].args[0] if _mock_ModelForm_f.call_args_list[0].args else "")
1445-
# ModelForm was called with id=prefixed_id
14461444
_form_calls = _mock_ModelForm_f.call_args_list
14471445
_form_ids = [c.kwargs.get("id", "") for c in _form_calls]
14481446
_assert(
@@ -1453,45 +1451,89 @@ async def _test_pipe_no_msgs_key():
14531451
"openrouter_pipe.anthropic/claude-3.5-sonnet" in _form_ids,
14541452
"_sync_model_icons: ModelForm uses prefixed ID (anthropic)",
14551453
)
1456-
# Verify _icons_synced tracks the RAW model IDs
1454+
# After insert (model not yet registered by OWUI), _icons_synced must NOT be updated.
1455+
# The next cache-hit call will confirm the icon is set correctly.
14571456
_assert(
1458-
"openai/gpt-4o" in _pipe_func._icons_synced,
1459-
"_sync_model_icons: _icons_synced uses raw model ID",
1457+
"openai/gpt-4o" not in _pipe_func._icons_synced,
1458+
"_sync_model_icons: _icons_synced NOT updated after insert (allows retry)",
14601459
)
14611460
finally:
1462-
Pipe.__module__ = _orig_module_f
14631461
sys.modules.pop("open_webui.models.models", None)
14641462

1465-
# 25g. Existing model with icon → skips overwrite
1463+
# 25g. Existing model with user custom icon → skips overwrite
14661464
_pipe_skip = Pipe()
14671465
_pipe_skip.valves = Pipe.Valves(OPENROUTER_API_KEY="k", SYNC_PROVIDER_ICONS=True)
1466+
_pipe_skip._function_id = "openrouter_pipe" # simulate OWUI module naming
14681467
_mock_Models_s = MagicMock()
14691468
_existing_model = MagicMock()
14701469
_existing_model.meta.profile_image_url = "https://custom-icon.example.com/icon.png"
14711470
_existing_model.name = "Custom GPT"
1471+
_existing_model.params = None
14721472
_mock_Models_s.get_model_by_id.return_value = _existing_model
14731473
_fake_owui_s = ModuleType("open_webui.models.models")
14741474
_fake_owui_s.Models = _mock_Models_s
14751475
_fake_owui_s.ModelForm = MagicMock()
14761476
_fake_owui_s.ModelMeta = MagicMock()
14771477
_fake_owui_s.ModelParams = MagicMock()
1478-
_orig_module_s = Pipe.__module__
14791478
try:
14801479
sys.modules["open_webui.models.models"] = _fake_owui_s
1481-
Pipe.__module__ = "function_openrouter_pipe"
14821480
_pipe_skip._sync_model_icons([{"id": "openai/gpt-4o", "name": "GPT-4o"}])
14831481
_assert(
14841482
not _mock_Models_s.update_model_by_id.called,
1485-
"_sync_model_icons: skips update when model has custom icon",
1483+
"_sync_model_icons: skips update when model has user custom icon",
14861484
)
14871485
_assert(
14881486
not _mock_Models_s.insert_new_model.called,
1489-
"_sync_model_icons: skips insert when model has custom icon",
1487+
"_sync_model_icons: skips insert when model has user custom icon",
1488+
)
1489+
_assert(
1490+
"openai/gpt-4o" in _pipe_skip._icons_synced,
1491+
"_sync_model_icons: adds to _icons_synced when skipping custom icon",
14901492
)
14911493
finally:
1492-
Pipe.__module__ = _orig_module_s
14931494
sys.modules.pop("open_webui.models.models", None)
14941495

1496+
# 25h. Existing model with OWUI default (data: URL) icon → updates with provider icon
1497+
_pipe_update = Pipe()
1498+
_pipe_update.valves = Pipe.Valves(OPENROUTER_API_KEY="k", SYNC_PROVIDER_ICONS=True)
1499+
_pipe_update._function_id = "openrouter_pipe" # simulate OWUI module naming
1500+
_mock_Models_u = MagicMock()
1501+
_existing_default = MagicMock()
1502+
_existing_default.name = "GPT-4o"
1503+
_existing_default.meta.profile_image_url = "data:image/svg+xml;base64,ABC123=="
1504+
_existing_default.params = None
1505+
_mock_Models_u.get_model_by_id.return_value = _existing_default
1506+
_fake_owui_u = ModuleType("open_webui.models.models")
1507+
_fake_owui_u.Models = _mock_Models_u
1508+
_fake_owui_u.ModelForm = MagicMock()
1509+
_fake_owui_u.ModelMeta = MagicMock()
1510+
_fake_owui_u.ModelParams = MagicMock()
1511+
try:
1512+
sys.modules["open_webui.models.models"] = _fake_owui_u
1513+
_pipe_update._sync_model_icons([{"id": "openai/gpt-4o", "name": "GPT-4o"}])
1514+
_assert(
1515+
_mock_Models_u.update_model_by_id.called,
1516+
"_sync_model_icons: updates model when icon is OWUI default (data: URL)",
1517+
)
1518+
_assert(
1519+
not _mock_Models_u.insert_new_model.called,
1520+
"_sync_model_icons: does not insert when model already exists",
1521+
)
1522+
_assert(
1523+
"openai/gpt-4o" in _pipe_update._icons_synced,
1524+
"_sync_model_icons: adds to _icons_synced after successful update",
1525+
)
1526+
finally:
1527+
sys.modules.pop("open_webui.models.models", None)
1528+
1529+
# 25i. _is_owui_managed_icon helper
1530+
_is_owui = mod._is_owui_managed_icon
1531+
_assert(_is_owui(""), "_is_owui_managed_icon: empty string → True (no icon)")
1532+
_assert(_is_owui("data:image/svg+xml;base64,ABC"), "_is_owui_managed_icon: data: URL → True")
1533+
_assert(_is_owui("https://openrouter.ai/images/models/openai.svg"), "_is_owui_managed_icon: openrouter.ai URL → True")
1534+
_assert(not _is_owui("https://custom-icon.example.com/icon.png"), "_is_owui_managed_icon: external URL → False")
1535+
_assert(not _is_owui("https://cdn.openai.com/logo.png"), "_is_owui_managed_icon: other https URL → False")
1536+
14951537
# ══════════════════════════════════════════════════════════════════════════════
14961538
# Summary
14971539
# ══════════════════════════════════════════════════════════════════════════════

0 commit comments

Comments
 (0)