mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +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
190 lines
7.5 KiB
Python
190 lines
7.5 KiB
Python
import json
|
|
import uuid
|
|
from collections.abc import Callable, Iterator
|
|
from itertools import chain
|
|
from typing import Final
|
|
|
|
import httpx
|
|
from integration._support.client import Gateway
|
|
from integration._support.wire import Reply, Request, Wire, wire_server
|
|
from integration.caching.response_cache_case import await_entry, redis_store
|
|
from pydantic import JsonValue, TypeAdapter
|
|
from redis import Redis
|
|
|
|
_BACKEND: Final = "z-ai/glm-5.2"
|
|
_API_KEY: Final = "synthetic-openrouter-key"
|
|
_ANSWER: Final = "The report covers revenue."
|
|
_URL_CITATION: Final[dict[str, JsonValue]] = {
|
|
"type": "url_citation",
|
|
"url_citation": {
|
|
"url": "https://example.com/report",
|
|
"title": "Report",
|
|
"start_index": 0,
|
|
"end_index": 10,
|
|
"content": "cited passage",
|
|
},
|
|
}
|
|
_FILE_ANNOTATION: Final[dict[str, JsonValue]] = {
|
|
"type": "file",
|
|
"file": {"hash": "sha-report", "name": "report.pdf", "content": [{"type": "text", "text": "parsed page one"}]},
|
|
}
|
|
_USAGE: Final[dict[str, JsonValue]] = {"prompt_tokens": 40, "completion_tokens": 6, "total_tokens": 46}
|
|
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
|
_CHOICES: Final = TypeAdapter(list[dict[str, JsonValue]])
|
|
|
|
|
|
def _completion(identity: str) -> bytes:
|
|
return json.dumps(
|
|
{
|
|
"id": identity,
|
|
"object": "chat.completion",
|
|
"created": 1,
|
|
"model": _BACKEND,
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": _ANSWER,
|
|
"annotations": [_URL_CITATION, _FILE_ANNOTATION],
|
|
},
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
"usage": _USAGE,
|
|
}
|
|
).encode()
|
|
|
|
|
|
def _frame(frame: dict[str, JsonValue]) -> bytes:
|
|
return b"data: " + json.dumps(frame).encode() + b"\n\n"
|
|
|
|
|
|
def _stream(identity: str) -> Reply:
|
|
chunk: Final[dict[str, JsonValue]] = {
|
|
"id": identity,
|
|
"object": "chat.completion.chunk",
|
|
"created": 1,
|
|
"model": _BACKEND,
|
|
}
|
|
return Reply(
|
|
content_type="text/event-stream",
|
|
chunks=(
|
|
_frame({**chunk, "choices": [{"index": 0, "delta": {"role": "assistant", "content": _ANSWER}}]}),
|
|
_frame({**chunk, "choices": [{"index": 0, "delta": {"annotations": [_URL_CITATION, _FILE_ANNOTATION]}}]}),
|
|
_frame({**chunk, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], "usage": _USAGE}),
|
|
b"data: [DONE]\n\n",
|
|
),
|
|
)
|
|
|
|
|
|
def _responder(identity: str) -> Callable[[Request], Reply]:
|
|
def respond(request: Request) -> Reply:
|
|
if _JSON_OBJECT.validate_json(request.body).get("stream") is True:
|
|
return _stream(identity)
|
|
return Reply(body=_completion(identity))
|
|
|
|
return respond
|
|
|
|
|
|
def _chat_body(model: str, marker: str, stream: bool) -> dict[str, JsonValue]:
|
|
return {
|
|
"model": model,
|
|
"messages": [{"role": "user", "content": f"Summarize the report {marker}"}],
|
|
"stream": stream,
|
|
}
|
|
|
|
|
|
def _posted(wire: Wire) -> int:
|
|
return sum(1 for request in wire.drain() if request.method == "POST")
|
|
|
|
|
|
def _message(response: httpx.Response) -> dict[str, JsonValue]:
|
|
assert response.status_code == 200, response.text
|
|
choices: Final = _CHOICES.validate_python(_JSON_OBJECT.validate_json(response.content)["choices"])
|
|
return _JSON_OBJECT.validate_python(choices[0]["message"])
|
|
|
|
|
|
def _assert_split(message: dict[str, JsonValue]) -> None:
|
|
assert message["content"] == _ANSWER, message
|
|
assert message["annotations"] == [_URL_CITATION], message
|
|
assert _JSON_OBJECT.validate_python(message["provider_specific_fields"])["annotations"] == [_FILE_ANNOTATION]
|
|
|
|
|
|
def test_file_annotation_reaches_the_caller_in_provider_specific_fields_and_survives_a_cache_hit(
|
|
gateway: Gateway,
|
|
) -> None:
|
|
marker: Final = uuid.uuid4().hex
|
|
identity: Final = f"gen-{marker}"
|
|
with wire_server(_responder(identity)) as wire, gateway.scenario() as scenario:
|
|
model: Final = scenario.model(model=f"openrouter/{_BACKEND}", api_base=wire.url, api_key=_API_KEY)
|
|
first: Final = gateway.request("POST", "/v1/chat/completions", _chat_body(model, marker, stream=False))
|
|
_assert_split(_message(first))
|
|
assert _posted(wire) == 1
|
|
await_entry(redis_store(), identity)
|
|
cached: Final = gateway.request("POST", "/v1/chat/completions", _chat_body(model, marker, stream=False))
|
|
assert cached.headers.get("x-litellm-cache-key"), dict(cached.headers)
|
|
_assert_split(_message(cached))
|
|
assert _posted(wire) == 0
|
|
|
|
|
|
def _deltas(response: httpx.Response) -> Iterator[dict[str, JsonValue]]:
|
|
for line in response.iter_lines():
|
|
if not line.startswith("data: ") or line == "data: [DONE]":
|
|
continue
|
|
choices = _CHOICES.validate_python(_JSON_OBJECT.validate_json(line.removeprefix("data: "))["choices"])
|
|
yield from (_JSON_OBJECT.validate_python(choice["delta"]) for choice in choices)
|
|
|
|
|
|
def _streamed(gateway: Gateway, body: dict[str, JsonValue]) -> tuple[dict[str, JsonValue], ...]:
|
|
with gateway.client.stream(
|
|
"POST", "/v1/chat/completions", json=body, headers={"Authorization": f"Bearer {gateway.key}"}
|
|
) as response:
|
|
assert response.status_code == 200, response.read()
|
|
return tuple(_deltas(response))
|
|
|
|
|
|
def _listed(value: JsonValue) -> list[JsonValue]:
|
|
return value if isinstance(value, list) else []
|
|
|
|
|
|
def _provider_fields(delta: dict[str, JsonValue]) -> dict[str, JsonValue]:
|
|
return _JSON_OBJECT.validate_python(delta.get("provider_specific_fields") or {})
|
|
|
|
|
|
def _annotations(deltas: tuple[dict[str, JsonValue], ...]) -> list[JsonValue]:
|
|
return list(chain.from_iterable(_listed(delta.get("annotations")) for delta in deltas))
|
|
|
|
|
|
def _provider_annotations(deltas: tuple[dict[str, JsonValue], ...]) -> list[JsonValue]:
|
|
return list(chain.from_iterable(_listed(_provider_fields(delta).get("annotations")) for delta in deltas))
|
|
|
|
|
|
def _text(deltas: tuple[dict[str, JsonValue], ...]) -> str:
|
|
return "".join(str(delta.get("content") or "") for delta in deltas)
|
|
|
|
|
|
def _cached_message(store: Redis, key: str) -> dict[str, JsonValue]:
|
|
raw: Final = store.get(key)
|
|
assert isinstance(raw, bytes), key
|
|
response: Final = _JSON_OBJECT.validate_json(raw)["response"]
|
|
stored: Final = _JSON_OBJECT.validate_json(response) if isinstance(response, str) else response
|
|
choices: Final = _CHOICES.validate_python(_JSON_OBJECT.validate_python(stored)["choices"])
|
|
return _JSON_OBJECT.validate_python(choices[0]["message"])
|
|
|
|
|
|
def test_streamed_annotations_reach_the_caller_as_sent_and_the_cached_stream_splits_them(gateway: Gateway) -> None:
|
|
marker: Final = uuid.uuid4().hex
|
|
identity: Final = f"gen-{marker}"
|
|
with wire_server(_responder(identity)) as wire, gateway.scenario() as scenario:
|
|
model: Final = scenario.model(model=f"openrouter/{_BACKEND}", api_base=wire.url, api_key=_API_KEY)
|
|
live: Final = _streamed(gateway, _chat_body(model, marker, stream=True))
|
|
assert _text(live) == _ANSWER, live
|
|
assert _annotations(live) == [_URL_CITATION, _FILE_ANNOTATION], live
|
|
assert _posted(wire) == 1
|
|
store: Final = redis_store()
|
|
_assert_split(_cached_message(store, await_entry(store, identity)))
|
|
replayed: Final = _streamed(gateway, _chat_body(model, marker, stream=True))
|
|
assert _text(replayed) == _ANSWER, replayed
|
|
assert _provider_annotations(replayed) == [_FILE_ANNOTATION], replayed
|
|
assert _posted(wire) == 0
|