mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
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:
1 parent
b41c61556e
commit
e0048f157b
18 files changed
+129
-22
No files matched your search
@@ -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: ""
|
||||
|
||||
|
||||
@@ -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') }}"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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,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()
|
||||
Reference in new issue
Block a user