fix(embedding): request float encoding_format on openai embedding calls (#938)

* test(embedding): add reproducer for missing encoding_format on openai paths

The openai SDK defaults encoding_format to base64 when it is not passed. OpenAI-compatible providers that don't support base64 embeddings (e.g. OpenRouter with nvidia/nemotron-3-embed-1b:free) return HTTP 200 with empty data, and every embedding call fails with 'No embedding data received'.

* fix(embedding): request float encoding_format on openai embedding calls

The openai SDK defaults encoding_format to base64 when the caller does not pass one. OpenAI-compatible providers that don't support base64 embeddings (e.g. OpenRouter hosting nvidia/nemotron-3-embed-1b:free) answer HTTP 200 with empty embedding data, and every embedding call fails with 'No embedding data received', breaking conclusions, semantic search, and the deriver. Pass encoding_format='float' explicitly on both the single-query and batch call paths.

* test(embedding): cover openai-compatible providers in the live embedding matrix

The existing openai family runs against real OpenAI, which serves base64
embeddings happily, so the matrix passes with or without the #932 fix. Adds an
`openai_compatible_embedding` family (openai transport, third-party base_url)
so the matrix can reach a provider that rejects base64. Empty default_models
keeps it skipped unless LIVE_EMBEDDING_OPENAI_COMPATIBLE_MODELS is set.

Also adds test_live_openai_float_encoding_matches_base64, which pins the other
direction: switching the wire format must not move vectors on real OpenAI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(embedding): keep an explicit embedding-count check on the openai paths

Passing `encoding_format` disables the openai SDK's own empty-data guard, so a
provider answering 200 with missing embeddings surfaced as `IndexError: list
index out of range` on the single path and `zip() argument 2 is shorter than
argument 1` on the batch path. The latter is also #745's signature, which would
have left it with two unrelated causes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(live-llm): correct the openai-compatible embedding matrix env docs

The documented default dimensions said 2048 after the family moved to 3072, and
LIVE_EMBEDDING_OPENAI_COMPATIBLE_SEND_DIMENSIONS was missing entirely. Also
points the example and the coverage note at a model that is actually reachable,
and records that OpenRouter load-balances, so the base64 failure is per-attempt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(embedding): resolve openai encoding_format by mode instead of pinning float

Requesting float unconditionally costs ~3.6x the response bytes of base64 and up
to +83% latency on a 500-item batch, which the default deployment on real OpenAI
pays for nothing: only third-party OpenAI-compatible providers reject base64.

Adds EMBEDDING_MODEL_CONFIG__ENCODING_FORMAT_MODE, mirroring dimensions_mode.
`auto` keeps base64 when no base_url override is set or it points at
api.openai.com, and picks float elsewhere. The format is still always sent
explicitly, since the SDK otherwise injects base64 on its own.

Also corrects the _validate_embedding_count docstring, which said "fewer" where
the guard is an inequality.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(embedding): request base64 embeddings by omission, not by name

The openai SDK decodes a base64 response only when it injected the default
itself; naming any format makes it skip the decode and hand back the raw string,
which then fails the dimension check with "Expected 1536, got 8192". base64 mode
therefore has to omit the kwarg rather than pass it.

The unit fake returned float lists whatever was asked for, so it could not catch
this. It now mirrors the SDK and returns a base64 string for a named base64
request, which fails against the previous commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Aakash Kattelu <aakash@plasticlabs.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Vansh Sharma 2026-08-12 22:54:38 +05:30 committed by GitHub
parent e6f436e69d
commit 7b8c2917f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 346 additions and 9 deletions

View File

@ -285,6 +285,14 @@ Forwarding `dimensions=` to OpenAI-compatible providers is controlled by `EMBEDD
- `always`: always forward, regardless of whether `EMBEDDING_VECTOR_DIMENSIONS` was set. Use for OpenAI-compatible self-hosted providers that require it. Do not pick `always` *just* for same-as-default truncation — `auto` handles that case correctly as long as you set `EMBEDDING_VECTOR_DIMENSIONS=1536` explicitly in your environment. `always` is the right answer when your config layer might strip explicit "default-valued" envs, or when you want defense-in-depth.
- `never`: never forward. Explicit opt-out for providers that reject the parameter (e.g. `text-embedding-ada-002` if it slips past the known-rejecting allowlist).
The embedding wire format is controlled by `EMBEDDING_MODEL_CONFIG__ENCODING_FORMAT_MODE`. The `openai` SDK sends `encoding_format=base64` when the caller passes nothing, and some OpenAI-compatible providers answer that with an error or with empty data, so Honcho always sends the format explicitly:
- `auto` (default): `base64` when no `EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL` is set or it points at `api.openai.com`, `float` otherwise. base64 is roughly 3.6x smaller on the wire than JSON floats, so this keeps the compact format for real OpenAI and only pays the larger payload where compatibility requires it.
- `float`: always request floats. Use for a provider that rejects base64 but sits behind a host `auto` reads as OpenAI-compatible-but-capable.
- `base64`: always request base64. Use for a proxy that fronts real OpenAI (Azure OpenAI, LiteLLM) where `auto` cannot tell from the host that base64 is safe, and you want the smaller payload.
Both formats decode to identical vectors, so switching modes does not require re-embedding.
#### Bootstrapping non-default dimensions
`EMBEDDING_VECTOR_DIMENSIONS` is treated as immutable for the life of a deployment. The pgvector schema is dim-pinned by Alembic at `1536` by default; if you want a different dim, you must ALTER the empty columns once at bootstrap time.

View File

@ -3,6 +3,7 @@ import math
import os
from pathlib import Path
from typing import Annotated, Any, ClassVar, Literal, cast
from urllib.parse import urlparse
import tomllib
from dotenv import load_dotenv
@ -26,12 +27,17 @@ logger = logging.getLogger(__name__)
ModelTransport = Literal["anthropic", "openai", "gemini"]
EmbeddingTransport = Literal["openai", "gemini"]
EmbeddingDimensionsMode = Literal["auto", "always", "never"]
EmbeddingEncodingFormat = Literal["float", "base64"]
EmbeddingEncodingFormatMode = Literal["auto", "float", "base64"]
# OpenAI-compatible models that reject the `dimensions=` request parameter.
_EMBEDDING_KNOWN_REJECTING_MODELS: frozenset[str] = frozenset(
{"text-embedding-ada-002"}
)
# Hosts known to serve base64 embeddings, which are ~3.6x smaller on the wire.
_EMBEDDING_BASE64_CAPABLE_HOSTS: frozenset[str] = frozenset({"api.openai.com"})
def _default_embedding_model_for_transport(transport: EmbeddingTransport) -> str:
if transport == "gemini":
@ -386,6 +392,7 @@ class ConfiguredEmbeddingModelSettings(BaseModel):
transport: EmbeddingTransport = "openai"
overrides: ModelOverrideSettings = Field(default_factory=ModelOverrideSettings)
dimensions_mode: EmbeddingDimensionsMode = "auto"
encoding_format_mode: EmbeddingEncodingFormatMode = "auto"
max_batch_size: Annotated[int, Field(gt=0)] | None = None
@model_validator(mode="before")
@ -830,6 +837,22 @@ class EmbeddingSettings(HonchoSettings):
return False
return "VECTOR_DIMENSIONS" in self.model_fields_set
def resolve_encoding_format(self) -> EmbeddingEncodingFormat:
"""Pick the ``encoding_format`` for OpenAI embedding calls.
``auto`` keeps the compact base64 wire format on hosts known to support
it and falls back to float elsewhere, since OpenAI-compatible providers
may answer a base64 request with an error or empty data.
"""
mode = self.MODEL_CONFIG.encoding_format_mode
if mode != "auto":
return mode
base_url = self.MODEL_CONFIG.overrides.base_url
if not base_url:
return "base64"
host = urlparse(base_url).hostname
return "base64" if host in _EMBEDDING_BASE64_CAPABLE_HOSTS else "float"
class DeriverSettings(HonchoSettings):
model_config = SettingsConfigDict( # pyright: ignore

View File

@ -12,7 +12,12 @@ from google.genai import types as genai_types
from nanoid import generate as generate_nanoid
from openai import AsyncOpenAI
from .config import EmbeddingModelConfig, resolve_embedding_model_config, settings
from .config import (
EmbeddingEncodingFormat,
EmbeddingModelConfig,
resolve_embedding_model_config,
settings,
)
logger = logging.getLogger(__name__)
@ -173,11 +178,13 @@ class _EmbeddingClient:
max_input_tokens: int,
max_tokens_per_request: int,
send_dimensions: bool,
encoding_format: EmbeddingEncodingFormat = "float",
):
self.transport: str = config.transport
self.model: str = config.model
self.vector_dimensions: int = vector_dimensions
self.send_dimensions: bool = send_dimensions
self.encoding_format: EmbeddingEncodingFormat = encoding_format
if self.transport == "gemini":
if not config.api_key:
@ -226,6 +233,29 @@ class _EmbeddingClient:
)
return embedding
def _apply_encoding_format(self, openai_kwargs: dict[str, Any]) -> None:
"""Set the embedding wire format on an openai request.
Base64 is requested by omission, not by name: the SDK injects
`encoding_format=base64` when the caller passes nothing and decodes the
response, but skips that decode for any format the caller names, handing
back the raw base64 string.
"""
if self.encoding_format != "base64":
openai_kwargs["encoding_format"] = self.encoding_format
def _validate_embedding_count(self, expected: int, received: int) -> None:
"""Guard against a 200 response whose embedding count differs from inputs.
An explicit `encoding_format` disables the openai SDK's own empty-data
check, so this has to live here.
"""
if received != expected:
raise ValueError(
f"Embedding count mismatch for {self.transport}:{self.model}. "
+ f"Expected {expected}, got {received}."
)
async def embed(self, query: str) -> list[float]:
token_count = len(self.encoding.encode(query))
@ -264,9 +294,11 @@ class _EmbeddingClient:
async def _call_openai() -> list[float]:
openai_kwargs: dict[str, Any] = {"model": self.model, "input": [query]}
self._apply_encoding_format(openai_kwargs)
if self.send_dimensions:
openai_kwargs["dimensions"] = self.vector_dimensions
response = await openai_client.embeddings.create(**openai_kwargs)
self._validate_embedding_count(1, len(response.data))
return self._validate_embedding_dimensions(response.data[0].embedding)
return await _emit_embedding_call(
@ -473,9 +505,11 @@ class _EmbeddingClient:
"model": self.model,
"input": [item.text for item in batch],
}
self._apply_encoding_format(openai_kwargs)
if self.send_dimensions:
openai_kwargs["dimensions"] = self.vector_dimensions
response = await self.client.embeddings.create(**openai_kwargs)
self._validate_embedding_count(len(batch), len(response.data))
for item, embedding_data in zip(batch, response.data, strict=True):
result[item.text_id][item.chunk_index] = (
self._validate_embedding_dimensions(embedding_data.embedding)
@ -612,6 +646,7 @@ class EmbeddingClient:
max_input_tokens=settings.EMBEDDING.MAX_INPUT_TOKENS,
max_tokens_per_request=settings.EMBEDDING.MAX_TOKENS_PER_REQUEST,
send_dimensions=settings.EMBEDDING.resolve_send_dimensions(),
encoding_format=settings.EMBEDDING.resolve_encoding_format(),
)
self._instance_signature = signature
logger.debug(
@ -637,6 +672,7 @@ class EmbeddingClient:
settings.EMBEDDING.MAX_INPUT_TOKENS,
settings.EMBEDDING.MAX_TOKENS_PER_REQUEST,
settings.EMBEDDING.resolve_send_dimensions(),
settings.EMBEDDING.resolve_encoding_format(),
)
async def embed(self, query: str) -> list[float]:

View File

@ -28,6 +28,12 @@ Embedding-model env vars:
- `LIVE_EMBEDDING_GEMINI_MODELS` (default: `gemini-embedding-001,gemini-embedding-2`; add `gemini-embedding-2-preview` to cover the preview twin)
- `LIVE_EMBEDDING_OPENAI_MODELS` (default: `text-embedding-3-small`)
- `LIVE_EMBEDDING_OPENAI_COMPATIBLE_MODELS` (no default → skipped) — OpenAI transport pointed at a third-party OpenAI-compatible provider. Also reads `OPENROUTER_API_KEY`, `LIVE_EMBEDDING_OPENAI_COMPATIBLE_BASE_URL` (default `https://openrouter.ai/api/v1`), `LIVE_EMBEDDING_OPENAI_COMPATIBLE_DIMENSIONS` (default `3072`) and `LIVE_EMBEDDING_OPENAI_COMPATIBLE_SEND_DIMENSIONS` (default on; set to `0` for a provider that rejects OpenAI's `dimensions` param)
```bash
export OPENROUTER_API_KEY="sk-or-v1-..."
export LIVE_EMBEDDING_OPENAI_COMPATIBLE_MODELS="google/gemini-embedding-001"
```
Each model env var accepts a comma-separated list of bare model ids or provider-qualified ids.
@ -63,3 +69,4 @@ Coverage by provider:
- Gemini 2.5/3.0 classes: structured outputs, cached-content reuse, thought signatures, multi-turn tool replay
- Gemini 3.1 class: thinking and tool replay coverage by default; structured-output/caching coverage should only be added once Google documents support for that path
- Embeddings (`test_live_embeddings.py`): single embed, batched embed, batch-vs-single alignment, and chunk-to-id mapping for every configured embedding model. `gemini-embedding-2*` is the reason this exists — those models collapse a list of bare strings into one document (#745), and only a live call catches it
- OpenAI-compatible embedding providers (e.g. OpenRouter's `google/gemini-embedding-001`): the #932 surface. Those providers reject a base64 embedding request outright (HTTP 400) or answer HTTP 200 with empty data, so the whole matrix fails without `encoding_format="float"`. Real OpenAI accepts base64 happily, so only a third-party provider catches it. Note that OpenRouter load-balances across upstreams, so the base64 failure is per-attempt rather than guaranteed: a retry can land on an endpoint that accepts it. `test_live_openai_float_encoding_matches_base64` covers the other side, that the float switch must not move vectors on real OpenAI

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import os
from collections.abc import Iterator
from typing import Any
@ -51,6 +52,11 @@ def require_provider_key(model_spec: LiveModelSpec) -> None:
def require_embedding_key(spec: LiveEmbeddingSpec) -> str:
if spec.api_key_env:
key = os.getenv(spec.api_key_env)
if not key:
pytest.skip(f"Missing {spec.api_key_env} for live embedding {spec.id}")
return key
key = {
"openai": settings.LLM.OPENAI_API_KEY,
"gemini": settings.LLM.GEMINI_API_KEY,
@ -72,8 +78,10 @@ def make_embedding_client(
"vector_dimensions": spec.dimensions,
"max_input_tokens": 2048,
"max_tokens_per_request": 300_000,
# Both providers accept an explicit dimension request for these models.
"send_dimensions": True,
"send_dimensions": spec.send_dimensions,
# Pinned rather than resolved from settings: the matrix exists to exercise
# the float path that `auto` only picks for third-party providers.
"encoding_format": "float",
}
kwargs.update(overrides)
return _EmbeddingClient(
@ -81,6 +89,7 @@ def make_embedding_client(
transport=spec.transport,
model=spec.model,
api_key=require_embedding_key(spec),
base_url=spec.base_url,
),
**kwargs,
)

View File

@ -14,6 +14,13 @@ class LiveEmbeddingFamily:
dimensions: int
default_models: tuple[str, ...] = ()
docs_url: str | None = None
base_url: str | None = None
# Falls back to the transport's own key when unset.
api_key_env: str | None = None
dimensions_env: str | None = None
base_url_env: str | None = None
send_dimensions: bool = True
send_dimensions_env: str | None = None
@dataclass(frozen=True)
@ -24,6 +31,9 @@ class LiveEmbeddingSpec:
env_var: str
dimensions: int
docs_url: str | None = None
base_url: str | None = None
api_key_env: str | None = None
send_dimensions: bool = True
@property
def id(self) -> str:
@ -55,6 +65,25 @@ EMBEDDING_FAMILIES: tuple[LiveEmbeddingFamily, ...] = (
default_models=("text-embedding-3-small",),
docs_url="https://platform.openai.com/docs/guides/embeddings",
),
# OpenAI transport pointed at an OpenAI-compatible provider. This is the
# regression surface for #932: the openai SDK asks for base64 embeddings
# unless told otherwise, and third-party providers reject or empty out that
# request. Empty default_models → skipped unless set.
LiveEmbeddingFamily(
transport="openai",
family="openai_compatible_embedding",
env_var="LIVE_EMBEDDING_OPENAI_COMPATIBLE_MODELS",
dimensions=3072,
dimensions_env="LIVE_EMBEDDING_OPENAI_COMPATIBLE_DIMENSIONS",
base_url="https://openrouter.ai/api/v1",
base_url_env="LIVE_EMBEDDING_OPENAI_COMPATIBLE_BASE_URL",
api_key_env="OPENROUTER_API_KEY",
# Mirrors honcho's own behaviour once VECTOR_DIMENSIONS is set; turn off
# for a provider that rejects the param.
send_dimensions=True,
send_dimensions_env="LIVE_EMBEDDING_OPENAI_COMPATIBLE_SEND_DIMENSIONS",
docs_url="https://openrouter.ai/docs/api-reference/embeddings",
),
)
@ -72,6 +101,17 @@ def get_live_embedding_specs(
if transport is not None and family.transport != transport:
continue
models = _parse_env_models(os.getenv(family.env_var)) or family.default_models
dimensions = family.dimensions
if family.dimensions_env:
dimensions = int(os.getenv(family.dimensions_env) or family.dimensions)
base_url = family.base_url
if family.base_url_env:
base_url = os.getenv(family.base_url_env) or family.base_url
send_dimensions = family.send_dimensions
if family.send_dimensions_env:
raw = os.getenv(family.send_dimensions_env)
if raw is not None:
send_dimensions = raw.strip().lower() in {"1", "true", "yes"}
for model in models:
specs.append(
LiveEmbeddingSpec(
@ -79,8 +119,11 @@ def get_live_embedding_specs(
family=family.family,
model=model,
env_var=family.env_var,
dimensions=family.dimensions,
dimensions=dimensions,
docs_url=family.docs_url,
base_url=base_url,
api_key_env=family.api_key_env,
send_dimensions=send_dimensions,
)
)
return tuple(specs)

View File

@ -1,6 +1,9 @@
from __future__ import annotations
from typing import Any, cast
import pytest
from openai import AsyncOpenAI
from .conftest import cosine_similarity, make_embedding_client
from .embedding_matrix import LiveEmbeddingSpec, get_live_embedding_specs
@ -18,6 +21,9 @@ BATCH_TEXTS: list[str] = [
ALL_SPECS = get_live_embedding_specs()
GEMINI_SPECS = get_live_embedding_specs(transport="gemini")
OPENAI_NATIVE_SPECS = tuple(
spec for spec in ALL_SPECS if spec.family == "openai_embedding"
)
@pytest.mark.asyncio
@ -114,6 +120,32 @@ async def test_live_batch_embed_maps_chunks_to_their_ids(
)
@pytest.mark.asyncio
@pytest.mark.parametrize("spec", OPENAI_NATIVE_SPECS, ids=lambda spec: spec.id)
async def test_live_openai_float_encoding_matches_base64(
spec: LiveEmbeddingSpec,
) -> None:
"""Guard for #938, which switched the openai paths to `encoding_format="float"`.
Requesting floats must return the same vectors the SDK's base64 default
decoded to, so existing stored embeddings stay comparable.
"""
client = make_embedding_client(spec)
openai_client = cast(AsyncOpenAI, client.client)
base64_kwargs: dict[str, Any] = {"model": spec.model, "input": [BATCH_TEXTS[0]]}
if spec.send_dimensions:
base64_kwargs["dimensions"] = spec.dimensions
float_vector = await client.embed(BATCH_TEXTS[0])
# No encoding_format → SDK sends base64 and decodes it, the pre-#938 path.
base64_response = await openai_client.embeddings.create(**base64_kwargs)
similarity = cosine_similarity(float_vector, base64_response.data[0].embedding)
assert (
similarity > 0.99999
), f"{spec.id}: float encoding diverges from base64 (cosine={similarity:.8f})"
@pytest.mark.asyncio
@pytest.mark.parametrize("spec", GEMINI_SPECS, ids=lambda spec: spec.id)
async def test_live_gemini_batch_embed_survives_batch_split(

View File

@ -1,10 +1,16 @@
import array
import base64
from types import SimpleNamespace
from typing import Any, cast
import pytest
from google.genai import types as genai_types
from src.config import EmbeddingModelConfig, resolve_embedding_model_config
from src.config import (
EmbeddingEncodingFormat,
EmbeddingModelConfig,
resolve_embedding_model_config,
)
from src.embedding_client import (
BatchItem,
_EmbeddingClient, # pyright: ignore[reportPrivateUsage]
@ -20,6 +26,9 @@ class FakeOpenAIEmbeddingsAPI:
def __init__(self, embedding: list[float]) -> None:
self.embedding: list[float] = embedding
self.calls: list[dict[str, Any]] = []
# Simulate a provider answering 200 with missing embeddings.
self.returns_no_data: bool = False
self.truncate_data_to: int | None = None
async def create(
self,
@ -31,10 +40,21 @@ class FakeOpenAIEmbeddingsAPI:
call: dict[str, Any] = {"model": model, "input": input}
call.update(kwargs)
self.calls.append(call)
# Mirror the SDK: a named encoding_format skips its base64 decode, so the
# response carries the raw string instead of floats.
payload: Any = self.embedding
if kwargs.get("encoding_format") == "base64":
payload = base64.b64encode(
array.array("f", self.embedding).tobytes()
).decode()
if isinstance(input, list):
data = [SimpleNamespace(embedding=self.embedding) for _ in input]
data = [SimpleNamespace(embedding=payload) for _ in input]
else:
data = [SimpleNamespace(embedding=self.embedding)]
data = [SimpleNamespace(embedding=payload)]
if self.returns_no_data:
data = []
elif self.truncate_data_to is not None:
data = data[: self.truncate_data_to]
return SimpleNamespace(data=data)
@ -69,7 +89,11 @@ async def test_openai_embedding_client_uses_configured_model_and_dimensions(
assert embedding == [0.1] * 8
assert fake_embeddings.calls == [
{"model": "text-embedding-3-small", "input": ["hello world"]}
{
"model": "text-embedding-3-small",
"input": ["hello world"],
"encoding_format": "float",
}
]
@ -204,6 +228,7 @@ def _build_openai_client(
send_dimensions: bool,
vector_dimensions: int,
max_batch_size: int | None = None,
encoding_format: EmbeddingEncodingFormat = "float",
) -> tuple[_EmbeddingClient, FakeOpenAIEmbeddingsAPI]:
fake_embeddings = FakeOpenAIEmbeddingsAPI(embedding)
@ -226,6 +251,7 @@ def _build_openai_client(
max_input_tokens=8192,
max_tokens_per_request=300_000,
send_dimensions=send_dimensions,
encoding_format=encoding_format,
)
return client, fake_embeddings
@ -248,6 +274,7 @@ async def test_openai_embed_forwards_dimensions_when_send_dimensions_true(
{
"model": "text-embedding-3-small",
"input": ["hello"],
"encoding_format": "float",
"dimensions": 768,
}
]
@ -267,7 +294,13 @@ async def test_openai_embed_omits_dimensions_when_send_dimensions_false(
await client.embed("hello")
assert fake.calls == [{"model": "text-embedding-3-small", "input": ["hello"]}]
assert fake.calls == [
{
"model": "text-embedding-3-small",
"input": ["hello"],
"encoding_format": "float",
}
]
@pytest.mark.asyncio
@ -437,6 +470,86 @@ async def test_openai_batch_embed_forwards_dimensions(
assert fake.calls[0]["dimensions"] == 768
@pytest.mark.asyncio
async def test_openai_embed_requests_float_encoding_format(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The single-query path must request float embeddings explicitly.
Without an explicit encoding_format, the openai SDK defaults to base64,
which OpenAI-compatible providers such as OpenRouter answer with empty
embedding data for models that don't support base64 encoding.
"""
client, fake = _build_openai_client(
monkeypatch,
embedding=[0.1] * 8,
model="text-embedding-3-small",
send_dimensions=False,
vector_dimensions=8,
)
await client.embed("hello")
assert fake.calls[0]["encoding_format"] == "float"
@pytest.mark.asyncio
async def test_openai_batch_embed_requests_float_encoding_format(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The batch path must request float embeddings explicitly, like embed()."""
client, fake = _build_openai_client(
monkeypatch,
embedding=[0.1] * 8,
model="text-embedding-3-small",
send_dimensions=False,
vector_dimensions=8,
)
await client.batch_embed({"a": "hello", "b": "world"})
assert len(fake.calls) == 1
assert fake.calls[0]["encoding_format"] == "float"
@pytest.mark.asyncio
async def test_openai_embed_reports_missing_embedding_data(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An explicit encoding_format turns off the SDK's own empty-data check, so
a provider answering 200 with no embeddings must still fail legibly."""
client, fake = _build_openai_client(
monkeypatch,
embedding=[0.1] * 8,
model="text-embedding-3-small",
send_dimensions=False,
vector_dimensions=8,
)
fake.returns_no_data = True
with pytest.raises(ValueError, match="Embedding count mismatch"):
await client.embed("hello")
@pytest.mark.asyncio
async def test_openai_batch_embed_reports_short_embedding_data(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A batch answered with fewer embeddings than inputs must name the counts
rather than surface a bare zip() error."""
client, fake = _build_openai_client(
monkeypatch,
embedding=[0.1] * 8,
model="text-embedding-3-small",
send_dimensions=False,
vector_dimensions=8,
)
fake.truncate_data_to = 1
with pytest.raises(ValueError, match="Expected 2, got 1"):
await client.batch_embed({"a": "hello", "b": "world"})
def _build_embedding_settings(
env: dict[str, str],
monkeypatch: pytest.MonkeyPatch,
@ -449,6 +562,8 @@ def _build_embedding_settings(
"EMBEDDING_MODEL_CONFIG__MODEL",
"EMBEDDING_MODEL_CONFIG__TRANSPORT",
"EMBEDDING_MODEL_CONFIG__DIMENSIONS_MODE",
"EMBEDDING_MODEL_CONFIG__ENCODING_FORMAT_MODE",
"EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL",
"EMBEDDING_MODEL_CONFIG__MAX_BATCH_SIZE",
):
monkeypatch.delenv(key, raising=False)
@ -457,6 +572,70 @@ def _build_embedding_settings(
return EmbeddingSettings()
@pytest.mark.parametrize(
("env", "expected"),
[
# No base_url means real OpenAI, which serves base64 at ~1/3.6 the bytes.
({}, "base64"),
(
{
"EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL": "https://api.openai.com/v1"
},
"base64",
),
(
{
"EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL": "https://openrouter.ai/api/v1"
},
"float",
),
(
{"EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL": "http://localhost:8000/v1"},
"float",
),
({"EMBEDDING_MODEL_CONFIG__ENCODING_FORMAT_MODE": "float"}, "float"),
(
{
"EMBEDDING_MODEL_CONFIG__ENCODING_FORMAT_MODE": "base64",
"EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL": "https://openrouter.ai/api/v1",
},
"base64",
),
],
)
def test_resolve_encoding_format(
env: dict[str, str], expected: str, monkeypatch: pytest.MonkeyPatch
) -> None:
s = _build_embedding_settings(env, monkeypatch)
assert s.resolve_encoding_format() == expected
@pytest.mark.asyncio
async def test_openai_base64_mode_omits_encoding_format_and_returns_floats(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""base64 mode must request by omission on both paths.
Naming `base64` explicitly makes the SDK skip its own decode and hand back
the raw string, which then fails the dimension check.
"""
client, fake = _build_openai_client(
monkeypatch,
embedding=[0.1] * 8,
model="text-embedding-3-small",
send_dimensions=False,
vector_dimensions=8,
encoding_format="base64",
)
embedding = await client.embed("hello")
batched = await client.batch_embed({"a": "hello", "b": "world"})
assert all("encoding_format" not in call for call in fake.calls)
assert len(embedding) == 8
assert [len(vectors[0]) for vectors in batched.values()] == [8, 8]
def test_resolve_send_dimensions_auto_default_dim_returns_false(
monkeypatch: pytest.MonkeyPatch,
) -> None: