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")