diff --git a/README.md b/README.md index 28679a008f..0f33c3c546 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ Every invokable role in the tested lifecycle envelope, with its upstream homepag | [Odoo ERP](roles/web-app-odoo/) | beta | Open-source ERP suite for CRM, sales, accounting, inventory, HR, and website management | [🌐](https://www.odoo.com/) [🎬](https://www.odoo.com/slides/getting-started-15) [🛠️](roles/web-app-odoo/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Keycloak](roles/web-app-keycloak/), [Mailu](roles/web-app-mailu/), [Matomo](roles/web-app-matomo/), [OpenLDAP](roles/svc-db-openldap/), [PostgreSQL](roles/svc-db-postgres/), [Prometheus](roles/web-app-prometheus/), [Redis](roles/svc-db-redis/), [Universal Logout](roles/web-svc-logout/) | | [Ollama](roles/svc-ai-ollama/) | beta | Self-hosted model server for running open LLMs locally behind a simple HTTP API | [🌐](https://ollama.com/) [🛠️](roles/svc-ai-ollama/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/) | | [OnlyOffice](roles/web-svc-onlyoffice/) | beta | Document server backend for real-time in-browser editing of documents, spreadsheets, and presentations | [🌐](https://www.onlyoffice.com/) [🎬](https://www.youtube.com/channel/UCNxeQm7vVujR8eFPtpVAVNg) [🛠️](roles/web-svc-onlyoffice/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Corporate Design](roles/web-svc-css/), [Matomo](roles/web-app-matomo/), [PostgreSQL](roles/svc-db-postgres/), [Prometheus](roles/web-app-prometheus/), [Redis](roles/svc-db-redis/) | -| [Open WebUI](roles/web-app-openwebui/) | beta | Self-hosted chat interface for interacting with local and private AI models via backends such as Ollama | [🌐](https://openwebui.com/) [🛠️](roles/web-app-openwebui/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Keycloak](roles/web-app-keycloak/), [Mailu](roles/web-app-mailu/), [Matomo](roles/web-app-matomo/), [Ollama](roles/svc-ai-ollama/), [OpenLDAP](roles/svc-db-openldap/), [Prometheus](roles/web-app-prometheus/), [SeaweedFS](roles/web-app-seaweedfs/), [Universal Logout](roles/web-svc-logout/) | +| [Open WebUI](roles/web-app-openwebui/) | beta | Self-hosted chat interface for interacting with local and private AI models via backends such as Ollama | [🌐](https://openwebui.com/) [🛠️](roles/web-app-openwebui/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Keycloak](roles/web-app-keycloak/), [LiteLLM Gateway](roles/svc-ai-litellm/), [Mailu](roles/web-app-mailu/), [Matomo](roles/web-app-matomo/), [OpenLDAP](roles/svc-db-openldap/), [Prometheus](roles/web-app-prometheus/), [SeaweedFS](roles/web-app-seaweedfs/), [Universal Logout](roles/web-svc-logout/) | | [OpenClaw](roles/web-app-openclaw/) | beta | Self-hosted personal AI agent gateway with a browser control UI, sandboxed in a Kata microVM or gVisor runtime | [🌐](https://openclaw.ai/) [🛠️](roles/web-app-openclaw/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Kata & gVisor](roles/svc-virt-kata/), [Keycloak](roles/web-app-keycloak/), [LiteLLM Gateway](roles/svc-ai-litellm/), [Matomo](roles/web-app-matomo/), [Prometheus](roles/web-app-prometheus/), [Universal Logout](roles/web-svc-logout/) | | [OpenCloud](roles/web-app-opencloud/) | beta | Self-hosted platform for file sync, sharing, and collaboration with Keycloak SSO and central LDAP directory | [🌐](https://opencloud.eu/) [🎬](https://www.youtube.com/watch?v=Cg48rFNY0R4) [🛠️](roles/web-app-opencloud/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Keycloak](roles/web-app-keycloak/), [Mailu](roles/web-app-mailu/), [Matomo](roles/web-app-matomo/), [OpenLDAP](roles/svc-db-openldap/), [Prometheus](roles/web-app-prometheus/), [Universal Logout](roles/web-svc-logout/) | | [OpenLDAP](roles/svc-db-openldap/) | beta | Self-hosted LDAP directory service for centralized user, group, and schema management with TLS-secured access | [🌐](https://www.openldap.org/) [🛠️](roles/svc-db-openldap/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/) | diff --git a/roles/svc-ai-litellm/README.md b/roles/svc-ai-litellm/README.md index 16ae6f0c41..267e34168f 100644 --- a/roles/svc-ai-litellm/README.md +++ b/roles/svc-ai-litellm/README.md @@ -29,6 +29,7 @@ flowchart LR dpt_web_app_hermes["web-app-hermes 🐳🐝"] dpt_web_app_nextcloud["web-app-nextcloud 🐳🐝"] dpt_web_app_openclaw["web-app-openclaw 🐳🐝"] + dpt_web_app_openwebui["web-app-openwebui 🐳🐝"] end dep_svc_ai_lmstudio -. "0..1" .-> svc_lmstudio dep_svc_ai_ollama -. "0..1" .-> svc_ollama @@ -36,6 +37,7 @@ flowchart LR svc_litellm -. "0..1" .-> dpt_web_app_hermes svc_litellm -. "0..1" .-> dpt_web_app_nextcloud svc_litellm -. "0..1" .-> dpt_web_app_openclaw + svc_litellm -. "0..1" .-> dpt_web_app_openwebui ``` Solid `1:1` edges are fixed relationships; dashed `0..1` edges are conditional (enabled only in matching deployments). Node markers show the role's deploy modes (💻 host, 🐳 compose, 🐝 swarm); ❌ marks a service that is explicitly turned off, and ⚙️ an Ansible role dependency declared in `meta/main.yml`. diff --git a/roles/svc-ai-litellm/templates/config.yaml.j2 b/roles/svc-ai-litellm/templates/config.yaml.j2 index ee3f4a06f2..b026804226 100644 --- a/roles/svc-ai-litellm/templates/config.yaml.j2 +++ b/roles/svc-ai-litellm/templates/config.yaml.j2 @@ -1,17 +1,13 @@ -{% set has_backend = ('svc-ai-ollama' in group_names) or ('svc-ai-lmstudio' in group_names) or LITELLM_OPENROUTER_API_KEY %} +{% set _ollama_models = lookup('config', 'svc-ai-ollama', 'services.ollama.preload_models') if 'svc-ai-ollama' in group_names else [] %} +{% set has_backend = _ollama_models or ('svc-ai-lmstudio' in group_names) or LITELLM_OPENROUTER_API_KEY %} model_list:{{ ' []' if not has_backend else '' }} -{% if 'svc-ai-ollama' in group_names %} - - model_name: ollama/llama3 +{% for _model in _ollama_models %} + - model_name: "{{ _model }}" litellm_params: - model: ollama/llama3 + model: "ollama/{{ _model }}" api_base: {{ OLLAMA_BASE_LOCAL_URL }} rpm: 1000 - - model_name: ollama/nomic-embed-text - litellm_params: - model: ollama/nomic-embed-text - api_base: {{ OLLAMA_BASE_LOCAL_URL }} - rpm: 1000 -{% endif %} +{% endfor %} {% if 'svc-ai-lmstudio' in group_names %} - model_name: lmstudio/default litellm_params: diff --git a/roles/svc-ai-lmstudio/meta/variants.yml b/roles/svc-ai-lmstudio/meta/variants.yml index 11ef6cce28..5a12caf234 100644 --- a/roles/svc-ai-lmstudio/meta/variants.yml +++ b/roles/svc-ai-lmstudio/meta/variants.yml @@ -5,3 +5,6 @@ - services: container_backup: enabled: true + lmstudio: + mem_reservation: 1g + mem_limit: 2g diff --git a/roles/svc-ai-ollama/README.md b/roles/svc-ai-ollama/README.md index 1aef6f6a33..98c9dfd685 100644 --- a/roles/svc-ai-ollama/README.md +++ b/roles/svc-ai-ollama/README.md @@ -25,13 +25,11 @@ flowchart LR dpt_svc_ai_litellm["svc-ai-litellm 🐳🐝"] dpt_web_app_flowise["web-app-flowise 🐳🐝"] dpt_web_app_minio["web-app-minio 🐳🐝"] - dpt_web_app_openwebui["web-app-openwebui 🐳🐝"] end dep_svc_bkp_volume_2_local -. "0..1" .-> svc_container_backup svc_ollama -. "0..1" .-> dpt_svc_ai_litellm svc_ollama -. "0..1" .-> dpt_web_app_flowise svc_ollama -. "0..1" .-> dpt_web_app_minio - svc_ollama -. "0..1" .-> dpt_web_app_openwebui ``` Solid `1:1` edges are fixed relationships; dashed `0..1` edges are conditional (enabled only in matching deployments). Node markers show the role's deploy modes (💻 host, 🐳 compose, 🐝 swarm); ❌ marks a service that is explicitly turned off, and ⚙️ an Ansible role dependency declared in `meta/main.yml`. diff --git a/roles/svc-ai-ollama/meta/variants.yml b/roles/svc-ai-ollama/meta/variants.yml index 369d218c9b..c88bc0c8bb 100644 --- a/roles/svc-ai-ollama/meta/variants.yml +++ b/roles/svc-ai-ollama/meta/variants.yml @@ -13,3 +13,5 @@ ollama: preload_models: - smollm2:135m + mem_reservation: 1g + mem_limit: 2g diff --git a/roles/web-app-openwebui/README.md b/roles/web-app-openwebui/README.md index 87ac27aa10..68d9dc0b8b 100644 --- a/roles/web-app-openwebui/README.md +++ b/roles/web-app-openwebui/README.md @@ -15,7 +15,7 @@ The diagram places Open WebUI in the Infinito.Nexus cosmos: the components it de ```mermaid flowchart LR subgraph deps [Dependencies] - dep_svc_ai_ollama["svc-ai-ollama 🐳🐝"] + dep_svc_ai_litellm["svc-ai-litellm 🐳🐝"] dep_svc_bkp_volume_2_local["svc-bkp-volume-2-local 💻"] dep_svc_db_openldap["svc-db-openldap 🐳🐝"] dep_web_app_dashboard["web-app-dashboard 🐳🐝"] @@ -39,7 +39,7 @@ flowchart LR svc_seaweedfs["seaweedfs"] svc_css["css"] svc_javascript["javascript"] - svc_ollama["ollama"] + svc_litellm["litellm"] svc_email["email"] svc_prometheus["prometheus"] svc_container_backup["container_backup"] @@ -47,7 +47,7 @@ flowchart LR subgraph dependents [Dependents] dpt_web_app_nextcloud["web-app-nextcloud 🐳🐝"] end - dep_svc_ai_ollama -. "0..1" .-> svc_ollama + dep_svc_ai_litellm -. "0..1" .-> svc_litellm dep_svc_bkp_volume_2_local -. "0..1" .-> svc_container_backup dep_svc_db_openldap -. "0..1" .-> svc_ldap dep_web_app_dashboard -. "0..1" .-> svc_dashboard diff --git a/roles/web-app-openwebui/files/playwright/playwright.spec.js b/roles/web-app-openwebui/files/playwright/playwright.spec.js index 57546de3dd..58acda5ceb 100644 --- a/roles/web-app-openwebui/files/playwright/playwright.spec.js +++ b/roles/web-app-openwebui/files/playwright/playwright.spec.js @@ -13,5 +13,5 @@ require("./test-admin-native-login").register(shared); require("./test-admin-ldap-login").register(shared); require("./test-biber-ldap-login").register(shared); require("./test-guest-persona").register(shared); -require("./test-ollama-chat").register(shared); +require("./test-litellm-chat").register(shared); require("./test-seaweedfs"); diff --git a/roles/web-app-openwebui/files/playwright/test-ollama-chat.js b/roles/web-app-openwebui/files/playwright/test-litellm-chat.js similarity index 73% rename from roles/web-app-openwebui/files/playwright/test-ollama-chat.js rename to roles/web-app-openwebui/files/playwright/test-litellm-chat.js index 56366df71a..da1dddb1e1 100644 --- a/roles/web-app-openwebui/files/playwright/test-ollama-chat.js +++ b/roles/web-app-openwebui/files/playwright/test-litellm-chat.js @@ -2,8 +2,8 @@ const { test, expect } = require("@playwright/test"); const { skipUnlessServiceEnabled } = require("./service-gating"); exports.register = function (shared) { - test("ollama chat: the preloaded model answers a chat completion via OpenWebUI", async ({ page }) => { - skipUnlessServiceEnabled("ollama"); + test("litellm chat: a gateway model answers a chat completion via OpenWebUI", async ({ page }) => { + skipUnlessServiceEnabled("litellm"); test.setTimeout(120_000); await shared.signInViaDashboardOidc( @@ -31,30 +31,30 @@ exports.register = function (shared) { const modelsBody = await modelsResp.json(); const modelIds = (Array.isArray(modelsBody?.data) ? modelsBody.data : []) .map((m) => String(m?.id ?? m?.name ?? "")); - const ollamaModel = modelIds.find((id) => id.startsWith("smollm2")); + const chatModel = modelIds.find((id) => id && !/embed/i.test(id)); expect( - ollamaModel, - `the preloaded Ollama model (smollm2) must be served by OpenWebUI, proving the Ollama backend is reachable and the model pulled (got ${JSON.stringify(modelIds)})` + chatModel, + `a chat model must be served by OpenWebUI through the LiteLLM gateway, proving gateway and backend are reachable and the model pulled (got ${JSON.stringify(modelIds)})` ).toBeTruthy(); const chatResp = await page.request.post(`${base}/api/chat/completions`, { headers, data: { - model: ollamaModel, + model: chatModel, messages: [{ role: "user", content: "Reply with exactly the word: pong" }], stream: false, }, }); expect( chatResp.ok(), - `the ${ollamaModel} chat completion must return 200 over OpenWebUI -> Ollama (HTTP ${chatResp.status()})` + `the ${chatModel} chat completion must return 200 over OpenWebUI -> LiteLLM (HTTP ${chatResp.status()})` ).toBeTruthy(); const chatBody = await chatResp.json(); const content = (chatBody?.choices?.[0]?.message?.content ?? "").trim(); expect( content.length, - `the model must return a non-empty assistant response over OpenWebUI -> Ollama -> ${ollamaModel} (got ${JSON.stringify(chatBody).slice(0, 300)})` + `the model must return a non-empty assistant response over OpenWebUI -> LiteLLM -> ${chatModel} (got ${JSON.stringify(chatBody).slice(0, 300)})` ).toBeGreaterThan(0); }); }; diff --git a/roles/web-app-openwebui/meta/schema.yml b/roles/web-app-openwebui/meta/schema.yml new file mode 100644 index 0000000000..3e849cac41 --- /dev/null +++ b/roles/web-app-openwebui/meta/schema.yml @@ -0,0 +1,7 @@ +--- +credentials: + litellm_api_key: + description: Per-consumer virtual key for the shared svc-ai-litellm gateway + algorithm: sk_prefixed + validation: + min_length: 50 diff --git a/roles/web-app-openwebui/meta/services.yml b/roles/web-app-openwebui/meta/services.yml index d00a429f00..69bf80c8b4 100644 --- a/roles/web-app-openwebui/meta/services.yml +++ b/roles/web-app-openwebui/meta/services.yml @@ -81,10 +81,10 @@ javascript: enabled: false # nocheck: dynamic-flag shared: true # nocheck: playwright-service-flag — AI backend, infrastructural -ollama: +litellm: bond: 1 - enabled: "{{ 'svc-ai-ollama' in group_names }}" - shared: "{{ 'svc-ai-ollama' in group_names }}" + enabled: "{{ 'svc-ai-litellm' in group_names }}" + shared: "{{ 'svc-ai-litellm' in group_names }}" email: bond: 1 enabled: "{{ 'web-app-mailu' in group_names }}" diff --git a/roles/web-app-openwebui/meta/variants.yml b/roles/web-app-openwebui/meta/variants.yml index b8716ae3bd..9ae4e5e399 100644 --- a/roles/web-app-openwebui/meta/variants.yml +++ b/roles/web-app-openwebui/meta/variants.yml @@ -25,7 +25,7 @@ sso: enabled: true shared: true - ollama: + litellm: enabled: true shared: true prometheus: @@ -59,7 +59,7 @@ sso: enabled: false shared: false # nocheck: variants-static-override backup shared stays literally true - ollama: + litellm: enabled: false shared: false # nocheck: variants-static-override backup shared stays literally true prometheus: @@ -96,7 +96,7 @@ matomo: enabled: false shared: false # nocheck: variants-static-override backup shared stays literally true - ollama: + litellm: enabled: false shared: false # nocheck: variants-static-override backup shared stays literally true prometheus: diff --git a/roles/web-app-openwebui/templates/env.j2 b/roles/web-app-openwebui/templates/env.j2 index 1a0cef7d02..fe69714f3c 100644 --- a/roles/web-app-openwebui/templates/env.j2 +++ b/roles/web-app-openwebui/templates/env.j2 @@ -1,6 +1,9 @@ {% include 'roles/sys-svc-container/templates/env.j2' %} -OLLAMA_BASE_URL={{ OLLAMA_BASE_LOCAL_URL }} +{% if OPENWEBUI_LITELLM_ENABLED | bool %} +OPENAI_API_BASE_URL={{ LITELLM_BASE_LOCAL_URL }}/v1 +OPENAI_API_KEY={{ OPENWEBUI_LITELLM_API_KEY | dotenv_quote }} +{% endif %} OFFLINE_MODE={{ OPENWEBUI_OFFLINE_MODE | ternary(1, 0) }} HF_HUB_OFFLINE={{ OPENWEBUI_HF_HUB_OFFLINE | ternary(1, 0) }} ENABLE_PERSISTENT_CONFIG=false diff --git a/roles/web-app-openwebui/templates/playwright.env.j2 b/roles/web-app-openwebui/templates/playwright.env.j2 index 545de9f3bf..b1ddbe9e2e 100644 --- a/roles/web-app-openwebui/templates/playwright.env.j2 +++ b/roles/web-app-openwebui/templates/playwright.env.j2 @@ -18,10 +18,11 @@ EMAIL_SERVICE_ENABLED={{ lookup('config', application_id, 'services.email.enable # nocheck: playwright-service-gate — persona drives the OIDC path; LDAP federation is exercised in the LDAP-only matrix variant LDAP_SERVICE_ENABLED={{ lookup('config', application_id, 'services.ldap.enabled') | string | lower }} +LITELLM_SERVICE_ENABLED={{ lookup('config', application_id, 'services.litellm.enabled') | string | lower }} + # nocheck: playwright-service-gate — consumed via shared persona helpers (roles/test-e2e-playwright/files/personas/) LOGOUT_SERVICE_ENABLED={{ lookup('config', application_id, 'services.logout.enabled') | string | lower }} -# Service gate flags rendered for closure. MATOMO_BASE_URL={{ lookup('tls', 'web-app-matomo', 'url.base') | dotenv_quote }} # nocheck: playwright-service-gate — consumed via shared persona helpers (roles/test-e2e-playwright/files/personas/) @@ -29,8 +30,6 @@ MATOMO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.matomo.enab OIDC_ISSUER_URL={{ OIDC.CLIENT.ISSUER_URL | dotenv_quote }} -OLLAMA_SERVICE_ENABLED={{ lookup('config', application_id, 'services.ollama.enabled') | string | lower }} - OPENWEBUI_BASE_URL={{ lookup('tls', application_id, 'url.base') | dotenv_quote }} SEAWEEDFS_ADMIN_PASSWORD={{ lookup('users', 'administrator').password | dotenv_quote }} diff --git a/roles/web-app-openwebui/vars/main.yml b/roles/web-app-openwebui/vars/main.yml index 1231cab277..d628b9ebe6 100644 --- a/roles/web-app-openwebui/vars/main.yml +++ b/roles/web-app-openwebui/vars/main.yml @@ -7,6 +7,8 @@ OPENWEBUI_OFFLINE_MODE: "{{ lookup('config', application_id, 'services.openwebui OPENWEBUI_HF_HUB_OFFLINE: "{{ lookup('config', application_id, 'services.openwebui.hf_hub_offline') }}" OPENWEBUI_ENABLE_API_KEYS: "{{ lookup('config', application_id, 'services.openwebui.enable_api_keys') }}" OPENWEBUI_LDAP_ENABLED: "{{ lookup('config', application_id, 'services.ldap.enabled') }}" +OPENWEBUI_LITELLM_ENABLED: "{{ lookup('config', application_id, 'services.litellm.enabled') }}" +OPENWEBUI_LITELLM_API_KEY: "{{ lookup('config', application_id, 'credentials.litellm_api_key') }}" OPENWEBUI_OIDC_ENABLED: "{{ lookup('config', application_id, 'services.sso.enabled') }}" OPENWEBUI_OIDC_REDIRECT_URL: "{{ [OPENWEBUI_URL,'/oauth/oidc/callback'] | url_join }}" diff --git a/tests/integration/roles/meta/variants/test_resource_budget.py b/tests/integration/roles/meta/variants/test_resource_budget.py index 4de46bbc9f..3d462bed5d 100644 --- a/tests/integration/roles/meta/variants/test_resource_budget.py +++ b/tests/integration/roles/meta/variants/test_resource_budget.py @@ -4,7 +4,11 @@ the host memory budget. For every role and every entry in its ``meta/variants.yml``, the deduplicated footprint (the role's own containers plus the shared dependencies it pulls in, each service counted once via the shared-service logic) is summed and checked -against: +against the budget below. Every role that ships variants is evaluated at the +same round index, falling back to variant 0 when it has fewer, because that is +the combination a matrix round actually deploys: a dependency sized for the +production model tier is not what the round runs. ``meta/services.yml`` only +supplies the roles that declare no variant at all. - ``mem_reservation`` total ≤ 32 GB - ``mem_limit`` total ≤ 64 GB @@ -67,6 +71,11 @@ def _collect_findings(root: Path) -> list[BudgetFinding]: for role, variant_list in sorted(variants.items()): for index, variant_config in enumerate(variant_list): scoped = dict(applications) + for dep_role, dep_variants in variants.items(): + if not dep_variants: + continue + dep_index = index if index < len(dep_variants) else 0 + scoped[dep_role] = dep_variants[dep_index] or {} scoped[role] = variant_config or {} rows: list = [] collect_role_resources( diff --git a/tests/lint/ansible/jinja/test_model_backend_via_gateway.py b/tests/lint/ansible/jinja/test_model_backend_via_gateway.py new file mode 100644 index 0000000000..f8e6702063 --- /dev/null +++ b/tests/lint/ansible/jinja/test_model_backend_via_gateway.py @@ -0,0 +1,79 @@ +"""Keep model traffic on the gateway instead of a provider URL. + +Rationale +========= +``svc-ai-litellm`` is the single entrypoint to every model backend: it owns the +model list, the per-consumer virtual keys and the routing. An application that +addresses a provider directly bypasses all three, so its traffic carries no +consumer identity, ignores the routing, and keeps working after the model list +moved on. + +The gateway itself is the one legitimate holder of provider URLs, because +routing to them is its job. + +Per-line opt-out +================ +Add ``# nocheck: model-backend-via-gateway`` on the offending line or on the +immediately preceding non-empty line, together with the reason this consumer +cannot go through the gateway. +""" + +from __future__ import annotations + +import re +import unittest +from pathlib import Path + +from utils.annotations.suppress import is_suppressed_at +from utils.cache.files import iter_project_files_with_content + +from . import PROJECT_ROOT + +_RULE = "model-backend-via-gateway" + +_GATEWAY_ROLE = "svc-ai-litellm" + +_PROVIDER_URL = re.compile(r"\{\{[^}]*\b(OLLAMA|LMSTUDIO)_BASE_LOCAL_URL\b") + + +class TestModelBackendViaGateway(unittest.TestCase): + def test_only_the_gateway_addresses_a_provider_directly(self) -> None: + findings: list[tuple[str, int, str]] = [] + for path_str, content in iter_project_files_with_content( + extensions=(".j2",), + exclude_tests=True, + ): + rel = Path(path_str).relative_to(PROJECT_ROOT).as_posix() + parts = rel.split("/") + if len(parts) < 2 or parts[0] != "roles" or "/templates/" not in rel: + continue + if parts[1] == _GATEWAY_ROLE: + continue + lines = content.splitlines() + for idx, line in enumerate(lines): + if not _PROVIDER_URL.search(line): + continue + if is_suppressed_at(lines, idx + 1, _RULE, mode="same-or-above"): + continue + findings.append((rel, idx + 1, line.strip())) + + if findings: + formatted = "\n".join( + f"- {p}:{n}: {s}" + for p, n, s in sorted(set(findings), key=lambda i: (i[0], i[1])) + ) + self.fail( + "These templates address a model provider directly instead of " + f"routing through {_GATEWAY_ROLE}, so their traffic carries no " + "consumer identity and ignores the gateway's model list.\n\n" + "Fix: declare `services.litellm` in the role's meta/services.yml " + "and a `litellm_api_key` credential in its meta/schema.yml, then " + "point the application at LITELLM_BASE_LOCAL_URL with that key. " + "The gateway provisions the virtual key for every role that " + "declares both.\n\n" + f"Offending lines:\n{formatted}" + ) + + +if __name__ == "__main__": # pragma: no cover + unittest.main()