mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
fix(responses): count input_file tokens instead of silently dropping the file
The Responses-to-chat transform dropped the filename OpenAI requires next to file_data, so a request carrying an inline PDF counted 13 tokens instead of 36 and a real completion through the chat bridge got a 400.
This commit is contained in:
parent
fe90c6f6fc
commit
c9908ffabb
4 changed files with 121 additions and 2 deletions
|
|
@ -21,7 +21,13 @@ class ResponsesInputImagePart(TypedDict):
|
|||
detail: ReadOnly[str]
|
||||
|
||||
|
||||
ResponsesInputPart = ResponsesInputTextPart | ResponsesInputImagePart
|
||||
class ResponsesInputFilePart(TypedDict):
|
||||
type: ReadOnly[Literal["input_file"]]
|
||||
filename: ReadOnly[str]
|
||||
file_data: ReadOnly[str]
|
||||
|
||||
|
||||
ResponsesInputPart = ResponsesInputTextPart | ResponsesInputImagePart | ResponsesInputFilePart
|
||||
|
||||
ResponsesContentRole = Literal["user", "assistant"]
|
||||
|
||||
|
|
@ -39,6 +45,22 @@ def _chat_image_block_to_responses_part(image_url: object) -> ResponsesInputImag
|
|||
return part
|
||||
|
||||
|
||||
def _chat_file_block_to_responses_part(file_value: object) -> ResponsesInputFilePart | None:
|
||||
"""Only an inline file round trips: OpenAI rejects `file_data` without the `filename` beside it."""
|
||||
if not isinstance(file_value, Mapping):
|
||||
return None
|
||||
filename: Final = file_value.get("filename")
|
||||
file_data: Final = file_value.get("file_data")
|
||||
if not isinstance(filename, str) or not filename or not isinstance(file_data, str) or not file_data:
|
||||
return None
|
||||
part: Final[ResponsesInputFilePart] = {
|
||||
"type": "input_file",
|
||||
"filename": filename,
|
||||
"file_data": file_data,
|
||||
}
|
||||
return part
|
||||
|
||||
|
||||
def _chat_block_to_responses_part(block: object, role: ResponsesContentRole) -> ResponsesInputPart | None:
|
||||
if isinstance(block, str):
|
||||
bare: Final[ResponsesInputTextPart] = {"type": "input_text", "text": block}
|
||||
|
|
@ -55,6 +77,8 @@ def _chat_block_to_responses_part(block: object, role: ResponsesContentRole) ->
|
|||
return text
|
||||
case "image_url" if role == "user":
|
||||
return _chat_image_block_to_responses_part(block.get("image_url"))
|
||||
case "file" if role == "user":
|
||||
return _chat_file_block_to_responses_part(block.get("file"))
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
|
@ -65,7 +89,7 @@ def chat_content_blocks_to_responses_content(
|
|||
) -> str | tuple[ResponsesInputPart, ...]:
|
||||
"""Text-only content collapses to a joined string, which every role accepts and counts identically.
|
||||
|
||||
Only a user turn may carry an image part: the Responses API rejects any part but
|
||||
Only a user turn may carry an image or file part: the Responses API rejects any part but
|
||||
output_text and refusal inside an assistant turn.
|
||||
"""
|
||||
parts: Final = tuple(
|
||||
|
|
|
|||
|
|
@ -1629,6 +1629,8 @@ class LiteLLMCompletionResponsesConfig:
|
|||
file_dict["file_id"] = file_id
|
||||
if item.get("file_data"):
|
||||
file_dict["file_data"] = item["file_data"]
|
||||
if item.get("filename"):
|
||||
file_dict["filename"] = item["filename"]
|
||||
|
||||
new_item: Final[dict[str, object]] = {"type": "file", "file": file_dict}
|
||||
if "cache_control" in item:
|
||||
|
|
|
|||
|
|
@ -323,6 +323,80 @@ def test_messages_to_responses_input_keeps_user_image_alongside_an_assistant_tur
|
|||
]
|
||||
|
||||
|
||||
def test_messages_to_responses_input_preserves_inline_files():
|
||||
"""An inline file must survive the round trip, or the count silently drops the file.
|
||||
|
||||
A small PDF is worth 36 tokens to OpenAI's counting API; dropping it left the same
|
||||
request counting 13, the text-only total.
|
||||
"""
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Summarize this file."},
|
||||
{
|
||||
"type": "file",
|
||||
"file": {"filename": "report.pdf", "file_data": "data:application/pdf;base64,JVBERi0="},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
input_items, _ = OpenAICountTokensConfig.messages_to_responses_input(messages)
|
||||
|
||||
assert input_items == [
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
{"type": "input_text", "text": "Summarize this file."},
|
||||
{
|
||||
"type": "input_file",
|
||||
"filename": "report.pdf",
|
||||
"file_data": "data:application/pdf;base64,JVBERi0=",
|
||||
},
|
||||
),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_messages_to_responses_input_drops_a_file_with_no_inline_data():
|
||||
"""OpenAI rejects `file_data` without a `filename`, and a rejected request loses the whole count."""
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Summarize this file."},
|
||||
{"type": "file", "file": {"file_data": "data:application/pdf;base64,JVBERi0="}},
|
||||
{"type": "file", "file": {"file_id": "file-abc123"}},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
input_items, _ = OpenAICountTokensConfig.messages_to_responses_input(messages)
|
||||
|
||||
assert input_items == [{"role": "user", "content": "Summarize this file."}]
|
||||
|
||||
|
||||
def test_messages_to_responses_input_assistant_file_block_is_dropped():
|
||||
"""A file part is illegal inside an assistant turn, so it must not reach the provider."""
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "Here it is"},
|
||||
{
|
||||
"type": "file",
|
||||
"file": {"filename": "report.pdf", "file_data": "data:application/pdf;base64,JVBERi0="},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
input_items, _ = OpenAICountTokensConfig.messages_to_responses_input(messages)
|
||||
|
||||
assert input_items == [{"role": "assistant", "content": "Here it is"}]
|
||||
|
||||
|
||||
def test_validate_request_valid():
|
||||
"""Test that valid requests pass validation."""
|
||||
config = OpenAICountTokensConfig()
|
||||
|
|
|
|||
|
|
@ -124,6 +124,25 @@ class TestLiteLLMCompletionResponsesConfig:
|
|||
assert "extra_field" not in result["file"]
|
||||
assert "another_field" not in result["file"]
|
||||
|
||||
def test_transform_input_file_item_to_file_item_keeps_filename(self):
|
||||
"""OpenAI rejects file_data with no filename beside it, so dropping it 400s the request"""
|
||||
result = (
|
||||
LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item(
|
||||
{
|
||||
"type": "input_file",
|
||||
"filename": "report.pdf",
|
||||
"file_data": "data:application/pdf;base64,JVBERi0=",
|
||||
}
|
||||
)
|
||||
)
|
||||
assert result == {
|
||||
"type": "file",
|
||||
"file": {
|
||||
"file_data": "data:application/pdf;base64,JVBERi0=",
|
||||
"filename": "report.pdf",
|
||||
},
|
||||
}
|
||||
|
||||
def test_transform_input_file_item_to_file_item_with_file_url(self):
|
||||
"""file_url should be mapped to file_id for downstream URL handling"""
|
||||
result = (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue