Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions backend/middleware/model_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Canonical model-id shape enforcement at the request boundary.

Every routable model id on this platform is exactly three
slash-separated segments — ``<namespace>/<model_org>/<model_name>`` —
where the namespace is the platform prefix (``SwissAI-Research``), a
passthrough provider prefix (``CSCS-Inference``, ``RCP-AIaaS``), or a
username (user launches). Anything else — historical bare upstream ids,
partial ids, empty ids — is refused here with a 404 before any routing
decision, so a malformed id can never be silently claimed by whichever
upstream happens to advertise it (registration order used to pick the
winner).
"""

import logging

from fastapi import HTTPException

logger = logging.getLogger(__name__)


def require_namespaced_model(model_id) -> str:
"""Return the id unchanged when it has the canonical three-segment
shape, else raise 404. The warning log is the operator's view of
unmigrated clients — the response body carries the remedy."""
parts = model_id.split("/") if isinstance(model_id, str) else []
if len(parts) == 3 and all(parts):
return model_id
logger.warning(
"Refusing model id %r: not <namespace>/<model_org>/<model_name>", model_id
)
raise HTTPException(
status_code=404,
detail=(
f"Model '{model_id}' not found. Model ids must be fully namespaced "
"as <namespace>/<model_org>/<model_name> "
"(e.g. CSCS-Inference/swiss-ai/Apertus-8B-Instruct-2509) — "
"see /v1/models for the available ids."
),
)
3 changes: 2 additions & 1 deletion backend/routers/classify.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends
from backend.middleware.auth import require_auth
from backend.middleware.body import json_body
from backend.middleware.model_id import require_namespaced_model
from backend.services.llm_service import llm_proxy_classify
from backend.config import get_settings

Expand All @@ -21,6 +22,6 @@ async def classify(
endpoint=settings.otela_head_addr + "/v1/service/llm/",
api_key=token,
payload=data,
model=data.get("model", "unknown"),
model=require_namespaced_model(data.get("model")),
)
return response.data
3 changes: 3 additions & 0 deletions backend/routers/completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from backend.middleware.auth import require_auth
from backend.middleware.ratelimit import enforce_rate_limit
from backend.middleware.body import json_body
from backend.middleware.model_id import require_namespaced_model
from backend.services.langfuse_service import (
prepare_stream_trace,
record_if_monitored,
Expand Down Expand Up @@ -149,6 +150,7 @@ async def chat_completion(
user_id=token, opt_out=opt_out, app_title=app_title, **reorg_data
)

require_namespaced_model(llm_request.model)
endpoint, api_key, provider_label, resolved = await _resolve_route(
llm_request.model, token
)
Expand Down Expand Up @@ -240,6 +242,7 @@ async def completion(
user_id=token, opt_out=opt_out, app_title=app_title, **reorg_data
)

require_namespaced_model(llm_request.model)
endpoint, api_key, provider_label, resolved = await _resolve_route(
llm_request.model, token
)
Expand Down
2 changes: 2 additions & 0 deletions backend/routers/embeddings.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from fastapi import APIRouter, Request, Depends
from backend.middleware.auth import require_auth
from backend.middleware.body import json_body
from backend.middleware.model_id import require_namespaced_model
from backend.services.llm_service import llm_proxy_embeddings
from backend.config import get_settings

Expand All @@ -14,6 +15,7 @@ async def embeddings(
token: str = Depends(require_auth),
data: dict = Depends(json_body),
):
require_namespaced_model(data.get("model"))
data["user_id"] = token

opt_out = request.headers.get("X-OPTOUT-TRACKING", "").lower() in (
Expand Down
5 changes: 3 additions & 2 deletions backend/routers/rerank.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends
from backend.middleware.auth import require_auth
from backend.middleware.body import json_body
from backend.middleware.model_id import require_namespaced_model
from backend.services.llm_service import llm_proxy_rerank, llm_proxy_score
from backend.config import get_settings

Expand All @@ -22,7 +23,7 @@ async def rerank(
endpoint=settings.otela_head_addr + "/v1/service/llm/",
api_key=token,
payload=data,
model=data.get("model", "unknown"),
model=require_namespaced_model(data.get("model")),
)
return response.data

Expand All @@ -36,6 +37,6 @@ async def score(
endpoint=settings.otela_head_addr + "/v1/service/llm/",
api_key=token,
payload=data,
model=data.get("model", "unknown"),
model=require_namespaced_model(data.get("model")),
)
return response.data
3 changes: 2 additions & 1 deletion backend/routers/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from backend.middleware.auth import require_auth
from backend.middleware.ratelimit import enforce_rate_limit
from backend.middleware.body import json_body
from backend.middleware.model_id import require_namespaced_model
from backend.services.llm_service import llm_proxy_responses, response_generator_raw
from backend.services.passthrough_service import (
resolve_model,
Expand All @@ -20,7 +21,7 @@ async def create_response(
data: dict = Depends(json_body),
):
stream = data.get("stream", False)
model = data.get("model", "unknown")
model = require_namespaced_model(data.get("model", "unknown"))

resolved = await resolve_model(model)
if resolved is not None:
Expand Down
5 changes: 3 additions & 2 deletions backend/routers/tokenization.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends
from backend.middleware.auth import require_auth
from backend.middleware.body import json_body
from backend.middleware.model_id import require_namespaced_model
from backend.services.llm_service import llm_proxy_tokenize, llm_proxy_detokenize
from backend.config import get_settings

Expand All @@ -21,7 +22,7 @@ async def tokenize(
endpoint=settings.otela_head_addr + "/v1/service/llm/",
api_key=token,
payload=data,
model=data.get("model", "unknown"),
model=require_namespaced_model(data.get("model")),
)
return response.data

Expand All @@ -35,6 +36,6 @@ async def detokenize(
endpoint=settings.otela_head_addr + "/v1/service/llm/",
api_key=token,
payload=data,
model=data.get("model", "unknown"),
model=require_namespaced_model(data.get("model")),
)
return response.data
30 changes: 9 additions & 21 deletions backend/services/passthrough_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@
collision-free across providers and against OpenTela-served models: the
first path segment of a requested id selects the provider, the remainder
is forwarded verbatim as the upstream id (see ``resolve_model``).
Un-prefixed upstream ids still route during a deprecation window, where
registration order is precedence.
Un-prefixed upstream ids no longer route: the canonical three-segment
shape is enforced at the request boundary (see
``backend/middleware/model_id.py``), which retired the old
registration-order back-compat for bare ids.

Curation is per provider via ``Provider.hidden_id_suffixes``: RCP
advertises every model twice — once under its canonical id and once
Expand All @@ -35,16 +37,13 @@
"""

import asyncio
import logging
import time
from dataclasses import dataclass

import aiohttp

from backend.config import get_settings

logger = logging.getLogger(__name__)


# 30 s strikes a balance: short enough that a new upstream model is
# visible within half a minute, long enough that page reloads +
Expand Down Expand Up @@ -255,9 +254,11 @@ async def resolve_model(model_id: str) -> ResolvedModel | None:
through to OpenTela by another name; it returns None and 404s there
under its full (never-launched) id.

Back-compat: a bare upstream id that a provider advertises still routes
(first provider in registration order wins), logged as deprecated.
Remove after clients have migrated to prefixed ids."""
Any other first segment (usernames, unknown prefixes) falls through
to OpenTela. Bare upstream ids never reach this function — the
request boundary 404s ids without the canonical three-segment shape
(``backend/middleware/model_id.py``), which replaced the old
registration-order back-compat routing for bare ids."""
if not model_id:
return None
providers = registered_providers()
Expand All @@ -278,19 +279,6 @@ async def resolve_model(model_id: str) -> ResolvedModel | None:
provider=provider, upstream_id=rest, public_id=model_id
)
return None
for provider in providers:
if model_id in await _get_cached_ids(provider):
logger.warning(
"Deprecated un-prefixed passthrough model id %r; use %s/%s",
model_id,
provider.prefix,
model_id,
)
return ResolvedModel(
provider=provider,
upstream_id=model_id,
public_id=f"{provider.prefix}/{model_id}",
)
return None


Expand Down
13 changes: 8 additions & 5 deletions backend/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,10 @@ async def fake_classify(*, endpoint, api_key, payload, model):
monkeypatch.setattr(classify_router, "llm_proxy_classify", fake_classify)
client.app.dependency_overrides[require_auth] = lambda: "test-token"
try:
response = client.post("/v1/classify", json={"model": "m", "input": "hello"})
response = client.post(
"/v1/classify",
json={"model": "SwissAI-Research/org/m", "input": "hello"},
)
finally:
client.app.dependency_overrides.pop(require_auth, None)

Expand Down Expand Up @@ -182,25 +185,25 @@ def test_leaderboard_no_auth(client):
"/v1/score",
"backend.routers.rerank",
"llm_proxy_score",
{"model": "m", "text_1": "a", "text_2": "b"},
{"model": "SwissAI-Research/org/m", "text_1": "a", "text_2": "b"},
),
(
"/v1/rerank",
"backend.routers.rerank",
"llm_proxy_rerank",
{"model": "m", "query": "q", "documents": ["a"]},
{"model": "SwissAI-Research/org/m", "query": "q", "documents": ["a"]},
),
(
"/v1/tokenize",
"backend.routers.tokenization",
"llm_proxy_tokenize",
{"model": "m", "prompt": "hello"},
{"model": "SwissAI-Research/org/m", "prompt": "hello"},
),
(
"/v1/detokenize",
"backend.routers.tokenization",
"llm_proxy_detokenize",
{"model": "m", "tokens": [1, 2, 3]},
{"model": "SwissAI-Research/org/m", "tokens": [1, 2, 3]},
),
],
)
Expand Down
72 changes: 72 additions & 0 deletions backend/tests/test_model_id_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Canonical model-id shape enforcement — unit tests for
``require_namespaced_model`` plus a router-level check that a bare id is
refused at the boundary, before any routing or proxying happens."""

from unittest.mock import AsyncMock, patch

import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient

from backend.middleware.auth import require_auth
from backend.middleware.model_id import require_namespaced_model
from backend.routers import completions


VALID_IDS = [
"SwissAI-Research/swiss-ai/Apertus-70B-Instruct-2509",
"CSCS-Inference/swiss-ai/Apertus-8B-Instruct-2509",
"RCP-AIaaS/apertus-ai/Apertus-v1.5-8B-Prerelease-2607",
"someuser/meta-llama/Llama-3.1-8B-Instruct",
]

INVALID_IDS = [
"swiss-ai/Apertus-8B-Instruct-2509", # historical bare upstream id
"Apertus-8B-Instruct-2509", # single segment
"a/b/c/d", # too many segments
"SwissAI-Research//model", # empty middle segment
"/org/model", # empty namespace
"org/model/", # empty model name
"",
"unknown", # the routers' data.get("model", ...) default
None, # model key missing entirely
]


@pytest.mark.parametrize("model_id", VALID_IDS)
def test_valid_ids_pass_through(model_id):
assert require_namespaced_model(model_id) == model_id


@pytest.mark.parametrize("model_id", INVALID_IDS)
def test_invalid_ids_raise_404(model_id):
with pytest.raises(HTTPException) as exc_info:
require_namespaced_model(model_id)
assert exc_info.value.status_code == 404
assert "<namespace>/<model_org>/<model_name>" in exc_info.value.detail


def _make_client() -> TestClient:
app = FastAPI()
app.include_router(completions.router)
app.dependency_overrides[require_auth] = lambda: "test-token"
return TestClient(app, raise_server_exceptions=False)


def test_chat_completion_rejects_bare_id_before_routing():
"""A bare id must be refused before _resolve_route runs — the old
back-compat would otherwise have silently picked a provider."""
client = _make_client()
with patch.object(
completions, "_resolve_route", new=AsyncMock(side_effect=AssertionError)
) as route:
resp = client.post(
"/v1/chat/completions",
json={
"model": "swiss-ai/Apertus-8B-Instruct-2509",
"messages": [{"role": "user", "content": "hi"}],
},
)
assert resp.status_code == 404
assert "<namespace>/<model_org>/<model_name>" in resp.json()["detail"]
route.assert_not_awaited()
23 changes: 10 additions & 13 deletions backend/tests/test_passthrough_service.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Unit tests for the OpenAI-compatible passthrough registry — provider
prefix namespacing, dynamic model discovery with per-provider TTL cache,
stale fallback, gating on configuration, and un-prefixed back-compat."""
stale fallback, and gating on configuration. Bare (un-prefixed) ids are
rejected at the request boundary (see test_model_id_validation.py);
here they must simply not resolve to any provider."""

import asyncio
from unittest.mock import AsyncMock, patch
Expand Down Expand Up @@ -147,16 +149,12 @@ def test_platform_prefix_works_without_any_provider_configured():
assert _run(resolve_model("SwissAI-Research/")) is None


def test_bare_upstream_id_still_routes_for_back_compat():
"""Deprecation window: clients using the historical un-prefixed ids
keep working, and the resolution carries the prefixed public_id so
responses advertise the migration target."""
def test_bare_upstream_id_does_not_route():
"""A historical un-prefixed id must NOT silently route to whichever
provider registered first, even when that provider advertises it —
None here means it falls through and 404s downstream."""
with _patch_settings(_FakeSettings()), _patch_fetch([APERTUS_8B]):
resolved = _run(resolve_model(APERTUS_8B))
assert resolved is not None
assert resolved.provider.name == "cscs_L1"
assert resolved.upstream_id == APERTUS_8B
assert resolved.public_id == f"CSCS-Inference/{APERTUS_8B}"
assert _run(resolve_model(APERTUS_8B)) is None


# ── listing ─────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -187,8 +185,7 @@ def test_synthetic_entries_are_prefixed():
def test_same_upstream_id_on_two_providers_lists_two_rows():
"""Prefixes make cross-provider collisions structurally impossible:
the same upstream model on CSCS and RCP is two distinct rows, each
individually routable. Bare-id back-compat picks the first provider
in registration order."""
individually routable. The bare id routes to neither."""
settings = _FakeSettings(rcp_base_url="https://rcp/v1", rcp_api_key="rcp-key")
with _patch_settings(settings), _patch_fetch([APERTUS_8B]):
entries = _run(get_synthetic_entries())
Expand All @@ -200,7 +197,7 @@ def test_same_upstream_id_on_two_providers_lists_two_rows():
assert via_cscs.provider.name == "cscs_L1"
assert via_rcp.provider.name == "rcp"
assert via_rcp.provider.api_key == "rcp-key"
assert bare.provider.name == "cscs_L1" # registered before rcp
assert bare is None


# ── discovery cache ─────────────────────────────────────────────────────────
Expand Down
Loading