litellm/tests/unit/llms/databricks/test_databricks_streaming_utils.py
devin-ai-integration[bot] 17f621e116
fix(vertex_ai): route Model Garden models through the generateContent adapter (#41836)
* fix(vertex_ai): route Model Garden models through the generateContent adapter

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(vertex_ai): annotate generateContent route test parameters

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(vertex_ai): type litellm_params in get_vertex_ai_model_route

* fix(vertex_ai): bill and log Model Garden generateContent calls like chat completions

Streamed Model Garden calls through the generateContent adapter now request
stream usage where the deployment supports it, keep cached-token details, and
look up the provider-relative cost key, so they bill like the non-stream call.
The adapter forwards router metadata, passes upstream errors through with their
status, countTokens falls back to local counting for non-Gemini routes, and the
native Gemini config is limited to the Gemini routes.

* fix(google_genai): keep adapter stream usage, admin opt-out, single failure count, and base_model routing

Streamed generateContent through the chat adapter now ends with the upstream usage, honors
always_include_stream_usage false and the caller's stream_options, counts one failure per
failed call, and routes Vertex deployments by their base_model like chat does

* fix(google_genai): ask for stream usage on every adapter stream so Claude streams end with usage

* test(google_genai): expect the trailing usage chunk on bridged OpenAI streams

The OpenAI bridge test now sends the usage chunk OpenAI returns for include_usage and expects it translated into usageMetadata. The response_format support helper goes back to its single-purpose form since its only other caller is gone.

* fix(google_genai): end adapter streams with one STOP event carrying usage, and keep upstream usage after the finish chunk

* fix(google_genai): send the held STOP event when no usage arrives, keep a caller's stream_options, and route countTokens like generateContent

* test(integration): cover the moved Vertex routes, adapter stream usage, the shared iterator and usage settings over the wire

* test(integration): add cache-hit twins for the stream usage setting and non-stream cells for every iterator member

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-15 08:06:49 -07:00

224 lines
7.6 KiB
Python

"""
Regression test for the databricks streaming chunk parser.
OpenAI-compatible servers (e.g. Vertex AI Model Garden vLLM endpoints) send a final
usage-only chunk with an empty `choices` list when `stream_options.include_usage` is
set. `chunk_parser` previously did `choices[0]` unconditionally, raising
`IndexError` -> `MidStreamFallbackError` and crashing the stream.
"""
import json
from typing import Final
import httpx
import pytest
import respx
import litellm
from litellm.llms.databricks.streaming_utils import ModelResponseIterator
def test_chunk_parser_handles_empty_choices_usage_chunk():
"""A usage-only final chunk (empty choices) must not raise IndexError."""
iterator = ModelResponseIterator(streaming_response=None, sync_stream=True)
usage_only_chunk = {
"id": "chatcmpl-x",
"object": "chat.completion.chunk",
"created": 1,
"model": "m",
"choices": [],
"usage": {"prompt_tokens": 20, "completion_tokens": 8, "total_tokens": 28},
}
result = iterator.chunk_parser(chunk=usage_only_chunk)
assert result["text"] == ""
assert result["is_finished"] is False
assert result["usage"] is not None
assert result["usage"]["prompt_tokens"] == 20
assert result["usage"]["completion_tokens"] == 8
def test_chunk_parser_empty_choices_without_usage():
"""An empty-choices chunk with no usage block returns usage=None, no error."""
iterator = ModelResponseIterator(streaming_response=None, sync_stream=True)
chunk = {
"id": "chatcmpl-x",
"object": "chat.completion.chunk",
"created": 1,
"model": "m",
"choices": [],
}
result = iterator.chunk_parser(chunk=chunk)
assert result["text"] == ""
assert result["usage"] is None
def test_chunk_parser_normal_content_chunk_still_works():
"""A regular content chunk is unaffected by the empty-choices guard."""
iterator = ModelResponseIterator(streaming_response=None, sync_stream=True)
chunk = {
"id": "chatcmpl-x",
"object": "chat.completion.chunk",
"created": 1,
"model": "m",
"choices": [{"index": 0, "delta": {"content": "hi"}, "finish_reason": None}],
}
result = iterator.chunk_parser(chunk=chunk)
assert result["text"] == "hi"
def test_chunk_parser_keeps_cached_tokens_on_usage_only_chunk():
iterator = ModelResponseIterator(streaming_response=None, sync_stream=True)
usage_only_chunk = {
"id": "chatcmpl-x",
"object": "chat.completion.chunk",
"created": 1,
"model": "m",
"choices": [],
"usage": {
"prompt_tokens": 340,
"completion_tokens": 1,
"total_tokens": 341,
"prompt_tokens_details": {"cached_tokens": 320},
},
}
result = iterator.chunk_parser(chunk=usage_only_chunk)
assert result["usage"] is not None
assert result["usage"]["prompt_tokens_details"] is not None
assert result["usage"]["prompt_tokens_details"]["cached_tokens"] == 320
def _sse_event(body: dict[str, object]) -> bytes:
return f"data: {json.dumps(body)}\n\n".encode()
@pytest.mark.respx(assert_all_called=True)
def test_stream_usage_after_finish_chunk_reaches_the_caller(respx_mock: respx.MockRouter) -> None:
chunk_header: Final = {"id": "chatcmpl-x", "object": "chat.completion.chunk", "created": 1, "model": "sabia-3"}
stream_body: Final = b"".join(
(
_sse_event({**chunk_header, "choices": [{"index": 0, "delta": {"role": "assistant", "content": "144"}}]}),
_sse_event({**chunk_header, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}),
_sse_event(
{
**chunk_header,
"choices": [],
"usage": {
"prompt_tokens": 340,
"completion_tokens": 1,
"total_tokens": 341,
"prompt_tokens_details": {"cached_tokens": 320},
},
}
),
b"data: [DONE]\n\n",
)
)
respx_mock.post("http://upstream.test/chat/completions").mock(
return_value=httpx.Response(200, headers={"content-type": "text/event-stream"}, content=stream_body)
)
stream: Final = litellm.completion(
model="maritalk/sabia-3",
api_base="http://upstream.test",
api_key="sk-test",
messages=[{"role": "user", "content": "What is 12*12?"}],
stream=True,
stream_options={"include_usage": True},
)
usages: Final = [chunk.usage for chunk in stream if getattr(chunk, "usage", None)]
assert [(usage.prompt_tokens, usage.prompt_tokens_details.cached_tokens) for usage in usages] == [(340, 320)]
def test_chunk_parser_keeps_usage_on_the_finish_chunk():
iterator: Final = ModelResponseIterator(streaming_response=None, sync_stream=True)
result: Final = iterator.chunk_parser(
chunk={
"id": "chatcmpl-x",
"object": "chat.completion.chunk",
"created": 1,
"model": "m",
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 340, "completion_tokens": 1, "total_tokens": 341},
}
)
assert result["is_finished"] is True
assert result["usage"] is not None
assert result["usage"]["prompt_tokens"] == 340
_SSE_LINES_WITH_BLANK_SEPARATORS: Final = (
'data: {"id":"c","object":"chat.completion.chunk","created":1,"model":"m",'
'"choices":[{"index":0,"delta":{"content":"144"},"finish_reason":null}]}',
"",
'data: {"id":"c","object":"chat.completion.chunk","created":1,"model":"m",'
'"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
"",
"data: {not json",
"",
'data: {"id":"c","object":"chat.completion.chunk","created":1,"model":"m","choices":[],'
'"usage":{"prompt_tokens":340,"completion_tokens":1,"total_tokens":341}}',
"",
"data: [DONE]",
"",
)
@pytest.mark.asyncio
async def test_async_stream_skips_blank_sse_lines_and_keeps_the_usage_chunk():
async def lines():
for line in _SSE_LINES_WITH_BLANK_SEPARATORS:
yield line
chunks: Final = [chunk async for chunk in ModelResponseIterator(streaming_response=lines(), sync_stream=False)]
assert [(chunk["text"], chunk["finish_reason"], (chunk["usage"] or {}).get("prompt_tokens")) for chunk in chunks] == [
("144", "", None),
("", "stop", None),
("", "", None),
("", "", 340),
]
class _BrokenTransport(ValueError):
pass
def test_sync_stream_transport_error_surfaces_as_runtime_error():
def lines():
yield _SSE_LINES_WITH_BLANK_SEPARATORS[0]
raise _BrokenTransport("connection reset")
with pytest.raises(RuntimeError, match="connection reset"):
list(ModelResponseIterator(streaming_response=lines(), sync_stream=True))
@pytest.mark.asyncio
async def test_async_stream_transport_error_surfaces_as_runtime_error():
async def lines():
yield _SSE_LINES_WITH_BLANK_SEPARATORS[0]
raise _BrokenTransport("connection reset")
with pytest.raises(RuntimeError, match="connection reset"):
[chunk async for chunk in ModelResponseIterator(streaming_response=lines(), sync_stream=False)]
@pytest.mark.asyncio
async def test_async_stream_without_done_marker_ends_after_the_last_chunk():
async def lines():
for line in _SSE_LINES_WITH_BLANK_SEPARATORS[:4]:
yield line
chunks: Final = [chunk async for chunk in ModelResponseIterator(streaming_response=lines(), sync_stream=False)]
assert [(chunk["text"], chunk["finish_reason"]) for chunk in chunks] == [("144", ""), ("", "stop")]