From 4f170d32cb40ab08c23a1772503b0b586be1e1a5 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 1 Jun 2026 17:39:23 +0200 Subject: [PATCH] fix(embedding): normalize gateway base_url to the /v1 base path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EMBEDDING_GATEWAY_URL is configured as a bare origin (scheme://host:port) — the deployment's Service URL. GatewayProvider now appends the gateway's /v1 base path before handing the URL to the OpenAI SDK, so both embed posts ({base}/embeddings) and dimension discovery ({base}/models) land under /v1. Idempotent: a URL already ending in /v1 is left unchanged. This lets EMBEDDING_GATEWAY_URL stay a bare domain (matching the gitops Service URLs) instead of requiring a hand-appended /v1. Also align the `embedding_gateway_model` field default with _DEFAULTS ("mistral/mistral-embed"). The gateway catalog is provider-namespaced, and _detect_dimension matches `entry.id == embedding_model`; the stale un-namespaced default would silently miss the catalog entry and leave the dimension unresolved (re-triggering the external-mode startup crash). Tests: bare / trailing-slash / idempotent normalization + a bare-origin discovery test asserting /v1/models. 16 gateway-provider tests pass; providers + vector suites green (129 total); ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- nextcloud_mcp_server/config.py | 4 +- .../embedding/gateway_client.py | 14 ++++- tests/unit/providers/test_gateway_provider.py | 56 +++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/nextcloud_mcp_server/config.py b/nextcloud_mcp_server/config.py index 993e1497..d4e60c9e 100644 --- a/nextcloud_mcp_server/config.py +++ b/nextcloud_mcp_server/config.py @@ -696,7 +696,9 @@ class Settings: fact_event_emitter: str = "none" # none | nats | stdout ingest_bus_url: str | None = None # required when ingest_mode=external embedding_gateway_url: str | None = None # required when provider=gateway - embedding_gateway_model: str = "mistral-embed" # logical model gateway routes + embedding_gateway_model: str = ( + "mistral/mistral-embed" # provider-namespaced id the gateway routes on + ) # Gateway M2M OIDC client creds (separate realm; see _DEFAULTS comment). embedding_gateway_token_url: str | None = None embedding_gateway_client_id: str | None = None diff --git a/nextcloud_mcp_server/embedding/gateway_client.py b/nextcloud_mcp_server/embedding/gateway_client.py index 72aedc1a..82f37257 100644 --- a/nextcloud_mcp_server/embedding/gateway_client.py +++ b/nextcloud_mcp_server/embedding/gateway_client.py @@ -119,6 +119,16 @@ class GatewayProvider(OpenAIProvider): token_provider: GatewayTokenProvider | None = None, timeout: float = 120.0, ): + # The gateway exposes its OpenAI-compatible API under the /v1 base path + # (/v1/embeddings, /v1/models). Callers configure EMBEDDING_GATEWAY_URL + # as a bare origin (scheme://host:port) — the deployment's Service URL — + # so we append /v1 here. This base path is then used uniformly: the + # OpenAI SDK posts embeds to {base_url}/embeddings and _detect_dimension + # GETs {base_url}/models, both correctly landing under /v1. Idempotent — + # a URL already ending in /v1 (or /v1/) is left as-is. + normalized_base_url = base_url.rstrip("/") + if not normalized_base_url.endswith("/v1"): + normalized_base_url = f"{normalized_base_url}/v1" # AsyncOpenAI rejects an empty key; use a non-secret placeholder when # the gateway is unauthenticated. When a token provider is configured, # the real Bearer is set on the client before each request. The bare @@ -126,7 +136,7 @@ class GatewayProvider(OpenAIProvider): # this is a public placeholder string, not a secret. super().__init__( api_key=_UNAUTHENTICATED_PLACEHOLDER, # NOSONAR - base_url=base_url, + base_url=normalized_base_url, embedding_model=embedding_model, generation_model=None, # gateway never generates timeout=timeout, @@ -134,7 +144,7 @@ class GatewayProvider(OpenAIProvider): self._token_provider = token_provider logger.info( "Initialized gateway embedding provider: base_url=%s, model=%s, auth=%s", - base_url, + normalized_base_url, embedding_model, "oidc-m2m" if token_provider else "none", ) diff --git a/tests/unit/providers/test_gateway_provider.py b/tests/unit/providers/test_gateway_provider.py index 923b33f3..1e941b8e 100644 --- a/tests/unit/providers/test_gateway_provider.py +++ b/tests/unit/providers/test_gateway_provider.py @@ -289,3 +289,59 @@ async def test_detect_dimension_skips_when_already_known(monkeypatch): await provider._detect_dimension() assert called["n"] == 0 assert provider.get_dimension() == 1024 + + +# --- /v1 base-path normalization -------------------------------------------- +# EMBEDDING_GATEWAY_URL is configured as a bare origin (scheme://host:port); +# the provider appends the gateway's /v1 base path so both the OpenAI SDK's +# embed posts ({base}/embeddings) and discovery ({base}/models) land under /v1. + + +def _client_base(provider: GatewayProvider) -> str: + return str(provider.client.base_url).rstrip("/") + + +def test_bare_base_url_gets_v1_base_path(): + provider = GatewayProvider( + base_url="http://gw:8083", embedding_model="mistral/mistral-embed" + ) + assert _client_base(provider).endswith("/v1") + + +def test_v1_base_url_is_idempotent(): + # A URL that already carries /v1 (e.g. legacy config) is not doubled. + provider = GatewayProvider( + base_url="http://gw:8083/v1", embedding_model="mistral/mistral-embed" + ) + base = _client_base(provider) + assert base.endswith("/v1") + assert not base.endswith("/v1/v1") + + +def test_trailing_slash_base_url_normalized(): + provider = GatewayProvider( + base_url="http://gw:8083/", embedding_model="mistral/mistral-embed" + ) + base = _client_base(provider) + assert base.endswith("/v1") + assert not base.endswith("/v1/v1") + + +async def test_detect_dimension_with_bare_base_url_hits_v1_models(monkeypatch): + """End-to-end of the fix: a bare-origin base_url still resolves the + dimension because discovery lands on /v1/models.""" + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return httpx.Response( + 200, json={"data": [{"id": "mistral/mistral-embed", "dimension": 1024}]} + ) + + _mock_async_client(monkeypatch, handler) + provider = GatewayProvider( + base_url="http://gw:8083", embedding_model="mistral/mistral-embed" + ) + await provider._detect_dimension() + assert provider.get_dimension() == 1024 + assert seen["url"].endswith("/v1/models")