fix(embeddings): omit encoding_format when the client omits it on OpenAI-compatible calls

When no encoding_format is set on the call, the model config, or
LITELLM_DEFAULT_EMBEDDING_ENCODING_FORMAT, leave the field out of the
upstream request instead of defaulting to float, and bypass the OpenAI
SDK's own base64 default so nothing re-adds it on the wire. Downstreams
that reject encoding_format, such as a second LiteLLM proxy fronting
Bedrock Titan embeddings, now work when the client omits the field.

Fixes #38661
This commit is contained in:
mateo-berri 2026-08-29 11:06:29 -07:00
parent 352789257d
commit e22744c439
4 changed files with 115 additions and 42 deletions

View file

@ -9,8 +9,7 @@ For OpenAI-compatible embedding calls (including `openai/...` with a custom `api
1. Explicit value on the embedding call (`encoding_format=...`).
2. Model config (`litellm_params.encoding_format` on the proxy `model_list` entry).
3. Environment variable `LITELLM_DEFAULT_EMBEDDING_ENCODING_FORMAT` (e.g. in `.env` or container env).
4. Default **`float`**.
That avoids forwarding `encoding_format=None` to the provider/SDK where some servers behave poorly.
If none of those is set, or the winning value is the literal string `none`, the field is omitted from the upstream request entirely (LiteLLM also bypasses the OpenAI SDK's own base64 default), so OpenAI-compatible servers that reject `encoding_format` keep working.
To pass provider-specific parameters, see [provider-specific params](https://docs.litellm.ai/docs/completion/provider_specific_params).
To pass provider-specific parameters, see [provider-specific params](https://docs.litellm.ai/docs/completion/provider_specific_params).

View file

@ -12,9 +12,14 @@ if TYPE_CHECKING:
import openai
from openai import AsyncOpenAI, OpenAI
from openai._base_client import make_request_options
from openai._constants import RAW_RESPONSE_HEADER
from openai._legacy_response import LegacyAPIResponse
from openai._types import RequestOptions
from openai.types import CreateEmbeddingResponse
from openai.types.beta.assistant_deleted import AssistantDeleted
from openai.types.file_deleted import FileDeleted
from pydantic import BaseModel
from pydantic import BaseModel, TypeAdapter
from typing_extensions import overload
import litellm
@ -322,6 +327,24 @@ class OpenAIChatCompletionResponseIterator(BaseModelResponseIterator):
raise e
_EXTRA_HEADERS_ADAPTER: Final = TypeAdapter(dict[str, str] | None)
_EXTRA_QUERY_ADAPTER: Final = TypeAdapter(dict[str, object] | None)
def _embedding_request_without_sdk_defaults(
data: Mapping[str, object], timeout: float | httpx.Timeout
) -> tuple[dict[str, object], RequestOptions]:
body: Final = {k: v for k, v in data.items() if k not in ("extra_headers", "extra_query", "extra_body")}
extra_headers: Final = _EXTRA_HEADERS_ADAPTER.validate_python(data.get("extra_headers"))
options: Final = make_request_options(
extra_headers={**(extra_headers or {}), RAW_RESPONSE_HEADER: "true"},
extra_query=_EXTRA_QUERY_ADAPTER.validate_python(data.get("extra_query")),
extra_body=data.get("extra_body"),
timeout=timeout,
)
return body, options
class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
def __init__(self) -> None:
super().__init__()
@ -1148,19 +1171,16 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
data: dict,
timeout: float | httpx.Timeout,
logging_obj: LiteLLMLoggingObj,
):
"""
Helper to:
- call embeddings.create.with_raw_response when litellm.return_response_headers is True
- call embeddings.create by default
"""
try:
raw_response = await openai_aclient.embeddings.with_raw_response.create(**data, timeout=timeout)
headers: Final = dict(raw_response.headers)
response: Final = raw_response.parse()
return headers, response
except Exception as e:
raise e
) -> tuple[dict[str, str], CreateEmbeddingResponse]:
if "encoding_format" not in data:
body, options = _embedding_request_without_sdk_defaults(data, timeout)
bypass_response: Final = await openai_aclient.post(
"/embeddings", body=body, options=options, cast_to=CreateEmbeddingResponse
)
assert isinstance(bypass_response, LegacyAPIResponse)
return dict(bypass_response.headers), bypass_response.parse(to=CreateEmbeddingResponse)
raw_response: Final = await openai_aclient.embeddings.with_raw_response.create(**data, timeout=timeout)
return dict(raw_response.headers), raw_response.parse()
@track_llm_api_timing()
def make_sync_openai_embedding_request(
@ -1169,20 +1189,16 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
data: dict,
timeout: float | httpx.Timeout,
logging_obj: LiteLLMLoggingObj,
):
"""
Helper to:
- call embeddings.create.with_raw_response when litellm.return_response_headers is True
- call embeddings.create by default
"""
try:
raw_response = openai_client.embeddings.with_raw_response.create(**data, timeout=timeout)
headers: Final = dict(raw_response.headers)
response: Final = raw_response.parse()
return headers, response
except Exception as e:
raise e
) -> tuple[dict[str, str], CreateEmbeddingResponse]:
if "encoding_format" not in data:
body, options = _embedding_request_without_sdk_defaults(data, timeout)
bypass_response: Final = openai_client.post(
"/embeddings", body=body, options=options, cast_to=CreateEmbeddingResponse
)
assert isinstance(bypass_response, LegacyAPIResponse)
return dict(bypass_response.headers), bypass_response.parse(to=CreateEmbeddingResponse)
raw_response: Final = openai_client.embeddings.with_raw_response.create(**data, timeout=timeout)
return dict(raw_response.headers), raw_response.parse()
async def aembedding(
self,

View file

@ -6289,18 +6289,15 @@ def embedding(
if headers is not None and headers != {}:
optional_params["extra_headers"] = headers
if encoding_format is not None:
optional_params["encoding_format"] = encoding_format
requested_encoding_format: Final = (
encoding_format
or optional_params.get("encoding_format")
or get_secret_str("LITELLM_DEFAULT_EMBEDDING_ENCODING_FORMAT")
)
if requested_encoding_format is None or requested_encoding_format.strip().lower() == "none":
optional_params.pop("encoding_format", None)
else:
env_fmt: Final = get_secret_str("LITELLM_DEFAULT_EMBEDDING_ENCODING_FORMAT")
if env_fmt is not None and env_fmt.strip().lower() == "none":
optional_params.pop("encoding_format", None)
else:
_default_fmt: Final = optional_params.get("encoding_format") or env_fmt or "float"
if _default_fmt.strip().lower() == "none":
optional_params.pop("encoding_format", None)
else:
optional_params["encoding_format"] = _default_fmt
optional_params["encoding_format"] = requested_encoding_format
api_version = None

View file

@ -3181,3 +3181,64 @@ def test_stream_chunk_builder_defers_cost_to_logging_obj_when_usage_cost_absent(
assert response is not None
assert response._hidden_params.get("response_cost") is None
def _mock_openai_embedding_route(respx_mock: respx.MockRouter) -> respx.Route:
return respx_mock.post("https://api.openai.com/v1/embeddings").mock(
return_value=httpx.Response(
200,
json={
"object": "list",
"data": [{"object": "embedding", "index": 0, "embedding": [0.1, 0.2, 0.3]}],
"model": "text-embedding-3-small",
"usage": {"prompt_tokens": 2, "total_tokens": 2},
},
)
)
def test_embedding_openai_omits_encoding_format_when_client_omits_it(respx_mock: respx.MockRouter) -> None:
mock_route: Final = _mock_openai_embedding_route(respx_mock)
response: Final = litellm.embedding(model="openai/text-embedding-3-small", input=["hello"], api_key="sk-test")
request_body: Final = json.loads(mock_route.calls.last.request.read())
assert "encoding_format" not in request_body
assert response.data[0]["embedding"] == [0.1, 0.2, 0.3]
def test_embedding_openai_forwards_explicit_encoding_format(respx_mock: respx.MockRouter) -> None:
mock_route: Final = _mock_openai_embedding_route(respx_mock)
litellm.embedding(
model="openai/text-embedding-3-small", input=["hello"], api_key="sk-test", encoding_format="base64"
)
request_body: Final = json.loads(mock_route.calls.last.request.read())
assert request_body["encoding_format"] == "base64"
def test_embedding_openai_env_var_sets_default_encoding_format(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("LITELLM_DEFAULT_EMBEDDING_ENCODING_FORMAT", "float")
mock_route: Final = _mock_openai_embedding_route(respx_mock)
litellm.embedding(model="openai/text-embedding-3-small", input=["hello"], api_key="sk-test")
request_body: Final = json.loads(mock_route.calls.last.request.read())
assert request_body["encoding_format"] == "float"
@pytest.mark.asyncio
async def test_aembedding_openai_omits_encoding_format_when_client_omits_it(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
mock_route: Final = _mock_openai_embedding_route(respx_mock)
response: Final = await litellm.aembedding(model="openai/text-embedding-3-small", input=["hello"], api_key="sk-test")
request_body: Final = json.loads(mock_route.calls.last.request.read())
assert "encoding_format" not in request_body
assert response.data[0]["embedding"] == [0.1, 0.2, 0.3]