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:
mateo-berri 2026-08-31 13:17:43 -07:00
parent fe90c6f6fc
commit c9908ffabb
4 changed files with 121 additions and 2 deletions

View file

@ -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(

View file

@ -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:

View file

@ -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()

View file

@ -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 = (