mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-24 05:45:11 +00:00
Merge 6879090caa into 82ca10cc2d
This commit is contained in:
commit
f58a032654
5 changed files with 277 additions and 32 deletions
|
|
@ -185,13 +185,48 @@ class AsyncGoogleGenAIGenerateContentStreamingIterator(BaseGoogleGenAIGenerateCo
|
|||
# Gemini streamGenerateContent uses SSE line framing; aiter_lines keeps
|
||||
# large inlineData payloads (e.g. image/jpeg) intact within one event.
|
||||
self.stream_iterator = response.aiter_lines()
|
||||
self._primed_chunk: bytes | None = None
|
||||
self._primed_exhausted: bool = False
|
||||
|
||||
async def _read_next_chunk(self) -> bytes:
|
||||
"""Read one SSE event off the line iterator. Callers record it, so a primed event is counted once, when it is handed over."""
|
||||
chunk: Final[bytes] = await _anext_google_genai_sse_chunk(self.stream_iterator)
|
||||
return chunk
|
||||
|
||||
async def prime_first_chunk(self) -> None:
|
||||
"""
|
||||
Read the first SSE event now, inside the caller's own retry window.
|
||||
|
||||
The handler that builds this iterator returns as soon as the response headers
|
||||
arrive, so a stream that drops before its first event would otherwise fail in
|
||||
whoever consumes the iterator, long after the call that opened it was recorded
|
||||
as a success and its retry budget spent. Gemini sends no lifecycle frames
|
||||
before content, so one event is enough to prove the stream is alive. A stream
|
||||
that ends before its first event is a valid empty answer and is replayed as an
|
||||
immediate end of stream.
|
||||
"""
|
||||
if self._primed_chunk is not None or self._primed_exhausted:
|
||||
return
|
||||
try:
|
||||
self._primed_chunk = await self._read_next_chunk()
|
||||
except StopAsyncIteration:
|
||||
self._primed_exhausted = True
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
if self._primed_chunk is not None:
|
||||
primed: Final[bytes] = self._primed_chunk
|
||||
self._primed_chunk = None
|
||||
self.collected_chunks.append(primed)
|
||||
return primed
|
||||
if self._primed_exhausted:
|
||||
self._primed_exhausted = False
|
||||
await self._handle_async_streaming_logging()
|
||||
raise StopAsyncIteration
|
||||
try:
|
||||
chunk: Final = await _anext_google_genai_sse_chunk(self.stream_iterator)
|
||||
chunk = await self._read_next_chunk()
|
||||
self.collected_chunks.append(chunk)
|
||||
return chunk
|
||||
except StopAsyncIteration:
|
||||
|
|
|
|||
|
|
@ -12198,7 +12198,7 @@ class BaseLLMHTTPHandler:
|
|||
stream=True,
|
||||
)
|
||||
# Return async streaming iterator
|
||||
return AsyncGoogleGenAIGenerateContentStreamingIterator(
|
||||
iterator: Final = AsyncGoogleGenAIGenerateContentStreamingIterator(
|
||||
response=response,
|
||||
model=model,
|
||||
logging_obj=logging_obj,
|
||||
|
|
@ -12213,6 +12213,18 @@ class BaseLLMHTTPHandler:
|
|||
response_headers=response.headers,
|
||||
),
|
||||
)
|
||||
# Read the first event before returning: this call is what the router
|
||||
# wraps in its retry loop, so a stream that drops before its first event
|
||||
# has to fail here, not in the consumer that reads the iterator later.
|
||||
try:
|
||||
await iterator.prime_first_chunk()
|
||||
except Exception:
|
||||
try:
|
||||
await response.aclose()
|
||||
except Exception as close_error: # noqa: BLE001 # closing must not mask the stream failure
|
||||
verbose_logger.debug("Could not close the dropped google genai stream: %s", close_error)
|
||||
raise
|
||||
return iterator
|
||||
else:
|
||||
response = await async_httpx_client.post(
|
||||
url=api_base,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,14 @@ def _mock_gemini_post_response():
|
|||
}
|
||||
]
|
||||
}
|
||||
|
||||
async def _aiter_lines():
|
||||
# The async handler reads the first SSE event before returning, so a stream
|
||||
# stand-in has to end (a bare MagicMock yields mock lines forever).
|
||||
yield 'data: {"candidates": [{"content": {"parts": [{"text": "hi"}]}}]}'
|
||||
yield ""
|
||||
|
||||
resp.aiter_lines = _aiter_lines
|
||||
return resp
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import json
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from litellm.google_genai.streaming_iterator import (
|
||||
|
|
@ -154,3 +155,80 @@ async def test_async_streaming_iterator_forwards_sse_comment_events():
|
|||
|
||||
chunk = await iterator.__anext__()
|
||||
assert chunk == b": keepalive\n\n"
|
||||
|
||||
|
||||
def _async_response_with_lines(lines: list[str], drop_after: Exception | None = None):
|
||||
"""An httpx.Response stand-in whose line iterator yields `lines`, then raises `drop_after`."""
|
||||
mock_response = MagicMock()
|
||||
|
||||
async def _aiter_lines():
|
||||
for line in lines:
|
||||
yield line
|
||||
if drop_after is not None:
|
||||
raise drop_after
|
||||
|
||||
mock_response.aiter_lines = _aiter_lines
|
||||
return mock_response
|
||||
|
||||
|
||||
def _async_iterator(mock_response) -> AsyncGoogleGenAIGenerateContentStreamingIterator:
|
||||
return AsyncGoogleGenAIGenerateContentStreamingIterator(
|
||||
response=mock_response,
|
||||
model="gemini-3.8-flash",
|
||||
logging_obj=MagicMock(spec=LiteLLMLoggingObj),
|
||||
generate_content_provider_config=MagicMock(),
|
||||
litellm_metadata={},
|
||||
custom_llm_provider="vertex_ai",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_priming_surfaces_a_stream_that_drops_before_its_first_event():
|
||||
"""A drop on the first read fails the call that opened the stream, not the consumer that reads it later."""
|
||||
mock_response = _async_response_with_lines([], drop_after=httpx.ReadError("Connection closed."))
|
||||
|
||||
iterator = _async_iterator(mock_response)
|
||||
|
||||
with pytest.raises(httpx.ReadError):
|
||||
await iterator.prime_first_chunk()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_priming_replays_the_first_event_without_losing_or_duplicating_it():
|
||||
mock_response = _async_response_with_lines(['data: {"first": 1}', "", 'data: {"second": 2}', ""])
|
||||
|
||||
iterator = _async_iterator(mock_response)
|
||||
await iterator.prime_first_chunk()
|
||||
|
||||
assert await iterator.__anext__() == b'data: {"first": 1}\n\n'
|
||||
assert await iterator.__anext__() == b'data: {"second": 2}\n\n'
|
||||
with pytest.raises(StopAsyncIteration):
|
||||
await iterator.__anext__()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_priming_an_empty_stream_ends_the_iterator_and_still_logs():
|
||||
"""A stream that ends before its first event is a valid (empty) answer, not a failure."""
|
||||
mock_response = _async_response_with_lines([])
|
||||
|
||||
iterator = _async_iterator(mock_response)
|
||||
iterator._handle_async_streaming_logging = AsyncMock()
|
||||
|
||||
await iterator.prime_first_chunk()
|
||||
|
||||
with pytest.raises(StopAsyncIteration):
|
||||
await iterator.__anext__()
|
||||
|
||||
iterator._handle_async_streaming_logging.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_priming_twice_does_not_consume_a_second_event():
|
||||
mock_response = _async_response_with_lines(['data: {"first": 1}', "", 'data: {"second": 2}', ""])
|
||||
|
||||
iterator = _async_iterator(mock_response)
|
||||
await iterator.prime_first_chunk()
|
||||
await iterator.prime_first_chunk()
|
||||
|
||||
assert await iterator.__anext__() == b'data: {"first": 1}\n\n'
|
||||
assert await iterator.__anext__() == b'data: {"second": 2}\n\n'
|
||||
|
|
|
|||
|
|
@ -410,9 +410,7 @@ def test_response_api_handler_sends_no_stream_options_when_web_search_forces_a_n
|
|||
):
|
||||
sent: Final = _SentRequestRecorder()
|
||||
recorder: Final = _AgenticLoopKwargsRecorder()
|
||||
monkeypatch.setattr(
|
||||
litellm, "callbacks", [WebSearchInterceptionLogger(enabled_providers=["azure"]), recorder]
|
||||
)
|
||||
monkeypatch.setattr(litellm, "callbacks", [WebSearchInterceptionLogger(enabled_providers=["azure"]), recorder])
|
||||
logging_obj: Final = Mock()
|
||||
logging_obj.dynamic_success_callbacks = []
|
||||
|
||||
|
|
@ -2298,7 +2296,9 @@ async def test_async_anthropic_messages_handler_passes_deployment_api_base_to_ag
|
|||
super().__init__()
|
||||
self.hook_kwargs: dict | None = None
|
||||
|
||||
async def async_should_run_agentic_loop(self, response, model, messages, tools, stream, custom_llm_provider, kwargs):
|
||||
async def async_should_run_agentic_loop(
|
||||
self, response, model, messages, tools, stream, custom_llm_provider, kwargs
|
||||
):
|
||||
self.hook_kwargs = dict(kwargs)
|
||||
return False, {}
|
||||
|
||||
|
|
@ -2658,9 +2658,7 @@ def _transform_subtitle_response(payload):
|
|||
|
||||
|
||||
def test_subtitle_synthesis_fallback_without_timings_drops_words():
|
||||
response = _transform_subtitle_response(
|
||||
{"text": "hello world", "words": [{"word": "hello"}, {"word": "world"}]}
|
||||
)
|
||||
response = _transform_subtitle_response({"text": "hello world", "words": [{"word": "hello"}, {"word": "world"}]})
|
||||
|
||||
assert response.text == "hello world"
|
||||
assert "words" not in response
|
||||
|
|
@ -3652,7 +3650,13 @@ def _capture_video_create_request(captured):
|
|||
captured["body"] = request.content
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"id": "video_123", "object": "video", "status": "queued", "created_at": 1712697600, "model": "sora-2"},
|
||||
json={
|
||||
"id": "video_123",
|
||||
"object": "video",
|
||||
"status": "queued",
|
||||
"created_at": 1712697600,
|
||||
"model": "sora-2",
|
||||
},
|
||||
)
|
||||
|
||||
return respond
|
||||
|
|
@ -3675,7 +3679,9 @@ def test_video_generation_without_file_sends_multipart_form_data():
|
|||
captured = {}
|
||||
client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_video_create_request(captured))))
|
||||
|
||||
result = BaseLLMHTTPHandler().video_generation_handler(client=client, **_video_create_call_kwargs(OpenAIVideoConfig()))
|
||||
result = BaseLLMHTTPHandler().video_generation_handler(
|
||||
client=client, **_video_create_call_kwargs(OpenAIVideoConfig())
|
||||
)
|
||||
|
||||
assert captured["content_type"].startswith("multipart/form-data")
|
||||
assert _multipart_text_fields(captured["content_type"], captured["body"]) == {
|
||||
|
|
@ -3715,7 +3721,9 @@ def test_azure_video_generation_without_file_sends_multipart_form_data():
|
|||
captured = {}
|
||||
client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_video_create_request(captured))))
|
||||
|
||||
result = BaseLLMHTTPHandler().video_generation_handler(client=client, **_video_create_call_kwargs(AzureVideoConfig()))
|
||||
result = BaseLLMHTTPHandler().video_generation_handler(
|
||||
client=client, **_video_create_call_kwargs(AzureVideoConfig())
|
||||
)
|
||||
|
||||
assert captured["content_type"].startswith("multipart/form-data")
|
||||
assert _multipart_text_fields(captured["content_type"], captured["body"]) == {
|
||||
|
|
@ -3730,7 +3738,9 @@ def test_video_generation_json_provider_keeps_json_body():
|
|||
captured = {}
|
||||
client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_video_create_request(captured))))
|
||||
|
||||
result = BaseLLMHTTPHandler().video_generation_handler(client=client, **_video_create_call_kwargs(_JSONBodyVideoConfig()))
|
||||
result = BaseLLMHTTPHandler().video_generation_handler(
|
||||
client=client, **_video_create_call_kwargs(_JSONBodyVideoConfig())
|
||||
)
|
||||
|
||||
assert captured["content_type"] == "application/json"
|
||||
assert json.loads(captured["body"]) == {"model": "sora-2", "prompt": "a cat surfing", "seconds": "4"}
|
||||
|
|
@ -3760,6 +3770,7 @@ def test_video_generation_with_input_reference_keeps_file_multipart():
|
|||
AZURE_AI_BASE = "https://myfoundry.services.ai.azure.com"
|
||||
AZURE_AI_CHAT_COMPLETIONS_URL = f"{AZURE_AI_BASE}/models/chat/completions"
|
||||
|
||||
|
||||
def _a_tool_with_an_unsupported_field() -> dict:
|
||||
return {
|
||||
"type": "function",
|
||||
|
|
@ -3767,14 +3778,13 @@ def _a_tool_with_an_unsupported_field() -> dict:
|
|||
"strict": True,
|
||||
}
|
||||
|
||||
|
||||
A_COMPLETION = {
|
||||
"id": "chatcmpl-1",
|
||||
"object": "chat.completion",
|
||||
"created": 1,
|
||||
"model": "grok-3",
|
||||
"choices": [
|
||||
{"index": 0, "message": {"role": "assistant", "content": "sent"}, "finish_reason": "stop"}
|
||||
],
|
||||
"choices": [{"index": 0, "message": {"role": "assistant", "content": "sent"}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
||||
}
|
||||
|
||||
|
|
@ -3818,9 +3828,7 @@ def _call_azure_ai(recorder: _RecordedAzureAI, **overrides):
|
|||
|
||||
|
||||
def test_a_tool_field_the_provider_rejects_is_dropped_and_the_call_retried():
|
||||
recorder = _RecordedAzureAI(
|
||||
[_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)]
|
||||
)
|
||||
recorder = _RecordedAzureAI([_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)])
|
||||
|
||||
response = _call_azure_ai(recorder)
|
||||
|
||||
|
|
@ -3831,9 +3839,7 @@ def test_a_tool_field_the_provider_rejects_is_dropped_and_the_call_retried():
|
|||
|
||||
|
||||
def test_the_retry_changes_only_the_field_the_provider_named():
|
||||
recorder = _RecordedAzureAI(
|
||||
[_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)]
|
||||
)
|
||||
recorder = _RecordedAzureAI([_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)])
|
||||
|
||||
_call_azure_ai(recorder)
|
||||
|
||||
|
|
@ -3872,9 +3878,7 @@ def test_an_extra_input_outside_a_tool_is_not_retried_unless_dropping_params_was
|
|||
|
||||
|
||||
def test_an_extra_input_outside_a_tool_is_retried_when_dropping_params_was_asked_for():
|
||||
recorder = _RecordedAzureAI(
|
||||
[_rejection(UNRELATED_REJECTION), httpx.Response(200, json=A_COMPLETION)]
|
||||
)
|
||||
recorder = _RecordedAzureAI([_rejection(UNRELATED_REJECTION), httpx.Response(200, json=A_COMPLETION)])
|
||||
|
||||
response = _call_azure_ai(recorder, drop_params=True)
|
||||
|
||||
|
|
@ -3888,9 +3892,7 @@ async def test_a_tool_field_the_provider_rejects_is_dropped_and_retried_on_the_a
|
|||
):
|
||||
import respx
|
||||
|
||||
recorder = _RecordedAzureAI(
|
||||
[_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)]
|
||||
)
|
||||
recorder = _RecordedAzureAI([_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)])
|
||||
|
||||
with respx.mock(assert_all_called=True) as router:
|
||||
router.post(AZURE_AI_CHAT_COMPLETIONS_URL).mock(side_effect=recorder)
|
||||
|
|
@ -4031,7 +4033,11 @@ async def test_create_batch_says_which_setting_is_missing_when_the_provider_reso
|
|||
|
||||
with pytest.raises(ValueError, match="api_base is required for create_batch"):
|
||||
await BaseLLMHTTPHandler().create_batch(
|
||||
create_batch_data={"input_file_id": "file_1", "endpoint": "/v1/chat/completions", "completion_window": "24h"},
|
||||
create_batch_data={
|
||||
"input_file_id": "file_1",
|
||||
"endpoint": "/v1/chat/completions",
|
||||
"completion_window": "24h",
|
||||
},
|
||||
litellm_params={},
|
||||
provider_config=provider_config,
|
||||
headers={},
|
||||
|
|
@ -4212,7 +4218,9 @@ def _start_async_completion(config, logging_obj=None):
|
|||
custom_llm_provider="openai",
|
||||
model_response=ModelResponse(),
|
||||
encoding=None,
|
||||
logging_obj=logging_obj if logging_obj is not None else Mock(dynamic_success_callbacks=None, model_call_details={}),
|
||||
logging_obj=logging_obj
|
||||
if logging_obj is not None
|
||||
else Mock(dynamic_success_callbacks=None, model_call_details={}),
|
||||
optional_params={},
|
||||
timeout=10.0,
|
||||
litellm_params={},
|
||||
|
|
@ -4600,7 +4608,9 @@ async def test_async_realtime_bridges_a_transcription_session_through_the_provid
|
|||
logging_obj.dispatch_failure_handlers = AsyncMock()
|
||||
handler = BaseLLMHTTPHandler()
|
||||
|
||||
with patch.object(handler, "_open_realtime_backend_ws", AsyncMock(side_effect=AssertionError("dialed a websocket"))) as dial:
|
||||
with patch.object(
|
||||
handler, "_open_realtime_backend_ws", AsyncMock(side_effect=AssertionError("dialed a websocket"))
|
||||
) as dial:
|
||||
await handler.async_realtime(
|
||||
model="chirp_3",
|
||||
websocket=client_ws,
|
||||
|
|
@ -5313,3 +5323,105 @@ async def test_retrieve_file_rejects_unverified_bedrock_range_error(
|
|||
)
|
||||
finally:
|
||||
sync_client.close()
|
||||
|
||||
|
||||
class _DroppingByteStream(httpx.AsyncByteStream):
|
||||
"""A response body that fails on its first read, the way a dropped connection does."""
|
||||
|
||||
async def __aiter__(self):
|
||||
raise httpx.ReadError("Connection closed.")
|
||||
yield b"" # pragma: no cover - unreachable, makes this an async generator
|
||||
|
||||
|
||||
def _google_genai_streaming_logging_obj():
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
|
||||
logging_obj = Logging(
|
||||
model="gemini-3.8-flash",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=True,
|
||||
call_type="agenerate_content_stream",
|
||||
start_time=time.time(),
|
||||
litellm_call_id="google-genai-stream-priming",
|
||||
function_id="f",
|
||||
)
|
||||
logging_obj.update_environment_variables(
|
||||
model="gemini-3.8-flash",
|
||||
user="",
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
custom_llm_provider="gemini",
|
||||
)
|
||||
return logging_obj
|
||||
|
||||
|
||||
async def _call_google_genai_stream(upstream: httpx.Response):
|
||||
"""Drive async_generate_content_handler against `upstream` faked at the HTTP boundary.
|
||||
|
||||
The provider config and the HTTP client are the real ones; only the upstream
|
||||
response is a double, so the error mapping under test is the provider's own.
|
||||
"""
|
||||
from litellm.llms.gemini.google_genai.transformation import GoogleGenAIConfig
|
||||
|
||||
transport: Final = httpx.MockTransport(lambda request: upstream)
|
||||
async with httpx.AsyncClient(transport=transport) as async_client:
|
||||
client: Final = AsyncHTTPHandler()
|
||||
await client.close()
|
||||
client.client = async_client
|
||||
return await BaseLLMHTTPHandler().async_generate_content_handler(
|
||||
model="gemini-3.8-flash",
|
||||
contents=[{"role": "user", "parts": [{"text": "hi"}]}],
|
||||
generate_content_provider_config=GoogleGenAIConfig(),
|
||||
generate_content_config_dict={},
|
||||
tools=None,
|
||||
custom_llm_provider="gemini",
|
||||
litellm_params=GenericLiteLLMParams(
|
||||
api_key="test-key",
|
||||
api_base="https://generativelanguage.googleapis.com",
|
||||
),
|
||||
logging_obj=_google_genai_streaming_logging_obj(),
|
||||
client=client,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_genai_async_stream_priming_fails_retriably_when_the_stream_drops_before_its_first_event():
|
||||
"""
|
||||
A stream that drops before its first event must fail inside the caller's own retry window.
|
||||
|
||||
The handler returns as soon as the response headers arrive, so without priming the first
|
||||
body read happens in the proxy's data generator, long after the router recorded the call
|
||||
as a success and spent its retry budget.
|
||||
"""
|
||||
upstream: Final = httpx.Response(
|
||||
status_code=200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
stream=_DroppingByteStream(),
|
||||
)
|
||||
|
||||
with pytest.raises(BaseLLMException) as exc_info:
|
||||
await _call_google_genai_stream(upstream)
|
||||
|
||||
# The router retries on this status, so the drop now lands inside its retry loop.
|
||||
assert litellm.should_retry(exc_info.value.status_code)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_genai_async_stream_priming_returns_an_iterator_that_still_yields_the_whole_stream():
|
||||
"""Priming must not consume the first event: the consumer sees the entire stream."""
|
||||
from litellm.google_genai.streaming_iterator import (
|
||||
AsyncGoogleGenAIGenerateContentStreamingIterator,
|
||||
)
|
||||
|
||||
upstream: Final = httpx.Response(
|
||||
status_code=200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
content=b'data: {"first": 1}\n\ndata: {"second": 2}\n\n',
|
||||
)
|
||||
|
||||
iterator = await _call_google_genai_stream(upstream)
|
||||
|
||||
assert isinstance(iterator, AsyncGoogleGenAIGenerateContentStreamingIterator)
|
||||
assert await iterator.__anext__() == b'data: {"first": 1}\n\n'
|
||||
assert await iterator.__anext__() == b'data: {"second": 2}\n\n'
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue