feat(svc-ai-litellm): assert every consumer keeps inference inside the deployment

A role that enables its native AI surface can reach the gateway with an empty
credential, or point its base URL at something that resolves outside the
cluster, and still deploy green. Both are silent: the surface simply answers
from somewhere else, or not at all. This adds the shared contract that makes
either state fail the deploy, so a consuming role inherits one definition of
"inside the deployment" instead of restating it.

The key assertion refuses an empty credentials.litellm_api_key while
services.litellm is enabled. Without it the gateway leg is unauthenticated and
nothing in the run says so.

The URL assertion is four independent checks, each able to fail on its own:
the scheme is plain http, the hostname carries no dot, the hostname is not the
consuming role's own domain, and the port is the gateway's internal port rather
than its published one. The undotted check is the load-bearing one. The
container dns-search suffix completes a dotted host into the public FQDN, so
`litellm.infinito.example` leaves through the ingress while looking local in
the config; only a bare service name stays on the container network. The
own-domain check catches a role that points its AI surface back at itself, and
the internal-port check catches a config that would work only via the host
bind.

An earlier revision of this file compared two values that both came from the
same lookup and therefore could never fail. Every check here is falsifiable
against the value the role actually deploys.

group_vars carries the SPOT the contract measures against.
LITELLM_OPENAI_BASE_LOCAL_URL appends the OpenAI-compatible /v1 suffix once,
rather than in each role. LITELLM_CHAT_MODEL derives the model from whichever
backend is deployed -- an ollama preload model with embedding models rejected,
else lmstudio, else openrouter -- resolved through `lookup('deployment').running`
so it reflects what is running rather than what the inventory could contain.

Contract and vars land before the roles that include them; the reverse order
would commit roles referencing a file that does not exist yet.

Verified: the contract's assertions ran green at runtime for matrix,
mattermost, moodle and zammad. Zammad's is the strongest evidence, since
`Setting.set('ai_provider_config')` triggers upstream
`AI::Provider::CustomOpenAI.ping!` and therefore a live gateway round trip.
`make quality` green on test-lint, test-unit, test-integration, test-external.
Not covered: nothing yet forces a role that declares services.litellm to
include this contract, so a future consumer can still skip it silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-08-24 12:30:54 +02:00
1 parent 97fae6d3db
commit 46a16127fc
2 files changed
+32

No files matched your search

+7
View File
@@ -9,6 +9,13 @@ LITELLM_BASE_LOCAL_URL: "http://{{ lookup('config','svc-ai-litellm', 'services.l
LMSTUDIO_BASE_LOCAL_URL: "http://{{ lookup('config','svc-ai-lmstudio', 'services.lmstudio.name') }}:{{ lookup('config','svc-ai-lmstudio', 'services.lmstudio.ports.internal.http')
}}"
LITELLM_OPENAI_BASE_LOCAL_URL: "{{ LITELLM_BASE_LOCAL_URL }}/v1"
LITELLM_BACKEND_ROLES: "{{ lookup('deployment').running }}"
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') }}"
OPENROUTER_API_KEY: ""
MCP_DISCOVERY: "{{ lookup('mcp_servers') }}"
@@ -0,0 +1,25 @@
---
- name: "✅ Assert '{{ application_id }}' carries a gateway virtual key"
ansible.builtin.assert:
that:
- (lookup('config', application_id, 'credentials.litellm_api_key', '') | trim) | length > 0
fail_msg: >-
{{ application_id }} enables services.litellm but its
credentials.litellm_api_key is empty, so its native AI surface would
reach the gateway unauthenticated.
- name: "✅ Assert the AI base URL of '{{ application_id }}' stays inside the deployment"
ansible.builtin.assert:
that:
- (LITELLM_BASE_LOCAL_URL | urlsplit('scheme')) == 'http'
- "'.' not in (LITELLM_BASE_LOCAL_URL | urlsplit('hostname'))"
- (LITELLM_BASE_LOCAL_URL | urlsplit('hostname')) != lookup('domain', application_id)
- (LITELLM_BASE_LOCAL_URL | urlsplit('port') | int) == (lookup('config', 'svc-ai-litellm', 'services.litellm.ports.internal.http') | int)
fail_msg: >-
{{ application_id }} would send inference traffic to
'{{ LITELLM_BASE_LOCAL_URL }}'. Only the container-network gateway is
allowed: plain http, an undotted service name (the dns-search suffix
turns a dotted host into the public FQDN and routes it through the
ingress), a host that is not this role's own domain, and the gateway's
internal port {{ lookup('config', 'svc-ai-litellm', 'services.litellm.ports.internal.http') }}
rather than its published one.