unsloth/studio/backend/tests/test_multimodal_document.py
Daniel Han 4854d4579f
Studio: surface Anthropic document citations inline + in Sources panel (#5718)
* Studio: surface Anthropic document citations inline + in Sources panel

Anthropic's Messages API streams ``citations_delta`` events on
``content_block_delta`` when the request enables
``citations: {enabled: true}`` on document blocks. Each event carries
one citation pointing at the source document; previously they were
silently dropped, so reader-visible references never reached the chat
UI even when the model was citing properly.

The proxy now:
- dedupes by the type-specific anchor (char_location / page_location /
  content_block_location / search_result_location) so re-cites of the
  same span collapse onto a single footnote;
- injects ``[N]`` inline right after the matching text run;
- forwards the full list as a synthetic ``document_citations``
  tool_event at ``message_stop`` so the Sources panel can render
  per-document footnotes next to web_search / web_fetch citations.

Streams that never emit ``citations_delta`` stay byte-identical.

References:
- https://platform.claude.com/docs/en/build-with-claude/citations
- https://platform.claude.com/docs/en/build-with-claude/search-results

Tests (5 in test_anthropic_citations.py): passthrough, single
char_location, dedup of repeat citations, distinct sources get
distinct numbers, search_result_location supported.

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

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

* Studio: surface Anthropic document_citations in the Sources panel

The PR added a backend _toolEvent.type='document_citations' on
message_stop and an inline [N] marker in the assistant text, but the
chat-adapter only handles container_*/tool_*/sources from
web_search and web_fetch tool calls. Reviewers flagged that the
inline [N] markers had no matching footnote entries in the Sources
panel.

Capture the new event into a documentCitationParts buffer, convert
each citation dict into a Sources-panel source entry (using
document_title or search-result source URL plus cited_text as the
snippet), dedupe by id, and append to the final yield alongside
the existing web_search/web_fetch sourceParts.

* Studio: dedupe search_result_location citations by search_result_index

Anthropic's documented search_result_location citation shape carries
search_result_index, source, title, and start/end_block_index --
NOT document_index/document_title. The previous key keyed on
document_index + document_title + source + start_block_index, so
two distinct search results from the same source collapsed onto the
same footnote and the second [N] marker was lost.

Switch the search_result_location branch to key on the documented
fields, and pin the behaviour with a regression test asserting that
two citations sharing source/title but with different
search_result_index get distinct [1] [2] markers.

* Studio: keep each citation distinct across the end-anchor

Codex follow-ups on the citations PR:

  * Backend _anthropic_citation_key now includes the end anchor for
    every variant (end_char_index, end_page_number,
    end_block_index). Anthropic ranges are start-AND-end pairs, so
    a same-start / different-end pair is two distinct citations
    that previously collapsed onto one footnote.

  * Frontend documentCitationToSource ids include the position
    fields (search_result_index, start/end char/page/block) instead
    of being keyed on URL alone. Two citations from the same
    document or two search_result_locations with the same source
    now produce distinct Sources-panel entries, matching the
    inline [N] numbering.

* Studio: key Sources list by per-citation id instead of url

Codex flagged that the Sources renderer keys badges on source.url,
so two Anthropic document citations sharing the same source URL
collide as React keys and one badge gets dropped (or duplicated).

The chat-adapter already mints a per-citation id that folds the
position fields (search_result_index, start/end char/page/block)
into the URL, so the two citations have distinct ids even when
their URL matches. Plumb that id through SourceData and use it as
the React key for both the measurement badges and the visible
SourceBadge list. Falls back to the URL when no id is supplied
(web_search and web_fetch source parts).

* Studio: enable Anthropic doc citations on input_document blocks

Plumb citations: {enabled: true} onto the translated Anthropic document
block (both base64 and URL source branches) so the upstream actually
emits citations_delta events. Without this opt-in the inline [N] +
Sources panel plumbing added in this PR is a no-op for real user
PDF / doc uploads.

Refs https://platform.claude.com/docs/en/build-with-claude/citations

Also add edge-case coverage for the citations_delta path:
malformed citations, mixed types per document, reversed indices,
missing document_index, non-int block indices, unknown citation
type, internal _key never leaking, footnote numbering across
content blocks, and the input_document wire-through itself.

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

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

* Reject unsafe citation sources, bound cited_text payload

Three follow-ups on top of #5718 surfaced by a deeper review pass:

1) javascript: / data: / vbscript: in citation source is XSS-able.
   ``documentCitationToSource`` was assigning ``cit.source`` straight
   into ``Source.url`` and rendering it as an <a href>. A hostile
   model emitting ``cit.source = "javascript:alert(document.domain)"``
   would execute on click (openLink only intercepts URLs that contain
   "://" or start with "mailto:", which both miss the javascript:
   scheme). Restrict the navigable path to http(s):// only; anything
   else falls back to the existing #anthropic-doc anchor and the
   source title still renders the raw identifier for context. Also
   reject CR/LF inside the URL string.

2) Frontend sources collapse distinct backend footnotes when the
   citation type differs but positions match. char_location(0,5) and
   page_location(0,5) over the same source previously deduped into
   one entry because the id only carried position. Fold citation
   type into the id anchor so the 1:1 mapping with inline [N]
   markers is preserved across every citation shape.

3) ``cited_text`` was forwarded unbounded inside the synthetic
   document_citations tool_event. The Sources panel trims to 240
   chars for display anyway; for large RAG / search_result spans
   (~10kB cited_text is plausible) this inflates SSE bytes 40x
   for no UI benefit. Truncate server-side at 512 chars with an
   ellipsis so the description-trim downstream still has room to
   work and the wire stays bounded.

Tests grow from 21 to 22; existing 7 + edge 15 still green. Frontend
typecheck clean.

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

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

* Studio: apply http(s) URL guard to all Sources-panel link sources

The previous round only filtered ``cit.source`` inside
``documentCitationToSource``. Two parallel code paths still copied
provider/tool-controlled ``URL:`` text directly into clickable
``<a href>`` Sources-panel links:

  * ``parseSourcesFromResult`` in chat-adapter.ts (legacy web_search /
    web_fetch tool result parser)
  * ``parseSearchResults`` in tool-ui-web-search.tsx (inline tool card)

A hostile tool response like ``URL: javascript:alert(1)`` or
``URL: data:text/html,...`` was therefore still rendered as a
navigable badge in the Sources panel.

Centralise the safe-URL test (``isSafeNavigableSourceUrl``,
``isSafeHttpUrl``) using ``new URL()`` + protocol allowlist + CR/LF
rejection, and apply it to both parsers. Unsafe blocks are dropped
rather than rewritten to a hash anchor because the web_search /
web_fetch parsers have no document-index fallback.

Citation conversion now uses the same helper so the in-place
http(s) regex and CR/LF check stay in one place.

* Shorten citation comments for PR #5718

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-25 23:39:02 -07:00

609 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"))
# citations: {enabled: true} opts into Anthropic's natural-citation
# pipeline; without it the citations_delta handler is a no-op.
assert doc == {
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": _TINY_PDF_B64,
},
"citations": {"enabled": True},
"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"},
"citations": {"enabled": True},
}
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"},
"citations": {"enabled": True},
"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"},
"citations": {"enabled": True},
}
# ── 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"}]