Skip to content

[Bug]: Responses→Chat lowering drops input_file in function_call_output.output (Vertex / Bedrock) #28232

Description

@inesmcm26

Check for existing issues

  • I have searched the existing issues and checked that my issue is not a duplicate.

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)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions