Check for existing issues
What happened?
When a request to LiteLLM's /responses proxy endpoint contains a function_call_output whose output array includes an input_file content part (e.g. a PDF returned from a tool call, as produced by the OpenAI Agents SDK's ToolOutputFileContent), the Responses→ChatCompletions transformer silently drops the input_file part. Only the sibling input_text parts survive into the resulting role: "tool" message's content, flattened to a plain string.
This affects every provider that lacks a native /responses endpoint and therefore goes through the lowering path. Confirmed on vertex_ai/gemini-2.5-flash; very likely the same on bedrock/* (same lowering code path). Azure is not affected because LiteLLM passes through to Azure OpenAI's native /openai/responses endpoint and the original Responses-API body — including the input_file part — reaches the provider intact.
Expected: the file content is forwarded to the provider (as a chat-completions {"type": "file", "file": {...}} part, analogous to how input_image is forwarded as image_url).
Actual: the file part is dropped during lowering; the model only sees the input_text framing string. End-to-end, the downstream LLM never sees the file.
Steps to Reproduce
Minimal repro using the OpenAI Agents SDK pointed at a LiteLLM proxy. The PDF is rendered in-process with reportlab and carries deliberately ungoogleable strings (`TEST-A1B2`, `INVOICE-7777`, `Page 2: amount $123.45`) so the model can't pattern-match its way to a passing description.
```python
import asyncio, base64, io
from openai import AsyncOpenAI
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas as rl_canvas
from agents import (
Agent, OpenAIResponsesModel, RunContextWrapper, Runner,
ToolOutputFileContent, ToolOutputText, function_tool,
)
def make_test_pdf_data_url() -> str:
buf = io.BytesIO()
c = rl_canvas.Canvas(buf, pagesize=letter)
c.setFont("Helvetica-Bold", 28); c.drawString(72, 720, "TEST-A1B2")
c.setFont("Helvetica", 18); c.drawString(72, 670, "INVOICE-7777")
c.showPage()
c.setFont("Helvetica-Bold", 28); c.drawString(72, 720, "Page 2: amount $123.45")
c.showPage(); c.save()
return f"data:application/pdf;base64,{base64.b64encode(buf.getvalue()).decode()}"
PDF = make_test_pdf_data_url()
@function_tool
async def get_test_pdf(ctx: RunContextWrapper[None]):
return [
ToolOutputText(text="Here is the PDF. Read every page and describe what's on it."),
ToolOutputFileContent(file_data=PDF, filename="test.pdf"),
]
async def main():
client = AsyncOpenAI(api_key="sk-...", base_url="http://localhost:4000\")
agent = Agent(
name="PdfSmoke",
instructions="Always call get_test_pdf, then describe each page.",
tools=[get_test_pdf],
model=OpenAIResponsesModel(model="vertex-gemini-2.5-flash", openai_client=client),
)
result = await Runner.run(agent, input="Use the tool and describe each page.", max_turns=4)
print(result.final_output)
asyncio.run(main())
```
Expected output: the model echoes back at least two of `TEST-A1B2`, `INVOICE-7777`, `123.45`.
Actual output: generic description with none of those tokens — the model never saw the PDF.
Relevant log output
```shell
Inbound /responses body (turn 2). PDF intact, base64 elided as :
'body': {'input': [
{'role': 'user', 'content': "Use the tool and describe each page."},
{'type': 'function_call', 'name': 'get_test_pdf', 'call_id': '...', 'arguments': '{}'},
{'type': 'function_call_output', 'call_id': '...', 'output': [
{'type': 'input_text', 'text': "Here is the PDF. Read every page and describe what's on it."},
{'type': 'input_file', 'file_data': 'data:application/pdf;base64,', 'filename': 'test.pdf'}
]}
]}
After Responses→Chat lowering (litellm.acompletion messages), Vertex path.
Note: input_file is gone; content is a plain string with only the input_text:
messages=[
{'role': 'system', 'content': "You are testing PDF tool outputs..."},
{'role': 'user', 'content': "Use the tool and describe each page."},
{'role': 'assistant', 'content': [], 'tool_calls': [{'id': '...', 'type': 'function', 'function': {'name': 'get_test_pdf', 'arguments': '{}'}}]},
{'role': 'tool', 'content': "Here is the PDF. Read every page and describe what's on it.",
'tool_call_id': '...'}
]
Same request to Azure for comparison — no lowering, PDF reaches provider intact:
api_base: https://.api.cognitive.microsoft.com/openai/responses?api-version=2025-04-01-preview
body.input[2] = {'type': 'function_call_output', 'output': [
{'type': 'input_text', 'text': '...'},
{'type': 'input_file', 'file_data': 'data:application/pdf;base64,', 'filename': 'test.pdf'} # ← preserved
]}
```
Root cause
In `litellm/responses/litellm_completion_transformation/transformation.py`, the nested helper that walks `function_call_output.output` (`_normalize_function_call_output_to_tool_content`, called from the `function_call_output` handler) only matches text and image parts:
```python
if part_type in ("input_text", "output_text", "text"):
...
elif part_type in ("input_image", "image_url"):
...
no input_file branch
```
`input_file` parts fall through and are discarded. The existing helper `_transform_input_file_item_to_file_item` (around line 1215 of the same file) already knows how to convert an `input_file` to a chat-completions `{"type": "file", "file": {...}}` content part — but it's only called from `_transform_responses_api_content_to_chat_completion_content` (user/system message content path, around line 1270), not from the `function_call_output.output` path.
Suggested fix
Mirror the approach taken in #17799 (which fixed the reverse-direction analogue for `input_image` in tool outputs).
In `_normalize_function_call_output_to_tool_content`, add an `input_file` branch that reuses the existing helper:
```python
elif part_type == "input_file":
chat_part = LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item(part)
parts.append(chat_part)
```
…plus whatever the surrounding code already does to make the resulting tool message use a list `content` rather than a flattened string when any non-text part is present.
Happy to send a PR if there's interest.
Related
What part of LiteLLM is this about?
Proxy
What LiteLLM version are you on?
v1.83.3 (bug also present on `main` per source inspection)
Check for existing issues
What happened?
When a request to LiteLLM's
/responsesproxy endpoint contains afunction_call_outputwhoseoutputarray includes aninput_filecontent part (e.g. a PDF returned from a tool call, as produced by the OpenAI Agents SDK'sToolOutputFileContent), the Responses→ChatCompletions transformer silently drops theinput_filepart. Only the siblinginput_textparts survive into the resultingrole: "tool"message'scontent, flattened to a plain string.This affects every provider that lacks a native
/responsesendpoint and therefore goes through the lowering path. Confirmed onvertex_ai/gemini-2.5-flash; very likely the same onbedrock/*(same lowering code path). Azure is not affected because LiteLLM passes through to Azure OpenAI's native/openai/responsesendpoint and the original Responses-API body — including theinput_filepart — reaches the provider intact.Expected: the file content is forwarded to the provider (as a chat-completions
{"type": "file", "file": {...}}part, analogous to howinput_imageis forwarded asimage_url).Actual: the file part is dropped during lowering; the model only sees the
input_textframing string. End-to-end, the downstream LLM never sees the file.Steps to Reproduce
Minimal repro using the OpenAI Agents SDK pointed at a LiteLLM proxy. The PDF is rendered in-process with reportlab and carries deliberately ungoogleable strings (`TEST-A1B2`, `INVOICE-7777`, `Page 2: amount $123.45`) so the model can't pattern-match its way to a passing description.
```python
import asyncio, base64, io
from openai import AsyncOpenAI
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas as rl_canvas
from agents import (
Agent, OpenAIResponsesModel, RunContextWrapper, Runner,
ToolOutputFileContent, ToolOutputText, function_tool,
)
def make_test_pdf_data_url() -> str:
buf = io.BytesIO()
c = rl_canvas.Canvas(buf, pagesize=letter)
c.setFont("Helvetica-Bold", 28); c.drawString(72, 720, "TEST-A1B2")
c.setFont("Helvetica", 18); c.drawString(72, 670, "INVOICE-7777")
c.showPage()
c.setFont("Helvetica-Bold", 28); c.drawString(72, 720, "Page 2: amount $123.45")
c.showPage(); c.save()
return f"data:application/pdf;base64,{base64.b64encode(buf.getvalue()).decode()}"
PDF = make_test_pdf_data_url()
@function_tool
async def get_test_pdf(ctx: RunContextWrapper[None]):
return [
ToolOutputText(text="Here is the PDF. Read every page and describe what's on it."),
ToolOutputFileContent(file_data=PDF, filename="test.pdf"),
]
async def main():
client = AsyncOpenAI(api_key="sk-...", base_url="http://localhost:4000\")
agent = Agent(
name="PdfSmoke",
instructions="Always call get_test_pdf, then describe each page.",
tools=[get_test_pdf],
model=OpenAIResponsesModel(model="vertex-gemini-2.5-flash", openai_client=client),
)
result = await Runner.run(agent, input="Use the tool and describe each page.", max_turns=4)
print(result.final_output)
asyncio.run(main())
```
Expected output: the model echoes back at least two of `TEST-A1B2`, `INVOICE-7777`, `123.45`.
Actual output: generic description with none of those tokens — the model never saw the PDF.
Relevant log output
```shell
Inbound /responses body (turn 2). PDF intact, base64 elided as :
'body': {'input': [
{'role': 'user', 'content': "Use the tool and describe each page."},
{'type': 'function_call', 'name': 'get_test_pdf', 'call_id': '...', 'arguments': '{}'},
{'type': 'function_call_output', 'call_id': '...', 'output': [
{'type': 'input_text', 'text': "Here is the PDF. Read every page and describe what's on it."},
{'type': 'input_file', 'file_data': 'data:application/pdf;base64,', 'filename': 'test.pdf'}
]}
]}
After Responses→Chat lowering (litellm.acompletion messages), Vertex path.
Note: input_file is gone; content is a plain string with only the input_text:
messages=[
{'role': 'system', 'content': "You are testing PDF tool outputs..."},
{'role': 'user', 'content': "Use the tool and describe each page."},
{'role': 'assistant', 'content': [], 'tool_calls': [{'id': '...', 'type': 'function', 'function': {'name': 'get_test_pdf', 'arguments': '{}'}}]},
{'role': 'tool', 'content': "Here is the PDF. Read every page and describe what's on it.",
'tool_call_id': '...'}
]
Same request to Azure for comparison — no lowering, PDF reaches provider intact:
api_base: https://.api.cognitive.microsoft.com/openai/responses?api-version=2025-04-01-preview
body.input[2] = {'type': 'function_call_output', 'output': [
{'type': 'input_text', 'text': '...'},
{'type': 'input_file', 'file_data': 'data:application/pdf;base64,', 'filename': 'test.pdf'} # ← preserved
]}
```
Root cause
In `litellm/responses/litellm_completion_transformation/transformation.py`, the nested helper that walks `function_call_output.output` (`_normalize_function_call_output_to_tool_content`, called from the `function_call_output` handler) only matches text and image parts:
```python
if part_type in ("input_text", "output_text", "text"):
...
elif part_type in ("input_image", "image_url"):
...
no input_file branch
```
`input_file` parts fall through and are discarded. The existing helper `_transform_input_file_item_to_file_item` (around line 1215 of the same file) already knows how to convert an `input_file` to a chat-completions `{"type": "file", "file": {...}}` content part — but it's only called from `_transform_responses_api_content_to_chat_completion_content` (user/system message content path, around line 1270), not from the `function_call_output.output` path.
Suggested fix
Mirror the approach taken in #17799 (which fixed the reverse-direction analogue for `input_image` in tool outputs).
In `_normalize_function_call_output_to_tool_content`, add an `input_file` branch that reuses the existing helper:
```python
elif part_type == "input_file":
chat_part = LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item(part)
parts.append(chat_part)
```
…plus whatever the surrounding code already does to make the resulting tool message use a list `content` rather than a flattened string when any non-text part is present.
Happy to send a PR if there's interest.
Related
What part of LiteLLM is this about?
Proxy
What LiteLLM version are you on?
v1.83.3 (bug also present on `main` per source inspection)