Skip to content

Commit 9661ec2

Browse files
lanesketdaavoo
andauthored
fix: handle empty provider response in _convert_chat_completion (#803)
## Summary Some providers (observed via OpenRouter -> OpenAI Model -> Azure) return HTTP 200 with an effectively empty `ChatCompletion` object when they fail to process a request. The existing code in `_convert_chat_completion` then crashes with an unhandled `TypeError` - not a typed `AnyLLMError` — so consumers cannot catch it cleanly. ## Bug When a provider returns an empty response, the OpenAI SDK parses it into: ``` ChatCompletion(id=None, choices=None, created=None, model=None, object='chat.completion', ...) ``` The guard `if not isinstance(response.created, int)` catches `None` (since `None` is not an `int`), then calls `int(None)` which raises: ``` TypeError: int() argument must be a string, a bytes-like object or a real number, not 'NoneType' ``` This turns a provider-side issue into an untyped exception that propagates up the call stack. ## How we found it While testing request size limits in our LLM proxy service, we sent a **large (~49 MB) fake base64 image** to `openai/gpt-4o-mini` via OpenRouter: ```python import base64, httpx # 49 MB of garbage bytes encoded as base64 (~65 MB encoded) fake_image = base64.b64encode(b"Y" * 49_000_000).decode() # note: instead of openrouter.ai here was our proxy url which using any-llm sdk response = httpx.post( "https://openrouter.ai/api/v1/chat/completions", headers={"Authorization": "Bearer <KEY>"}, json={ "model": "openai/gpt-4o-mini", "messages": [{ "role": "user", "content": [ {"type": "text", "text": "Analyze this image"}, {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{fake_image}"}}, ], }], "max_tokens": 10, }, timeout=120, ) ``` **What happened:** The request reached the backing provider (Azure via OpenRouter). Small fake images get a clean `400 Bad Request ("Invalid image data")`. But the 49 MB payload overwhelmed the provider backend (~25s processing), and it returned a **malformed 200 OK with an empty body** instead of a proper error. The OpenAI SDK parsed this into a `ChatCompletion` with all fields `None`. When `any_llm` tried to normalize this object, `int(None)` crashed. **This is non-deterministic** - the same request may get routed to a different provider backend that returns a proper 400. But when it hits a backend that chokes, the crash is 100% reproducible. ## Fix Add an early guard clause in `_convert_chat_completion` that detects completely empty responses (`id`, `choices`, and `model` all `None`) and raises `ProviderError` with a clear message. **Why `AND` (all three None), not `OR`?** Some providers may legitimately return one field as `None` in non-standard responses. Requiring all three ensures we only catch truly empty/broken responses. --------- Co-authored-by: daavoo <daviddelaiglesiacastro@gmail.com>
1 parent 038cfcf commit 9661ec2

File tree

3 files changed

+61
-1
lines changed

3 files changed

+61
-1
lines changed

src/any_llm/providers/openai/utils.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from openai.types.chat.chat_completion import ChatCompletion as OpenAIChatCompletion
66

77
from any_llm.constants import REASONING_FIELD_NAMES
8+
from any_llm.exceptions import ProviderError
89
from any_llm.logging import logger
910
from any_llm.types.completion import ChatCompletion
1011

@@ -58,6 +59,17 @@ def _convert_chat_completion(response: OpenAIChatCompletion) -> ChatCompletion:
5859
response.object,
5960
)
6061
response.object = "chat.completion"
62+
63+
# Detect completely empty responses (all required fields None).
64+
# Some providers return 200 OK with an empty body on malformed input
65+
# instead of a proper error. Fail fast with a clear message.
66+
if response.id is None and response.choices is None and response.model is None:
67+
msg = (
68+
"Provider returned an empty response with no id, choices, or model. "
69+
"This usually means the provider failed to process the request."
70+
)
71+
raise ProviderError(msg)
72+
6173
if not isinstance(response.created, int):
6274
# Sambanova returns a float instead of an int.
6375
logger.warning(

src/any_llm/providers/together/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def _create_openai_chunk_from_together_chunk(together_chunk: TogetherChatComplet
3535
logger.warning("Together delta_content missing 'content' attribute: %s", delta_content)
3636
content = getattr(delta_content, "content", None)
3737
if getattr(delta_content, "role", None):
38-
role = cast("Literal['assistant', 'user', 'system']", delta_content.role) # type: ignore[attr-defined]
38+
role = cast("Literal['assistant', 'user', 'system']", delta_content.role)
3939
if hasattr(delta_content, "reasoning") and delta_content.reasoning:
4040
reasoning = Reasoning(content=delta_content.reasoning)
4141

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import pytest
2+
from openai.types.chat.chat_completion import ChatCompletion as OpenAIChatCompletion
3+
4+
from any_llm.exceptions import ProviderError
5+
from any_llm.providers.openai.utils import _convert_chat_completion
6+
7+
8+
def test_convert_chat_completion_with_empty_response() -> None:
9+
# Simulating the malformed response described in the PR
10+
# ChatCompletion(id=None, choices=None, created=None, model=None, object='chat.completion', ...)
11+
openai_response = OpenAIChatCompletion.model_construct(
12+
id=None,
13+
choices=None,
14+
created=None,
15+
model=None,
16+
object="chat.completion",
17+
)
18+
19+
with pytest.raises(ProviderError) as exc_info:
20+
_convert_chat_completion(openai_response)
21+
22+
assert "Provider returned an empty response" in str(exc_info.value)
23+
24+
25+
def test_convert_chat_completion_with_partial_none_response() -> None:
26+
# If not all THREE (id, choices, model) are None, it should NOT raise ProviderError early.
27+
# It might fail later if other required fields like 'created' are missing or invalid,
28+
# but the specific guard being tested here should not trigger.
29+
openai_response = OpenAIChatCompletion.model_construct(
30+
id="test-id",
31+
choices=None,
32+
created=1234567890,
33+
model=None,
34+
object="chat.completion",
35+
)
36+
37+
# In this case, it will fail later during ChatCompletion.model_validate(normalized)
38+
# because 'choices' is None, or during _normalize_openai_dict_response if it expect choices to be a list.
39+
40+
# Actually _normalize_openai_dict_response handles choices=None:
41+
# choices = response_dict.get("choices")
42+
# if isinstance(choices, list): ...
43+
44+
# But ChatCompletion.model_validate(normalized) will fail because choices is required.
45+
from pydantic import ValidationError
46+
47+
with pytest.raises(ValidationError):
48+
_convert_chat_completion(openai_response)

0 commit comments

Comments
 (0)