fix(litellm): gate consumers on a served model, not on the gateway being deployed

The gateway publishes only models it has a backend for and renders model_list: [] when neither ollama nor lmstudio is deployed and no OpenRouter key is set. LITELLM_CHAT_MODEL fell back to openrouter/auto without checking that key, so consumers were configured for a model the gateway does not carry and every prompt failed with a 400.

Observed in the Mattermost#1 CI job: litellm.log carries 12 occurrences of "Invalid model name passed in model=openrouter/auto" next to 400 Bad Request, and tests/test-litellm-agent.js failed on every attempt while the rest of the suite passed.

Make the fallback conditional on the key so the name is empty when nothing is served, promote LITELLM_CHAT_MODEL_SERVED from the role vars to group_vars where consumers can see it (it was the third copy of this logic and the only correct one), and switch the 14 consumers from "litellm is deployed" to "litellm serves a model". The Playwright service flag reads the same lookup, so the spec skips together with the Ansible gate. The bool filter carries weight: the folded scalar yields the string False, which Jinja treats as truthy.

A new unit test renders both expressions against Jinja and pins the invariant that a model name exists exactly when a model is served, including the case where ollama preloads embedding models only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-09-04 19:48:32 +02:00
1 parent b41c61556e
commit e0048f157b
18 files changed
+129 -22

No files matched your search

+8 -1
View File
@@ -14,7 +14,14 @@ LITELLM_OPENAI_BASE_LOCAL_URL: "{{ LITELLM_BASE_LOCAL_URL }}/v1"
LITELLM_BACKEND_ROLES: "{{ lookup('deployment').groups }}"
LITELLM_CHAT_MODEL: "{{ (lookup('config', 'svc-ai-ollama', 'services.ollama.preload_models') | reject('search', 'embed') | list | first) if 'svc-ai-ollama' in LITELLM_BACKEND_ROLES
else ('lmstudio/default' if 'svc-ai-lmstudio' in LITELLM_BACKEND_ROLES else 'openrouter/auto') }}"
else ('lmstudio/default' if 'svc-ai-lmstudio' in LITELLM_BACKEND_ROLES else ('openrouter/auto' if ((OPENROUTER_API_KEY | string) | length > 0) else '')) }}"
LITELLM_CHAT_MODEL_SERVED: >-
{{ (('svc-ai-ollama' in LITELLM_BACKEND_ROLES)
and ((lookup('config', 'svc-ai-ollama', 'services.ollama.preload_models')
| reject('search', 'embed') | list) | length > 0))
or ('svc-ai-lmstudio' in LITELLM_BACKEND_ROLES)
or ((OPENROUTER_API_KEY | string) | length > 0) }}
OPENROUTER_API_KEY: ""
-7
View File
@@ -8,13 +8,6 @@ LITELLM_MASTER_KEY: "{{ lookup('config', application_id, 'secrets.credentials.ma
LITELLM_CONFIG_PATH_HOST: "{{ [ lookup('container', application_id, 'directories.config'), 'config.yaml' ] | path_join }}"
LITELLM_CONFIG_PATH_DOCKER: "{{ lookup('volume', application_id, 'litellm_config').target }}"
LITELLM_OPENROUTER_API_KEY: "{{ OPENROUTER_API_KEY }}"
LITELLM_CHAT_MODEL_SERVED: >-
{{ (('svc-ai-ollama' in LITELLM_BACKEND_ROLES)
and ((lookup('config', 'svc-ai-ollama', 'services.ollama.preload_models')
| reject('search', 'embed') | list) | length > 0))
or ('svc-ai-lmstudio' in LITELLM_BACKEND_ROLES)
or ((LITELLM_OPENROUTER_API_KEY | string) | length > 0) }}
LITELLM_UI_ROLE: "web-app-litellm"
LITELLM_UI_SSO: "{{ (LITELLM_UI_ROLE in group_names) and (lookup('sso', LITELLM_UI_ROLE, 'is_oidc_native') | bool) }}"
LITELLM_UI_BASE_URL: "{{ lookup('tls', LITELLM_UI_ROLE, 'url.base') }}"
+1 -1
View File
@@ -3,7 +3,7 @@
# nocheck: playwright-service-flag - shared gateway consumer; inference wiring is covered by the gateway role
litellm:
bond: 0.5
enabled: "{{ 'svc-ai-litellm' in group_names }}"
enabled: "{{ ('svc-ai-litellm' in group_names) and (LITELLM_CHAT_MODEL_SERVED | bool) }}"
shared: "{{ 'svc-ai-litellm' in group_names }}"
# nocheck: playwright-service-flag — static-asset provider, no Playwright surface
asset:
+1 -1
View File
@@ -31,7 +31,7 @@ ldap:
# nocheck: playwright-service-flag - shared gateway consumer; inference wiring is covered by the gateway role
litellm:
bond: 1
enabled: "{{ 'svc-ai-litellm' in group_names }}"
enabled: "{{ ('svc-ai-litellm' in group_names) and (LITELLM_CHAT_MODEL_SERVED | bool) }}"
shared: "{{ 'svc-ai-litellm' in group_names }}"
# nocheck: playwright-service-flag - DB engine, covered by role-local integration tests
qdrant:
+1 -1
View File
@@ -63,7 +63,7 @@ logout:
# nocheck: playwright-service-flag
litellm:
bond: 1
enabled: "{{ 'svc-ai-litellm' in group_names }}"
enabled: "{{ ('svc-ai-litellm' in group_names) and (LITELLM_CHAT_MODEL_SERVED | bool) }}"
shared: "{{ 'svc-ai-litellm' in group_names }}"
dashboard:
+1 -1
View File
@@ -1,7 +1,7 @@
---
litellm:
bond: 0.5
enabled: "{{ 'svc-ai-litellm' in group_names }}"
enabled: "{{ ('svc-ai-litellm' in group_names) and (LITELLM_CHAT_MODEL_SERVED | bool) }}"
shared: "{{ 'svc-ai-litellm' in group_names }}"
# nocheck: dockerfile-custom
sso:
+1 -1
View File
@@ -109,7 +109,7 @@ container_backup: # nocheck: bond-baseline backup host joins outside the variant
shared: true
litellm:
bond: 0.5
enabled: "{{ 'svc-ai-litellm' in group_names }}"
enabled: "{{ ('svc-ai-litellm' in group_names) and (LITELLM_CHAT_MODEL_SERVED | bool) }}"
shared: "{{ 'svc-ai-litellm' in group_names }}"
# nocheck: playwright-service-flag internal adapter sidecar, covered by the role's MCP spec
# nocheck: dockerfile-custom the Dockerfile is owned by svc-ai-mcp-adapter and staged per instance, so every adapter-backed provider shares one audited build
+1 -1
View File
@@ -2,7 +2,7 @@
# nocheck: playwright-service-flag - shared gateway consumer; inference wiring is covered by the gateway role
litellm:
bond: 0.5
enabled: "{{ 'svc-ai-litellm' in group_names }}"
enabled: "{{ ('svc-ai-litellm' in group_names) and (LITELLM_CHAT_MODEL_SERVED | bool) }}"
shared: "{{ 'svc-ai-litellm' in group_names }}"
# nocheck: seaweedfs-required — object storage not implemented for this role
sso:
+1 -1
View File
@@ -1,7 +1,7 @@
---
litellm:
bond: 0.5
enabled: "{{ 'svc-ai-litellm' in group_names }}"
enabled: "{{ ('svc-ai-litellm' in group_names) and (LITELLM_CHAT_MODEL_SERVED | bool) }}"
shared: "{{ 'svc-ai-litellm' in group_names }}"
# nocheck: seaweedfs-required — object storage not implemented for this role
sso:
+1 -1
View File
@@ -96,7 +96,7 @@ container_backup: # nocheck: bond-baseline backup host joins outside the variant
shared: true
litellm:
bond: 0.5
enabled: "{{ 'svc-ai-litellm' in group_names }}"
enabled: "{{ ('svc-ai-litellm' in group_names) and (LITELLM_CHAT_MODEL_SERVED | bool) }}"
shared: "{{ 'svc-ai-litellm' in group_names }}"
# nocheck: playwright-service-flag MCP pairing is proven by the role's own MCP spec, not by a per-consumer gate
openwebui:
+1 -1
View File
@@ -334,7 +334,7 @@ openwebui:
# nocheck: playwright-service-flag: bridged litellm gateway partner, covered by partner role spec
litellm:
bond: 0.5
enabled: "{{ 'svc-ai-litellm' in group_names }}"
enabled: "{{ ('svc-ai-litellm' in group_names) and (LITELLM_CHAT_MODEL_SERVED | bool) }}"
shared: "{{ 'svc-ai-litellm' in group_names }}"
# nocheck: playwright-service-flag: bridged mastodon partner, covered by partner role spec
mastodon:
+1 -1
View File
@@ -53,7 +53,7 @@ sso:
# nocheck: playwright-service-flag
litellm:
bond: 1
enabled: "{{ 'svc-ai-litellm' in group_names }}"
enabled: "{{ ('svc-ai-litellm' in group_names) and (LITELLM_CHAT_MODEL_SERVED | bool) }}"
shared: "{{ 'svc-ai-litellm' in group_names }}"
logout:
+1 -1
View File
@@ -84,7 +84,7 @@ javascript:
# nocheck: playwright-service-flag — AI backend, infrastructural
litellm:
bond: 1
enabled: "{{ 'svc-ai-litellm' in group_names }}"
enabled: "{{ ('svc-ai-litellm' in group_names) and (LITELLM_CHAT_MODEL_SERVED | bool) }}"
shared: "{{ 'svc-ai-litellm' in group_names }}"
email:
+1 -1
View File
@@ -2,7 +2,7 @@
# nocheck: playwright-service-flag - shared gateway consumer; inference wiring is covered by the gateway role
litellm:
bond: 0.5
enabled: "{{ 'svc-ai-litellm' in group_names }}"
enabled: "{{ ('svc-ai-litellm' in group_names) and (LITELLM_CHAT_MODEL_SERVED | bool) }}"
shared: "{{ 'svc-ai-litellm' in group_names }}"
# nocheck: seaweedfs-required, object storage not implemented for this role
sso:
+1 -1
View File
@@ -1,7 +1,7 @@
---
litellm:
bond: 0.5
enabled: "{{ 'svc-ai-litellm' in group_names }}"
enabled: "{{ ('svc-ai-litellm' in group_names) and (LITELLM_CHAT_MODEL_SERVED | bool) }}"
shared: "{{ 'svc-ai-litellm' in group_names }}"
# nocheck: seaweedfs-required — object storage not implemented for this role
sso:
+1 -1
View File
@@ -1,7 +1,7 @@
---
litellm:
bond: 0.5
enabled: "{{ 'svc-ai-litellm' in group_names }}"
enabled: "{{ ('svc-ai-litellm' in group_names) and (LITELLM_CHAT_MODEL_SERVED | bool) }}"
shared: "{{ 'svc-ai-litellm' in group_names }}"
# nocheck: seaweedfs-required - object storage not implemented for this role
sso:
Whitespace-only changes.
@@ -0,0 +1,107 @@
import re
import unittest
from pathlib import Path
from typing import ClassVar
from jinja2 import Environment, StrictUndefined, select_autoescape
from utils.cache.files import PROJECT_ROOT
from utils.cache.yaml import load_yaml
_AI_VARS = Path(PROJECT_ROOT) / "group_vars" / "all" / "16_ai.yml"
def _stub_lookup(preload_models):
def lookup(kind, role, path, *args):
if (kind, path) == ("config", "services.ollama.preload_models"):
return preload_models
raise AssertionError(f"unexpected lookup({kind!r}, {role!r}, {path!r})")
return lookup
class TestLitellmChatModel(unittest.TestCase):
"""The gateway only publishes a model it has a backend for
(roles/svc-ai-litellm/templates/config.yaml.j2). These expressions decide
what consumers are told to ask for, so naming an unpublished model makes
every prompt fail with a 400.
"""
env: ClassVar[Environment]
source: ClassVar[dict]
@classmethod
def setUpClass(cls):
cls.env = Environment(undefined=StrictUndefined, autoescape=select_autoescape())
cls.env.tests["search"] = lambda value, pattern: bool(
re.search(pattern, str(value))
)
cls.source = load_yaml(_AI_VARS)
def _render(self, name, *, roles, api_key, preload_models=()):
return (
self.env.from_string(self.source[name])
.render(
LITELLM_BACKEND_ROLES=list(roles),
OPENROUTER_API_KEY=api_key,
lookup=_stub_lookup(list(preload_models)),
)
.strip()
)
def _both(self, **kwargs):
return (
self._render("LITELLM_CHAT_MODEL", **kwargs),
self._render("LITELLM_CHAT_MODEL_SERVED", **kwargs),
)
def test_no_backend_and_no_key_names_no_model(self):
model, served = self._both(roles=["web-app-mattermost"], api_key="")
self.assertEqual(model, "")
self.assertEqual(served, "False")
def test_no_backend_with_a_key_uses_openrouter(self):
model, served = self._both(roles=["web-app-mattermost"], api_key="sk-test")
self.assertEqual(model, "openrouter/auto")
self.assertEqual(served, "True")
def test_lmstudio_wins_over_the_openrouter_fallback(self):
model, served = self._both(roles=["svc-ai-lmstudio"], api_key="")
self.assertEqual(model, "lmstudio/default")
self.assertEqual(served, "True")
def test_ollama_serves_its_first_non_embedding_model(self):
model, served = self._both(
roles=["svc-ai-ollama"],
api_key="",
preload_models=["nomic-embed-text", "llama3.2"],
)
self.assertEqual(model, "llama3.2")
self.assertEqual(served, "True")
def test_ollama_with_only_embedding_models_serves_nothing(self):
served = self._render(
"LITELLM_CHAT_MODEL_SERVED",
roles=["svc-ai-ollama"],
api_key="",
preload_models=["nomic-embed-text"],
)
self.assertEqual(served, "False")
def test_the_model_is_named_exactly_when_one_is_served(self):
cases = (
(["web-app-mattermost"], "", ()),
(["web-app-mattermost"], "sk-test", ()),
(["svc-ai-lmstudio"], "", ()),
(["svc-ai-ollama"], "", ("llama3.2",)),
)
for roles, api_key, preload in cases:
with self.subTest(roles=roles, api_key=bool(api_key)):
model, served = self._both(
roles=roles, api_key=api_key, preload_models=preload
)
self.assertEqual(bool(model), served == "True")
if __name__ == "__main__":
unittest.main()