mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-31 06:56:59 +00:00
* fix(anthropic): send /v1/messages PDF documents to OpenAI-shaped providers as file parts * refactor(anthropic): hold the chat completions document change for a later commit * fix(anthropic): send /v1/messages PDFs as file parts and keep file parts on azure_ai * fix(snowflake): turn file parts into document blocks for Cortex Claude * fix(prompt_security): sanitize chat file parts that carry file_data * fix: answer 400 or drop PDFs for models that cannot read them Models whose model_info or cost map entry sets supports_pdf_input to false, and Ollama, now get the PDF dropped under drop_params and a 400 from litellm without it. GigaChat uploads file parts, SAP keeps sending them as image_url data URLs, azure_ai passes dict content through, and annotations other than url_citation move to provider_specific_fields instead of failing the response. * fix(router): scope supports_pdf_input to its deployment and keep provider annotation keys Mark the azure_ai chat models in the cost map that cannot read PDFs, so drop_params drops the PDF and litellm answers its own 400 without it. Keep url_citation annotations whole, move only typed provider annotations to provider_specific_fields, and split them the same way when a stream is rebuilt. * fix(vertex_ai): send PDF file parts to Vertex partner models as image_url Vertex AI's OpenAI-compatible partner endpoint rejects the chat `file` part, so PDFs from /v1/messages reach those models as the image_url data URL they took before. Mark native DeepSeek chat models as unable to read PDFs, and treat a file part as a PDF by its format or filename when it has no data URL. * fix(cost-map): leave native DeepSeek PDF support unset /v1/messages reaches native DeepSeek through its own Anthropic endpoint, so the marks never applied there and would only change chat completions, which this PR does not otherwise touch. * fix(main): drop a user turn that held only a PDF under drop_params Dropping the PDF from a PDF-only user turn left an empty content list, which Foundry and OpenAI-shaped providers reject with a 400, so drop_params still failed for that request. The emptied user turn is now removed. * fix: read OCI PDFs, gate deployment base models, and stop empty drops OCI generic chat sends file parts as image data URLs again, so OCI Gemini keeps reading PDFs from /v1/messages and chat file parts. The PDF gate now reads model_info.base_model, drops PDFs under drop_params for azure_ai deployments that never declared PDF support (the old flattening behavior), and answers 400 when dropping the PDF leaves no user message. The cost map marks the Vertex partner text models that a live probe showed answering without reading the PDF. * test(integration): cover the PDF input gate, messages PDF file parts and provider annotations Wire cells for the supports_pdf_input check on chat, chat streaming, /v1/messages and the Responses bridge, its model_info, base_model, cost map and Ollama sources, drop_params at deployment and request level, azure_ai file parts, sibling deployment isolation, Anthropic SDK PDFs reaching azure as titled file parts, Gemini and Bedrock document controls, Prompt Security sanitizing a /v1/messages PDF, and OpenRouter file annotations live, cached and streamed * test(integration): cover Snowflake Claude and SAP in the messages PDF wire cells * fix(azure_ai): flatten non-PDF file parts to text as before and cover the GigaChat PDF upload * fix(main): PDF check reads the deployment's top-level base_model * fix(main): keep roles alternating after drop_params removes a PDF-only turn When ensure_alternating_roles is set, re-run role normalization on the messages left after the PDF check drops a turn, so two assistant turns never end up next to each other * test: type the PDF and annotation tests the PR added * test: type the GigaChat, SAP and Snowflake PDF tests
236 lines
7.2 KiB
Python
236 lines
7.2 KiB
Python
"""
|
|
Tests for stream_chunk_builder annotation merging.
|
|
|
|
Previously, stream_chunk_builder only took annotations from the FIRST
|
|
annotation chunk, losing any annotations that arrived in later chunks.
|
|
This fix merges annotations from ALL chunks.
|
|
"""
|
|
|
|
from typing import Final
|
|
|
|
from litellm import stream_chunk_builder
|
|
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices
|
|
|
|
|
|
def test_stream_chunk_builder_merges_annotations_from_multiple_chunks():
|
|
"""
|
|
stream_chunk_builder must merge annotations from ALL streaming chunks,
|
|
not just take them from the first annotation chunk.
|
|
|
|
Providers may spread annotations across multiple chunks (e.g. Gemini
|
|
sends grounding metadata in the final chunk, while intermediate chunks
|
|
may carry different annotations).
|
|
"""
|
|
annotation_a = {
|
|
"type": "url_citation",
|
|
"url_citation": {
|
|
"url": "https://example.com/a",
|
|
"title": "Source A",
|
|
"start_index": 0,
|
|
"end_index": 10,
|
|
},
|
|
}
|
|
annotation_b = {
|
|
"type": "url_citation",
|
|
"url_citation": {
|
|
"url": "https://example.com/b",
|
|
"title": "Source B",
|
|
"start_index": 20,
|
|
"end_index": 30,
|
|
},
|
|
}
|
|
|
|
chunks = [
|
|
ModelResponseStream(
|
|
id="chatcmpl-test",
|
|
created=1700000000,
|
|
model="test-model",
|
|
object="chat.completion.chunk",
|
|
choices=[
|
|
StreamingChoices(
|
|
finish_reason=None,
|
|
index=0,
|
|
delta=Delta(
|
|
content="Part one. ",
|
|
role="assistant",
|
|
annotations=[annotation_a],
|
|
),
|
|
)
|
|
],
|
|
),
|
|
ModelResponseStream(
|
|
id="chatcmpl-test",
|
|
created=1700000000,
|
|
model="test-model",
|
|
object="chat.completion.chunk",
|
|
choices=[
|
|
StreamingChoices(
|
|
finish_reason=None,
|
|
index=0,
|
|
delta=Delta(content="Part two."),
|
|
)
|
|
],
|
|
),
|
|
ModelResponseStream(
|
|
id="chatcmpl-test",
|
|
created=1700000000,
|
|
model="test-model",
|
|
object="chat.completion.chunk",
|
|
choices=[
|
|
StreamingChoices(
|
|
finish_reason="stop",
|
|
index=0,
|
|
delta=Delta(
|
|
content=None,
|
|
annotations=[annotation_b],
|
|
),
|
|
)
|
|
],
|
|
),
|
|
]
|
|
|
|
response = stream_chunk_builder(chunks=chunks)
|
|
assert response is not None
|
|
|
|
message = response["choices"][0]["message"]
|
|
assert message.annotations is not None
|
|
assert len(message.annotations) == 2
|
|
assert message.annotations[0] == annotation_a
|
|
assert message.annotations[1] == annotation_b
|
|
|
|
|
|
def test_stream_chunk_builder_single_annotation_chunk_still_works():
|
|
"""
|
|
When annotations come from a single chunk (most common case),
|
|
stream_chunk_builder must still work correctly (no regression).
|
|
"""
|
|
annotation = {
|
|
"type": "url_citation",
|
|
"url_citation": {
|
|
"url": "https://example.com/only",
|
|
"title": "Only Source",
|
|
"start_index": 0,
|
|
"end_index": 5,
|
|
},
|
|
}
|
|
|
|
chunks = [
|
|
ModelResponseStream(
|
|
id="chatcmpl-test",
|
|
created=1700000000,
|
|
model="test-model",
|
|
object="chat.completion.chunk",
|
|
choices=[
|
|
StreamingChoices(
|
|
finish_reason=None,
|
|
index=0,
|
|
delta=Delta(content="Hello", role="assistant"),
|
|
)
|
|
],
|
|
),
|
|
ModelResponseStream(
|
|
id="chatcmpl-test",
|
|
created=1700000000,
|
|
model="test-model",
|
|
object="chat.completion.chunk",
|
|
choices=[
|
|
StreamingChoices(
|
|
finish_reason="stop",
|
|
index=0,
|
|
delta=Delta(content=None, annotations=[annotation]),
|
|
)
|
|
],
|
|
),
|
|
]
|
|
|
|
response = stream_chunk_builder(chunks=chunks)
|
|
assert response is not None
|
|
|
|
message = response["choices"][0]["message"]
|
|
assert message.annotations is not None
|
|
assert len(message.annotations) == 1
|
|
assert message.annotations[0] == annotation
|
|
|
|
|
|
def test_stream_chunk_builder_no_annotations():
|
|
"""
|
|
When no chunks contain annotations, the message should not have
|
|
an annotations key (no regression).
|
|
"""
|
|
chunks = [
|
|
ModelResponseStream(
|
|
id="chatcmpl-test",
|
|
created=1700000000,
|
|
model="test-model",
|
|
object="chat.completion.chunk",
|
|
choices=[
|
|
StreamingChoices(
|
|
finish_reason=None,
|
|
index=0,
|
|
delta=Delta(content="Hello", role="assistant"),
|
|
)
|
|
],
|
|
),
|
|
ModelResponseStream(
|
|
id="chatcmpl-test",
|
|
created=1700000000,
|
|
model="test-model",
|
|
object="chat.completion.chunk",
|
|
choices=[
|
|
StreamingChoices(
|
|
finish_reason="stop",
|
|
index=0,
|
|
delta=Delta(content=None),
|
|
)
|
|
],
|
|
),
|
|
]
|
|
|
|
response = stream_chunk_builder(chunks=chunks)
|
|
assert response is not None
|
|
|
|
message = response["choices"][0]["message"]
|
|
assert not hasattr(message, "annotations") or message.annotations is None
|
|
|
|
|
|
def test_stream_chunk_builder_moves_typed_provider_annotations_like_non_streaming() -> None:
|
|
url_citation: Final = {
|
|
"type": "url_citation",
|
|
"url_citation": {"url": "https://example.com", "title": "Example", "start_index": 0, "end_index": 5},
|
|
}
|
|
file_annotation: Final = {"type": "file", "file": {"hash": "abc123", "name": "report.pdf"}}
|
|
chunks: Final = [
|
|
ModelResponseStream(
|
|
id="chatcmpl-test",
|
|
created=1700000000,
|
|
model="test-model",
|
|
object="chat.completion.chunk",
|
|
choices=[
|
|
StreamingChoices(
|
|
finish_reason=None,
|
|
index=0,
|
|
delta=Delta(content="Hello", role="assistant", provider_specific_fields={"region": "eu"}),
|
|
)
|
|
],
|
|
),
|
|
ModelResponseStream(
|
|
id="chatcmpl-test",
|
|
created=1700000000,
|
|
model="test-model",
|
|
object="chat.completion.chunk",
|
|
choices=[
|
|
StreamingChoices(
|
|
finish_reason="stop",
|
|
index=0,
|
|
delta=Delta(content=None, annotations=[url_citation, file_annotation]),
|
|
)
|
|
],
|
|
),
|
|
]
|
|
|
|
response: Final = stream_chunk_builder(chunks=chunks)
|
|
assert response is not None
|
|
|
|
message: Final = response["choices"][0]["message"]
|
|
assert message.annotations == [url_citation]
|
|
assert message.provider_specific_fields == {"region": "eu", "annotations": [file_annotation]}
|