# Fix for issue #30004: EmbeddingResponse(**response_json) fails with "must be a mapping, not list"

## Root Cause

The issue affects two separate code paths, both triggered when an OpenAI-compatible embedding server returns a JSON array (list) instead of a JSON object (dict) at the top level. This can happen when the `api_base` URL is misconfigured (missing the `/v1` API prefix that many servers like llama.cpp require), causing the server to return an unexpected response.

### Path 1: `openai_like` handler (affects `lm_studio/`, `hosted_vllm/` via `openai_like`, `llamafile/`)

`litellm/llms/openai_like/embedding/handler.py` — both `aembedding()` and `embedding()` directly called `EmbeddingResponse(**response_json)` where `response_json = response.json()`. If `response.json()` returned a list, the `**` unpacking failed with `TypeError: argument after ** must be a mapping, not list`.

Additionally, this handler was inconsistent with the rest of the codebase: other embedding handlers (e.g. `HostedVLLMEmbeddingConfig`, `HuggingFaceEmbeddingConfig`) use `convert_to_model_response_object()` to parse embedding responses, but `OpenAILikeEmbeddingHandler` bypassed it and created `EmbeddingResponse` directly.

### Path 2: `openai/` handler

`litellm/llms/openai/openai.py` — `make_openai_embedding_request()` and `make_sync_openai_embedding_request()` called `raw_response.parse()` from the OpenAI SDK, which can return raw parsed JSON (a list or dict) instead of a Pydantic model when the server response doesn't match the expected schema. The callers then called `.model_dump()` on the result, which failed with `AttributeError: 'list' object has no attribute 'model_dump'`.

## Changes

### `litellm/llms/openai_like/embedding/handler.py`

- Added import for `convert_to_model_response_object`
- In `aembedding()`: replaced `EmbeddingResponse(**response_json)` with `convert_to_model_response_object(response_object=response_json, model_response_object=model_response, response_type="embedding")`
- In `embedding()`: replaced `litellm.EmbeddingResponse(**response_json)` with `convert_to_model_response_object(response_object=response_json, model_response_object=model_response or EmbeddingResponse(), response_type="embedding")`
- Both methods now validate that `response_json` is a dict before processing; if it's a list, they raise a clear `OpenAILikeError` with guidance to check the `api_base` includes the API prefix (e.g. `/v1`)

### `litellm/llms/openai/openai.py`

- Added `_normalize_embedding_response()` helper method that handles three cases:
  - Pydantic model with `.model_dump()` → returns the dict
  - Plain dict → returns as-is
  - List or other → wraps in a dict with `{"object": "list", "data": [...], "model": "...", "usage": {...}}`
- Updated `make_openai_embedding_request()` and `make_sync_openai_embedding_request()` to return dicts (via the new helper) instead of raw Pydantic model objects
- Updated callers (`aembedding()` and `embedding()`) to use the dict directly instead of calling `.model_dump()`

### `tests/test_litellm/llms/openai_like/embedding/test_openai_like_embedding.py`

- Added `test_embedding_response_list_raises_type_error`: verifies that a list response raises a clear `OpenAILikeError` with guidance about the API prefix
- Added `test_embedding_response_dict_uses_convert_to_model_response_object`: verifies that a valid dict response is correctly parsed through `convert_to_model_response_object`
- Added import for `OpenAILikeError`

## Type

🐛 Bug Fix

## Relevant issues

Fixes #30004
This commit is contained in:
Dr1985 2026-06-09 15:03:47 +08:00
parent 51ba6e39cd
commit 1c82e5da61
3 changed files with 132 additions and 10 deletions

View file

@ -1201,17 +1201,35 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
Helper to:
- call embeddings.create.with_raw_response when litellm.return_response_headers is True
- call embeddings.create by default
Returns (headers, response_dict) where response_dict is always a dict.
"""
try:
raw_response = await openai_aclient.embeddings.with_raw_response.create(
**data, timeout=timeout
) # type: ignore
headers = dict(raw_response.headers)
response = raw_response.parse()
return headers, response
parsed = raw_response.parse()
return headers, self._normalize_embedding_response(parsed, data)
except Exception as e:
raise e
def _normalize_embedding_response(
self,
response: Any,
request_data: dict,
) -> dict:
if hasattr(response, "model_dump"):
return response.model_dump()
if isinstance(response, dict):
return response
return {
"object": "list",
"data": list(response) if isinstance(response, list) else [],
"model": request_data.get("model", ""),
"usage": {"prompt_tokens": 0, "total_tokens": 0},
}
@track_llm_api_timing()
def make_sync_openai_embedding_request(
self,
@ -1224,6 +1242,8 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
Helper to:
- call embeddings.create.with_raw_response when litellm.return_response_headers is True
- call embeddings.create by default
Returns (headers, response_dict) where response_dict is always a dict.
"""
try:
raw_response = openai_client.embeddings.with_raw_response.create(
@ -1231,8 +1251,8 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
) # type: ignore
headers = dict(raw_response.headers)
response = raw_response.parse()
return headers, response
parsed = raw_response.parse()
return headers, self._normalize_embedding_response(parsed, data)
except Exception as e:
raise e
@ -1266,16 +1286,15 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
logging_obj=logging_obj,
)
logging_obj.model_call_details["response_headers"] = headers
stringified_response = response.model_dump()
## LOGGING
logging_obj.post_call(
input=input,
api_key=api_key,
additional_args={"complete_input_dict": data},
original_response=stringified_response,
original_response=response,
)
returned_response: EmbeddingResponse = convert_to_model_response_object(
response_object=stringified_response,
response_object=response,
model_response_object=model_response,
response_type="embedding",
_response_headers=headers,
@ -1377,7 +1396,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
original_response=sync_embedding_response,
)
response: EmbeddingResponse = convert_to_model_response_object(
response_object=sync_embedding_response.model_dump(),
response_object=sync_embedding_response,
model_response_object=model_response,
_response_headers=headers,
response_type="embedding",

View file

@ -13,6 +13,9 @@ from litellm.llms.custom_httpx.http_handler import (
HTTPHandler,
get_async_httpx_client,
)
from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import (
convert_to_model_response_object,
)
from litellm.types.utils import EmbeddingResponse
from ..common_utils import OpenAILikeBase, OpenAILikeError
@ -72,7 +75,22 @@ class OpenAILikeEmbeddingHandler(OpenAILikeBase):
additional_args={"complete_input_dict": data},
original_response=response_json,
)
return EmbeddingResponse(**response_json)
if not isinstance(response_json, dict):
raise OpenAILikeError(
status_code=500,
message=(
"Embedding response is not a mapping. "
"If you are using an OpenAI-compatible server, "
"make sure the api_base includes the API prefix (e.g. /v1). "
f"Received type: {type(response_json).__name__}. "
f"Response: {str(response_json)[:500]}"
),
)
return convert_to_model_response_object(
response_object=response_json,
model_response_object=model_response,
response_type="embedding",
)
except Exception as e:
## LOGGING
logging_obj.post_call(
@ -153,4 +171,19 @@ class OpenAILikeEmbeddingHandler(OpenAILikeBase):
original_response=response_json,
)
return litellm.EmbeddingResponse(**response_json)
if not isinstance(response_json, dict):
raise OpenAILikeError(
status_code=500,
message=(
"Embedding response is not a mapping. "
"If you are using an OpenAI-compatible server, "
"make sure the api_base includes the API prefix (e.g. /v1). "
f"Received type: {type(response_json).__name__}. "
f"Response: {str(response_json)[:500]}"
),
)
return convert_to_model_response_object(
response_object=response_json,
model_response_object=model_response or EmbeddingResponse(),
response_type="embedding",
)

View file

@ -7,6 +7,7 @@ from unittest.mock import MagicMock, Mock, patch
import pytest
from litellm.llms.openai_like.common_utils import OpenAILikeError
from litellm.llms.openai_like.embedding.handler import OpenAILikeEmbeddingHandler
from litellm.types.utils import EmbeddingResponse
@ -346,3 +347,72 @@ class TestOpenAILikeEmbeddingHandler:
assert sent_data["model"] == "test-model"
assert sent_data["input"] == ["test input"]
assert "encoding_format" not in sent_data
def test_embedding_response_list_raises_type_error(self):
handler = OpenAILikeEmbeddingHandler()
mock_client = MagicMock()
mock_response = Mock()
mock_response.json.return_value = [
{"object": "embedding", "embedding": [0.1, 0.2, 0.3], "index": 0}
]
mock_response.raise_for_status = Mock()
mock_client.post.return_value = mock_response
mock_logging = MagicMock()
with patch.object(
handler,
"_validate_environment",
return_value=("http://test.com/v1/embeddings", {}),
):
with pytest.raises(OpenAILikeError) as exc_info:
handler.embedding(
model="test-model",
input=["test input"],
timeout=60.0,
logging_obj=mock_logging,
api_key="test-key",
api_base="http://test.com/v1",
optional_params={},
client=mock_client,
)
assert "not a mapping" in str(exc_info.value.message)
assert "api_base includes the API prefix" in str(exc_info.value.message)
def test_embedding_response_dict_uses_convert_to_model_response_object(self):
handler = OpenAILikeEmbeddingHandler()
mock_client = MagicMock()
mock_response = Mock()
mock_response.json.return_value = {
"object": "list",
"data": [{"object": "embedding", "embedding": [0.1, 0.2, 0.3], "index": 0}],
"model": "test-model",
"usage": {"prompt_tokens": 5, "total_tokens": 5},
}
mock_response.raise_for_status = Mock()
mock_client.post.return_value = mock_response
mock_logging = MagicMock()
with patch.object(
handler,
"_validate_environment",
return_value=("http://test.com/v1/embeddings", {}),
):
response = handler.embedding(
model="test-model",
input=["test input"],
timeout=60.0,
logging_obj=mock_logging,
api_key="test-key",
api_base="http://test.com/v1",
optional_params={},
client=mock_client,
)
assert response.model == "test-model"
assert response.object == "list"
assert len(response.data) == 1
assert response.data[0]["embedding"] == [0.1, 0.2, 0.3]