unsloth/studio/backend/models
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
..
.gitkeep fix: restore models directory files deleted during restructure 2026-02-02 19:36:30 +00:00
__init__.py Studio: Dark theme refactor, right sidebar redesign, and chat UI polish (#5150) 2026-05-07 14:33:31 +04:00
auth.py studio: security and hardening pass (auth rate-limit, sandbox, path containment, schema validation, headers) (#5375) 2026-05-13 06:12:18 -07:00
data_recipe.py feat(studio): multi-file unstructured seed upload with better backend extraction (#4468) 2026-03-20 13:22:42 -07:00
datasets.py Final cleanup 2026-03-12 18:28:04 +00:00
export.py studio: security and hardening pass (auth rate-limit, sandbox, path containment, schema validation, headers) (#5375) 2026-05-13 06:12:18 -07:00
inference.py Studio: PDF / document attachments for Anthropic + OpenAI (#5689) 2026-05-22 06:22:57 -07:00
models.py Studio: add folder browser modal for Custom Folders (#5035) 2026-04-15 08:04:33 -07:00
providers.py Studio: make API key optional for local providers (llama.cpp/vLLM/Ollama) (#5457) 2026-05-15 23:33:22 +04:00
responses.py Final cleanup 2026-03-12 18:28:04 +00:00
training.py studio: drop unused max_grad_value schema + route plumbing (#5424) 2026-05-14 05:43:58 -07:00
users.py fix: remove old comments (#4292) 2026-03-14 16:50:13 +04:00