* 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>
603 lines
21 KiB
Python
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"}]
|