From 46967ade6855e424bc96f77fb36e3c83b8bc07d7 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sun, 20 Sep 2026 16:23:25 +0200 Subject: [PATCH] feat(engines): list LiteLLM router aliases in the model dropdown Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 3 +++ routes/conversation.py | 25 ++++++++++++++++++++++++- tests/test_conversation.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9a7296..f512233 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] +### Added +- **LiteLLM model-group aliases in the model list.** The Active Language Model / OCR model dropdown now also lists the gateway's router aliases (ocr, default, cheap, vision, local-stt …), which LiteLLM's `/models` omits; they are read from `/router/settings` and shown first. Gateways without that endpoint are unaffected. + ### Changed - **Searchable model dropdown** for the Active Language Model and Active OCR Model pickers (Engines page): the long native model list is now the same type-to-search dropdown used for voices, with model-specific wording and no avatars. diff --git a/routes/conversation.py b/routes/conversation.py index a9a1680..8bedc48 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -5030,6 +5030,28 @@ async def mcp_sse(request: Request): # ── Conversation playground ─────────────────────────────────────────────────── +def _litellm_model_aliases(base: str, key: str) -> list[str]: + """Names of a LiteLLM gateway's router aliases (Model Group Alias), which its /models list omits. + + Best effort: the settings endpoint needs an admin-capable key, and other gateways do not have it. + """ + root = base[:-3] if base.endswith("/v1") else base + try: + r = requests.get(f"{root}/router/settings", timeout=5, headers={"Authorization": f"Bearer {key}"}) + if r.status_code != 200: + return [] + data = r.json() + except Exception: + return [] + if not isinstance(data, dict): + return [] + for holder in (data.get("current_values"), data.get("router_settings"), data): + aliases = holder.get("model_group_alias") if isinstance(holder, dict) else None + if isinstance(aliases, dict): + return [str(name) for name in aliases if name] + return [] + + @router.get("/api/conversation/llm-models") async def conversation_llm_models( url: str = "", @@ -5058,7 +5080,8 @@ async def conversation_llm_models( str(item["id"]) if isinstance(item, dict) and item.get("id") else str(item) for item in data if item ] - return {"models": models, "url": base} + aliases = [a for a in _litellm_model_aliases(base, key_to_use) if a not in models] + return {"models": aliases + models, "url": base, "aliases": aliases} else: return {"models": [], "url": base, "error": r.text} except Exception as e: diff --git a/tests/test_conversation.py b/tests/test_conversation.py index 3a60636..8178e24 100644 --- a/tests/test_conversation.py +++ b/tests/test_conversation.py @@ -290,3 +290,39 @@ class CharacterSheetsEmptyAnswerTests(unittest.TestCase): with mock.patch.object(conv, "_post_llm_chat_completion", return_value=self._resp(good)) as post: client.post("/api/character-sheets", json={"text": "A kam.", "known_characters": ["A"]}) self.assertEqual(post.call_count, 1) + + +class LlmModelAliasTests(unittest.TestCase): + def _get(self, responses): + from unittest import mock + from fastapi import FastAPI + from fastapi.testclient import TestClient + import routes.conversation as conv + app = FastAPI(); app.include_router(conv.router) + + def fake_get(url, **kw): + r = mock.Mock() + status, body = responses.get(url.split("://", 1)[1].split("/", 1)[1], (404, {})) + r.status_code = status; r.json.return_value = body; r.text = str(body) + return r + with mock.patch.object(conv.requests, "get", side_effect=fake_get): + return TestClient(app).get("/api/conversation/llm-models", params={"url": "http://192.168.1.5:4000"}).json() + + def test_router_aliases_are_listed_before_the_models(self): + out = self._get({ + "models": (200, {"data": [{"id": "gpt-x"}]}), + "router/settings": (200, {"current_values": {"model_group_alias": {"ocr": "a", "default": "b"}}}), + }) + self.assertEqual(out["models"], ["ocr", "default", "gpt-x"]) + self.assertEqual(out["aliases"], ["ocr", "default"]) + + def test_a_gateway_without_alias_settings_still_lists_its_models(self): + out = self._get({"models": (200, {"data": [{"id": "m1"}, {"id": "m2"}]})}) + self.assertEqual(out["models"], ["m1", "m2"]) + + def test_an_alias_that_is_already_a_model_is_not_repeated(self): + out = self._get({ + "models": (200, {"data": [{"id": "reasoning"}]}), + "router/settings": (200, {"model_group_alias": {"reasoning": "x", "cheap": "y"}}), + }) + self.assertEqual(out["models"], ["cheap", "reasoning"])