diff --git a/nextcloud_mcp_server/config.py b/nextcloud_mcp_server/config.py index c42a6860..993e1497 100644 --- a/nextcloud_mcp_server/config.py +++ b/nextcloud_mcp_server/config.py @@ -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, "/" + # (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 diff --git a/nextcloud_mcp_server/embedding/gateway_client.py b/nextcloud_mcp_server/embedding/gateway_client.py index 2d729b7c..72aedc1a 100644 --- a/nextcloud_mcp_server/embedding/gateway_client.py +++ b/nextcloud_mcp_server/embedding/gateway_client.py @@ -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) diff --git a/tests/unit/providers/test_gateway_provider.py b/tests/unit/providers/test_gateway_provider.py index 76b53563..923b33f3 100644 --- a/tests/unit/providers/test_gateway_provider.py +++ b/tests/unit/providers/test_gateway_provider.py @@ -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