fix(anthropic)!: send /v1/messages PDF documents to OpenAI-shaped providers as file parts (#47166)

* 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
This commit is contained in:
Mateo Wang 2026-10-29 08:06:12 -07:00 • committed by GitHub
parent c13ecb3085
commit be6d082bdc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 2374 additions and 89 deletions

View file

@ -19,6 +19,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
from litellm.llms.ollama.common_utils import OllamaResponseIdNormalizer
from litellm.types.llms.databricks import DatabricksTool
from litellm.types.llms.openai import (
ChatCompletionAnnotation,
ChatCompletionReasoningItem,
ChatCompletionThinkingBlock,
ImageURLListItem,
@ -59,6 +60,31 @@ _CHOICES_FIELDS: Final[frozenset] = frozenset(Choices.model_fields.keys())
_MODEL_RESPONSE_FIELDS: Final[frozenset] = frozenset(ModelResponse.model_fields.keys()) | {"usage"}
_ANNOTATION_ENTRIES: Final = TypeAdapter(tuple[object, ...])
def _is_provider_specific_annotation(annotation: object) -> bool:
match annotation:
case {"type": "url_citation"}:
return False
case {"type": _}:
return True
case _:
return False
def _annotation_entries(annotations: object) -> tuple[object, ...]:
return _ANNOTATION_ENTRIES.validate_python(annotations) if isinstance(annotations, list) else ()
def partition_annotations(annotations: object) -> tuple[object, tuple[object, ...]]:
entries: Final = _annotation_entries(annotations)
others: Final = tuple(annotation for annotation in entries if _is_provider_specific_annotation(annotation))
if not others:
return annotations, ()
return [annotation for annotation in entries if not _is_provider_specific_annotation(annotation)] or None, others
def _normalize_images_for_message(
images: list[dict] | None,
) -> list[ImageURLListItem] | None:
@ -787,6 +813,9 @@ def convert_to_model_response_object(
provider_specific_fields = dict(choice["message"].get("provider_specific_fields", None) or {})
for f in choice["message"].keys() - _MESSAGE_FIELDS:
provider_specific_fields[f] = choice["message"][f]
annotations, other_annotations = partition_annotations(choice["message"].get("annotations", None))
if other_annotations:
provider_specific_fields["annotations"] = list(other_annotations)
# Handle reasoning models that display `reasoning_content` within `content`
reasoning_content, content = extract_reasoning_content(choice["message"])
@ -809,7 +838,9 @@ def convert_to_model_response_object(
reasoning_content=reasoning_content,
thinking_blocks=thinking_blocks,
reasoning_items=choice["message"].get("reasoning_items", None),
annotations=choice["message"].get("annotations", None),
annotations=cast( # cast-ok: provider annotation dicts pass through whole, keeping keys such as url_citation.content
list[ChatCompletionAnnotation] | None, annotations
),
images=_normalize_images_for_message(choice["message"].get("images", None)),
)
finish_reason = choice.get("finish_reason", None)

View file

@ -424,6 +424,109 @@ def anthropic_image_source_to_openai_url(image_source: Mapping[str, object]) ->
return None
@dataclass(frozen=True, slots=True)
class InlineDocument:
filename: str
file_data: str
def _document_media_type(source: object) -> str:
match source:
case {"media_type": str() as media_type} if media_type:
return media_type
case _:
return "application/pdf"
def anthropic_inline_document(document: Mapping[str, object]) -> InlineDocument | None:
source: Final = document.get("source")
match source:
case {"type": "base64", "data": str() as data} if data:
title: Final = document.get("title")
return InlineDocument(
filename=title if isinstance(title, str) and title else "document.pdf",
file_data=f"data:{_document_media_type(source)};base64,{data}",
)
case _:
return None
def is_pdf_file_part(part: object) -> bool:
match part:
case {"type": "file", "file": {"file_data": str() as file_data}} if file_data.startswith("data:"):
return file_data.startswith("data:application/pdf")
case {"type": "file", "file": {"format": str() as file_format}}:
return file_format == "application/pdf"
case {"type": "file", "file": {"filename": str() as filename}}:
return filename.lower().endswith(".pdf")
case _:
return False
_CONTENT_PARTS: Final = TypeAdapter(tuple[object, ...])
def content_parts(message: Mapping[str, object]) -> Sequence[object]:
content: Final = message.get("content")
return _CONTENT_PARTS.validate_python(content) if isinstance(content, list) else ()
def _has_pdf_file_part(message: Mapping[str, object]) -> bool:
return any(is_pdf_file_part(part) for part in content_parts(message))
def messages_have_pdf_file_part(messages: Sequence[Mapping[str, object]]) -> bool:
return any(_has_pdf_file_part(message) for message in messages)
def _message_without_pdf_file_parts(message: AllMessageValues) -> AllMessageValues:
if not _has_pdf_file_part(message):
return message
kept: Final = [part for part in content_parts(message) if not is_pdf_file_part(part)]
return cast(AllMessageValues, {**message, "content": kept}) # cast-ok: same message with fewer content parts
def _is_pdf_only_user_turn(message: AllMessageValues) -> bool:
parts: Final = content_parts(message)
return message["role"] == "user" and bool(parts) and all(is_pdf_file_part(part) for part in parts)
def messages_without_pdf_file_parts(messages: Sequence[AllMessageValues]) -> tuple[AllMessageValues, ...]:
return tuple(
_message_without_pdf_file_parts(message) for message in messages if not _is_pdf_only_user_turn(message)
)
_CONTENT_PART: Final = TypeAdapter(Mapping[str, object])
def _file_part_as_image_url(part: object) -> object:
match part:
case {"type": "file", "file": {"file_data": str() as file_data}} if file_data:
fields: Final = _CONTENT_PART.validate_python(part)
return {key: value for key, value in fields.items() if key != "file"} | {
"type": "image_url",
"image_url": {"url": file_data},
}
case _:
return part
def message_with_file_parts_as_image_urls(message: Mapping[str, object]) -> Mapping[str, object]:
parts: Final = content_parts(message)
if not parts:
return message
return {**message, "content": [_file_part_as_image_url(part) for part in parts]}
def _typed_message_with_file_parts_as_image_urls(message: AllMessageValues) -> AllMessageValues:
return cast(AllMessageValues, message_with_file_parts_as_image_urls(message)) # cast-ok: same message, image parts
def messages_with_file_parts_as_image_urls(messages: Sequence[AllMessageValues]) -> tuple[AllMessageValues, ...]:
return tuple(_typed_message_with_file_parts_as_image_urls(message) for message in messages)
def _image_part_url(part: Mapping[str, object]) -> str | None:
"""The image URL carried by one content part, whichever of the three dialects wrote it."""
part_type: Final = part.get("type")

View file

@ -19,6 +19,7 @@ from litellm.litellm_core_utils.core_helpers import (
)
from litellm.litellm_core_utils.hidden_params import HIDDEN_PARAMS_ATTR, set_hidden_params
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import BEDROCK_TRACE_FIELDS, object_items, object_mapping
from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import partition_annotations
from litellm.litellm_core_utils.prompt_templates.common_utils import (
get_content_from_model_response, # pyright: ignore[reportUnknownVariableType] # response param accepts an untyped dict
)
@ -1700,10 +1701,17 @@ def stream_chunk_builder(
# Merge annotations from ALL chunks — providers may spread
# them across multiple streaming chunks or send them only in
# the final chunk.
all_annotations: Final[list] = []
all_annotations: Final[list[object]] = []
for ac in annotation_chunks:
all_annotations.extend(ac["choices"][0]["delta"]["annotations"])
response["choices"][0]["message"]["annotations"] = all_annotations
message_annotations, provider_annotations = partition_annotations(all_annotations)
if message_annotations is not None:
response["choices"][0]["message"]["annotations"] = message_annotations
if provider_annotations:
response.choices[0].message.provider_specific_fields = {
**(response.choices[0].message.provider_specific_fields or {}),
"annotations": list(provider_annotations),
}
audio_chunks: Final = [
chunk
@ -1766,7 +1774,10 @@ def stream_chunk_builder(
if combined_provider_fields:
_choice = response.choices[0]
_choice.message.provider_specific_fields = combined_provider_fields
_choice.message.provider_specific_fields = {
**(_choice.message.provider_specific_fields or {}),
**combined_provider_fields,
}
completion_output = get_content_from_model_response(response)

View file

@ -123,6 +123,7 @@ from openai.types.chat.chat_completion_chunk import Choice as OpenAIStreamingCho
from litellm.litellm_core_utils.prompt_templates.common_utils import (
anthropic_image_source_to_openai_url,
anthropic_inline_document,
parse_tool_call_arguments,
reasoning_content_from_thinking_blocks,
with_prompt_cache_breakpoint,
@ -185,6 +186,8 @@ from litellm.types.llms.openai import (
AllMessageValues,
ChatCompletionAssistantMessage,
ChatCompletionAssistantToolCall,
ChatCompletionFileObject,
ChatCompletionFileObjectFile,
ChatCompletionImageObject,
ChatCompletionImageUrlObject,
ChatCompletionRedactedThinkingBlock,
@ -498,7 +501,9 @@ class LiteLLMAnthropicMessagesAdapter:
for m in ordered_messages:
user_message: ChatCompletionUserMessage | None = None
tool_message_list: list[ChatCompletionToolMessage] = []
new_user_content_list: list[ChatCompletionTextObject | ChatCompletionImageObject] = []
new_user_content_list: list[
ChatCompletionTextObject | ChatCompletionImageObject | ChatCompletionFileObject
] = []
if m["role"] == "system":
system_message = self._translate_midturn_system_message_to_openai(m, model)
if system_message is not None:
@ -531,13 +536,8 @@ class LiteLLMAnthropicMessagesAdapter:
self._add_prompt_cache_breakpoint_if_present(content, image_obj)
)
elif content.get("type") == "document":
# Convert Anthropic document format (PDF, etc.) to OpenAI format
source = content.get("source", {})
openai_image_url = self._translate_anthropic_image_to_openai(cast(dict, source))
if openai_image_url:
image_url_obj = ChatCompletionImageUrlObject(url=openai_image_url)
doc_obj = ChatCompletionImageObject(type="image_url", image_url=image_url_obj)
doc_obj = self._translate_anthropic_document_to_openai(content)
if doc_obj is not None:
self._add_cache_control_if_applicable(content, doc_obj, model)
new_user_content_list.append(doc_obj)
elif content.get("type") == "tool_result":
@ -1357,7 +1357,7 @@ class LiteLLMAnthropicMessagesAdapter:
text: Final = tool_result_text_part(block)
return None if text is None else ChatCompletionTextObject(type="text", text=text)
case "image" | "document":
return self._tool_result_image_part(block.get("source"))
return self._source_image_url_part(block.get("source"))
case "tool_reference":
return ChatCompletionToolReferenceObject(
type="tool_reference", tool_name=str(block.get("tool_name") or "")
@ -1365,7 +1365,18 @@ class LiteLLMAnthropicMessagesAdapter:
case _:
return None
def _tool_result_image_part(self, image_source: object) -> ChatCompletionImageObject | None:
def _translate_anthropic_document_to_openai(
self, document: Mapping[str, object]
) -> ChatCompletionFileObject | ChatCompletionImageObject | None:
inline_document: Final = anthropic_inline_document(document)
if inline_document is None:
return self._source_image_url_part(document.get("source"))
return ChatCompletionFileObject(
type="file",
file=ChatCompletionFileObjectFile(filename=inline_document.filename, file_data=inline_document.file_data),
)
def _source_image_url_part(self, image_source: object) -> ChatCompletionImageObject | None:
if not isinstance(image_source, dict):
return None
openai_image_url = self._translate_anthropic_image_to_openai(image_source)

View file

@ -13,6 +13,7 @@ from typing import Any, Final, cast
from litellm.litellm_core_utils.prompt_templates.common_utils import (
TOOL_RESULT_IMAGE_BOUNDARY,
TOOL_RESULT_IMAGE_PLACEHOLDER,
anthropic_inline_document,
encrypted_reasoning_signature,
responses_reasoning_items_from_thinking_blocks,
with_prompt_cache_breakpoint,
@ -106,27 +107,18 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
block: Mapping[str, object],
) -> dict[str, str] | None: # mutable-ok: API message payload
"""Convert an Anthropic document block to a Responses input_file part."""
inline_document: Final = anthropic_inline_document(block)
if inline_document is not None:
return {
"type": "input_file",
"filename": inline_document.filename,
"file_data": inline_document.file_data,
}
raw_source: Final = block.get("source")
if not isinstance(raw_source, Mapping):
return None
source: Final = cast(Mapping[str, object], raw_source) # cast-ok: untrusted client payload
source_type: Final = source.get("type")
if source_type == "base64":
data: Final = source.get("data")
if not isinstance(data, str) or not data:
return None
raw_media_type: Final = source.get("media_type")
media_type: Final = (
raw_media_type if isinstance(raw_media_type, str) and raw_media_type else "application/pdf"
)
raw_title: Final = block.get("title")
filename: Final = raw_title if isinstance(raw_title, str) and raw_title else "document.pdf"
return {
"type": "input_file",
"filename": filename,
"file_data": f"data:{media_type};base64,{data}",
}
if source_type == "url":
if source.get("type") == "url":
url: Final = source.get("url")
if not isinstance(url, str) or not url:
return None

View file

@ -1,7 +1,7 @@
import copy
import enum
import re
from collections.abc import AsyncIterator, Collection, Iterator
from collections.abc import AsyncIterator, Collection, Iterator, Mapping
from typing import TYPE_CHECKING, Final, cast
import httpx
@ -10,10 +10,11 @@ from httpx import Response
import litellm
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.prompt_templates.common_utils import (
audio_or_image_in_message_content,
content_parts,
convert_content_list_to_str,
drop_tool_reference_parts_from_tool_messages,
filter_value_from_dict,
is_pdf_file_part,
)
from litellm.llms.azure.common_utils import BaseAzureLLM
from litellm.llms.azure_ai.common_utils import (
@ -46,6 +47,20 @@ NON_OPENAI_SPEC_MESSAGE_FIELDS: Final = (
"cache_control",
)
LIST_ONLY_CONTENT_PART_TYPES: Final = frozenset({"image_url", "input_audio"})
def _is_list_only_content_part(part: object) -> bool:
match part:
case {"type": str() as part_type} if part_type in LIST_ONLY_CONTENT_PART_TYPES:
return True
case _:
return is_pdf_file_part(part)
def _has_list_only_content_part(message: Mapping[str, object]) -> bool:
return any(_is_list_only_content_part(part) for part in content_parts(message))
class AzureAIChatCompletionResponseIterator(OpenAIChatCompletionResponseIterator):
def chunk_parser(self, chunk: dict[str, object]) -> ModelResponseStream: # mutable-ok: mirrors override contract
@ -246,7 +261,7 @@ class AzureAIStudioConfig(OpenAIConfig):
these with "Extra inputs are not permitted", which breaks multi-turn
Anthropic-format clients that echo thinking blocks back as history.
2. Transforms list content to a string.
3. If message contains an image or audio, send as is (user-intended)
3. If message contains an image, audio, or PDF file part, send as is (user-intended)
Operates on a deep copy so the caller's messages keep their thinking blocks
and provider metadata, which a fallback to another provider still needs.
@ -257,8 +272,7 @@ class AzureAIStudioConfig(OpenAIConfig):
for field in NON_OPENAI_SPEC_MESSAGE_FIELDS:
filter_value_from_dict(message_dict, field)
# Do nothing if the message contains an image or audio
if audio_or_image_in_message_content(message):
if _has_list_only_content_part(message):
continue
texts = convert_content_list_to_str(message=message)

View file

@ -38,6 +38,15 @@ else:
_EMPTY_FUNCTION: Final[Mapping[str, object]] = MappingProxyType({})
_JSON_VALUE: Final = TypeAdapter(object)
_CONTENT_PART: Final = TypeAdapter(Mapping[str, object])
def _file_part_data(part: Mapping[str, object]) -> str | None:
match part:
case {"type": "file", "file": {"file_data": str() as file_data}} if file_data:
return file_data
case _:
return None
def is_valid_json(value: str) -> bool:
@ -295,7 +304,7 @@ class GigaChatConfig(BaseConfig):
def _transform_list_content(self, content: Sequence) -> tuple[str, Sequence[object]]:
"""
Extract text and image attachments from a multimodal message content list.
Extract text, image and file attachments from a multimodal message content list.
Args:
content: List of content parts (OpenAI multimodal format)
@ -320,6 +329,10 @@ class GigaChatConfig(BaseConfig):
file_id = self._upload_image(upload_url)
if file_id:
attachments.append(file_id)
elif (file_data := _file_part_data(_CONTENT_PART.validate_python(part))) and (
uploaded_file_id := self._upload_image(file_data)
):
attachments.append(uploaded_file_id)
text: Final = "\n".join(texts) if texts else ""
return text, attachments

View file

@ -20,6 +20,7 @@ import litellm
from litellm._logging import verbose_logger
from litellm.constants import DEFAULT_OCI_CHAT_MAX_TOKENS
from litellm.litellm_core_utils.logging_utils import track_llm_api_timing
from litellm.litellm_core_utils.prompt_templates.common_utils import messages_with_file_parts_as_image_urls
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
from litellm.litellm_core_utils.url_utils import join_api_base
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
@ -681,7 +682,9 @@ class OCIChatConfig(BaseConfig):
servingMode=serving_mode,
chatRequest=OCIChatRequestPayload(
apiFormat=vendor.value,
messages=adapt_messages_to_generic_oci_standard(messages),
messages=adapt_messages_to_generic_oci_standard(
list(messages_with_file_parts_as_image_urls(messages))
),
**self._get_optional_params(vendor, optional_params, model),
),
)

View file

@ -11,6 +11,7 @@ import httpx
from pydantic import BaseModel
import litellm
from litellm.litellm_core_utils.prompt_templates.common_utils import message_with_file_parts_as_image_urls
from litellm.litellm_core_utils.url_utils import join_api_base
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import ModelResponse
@ -104,7 +105,7 @@ def _message_role_mapping(role: str) -> type[ChatMessage]:
def _messages_to_sap_template(messages: list[AllMessageValues]) -> list:
template: Final = []
for message in messages:
folded_message = _fold_message_cache_control(message)
folded_message = _fold_message_cache_control(message_with_file_parts_as_image_urls(message))
message_role_class = _message_role_mapping(str(folded_message["role"]))
validated_message = validate_dict(folded_message, message_role_class)
template.append(validated_message)

View file

@ -112,7 +112,7 @@ def _convert_image_url_to_anthropic(block: Mapping[str, object]) -> object:
pdf/text data URIs become document blocks rather than malformed image blocks.
"""
image_url: Final = block.get("image_url")
url: Final = image_url if isinstance(image_url, str) else _image_url_field(image_url, "url")
url: Final = image_url if isinstance(image_url, str) else _str_field(image_url, "url")
if not url:
return block
@ -121,18 +121,31 @@ def _convert_image_url_to_anthropic(block: Mapping[str, object]) -> object:
if select_anthropic_content_block_type_for_file(_data_uri_media_type(url)) == "document"
else create_anthropic_image_param(
image_url if isinstance(image_url, dict) else url,
format=_image_url_field(image_url, "format"),
format=_str_field(image_url, "format"),
is_bedrock_invoke=True,
)
)
return _with_cache_control(converted, block)
def _convert_file_to_anthropic(block: Mapping[str, object]) -> object:
file_data: Final = _str_field(block.get("file"), "file_data")
if not file_data:
return block
return _with_cache_control(
anthropic_process_openai_file_message({"type": "file", "file": {"file_data": file_data}}), block
)
def _with_cache_control(converted: Mapping[str, object], block: Mapping[str, object]) -> Mapping[str, object]:
cache_control: Final = block.get("cache_control")
if cache_control is None:
return converted
return {**converted, "cache_control": cache_control}
def _image_url_field(image_url: object, key: str) -> str | None:
value: Final = image_url.get(key) if isinstance(image_url, dict) else None
def _str_field(container: object, key: str) -> str | None:
value: Final = container.get(key) if isinstance(container, dict) else None
return value if isinstance(value, str) else None
@ -141,15 +154,23 @@ def _data_uri_media_type(url: str) -> str:
return match.group(1) if match else ""
def _convert_image_url_blocks_to_anthropic(content: object) -> object:
def _convert_media_block_to_anthropic(block: object) -> object:
mapping: Final = as_str_mapping(block)
if mapping is None:
return block
match mapping.get("type"):
case "image_url":
return _convert_image_url_to_anthropic(mapping)
case "file":
return _convert_file_to_anthropic(mapping)
case _:
return block
def _convert_media_blocks_to_anthropic(content: object) -> object:
if not isinstance(content, list):
return content
return [
_convert_image_url_to_anthropic(block)
if isinstance(block, Mapping) and block.get("type") == "image_url"
else block
for block in content
]
return [_convert_media_block_to_anthropic(block) for block in content]
def _convert_tool_result_to_anthropic(
@ -391,7 +412,7 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig):
conversation.append(
{
"role": role,
"content": _convert_image_url_blocks_to_anthropic(content),
"content": _convert_media_blocks_to_anthropic(content),
}
)

View file

@ -9,6 +9,7 @@ import httpx
import litellm
from litellm import LlmProviders
from litellm.litellm_core_utils.prompt_templates.common_utils import messages_with_file_parts_as_image_urls
from litellm.types.llms.vertex_ai import VertexPartnerProvider
from litellm.utils import ModelResponse
@ -217,7 +218,7 @@ class VertexAIPartnerModels(VertexBase):
return base_llm_http_handler.completion(
model=model,
stream=stream,
messages=messages,
messages=list(messages_with_file_parts_as_image_urls(messages)),
acompletion=acompletion,
api_base=api_base,
model_response=model_response,

View file

@ -108,6 +108,10 @@ from litellm.litellm_core_utils.mock_functions import (
from litellm.litellm_core_utils.prompt_templates.common_utils import (
get_content_from_model_response as get_content_from_model_response, # noqa: PLC0414 # public re-export
)
from litellm.litellm_core_utils.prompt_templates.common_utils import (
messages_have_pdf_file_part,
messages_without_pdf_file_parts,
)
from litellm.litellm_core_utils.provider_affinity import add_provider_affinity_header
from litellm.litellm_core_utils.request_timeout_resolver import (
get_configured_request_timeout,
@ -191,11 +195,13 @@ from litellm.utils import (
get_requester_metadata,
get_secret,
get_standard_openai_params,
is_explicitly_disabled_factory,
mock_completion_streaming_obj,
pre_process_non_default_params,
read_config_args,
should_run_mock_completion,
supports_httpx_timeout,
supports_pdf_input,
validate_and_fix_openai_messages,
validate_and_fix_openai_tools,
validate_and_fix_thinking_param,
@ -393,6 +399,125 @@ def _resolve_control_options(kwargs: Mapping[str, object], model: str) -> Contro
return assert_never(control)
PROVIDERS_WITHOUT_DOCUMENT_INPUT: Final = frozenset({"ollama", "ollama_chat"})
PROVIDERS_DROPPING_UNDECLARED_PDF_INPUT: Final = frozenset({"azure_ai"})
def _cost_map_pdf_input_support(model: str, custom_llm_provider: str) -> bool | None:
if custom_llm_provider in PROVIDERS_WITHOUT_DOCUMENT_INPUT or is_explicitly_disabled_factory(
model, custom_llm_provider, "supports_pdf_input"
):
return False
if supports_pdf_input(model, custom_llm_provider):
return True
return None
def _deployment_pdf_input_support(model: str, base_model: str, custom_llm_provider: str) -> bool | None:
supports: Final = (
_cost_map_pdf_input_support(model, custom_llm_provider),
_cost_map_pdf_input_support(base_model, custom_llm_provider),
)
if False in supports:
return False
if True in supports:
return True
return None
def _declared_pdf_input_support(
model: str, custom_llm_provider: str, model_info: Mapping[str, object] | None, base_model: str | None
) -> bool | None:
match model_info:
case {"supports_pdf_input": bool() as declared}:
return declared
case _ if base_model:
return _deployment_pdf_input_support(model, base_model, custom_llm_provider)
case _:
return _cost_map_pdf_input_support(model, custom_llm_provider)
def _pdf_input_unreadable(
model: str,
custom_llm_provider: str,
model_info: Mapping[str, object] | None,
base_model: str | None,
drop_params: bool,
) -> bool:
match _declared_pdf_input_support(model, custom_llm_provider, model_info, base_model):
case False:
return True
case None:
return drop_params and custom_llm_provider in PROVIDERS_DROPPING_UNDECLARED_PDF_INPUT
case True:
return False
def _messages_without_unreadable_pdfs(
messages: Sequence[AllMessageValues],
model: str,
custom_llm_provider: str,
model_info: Mapping[str, object] | None,
base_model: str | None,
drop_params: bool,
) -> tuple[AllMessageValues, ...] | None:
if not messages_have_pdf_file_part(messages) or not _pdf_input_unreadable(
model, custom_llm_provider, model_info, base_model, drop_params
):
return None
if not drop_params:
raise litellm.BadRequestError(
message=(
f"{custom_llm_provider}/{model} does not accept PDF input. "
"Remove the PDF, or set drop_params=True to send the request without it"
),
model=model,
llm_provider=custom_llm_provider,
)
readable_messages: Final = messages_without_pdf_file_parts(messages)
if not any(message["role"] == "user" for message in readable_messages):
raise litellm.BadRequestError(
message=(
f"{custom_llm_provider}/{model} does not accept PDF input, "
"and no user message is left once the PDF is dropped"
),
model=model,
llm_provider=custom_llm_provider,
)
return readable_messages
def _messages_readable_by_model(
messages: Sequence[AllMessageValues],
model: str,
custom_llm_provider: str,
model_info: Mapping[str, object] | None,
base_model: str | None,
drop_params: bool,
ensure_alternating_roles: bool | None,
user_continue_message: ChatCompletionUserMessage | None,
assistant_continue_message: ChatCompletionAssistantMessage | None,
) -> tuple[AllMessageValues, ...] | None:
pdf_free_messages: Final = _messages_without_unreadable_pdfs(
messages=messages,
model=model,
custom_llm_provider=custom_llm_provider,
model_info=model_info,
base_model=base_model,
drop_params=drop_params,
)
if pdf_free_messages is None:
return None
return tuple(
get_completion_messages(
messages=list(pdf_free_messages),
ensure_alternating_roles=ensure_alternating_roles or False,
user_continue_message=user_continue_message,
assistant_continue_message=assistant_continue_message,
)
)
####### COMPLETION ENDPOINTS ################
@ -4234,7 +4359,6 @@ def _complete_sagemaker(ctx: CompletionDispatchContext) -> _CompletionDispatchRe
_ADDITIONAL_DROP_PARAMS_ADAPTER: Final = TypeAdapter(list[str])
_OPTIONAL_PARAMS_ADAPTER: Final = TypeAdapter(dict[str, object])
_OLLAMA_EMBEDDING_HEADERS_ADAPTER: Final = TypeAdapter(dict[str, str])
_REQUEST_KWARGS_ADAPTER: Final = TypeAdapter(Mapping[str, object])
_MODEL_INFO_ADAPTER: Final = TypeAdapter(Mapping[str, object])
_PROVIDER_ROUTING_HINTS: Final = TypeAdapter(ProviderRoutingHints)
_BASE_URL_HINT: Final = TypeAdapter(BaseUrlRoutingHint)
@ -5558,9 +5682,15 @@ def completion(
)
headers = kwargs.get("headers", None) or extra_headers
ensure_alternating_roles: Final[bool | None] = kwargs.get("ensure_alternating_roles", None)
user_continue_message: Final[ChatCompletionUserMessage | None] = kwargs.get("user_continue_message", None)
assistant_continue_message: ChatCompletionAssistantMessage | None = kwargs.get("assistant_continue_message", None)
ensure_alternating_roles: Final = cast( # cast-ok: read from untyped kwargs
bool | None, kwargs.get("ensure_alternating_roles", None)
)
user_continue_message: Final = cast( # cast-ok: read from untyped kwargs
ChatCompletionUserMessage | None, kwargs.get("user_continue_message", None)
)
assistant_continue_message: Final = cast( # cast-ok: read from untyped kwargs
ChatCompletionAssistantMessage | None, kwargs.get("assistant_continue_message", None)
)
headers = {} if headers is None else dict(headers)
if extra_headers is not None:
headers.update(extra_headers)
@ -5928,15 +6058,17 @@ def completion(
def optional_params_for_model_info(deployment_info: Mapping[str, object] | None) -> dict[str, object]:
return get_optional_params(**mapping_args, model_info=deployment_info, **mapping_special_params)
optional_params = optional_params_for_model_info(
validated_model_info: Final = (
_MODEL_INFO_ADAPTER.validate_python(model_info) if isinstance(model_info, dict) else None
)
deployment_base_model: Final = base_model if isinstance(base_model, str) else None
optional_params = optional_params_for_model_info(validated_model_info)
fakes_stream: Final = partial(
_completion_fakes_stream,
model,
custom_llm_provider,
provider_config,
base_model=base_model if isinstance(base_model, str) else None,
base_model=deployment_base_model,
bridges_to_responses_api=bridges_to_responses_api is True,
)
resolve_timeout: Final = partial(
@ -5974,16 +6106,29 @@ def completion(
functions_unsupported_model: Final[list[object]] = TypeAdapter(list[object]).validate_python(
optional_params.pop("functions_unsupported_model")
)
request_kwargs: Final = _REQUEST_KWARGS_ADAPTER.validate_python(kwargs)
messages = function_call_prompt(
messages=messages,
functions=functions_unsupported_model,
model=model,
llm_provider=custom_llm_provider,
drop_params=litellm.drop_params is True
or normalize_drop_params(request_kwargs.get("drop_params")) is True,
drop_params=drop_unsupported_reasoning,
)
readable_messages: Final = _messages_readable_by_model(
messages=cast(list[AllMessageValues], messages), # cast-ok: completion types messages as a bare List
model=model,
custom_llm_provider=custom_llm_provider,
model_info=validated_model_info,
base_model=deployment_base_model,
drop_params=drop_unsupported_reasoning,
ensure_alternating_roles=ensure_alternating_roles,
user_continue_message=user_continue_message,
assistant_continue_message=assistant_continue_message,
)
messages = ( # rebind-ok: completion() rewrites messages step by step
messages if readable_messages is None else list(readable_messages)
)
# For logging - save the values of the litellm-specific params passed in
requested_litellm_params: Final = get_litellm_params(
acompletion=acompletion,

View file

@ -4049,6 +4049,7 @@
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": false,
"supports_response_schema": true,
"supports_tool_choice": true
},
@ -12437,7 +12438,8 @@
"supported_output_modalities": [
"text"
],
"supports_native_streaming": true
"supports_native_streaming": true,
"supports_pdf_input": false
},
"azure_ai/FLUX-1.1-pro": {
"deprecation_date": "2027-01-05",
@ -12502,6 +12504,7 @@
"output_cost_per_token": 1.85e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12516,6 +12519,7 @@
"output_cost_per_token": 3.828e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12531,6 +12535,7 @@
"output_cost_per_token": 3.52e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12546,6 +12551,7 @@
"output_cost_per_token": 4.84e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12560,6 +12566,7 @@
"output_cost_per_token": 4.84e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12574,6 +12581,7 @@
"output_cost_per_token": 7.26e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12594,6 +12602,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12616,6 +12625,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -12638,6 +12648,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -12660,6 +12671,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -12687,6 +12699,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -12703,6 +12716,7 @@
"output_cost_per_token": 1.32e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12724,6 +12738,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -12744,6 +12759,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -12765,6 +12781,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12833,6 +12850,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12847,6 +12865,7 @@
"output_cost_per_token": 7.1e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/Llama-4-Maverick-17B-128E-Instruct-FP8": {
@ -12859,6 +12878,7 @@
"output_cost_per_token": 1e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": true
},
@ -12872,6 +12892,7 @@
"output_cost_per_token": 7.8e-07,
"source": "https://azure.microsoft.com/en-us/blog/introducing-the-llama-4-herd-in-azure-ai-foundry-and-azure-databricks/",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": true
},
@ -12884,6 +12905,7 @@
"mode": "chat",
"output_cost_per_token": 3.7e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/Meta-Llama-3.1-70B-Instruct": {
@ -12895,6 +12917,7 @@
"mode": "chat",
"output_cost_per_token": 3.54e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/Phi-3-medium-128k-instruct": {
@ -12906,6 +12929,7 @@
"mode": "chat",
"output_cost_per_token": 6.8e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12918,6 +12942,7 @@
"mode": "chat",
"output_cost_per_token": 6.8e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12930,6 +12955,7 @@
"mode": "chat",
"output_cost_per_token": 5.2e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12942,6 +12968,7 @@
"mode": "chat",
"output_cost_per_token": 5.2e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12954,6 +12981,7 @@
"mode": "chat",
"output_cost_per_token": 6e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12966,6 +12994,7 @@
"mode": "chat",
"output_cost_per_token": 6e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12978,6 +13007,7 @@
"mode": "chat",
"output_cost_per_token": 6.4e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12990,6 +13020,7 @@
"mode": "chat",
"output_cost_per_token": 5.2e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -13002,6 +13033,7 @@
"mode": "chat",
"output_cost_per_token": 5.2e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": true
},
@ -13015,6 +13047,7 @@
"output_cost_per_token": 5e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -13027,7 +13060,8 @@
"mode": "chat",
"output_cost_per_token": 3e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true
"supports_function_calling": true,
"supports_pdf_input": false
},
"azure_ai/Phi-4-multimodal-instruct": {
"deprecation_date": "2027-01-17",
@ -13041,6 +13075,7 @@
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_audio_input": true,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_vision": true
},
"azure_ai/Phi-4-mini-reasoning": {
@ -13052,7 +13087,8 @@
"mode": "chat",
"output_cost_per_token": 3e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true
"supports_function_calling": true,
"supports_pdf_input": false
},
"azure_ai/Phi-4-reasoning": {
"deprecation_date": "2027-01-17",
@ -13064,6 +13100,7 @@
"output_cost_per_token": 5e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_reasoning": true
},
@ -13106,6 +13143,7 @@
"output_cost_per_token": 1e-05,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/doc-intelligence/prebuilt-read": {
@ -13144,6 +13182,7 @@
"mode": "chat",
"output_cost_per_token": 5.4e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_tool_choice": true
},
@ -13202,6 +13241,7 @@
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -13217,6 +13257,7 @@
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -13230,6 +13271,7 @@
"mode": "chat",
"output_cost_per_token": 4.56e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/deepseek-v4-pro": {
@ -13242,6 +13284,7 @@
"output_cost_per_token": 3.48e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_tool_choice": true,
"cache_read_input_token_cost": 1.45e-07,
@ -13257,6 +13300,7 @@
"output_cost_per_token": 5.1e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_tool_choice": true,
"cache_read_input_token_cost": 2.8e-08,
@ -13273,6 +13317,7 @@
"output_cost_per_token": 1.32e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -13306,6 +13351,7 @@
"output_cost_per_token": 1.5e-05,
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_web_search": true
@ -13323,6 +13369,7 @@
"output_cost_per_token_above_200k_tokens": 5e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
@ -13343,6 +13390,7 @@
"output_cost_per_token_above_200k_tokens": 1.2e-05,
"source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/grok/",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
@ -13361,6 +13409,7 @@
"output_cost_per_token": 2.5e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_response_schema": true,
"supports_tool_choice": true,
@ -13379,6 +13428,7 @@
"output_cost_per_token": 2.5e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_response_schema": true,
"supports_tool_choice": true,
@ -13395,6 +13445,7 @@
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_web_search": true
@ -13409,6 +13460,7 @@
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
@ -13424,6 +13476,7 @@
"output_cost_per_token": 1.5e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_web_search": true
@ -13436,7 +13489,8 @@
"max_output_tokens": 8192,
"mode": "chat",
"output_cost_per_token": 0.00971,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models"
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false
},
"azure_ai/jamba-instruct": {
"deprecation_date": "2025-03-01",
@ -13447,6 +13501,7 @@
"mode": "chat",
"output_cost_per_token": 7e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/kimi-k2.5": {
@ -13459,6 +13514,7 @@
"output_cost_per_token": 3e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_video_input": true,
"supports_vision": true,
@ -13482,6 +13538,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_tool_choice": true,
"supports_vision": true,
@ -13498,6 +13555,7 @@
"output_cost_per_token": 4e-08,
"source": "https://marketplace.microsoft.com/en/marketplace/apps/000-000.ministral-3b-2410-offer?tab=Overview",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/mistral-large": {
@ -13510,6 +13568,7 @@
"output_cost_per_token": 1.2e-05,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/mistral-large-2407": {
@ -13522,6 +13581,7 @@
"output_cost_per_token": 6e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/mistral-large-latest": {
@ -13533,6 +13593,7 @@
"output_cost_per_token": 6e-06,
"source": "https://marketplace.microsoft.com/en/marketplace/apps/000-000.mistral-ai-large-2407-offer?tab=Overview",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/mistral-large-3": {
@ -13544,6 +13605,7 @@
"output_cost_per_token": 1.5e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": true
},
@ -13557,6 +13619,7 @@
"output_cost_per_token": 2e-06,
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/mistral-nemo": {
@ -13568,7 +13631,8 @@
"mode": "chat",
"output_cost_per_token": 1.5e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_function_calling": true
"supports_function_calling": true,
"supports_pdf_input": false
},
"azure_ai/mistral-small": {
"deprecation_date": "2025-07-31",
@ -13580,6 +13644,7 @@
"output_cost_per_token": 3e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/mistral-small-2503": {
@ -13591,6 +13656,7 @@
"mode": "chat",
"output_cost_per_token": 3e-07,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": true
},
@ -52006,6 +52072,7 @@
],
"supports_assistant_prefill": false,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -52026,6 +52093,7 @@
],
"supports_assistant_prefill": false,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -52777,6 +52845,7 @@
"output_cost_per_token": 2.5e-06,
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_web_search": true
},
@ -52794,6 +52863,7 @@
"global"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_tool_choice": true
},
@ -52811,6 +52881,7 @@
"global"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -52989,6 +53060,7 @@
"output_cost_per_token": 3.6e-07,
"output_cost_per_token_batches": 1.8e-07,
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
"supports_pdf_input": false,
"supports_reasoning": true
},
"vertex_ai/openai/gpt-oss-20b-maas": {
@ -53000,6 +53072,7 @@
"mode": "chat",
"output_cost_per_token": 2.5e-07,
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
"supports_pdf_input": false,
"supports_reasoning": true,
"cache_read_input_token_cost": 7e-09,
"input_cost_per_token_batches": 3.5e-08,
@ -53131,6 +53204,7 @@
"us-south1"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"vertex_ai/qwen/qwen3-coder-480b-a35b-instruct-maas": {
@ -53149,6 +53223,7 @@
"global"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"vertex_ai/qwen/qwen3-next-80b-a3b-instruct-maas": {
@ -53164,6 +53239,7 @@
"global"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"vertex_ai/qwen/qwen3-next-80b-a3b-thinking-maas": {
@ -53179,6 +53255,7 @@
"global"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"vertex_ai/veo-3.1-generate-preview": {
@ -67322,6 +67399,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -67336,6 +67414,7 @@
"mode": "chat",
"output_cost_per_token": 1.2e-06,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -67378,6 +67457,7 @@
"output_cost_per_token": 1.5e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -67392,6 +67472,7 @@
"output_cost_per_token": 3.1e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -67406,6 +67487,7 @@
"output_cost_per_token": 5.5e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -67420,6 +67502,7 @@
"output_cost_per_token": 6.25e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -67436,6 +67519,7 @@
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
@ -67451,6 +67535,7 @@
"output_cost_per_token": 3.2e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_tool_choice": true
},
@ -67469,6 +67554,7 @@
"supported_output_modalities": [
"text"
],
"supports_pdf_input": false,
"supports_response_schema": true,
"supports_vision": true
},
@ -67478,7 +67564,8 @@
"output_cost_per_token": 5.4e-06,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"azure_ai/deepseek-v3-0324": {
"deprecation_date": "2026-07-13",
@ -67486,7 +67573,8 @@
"output_cost_per_token": 4.56e-06,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"azure_ai/deepseek-v3.1": {
"deprecation_date": "2026-07-13",
@ -67494,7 +67582,8 @@
"output_cost_per_token": 4.94e-06,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"azure_ai/grok-3": {
"deprecation_date": "2026-05-01",
@ -67502,7 +67591,8 @@
"output_cost_per_token": 1.5e-05,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"azure_ai/grok-3-mini": {
"deprecation_date": "2026-05-01",
@ -67510,7 +67600,8 @@
"output_cost_per_token": 1.27e-06,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"azure_ai/grok-4-fast-non-reasoning": {
"deprecation_date": "2026-05-01",
@ -67518,7 +67609,8 @@
"output_cost_per_token": 5e-07,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"azure_ai/grok-4-fast-reasoning": {
"deprecation_date": "2026-05-01",
@ -67526,7 +67618,8 @@
"output_cost_per_token": 5e-07,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"bedrock/us-gov-west-1/nvidia.nemotron-nano-3-30b": {
"input_cost_per_token": 7.2e-08,
@ -73196,6 +73289,7 @@
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
"supported_regions": ["global"],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
@ -73811,6 +73905,7 @@
"output_cost_per_token": 3.5e-06,
"source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/microsoft/",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -74865,6 +74960,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": false,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -75113,6 +75209,7 @@
"output_cost_per_token_above_200k_tokens": 1.2e-05,
"source": "https://docs.x.ai/developers/models/grok-4.7",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,

View file

@ -647,9 +647,10 @@ class PromptSecurityGuardrail(CustomGuardrail):
async def _process_document_item(self, item: dict, user_api_key_alias: str | None) -> dict:
"""Process and sanitize document/file items."""
doc_data: Final = item.get("document") or item.get("file") or item
url_key: Final = "file_data" if isinstance(doc_data, dict) and not doc_data.get("url") else "url"
if isinstance(doc_data, dict):
url = doc_data.get("url", "")
url = doc_data.get(url_key) or ""
doc_content = doc_data.get("data", "")
else:
url = doc_data if isinstance(doc_data, str) else ""
@ -698,7 +699,7 @@ class PromptSecurityGuardrail(CustomGuardrail):
if url.startswith("data:") and header:
sanitized_url: Final = f"{header},{sanitized_encoded}"
if isinstance(doc_data, dict):
doc_data["url"] = sanitized_url
doc_data[url_key] = sanitized_url
elif isinstance(doc_data, dict):
doc_data["data"] = sanitized_encoded

View file

@ -4147,7 +4147,12 @@ class CustomPricingLiteLLMParams(MirroredPricingParams):
DEPLOYMENT_SCOPED_PRICING_FIELDS: Final[frozenset[str]] = frozenset({"off_peak_pricing"})
DEPLOYMENT_SCOPED_CAPABILITY_FIELDS: Final[frozenset[str]] = frozenset(
{"supports_regex_lookaround", "supports_assistant_prefill", "supports_bedrock_native_structured_output"}
{
"supports_regex_lookaround",
"supports_assistant_prefill",
"supports_bedrock_native_structured_output",
"supports_pdf_input",
}
)
DEPLOYMENT_SCOPED_TOKEN_COUNT_FIELDS: Final[frozenset[str]] = frozenset({"token_count_framing", "token_count_model"})

View file

@ -4049,6 +4049,7 @@
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": false,
"supports_response_schema": true,
"supports_tool_choice": true
},
@ -12437,7 +12438,8 @@
"supported_output_modalities": [
"text"
],
"supports_native_streaming": true
"supports_native_streaming": true,
"supports_pdf_input": false
},
"azure_ai/FLUX-1.1-pro": {
"deprecation_date": "2027-01-05",
@ -12502,6 +12504,7 @@
"output_cost_per_token": 1.85e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12516,6 +12519,7 @@
"output_cost_per_token": 3.828e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12531,6 +12535,7 @@
"output_cost_per_token": 3.52e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12546,6 +12551,7 @@
"output_cost_per_token": 4.84e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12560,6 +12566,7 @@
"output_cost_per_token": 4.84e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12574,6 +12581,7 @@
"output_cost_per_token": 7.26e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12594,6 +12602,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12616,6 +12625,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -12638,6 +12648,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -12660,6 +12671,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -12687,6 +12699,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -12703,6 +12716,7 @@
"output_cost_per_token": 1.32e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12724,6 +12738,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -12744,6 +12759,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -12765,6 +12781,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12833,6 +12850,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12847,6 +12865,7 @@
"output_cost_per_token": 7.1e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/Llama-4-Maverick-17B-128E-Instruct-FP8": {
@ -12859,6 +12878,7 @@
"output_cost_per_token": 1e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": true
},
@ -12872,6 +12892,7 @@
"output_cost_per_token": 7.8e-07,
"source": "https://azure.microsoft.com/en-us/blog/introducing-the-llama-4-herd-in-azure-ai-foundry-and-azure-databricks/",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": true
},
@ -12884,6 +12905,7 @@
"mode": "chat",
"output_cost_per_token": 3.7e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/Meta-Llama-3.1-70B-Instruct": {
@ -12895,6 +12917,7 @@
"mode": "chat",
"output_cost_per_token": 3.54e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/Phi-3-medium-128k-instruct": {
@ -12906,6 +12929,7 @@
"mode": "chat",
"output_cost_per_token": 6.8e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12918,6 +12942,7 @@
"mode": "chat",
"output_cost_per_token": 6.8e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12930,6 +12955,7 @@
"mode": "chat",
"output_cost_per_token": 5.2e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12942,6 +12968,7 @@
"mode": "chat",
"output_cost_per_token": 5.2e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12954,6 +12981,7 @@
"mode": "chat",
"output_cost_per_token": 6e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12966,6 +12994,7 @@
"mode": "chat",
"output_cost_per_token": 6e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12978,6 +13007,7 @@
"mode": "chat",
"output_cost_per_token": 6.4e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12990,6 +13020,7 @@
"mode": "chat",
"output_cost_per_token": 5.2e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -13002,6 +13033,7 @@
"mode": "chat",
"output_cost_per_token": 5.2e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": true
},
@ -13015,6 +13047,7 @@
"output_cost_per_token": 5e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -13027,7 +13060,8 @@
"mode": "chat",
"output_cost_per_token": 3e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true
"supports_function_calling": true,
"supports_pdf_input": false
},
"azure_ai/Phi-4-multimodal-instruct": {
"deprecation_date": "2027-01-17",
@ -13041,6 +13075,7 @@
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_audio_input": true,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_vision": true
},
"azure_ai/Phi-4-mini-reasoning": {
@ -13052,7 +13087,8 @@
"mode": "chat",
"output_cost_per_token": 3e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true
"supports_function_calling": true,
"supports_pdf_input": false
},
"azure_ai/Phi-4-reasoning": {
"deprecation_date": "2027-01-17",
@ -13064,6 +13100,7 @@
"output_cost_per_token": 5e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_reasoning": true
},
@ -13106,6 +13143,7 @@
"output_cost_per_token": 1e-05,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/doc-intelligence/prebuilt-read": {
@ -13144,6 +13182,7 @@
"mode": "chat",
"output_cost_per_token": 5.4e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_tool_choice": true
},
@ -13202,6 +13241,7 @@
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -13217,6 +13257,7 @@
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -13230,6 +13271,7 @@
"mode": "chat",
"output_cost_per_token": 4.56e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/deepseek-v4-pro": {
@ -13242,6 +13284,7 @@
"output_cost_per_token": 3.48e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_tool_choice": true,
"cache_read_input_token_cost": 1.45e-07,
@ -13257,6 +13300,7 @@
"output_cost_per_token": 5.1e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_tool_choice": true,
"cache_read_input_token_cost": 2.8e-08,
@ -13273,6 +13317,7 @@
"output_cost_per_token": 1.32e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -13306,6 +13351,7 @@
"output_cost_per_token": 1.5e-05,
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_web_search": true
@ -13323,6 +13369,7 @@
"output_cost_per_token_above_200k_tokens": 5e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
@ -13343,6 +13390,7 @@
"output_cost_per_token_above_200k_tokens": 1.2e-05,
"source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/grok/",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
@ -13361,6 +13409,7 @@
"output_cost_per_token": 2.5e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_response_schema": true,
"supports_tool_choice": true,
@ -13379,6 +13428,7 @@
"output_cost_per_token": 2.5e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_response_schema": true,
"supports_tool_choice": true,
@ -13395,6 +13445,7 @@
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_web_search": true
@ -13409,6 +13460,7 @@
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
@ -13424,6 +13476,7 @@
"output_cost_per_token": 1.5e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_web_search": true
@ -13436,7 +13489,8 @@
"max_output_tokens": 8192,
"mode": "chat",
"output_cost_per_token": 0.00971,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models"
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false
},
"azure_ai/jamba-instruct": {
"deprecation_date": "2025-03-01",
@ -13447,6 +13501,7 @@
"mode": "chat",
"output_cost_per_token": 7e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/kimi-k2.5": {
@ -13459,6 +13514,7 @@
"output_cost_per_token": 3e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_video_input": true,
"supports_vision": true,
@ -13482,6 +13538,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_tool_choice": true,
"supports_vision": true,
@ -13498,6 +13555,7 @@
"output_cost_per_token": 4e-08,
"source": "https://marketplace.microsoft.com/en/marketplace/apps/000-000.ministral-3b-2410-offer?tab=Overview",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/mistral-large": {
@ -13510,6 +13568,7 @@
"output_cost_per_token": 1.2e-05,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/mistral-large-2407": {
@ -13522,6 +13581,7 @@
"output_cost_per_token": 6e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/mistral-large-latest": {
@ -13533,6 +13593,7 @@
"output_cost_per_token": 6e-06,
"source": "https://marketplace.microsoft.com/en/marketplace/apps/000-000.mistral-ai-large-2407-offer?tab=Overview",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/mistral-large-3": {
@ -13544,6 +13605,7 @@
"output_cost_per_token": 1.5e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": true
},
@ -13557,6 +13619,7 @@
"output_cost_per_token": 2e-06,
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/mistral-nemo": {
@ -13568,7 +13631,8 @@
"mode": "chat",
"output_cost_per_token": 1.5e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_function_calling": true
"supports_function_calling": true,
"supports_pdf_input": false
},
"azure_ai/mistral-small": {
"deprecation_date": "2025-07-31",
@ -13580,6 +13644,7 @@
"output_cost_per_token": 3e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/mistral-small-2503": {
@ -13591,6 +13656,7 @@
"mode": "chat",
"output_cost_per_token": 3e-07,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": true
},
@ -52006,6 +52072,7 @@
],
"supports_assistant_prefill": false,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -52026,6 +52093,7 @@
],
"supports_assistant_prefill": false,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -52777,6 +52845,7 @@
"output_cost_per_token": 2.5e-06,
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_web_search": true
},
@ -52794,6 +52863,7 @@
"global"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_tool_choice": true
},
@ -52811,6 +52881,7 @@
"global"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -52989,6 +53060,7 @@
"output_cost_per_token": 3.6e-07,
"output_cost_per_token_batches": 1.8e-07,
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
"supports_pdf_input": false,
"supports_reasoning": true
},
"vertex_ai/openai/gpt-oss-20b-maas": {
@ -53000,6 +53072,7 @@
"mode": "chat",
"output_cost_per_token": 2.5e-07,
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
"supports_pdf_input": false,
"supports_reasoning": true,
"cache_read_input_token_cost": 7e-09,
"input_cost_per_token_batches": 3.5e-08,
@ -53131,6 +53204,7 @@
"us-south1"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"vertex_ai/qwen/qwen3-coder-480b-a35b-instruct-maas": {
@ -53149,6 +53223,7 @@
"global"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"vertex_ai/qwen/qwen3-next-80b-a3b-instruct-maas": {
@ -53164,6 +53239,7 @@
"global"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"vertex_ai/qwen/qwen3-next-80b-a3b-thinking-maas": {
@ -53179,6 +53255,7 @@
"global"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"vertex_ai/veo-3.1-generate-preview": {
@ -67322,6 +67399,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -67336,6 +67414,7 @@
"mode": "chat",
"output_cost_per_token": 1.2e-06,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -67378,6 +67457,7 @@
"output_cost_per_token": 1.5e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -67392,6 +67472,7 @@
"output_cost_per_token": 3.1e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -67406,6 +67487,7 @@
"output_cost_per_token": 5.5e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -67420,6 +67502,7 @@
"output_cost_per_token": 6.25e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -67436,6 +67519,7 @@
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
@ -67451,6 +67535,7 @@
"output_cost_per_token": 3.2e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_tool_choice": true
},
@ -67469,6 +67554,7 @@
"supported_output_modalities": [
"text"
],
"supports_pdf_input": false,
"supports_response_schema": true,
"supports_vision": true
},
@ -67478,7 +67564,8 @@
"output_cost_per_token": 5.4e-06,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"azure_ai/deepseek-v3-0324": {
"deprecation_date": "2026-07-13",
@ -67486,7 +67573,8 @@
"output_cost_per_token": 4.56e-06,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"azure_ai/deepseek-v3.1": {
"deprecation_date": "2026-07-13",
@ -67494,7 +67582,8 @@
"output_cost_per_token": 4.94e-06,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"azure_ai/grok-3": {
"deprecation_date": "2026-05-01",
@ -67502,7 +67591,8 @@
"output_cost_per_token": 1.5e-05,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"azure_ai/grok-3-mini": {
"deprecation_date": "2026-05-01",
@ -67510,7 +67600,8 @@
"output_cost_per_token": 1.27e-06,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"azure_ai/grok-4-fast-non-reasoning": {
"deprecation_date": "2026-05-01",
@ -67518,7 +67609,8 @@
"output_cost_per_token": 5e-07,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"azure_ai/grok-4-fast-reasoning": {
"deprecation_date": "2026-05-01",
@ -67526,7 +67618,8 @@
"output_cost_per_token": 5e-07,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"bedrock/us-gov-west-1/nvidia.nemotron-nano-3-30b": {
"input_cost_per_token": 7.2e-08,
@ -73196,6 +73289,7 @@
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
"supported_regions": ["global"],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
@ -73811,6 +73905,7 @@
"output_cost_per_token": 3.5e-06,
"source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/microsoft/",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -74865,6 +74960,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": false,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -75113,6 +75209,7 @@
"output_cost_per_token_above_200k_tokens": 1.2e-05,
"source": "https://docs.x.ai/developers/models/grok-4.7",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,

View file

@ -0,0 +1,421 @@
import json
import uuid
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Final
import anthropic
import pytest
from integration._support.client import Gateway, Scenario, string_value
from integration._support.pdf_document import LETTER, Page, document, encoded, pdf_bytes
from integration._support.process import owned_proxy_process
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import JsonValue, TypeAdapter
_API_KEY: Final = "synthetic-pdf-document-key"
_ANSWER: Final = "The codeword is PELICAN-42."
_TITLE: Final = "Quarterly report"
_URL: Final = "https://example.com/brief.pdf"
_SAP_TOKEN_TARGET: Final = "/oauth/token"
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
_PARTS: Final = TypeAdapter(list[dict[str, JsonValue]])
def _pdf(text: str) -> str:
return encoded(pdf_bytes((Page(text=text), LETTER)))
def _chat_completion(identity: str, model: str) -> Reply:
return Reply(
body=json.dumps(
{
"id": identity,
"object": "chat.completion",
"created": 1,
"model": model,
"choices": [
{"index": 0, "message": {"role": "assistant", "content": _ANSWER}, "finish_reason": "stop"}
],
"usage": {"prompt_tokens": 914, "completion_tokens": 9, "total_tokens": 923},
}
).encode()
)
def _only_body(wire: Wire) -> dict[str, JsonValue]:
received: Final = tuple(
request
for request in wire.drain()
if request.method == "POST" and not request.target.startswith(_SAP_TOKEN_TARGET)
)
assert len(received) == 1, received
return _JSON_OBJECT.validate_json(received[0].body)
def _user_parts(body: dict[str, JsonValue], key: str) -> list[dict[str, JsonValue]]:
turns: Final = _PARTS.validate_python(body[key])
assert len(turns) == 1, turns
return _PARTS.validate_python(turns[0]["content" if key == "messages" else "parts"])
def test_anthropic_sdk_pdf_documents_reach_azure_as_file_parts_named_by_title(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
backend: Final = f"gpt-pdf-{marker[:8]}"
identity: Final = f"chatcmpl-{marker}"
titled: Final = _pdf(f"PELICAN-42 {marker}")
untitled: Final = _pdf(f"ORCHID-77 {marker}")
question: Final = f"What codeword does each document carry? {marker}"
with wire_server(lambda _: _chat_completion(identity, backend)) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(
model=f"azure/{backend}", api_base=wire.url, api_key=_API_KEY, api_version="2024-10-21"
)
client: Final = anthropic.Anthropic(base_url=str(gateway.client.base_url), api_key=gateway.key, max_retries=0)
message: Final = client.messages.create(
model=model,
max_tokens=64,
messages=[
{
"role": "user",
"content": [
{
"type": "document",
"title": _TITLE,
"source": {"type": "base64", "media_type": "application/pdf", "data": titled},
},
{
"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": untitled},
},
{"type": "document", "source": {"type": "url", "url": _URL}},
{"type": "text", "text": question},
],
}
],
)
assert message.id == identity
assert [(block.type, getattr(block, "text", None)) for block in message.content] == [("text", _ANSWER)]
assert _user_parts(_only_body(wire), "messages") == [
{"type": "file", "file": {"filename": _TITLE, "file_data": f"data:application/pdf;base64,{titled}"}},
{
"type": "file",
"file": {"filename": "document.pdf", "file_data": f"data:application/pdf;base64,{untitled}"},
},
{"type": "image_url", "image_url": {"url": _URL}},
{"type": "text", "text": question},
]
@dataclass(frozen=True, slots=True)
class _Reader:
deploy: Callable[[Scenario, Wire], str]
reply: Callable[[Request], Reply]
pdf_parts: Callable[[dict[str, JsonValue]], Sequence[JsonValue]]
def _gemini_reply(_: Request) -> Reply:
return Reply(
body=json.dumps(
{
"candidates": [
{"content": {"parts": [{"text": _ANSWER}], "role": "model"}, "finishReason": "STOP", "index": 0}
],
"usageMetadata": {"promptTokenCount": 300, "candidatesTokenCount": 9, "totalTokenCount": 309},
"modelVersion": "gemini-2.5-flash",
}
).encode()
)
def _bedrock_reply(_: Request) -> Reply:
return Reply(
body=json.dumps(
{
"output": {"message": {"role": "assistant", "content": [{"text": _ANSWER}]}},
"stopReason": "end_turn",
"usage": {"inputTokens": 300, "outputTokens": 9, "totalTokens": 309},
"metrics": {"latencyMs": 1},
}
).encode()
)
def _anthropic_reply(_: Request) -> Reply:
return Reply(
body=json.dumps(
{
"id": "msg_pdf",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-5",
"content": [{"type": "text", "text": _ANSWER}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 300, "output_tokens": 9},
}
).encode()
)
def _sap_reply(request: Request) -> Reply:
if request.target.startswith(_SAP_TOKEN_TARGET):
return Reply(body=json.dumps({"access_token": "scripted", "expires_in": 3600}).encode())
match request.target:
case "/v2/lm/deployments":
deployment: Final = {
"scenarioId": "orchestration",
"configurationId": "orchestration-config",
"deploymentUrl": f"http://{request.headers['host']}/sap-deployment",
"createdAt": "2026-01-01T00:00:00Z",
}
return Reply(body=json.dumps({"resources": [deployment]}).encode())
case "/v2/lm/configurations/orchestration-config":
return Reply(body=json.dumps({"executableId": "orchestration"}).encode())
case _:
completion: Final = _JSON_OBJECT.validate_json(_chat_completion("chatcmpl-sap-pdf", "claude").body)
return Reply(body=json.dumps({"final_result": completion}).encode())
def _snowflake(scenario: Scenario, wire: Wire) -> str:
return scenario.model(model="snowflake/claude-sonnet-4-5", api_base=wire.url, api_key=_API_KEY)
def _sap(scenario: Scenario, wire: Wire) -> str:
service_key: Final = {
"clientid": "scripted",
"clientsecret": _API_KEY,
"url": wire.url,
"serviceurls": {"AI_API_URL": wire.url},
}
return scenario.model(model="sap/anthropic--claude-4.5-sonnet", api_base=wire.url, api_key=json.dumps(service_key))
def _snowflake_pdf_parts(body: dict[str, JsonValue]) -> Sequence[JsonValue]:
return tuple(part.get("source") for part in _user_parts(body, "messages") if part.get("type") == "document")
def _sap_pdf_parts(body: dict[str, JsonValue]) -> Sequence[JsonValue]:
config: Final = _JSON_OBJECT.validate_python(body["config"])
templating: Final = _JSON_OBJECT.validate_python(
_JSON_OBJECT.validate_python(config["modules"])["prompt_templating"]
)
prompt: Final = _JSON_OBJECT.validate_python(templating["prompt"])
turns: Final = _PARTS.validate_python(prompt["template"])
assert len(turns) == 1, turns
parts: Final = _PARTS.validate_python(turns[0]["content"])
return tuple(
_JSON_OBJECT.validate_python(part["image_url"]).get("url") for part in parts if part.get("type") == "image_url"
)
def _gemini(scenario: Scenario, wire: Wire) -> str:
return scenario.model(model="gemini/gemini-2.5-flash", api_base=wire.url, api_key=_API_KEY)
def _bedrock(scenario: Scenario, wire: Wire) -> str:
return scenario.model(
model="bedrock/converse/us.amazon.nova-2-lite-v1:0",
api_key=None,
aws_access_key_id="AKIDSYNTHETIC",
aws_secret_access_key="synthetic-secret",
aws_region_name="us-east-1",
aws_bedrock_runtime_endpoint=wire.url,
)
def _gemini_pdf_parts(body: dict[str, JsonValue]) -> Sequence[JsonValue]:
return tuple(part.get("inline_data") for part in _user_parts(body, "contents") if "inline_data" in part)
def _bedrock_pdf_parts(body: dict[str, JsonValue]) -> Sequence[JsonValue]:
documents: Final = tuple(
_JSON_OBJECT.validate_python(part["document"]) for part in _user_parts(body, "messages") if "document" in part
)
return tuple({"format": item.get("format"), "source": item.get("source")} for item in documents)
@pytest.mark.parametrize(
("reader", "expected"),
[
pytest.param(
_Reader(_gemini, _gemini_reply, _gemini_pdf_parts),
lambda data: [{"mime_type": "application/pdf", "data": data}],
id="gemini",
),
pytest.param(
_Reader(_bedrock, _bedrock_reply, _bedrock_pdf_parts),
lambda data: [{"format": "pdf", "source": {"bytes": data}}],
id="bedrock-converse",
),
pytest.param(
_Reader(_snowflake, _anthropic_reply, _snowflake_pdf_parts),
lambda data: [{"type": "base64", "media_type": "application/pdf", "data": data}],
id="snowflake-claude",
),
pytest.param(
_Reader(_sap, _sap_reply, _sap_pdf_parts),
lambda data: [f"data:application/pdf;base64,{data}"],
id="sap-orchestration",
),
],
)
def test_messages_pdf_still_reaches_providers_that_read_pdfs_in_their_own_document_shape(
gateway: Gateway, reader: _Reader, expected: Callable[[str], Sequence[JsonValue]]
) -> None:
marker: Final = uuid.uuid4().hex
data: Final = _pdf(f"PELICAN-42 {marker}")
with wire_server(reader.reply) as wire, gateway.scenario() as scenario:
model: Final = reader.deploy(scenario, wire)
response: Final = gateway.request(
"POST",
"/v1/messages",
{
"model": model,
"max_tokens": 64,
"messages": [
{
"role": "user",
"content": [
document({"type": "base64", "media_type": "application/pdf", "data": data}, title=_TITLE),
{"type": "text", "text": f"What is the codeword? {marker}"},
],
}
],
},
headers={"anthropic-version": "2023-06-01"},
)
assert response.status_code == 200, response.text
assert _JSON_OBJECT.validate_json(response.content)["content"] == [{"type": "text", "text": _ANSWER}]
assert list(reader.pdf_parts(_only_body(wire))) == list(expected(data))
def _prompt_security(job: str) -> Callable[[Request], Reply]:
def respond(request: Request) -> Reply:
match (request.method, request.target.partition("?")[0]):
case ("POST", "/api/sanitizeFile"):
return Reply(body=json.dumps({"jobId": job}).encode())
case ("GET", "/api/sanitizeFile"):
return Reply(
body=json.dumps(
{
"status": "done",
"content": "",
"metadata": {"action": "block", "violations": ["Malicious File"]},
}
).encode()
)
case _:
return Reply(body=json.dumps({"result": {"prompt": {"action": "allow"}}}).encode())
return respond
def _delete_guardrail(gateway: Gateway, identity: str) -> None:
response: Final = gateway.request("DELETE", f"/guardrails/{identity}")
assert response.status_code == 200, response.text
def test_prompt_security_still_sanitizes_a_messages_pdf_and_blocks_it_before_the_provider(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
pdf: Final = pdf_bytes((Page(text=f"PELICAN-42 {marker}"), LETTER))
with (
wire_server(lambda _: _chat_completion(f"chatcmpl-{marker}", "gpt-pdf")) as provider,
wire_server(_prompt_security(f"job-{marker}")) as sink,
gateway.scenario() as scenario,
):
model: Final = scenario.model(
model=f"azure/gpt-pdf-{marker[:8]}", api_base=provider.url, api_key=_API_KEY, api_version="2024-10-21"
)
name: Final = f"pdf-prompt-security-{marker}"
created: Final = gateway.post(
"/guardrails",
{
"guardrail": {
"guardrail_name": name,
"litellm_params": {
"guardrail": "prompt_security",
"mode": "pre_call",
"api_key": "synthetic-prompt-security-key",
"api_base": sink.url,
},
}
},
)
scenario.cleanups.callback(_delete_guardrail, gateway, string_value(created["guardrail_id"]))
response: Final = gateway.request(
"POST",
"/v1/messages",
{
"model": model,
"max_tokens": 64,
"guardrails": [name],
"messages": [
{
"role": "user",
"content": [
document({"type": "base64", "media_type": "application/pdf", "data": encoded(pdf)}),
{"type": "text", "text": f"Summarize this PDF {marker}"},
],
}
],
},
headers={"anthropic-version": "2023-06-01"},
)
assert response.status_code == 400, response.text
assert "Malicious File" in response.text
assert provider.drain() == ()
uploads: Final = tuple(
request for request in sink.drain() if (request.method, request.target) == ("POST", "/api/sanitizeFile")
)
assert len(uploads) == 1, uploads
assert pdf in uploads[0].body
def _gigachat_reply(file_id: str) -> Callable[[Request], Reply]:
def respond(request: Request) -> Reply:
if request.target.endswith("/files"):
return Reply(body=json.dumps({"id": file_id, "object": "file", "purpose": "general"}).encode())
return _chat_completion(f"chatcmpl-{file_id}", "GigaChat-Pro")
return respond
def test_anthropic_sdk_pdf_document_reaches_gigachat_as_an_uploaded_attachment(
gateway: Gateway, tmp_path: Path
) -> None:
marker: Final = uuid.uuid4().hex
file_id: Final = f"file-{marker}"
pdf: Final = pdf_bytes((Page(text=f"PELICAN-42 {marker}"), LETTER))
with (
wire_server(_gigachat_reply(file_id)) as wire,
owned_proxy_process(gateway, tmp_path, {"GIGACHAT_ACCESS_TOKEN": _API_KEY}) as owned,
owned.gateway.scenario() as scenario,
):
model: Final = scenario.model(model="gigachat/GigaChat-Pro", api_base=f"{wire.url}/v1", api_key=_API_KEY)
client: Final = anthropic.Anthropic(
base_url=str(owned.gateway.client.base_url), api_key=owned.gateway.key, max_retries=0
)
message: Final = client.messages.create(
model=model,
max_tokens=64,
messages=[
{
"role": "user",
"content": [
{
"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": encoded(pdf)},
},
{"type": "text", "text": f"What is the codeword? {marker}"},
],
}
],
)
assert [(block.type, getattr(block, "text", None)) for block in message.content] == [("text", _ANSWER)]
upload, chat = (request for request in wire.drain() if request.method == "POST")
assert upload.target == "/v1/files", upload.target
assert upload.headers["authorization"] == f"Bearer {_API_KEY}", sorted(upload.headers)
assert pdf in upload.body
assert b"application/pdf" in upload.body
assert chat.target == "/v1/chat/completions", chat.target
turns: Final = _PARTS.validate_python(_JSON_OBJECT.validate_json(chat.body)["messages"])
assert turns[-1]["attachments"] == [file_id], chat.body

View file

@ -0,0 +1,190 @@
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

View file

@ -0,0 +1,390 @@
import json
import uuid
from collections.abc import Callable, Mapping, Sequence
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
from typing import Final
import httpx
import pytest
from integration._support.client import Gateway, Scenario
from integration._support.openai_wire import chat_reply
from integration._support.pdf_document import (
LETTER,
chat_body,
chat_file,
messages_body,
pdf_data_url,
pdf_document,
responses_body,
responses_input_file,
)
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import JsonValue, TypeAdapter
_API_KEY: Final = "synthetic-pdf-gate-key"
_ANSWER: Final = "The report covers revenue."
_FILENAME: Final = "report.pdf"
_PDF: Final = pdf_data_url((LETTER,))
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
_MESSAGES: Final = TypeAdapter(list[JsonValue])
_COST_MAP_PATH: Final = Path(__file__).parents[3] / "litellm" / "model_prices_and_context_window_backup.json"
@dataclass(frozen=True, slots=True)
class _Entrypoint:
path: str
body: Callable[[str, str], Mapping[str, JsonValue]]
stream: bool = False
provider: str = "hosted_vllm"
_ENTRYPOINTS: Final = (
pytest.param(
_Entrypoint("/v1/chat/completions", lambda model, text: chat_body(model, [chat_file(_PDF, _FILENAME)], text)),
id="chat-completions",
),
pytest.param(
_Entrypoint(
"/v1/chat/completions",
lambda model, text: chat_body(model, [chat_file(_PDF, _FILENAME)], text, stream=True),
stream=True,
),
id="chat-completions-stream",
),
pytest.param(
_Entrypoint("/v1/messages", lambda model, text: messages_body(model, [pdf_document((LETTER,))], text)),
id="messages",
),
pytest.param(
_Entrypoint(
"/v1/responses",
lambda model, text: responses_body(model, [responses_input_file(_PDF, _FILENAME)], text),
provider="lm_studio",
),
id="responses-bridge",
),
)
def _azure_ai_chat_model_pdf_input(entry: JsonValue) -> bool | None:
match entry:
case {"litellm_provider": "azure_ai", "mode": "chat", "supports_pdf_input": bool() as supports}:
return supports
case _:
return None
def _azure_ai_chat_model(reads_pdfs: bool) -> str:
entries: Final = _JSON_OBJECT.validate_json(_COST_MAP_PATH.read_bytes())
matching: Final = sorted(
name for name, entry in entries.items() if _azure_ai_chat_model_pdf_input(entry) is reads_pdfs
)
assert matching, f"the cost map marks no azure_ai chat model with supports_pdf_input {reads_pdfs}"
return matching[0]
def _ollama_reply(backend: str) -> Reply:
return Reply(
body=json.dumps(
{
"model": backend,
"created_at": "2026-01-01T00:00:00Z",
"message": {"role": "assistant", "content": _ANSWER},
"done": True,
"done_reason": "stop",
"prompt_eval_count": 5,
"eval_count": 3,
}
).encode()
)
def _responder(backend: str) -> Callable[[Request], Reply]:
def respond(request: Request) -> Reply:
if request.target.endswith("/api/chat"):
return _ollama_reply(backend)
body: Final = _JSON_OBJECT.validate_json(request.body)
return chat_reply(f"chatcmpl-{uuid.uuid4().hex}", backend, _ANSWER, stream=body.get("stream") is True)
return respond
def _post(gateway: Gateway, path: str, body: Mapping[str, JsonValue]) -> httpx.Response:
return gateway.request("POST", path, body, headers={"anthropic-version": "2023-06-01"})
def _post_streaming(gateway: Gateway, path: str, body: Mapping[str, JsonValue]) -> httpx.Response:
with gateway.client.stream(
"POST", path, json=dict(body), headers={"Authorization": f"Bearer {gateway.key}"}
) as response:
response.read()
return response
def _send(gateway: Gateway, entrypoint: _Entrypoint, model: str, text: str) -> httpx.Response:
body: Final = entrypoint.body(model, text)
if entrypoint.stream:
return _post_streaming(gateway, entrypoint.path, body)
return _post(gateway, entrypoint.path, body)
def _error_message(response: httpx.Response) -> str:
match _JSON_OBJECT.validate_json(response.content):
case {"error": {"message": str() as message}}:
return message
case _:
raise AssertionError(response.text)
def _assert_refused_before_the_provider(response: httpx.Response, wire: Wire, backend: str) -> None:
assert response.status_code == 400, response.text
message: Final = _error_message(response)
assert "PDF" in message and backend in message, message
assert wire.drain() == ()
def _bodies(wire: Wire) -> tuple[dict[str, JsonValue], ...]:
return tuple(_JSON_OBJECT.validate_json(request.body) for request in wire.drain() if request.method == "POST")
def _only_body(wire: Wire) -> dict[str, JsonValue]:
bodies: Final = _bodies(wire)
assert len(bodies) == 1, bodies
return bodies[0]
def _assert_reaches_the_provider(gateway: Gateway, wire: Wire, model: str) -> None:
response: Final = _post(gateway, "/v1/chat/completions", chat_body(model, [], f"text only {uuid.uuid4().hex}"))
assert response.status_code == 200, response.text
assert len(_bodies(wire)) == 1
def _openai_compatible(
scenario: Scenario,
wire: Wire,
backend: str,
model_info: Mapping[str, JsonValue],
provider: str = "hosted_vllm",
**parameters: JsonValue,
) -> str:
return scenario.model(
model=f"{provider}/{backend}",
api_base=f"{wire.url}/v1",
api_key=_API_KEY,
model_info=model_info,
**parameters,
)
def _backend() -> str:
return f"pdf-gate-{uuid.uuid4().hex[:12]}"
@pytest.mark.parametrize("entrypoint", _ENTRYPOINTS)
def test_pdf_to_a_deployment_marked_unable_to_read_it_is_refused_before_the_provider(
gateway: Gateway, entrypoint: _Entrypoint
) -> None:
backend: Final = _backend()
with wire_server(_responder(backend)) as wire, gateway.scenario() as scenario:
model: Final = _openai_compatible(scenario, wire, backend, {"supports_pdf_input": False}, entrypoint.provider)
response: Final = _send(gateway, entrypoint, model, f"Summarize the report {uuid.uuid4().hex}")
_assert_refused_before_the_provider(response, wire, backend)
_assert_reaches_the_provider(gateway, wire, model)
def _base_model_deployment(scenario: Scenario, wire: Wire) -> tuple[str, str]:
backend: Final = f"pdf-gate-{uuid.uuid4().hex[:12]}"
model: Final = scenario.model(
model=f"azure_ai/{backend}",
api_base=wire.url,
api_key=_API_KEY,
model_info={"base_model": _azure_ai_chat_model(reads_pdfs=False)},
)
return model, backend
def _litellm_params_base_model_deployment(scenario: Scenario, wire: Wire) -> tuple[str, str]:
backend: Final = _backend()
model: Final = scenario.model(
model=f"azure_ai/{backend}",
api_base=wire.url,
api_key=_API_KEY,
base_model=_azure_ai_chat_model(reads_pdfs=False),
)
return model, backend
def _cost_map_deployment(scenario: Scenario, wire: Wire) -> tuple[str, str]:
marked: Final = _azure_ai_chat_model(reads_pdfs=False)
return scenario.model(model=marked, api_base=wire.url, api_key=_API_KEY), marked.removeprefix("azure_ai/")
def _ollama_deployment(scenario: Scenario, wire: Wire) -> tuple[str, str]:
backend: Final = _backend()
return scenario.model(model=f"ollama_chat/{backend}", api_base=wire.url, api_key=None), backend
@pytest.mark.parametrize(
"deploy",
[
pytest.param(_base_model_deployment, id="model-info-base-model"),
pytest.param(_litellm_params_base_model_deployment, id="litellm-params-base-model"),
pytest.param(_cost_map_deployment, id="cost-map"),
pytest.param(_ollama_deployment, id="ollama"),
],
)
def test_pdf_to_a_model_its_declaration_marks_unable_is_refused_before_the_provider(
gateway: Gateway, deploy: Callable[[Scenario, Wire], tuple[str, str]]
) -> None:
with wire_server(_responder("scripted")) as wire, gateway.scenario() as scenario:
model, backend = deploy(scenario, wire)
response: Final = _post(
gateway,
"/v1/chat/completions",
chat_body(model, [chat_file(_PDF, _FILENAME)], f"Summarize the report {uuid.uuid4().hex}"),
)
_assert_refused_before_the_provider(response, wire, backend)
_assert_reaches_the_provider(gateway, wire, model)
def _pdf_turns(question: str) -> list[JsonValue]:
return [
{"role": "user", "content": [chat_file(_PDF, _FILENAME), {"type": "text", "text": question}]},
{"role": "assistant", "content": "It is a quarterly report."},
{"role": "user", "content": [chat_file(_PDF, "appendix.pdf")]},
{"role": "user", "content": "Who wrote it?"},
]
def _answer(response: httpx.Response) -> JsonValue:
match _JSON_OBJECT.validate_json(response.content):
case {"choices": [{"message": {"content": content}}]}:
return content
case _:
raise AssertionError(response.text)
def _turns_without_pdfs(question: str) -> list[JsonValue]:
return [
{"role": "user", "content": [{"type": "text", "text": question}]},
{"role": "assistant", "content": "It is a quarterly report."},
{"role": "user", "content": "Who wrote it?"},
]
@pytest.mark.parametrize(
("deployment_params", "request_params"),
[
pytest.param({"drop_params": True}, {}, id="deployment-drop-params"),
pytest.param({}, {"drop_params": True}, id="request-drop-params"),
],
)
def test_drop_params_sends_the_conversation_without_the_pdfs_a_model_cannot_read(
gateway: Gateway, deployment_params: Mapping[str, JsonValue], request_params: Mapping[str, JsonValue]
) -> None:
backend: Final = _backend()
question: Final = f"Summarize the report {uuid.uuid4().hex}"
with wire_server(_responder(backend)) as wire, gateway.scenario() as scenario:
model: Final = _openai_compatible(scenario, wire, backend, {"supports_pdf_input": False}, **deployment_params)
response: Final = _post(
gateway,
"/v1/chat/completions",
{"model": model, "max_tokens": 64, "messages": _pdf_turns(question), **request_params},
)
assert response.status_code == 200, response.text
assert _answer(response) == _ANSWER
assert _only_body(wire)["messages"] == _turns_without_pdfs(question)
def test_drop_params_refuses_a_request_left_with_no_user_turn_once_the_pdf_is_dropped(gateway: Gateway) -> None:
backend: Final = _backend()
with wire_server(_responder(backend)) as wire, gateway.scenario() as scenario:
model: Final = _openai_compatible(scenario, wire, backend, {"supports_pdf_input": False}, drop_params=True)
response: Final = _post(
gateway,
"/v1/chat/completions",
{
"model": model,
"max_tokens": 64,
"messages": [
{"role": "system", "content": f"Answer from the attachment {uuid.uuid4().hex}"},
{"role": "user", "content": [chat_file(_PDF, _FILENAME)]},
],
},
)
_assert_refused_before_the_provider(response, wire, backend)
_assert_reaches_the_provider(gateway, wire, model)
def _file_and_question(question: str) -> list[JsonValue]:
return [{"role": "user", "content": [chat_file(_PDF, _FILENAME), {"type": "text", "text": question}]}]
@pytest.mark.parametrize(
("model_info", "deployment_params", "keeps_pdf"),
[
pytest.param({}, {}, True, id="undeclared"),
pytest.param({}, {"drop_params": True}, False, id="undeclared-drop-params"),
pytest.param({"supports_pdf_input": True}, {"drop_params": True}, True, id="declared-drop-params"),
pytest.param(
{},
{"drop_params": True, "base_model": _azure_ai_chat_model(reads_pdfs=True)},
True,
id="base-model-reads-drop-params",
),
],
)
def test_azure_ai_model_outside_the_cost_map_gets_the_pdf_unless_drop_params_finds_no_declaration(
gateway: Gateway, model_info: Mapping[str, JsonValue], deployment_params: Mapping[str, JsonValue], keeps_pdf: bool
) -> None:
backend: Final = _backend()
question: Final = f"Summarize the report {uuid.uuid4().hex}"
with wire_server(_responder(backend)) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(
model=f"azure_ai/{backend}",
api_base=wire.url,
api_key=_API_KEY,
model_info=model_info,
**deployment_params,
)
response: Final = _post(
gateway, "/v1/chat/completions", chat_body(model, [chat_file(_PDF, _FILENAME)], question)
)
assert response.status_code == 200, response.text
expected: Final[list[JsonValue]] = (
_file_and_question(question) if keeps_pdf else [{"role": "user", "content": question}]
)
assert _only_body(wire)["messages"] == expected
def _sibling_pdf_request(gateway: Gateway, model: str) -> httpx.Response:
return _post(
gateway,
"/v1/chat/completions",
chat_body(model, [chat_file(_PDF, _FILENAME)], f"Summarize the report {uuid.uuid4().hex}"),
)
def _forwarded_file_parts(bodies: Sequence[dict[str, JsonValue]]) -> tuple[JsonValue, ...]:
return tuple(_MESSAGES.validate_python(body["messages"])[0] for body in bodies)
def test_pdf_opt_out_on_one_deployment_leaves_its_sibling_on_the_same_model_reading_pdfs(gateway: Gateway) -> None:
backend: Final = _backend()
burst: Final = 6
with wire_server(_responder(backend)) as wire, gateway.scenario() as scenario:
sibling: Final = _openai_compatible(scenario, wire, backend, {})
marked: Final = _openai_compatible(scenario, wire, backend, {"supports_pdf_input": False})
with ThreadPoolExecutor(max_workers=burst) as pool:
responses: Final = tuple(pool.map(lambda _: _sibling_pdf_request(gateway, sibling), range(burst)))
assert [response.status_code for response in responses] == [200] * burst, [r.text for r in responses]
forwarded: Final = _forwarded_file_parts(_bodies(wire))
assert len(forwarded) == burst, forwarded
assert all(
_MESSAGES.validate_python(_JSON_OBJECT.validate_python(message)["content"])[0] == chat_file(_PDF, _FILENAME)
for message in forwarded
), forwarded
refused: Final = _sibling_pdf_request(gateway, marked)
_assert_refused_before_the_provider(refused, wire, backend)

View file

@ -2968,3 +2968,54 @@ def test_convert_to_model_response_object_function_output():
assert result.usage.completion_tokens_details == CompletionTokensDetailsWrapper(reasoning_tokens=0)
assert result._hidden_params is not None
_URL_CITATION: Final = {
"type": "url_citation",
"url_citation": {
"url": "https://example.com",
"title": "Example",
"content": "cited snippet",
"start_index": 0,
"end_index": 4,
},
}
_FILE_ANNOTATION: Final = {
"type": "file",
"file": {"hash": "abc123", "name": "report.pdf", "content": [{"type": "text", "text": "parsed page"}]},
}
def _message_with_annotations(annotations: list[dict[str, object]]) -> Message:
response: Final = {
"id": "gen-1",
"object": "chat.completion",
"created": 1712697600,
"model": "z-ai/glm-5.3",
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {"role": "assistant", "content": "done", "annotations": annotations},
}
],
"usage": {"completion_tokens": 1, "prompt_tokens": 1, "total_tokens": 2},
}
return convert_to_model_response_object(
response_object=response, model_response_object=ModelResponse(), response_type="completion"
).choices[0].message
def test_annotations_typed_other_than_url_citation_move_to_provider_specific_fields() -> None:
untyped_annotation: Final = {"start_index": 0, "end_index": 4}
message: Final = _message_with_annotations([_URL_CITATION, untyped_annotation, _FILE_ANNOTATION])
assert message.annotations == [_URL_CITATION, untyped_annotation]
assert message.provider_specific_fields["annotations"] == [_FILE_ANNOTATION]
def test_url_citation_annotations_keep_provider_keys() -> None:
message: Final = _message_with_annotations([_URL_CITATION])
assert message.annotations == [_URL_CITATION]
assert "annotations" not in (message.provider_specific_fields or {})

View file

@ -5286,6 +5286,40 @@ def test_eager_input_streaming_tool_reaches_bedrock_converse_as_beta():
assert data["toolConfig"]["tools"][0]["toolSpec"]["inputSchema"]["json"] == EAGER_INPUT_SCHEMA
@pytest.mark.parametrize(
("title", "expected_filename"),
[(None, "document.pdf"), ("Quarterly report", "Quarterly report")],
)
def test_user_pdf_document_reaches_openai_compatible_providers_as_file_part(
title: str | None, expected_filename: str
) -> None:
document: Final = _base64_pdf_block() | ({"title": title} if title else {})
request_messages: Final = _run_chat_completions_pipeline(
[
AnthropicMessagesUserMessageParam(
role="user",
content=[document, {"type": "text", "text": "What is the codeword?"}],
)
]
)
assert request_messages == [
{
"role": "user",
"content": [
{
"type": "file",
"file": {
"filename": expected_filename,
"file_data": f"data:application/pdf;base64,{TOOL_RESULT_PDF_B64}",
},
},
{"type": "text", "text": "What is the codeword?"},
],
}
]
_CHAT_BRIDGE_MODEL: Final = "azure/gpt-5.6"
_CHAT_BRIDGE_API_BASE: Final = "https://tool-result-text.openai.azure.com"
_CHAT_BRIDGE_URL: Final = r".*tool-result-text\.openai\.azure\.com/openai/deployments/gpt-5\.6/chat/completions.*"

View file

@ -1101,6 +1101,58 @@ def test_map_azure_model_group(model_group_header, expected_model):
assert config._map_azure_model_group(model_group_header) == expected_model
def test_azure_ai_sends_file_parts_instead_of_flattening_them_to_text() -> None:
file_part: Final = {
"type": "file",
"file": {"filename": "document.pdf", "file_data": "data:application/pdf;base64,JVBERi0xLjQK"},
}
content: Final = [file_part, {"type": "text", "text": "What is the codeword?"}]
request: Final = AzureAIStudioConfig().transform_request(
model="Kimi-K2.6",
messages=[{"role": "user", "content": content}],
optional_params={},
litellm_params={},
headers={},
)
assert request["messages"] == [{"role": "user", "content": content}]
def test_azure_ai_passes_dict_message_content_through_for_the_provider_to_judge() -> None:
content: Final = {"type": "text", "text": "Say hi"}
request: Final = AzureAIStudioConfig().transform_request(
model="gpt-5.6-sol",
messages=[{"role": "user", "content": content}],
optional_params={},
litellm_params={},
headers={},
)
assert request["messages"] == [{"role": "user", "content": content}]
@pytest.mark.parametrize(
"file",
[
{"filename": "notes.txt", "file_data": "data:text/plain;base64,aGVsbG8="},
{"file_id": "file-abc"},
],
ids=["text_data_url", "file_id_only"],
)
def test_azure_ai_flattens_a_non_pdf_file_part_to_its_text_as_before(file: dict[str, str]) -> None:
request: Final = AzureAIStudioConfig().transform_request(
model="Llama-3.3-70B-Instruct",
messages=[{"role": "user", "content": [{"type": "text", "text": "summarize"}, {"type": "file", "file": file}]}],
optional_params={},
litellm_params={},
headers={},
)
assert request["messages"] == [{"role": "user", "content": "summarize"}]
def test_translate_developer_role_hoists_a_later_developer_message_for_a_model_that_rejects_it(
declare_mid_conversation_system: Callable[[str, bool], None],
) -> None:

View file

@ -8,6 +8,7 @@ get_model_response_iterator, and get_error_class.
"""
import json
from typing import Final
from unittest.mock import MagicMock, patch
import httpx
@ -512,6 +513,20 @@ class TestTransformMessages:
assert result[0]["content"] == "look"
assert "file-456" in result[0]["attachments"]
def test_list_content_with_pdf_file_part_uploads_it_as_an_attachment(self) -> None:
pdf_data_url: Final = "data:application/pdf;base64,JVBERi0xLjQK"
content: Final = [
{"type": "text", "text": "summarize"},
{"type": "file", "file": {"filename": "report.pdf", "file_data": pdf_data_url}},
]
with patch.object(self.config, "_upload_image", return_value="file-789") as upload:
result: Final = self.config._transform_messages([
{"role": "user", "content": content}
])
upload.assert_called_once_with(pdf_data_url)
assert result[0]["content"] == "summarize"
assert result[0]["attachments"] == ["file-789"]
class TestTransformRequest:
def setup_method(self):

View file

@ -1,4 +1,5 @@
import datetime
from typing import Final
import httpx
import pytest
import json
@ -2164,3 +2165,32 @@ class TestOpenAIPrefixMaxTokensKey:
chat_request = _oci_chat_request("openai.gpt-5", {"max_tokens": 64})
assert chat_request["maxCompletionTokens"] == 64
assert "maxTokens" not in chat_request
def test_transform_request_sends_a_pdf_file_part_as_an_image_data_url() -> None:
pdf_data_url: Final = "data:application/pdf;base64,JVBERi0xLjQK"
transformed_request: Final = OCIChatConfig().transform_request(
model="google.gemini-2.5-flash",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "Summarize this."},
{"type": "file", "file": {"filename": "report.pdf", "file_data": pdf_data_url}},
],
}
],
optional_params={"oci_compartment_id": TEST_COMPARTMENT_ID},
litellm_params={},
headers={},
)
assert transformed_request["chatRequest"]["messages"] == [
{
"role": "USER",
"content": [
{"type": "TEXT", "text": "Summarize this."},
{"type": "IMAGE", "imageUrl": {"url": pdf_data_url}},
],
}
]

View file

@ -1,4 +1,6 @@
import warnings
from typing import Final
import pytest
from pydantic import ValidationError
@ -908,6 +910,22 @@ class TestCacheControl:
assert "cache_control" not in content[0]
assert content[1].get("cache_control") == {"type": "ephemeral"}
def test_pdf_file_part_reaches_sap_as_an_image_url_data_url(self) -> None:
pdf_data_url: Final = "data:application/pdf;base64,JVBERi0xLjQK"
messages: Final = [
{
"role": "user",
"content": [
{"type": "text", "text": "Summarize this."},
{"type": "file", "file": {"filename": "report.pdf", "file_data": pdf_data_url}},
],
}
]
body: Final = self._transform("anthropic--claude-3-5-sonnet", messages)
content: Final = self._template(body)[0]["content"]
assert content[1]["type"] == "image_url"
assert content[1]["image_url"]["url"] == pdf_data_url
def test_message_level_cache_control_folded_onto_trailing_image_block(self):
messages = [
{

View file

@ -18,6 +18,7 @@ import pytest
import litellm
from litellm import completion, acompletion
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.llms.anthropic.messages.adapters.transformation import LiteLLMAnthropicMessagesAdapter
from litellm.llms.snowflake.chat.transformation import SnowflakeConfig, SnowflakeStreamingHandler
from litellm.types.utils import ModelResponse
@ -479,6 +480,35 @@ class TestSnowflakeCortexClaudeFixes:
}
]
def test_messages_api_pdf_reaches_cortex_claude_as_a_document_block(self) -> None:
translated: Final = LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai(
messages=[
{
"role": "user",
"content": [
{
"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": "ZmFrZQ=="},
"cache_control": {"type": "ephemeral"},
},
{"type": "text", "text": "What is the codeword?"},
],
}
],
model="snowflake/claude-sonnet-4-6",
)
body: Final = self._transform(translated)
assert body["messages"][0]["content"] == [
{
"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": "ZmFrZQ=="},
"cache_control": {"type": "ephemeral"},
},
{"type": "text", "text": "What is the codeword?"},
]
def test_multipart_tool_result_preserves_text_and_converts_image(self):
body = self._transform(
[

View file

@ -13,6 +13,7 @@ These tests verify that:
import json
import os
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@ -422,3 +423,52 @@ async def test_vertex_ai_gemma_vision_passthrough():
assert isinstance(content, list), f"Expected list content, got: {content}"
image_parts = [p for p in content if p.get("type") == "image_url"]
assert image_parts, f"No image_url part in forwarded message content: {content}"
@pytest.mark.asyncio
async def test_vertex_ai_gemma_pdf_file_part_is_sent_as_image_url() -> None:
pdf_data_url: Final = "data:application/pdf;base64,JVBERi0xLjQKJcfsj6IKMSAwIG9iago8PD4+CmVuZG9iagp0cmFpbGVyCjw8Pj4KJSVFT0YK"
messages: Final = [
{
"role": "user",
"content": [
{"type": "text", "text": "What does the PDF say?"},
{"type": "file", "file": {"file_data": pdf_data_url, "filename": "report.pdf"}},
],
}
]
mock_response: Final = MagicMock()
mock_response.status_code = 200
mock_response.headers = {}
mock_response.json.return_value = _MOCK_RESPONSE_JSON
mock_vertexai: Final = MagicMock()
mock_vertexai.preview = MagicMock()
with (
patch("litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler") as mock_http_handler,
patch(
"litellm.llms.vertex_ai.vertex_ai_partner_models.main.VertexAIPartnerModels._ensure_access_token",
return_value=("fake-token", "test-project"),
),
patch.dict(
"sys.modules",
{"vertexai": mock_vertexai, "vertexai.preview": mock_vertexai.preview},
),
patch.dict(litellm.model_cost, _GEMMA_MODEL_COST_ENTRY, clear=False),
):
mock_http_handler.return_value.post = AsyncMock(return_value=mock_response)
await litellm.acompletion(
model="vertex_ai/google/gemma-4-26b-a4b-it-maas",
messages=messages,
vertex_ai_project="test-project",
)
body: Final = json.loads(mock_http_handler.return_value.post.call_args.kwargs["data"])
user_msg: Final = next(m for m in body["messages"] if m["role"] == "user")
assert user_msg["content"] == [
{"type": "text", "text": "What does the PDF say?"},
{"type": "image_url", "image_url": {"url": pdf_data_url}},
]

View file

@ -1,6 +1,7 @@
import asyncio
import base64
from collections.abc import Mapping, Sequence
from typing import Final
from unittest.mock import AsyncMock, patch
import pytest
@ -8,6 +9,7 @@ from fastapi.exceptions import HTTPException
from httpx import ReadTimeout, Request, Response
import litellm
from litellm.llms.anthropic.chat.guardrail_translation.handler import AnthropicMessagesHandler
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.guardrails.guardrail_hooks.prompt_security.prompt_security import (
PromptSecurityGuardrail,
@ -1247,3 +1249,54 @@ async def test_check_tool_results_enabled(monkeypatch: pytest.MonkeyPatch):
assert "indirect_prompt_injection" in str(excinfo.value.detail)
@pytest.mark.asyncio
async def test_messages_api_pdf_reaches_file_sanitization_and_is_blocked() -> None:
sanitize_url: Final = "https://test.prompt.security/api/sanitizeFile"
upload: Final = AsyncMock(
return_value=Response(200, json={"jobId": "pdf-job"}, request=Request("POST", sanitize_url))
)
poll: Final = AsyncMock(
return_value=Response(
200,
json={"status": "done", "content": "", "metadata": {"action": "block", "violations": ["Malicious File"]}},
request=Request("GET", sanitize_url),
)
)
guardrail: Final = PromptSecurityGuardrail(
api_key="test-key",
api_base="https://test.prompt.security",
guardrail_name="test-guard",
event_hook="pre_call",
default_on=True,
)
guardrail.poll_interval = 0
pdf: Final = b"%PDF-1.4 pelican"
request: Final = {
"model": "gpt-5.6",
"max_tokens": 64,
"messages": [
{
"role": "user",
"content": [
{
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": base64.b64encode(pdf).decode(),
},
},
{"type": "text", "text": "Summarize this PDF"},
],
}
],
}
with patch.object(guardrail.async_handler, "post", upload), patch.object(guardrail.async_handler, "get", poll):
with pytest.raises(HTTPException) as blocked:
await AnthropicMessagesHandler().process_input_messages(request, guardrail)
assert blocked.value.status_code == 400
assert "Malicious File" in str(blocked.value.detail)
assert upload.call_args.kwargs["files"]["file"][1] == pdf

View file

@ -23,7 +23,7 @@ import respx
from openai import APITimeoutError, AsyncOpenAI, OpenAI
from openai._legacy_response import LegacyAPIResponse
from openai.types.chat.chat_completion import ChatCompletion
from pydantic import TypeAdapter
from pydantic import BaseModel, ConfigDict, TypeAdapter
import litellm
from litellm import (
@ -10200,6 +10200,309 @@ async def test_atranscription_ignores_a_non_string_base_url_when_api_base_is_set
assert response.text == "four score"
_PDF_FILE_PART: Final = {
"type": "file",
"file": {"filename": "report.pdf", "file_data": "data:application/pdf;base64,JVBERi0xLjQK"},
}
_SUMMARIZE_TEXT_PART: Final = {"type": "text", "text": "Summarize this."}
def _pdf_messages() -> list[dict[str, object]]:
return [{"role": "user", "content": [_SUMMARIZE_TEXT_PART, _PDF_FILE_PART]}]
class _SentChatRequest(BaseModel):
model_config = ConfigDict(frozen=True)
messages: list[dict[str, object]]
def _sent_messages(respx_mock: respx.MockRouter) -> list[dict[str, object]]:
request: Final[httpx.Request] = respx_mock.calls[0].request
return _SentChatRequest.model_validate_json(request.content).messages
def _mock_pdf_chat_route(respx_mock: respx.MockRouter, api_base: str) -> respx.Route:
return respx_mock.post(url__regex=rf"{api_base}/chat/completions.*").mock(
return_value=httpx.Response(
status_code=200,
json={
"id": "chatcmpl-pdf",
"object": "chat.completion",
"created": 1712697600,
"model": "qa-pdf-model",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
},
)
)
def test_drop_params_drops_a_pdf_the_model_cannot_read(respx_mock: respx.MockRouter) -> None:
api_base: Final = "http://localhost:12347/v1"
_mock_pdf_chat_route(respx_mock, api_base)
litellm.completion(
model="openai/qa-text-only-model",
messages=_pdf_messages(),
api_base=api_base,
api_key="fake_openai_api_key",
model_info={"supports_pdf_input": False},
drop_params=True,
)
assert _sent_messages(respx_mock) == [{"role": "user", "content": [_SUMMARIZE_TEXT_PART]}]
@pytest.mark.parametrize(
"pdf_file",
[
{"filename": "report.pdf", "file_data": "JVBERi0xLjQK"},
{"file_id": "file-report", "format": "application/pdf"},
{"file_id": "file-report", "filename": "Report.PDF"},
],
ids=["bare_base64_named_pdf", "file_id_with_pdf_format", "file_id_named_pdf"],
)
def test_drop_params_drops_a_pdf_file_part_without_a_data_url(
respx_mock: respx.MockRouter, pdf_file: dict[str, str]
) -> None:
api_base: Final = "http://localhost:12350/v1"
_mock_pdf_chat_route(respx_mock, api_base)
litellm.completion(
model="openai/qa-text-only-model",
messages=[{"role": "user", "content": [_SUMMARIZE_TEXT_PART, {"type": "file", "file": pdf_file}]}],
api_base=api_base,
api_key="fake_openai_api_key",
model_info={"supports_pdf_input": False},
drop_params=True,
)
assert _sent_messages(respx_mock) == [{"role": "user", "content": [_SUMMARIZE_TEXT_PART]}]
def test_drop_params_drops_a_user_turn_that_held_only_a_pdf(respx_mock: respx.MockRouter) -> None:
api_base: Final = "http://localhost:12352/v1"
_mock_pdf_chat_route(respx_mock, api_base)
litellm.completion(
model="openai/qa-text-only-model",
messages=[
{"role": "user", "content": [_PDF_FILE_PART]},
{"role": "user", "content": [_SUMMARIZE_TEXT_PART]},
],
api_base=api_base,
api_key="fake_openai_api_key",
model_info={"supports_pdf_input": False},
drop_params=True,
)
assert _sent_messages(respx_mock) == [{"role": "user", "content": [_SUMMARIZE_TEXT_PART]}]
def test_drop_params_keeps_roles_alternating_after_dropping_a_pdf_only_turn(respx_mock: respx.MockRouter) -> None:
api_base: Final = "http://localhost:12353/v1"
_mock_pdf_chat_route(respx_mock, api_base)
litellm.completion(
model="openai/qa-text-only-model",
messages=[
{"role": "user", "content": [_SUMMARIZE_TEXT_PART]},
{"role": "assistant", "content": "Send the file."},
{"role": "user", "content": [_PDF_FILE_PART]},
{"role": "assistant", "content": "Got it."},
{"role": "user", "content": [_SUMMARIZE_TEXT_PART]},
],
api_base=api_base,
api_key="fake_openai_api_key",
model_info={"supports_pdf_input": False},
drop_params=True,
ensure_alternating_roles=True,
)
sent_roles: Final = [message["role"] for message in _sent_messages(respx_mock)]
assert sent_roles == ["user", "assistant", "user", "assistant", "user"]
def test_drop_params_keeps_an_image_data_url_whose_filename_says_pdf(respx_mock: respx.MockRouter) -> None:
api_base: Final = "http://localhost:12351/v1"
_mock_pdf_chat_route(respx_mock, api_base)
image_part: Final = {
"type": "file",
"file": {"filename": "scan.pdf", "file_data": "data:image/png;base64,iVBORw0KGgo="},
}
litellm.completion(
model="openai/qa-text-only-model",
messages=[{"role": "user", "content": [_SUMMARIZE_TEXT_PART, image_part]}],
api_base=api_base,
api_key="fake_openai_api_key",
model_info={"supports_pdf_input": False},
drop_params=True,
)
assert _sent_messages(respx_mock) == [{"role": "user", "content": [_SUMMARIZE_TEXT_PART, image_part]}]
def test_pdf_to_a_model_that_cannot_read_it_is_rejected_without_drop_params(respx_mock: respx.MockRouter) -> None:
api_base: Final = "http://localhost:12348/v1"
route: Final = _mock_pdf_chat_route(respx_mock, api_base)
with pytest.raises(litellm.BadRequestError, match="does not accept PDF input"):
litellm.completion(
model="openai/qa-text-only-model",
messages=_pdf_messages(),
api_base=api_base,
api_key="fake_openai_api_key",
model_info={"supports_pdf_input": False},
)
assert not route.called
def test_pdf_to_a_model_with_unknown_pdf_support_is_sent_even_with_drop_params(respx_mock: respx.MockRouter) -> None:
api_base: Final = "http://localhost:12349/v1"
_mock_pdf_chat_route(respx_mock, api_base)
litellm.completion(
model="openai/qa-unlisted-model",
messages=_pdf_messages(),
api_base=api_base,
api_key="fake_openai_api_key",
drop_params=True,
)
assert _sent_messages(respx_mock) == _pdf_messages()
def test_pdf_to_ollama_is_rejected_without_drop_params() -> None:
with pytest.raises(litellm.BadRequestError, match="does not accept PDF input"):
litellm.completion(model="ollama_chat/llama3.2", messages=_pdf_messages(), api_base="http://127.0.0.1:9")
def _azure_ai_model_without_pdf_input() -> str:
return next(
name
for name, entry in litellm.model_cost.items()
if entry.get("litellm_provider") == "azure_ai" and entry.get("supports_pdf_input") is False
)
def test_pdf_to_a_deployment_whose_base_model_cannot_read_it_is_rejected() -> None:
with pytest.raises(litellm.BadRequestError, match="does not accept PDF input"):
litellm.completion(
model="azure_ai/qa-custom-deployment",
messages=_pdf_messages(),
api_base="http://127.0.0.1:9",
api_key="fake_azure_ai_api_key",
model_info={"base_model": _azure_ai_model_without_pdf_input()},
)
def test_drop_params_drops_a_pdf_a_deployments_base_model_cannot_read(respx_mock: respx.MockRouter) -> None:
api_base: Final = "http://localhost:12353"
_mock_pdf_chat_route(respx_mock, api_base)
litellm.completion(
model="azure_ai/qa-custom-deployment",
messages=_pdf_messages(),
api_base=api_base,
api_key="fake_azure_ai_api_key",
model_info={"base_model": _azure_ai_model_without_pdf_input()},
drop_params=True,
)
assert _sent_messages(respx_mock) == [{"role": "user", "content": _SUMMARIZE_TEXT_PART["text"]}]
def _azure_ai_model_with_pdf_input() -> str:
return next(
name
for name, entry in litellm.model_cost.items()
if entry.get("litellm_provider") == "azure_ai" and entry.get("supports_pdf_input") is True
)
def test_pdf_to_a_deployment_whose_base_model_param_cannot_read_it_is_rejected() -> None:
with pytest.raises(litellm.BadRequestError, match="does not accept PDF input"):
litellm.completion(
model="azure_ai/qa-custom-deployment",
messages=_pdf_messages(),
api_base="http://127.0.0.1:9",
api_key="fake_azure_ai_api_key",
base_model=_azure_ai_model_without_pdf_input(),
)
def test_drop_params_keeps_a_pdf_a_deployments_base_model_param_reads(respx_mock: respx.MockRouter) -> None:
api_base: Final = "http://localhost:12357"
_mock_pdf_chat_route(respx_mock, api_base)
litellm.completion(
model="azure_ai/qa-custom-deployment",
messages=_pdf_messages(),
api_base=api_base,
api_key="fake_azure_ai_api_key",
base_model=_azure_ai_model_with_pdf_input(),
drop_params=True,
)
assert _sent_messages(respx_mock) == _pdf_messages()
def test_drop_params_rejects_a_request_whose_only_user_turn_was_a_pdf(respx_mock: respx.MockRouter) -> None:
api_base: Final = "http://localhost:12354/v1"
route: Final = _mock_pdf_chat_route(respx_mock, api_base)
with pytest.raises(litellm.BadRequestError, match="no user message is left"):
litellm.completion(
model="openai/qa-text-only-model",
messages=[
{"role": "system", "content": "You read documents."},
{"role": "user", "content": [_PDF_FILE_PART]},
],
api_base=api_base,
api_key="fake_openai_api_key",
model_info={"supports_pdf_input": False},
drop_params=True,
)
assert not route.called
def test_drop_params_drops_a_pdf_an_unlisted_azure_ai_model_never_declared_it_reads(
respx_mock: respx.MockRouter,
) -> None:
api_base: Final = "http://localhost:12355"
_mock_pdf_chat_route(respx_mock, api_base)
litellm.completion(
model="azure_ai/qa-unlisted-deployment",
messages=_pdf_messages(),
api_base=api_base,
api_key="fake_azure_ai_api_key",
drop_params=True,
)
assert _sent_messages(respx_mock) == [{"role": "user", "content": _SUMMARIZE_TEXT_PART["text"]}]
def test_drop_params_keeps_a_pdf_an_azure_ai_deployment_declares_it_reads(respx_mock: respx.MockRouter) -> None:
api_base: Final = "http://localhost:12356"
_mock_pdf_chat_route(respx_mock, api_base)
litellm.completion(
model="azure_ai/qa-unlisted-deployment",
messages=_pdf_messages(),
api_base=api_base,
api_key="fake_azure_ai_api_key",
model_info={"supports_pdf_input": True},
drop_params=True,
)
assert _sent_messages(respx_mock) == _pdf_messages()
@pytest.mark.parametrize("stream", (False, True))
@pytest.mark.parametrize("asynchronous", (False, True))
@pytest.mark.parametrize("profile,custom_price", ((None, False), ("us.openai.gpt-6-sol", False), (None, True)))

View file

@ -614,6 +614,63 @@ def test_capability_flag_stays_on_the_deployment_that_set_it(flag: str) -> None:
_restore_model_cost_entries(model_keys)
def test_pdf_opt_out_on_one_deployment_does_not_block_its_sibling(respx_mock: respx.MockRouter) -> None:
api_base: Final = "http://localhost:12361/v1"
backend_model: Final = "openai/qa-shared-pdf-backend"
pdf_messages: Final = [
{
"role": "user",
"content": [
{"type": "text", "text": "Summarize this."},
{
"type": "file",
"file": {"filename": "report.pdf", "file_data": "data:application/pdf;base64,JVBERi0xLjQK"},
},
],
}
]
route: Final = respx_mock.post(url__regex=rf"{api_base}/chat/completions.*").mock(
return_value=httpx.Response(
status_code=200,
json={
"id": "chatcmpl-pdf",
"object": "chat.completion",
"created": 1712697600,
"model": "qa-shared-pdf-backend",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
},
)
)
model_keys: Final = {
key: litellm.model_cost.get(key) for key in ("qa-pdf-marked", "qa-pdf-unmarked", backend_model)
}
try:
router: Final = Router(
model_list=[
{
"model_name": "pdf-blocked",
"litellm_params": {"model": backend_model, "api_base": api_base, "api_key": "fake-key"},
"model_info": {"id": "qa-pdf-marked", "supports_pdf_input": False},
},
{
"model_name": "pdf-allowed",
"litellm_params": {"model": backend_model, "api_base": api_base, "api_key": "fake-key"},
"model_info": {"id": "qa-pdf-unmarked"},
},
],
)
with pytest.raises(litellm.BadRequestError, match="does not accept PDF input"):
router.completion(model="pdf-blocked", messages=pdf_messages)
router.completion(model="pdf-allowed", messages=pdf_messages)
assert route.call_count == 1
assert json.loads(route.calls[0].request.content)["messages"] == pdf_messages
finally:
_restore_model_cost_entries(model_keys)
def test_token_count_settings_stay_on_the_deployment_that_set_them() -> None:
"""A deployment's ``token_count_model`` and ``token_count_framing`` must not land on
the shared ``{provider}/{model}`` key, or a sibling deployment of that model would count with them."""

View file

@ -6,6 +6,8 @@ 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
@ -189,3 +191,46 @@ def test_stream_chunk_builder_no_annotations():
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]}