Commit 9661ec2
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- src/any_llm/providers
- openai
- together
- tests/unit/providers
3 files changed
+61
-1
lines changed| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| 8 | + | |
8 | 9 | | |
9 | 10 | | |
10 | 11 | | |
| |||
58 | 59 | | |
59 | 60 | | |
60 | 61 | | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
61 | 73 | | |
62 | 74 | | |
63 | 75 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
35 | 35 | | |
36 | 36 | | |
37 | 37 | | |
38 | | - | |
| 38 | + | |
39 | 39 | | |
40 | 40 | | |
41 | 41 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
0 commit comments