271 lines
9.0 KiB
Python
271 lines
9.0 KiB
Python
"""Tests for ContentBlockDocument and provider mapping.
|
|
|
|
The contract is:
|
|
|
|
- test_existing_image_block_unchanged is a regression — it should pass on
|
|
day-zero (no prod code change required) because ContentBlockImage already
|
|
works.
|
|
|
|
- The other 4 tests fail with ImportError or AttributeError until
|
|
ContentBlockDocument is added to provider/types.py and the document branch
|
|
is wired into provider/anthropic.py:_build_message_payload.
|
|
|
|
Image flows must not regress. PDF document blocks require Claude 3.5 Sonnet+
|
|
or newer; older SKUs and non-Anthropic providers gracefully skip with WARN +
|
|
counter + user-visible "[document attached but not consumable by this model]".
|
|
Anthropic 400s on document blocks must surface the error code into transcript
|
|
and increment counter provider.document_block.rejected{code}; never silently
|
|
swallow.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from opensquilla.provider.anthropic import AnthropicProvider, _build_message_payload
|
|
from opensquilla.provider.types import (
|
|
ChatConfig,
|
|
ContentBlockImage,
|
|
ContentBlockText,
|
|
ErrorEvent,
|
|
Message,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 1 — regression: existing image block round-trip unchanged.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_existing_image_block_unchanged() -> None:
|
|
"""ContentBlockImage round-trips through the Anthropic adapter unchanged.
|
|
|
|
Locks the existing image-attachment shape so future additions don't
|
|
regress the only attachment kind that works today.
|
|
"""
|
|
msg = Message(
|
|
role="user",
|
|
content=[
|
|
ContentBlockText(text="describe this"),
|
|
ContentBlockImage(
|
|
source_type="base64",
|
|
media_type="image/png",
|
|
data="aGVsbG8=", # arbitrary base64
|
|
),
|
|
],
|
|
)
|
|
|
|
payload = _build_message_payload(msg)
|
|
|
|
assert payload["role"] == "user"
|
|
parts = payload["content"]
|
|
assert {"type": "text", "text": "describe this"} in parts
|
|
image_part = next(p for p in parts if p["type"] == "image")
|
|
assert image_part == {
|
|
"type": "image",
|
|
"source": {
|
|
"type": "base64",
|
|
"media_type": "image/png",
|
|
"data": "aGVsbG8=",
|
|
},
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 2 — ContentBlockDocument round-trips through the Anthropic adapter as
|
|
# a native {type:"document", source:{type:"base64", media_type, data}, title}
|
|
# block.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_document_block_round_trips_through_anthropic_adapter() -> None:
|
|
"""A PDF ContentBlockDocument emits Anthropic's native document shape."""
|
|
from opensquilla.provider.types import ContentBlockDocument # noqa: F401
|
|
|
|
msg = Message(
|
|
role="user",
|
|
content=[
|
|
ContentBlockText(text="summarise the attachment"),
|
|
ContentBlockDocument(
|
|
source_type="base64",
|
|
media_type="application/pdf",
|
|
data="JVBERi0xLjQK", # %PDF-1.4 base64
|
|
title="report.pdf",
|
|
),
|
|
],
|
|
)
|
|
|
|
payload = _build_message_payload(msg)
|
|
|
|
parts = payload["content"]
|
|
doc_part = next(p for p in parts if p["type"] == "document")
|
|
assert doc_part == {
|
|
"type": "document",
|
|
"source": {
|
|
"type": "base64",
|
|
"media_type": "application/pdf",
|
|
"data": "JVBERi0xLjQK",
|
|
},
|
|
"title": "report.pdf",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 3 — ContentBlockDocument validator rejects non-PDF media types in v1.
|
|
# Locked decision: only application/pdf is a legal document block media_type
|
|
# in this release. Text/csv/json go through ContentBlockText with <file> wrap.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_document_block_with_unsupported_media_type_raises() -> None:
|
|
"""Constructing a ContentBlockDocument with a non-PDF media_type raises."""
|
|
from pydantic import ValidationError
|
|
|
|
from opensquilla.provider.types import ContentBlockDocument
|
|
|
|
with pytest.raises(ValidationError):
|
|
ContentBlockDocument(
|
|
source_type="base64",
|
|
media_type="text/plain", # not legal in v1
|
|
data="aGVsbG8=",
|
|
title="notes.txt",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 4 — when the model SKU does not support document blocks, the adapter
|
|
# substitutes a fallback ContentBlockText so the conversation still proceeds.
|
|
# Locked decision: user-visible string is "[document attached but not
|
|
# consumable by this model]"; counter provider.document_block.unsupported
|
|
# is incremented.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_document_block_unsupported_provider_skips_gracefully() -> None:
|
|
"""Older SKUs replace the document block with a fallback text block."""
|
|
from opensquilla.provider.types import ContentBlockDocument
|
|
|
|
# The adapter's payload builder accepts an optional model parameter to
|
|
# detect SKU support. An older Haiku SKU is below the 3.5 Sonnet floor.
|
|
msg = Message(
|
|
role="user",
|
|
content=[
|
|
ContentBlockDocument(
|
|
source_type="base64",
|
|
media_type="application/pdf",
|
|
data="JVBERi0xLjQK",
|
|
title="report.pdf",
|
|
),
|
|
],
|
|
)
|
|
|
|
payload = _build_message_payload(msg, model="claude-haiku-4-5-20251001")
|
|
|
|
parts = payload["content"]
|
|
# No document block survives.
|
|
assert all(p.get("type") != "document" for p in parts), parts
|
|
# A fallback text block carries the user-visible string and the filename.
|
|
fallback = next(
|
|
p
|
|
for p in parts
|
|
if p.get("type") == "text"
|
|
and "[document attached but not consumable by this model]" in p.get("text", "")
|
|
)
|
|
assert "report.pdf" in fallback["text"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 5 — Anthropic 400 on a document block surfaces the HTTP code into the
|
|
# error stream and increments the rejected counter, never silently swallowed.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_anthropic_400_on_document_block_surfaces_actionable_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A 400 response while sending a document block surfaces code + body."""
|
|
from opensquilla.provider import anthropic as anthropic_mod
|
|
from opensquilla.provider.types import ContentBlockDocument
|
|
|
|
captured: dict[str, Any] = {"counter_calls": []}
|
|
|
|
def _record_counter(code: str) -> None:
|
|
captured["counter_calls"].append(code)
|
|
|
|
# The adapter must expose a hook the integration uses to bump the counter.
|
|
# We monkeypatch it so the test can observe the increment without wiring
|
|
# a full metrics backend.
|
|
monkeypatch.setattr(
|
|
anthropic_mod,
|
|
"_increment_document_block_rejected",
|
|
_record_counter,
|
|
raising=False,
|
|
)
|
|
|
|
error_body = json.dumps(
|
|
{
|
|
"type": "error",
|
|
"error": {
|
|
"type": "invalid_request_error",
|
|
"message": "document block malformed",
|
|
},
|
|
}
|
|
)
|
|
|
|
class _StubResponse:
|
|
status_code = 400
|
|
|
|
async def aread(self) -> bytes:
|
|
return error_body.encode()
|
|
|
|
async def aiter_lines(self):
|
|
if False:
|
|
yield ""
|
|
|
|
async def __aenter__(self) -> _StubResponse:
|
|
return self
|
|
|
|
async def __aexit__(self, *exc: Any) -> None:
|
|
return None
|
|
|
|
class _StubClient:
|
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
pass
|
|
|
|
async def __aenter__(self) -> _StubClient:
|
|
return self
|
|
|
|
async def __aexit__(self, *exc: Any) -> None:
|
|
return None
|
|
|
|
def stream(self, *args: Any, **kwargs: Any) -> _StubResponse:
|
|
return _StubResponse()
|
|
|
|
monkeypatch.setattr(anthropic_mod.httpx, "AsyncClient", _StubClient)
|
|
|
|
provider = AnthropicProvider(api_key="k", model="claude-sonnet-4-6")
|
|
msg = Message(
|
|
role="user",
|
|
content=[
|
|
ContentBlockDocument(
|
|
source_type="base64",
|
|
media_type="application/pdf",
|
|
data="JVBERi0xLjQK",
|
|
title="report.pdf",
|
|
),
|
|
],
|
|
)
|
|
|
|
async def _run() -> list[Any]:
|
|
out: list[Any] = []
|
|
async for ev in provider.chat([msg], None, ChatConfig()):
|
|
out.append(ev)
|
|
return out
|
|
|
|
events = asyncio.run(_run())
|
|
|
|
error_events = [e for e in events if isinstance(e, ErrorEvent)]
|
|
assert error_events, f"expected ErrorEvent in {events!r}"
|
|
err = error_events[0]
|
|
assert err.code == "400"
|
|
assert "document block malformed" in err.message
|
|
assert "400" in captured["counter_calls"], captured["counter_calls"]
|