1212"""
1313
1414import copy
15+ import hashlib
1516import json
1617import os
1718import random
4142# Provider icons — synced into the Open WebUI Models database by
4243# _sync_model_icons() so the frontend can serve them via
4344# /models/model/profile/image. Disable with SYNC_PROVIDER_ICONS = False.
45+ # URLs verified against https://openrouter.ai/images/icons/ (May 2025).
4446_PROVIDER_ICONS = {
45- "openai" : "https://openrouter.ai/images/models/openai.svg" ,
46- "anthropic" : "https://openrouter.ai/images/models/anthropic.svg" ,
47- "google" : "https://openrouter.ai/images/models/google.svg" ,
48- "meta-llama" : "https://openrouter.ai/images/models/meta.svg" ,
49- "mistralai" : "https://openrouter.ai/images/models/mistralai.svg" ,
50- "amazon" : "https://openrouter.ai/images/models/amazon.svg" ,
51- "deepseek" : "https://openrouter.ai/images/models/deepseek.svg" ,
52- "x-ai" : "https://openrouter.ai/images/models/xai.svg" ,
53- "cohere" : "https://openrouter.ai/images/models/cohere.svg" ,
54- "perplexity" : "https://openrouter.ai/images/models/perplexity.svg" ,
55- "allenai" : "https://openrouter.ai/images/models/allenai.svg" ,
56- "qwen" : "https://openrouter.ai/images/models/qwen.svg" ,
57- "nvidia" : "https://openrouter.ai/images/models/nvidia.svg" ,
58- "databricks" : "https://openrouter.ai/images/models/databricks.svg" ,
59- "microsoft" : "https://openrouter.ai/images/models/microsoft.svg" ,
60- "together" : "https://openrouter.ai/images/models/together.svg" ,
61- "fireworks" : "https://openrouter.ai/images/models/fireworks.svg" ,
62- "sambanova" : "https://openrouter.ai/images/models/sambanova.svg" ,
63- "cerebras" : "https://openrouter.ai/images/models/cerebras.svg" ,
64- "groq" : "https://openrouter.ai/images/models/groq.svg" ,
65- "inflection" : "https://openrouter.ai/images/models/inflection.svg" ,
66- "01-ai" : "https://openrouter.ai/images/models/01ai.svg" ,
47+ "openai" : "https://openrouter.ai/images/icons/OpenAI.svg" ,
48+ "anthropic" : "https://openrouter.ai/images/icons/Anthropic.svg" ,
49+ "google" : "https://openrouter.ai/images/icons/GoogleGemini.svg" ,
50+ "meta-llama" : "https://openrouter.ai/images/icons/Meta.png" ,
51+ "mistralai" : "https://openrouter.ai/images/icons/Mistral.png" ,
52+ "amazon" : "https://openrouter.ai/images/icons/Bedrock.svg" ,
53+ "deepseek" : "https://openrouter.ai/images/icons/DeepSeek.png" ,
54+ "cohere" : "https://openrouter.ai/images/icons/Cohere.png" ,
55+ "perplexity" : "https://openrouter.ai/images/icons/Perplexity.svg" ,
56+ "qwen" : "https://openrouter.ai/images/icons/Qwen.png" ,
57+ "microsoft" : "https://openrouter.ai/images/icons/Microsoft.svg" ,
58+ "fireworks" : "https://openrouter.ai/images/icons/Fireworks.png" ,
59+ "moonshotai" : "https://openrouter.ai/images/icons/MoonshotAI.png" ,
6760}
6861
6962
@@ -76,14 +69,16 @@ def _is_owui_managed_icon(url: str) -> bool:
7669 """Return True if the icon URL was set by OWUI or our sync logic.
7770
7871 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.
72+ manifold child models. openrouter.ai/images/models/ and
73+ openrouter.ai/images/icons/ are the provider icon paths we write (the
74+ former was the old path, now superseded by the latter). Any other URL is
75+ assumed to be a user-set custom icon and must not be overwritten.
8276 """
8377 return (
8478 not url
8579 or url .startswith ("data:" )
8680 or url .startswith ("https://openrouter.ai/images/models/" )
81+ or url .startswith ("https://openrouter.ai/images/icons/" )
8782 )
8883
8984
@@ -340,12 +335,28 @@ def chat_url(self) -> str:
340335 """Return the full URL for the chat completions endpoint."""
341336 return f"{ self ._base } { _API_PATH_CHAT } "
342337
338+ def _build_cache_key (self ) -> str :
339+ """Build a fingerprint of the valves that affect the model list.
340+
341+ The API key is hashed (not embedded raw) so it doesn't sit in plaintext
342+ in long-lived strings that may end up in logs or memory dumps.
343+ """
344+ api_key_hash = (
345+ hashlib .sha256 (self .valves .OPENROUTER_API_KEY .encode ("utf-8" )).hexdigest ()[:16 ]
346+ if self .valves .OPENROUTER_API_KEY
347+ else ""
348+ )
349+ return (
350+ f"{ api_key_hash } |{ self .valves .FREE_ONLY } |"
351+ f"{ self .valves .MODEL_PROVIDERS } |{ self .valves .INVERT_PROVIDER_LIST } |"
352+ f"{ self .valves .MODEL_PREFIX } "
353+ )
354+
343355 def _models_cache_valid (self ) -> bool :
344356 """Check if the cached model list is still valid."""
345357 if not self ._models_cache :
346358 return False
347- key = f"{ self .valves .OPENROUTER_API_KEY } |{ self .valves .FREE_ONLY } |{ self .valves .MODEL_PROVIDERS } |{ self .valves .INVERT_PROVIDER_LIST } |{ self .valves .MODEL_PREFIX } "
348- if key != self ._models_cache_key :
359+ if self ._build_cache_key () != self ._models_cache_key :
349360 return False
350361 return (time .monotonic () - self ._models_cache_ts ) < _MODELS_CACHE_TTL
351362
@@ -428,9 +439,11 @@ def pipes(self) -> List[dict]:
428439 if not is_free :
429440 continue
430441
431- # Split model_id once for provider extraction
442+ # Split model_id once for provider extraction.
443+ # Strip leading '~' (OpenRouter "latest" aliases like ~anthropic/claude-haiku-latest)
444+ # so they match the same provider filter as their base provider.
432445 parts = model_id .split ("/" , 1 )
433- provider_key = parts [0 ].lower () if len (parts ) > 1 else "openrouter"
446+ provider_key = parts [0 ].lstrip ( "~" ). lower () if len (parts ) > 1 else "openrouter"
434447
435448 if provider_filter :
436449 keep = (provider_key in provider_filter ) ^ self .valves .INVERT_PROVIDER_LIST
@@ -459,7 +472,7 @@ def pipes(self) -> List[dict]:
459472 # Store in cache
460473 self ._models_cache = models
461474 self ._models_cache_ts = time .monotonic ()
462- self ._models_cache_key = f" { self .valves . OPENROUTER_API_KEY } | { self . valves . FREE_ONLY } | { self . valves . MODEL_PROVIDERS } | { self . valves . INVERT_PROVIDER_LIST } | { self . valves . MODEL_PREFIX } "
475+ self ._models_cache_key = self ._build_cache_key ()
463476
464477 # Sync provider icons into Open WebUI's Models database
465478 if self .valves .SYNC_PROVIDER_ICONS :
@@ -469,10 +482,21 @@ def pipes(self) -> List[dict]:
469482
470483 @staticmethod
471484 def _clean_model_id (model_id : str ) -> str :
472- """Strip the manifold prefix from a model ID."""
473- if "." in model_id :
474- return model_id .split ("." , 1 )[- 1 ]
475- return model_id
485+ """Strip the manifold prefix from a model ID.
486+
487+ OpenRouter model IDs use the format ``provider/model`` (e.g.
488+ ``anthropic/claude-3.5-sonnet``). The manifold prefix added by Open
489+ WebUI is a function id without ``/`` (e.g. ``openrouter_pipe``). We
490+ only strip when the text before the first ``.`` contains no ``/`` —
491+ otherwise the dot is part of the model version (e.g.
492+ ``claude-3.5-sonnet``) and must be preserved.
493+ """
494+ if "." not in model_id :
495+ return model_id
496+ prefix , rest = model_id .split ("." , 1 )
497+ if "/" in prefix :
498+ return model_id
499+ return rest
476500
477501 async def pipe (
478502 self ,
@@ -582,17 +606,44 @@ def _sync_model_icons(self, models: List[dict]) -> None:
582606 if model_id in self ._icons_synced :
583607 continue
584608
585- # Determine provider icon
609+ # Determine provider icon. Strip '~' so latest aliases (e.g.
610+ # ~anthropic/claude-haiku-latest) resolve to the correct icon.
586611 parts = model_id .split ("/" , 1 )
587- provider_key = parts [0 ].lower () if len (parts ) > 1 else ""
612+ provider_key = parts [0 ].lstrip ( "~" ). lower () if len (parts ) > 1 else ""
588613 icon_url = _PROVIDER_ICONS .get (provider_key )
614+ # Build the prefixed ID that Open WebUI uses in the frontend
615+ db_model_id = f"{ function_id } .{ model_id } "
616+
589617 if not icon_url :
618+ # No icon for this provider. If the DB holds one of our old
619+ # broken /images/models/ URLs, clear it so OWUI shows its
620+ # default icon rather than a broken image.
621+ try :
622+ existing = Models .get_model_by_id (db_model_id )
623+ if existing and hasattr (existing , "meta" ) and existing .meta :
624+ stale = getattr (existing .meta , "profile_image_url" , "" ) or ""
625+ if stale .startswith ("https://openrouter.ai/images/models/" ):
626+ existing_params = ModelParams ()
627+ if hasattr (existing , "params" ) and existing .params :
628+ existing_params = existing .params
629+ Models .update_model_by_id (
630+ db_model_id ,
631+ ModelForm (
632+ id = db_model_id ,
633+ name = (
634+ existing .name
635+ if hasattr (existing , "name" )
636+ else model .get ("name" , model_id )
637+ ),
638+ meta = ModelMeta (profile_image_url = "" ),
639+ params = existing_params ,
640+ ),
641+ )
642+ except Exception :
643+ pass
590644 self ._icons_synced .add (model_id )
591645 continue
592646
593- # Build the prefixed ID that Open WebUI uses in the frontend
594- db_model_id = f"{ function_id } .{ model_id } "
595-
596647 try :
597648 existing = Models .get_model_by_id (db_model_id )
598649 if existing :
@@ -685,8 +736,7 @@ def _prepare_payload(self, body: dict) -> dict:
685736 payload .pop (key , None )
686737
687738 # Open WebUI sends 'user' as dict; OpenRouter expects a string
688- user_field = payload .get ("user" )
689- if isinstance (user_field , dict ):
739+ if isinstance (payload .get ("user" ), dict ):
690740 payload .pop ("user" , None )
691741
692742 # Fix model ID (strip manifold prefix)
@@ -960,7 +1010,9 @@ def _close_think_tag():
9601010 if response is not None :
9611011 response .close ()
9621012
963- def _retryable_request (self , headers : dict , payload : dict , stream : bool ):
1013+ def _retryable_request (
1014+ self , headers : dict , payload : dict , stream : bool
1015+ ) -> requests .Response :
9641016 """Send a POST request with automatic retry and exponential backoff."""
9651017 last_exc : Optional [Exception ] = None
9661018 for attempt in range (self .valves .MAX_RETRIES + 1 ):
0 commit comments