Merge pull request #827 from cbcoutinho/fix/gateway-v1-base-path

fix(embedding): normalize gateway base_url to the /v1 base path
This commit is contained in:
Chris Coutinho
2026-06-01 17:55:48 +02:00
committed by GitHub
3 changed files with 71 additions and 3 deletions
+3 -1
View File
@@ -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
@@ -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",
)
@@ -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")