Files
opensquilla--opensquilla/tests/test_provider_attachments.py
2026-07-13 13:12:33 +08:00

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"]