1322 lines
52 KiB
Python
1322 lines
52 KiB
Python
"""Red→green tests for the multimodal agent's AG-UI content mapping.
|
|
|
|
Exercises the REAL failure surface end-to-end:
|
|
|
|
AG-UI ``UserMessage(content=[TextInputContent, ImageInputContent, ...])``
|
|
→ ``pydantic_ai.ag_ui._messages_from_ag_ui`` (the AG-UI bridge)
|
|
→ ``multimodal_agent._flatten_messages_for_model`` (model-boundary flatten)
|
|
→ ``OpenAIResponsesModel._map_user_prompt`` (lib mapping)
|
|
|
|
The bug: the PydanticAI AG-UI bridge drops the raw ``UserMessage.content``
|
|
list — a list of AG-UI ``InputContent`` *model objects* — straight into
|
|
``UserPromptPart.content`` with no conversion, and ``_map_user_prompt`` then
|
|
``assert_never``s on those AG-UI objects. The model-boundary flatten
|
|
(``_flatten_messages_for_model``, invoked by the ``_MultimodalFlattenModel``
|
|
wrapper) must normalise each part to a native PydanticAI content type
|
|
(``str`` / ``ImageUrl`` / ``BinaryContent`` / ``DocumentUrl`` / extracted-PDF
|
|
text) before the model maps it — for BOTH inline-``data`` and ``url`` sources —
|
|
and **fail loud** on a genuinely unknown shape rather than feed the downstream
|
|
``assert_never``.
|
|
|
|
RED before the original rework: ``_map_user_prompt`` raises
|
|
``AssertionError("Expected code to be unreachable, but got: <AG-UI object>")``
|
|
for url-source content. Earlier rounds wired the flatten as a
|
|
``history_processor``, whose return PydanticAI persists to
|
|
``ctx.state.message_history`` — leaking the flattened content into UI-visible
|
|
state.
|
|
GREEN after: every content subtype maps to OpenAI ``input_text`` /
|
|
``input_image`` / ``input_file`` content parts with no ``assert_never``; the
|
|
flatten is scoped to the model call (no ``history_processor``), so the persisted
|
|
conversation keeps the original AG-UI content; a missing-mime inline-DATA image
|
|
never produces a ``data:image/*`` URI; recoverable-empty attachments degrade to
|
|
placeholders; and a genuinely unknown shape raises a clear ``ValueError``.
|
|
|
|
These tests are plain ``def`` functions driving coroutines via
|
|
``asyncio.run`` (mirroring the sibling ``test_cvdiag_boundaries.py``
|
|
convention) so they actually execute their assertions in a clean env —
|
|
``pytest-asyncio`` is NOT a dependency, so ``@pytest.mark.asyncio`` tests
|
|
would be collected-but-never-awaited (a vacuous pass). They need the real
|
|
``pydantic_ai`` + ``ag_ui`` runtime deps and an ``OPENAI_API_KEY`` (module-level
|
|
``Agent`` construction reads it; no network call is made — only the pure
|
|
mapping is exercised). They skip cleanly when those deps are unavailable so the
|
|
lightweight CVDIAG lane is unaffected.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
|
|
import pytest
|
|
|
|
# A 1x1 transparent PNG, base64.
|
|
_PNG_B64 = (
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4"
|
|
"2mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
|
|
)
|
|
|
|
os.environ.setdefault("OPENAI_API_KEY", "sk-test-multimodal-mapping")
|
|
|
|
ag_core = pytest.importorskip("ag_ui.core")
|
|
pydantic_ag_ui = pytest.importorskip("pydantic_ai.ag_ui")
|
|
pydantic_openai = pytest.importorskip("pydantic_ai.models.openai")
|
|
|
|
from pydantic_ai import BinaryContent, ImageUrl # noqa: E402
|
|
from pydantic_ai.messages import ModelResponse, UserPromptPart # noqa: E402
|
|
from pydantic_ai.models import ModelRequestParameters # noqa: E402
|
|
|
|
from agents.multimodal_agent import ( # noqa: E402
|
|
_flatten_messages_for_model,
|
|
_rewrite_part_for_model,
|
|
)
|
|
|
|
UserMessage = ag_core.UserMessage
|
|
TextInputContent = ag_core.TextInputContent
|
|
ImageInputContent = ag_core.ImageInputContent
|
|
AudioInputContent = ag_core.AudioInputContent
|
|
VideoInputContent = ag_core.VideoInputContent
|
|
DocumentInputContent = ag_core.DocumentInputContent
|
|
BinaryInputContent = ag_core.BinaryInputContent
|
|
InputContentDataSource = ag_core.InputContentDataSource
|
|
InputContentUrlSource = ag_core.InputContentUrlSource
|
|
_messages_from_ag_ui = pydantic_ag_ui._messages_from_ag_ui
|
|
OpenAIResponsesModel = pydantic_openai.OpenAIResponsesModel
|
|
|
|
|
|
def _user_prompt_part(model_messages):
|
|
for mm in model_messages:
|
|
for part in getattr(mm, "parts", []):
|
|
if isinstance(part, UserPromptPart):
|
|
return part
|
|
raise AssertionError("no UserPromptPart produced by the AG-UI bridge")
|
|
|
|
|
|
async def _bridge_and_map(user_message: UserMessage):
|
|
"""Run the full real surface and return ``(rewritten_part, mapped, orig_part)``.
|
|
|
|
``orig_part`` is the UserPromptPart the AG-UI bridge produced *before* the
|
|
model-boundary flatten ran — used to assert the flatten never mutates the
|
|
state/UI-backing objects.
|
|
"""
|
|
bridged = _messages_from_ag_ui([user_message])
|
|
orig_part = _user_prompt_part(bridged)
|
|
orig_snapshot = (
|
|
list(orig_part.content)
|
|
if isinstance(orig_part.content, list)
|
|
else orig_part.content
|
|
)
|
|
model_messages = _flatten_messages_for_model(bridged)
|
|
part = _user_prompt_part(model_messages)
|
|
model = OpenAIResponsesModel("gpt-4o")
|
|
mapped = await model._map_user_prompt(part)
|
|
return part, mapped, orig_part, orig_snapshot
|
|
|
|
|
|
def _content_types(mapped):
|
|
content = mapped["content"]
|
|
if isinstance(content, str):
|
|
return ["__str__"]
|
|
return [p.get("type") for p in content]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Data-source (inline base64) regressions — must keep working.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_multimodal_text_plus_image_maps_without_assert_never():
|
|
"""Text + inline-image message maps to input_text + input_image, and the
|
|
user's literal prompt text survives alongside the attachment."""
|
|
|
|
async def run():
|
|
um = UserMessage(
|
|
id="m1",
|
|
role="user",
|
|
content=[
|
|
TextInputContent(type="text", text="describe the sample image"),
|
|
ImageInputContent(
|
|
type="image",
|
|
source=InputContentDataSource(
|
|
type="data", value=_PNG_B64, mime_type="image/png"
|
|
),
|
|
),
|
|
],
|
|
)
|
|
part, mapped, _orig, _snap = await _bridge_and_map(um)
|
|
|
|
# Our history_processor must have produced native PydanticAI types.
|
|
assert isinstance(part.content, list)
|
|
assert any(isinstance(c, str) for c in part.content)
|
|
assert any(isinstance(c, ImageUrl) for c in part.content)
|
|
|
|
# The lib maps them with no ``assert_never``.
|
|
types = _content_types(mapped)
|
|
assert "input_text" in types
|
|
assert "input_image" in types
|
|
|
|
# The user's literal prompt text survives verbatim.
|
|
texts = [
|
|
p.get("text") for p in mapped["content"] if p.get("type") == "input_text"
|
|
]
|
|
assert "describe the sample image" in texts
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_plain_text_user_message_passes_through():
|
|
"""A non-multimodal ``str`` message is untouched and maps fine."""
|
|
|
|
async def run():
|
|
um = UserMessage(id="m2", role="user", content="hello there")
|
|
part, mapped, _orig, _snap = await _bridge_and_map(um)
|
|
assert part.content == "hello there"
|
|
assert mapped["content"] == "hello there"
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_pdf_document_data_flattened_to_text():
|
|
"""An unreadable inline PDF document part degrades to a placeholder string,
|
|
and the sibling prompt text survives."""
|
|
|
|
async def run():
|
|
um = UserMessage(
|
|
id="m3",
|
|
role="user",
|
|
content=[
|
|
TextInputContent(type="text", text="summarize this"),
|
|
DocumentInputContent(
|
|
type="document",
|
|
source=InputContentDataSource(
|
|
type="data",
|
|
value="bm90YXBkZg==", # "notapdf" — not a real PDF
|
|
mime_type="application/pdf",
|
|
),
|
|
),
|
|
],
|
|
)
|
|
part, mapped, _orig, _snap = await _bridge_and_map(um)
|
|
|
|
assert isinstance(part.content, list)
|
|
assert all(isinstance(c, str) for c in part.content)
|
|
texts = [
|
|
p.get("text") for p in mapped["content"] if p.get("type") == "input_text"
|
|
]
|
|
assert "summarize this" in texts # prompt text survives
|
|
assert any("could not be read" in (t or "") for t in texts)
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# URL-source coverage — the A1 incompleteness that crashed with assert_never.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_url_source_image_maps_to_image_url():
|
|
"""A url-source image (modern ``InputContentUrlSource``) maps to an
|
|
``ImageUrl`` and an ``input_image`` part — RED before the rework
|
|
(``assert_never``), GREEN after."""
|
|
|
|
async def run():
|
|
um = UserMessage(
|
|
id="u1",
|
|
role="user",
|
|
content=[
|
|
TextInputContent(type="text", text="what is in this picture"),
|
|
ImageInputContent(
|
|
type="image",
|
|
source=InputContentUrlSource(
|
|
type="url",
|
|
value="https://example.com/cat.png",
|
|
mime_type="image/png",
|
|
),
|
|
),
|
|
],
|
|
)
|
|
part, mapped, _orig, _snap = await _bridge_and_map(um)
|
|
assert any(isinstance(c, ImageUrl) for c in part.content)
|
|
img = next(c for c in part.content if isinstance(c, ImageUrl))
|
|
assert img.url == "https://example.com/cat.png"
|
|
types = _content_types(mapped)
|
|
assert "input_text" in types
|
|
assert "input_image" in types
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_url_carrying_binary_maps_to_image_url():
|
|
"""A legacy ``binary`` part carrying a ``url`` (not ``data``) maps to an
|
|
``ImageUrl`` — RED before the rework (``assert_never``), GREEN after."""
|
|
|
|
async def run():
|
|
um = UserMessage(
|
|
id="u2",
|
|
role="user",
|
|
content=[
|
|
BinaryInputContent(
|
|
type="binary",
|
|
mime_type="image/png",
|
|
url="https://example.com/cat.png",
|
|
),
|
|
],
|
|
)
|
|
part, mapped, _orig, _snap = await _bridge_and_map(um)
|
|
assert any(isinstance(c, ImageUrl) for c in part.content)
|
|
assert "input_image" in _content_types(mapped)
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_missing_mime_image_not_dropped():
|
|
"""A modern image part whose source omits ``mime_type`` is classified by its
|
|
part ``type`` and still produces an ``ImageUrl`` — never silently dropped."""
|
|
|
|
async def run():
|
|
um = UserMessage(
|
|
id="u3",
|
|
role="user",
|
|
content=[
|
|
ImageInputContent(
|
|
type="image",
|
|
source=InputContentUrlSource(
|
|
type="url",
|
|
value="https://example.com/no-mime",
|
|
mime_type=None,
|
|
),
|
|
),
|
|
],
|
|
)
|
|
part, mapped, _orig, _snap = await _bridge_and_map(um)
|
|
assert any(isinstance(c, ImageUrl) for c in part.content)
|
|
assert "input_image" in _content_types(mapped)
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# State purity — the A3 leak (in-place mutation persisted to chat state).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_model_boundary_flatten_does_not_mutate_input():
|
|
"""The model-boundary flatten must NOT mutate the AG-UI-derived (UI-backing)
|
|
message objects — it builds NEW objects so the rewrite stays scoped to the
|
|
outgoing request and never touches the list PydanticAI persists to
|
|
``ctx.state.message_history``.
|
|
|
|
RED before the original rework: the flatten mutated ``part.content`` in
|
|
place, so the flattened ImageUrl/text leaked back into the original
|
|
objects PydanticAI persists to state.
|
|
"""
|
|
|
|
async def run():
|
|
um = UserMessage(
|
|
id="s1",
|
|
role="user",
|
|
content=[
|
|
TextInputContent(type="text", text="hi"),
|
|
ImageInputContent(
|
|
type="image",
|
|
source=InputContentDataSource(
|
|
type="data", value=_PNG_B64, mime_type="image/png"
|
|
),
|
|
),
|
|
],
|
|
)
|
|
part, _mapped, orig_part, orig_snapshot = await _bridge_and_map(um)
|
|
|
|
# The rewritten part is native content...
|
|
assert any(isinstance(c, ImageUrl) for c in part.content)
|
|
# ...but the original (state/UI-backing) part is untouched: still the
|
|
# raw AG-UI InputContent objects, and a NEW part object.
|
|
assert orig_part.content == orig_snapshot
|
|
assert orig_part is not part
|
|
assert all(type(o).__name__.endswith("InputContent") for o in orig_part.content)
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fail-loud on unknown shapes — the A2 swallow that re-enabled assert_never.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_unknown_object_shape_fails_loud():
|
|
"""A genuinely unknown content object raises a clear ``ValueError`` naming
|
|
the shape — never silent pass-through to the downstream ``assert_never``."""
|
|
|
|
class _Mystery:
|
|
pass
|
|
|
|
with pytest.raises(ValueError) as exc:
|
|
_rewrite_part_for_model(_Mystery())
|
|
assert "unrecognised content part" in str(exc.value)
|
|
assert "_Mystery" in str(exc.value)
|
|
|
|
|
|
def test_unknown_dict_shape_fails_loud():
|
|
"""An unknown dict-shaped part also fails loud rather than passing through."""
|
|
|
|
with pytest.raises(ValueError) as exc:
|
|
_rewrite_part_for_model({"type": "mystery", "foo": 1})
|
|
assert "unrecognised content part" in str(exc.value)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# A1' — missing-mime inline-DATA image must NEVER produce a "data:image/*" URI
|
|
# (OpenAI's Responses API rejects the wildcard media type → image dropped /
|
|
# turn fails). The reference DROPS missing-mime parts; we sniff a concrete
|
|
# image type from the base64 magic bytes when we can, else degrade to a
|
|
# generic-attachment text placeholder.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_missing_mime_data_image_sniffs_concrete_type_never_wildcard():
|
|
"""A modern image part with inline DATA but no mime must map to an ImageUrl
|
|
whose data URI carries a CONCRETE media type sniffed from the PNG magic
|
|
bytes — NEVER ``data:image/*;base64`` (which OpenAI rejects).
|
|
|
|
RED before the fix: ``_kind_for`` returns ``image/*`` for a missing-mime
|
|
image, so the data URI is ``data:image/*;base64,...`` (invalid).
|
|
GREEN after: the URI carries ``image/png``.
|
|
"""
|
|
out = _rewrite_part_for_model(
|
|
ImageInputContent(
|
|
type="image",
|
|
source=InputContentDataSource(type="data", value=_PNG_B64, mime_type=""),
|
|
)
|
|
)
|
|
assert isinstance(out, ImageUrl)
|
|
assert "image/*" not in out.url
|
|
assert out.url == f"data:image/png;base64,{_PNG_B64}"
|
|
|
|
|
|
def test_missing_mime_unsniffable_data_image_degrades_to_placeholder():
|
|
"""A missing-mime inline-DATA image whose bytes are NOT a recognisable
|
|
image format must NOT emit a ``data:image/*`` URI; it degrades to a
|
|
generic-attachment text placeholder (reference DROPS such parts).
|
|
|
|
RED before the fix: produces ``ImageUrl("data:image/*;base64,...")``.
|
|
GREEN after: a ``str`` placeholder, no invalid wildcard URI.
|
|
"""
|
|
# "notanimage" base64 — no PNG/JPEG/GIF/WebP magic.
|
|
out = _rewrite_part_for_model(
|
|
ImageInputContent(
|
|
type="image",
|
|
source=InputContentDataSource(
|
|
type="data", value="bm90YW5pbWFnZQ==", mime_type=""
|
|
),
|
|
)
|
|
)
|
|
assert isinstance(out, str)
|
|
assert "image/*" not in out
|
|
assert "[Attached" in out
|
|
|
|
|
|
def test_missing_mime_data_image_full_surface_maps_to_input_image():
|
|
"""End-to-end: a missing-mime inline-DATA PNG must still map to an
|
|
``input_image`` (sniffed concrete type) through the real model mapper —
|
|
NEVER an invalid wildcard data URI that OpenAI would reject."""
|
|
|
|
async def run():
|
|
um = UserMessage(
|
|
id="mm1",
|
|
role="user",
|
|
content=[
|
|
ImageInputContent(
|
|
type="image",
|
|
source=InputContentDataSource(
|
|
type="data", value=_PNG_B64, mime_type=""
|
|
),
|
|
),
|
|
],
|
|
)
|
|
part, mapped, _orig, _snap = await _bridge_and_map(um)
|
|
imgs = [c for c in part.content if isinstance(c, ImageUrl)]
|
|
assert imgs, "missing-mime DATA image should still produce an ImageUrl"
|
|
assert all("image/*" not in c.url for c in imgs)
|
|
assert "input_image" in _content_types(mapped)
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fail-loud OVERREACH — recoverable-empty cases must degrade, NOT raise.
|
|
# Only a GENUINELY-unknown shape fails loud.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_empty_payload_legacy_binary_degrades_not_raises():
|
|
"""A legacy ``binary`` part with a known mime but NO data and NO url is a
|
|
recoverable-empty case per the taxonomy — it must degrade (placeholder /
|
|
skip), NOT raise the fail-loud ValueError.
|
|
|
|
RED before the fix: ``_classify_content_part`` returns ``None`` and the
|
|
part falls through to the fail-loud ``ValueError``.
|
|
GREEN after: returns a degraded text placeholder, no raise.
|
|
"""
|
|
out = _rewrite_part_for_model({"type": "binary", "mimeType": "image/png"})
|
|
assert isinstance(out, str)
|
|
assert "[Attached" in out
|
|
|
|
|
|
def test_empty_source_modern_image_degrades_not_raises():
|
|
"""A modern ``image`` part with an empty/absent source is recoverable-empty
|
|
and must degrade gracefully, NOT raise."""
|
|
out = _rewrite_part_for_model({"type": "image", "source": {}})
|
|
assert isinstance(out, str)
|
|
assert "[Attached" in out
|
|
|
|
|
|
def test_empty_source_modern_document_degrades_not_raises():
|
|
"""A modern ``document`` part with no usable source degrades, NOT raises."""
|
|
out = _rewrite_part_for_model({"type": "document"})
|
|
assert isinstance(out, str)
|
|
assert "[Attached" in out
|
|
|
|
|
|
def test_genuinely_unknown_shape_still_fails_loud_after_overreach_fix():
|
|
"""The overreach fix must NOT weaken the fail-loud on a TRULY-unknown shape:
|
|
a dict whose ``type`` is unrecognised still raises a clear ValueError."""
|
|
with pytest.raises(ValueError) as exc:
|
|
_rewrite_part_for_model({"type": "totally-bogus", "x": 1})
|
|
assert "unrecognised content part" in str(exc.value)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# A3' — the flatten MUST NOT persist into ctx.state.message_history (UI state).
|
|
# pydantic_ai._agent_graph writes the history_processor's RETURNED list back to
|
|
# state (``ctx.state.message_history[:] = message_history``), so a processor
|
|
# that RETURNS flattened content leaks it into the UI-visible conversation —
|
|
# even if the original objects are untouched. The fix scopes the flatten to the
|
|
# model boundary (a WrapperModel) so the persisted history keeps the ORIGINAL
|
|
# AG-UI content while only the outgoing provider request is flattened.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_agent_uses_no_content_flattening_history_processor():
|
|
"""The agent must NOT carry a history_processor that flattens content,
|
|
because pydantic_ai persists the processor's return to
|
|
``ctx.state.message_history`` (the UI-visible state).
|
|
|
|
This asserts the load-bearing INVARIANT — no registered processor flattens
|
|
raw AG-UI content — by RUNNING every registered processor over a real
|
|
bridged history and proving the result still holds raw AG-UI objects. It
|
|
inspects only the agent's actual registered processors, so it stays robust
|
|
regardless of any internal helper names.
|
|
|
|
RED before the fix: the flatten was registered as a history_processor, so a
|
|
processor returns flattened content (ImageUrl/str) → UI leak.
|
|
GREEN after: no content-flattening processor is registered (the flatten
|
|
happens at the model boundary instead), so the processed history is
|
|
unchanged raw AG-UI content.
|
|
"""
|
|
from agents.multimodal_agent import agent
|
|
|
|
um = UserMessage(
|
|
id="proc1",
|
|
role="user",
|
|
content=[
|
|
TextInputContent(type="text", text="hi"),
|
|
ImageInputContent(
|
|
type="image",
|
|
source=InputContentDataSource(
|
|
type="data", value=_PNG_B64, mime_type="image/png"
|
|
),
|
|
),
|
|
],
|
|
)
|
|
history = _messages_from_ag_ui([um])
|
|
|
|
processed = list(history)
|
|
for proc in list(getattr(agent, "history_processors", []) or []):
|
|
processed = proc(processed)
|
|
|
|
part = _user_prompt_part(processed)
|
|
assert isinstance(part.content, list)
|
|
assert all(type(c).__name__.endswith("InputContent") for c in part.content), (
|
|
"a registered history_processor flattened content — its return is "
|
|
"persisted to ctx.state.message_history and would leak the flattened "
|
|
f"PDF/image content into UI state: {[type(c).__name__ for c in part.content]}"
|
|
)
|
|
|
|
|
|
def test_model_boundary_flatten_does_not_persist_to_state():
|
|
"""EMPIRICAL A3' leak proof, driven through the REAL model-boundary wrapper.
|
|
|
|
The previous version of this test looped over ``agent.history_processors``
|
|
— which is empty — so its loop never ran and it passed trivially (a
|
|
tautology). This version instead drives the load-bearing surface: the
|
|
``_MultimodalFlattenModel`` wrapper that actually flattens the outgoing
|
|
request. We hand the wrapper a captured ``state_history`` (exactly the list
|
|
``_agent_graph`` keeps as ``ctx.state.message_history``), let the wrapper's
|
|
model-boundary flatten run, and assert the state-backing list STILL holds
|
|
the ORIGINAL raw AG-UI content objects — proving the flatten is scoped to
|
|
the outgoing request and never persists into UI-visible state.
|
|
|
|
This is non-tautological: a captured ``RecordingModel`` confirms the
|
|
wrapper's delegate actually RECEIVED flattened (native) content (so the
|
|
flatten really ran), while the asserted state list is unchanged.
|
|
|
|
RED (a regression where the wrapper mutated/persisted its input, or a
|
|
re-introduced history_processor): the state list would hold ImageUrl/str.
|
|
GREEN: state keeps raw AG-UI objects; the wrapped model saw native content.
|
|
"""
|
|
from pydantic_ai.usage import RequestUsage
|
|
|
|
from agents.multimodal_agent import _MultimodalFlattenModel
|
|
|
|
class RecordingModel(OpenAIResponsesModel):
|
|
"""Captures the messages the wrapper forwards, without a network call."""
|
|
|
|
def __init__(self):
|
|
super().__init__("gpt-4o")
|
|
self.seen_request = None
|
|
self.seen_count_tokens = None
|
|
|
|
async def request(self, messages, model_settings, model_request_parameters):
|
|
self.seen_request = messages
|
|
return ModelResponse(parts=[])
|
|
|
|
async def count_tokens(
|
|
self, messages, model_settings, model_request_parameters
|
|
):
|
|
self.seen_count_tokens = messages
|
|
return RequestUsage()
|
|
|
|
um = UserMessage(
|
|
id="leak1",
|
|
role="user",
|
|
content=[
|
|
TextInputContent(type="text", text="hi"),
|
|
ImageInputContent(
|
|
type="image",
|
|
source=InputContentDataSource(
|
|
type="data", value=_PNG_B64, mime_type="image/png"
|
|
),
|
|
),
|
|
],
|
|
)
|
|
state_history = _messages_from_ag_ui([um])
|
|
orig_part = _user_prompt_part(state_history)
|
|
orig_snapshot = list(orig_part.content)
|
|
|
|
rec = RecordingModel()
|
|
wrapper = _MultimodalFlattenModel(rec)
|
|
params = ModelRequestParameters()
|
|
|
|
async def run():
|
|
await wrapper.request(state_history, None, params)
|
|
await wrapper.count_tokens(state_history, None, params)
|
|
|
|
asyncio.run(run())
|
|
|
|
# The wrapped model actually received FLATTENED (native) content on BOTH
|
|
# the request and the count_tokens path — proves the flatten really ran.
|
|
for seen in (rec.seen_request, rec.seen_count_tokens):
|
|
assert seen is not None
|
|
seen_part = _user_prompt_part(seen)
|
|
assert any(isinstance(c, ImageUrl) for c in seen_part.content), (
|
|
"wrapped model did not receive flattened content: "
|
|
f"{[type(c).__name__ for c in seen_part.content]}"
|
|
)
|
|
|
|
# ...yet the state-backing history is UNCHANGED — still raw AG-UI objects,
|
|
# same identity, same content (no persistence/leak into UI state).
|
|
assert _user_prompt_part(state_history) is orig_part
|
|
assert orig_part.content == orig_snapshot
|
|
assert all(type(c).__name__.endswith("InputContent") for c in orig_part.content), (
|
|
"model-boundary flatten leaked into state: "
|
|
f"{[type(c).__name__ for c in orig_part.content]}"
|
|
)
|
|
|
|
|
|
def test_model_wrapper_flattens_outgoing_request():
|
|
"""The model boundary MUST flatten AG-UI content before the provider call.
|
|
|
|
The wrapper-model approach (the A3' fix) flattens the outgoing messages
|
|
just before the wrapped model's request. We exercise the wrapper's flatten
|
|
on a UserPromptPart carrying raw AG-UI content and assert it produces native
|
|
PydanticAI content (so the provider never sees an AG-UI object), while the
|
|
INPUT list is left untouched (model-boundary scope, no state mutation).
|
|
"""
|
|
from agents.multimodal_agent import _flatten_messages_for_model
|
|
|
|
bridged = _messages_from_ag_ui(
|
|
[
|
|
UserMessage(
|
|
id="w1",
|
|
role="user",
|
|
content=[
|
|
TextInputContent(type="text", text="describe"),
|
|
ImageInputContent(
|
|
type="image",
|
|
source=InputContentDataSource(
|
|
type="data", value=_PNG_B64, mime_type="image/png"
|
|
),
|
|
),
|
|
],
|
|
)
|
|
]
|
|
)
|
|
orig_part = _user_prompt_part(bridged)
|
|
orig_types = [type(c).__name__ for c in orig_part.content]
|
|
|
|
flattened = _flatten_messages_for_model(bridged)
|
|
fpart = _user_prompt_part(flattened)
|
|
assert any(isinstance(c, ImageUrl) for c in fpart.content)
|
|
assert any(isinstance(c, str) for c in fpart.content)
|
|
|
|
# The input messages are untouched (no state mutation at the boundary).
|
|
assert [type(c).__name__ for c in orig_part.content] == orig_types
|
|
assert all(type(c).__name__.endswith("InputContent") for c in orig_part.content)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# count_tokens path — the model-call path _agent_graph runs when
|
|
# UsageLimits.count_tokens_before_request is set. It passes the UN-flattened
|
|
# message_history, so without a wrapper count_tokens override the raw AG-UI
|
|
# InputContent reaches the wrapped model's prompt mapper (assert_never), OR the
|
|
# call never delegates at all (base Model.count_tokens raises NotImplementedError
|
|
# regardless of the wrapped model). The wrapper must flatten + delegate.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_count_tokens_path_does_not_reach_assert_never_on_raw_ag_ui():
|
|
"""RED before the override: the wrapper has NO ``count_tokens``, so it
|
|
resolves to base ``Model.count_tokens`` — which NEVER delegates to the
|
|
wrapped model and raises ``NotImplementedError`` (the count_tokens path is
|
|
entirely uncovered). Even where a wrapped model DOES implement count_tokens
|
|
by mapping the prompt, the raw AG-UI objects would reach its ``assert_never``.
|
|
|
|
GREEN after: the wrapper overrides ``count_tokens`` to flatten the outgoing
|
|
messages and delegate — so a wrapped model that maps the prompt during
|
|
count_tokens (exactly the OpenAI Responses ``_map_user_prompt`` surface)
|
|
receives NATIVE content and never hits ``assert_never``.
|
|
"""
|
|
|
|
from pydantic_ai.usage import RequestUsage
|
|
|
|
from agents.multimodal_agent import _MultimodalFlattenModel
|
|
|
|
mapper_model = OpenAIResponsesModel("gpt-4o")
|
|
|
|
class CountingModel(OpenAIResponsesModel):
|
|
"""A wrapped model whose count_tokens maps the prompt — the real
|
|
``OpenAIResponsesModel._map_user_prompt`` surface that ``assert_never``s
|
|
on non-native content. This is the path ``_agent_graph`` drives."""
|
|
|
|
def __init__(self):
|
|
super().__init__("gpt-4o")
|
|
self.mapped_types = None
|
|
|
|
async def count_tokens(
|
|
self, messages, model_settings, model_request_parameters
|
|
):
|
|
part = _user_prompt_part(messages)
|
|
mapped = await mapper_model._map_user_prompt(part)
|
|
self.mapped_types = _content_types(mapped)
|
|
return RequestUsage()
|
|
|
|
um = UserMessage(
|
|
id="ct1",
|
|
role="user",
|
|
content=[
|
|
TextInputContent(type="text", text="how many tokens"),
|
|
ImageInputContent(
|
|
type="image",
|
|
source=InputContentDataSource(
|
|
type="data", value=_PNG_B64, mime_type="image/png"
|
|
),
|
|
),
|
|
],
|
|
)
|
|
messages = _messages_from_ag_ui([um])
|
|
|
|
counting = CountingModel()
|
|
wrapper = _MultimodalFlattenModel(counting)
|
|
params = ModelRequestParameters()
|
|
|
|
async def run():
|
|
# RED: without the override this resolves to base Model.count_tokens →
|
|
# NotImplementedError (path uncovered). With a delegating-but-unflattened
|
|
# path, _map_user_prompt would assert_never on the raw AG-UI image.
|
|
await wrapper.count_tokens(messages, None, params)
|
|
|
|
asyncio.run(run())
|
|
|
|
# GREEN: the prompt mapped cleanly to native input parts — no assert_never.
|
|
assert counting.mapped_types is not None
|
|
assert "input_text" in counting.mapped_types
|
|
assert "input_image" in counting.mapped_types
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Audio / video — KNOWN-but-unsupported modalities must DEGRADE to a text
|
|
# placeholder (consistent with non-image/non-document binary), NOT fail-loud.
|
|
# Fail-loud stays reserved for GENUINELY-unknown shapes.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_audio_data_input_degrades_to_placeholder_not_raises():
|
|
"""A valid inline-DATA ``AudioInputContent`` degrades to a text placeholder,
|
|
NOT a fail-loud ValueError.
|
|
|
|
RED before the fix: ``audio`` is not a recognised attachment ``type`` in
|
|
``_classify_content_part``, so it falls through to the fail-loud
|
|
``ValueError``.
|
|
GREEN after: it degrades to ``[Attached audio: <mime>]`` and the turn
|
|
proceeds.
|
|
"""
|
|
out = _rewrite_part_for_model(
|
|
AudioInputContent(
|
|
type="audio",
|
|
source=InputContentDataSource(
|
|
type="data", value="AAAA", mime_type="audio/mpeg"
|
|
),
|
|
)
|
|
)
|
|
assert isinstance(out, str)
|
|
assert "[Attached audio" in out
|
|
|
|
|
|
def test_video_url_input_degrades_to_placeholder_not_raises():
|
|
"""A valid url-source ``VideoInputContent`` degrades to a placeholder, NOT
|
|
a fail-loud ValueError."""
|
|
out = _rewrite_part_for_model(
|
|
VideoInputContent(
|
|
type="video",
|
|
source=InputContentUrlSource(
|
|
type="url", value="https://example.com/v.mp4", mime_type="video/mp4"
|
|
),
|
|
)
|
|
)
|
|
assert isinstance(out, str)
|
|
assert "[Attached video" in out
|
|
|
|
|
|
def test_audio_dict_with_empty_source_degrades_not_raises():
|
|
"""A modern ``audio`` part with no usable source still degrades (recognised
|
|
modality), NOT fail-loud."""
|
|
out = _rewrite_part_for_model({"type": "audio"})
|
|
assert isinstance(out, str)
|
|
assert "[Attached audio" in out
|
|
|
|
|
|
def test_audio_video_message_maps_through_full_surface_without_assert_never():
|
|
"""End-to-end: a message mixing text + audio + video maps through the real
|
|
model mapper with NO ``assert_never`` — audio/video become text placeholders
|
|
and the user's prompt text survives."""
|
|
|
|
async def run():
|
|
um = UserMessage(
|
|
id="av1",
|
|
role="user",
|
|
content=[
|
|
TextInputContent(type="text", text="what is in these clips"),
|
|
AudioInputContent(
|
|
type="audio",
|
|
source=InputContentDataSource(
|
|
type="data", value="AAAA", mime_type="audio/mpeg"
|
|
),
|
|
),
|
|
VideoInputContent(
|
|
type="video",
|
|
source=InputContentUrlSource(
|
|
type="url",
|
|
value="https://example.com/v.mp4",
|
|
mime_type="video/mp4",
|
|
),
|
|
),
|
|
],
|
|
)
|
|
part, mapped, _orig, _snap = await _bridge_and_map(um)
|
|
assert all(isinstance(c, str) for c in part.content)
|
|
types = _content_types(mapped)
|
|
assert "input_text" in types
|
|
texts = [
|
|
p.get("text") for p in mapped["content"] if p.get("type") == "input_text"
|
|
]
|
|
assert "what is in these clips" in texts
|
|
assert any("Attached audio" in (t or "") for t in texts)
|
|
assert any("Attached video" in (t or "") for t in texts)
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unsupported IMAGE SUBTYPES — OpenAI's Responses vision API only supports
|
|
# PNG / JPEG / GIF / WebP. A present-mime image whose subtype is anything else
|
|
# (image/heic — the iPhone default! — image/svg+xml, image/tiff, image/bmp …)
|
|
# must NEVER be emitted as a ``data:image/<subtype>;base64`` ImageUrl: the
|
|
# Responses API rejects the unsupported subtype → the image is dropped / the
|
|
# turn fails. It must DEGRADE to a text placeholder (same pattern as the
|
|
# audio/video degrade). The allow-list applies to BOTH the present-mime image
|
|
# path AND the missing-mime sniff path (so a sniffed tiff/bmp also degrades).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_present_mime_heic_image_degrades_not_image_url():
|
|
"""A present-mime ``image/heic`` part (iPhone default) must degrade to a
|
|
text placeholder — OpenAI's Responses vision API rejects HEIC.
|
|
|
|
RED before the fix: routed to ``kind="image"`` on the ``image/`` prefix and
|
|
emitted as ``ImageUrl("data:image/heic;base64,...")`` (OpenAI rejects).
|
|
GREEN after: a ``str`` placeholder naming the mime; no ``data:image/heic``.
|
|
"""
|
|
out = _rewrite_part_for_model(
|
|
ImageInputContent(
|
|
type="image",
|
|
source=InputContentDataSource(
|
|
type="data", value=_PNG_B64, mime_type="image/heic"
|
|
),
|
|
)
|
|
)
|
|
assert isinstance(out, str)
|
|
assert "[Attached image" in out
|
|
assert "image/heic" in out
|
|
|
|
|
|
def test_present_mime_svg_image_degrades_not_image_url():
|
|
"""A present-mime ``image/svg+xml`` part degrades to a placeholder — SVG is
|
|
not a supported Responses vision subtype."""
|
|
out = _rewrite_part_for_model(
|
|
ImageInputContent(
|
|
type="image",
|
|
source=InputContentDataSource(
|
|
type="data", value=_PNG_B64, mime_type="image/svg+xml"
|
|
),
|
|
)
|
|
)
|
|
assert isinstance(out, str)
|
|
assert "[Attached image" in out
|
|
assert "image/svg+xml" in out
|
|
|
|
|
|
def test_present_mime_unsupported_image_url_source_degrades():
|
|
"""An unsupported image subtype delivered via a URL source ALSO degrades —
|
|
OpenAI would reject the ``image/heic`` regardless of data-vs-url delivery."""
|
|
out = _rewrite_part_for_model(
|
|
ImageInputContent(
|
|
type="image",
|
|
source=InputContentUrlSource(
|
|
type="url",
|
|
value="https://example.com/photo.heic",
|
|
mime_type="image/heic",
|
|
),
|
|
)
|
|
)
|
|
assert isinstance(out, str)
|
|
assert "[Attached image" in out
|
|
assert "image/heic" in out
|
|
|
|
|
|
def test_sniffed_tiff_missing_mime_image_degrades_not_image_url():
|
|
"""A missing-mime inline-DATA image whose magic bytes sniff as TIFF must
|
|
degrade — TIFF is recognisable but NOT a supported Responses subtype, so it
|
|
must NOT be emitted as ``data:image/tiff``.
|
|
|
|
RED before the fix: ``_sniff_image_mime`` returns ``image/tiff`` and the
|
|
part is emitted as ``ImageUrl("data:image/tiff;base64,...")``.
|
|
GREEN after: degrades to a placeholder ``str``; no ``data:image/tiff``.
|
|
"""
|
|
import base64 as _b64
|
|
|
|
tiff_b64 = _b64.b64encode(b"II*\x00" + b"\x00" * 20).decode()
|
|
out = _rewrite_part_for_model(
|
|
ImageInputContent(
|
|
type="image",
|
|
source=InputContentDataSource(type="data", value=tiff_b64, mime_type=""),
|
|
)
|
|
)
|
|
assert isinstance(out, str)
|
|
assert "image/tiff" not in out
|
|
assert "data:image" not in out
|
|
assert "[Attached image" in out
|
|
|
|
|
|
def test_sniffed_bmp_missing_mime_image_degrades_not_image_url():
|
|
"""A missing-mime inline-DATA image whose magic bytes sniff as BMP also
|
|
degrades — BMP is not a supported Responses subtype."""
|
|
import base64 as _b64
|
|
|
|
bmp_b64 = _b64.b64encode(b"BM" + b"\x00" * 20).decode()
|
|
out = _rewrite_part_for_model(
|
|
ImageInputContent(
|
|
type="image",
|
|
source=InputContentDataSource(type="data", value=bmp_b64, mime_type=""),
|
|
)
|
|
)
|
|
assert isinstance(out, str)
|
|
assert "data:image" not in out
|
|
assert "[Attached image" in out
|
|
|
|
|
|
def test_supported_image_subtypes_still_image_url_present_mime():
|
|
"""The four supported subtypes (PNG/JPEG/GIF/WebP) still emit an ImageUrl
|
|
when delivered with a present mime — the allow-list must not over-degrade."""
|
|
for subtype in ("png", "jpeg", "gif", "webp"):
|
|
out = _rewrite_part_for_model(
|
|
ImageInputContent(
|
|
type="image",
|
|
source=InputContentDataSource(
|
|
type="data", value=_PNG_B64, mime_type=f"image/{subtype}"
|
|
),
|
|
)
|
|
)
|
|
assert isinstance(out, ImageUrl), f"image/{subtype} should map to ImageUrl"
|
|
assert out.url == f"data:image/{subtype};base64,{_PNG_B64}"
|
|
|
|
|
|
def test_supported_image_subtype_url_source_still_image_url():
|
|
"""A supported-subtype URL-source image still maps to an ImageUrl."""
|
|
out = _rewrite_part_for_model(
|
|
ImageInputContent(
|
|
type="image",
|
|
source=InputContentUrlSource(
|
|
type="url", value="https://example.com/cat.png", mime_type="image/png"
|
|
),
|
|
)
|
|
)
|
|
assert isinstance(out, ImageUrl)
|
|
assert out.url == "https://example.com/cat.png"
|
|
|
|
|
|
def test_heic_image_maps_through_full_surface_as_text_not_image():
|
|
"""End-to-end: a present-mime HEIC image maps through the real model mapper
|
|
as an ``input_text`` placeholder — never an unsupported ``input_image``."""
|
|
|
|
async def run():
|
|
um = UserMessage(
|
|
id="heic1",
|
|
role="user",
|
|
content=[
|
|
TextInputContent(type="text", text="describe this photo"),
|
|
ImageInputContent(
|
|
type="image",
|
|
source=InputContentDataSource(
|
|
type="data", value=_PNG_B64, mime_type="image/heic"
|
|
),
|
|
),
|
|
],
|
|
)
|
|
part, mapped, _orig, _snap = await _bridge_and_map(um)
|
|
# No ImageUrl produced for the HEIC part; it degraded to a str.
|
|
assert not any(isinstance(c, ImageUrl) for c in part.content)
|
|
types = _content_types(mapped)
|
|
assert "input_text" in types
|
|
assert "input_image" not in types
|
|
texts = [
|
|
p.get("text") for p in mapped["content"] if p.get("type") == "input_text"
|
|
]
|
|
assert "describe this photo" in texts
|
|
assert any("Attached image" in (t or "") for t in texts)
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Empty-but-known-mime attachment must preserve the mime in the placeholder
|
|
# (consistent with the legacy-binary empty path), not discard it.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_empty_known_mime_image_preserves_mime_in_placeholder():
|
|
"""A modern ``image`` part whose source is empty but whose modality is known
|
|
degrades to a placeholder that names the modality — and a legacy binary with
|
|
a known mime but no payload preserves that mime."""
|
|
# Legacy binary with a known mime but no data/url: mime preserved.
|
|
out = _rewrite_part_for_model({"type": "binary", "mimeType": "image/png"})
|
|
assert isinstance(out, str)
|
|
assert "image/png" in out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# request_stream override — the streaming model-call path must flatten the
|
|
# outgoing messages before delegating to the wrapped model (mirror the
|
|
# request / count_tokens wrapper coverage).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_request_stream_flattens_outgoing_messages():
|
|
"""The wrapper's ``request_stream`` override MUST flatten AG-UI content
|
|
before delegating, so the wrapped model's streaming path never sees raw
|
|
AG-UI ``InputContent`` (which would crash ``_map_user_prompt``).
|
|
|
|
RED if the override forwarded raw messages: the wrapped model would receive
|
|
AG-UI objects on the stream path.
|
|
GREEN: the wrapped model's ``request_stream`` receives flattened native
|
|
content, and the input state list is left untouched.
|
|
"""
|
|
from contextlib import asynccontextmanager
|
|
|
|
from agents.multimodal_agent import _MultimodalFlattenModel
|
|
|
|
class StreamRecordingModel(OpenAIResponsesModel):
|
|
"""Captures the messages the wrapper forwards on the stream path."""
|
|
|
|
def __init__(self):
|
|
super().__init__("gpt-4o")
|
|
self.seen_stream = None
|
|
|
|
@asynccontextmanager
|
|
async def request_stream(
|
|
self,
|
|
messages,
|
|
model_settings,
|
|
model_request_parameters,
|
|
run_context=None,
|
|
):
|
|
self.seen_stream = messages
|
|
yield object() # a stand-in StreamedResponse; never iterated here
|
|
|
|
um = UserMessage(
|
|
id="stream1",
|
|
role="user",
|
|
content=[
|
|
TextInputContent(type="text", text="stream this"),
|
|
ImageInputContent(
|
|
type="image",
|
|
source=InputContentDataSource(
|
|
type="data", value=_PNG_B64, mime_type="image/png"
|
|
),
|
|
),
|
|
],
|
|
)
|
|
state_history = _messages_from_ag_ui([um])
|
|
orig_part = _user_prompt_part(state_history)
|
|
orig_snapshot = list(orig_part.content)
|
|
|
|
rec = StreamRecordingModel()
|
|
wrapper = _MultimodalFlattenModel(rec)
|
|
params = ModelRequestParameters()
|
|
|
|
async def run():
|
|
async with wrapper.request_stream(state_history, None, params, None):
|
|
pass
|
|
|
|
asyncio.run(run())
|
|
|
|
# The wrapped model's stream path received FLATTENED native content.
|
|
assert rec.seen_stream is not None
|
|
seen_part = _user_prompt_part(rec.seen_stream)
|
|
assert any(isinstance(c, ImageUrl) for c in seen_part.content), (
|
|
"wrapped model's request_stream did not receive flattened content: "
|
|
f"{[type(c).__name__ for c in seen_part.content]}"
|
|
)
|
|
assert any(isinstance(c, str) for c in seen_part.content)
|
|
|
|
# ...and the state-backing history is unchanged (no leak into UI state).
|
|
assert _user_prompt_part(state_history) is orig_part
|
|
assert orig_part.content == orig_snapshot
|
|
assert all(type(c).__name__.endswith("InputContent") for c in orig_part.content)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unit-level flatten behaviour (no model required).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_text_input_content_object_flattens_to_str():
|
|
"""``_rewrite_part_for_model`` flattens an AG-UI text object to ``str``."""
|
|
out = _rewrite_part_for_model(TextInputContent(type="text", text="hi"))
|
|
assert out == "hi"
|
|
|
|
|
|
def test_image_input_content_object_flattens_to_image_url():
|
|
"""``_rewrite_part_for_model`` flattens an inline AG-UI image to ImageUrl."""
|
|
out = _rewrite_part_for_model(
|
|
ImageInputContent(
|
|
type="image",
|
|
source=InputContentDataSource(
|
|
type="data", value=_PNG_B64, mime_type="image/png"
|
|
),
|
|
)
|
|
)
|
|
assert isinstance(out, ImageUrl)
|
|
assert out.url == f"data:image/png;base64,{_PNG_B64}"
|
|
|
|
|
|
def test_legacy_binary_dict_still_handled():
|
|
"""Frontend legacy on-wire dict shape still maps to ImageUrl."""
|
|
out = _rewrite_part_for_model(
|
|
{"type": "binary", "mimeType": "image/png", "data": _PNG_B64}
|
|
)
|
|
assert isinstance(out, ImageUrl)
|
|
assert out.url == f"data:image/png;base64,{_PNG_B64}"
|
|
|
|
|
|
def test_native_content_passes_through_unchanged():
|
|
"""Already-native content (idempotency) passes through untouched."""
|
|
iu = ImageUrl(url="https://example.com/x.png")
|
|
assert _rewrite_part_for_model(iu) is iu
|
|
assert _rewrite_part_for_model("already a string") == "already a string"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# R8 — consolidated type-gating at the EMISSION CHOKE POINT.
|
|
#
|
|
# These three tests close a CR-round-7 cluster whose shared root cause is that
|
|
# the OpenAI supported-type gate + degrade lived only on the ``image`` content
|
|
# branch (and the mime allow-list under-accepted parameterised / non-canonical
|
|
# mimes). After R8 the supported-type check + degrade happen at the SINGLE
|
|
# native-type emission choke point, and mime normalisation strips RFC-2045
|
|
# parameters / whitespace and aliases ``image/jpg`` → ``image/jpeg``.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_jpg_alias_data_image_forwarded_as_image_url():
|
|
"""GAP (a) — ``image/jpg`` (non-canonical but ubiquitous) inline-DATA image
|
|
must be FORWARDED as a supported JPEG ``ImageUrl``, not degraded.
|
|
|
|
RED before R8: the subtype ``"jpg"`` is not in the allow-list, so
|
|
``_is_supported_image_mime("image/jpg")`` returns ``False`` and the image is
|
|
degraded to a ``[Attached image: image/jpg]`` placeholder — a real JPEG
|
|
silently never reaches the vision model.
|
|
GREEN after R8: ``image/jpg`` is aliased to ``image/jpeg`` before the
|
|
membership test, so it emits an ``ImageUrl``.
|
|
"""
|
|
out = _rewrite_part_for_model(
|
|
ImageInputContent(
|
|
type="image",
|
|
source=InputContentDataSource(
|
|
type="data", value=_PNG_B64, mime_type="image/jpg"
|
|
),
|
|
)
|
|
)
|
|
assert isinstance(out, ImageUrl), "image/jpg should forward as a JPEG ImageUrl"
|
|
assert out.url.startswith("data:image/jpeg;base64,"), (
|
|
f"image/jpg should normalise to canonical image/jpeg in the data URI, "
|
|
f"got {out.url!r}"
|
|
)
|
|
|
|
|
|
def test_parameterised_supported_mime_data_image_forwarded():
|
|
"""GAP (c) — a SUPPORTED image whose mime carries an RFC-2045 parameter or
|
|
stray whitespace (e.g. ``image/jpeg; charset=binary``) must be FORWARDED as
|
|
an ``ImageUrl``, not degraded.
|
|
|
|
RED before R8: ``_is_supported_image_mime`` takes the verbatim remainder
|
|
after ``image/`` as the subtype (``"jpeg; charset=binary"``), which is not in
|
|
the allow-list, so the image is degraded.
|
|
GREEN after R8: the mime is normalised (split on ``;`` + strip) before the
|
|
membership test, so it emits an ``ImageUrl``.
|
|
"""
|
|
out = _rewrite_part_for_model(
|
|
ImageInputContent(
|
|
type="image",
|
|
source=InputContentDataSource(
|
|
type="data", value=_PNG_B64, mime_type="image/jpeg; charset=binary"
|
|
),
|
|
)
|
|
)
|
|
assert isinstance(out, ImageUrl), (
|
|
"parameterised image/jpeg should forward as an ImageUrl"
|
|
)
|
|
assert out.url.startswith("data:image/jpeg;base64,"), (
|
|
f"parameterised mime should normalise to bare image/jpeg in the data "
|
|
f"URI, got {out.url!r}"
|
|
)
|
|
|
|
|
|
def test_url_document_media_mime_degrades_not_document_url():
|
|
"""GAP (b) — a ``document`` (or legacy ``binary``) URL source carrying a
|
|
non-PDF / media (audio/video) mime must DEGRADE to a text placeholder, NOT
|
|
be emitted as an unconditional ``DocumentUrl`` the OpenAI mapper may reject.
|
|
|
|
RED before R8: ``_kind_for("audio/mpeg", default=...)`` returns
|
|
``("other", "audio/mpeg")`` and the ``"other"`` URL branch returns
|
|
``DocumentUrl(url=value)`` unconditionally — no mime gate at all.
|
|
GREEN after R8: the supported-type gate at the emission choke point degrades
|
|
a non-fetchable-document URL to a ``[Attached ...]`` placeholder.
|
|
"""
|
|
from pydantic_ai import DocumentUrl
|
|
|
|
# Legacy binary carrying an audio mime with a url source.
|
|
out = _rewrite_part_for_model(
|
|
{
|
|
"type": "binary",
|
|
"mimeType": "audio/mpeg",
|
|
"url": "https://example.com/voice.mp3",
|
|
}
|
|
)
|
|
assert not isinstance(out, DocumentUrl), (
|
|
"an audio url must NOT be emitted as a DocumentUrl"
|
|
)
|
|
assert isinstance(out, str)
|
|
assert "[Attached" in out
|
|
|
|
# Modern document part carrying a video mime with a url source.
|
|
out2 = _rewrite_part_for_model(
|
|
DocumentInputContent(
|
|
type="document",
|
|
source=InputContentUrlSource(
|
|
type="url",
|
|
value="https://example.com/clip.mp4",
|
|
mime_type="video/mp4",
|
|
),
|
|
)
|
|
)
|
|
assert not isinstance(out2, DocumentUrl), (
|
|
"a video url document must NOT be emitted as a DocumentUrl"
|
|
)
|
|
assert isinstance(out2, str)
|
|
assert "[Attached" in out2
|
|
|
|
|
|
def test_url_real_document_still_document_url():
|
|
"""The supported-type gate must NOT over-degrade a genuine non-PDF document
|
|
URL the model CAN fetch (e.g. a plain-text or office document): it still
|
|
emits a ``DocumentUrl`` so the model can fetch it."""
|
|
from pydantic_ai import DocumentUrl
|
|
|
|
out = _rewrite_part_for_model(
|
|
DocumentInputContent(
|
|
type="document",
|
|
source=InputContentUrlSource(
|
|
type="url",
|
|
value="https://example.com/report.docx",
|
|
mime_type=(
|
|
"application/vnd.openxmlformats-officedocument."
|
|
"wordprocessingml.document"
|
|
),
|
|
),
|
|
)
|
|
)
|
|
assert isinstance(out, DocumentUrl), (
|
|
"a fetchable document url should still emit a DocumentUrl"
|
|
)
|
|
assert out.url == "https://example.com/report.docx"
|
|
|
|
|
|
def test_unsupported_image_binary_data_degrades_at_choke_point():
|
|
"""GAP (b/F3) — an unsupported image subtype that reaches the ``other`` DATA
|
|
branch as a ``BinaryContent`` (``binary.is_image`` true, e.g. HEIC) must
|
|
DEGRADE at the emission choke point, not be emitted as a rejected image
|
|
binary.
|
|
|
|
Exercises the choke-point gate directly via a legacy binary dict whose mime
|
|
is an unsupported image subtype but is NOT routed through the ``image`` kind
|
|
(it is — so to hit the ``other`` BinaryContent path we rely on the choke
|
|
point catching ``binary.is_image`` for an unsupported subtype). With the
|
|
consolidated gate, NO unsupported image subtype is ever emitted as a native
|
|
image, regardless of routing.
|
|
"""
|
|
# A heic data binary: even if a future routing change sent it to the
|
|
# BinaryContent path, the choke point must degrade it (not emit a rejected
|
|
# image binary). Today it routes via kind=="image" and degrades there; the
|
|
# assertion is the invariant: never a native image type for heic.
|
|
out = _rewrite_part_for_model(
|
|
{
|
|
"type": "binary",
|
|
"mimeType": "image/heic",
|
|
"data": _PNG_B64,
|
|
}
|
|
)
|
|
assert not isinstance(out, (ImageUrl, BinaryContent)), (
|
|
"an unsupported image subtype must never be emitted as a native image"
|
|
)
|
|
assert isinstance(out, str)
|
|
assert "[Attached image" in out
|