Merge pull request #823 from cbcoutinho/feat/gateway-dimension-discovery
feat(embedding): gateway provider discovers dimension via GET /v1/models
This commit is contained in:
@@ -174,9 +174,10 @@ _DEFAULTS: dict[str, Any] = {
|
||||
"fact_event_emitter": "none", # none | nats | stdout
|
||||
"ingest_bus_url": None, # required when ingest_mode=external
|
||||
"embedding_gateway_url": None, # required when embedding_provider=gateway
|
||||
# Logical model the gateway routes on (e.g. mistral-embed → Mistral for
|
||||
# the MVP). Only consulted when embedding_provider=gateway.
|
||||
"embedding_gateway_model": "mistral-embed",
|
||||
# Provider-namespaced model the gateway serves, "<provider>/<model>"
|
||||
# (the gateway routes on the "/"-prefix; mistral/mistral-embed → Mistral
|
||||
# for the MVP). Only consulted when embedding_provider=gateway.
|
||||
"embedding_gateway_model": "mistral/mistral-embed",
|
||||
# Gateway auth: the MCP server is an OIDC *client* in the gateway's own
|
||||
# M2M realm (parallel to, and distinct from, the tenant realm it already
|
||||
# serves). It obtains a client-credentials token and the gateway maps the
|
||||
|
||||
@@ -147,6 +147,66 @@ class GatewayProvider(OpenAIProvider):
|
||||
if self._token_provider is not None:
|
||||
self.client.api_key = await self._token_provider.get_token()
|
||||
|
||||
async def _detect_dimension(self) -> None:
|
||||
"""Resolve the embedding dimension from the gateway's ``GET /v1/models``
|
||||
before the first embed.
|
||||
|
||||
Qdrant collection init needs the vector size at startup (it calls
|
||||
``get_dimension()`` before any ``embed()``); the vector-sync bootstrap
|
||||
invokes this hook first (``vector/qdrant_client.py`` —
|
||||
``hasattr(provider, "_detect_dimension")``). The gateway is the
|
||||
authority on the dimensions of the models it serves, so we read it from
|
||||
there rather than hardcoding (``mistral-embed`` isn't an OpenAI model,
|
||||
so the OpenAI-wire base class can't know its size statically).
|
||||
|
||||
Best-effort: any failure (old gateway without /v1/models, model absent,
|
||||
network) leaves ``_dimension`` unset so the inherited lazy
|
||||
detect-on-first-embed path still applies. Never raises.
|
||||
"""
|
||||
if self._dimension is not None:
|
||||
return # already known (e.g. an OpenAI model in the static map)
|
||||
|
||||
# ``models`` is a sibling of ``embeddings`` under the gateway's base —
|
||||
# derive it from the same base_url the OpenAI client uses for embeds so
|
||||
# the two stay consistent (str(base_url) has a trailing slash).
|
||||
models_url = str(self.client.base_url).rstrip("/") + "/models"
|
||||
headers: dict[str, str] = {}
|
||||
if self._token_provider is not None:
|
||||
headers["Authorization"] = (
|
||||
f"Bearer {await self._token_provider.get_token()}"
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(10.0, connect=5.0)
|
||||
) as client:
|
||||
resp = await client.get(models_url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
catalog = resp.json().get("data", [])
|
||||
for entry in catalog:
|
||||
if entry.get("id") == self.embedding_model:
|
||||
dim = entry.get("dimension")
|
||||
if isinstance(dim, int):
|
||||
self._dimension = dim
|
||||
logger.info(
|
||||
"Resolved embedding dimension %d for model %s via "
|
||||
"gateway /v1/models",
|
||||
dim,
|
||||
self.embedding_model,
|
||||
)
|
||||
return
|
||||
logger.warning(
|
||||
"Gateway /v1/models reported no dimension for model %s; "
|
||||
"falling back to lazy detection on first embed",
|
||||
self.embedding_model,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - best-effort, never fatal
|
||||
logger.warning(
|
||||
"Could not fetch model dimensions from gateway %s: %s; "
|
||||
"falling back to lazy detection on first embed",
|
||||
models_url,
|
||||
exc,
|
||||
)
|
||||
|
||||
async def embed(self, text: str) -> list[float]:
|
||||
await self._ensure_bearer()
|
||||
return await super().embed(text)
|
||||
|
||||
@@ -30,12 +30,12 @@ def test_gateway_selected_unauthenticated(monkeypatch):
|
||||
settings = Settings(
|
||||
embedding_provider="gateway",
|
||||
embedding_gateway_url="https://gateway:8083",
|
||||
embedding_gateway_model="mistral-embed",
|
||||
embedding_gateway_model="mistral/mistral-embed",
|
||||
)
|
||||
_patch_settings(monkeypatch, settings)
|
||||
provider = ProviderRegistry.create_provider()
|
||||
assert isinstance(provider, GatewayProvider)
|
||||
assert provider.embedding_model == "mistral-embed"
|
||||
assert provider.embedding_model == "mistral/mistral-embed"
|
||||
assert provider.supports_embeddings is True
|
||||
assert provider.supports_generation is False
|
||||
assert provider._token_provider is None # unauthenticated
|
||||
@@ -160,3 +160,132 @@ async def test_token_provider_concurrent_callers_issue_single_request(monkeypatc
|
||||
# and both callers observe the same cached token.
|
||||
assert calls["n"] == 1
|
||||
assert results == ["tok1", "tok1"]
|
||||
|
||||
|
||||
# --- Dimension discovery via gateway GET /v1/models -------------------------
|
||||
|
||||
|
||||
def _mock_async_client(monkeypatch, handler):
|
||||
"""Route every httpx.AsyncClient through a MockTransport (mirrors the
|
||||
token-provider tests above)."""
|
||||
transport = httpx.MockTransport(handler)
|
||||
orig = httpx.AsyncClient
|
||||
|
||||
def _client(*args, **kwargs):
|
||||
kwargs["transport"] = transport
|
||||
return orig(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _client)
|
||||
|
||||
|
||||
async def test_detect_dimension_from_models_endpoint(monkeypatch):
|
||||
"""_detect_dimension() resolves the dimension from /v1/models with no embed
|
||||
call — the regression that crashed external-mode startup."""
|
||||
seen = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["url"] = str(request.url)
|
||||
seen["auth"] = request.headers.get("Authorization")
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "mistral/mistral-embed",
|
||||
"object": "model",
|
||||
"dimension": 1024,
|
||||
},
|
||||
{
|
||||
"id": "text-embedding-3-large",
|
||||
"object": "model",
|
||||
"dimension": 3072,
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
_mock_async_client(monkeypatch, handler)
|
||||
provider = GatewayProvider(
|
||||
base_url="http://gw:8083/v1", embedding_model="mistral/mistral-embed"
|
||||
)
|
||||
await provider._detect_dimension()
|
||||
assert provider.get_dimension() == 1024
|
||||
assert seen["url"].endswith("/v1/models")
|
||||
assert seen["auth"] is None # unauthenticated gateway
|
||||
|
||||
|
||||
async def test_detect_dimension_sends_bearer(monkeypatch):
|
||||
"""When a token provider is configured, discovery presents the M2M bearer."""
|
||||
captured = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/token"):
|
||||
return httpx.Response(200, json={"access_token": "tok", "expires_in": 3600})
|
||||
captured["auth"] = request.headers.get("Authorization")
|
||||
return httpx.Response(
|
||||
200, json={"data": [{"id": "mistral/mistral-embed", "dimension": 1024}]}
|
||||
)
|
||||
|
||||
_mock_async_client(monkeypatch, handler)
|
||||
tp = GatewayTokenProvider(
|
||||
token_url="http://idp.example/token", client_id="c", client_secret="s"
|
||||
)
|
||||
provider = GatewayProvider(
|
||||
base_url="http://gw:8083/v1",
|
||||
embedding_model="mistral/mistral-embed",
|
||||
token_provider=tp,
|
||||
)
|
||||
await provider._detect_dimension()
|
||||
assert provider.get_dimension() == 1024
|
||||
assert captured["auth"] == "Bearer tok"
|
||||
|
||||
|
||||
async def test_detect_dimension_non_fatal_on_http_error(monkeypatch):
|
||||
"""An old gateway without /v1/models (404) must not crash startup —
|
||||
dimension stays unknown so lazy detect-on-first-embed still applies."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(404, json={"detail": "not found"})
|
||||
|
||||
_mock_async_client(monkeypatch, handler)
|
||||
provider = GatewayProvider(
|
||||
base_url="http://gw:8083/v1", embedding_model="mistral/mistral-embed"
|
||||
)
|
||||
await provider._detect_dimension() # must not raise
|
||||
with pytest.raises(RuntimeError):
|
||||
provider.get_dimension() # still unknown
|
||||
|
||||
|
||||
async def test_detect_dimension_model_absent(monkeypatch):
|
||||
"""Gateway reachable but doesn't list our model → no dimension set,
|
||||
no raise."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"data": [{"id": "other", "dimension": 99}]})
|
||||
|
||||
_mock_async_client(monkeypatch, handler)
|
||||
provider = GatewayProvider(
|
||||
base_url="http://gw:8083/v1", embedding_model="mistral/mistral-embed"
|
||||
)
|
||||
await provider._detect_dimension()
|
||||
with pytest.raises(RuntimeError):
|
||||
provider.get_dimension()
|
||||
|
||||
|
||||
async def test_detect_dimension_skips_when_already_known(monkeypatch):
|
||||
"""If the dimension is already known, discovery makes no HTTP call."""
|
||||
called = {"n": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
called["n"] += 1
|
||||
return httpx.Response(200, json={"data": []})
|
||||
|
||||
_mock_async_client(monkeypatch, handler)
|
||||
provider = GatewayProvider(
|
||||
base_url="http://gw:8083/v1", embedding_model="mistral/mistral-embed"
|
||||
)
|
||||
provider._dimension = 1024 # pre-set (e.g. explicit override / OpenAI model)
|
||||
await provider._detect_dimension()
|
||||
assert called["n"] == 0
|
||||
assert provider.get_dimension() == 1024
|
||||
|
||||
Reference in New Issue
Block a user