unsloth/studio/backend/tests/test_multimodal_document.py
Daniel Han ebe504b558
Studio: PDF / document attachments for Anthropic + OpenAI (#5689)
* Studio: PDF / document attachments for Anthropic + OpenAI

Studio's local-GGUF chat already supports image attachments via the
`image_url` content part shape. PDFs and other documents had no
plumbing for the external-provider path: there was no normalised
content type the frontend could send that translated to Anthropic's
native `document` block or OpenAI's `input_file`.

Add a Studio-side `input_document` content part on assistant /
user messages with three shapes:

  {type: "input_document",
   file_data: "data:application/pdf;base64,<DATA>",
   filename?: "name.pdf",
   media_type?: "application/pdf"}

  {type: "input_document",
   file_url: "https://example.com/doc.pdf",
   filename?: "doc.pdf"}

Translation:

- Anthropic Messages API: emits a `document` block with
  `{source: {type:"base64", media_type, data}}` or
  `{source: {type:"url", url}}`, plus an optional `title` from
  `filename`. PDFs are extracted server-side by Anthropic per their
  vision/document docs and counted toward input tokens.
- OpenAI Responses API: emits `{type:"input_file", file_data |
  file_url, filename?}`. PDFs are extracted server-side.

Empty / unparseable `input_document` parts are silently dropped so
a malformed frontend payload can't blow up the request.

Tests:

- New `test_multimodal_document.py` with 6 cases pinning the
  outbound body shape for base64 + URL inputs on both providers,
  and the empty-part drop behavior on both.
- The Anthropic assertions strip the prompt-cache wrapper
  (`cache_control:{type:ephemeral}` that the tail-message caching
  layer adds) before comparing the document core fields, so this
  test stays focused on the translation, not the caching layer.

Live verified end-to-end against both providers: a 363-byte
single-page "HELLO" PDF, base64-encoded, attached as a `document`
block to Opus 4.7 and as an `input_file` to gpt-5.5. Both models
correctly extracted the word "HELLO" from the PDF.

Follow-up (out of scope):

- Pydantic schema entry on ChatMessage.content for `input_document`
  (today it rides through because ChatCompletionRequest uses
  extra=allow). Will tighten when the frontend attach button lands.
- Frontend file-picker UX for non-image attachments on the external
  provider path.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Address review: gate empty-content msg + skip empty data-URI payload

Gemini High + Codex P2 on PR #5689:

1. Anthropic translation appended an empty `anthropic_parts` array
   when every part was dropped (e.g. user sent only an unparseable
   input_document). Anthropic 400s on "messages.N.content: at least
   one block is required". Skip the whole-message append when no
   parts survived. The OpenAI Responses path already had the
   equivalent guard, so this brings the two providers into parity.

2. `data:application/pdf;base64,` with no payload (or whitespace-only)
   parses to an empty `source.data` string. Anthropic rejects that
   with 400 as well. Skip the document block before constructing it.

Plus 2 new test cases pinning both behaviors:

- `test_anthropic_empty_only_document_drops_whole_message`: confirms
  a turn whose only content is an unparseable input_document does
  NOT make it onto the outbound `messages` array.
- `test_anthropic_empty_data_uri_payload_is_dropped`: confirms an
  empty-payload data-URI is filtered out at translation time.

(Note re: gemini's other High note about adding `input_document` to
the Pydantic ContentPart union -- ChatCompletionRequest is configured
with `extra=allow` so the part rides through today. Tightening the
union belongs with the frontend attach-button PR that surfaces the
field; called out as follow-up in the PR description.)

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Address review: register input_document in ContentPart + builder

Reviewer caught that the translation code on the external_provider
side was unreachable from a real ChatCompletionRequest:

- ContentPart is a discriminated Union of (text, image_url) only, so
  any `{"type": "input_document", ...}` part was rejected by Pydantic
  at request parsing with a discriminator error before the helper
  could see it.
- _build_external_messages in routes/inference.py only walked text
  and image_url parts, so even with a permissive schema the document
  parts would have been silently dropped instead of forwarded to
  the per-provider translator.

Fixes:

- Add InputDocumentContentPart with optional file_data / file_url /
  filename / media_type and Tag("input_document") on the Union.
- Extend _build_external_messages to pass input_document through as
  a plain dict for vision-capable providers (so external_provider's
  existing Anthropic `document` and OpenAI Responses `input_file`
  mappers actually run) and strip them on non-vision providers.

Tests added: schema accepts input_document, builder passes it to
vision providers, builder strips it on non-vision providers.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Address review: validate file_data before preferring over file_url

Codex P2 caught that the OpenAI input_document translator treats any
truthy file_data as valid and never falls back to file_url. That
means a malformed `data:application/pdf;base64,` (empty payload) or
a whitespace-only data URI gets forwarded as `file_data=""` and
400s the whole turn, AND silently discards a perfectly recoverable
file_url on the same part.

Mirror the Anthropic-side guard onto the OpenAI Responses path:
treat any "data:" URI with no actual base64 payload as missing and
fall through to file_url. Standalone-empty data URIs (no fallback)
are dropped entirely instead of being sent to the wire.

Tests added: empty data URI + valid file_url -> file_url wins,
whitespace-only data URI + valid file_url -> file_url wins,
empty data URI without fallback -> part is dropped.

* Address review: Anthropic side also falls back to file_url on empty data URI

Codex P2 follow-up to my earlier fix: I added the empty-data-URI ->
file_url fallback to the OpenAI Responses translator but missed
the Anthropic translator, which still `continue`d on empty payloads
and discarded an otherwise valid file_url on the same part. Result:
when the frontend supplied both file_data (placeholder / broken)
AND a working file_url, Anthropic silently lost the attachment;
when the message contained only that part, the whole message could
be dropped before reaching the wire.

Mirrored the OpenAI guard: any "data:" URI with no actual base64
payload (`data:application/pdf;base64,` or whitespace-only) is
treated as missing, and the file_url branch takes over. The
all-parts-dropped guard further down already handles the
no-fallback case.

Tests added: empty data URI + valid file_url -> URL source on the
wire with the filename preserved; whitespace-only data URI + valid
file_url -> URL source on the wire.

* Address review: gate input_document passthrough to anthropic + openai

Codex P1: only `_stream_anthropic` and `_stream_openai_responses`
have explicit translation logic for input_document parts (the former
maps to {type:"document", source:...}, the latter to
{type:"input_file", file_data|file_url}). Every other provider
(gemini / mistral / kimi / openrouter / deepseek / qwen / custom)
goes through the generic /chat/completions passthrough that forwards
`messages` verbatim, so any input_document part on a non-vision
route on those providers would 400 with an unknown content_part
type.

Added `_INPUT_DOCUMENT_PROVIDERS = frozenset({"anthropic", "openai"})`
constant and gated the pass-through branch on `provider_type in
_INPUT_DOCUMENT_PROVIDERS`. Every other provider strips the part
(text content survives). Threaded provider_type through from
_proxy_to_external_provider's call site.

Tests updated: vision + provider in {anthropic, openai} still
forwards; six unmapped providers (gemini/mistral/kimi/openrouter/
deepseek/qwen) strip the part; missing provider_type strips
defensively. The existing non-vision drop test still passes.

* Fix stale web_fetch tool-version assertion after merging main

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-22 06:22:57 -07:00

603 lines
21 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Tests for PDF / document attachment translation on external providers.
Studio introduces a normalised `input_document` content part on
ChatCompletionRequest so the frontend doesn't have to know the
per-provider attachment shape:
- Anthropic: translates to `{type:"document", source:{type:"base64"|"url", ...}}`
- OpenAI Responses: translates to `{type:"input_file", file_data|file_url, filename?}`
These tests pin the translation shape on both paths for base64 data
URIs and remote URLs, with optional filename metadata, and confirm
unknown / empty document parts are dropped without breaking the
request.
"""
import asyncio
import json
import httpx
from core.inference import external_provider as ep_mod
from core.inference.external_provider import ExternalProviderClient
def _drive(coro):
return asyncio.new_event_loop().run_until_complete(coro)
def _capture(monkeypatch, *, provider: str, base_url: str, messages) -> dict:
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content.decode("utf-8"))
if provider == "anthropic":
body = b"event: message_stop\n" b'data: {"type": "message_stop"}\n\n'
else:
body = (
b"event: response.completed\n"
b'data: {"type":"response.completed",'
b'"response":{"output":[],"usage":{"input_tokens":0,'
b'"output_tokens":0}}}\n\n'
)
return httpx.Response(
200,
content = body,
headers = {"content-type": "text/event-stream"},
)
monkeypatch.setattr(
ep_mod,
"_http_client",
httpx.AsyncClient(transport = httpx.MockTransport(handler)),
)
async def run():
client = ExternalProviderClient(
provider_type = provider,
base_url = base_url,
api_key = "sk-test",
)
kwargs = {
"messages": messages,
"model": "claude-opus-4-7" if provider == "anthropic" else "gpt-5.5",
"temperature": 0.7,
"top_p": 0.95,
"max_tokens": 32,
}
if provider == "openai":
kwargs["reasoning_effort"] = "medium"
async for _ in client.stream_chat_completion(**kwargs):
pass
await client.close()
_drive(run())
return captured
_TINY_PDF_B64 = "JVBERi0xLjQKJcOkw7zDtsOfCjEgMCBvYmoKPDw+PgplbmRvYmoK"
_PDF_DATA_URI = f"data:application/pdf;base64,{_TINY_PDF_B64}"
# ── Anthropic translation ───────────────────────────────────────────
def _strip_cache(p: dict) -> dict:
# Studio's prompt-cache wiring attaches cache_control:{type:ephemeral}
# to the tail block of the last user message; strip it before
# comparing the document core fields so this test stays focused
# on the translation, not the caching layer.
return {k: v for k, v in p.items() if k != "cache_control"}
def test_anthropic_base64_pdf_becomes_document_block(monkeypatch):
captured = _capture(
monkeypatch,
provider = "anthropic",
base_url = "https://api.anthropic.com/v1",
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Summarise this paper."},
{
"type": "input_document",
"file_data": _PDF_DATA_URI,
"filename": "paper.pdf",
},
],
}
],
)
user_msg = captured["body"]["messages"][0]
parts = user_msg["content"]
types = [p.get("type") for p in parts]
assert "document" in types, parts
doc = _strip_cache(next(p for p in parts if p.get("type") == "document"))
assert doc == {
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": _TINY_PDF_B64,
},
"title": "paper.pdf",
}
def test_anthropic_url_pdf_becomes_document_block(monkeypatch):
captured = _capture(
monkeypatch,
provider = "anthropic",
base_url = "https://api.anthropic.com/v1",
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Read this URL."},
{
"type": "input_document",
"file_url": "https://example.com/doc.pdf",
},
],
}
],
)
parts = captured["body"]["messages"][0]["content"]
doc = _strip_cache(next(p for p in parts if p.get("type") == "document"))
assert doc == {
"type": "document",
"source": {"type": "url", "url": "https://example.com/doc.pdf"},
}
def test_anthropic_empty_document_part_is_dropped(monkeypatch):
captured = _capture(
monkeypatch,
provider = "anthropic",
base_url = "https://api.anthropic.com/v1",
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Hi."},
{"type": "input_document"}, # nothing usable
],
}
],
)
parts = captured["body"]["messages"][0]["content"]
types = [p.get("type") for p in parts]
assert "document" not in types, parts
def test_anthropic_empty_only_document_drops_whole_message(monkeypatch):
# If the ONLY part in a user message is an unparseable input_document,
# the helper must NOT append an empty-content message to the outbound
# body (Anthropic 400s on "at least one block is required").
captured = _capture(
monkeypatch,
provider = "anthropic",
base_url = "https://api.anthropic.com/v1",
messages = [
{"role": "user", "content": [{"type": "input_document"}]},
{"role": "user", "content": "but THIS one is fine"},
],
)
msgs = captured["body"]["messages"]
# The empty-content message must be skipped; only the second remains.
assert len(msgs) == 1, msgs
def test_anthropic_empty_data_uri_payload_is_dropped(monkeypatch):
# Codex P2: `data:application/pdf;base64,` with no payload (or
# whitespace-only) would create an empty `source.data` that
# Anthropic 400s on. Must be filtered before the wire.
captured = _capture(
monkeypatch,
provider = "anthropic",
base_url = "https://api.anthropic.com/v1",
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "still here"},
{
"type": "input_document",
"file_data": "data:application/pdf;base64,",
"filename": "empty.pdf",
},
{
"type": "input_document",
"file_data": "data:application/pdf;base64, ",
"filename": "whitespace.pdf",
},
],
}
],
)
parts = captured["body"]["messages"][0]["content"]
assert all(p.get("type") != "document" for p in parts), parts
def test_anthropic_empty_data_uri_falls_back_to_file_url(monkeypatch):
# Codex P2 follow-up: my previous fix added the empty-data-URI ->
# file_url fallback to the OpenAI side but missed the Anthropic
# side, where the empty-payload branch did `continue` and discarded
# an otherwise-valid file_url on the same part. Mirror the OpenAI
# behavior so a malformed inline payload + remote URL still
# attaches.
captured = _capture(
monkeypatch,
provider = "anthropic",
base_url = "https://api.anthropic.com/v1",
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Read this."},
{
"type": "input_document",
"file_data": "data:application/pdf;base64,",
"file_url": "https://example.com/doc.pdf",
"filename": "doc.pdf",
},
],
}
],
)
parts = captured["body"]["messages"][0]["content"]
doc = _strip_cache(next(p for p in parts if p.get("type") == "document"))
# base64 source MUST NOT have landed on the wire; URL source survived.
assert doc == {
"type": "document",
"source": {"type": "url", "url": "https://example.com/doc.pdf"},
"title": "doc.pdf",
}
def test_anthropic_whitespace_only_data_uri_falls_back_to_file_url(monkeypatch):
captured = _capture(
monkeypatch,
provider = "anthropic",
base_url = "https://api.anthropic.com/v1",
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Read this."},
{
"type": "input_document",
"file_data": "data:application/pdf;base64, ",
"file_url": "https://example.com/doc.pdf",
},
],
}
],
)
parts = captured["body"]["messages"][0]["content"]
doc = _strip_cache(next(p for p in parts if p.get("type") == "document"))
assert doc == {
"type": "document",
"source": {"type": "url", "url": "https://example.com/doc.pdf"},
}
# ── OpenAI Responses translation ────────────────────────────────────
def test_openai_base64_pdf_becomes_input_file(monkeypatch):
captured = _capture(
monkeypatch,
provider = "openai",
base_url = "https://api.openai.com/v1",
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Summarise this paper."},
{
"type": "input_document",
"file_data": _PDF_DATA_URI,
"filename": "paper.pdf",
},
],
}
],
)
user_msg = captured["body"]["input"][0]
parts = user_msg["content"]
fileblk = next(p for p in parts if p.get("type") == "input_file")
assert fileblk == {
"type": "input_file",
"file_data": _PDF_DATA_URI,
"filename": "paper.pdf",
}
def test_openai_url_pdf_becomes_input_file(monkeypatch):
captured = _capture(
monkeypatch,
provider = "openai",
base_url = "https://api.openai.com/v1",
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Read this URL."},
{
"type": "input_document",
"file_url": "https://example.com/doc.pdf",
},
],
}
],
)
parts = captured["body"]["input"][0]["content"]
fileblk = next(p for p in parts if p.get("type") == "input_file")
assert fileblk == {
"type": "input_file",
"file_url": "https://example.com/doc.pdf",
}
def test_openai_empty_data_uri_falls_back_to_file_url(monkeypatch):
# Codex P2 follow-up: an empty `data:application/pdf;base64,`
# payload was being preferred over a perfectly valid `file_url`
# in the same part, sending `file_data=""` to OpenAI and 400ing
# the whole turn. The translator must treat empty data URIs as
# missing and recover via file_url.
captured = _capture(
monkeypatch,
provider = "openai",
base_url = "https://api.openai.com/v1",
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Read this."},
{
"type": "input_document",
"file_data": "data:application/pdf;base64,",
"file_url": "https://example.com/doc.pdf",
"filename": "doc.pdf",
},
],
}
],
)
parts = captured["body"]["input"][0]["content"]
fileblk = next(p for p in parts if p.get("type") == "input_file")
# file_data MUST NOT be on the wire; file_url survives.
assert "file_data" not in fileblk, fileblk
assert fileblk["file_url"] == "https://example.com/doc.pdf"
assert fileblk["filename"] == "doc.pdf"
def test_openai_whitespace_only_data_uri_falls_back_to_file_url(monkeypatch):
captured = _capture(
monkeypatch,
provider = "openai",
base_url = "https://api.openai.com/v1",
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Read this."},
{
"type": "input_document",
"file_data": "data:application/pdf;base64, ",
"file_url": "https://example.com/doc.pdf",
},
],
}
],
)
parts = captured["body"]["input"][0]["content"]
fileblk = next(p for p in parts if p.get("type") == "input_file")
assert "file_data" not in fileblk, fileblk
assert fileblk["file_url"] == "https://example.com/doc.pdf"
def test_openai_empty_data_uri_without_fallback_is_dropped(monkeypatch):
# If the only signal is an empty data URI (no file_url), the
# whole part is skipped rather than sent as `file_data=""`.
captured = _capture(
monkeypatch,
provider = "openai",
base_url = "https://api.openai.com/v1",
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Hi."},
{
"type": "input_document",
"file_data": "data:application/pdf;base64,",
"filename": "empty.pdf",
},
],
}
],
)
parts = captured["body"]["input"][0]["content"]
types = [p.get("type") for p in parts]
assert "input_file" not in types, parts
def test_openai_empty_document_part_is_dropped(monkeypatch):
captured = _capture(
monkeypatch,
provider = "openai",
base_url = "https://api.openai.com/v1",
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Hi."},
{"type": "input_document"},
],
}
],
)
parts = captured["body"]["input"][0]["content"]
types = [p.get("type") for p in parts]
assert "input_file" not in types, parts
# ── Pydantic schema + builder pass-through ──────────────────────────
#
# The translation tests above call the external-provider client directly
# with hand-built dicts, which bypasses BOTH ChatCompletionRequest's
# discriminated Union AND routes/inference._build_external_messages. The
# tests below close that gap: parse an input_document part through the
# real request schema, run the builder, and assert the part survives to
# the dict the client would receive.
def test_chat_message_accepts_input_document_part():
from models.inference import ChatMessage
msg = ChatMessage.model_validate(
{
"role": "user",
"content": [
{"type": "text", "text": "look"},
{
"type": "input_document",
"file_data": _PDF_DATA_URI,
"filename": "paper.pdf",
"media_type": "application/pdf",
},
],
}
)
assert isinstance(msg.content, list)
assert msg.content[1].type == "input_document"
assert msg.content[1].file_data == _PDF_DATA_URI
assert msg.content[1].filename == "paper.pdf"
assert msg.content[1].media_type == "application/pdf"
def test_build_external_messages_passes_input_document_for_anthropic_and_openai():
# Both providers' stream helpers have explicit input_document
# translation logic (Anthropic -> {type:"document"}, OpenAI
# Responses -> {type:"input_file"}), so the part round-trips
# through the builder unchanged on those routes.
from models.inference import ChatMessage
from routes.inference import _build_external_messages
msgs = [
ChatMessage.model_validate(
{
"role": "user",
"content": [
{"type": "text", "text": "summarise"},
{
"type": "input_document",
"file_url": "https://example.com/doc.pdf",
"filename": "doc.pdf",
},
],
}
)
]
for provider in ("anthropic", "openai"):
out = _build_external_messages(
msgs, supports_vision = True, provider_type = provider
)
assert len(out) == 1, (provider, out)
parts = out[0]["content"]
assert parts[0] == {"type": "text", "text": "summarise"}, provider
assert parts[1] == {
"type": "input_document",
"file_url": "https://example.com/doc.pdf",
"filename": "doc.pdf",
}, provider
def test_build_external_messages_strips_input_document_for_unmapped_providers():
# Codex P1 follow-up: gemini / mistral / kimi / openrouter / deepseek
# / custom go through generic /chat/completions passthrough that
# forwards `messages` verbatim. Handing them an `input_document`
# part fails the upstream validator. Builder must strip the part
# for every provider whose stream helper doesn't translate it.
from models.inference import ChatMessage
from routes.inference import _build_external_messages
msgs = [
ChatMessage.model_validate(
{
"role": "user",
"content": [
{"type": "text", "text": "summarise"},
{
"type": "input_document",
"file_url": "https://example.com/doc.pdf",
"filename": "doc.pdf",
},
],
}
)
]
for provider in ("gemini", "mistral", "kimi", "openrouter", "deepseek", "qwen"):
out = _build_external_messages(
msgs, supports_vision = True, provider_type = provider
)
assert len(out) == 1, (provider, out)
parts = out[0]["content"]
types = [p.get("type") for p in parts if isinstance(p, dict)]
assert "input_document" not in types, (provider, parts)
# Text part survives.
assert {"type": "text", "text": "summarise"} in parts, (provider, parts)
def test_build_external_messages_strips_input_document_when_provider_type_unknown():
# Defensive: legacy callers that don't pass provider_type must
# not leak the part to an unknown destination.
from models.inference import ChatMessage
from routes.inference import _build_external_messages
msgs = [
ChatMessage.model_validate(
{
"role": "user",
"content": [
{"type": "text", "text": "summarise"},
{
"type": "input_document",
"file_data": _PDF_DATA_URI,
},
],
}
)
]
out = _build_external_messages(msgs, supports_vision = True)
parts = out[0]["content"]
types = [p.get("type") for p in parts if isinstance(p, dict)]
assert "input_document" not in types, parts
def test_build_external_messages_drops_input_document_for_non_vision_provider():
from models.inference import ChatMessage
from routes.inference import _build_external_messages
msgs = [
ChatMessage.model_validate(
{
"role": "user",
"content": [
{"type": "text", "text": "summarise"},
{
"type": "input_document",
"file_data": _PDF_DATA_URI,
},
],
}
)
]
out = _build_external_messages(msgs, supports_vision = False)
assert out == [{"role": "user", "content": "summarise"}]