559 lines
17 KiB
Python
559 lines
17 KiB
Python
import asyncio
|
|
import json
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from opensquilla.provider.anthropic import (
|
|
AnthropicProvider,
|
|
_anthropic_input_token_counts,
|
|
_anthropic_iteration_token_counts,
|
|
_build_message_payload,
|
|
)
|
|
from opensquilla.provider.selector import build_provider
|
|
from opensquilla.provider.types import (
|
|
ChatConfig,
|
|
ContentBlockCompaction,
|
|
DoneEvent,
|
|
ErrorEvent,
|
|
Message,
|
|
ModelInfo,
|
|
)
|
|
|
|
|
|
def test_anthropic_input_tokens_include_cache_read_and_creation_tokens() -> None:
|
|
total, cache_read, cache_creation = _anthropic_input_token_counts(
|
|
{
|
|
"input_tokens": 21,
|
|
"cache_read_input_tokens": 188_086,
|
|
"cache_creation_input_tokens": 456,
|
|
"output_tokens": 393,
|
|
}
|
|
)
|
|
|
|
assert total == 188_563
|
|
assert cache_read == 188_086
|
|
assert cache_creation == 456
|
|
|
|
|
|
def test_anthropic_input_tokens_include_structured_cache_creation_tokens() -> None:
|
|
total, cache_read, cache_creation = _anthropic_input_token_counts(
|
|
{
|
|
"input_tokens": 21,
|
|
"cache_read_input_tokens": 100,
|
|
"cache_creation": {
|
|
"ephemeral_5m_input_tokens": 456,
|
|
"ephemeral_1h_input_tokens": 100,
|
|
},
|
|
"output_tokens": 393,
|
|
}
|
|
)
|
|
|
|
assert total == 677
|
|
assert cache_read == 100
|
|
assert cache_creation == 556
|
|
|
|
|
|
def test_anthropic_iteration_tokens_sum_compaction_and_message_usage() -> None:
|
|
input_tokens, output_tokens = _anthropic_iteration_token_counts(
|
|
{
|
|
"input_tokens": 23000,
|
|
"output_tokens": 1000,
|
|
"iterations": [
|
|
{"type": "compaction", "input_tokens": 180000, "output_tokens": 3500},
|
|
{"type": "message", "input_tokens": 23000, "output_tokens": 1000},
|
|
],
|
|
}
|
|
)
|
|
|
|
assert input_tokens == 203000
|
|
assert output_tokens == 4500
|
|
|
|
|
|
def test_anthropic_message_payload_replays_compaction_block_with_cache_control() -> None:
|
|
payload = _build_message_payload(
|
|
Message(
|
|
role="assistant",
|
|
content=[
|
|
ContentBlockCompaction(
|
|
content="summary text",
|
|
cache_control={"type": "ephemeral"},
|
|
)
|
|
],
|
|
)
|
|
)
|
|
|
|
assert payload == {
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "compaction",
|
|
"content": "summary text",
|
|
"cache_control": {"type": "ephemeral"},
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
def _sse_body(events: list[dict]) -> bytes:
|
|
parts = []
|
|
for ev in events:
|
|
parts.append(f"event: {ev['type']}\n".encode())
|
|
parts.append(f"data: {json.dumps(ev)}\n\n".encode())
|
|
return b"".join(parts)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("provider_id", "expected_base_url"),
|
|
[
|
|
("minimax", "https://api.minimaxi.com/anthropic"),
|
|
("minimax_global", "https://api.minimax.io/anthropic"),
|
|
("volcengine_coding_plan_anthropic", "https://ark.cn-beijing.volces.com/api/coding"),
|
|
(
|
|
"byteplus_coding_plan_anthropic",
|
|
"https://ark.ap-southeast.bytepluses.com/api/coding",
|
|
),
|
|
(
|
|
"tencent_token_plan_anthropic",
|
|
"https://api.lkeap.cloud.tencent.com/plan/anthropic",
|
|
),
|
|
],
|
|
)
|
|
def test_anthropic_compatible_endpoints_use_authorization_bearer(
|
|
monkeypatch,
|
|
provider_id: str,
|
|
expected_base_url: str,
|
|
) -> None:
|
|
"""Registry-built Anthropic-compatible providers sign with Authorization: Bearer.
|
|
|
|
The auth style comes from the ProviderSpec (auth_header_style) rather
|
|
than a base-url sniff, so the proof drives the production build path.
|
|
"""
|
|
captured: dict[str, object] = {}
|
|
body = _sse_body(
|
|
[
|
|
{
|
|
"type": "message_start",
|
|
"message": {"id": "msg_1", "model": "MiniMax-M2.7", "usage": {}},
|
|
},
|
|
{"type": "message_stop"},
|
|
]
|
|
)
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["url"] = str(request.url)
|
|
captured["headers"] = request.headers
|
|
return httpx.Response(
|
|
200,
|
|
headers={"content-type": "text/event-stream"},
|
|
content=body,
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def patched_async_client(*args, **kwargs):
|
|
kwargs["transport"] = transport
|
|
return real_async_client(*args, **kwargs)
|
|
|
|
monkeypatch.setattr("opensquilla.provider.anthropic.httpx.AsyncClient", patched_async_client)
|
|
|
|
provider = build_provider(
|
|
provider=provider_id,
|
|
model="MiniMax-M2.7",
|
|
api_key="test-key",
|
|
)
|
|
assert isinstance(provider, AnthropicProvider)
|
|
|
|
async def _collect() -> None:
|
|
async for _ in provider.chat([Message(role="user", content="hi")], config=ChatConfig()):
|
|
pass
|
|
|
|
asyncio.run(_collect())
|
|
|
|
headers = captured["headers"]
|
|
assert captured["url"] == f"{expected_base_url}/v1/messages"
|
|
assert headers["Authorization"] == "Bearer test-key"
|
|
assert "x-api-key" not in headers
|
|
|
|
|
|
def test_direct_construction_defaults_to_x_api_key(monkeypatch) -> None:
|
|
"""Bare AnthropicProvider construction keeps Anthropic-proper auth."""
|
|
captured: dict[str, object] = {}
|
|
body = _sse_body(
|
|
[
|
|
{
|
|
"type": "message_start",
|
|
"message": {"id": "msg_1", "model": "claude-test", "usage": {}},
|
|
},
|
|
{"type": "message_stop"},
|
|
]
|
|
)
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["headers"] = request.headers
|
|
return httpx.Response(
|
|
200,
|
|
headers={"content-type": "text/event-stream"},
|
|
content=body,
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def patched_async_client(*args, **kwargs):
|
|
kwargs["transport"] = transport
|
|
return real_async_client(*args, **kwargs)
|
|
|
|
monkeypatch.setattr("opensquilla.provider.anthropic.httpx.AsyncClient", patched_async_client)
|
|
|
|
provider = AnthropicProvider(api_key="test-key", model="claude-test")
|
|
|
|
async def _collect() -> None:
|
|
async for _ in provider.chat([Message(role="user", content="hi")], config=ChatConfig()):
|
|
pass
|
|
|
|
asyncio.run(_collect())
|
|
|
|
headers = captured["headers"]
|
|
assert headers["x-api-key"] == "test-key"
|
|
assert "Authorization" not in headers
|
|
|
|
|
|
def test_anthropic_done_event_carries_cache_write_tokens(monkeypatch) -> None:
|
|
"""End-to-end: SSE usage populates DoneEvent.cache_write_tokens."""
|
|
|
|
sse_events = [
|
|
{
|
|
"type": "message_start",
|
|
"message": {
|
|
"id": "msg_1",
|
|
"model": "claude-opus-4-7",
|
|
"usage": {
|
|
"input_tokens": 10,
|
|
"cache_read_input_tokens": 1000,
|
|
"cache_creation_input_tokens": 500,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"type": "content_block_start",
|
|
"index": 0,
|
|
"content_block": {"type": "text", "text": ""},
|
|
},
|
|
{
|
|
"type": "content_block_delta",
|
|
"index": 0,
|
|
"delta": {"type": "text_delta", "text": "ok"},
|
|
},
|
|
{"type": "content_block_stop", "index": 0},
|
|
{
|
|
"type": "message_delta",
|
|
"delta": {"stop_reason": "end_turn"},
|
|
"usage": {
|
|
"output_tokens": 5,
|
|
"cache_read_input_tokens": 1000,
|
|
"cache_creation_input_tokens": 500,
|
|
},
|
|
},
|
|
{"type": "message_stop"},
|
|
]
|
|
|
|
body = _sse_body(sse_events)
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(
|
|
200,
|
|
headers={"content-type": "text/event-stream"},
|
|
content=body,
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def patched_async_client(*args, **kwargs):
|
|
kwargs["transport"] = transport
|
|
return real_async_client(*args, **kwargs)
|
|
|
|
monkeypatch.setattr("opensquilla.provider.anthropic.httpx.AsyncClient", patched_async_client)
|
|
|
|
provider = AnthropicProvider(api_key="test", model="claude-opus-4-7")
|
|
|
|
async def _collect() -> DoneEvent:
|
|
done: DoneEvent | None = None
|
|
async for ev in provider.chat([Message(role="user", content="hi")], config=ChatConfig()):
|
|
if isinstance(ev, DoneEvent):
|
|
done = ev
|
|
assert done is not None
|
|
return done
|
|
|
|
done = asyncio.run(_collect())
|
|
assert done.cached_tokens == 1000
|
|
assert done.cache_write_tokens == 500
|
|
|
|
|
|
def test_anthropic_provider_writes_llm_trace(monkeypatch, tmp_path) -> None:
|
|
trace_path = tmp_path / "anthropic-llm-calls.jsonl"
|
|
monkeypatch.setenv("OPENSQUILLA_LLM_TRACE_RECORDER", "full")
|
|
monkeypatch.setenv("OPENSQUILLA_LLM_TRACE_PATH", str(trace_path))
|
|
sse_events = [
|
|
{
|
|
"type": "message_start",
|
|
"message": {
|
|
"id": "msg_1",
|
|
"model": "claude-opus-4-7",
|
|
"usage": {"input_tokens": 10},
|
|
},
|
|
},
|
|
{
|
|
"type": "content_block_start",
|
|
"index": 0,
|
|
"content_block": {"type": "text", "text": ""},
|
|
},
|
|
{
|
|
"type": "content_block_delta",
|
|
"index": 0,
|
|
"delta": {"type": "text_delta", "text": "ok"},
|
|
},
|
|
{"type": "content_block_stop", "index": 0},
|
|
{
|
|
"type": "message_delta",
|
|
"delta": {"stop_reason": "end_turn"},
|
|
"usage": {"output_tokens": 5},
|
|
},
|
|
{"type": "message_stop"},
|
|
]
|
|
body = _sse_body(sse_events)
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(
|
|
200,
|
|
headers={"content-type": "text/event-stream"},
|
|
content=body,
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def patched_async_client(*args, **kwargs):
|
|
kwargs["transport"] = transport
|
|
return real_async_client(*args, **kwargs)
|
|
|
|
monkeypatch.setattr("opensquilla.provider.anthropic.httpx.AsyncClient", patched_async_client)
|
|
provider = AnthropicProvider(api_key="test", model="claude-opus-4-7")
|
|
|
|
async def _collect() -> list[object]:
|
|
return [
|
|
event
|
|
async for event in provider.chat(
|
|
[Message(role="user", content="hi")],
|
|
config=ChatConfig(),
|
|
)
|
|
]
|
|
|
|
events = asyncio.run(_collect())
|
|
|
|
assert any(isinstance(event, DoneEvent) for event in events)
|
|
rows = [json.loads(line) for line in trace_path.read_text(encoding="utf-8").splitlines()]
|
|
assert rows[0]["event"] == "llm.request"
|
|
assert rows[0]["headers"]["x-api-key"] == "[REDACTED]"
|
|
assert any(row["event"] == "llm.response_chunk" for row in rows)
|
|
assert rows[-1]["event"] == "llm.response"
|
|
assert rows[-1]["assistant_text"] == "ok"
|
|
assert rows[-1]["response_ids"] == ["msg_1"]
|
|
|
|
|
|
def test_anthropic_done_event_includes_compaction_iteration_usage(monkeypatch) -> None:
|
|
sse_events = [
|
|
{
|
|
"type": "message_start",
|
|
"message": {
|
|
"id": "msg_1",
|
|
"model": "claude-opus-4-7",
|
|
"usage": {"input_tokens": 10},
|
|
},
|
|
},
|
|
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
|
|
{"type": "content_block_stop", "index": 0},
|
|
{
|
|
"type": "message_delta",
|
|
"delta": {"stop_reason": "end_turn"},
|
|
"usage": {
|
|
"input_tokens": 23,
|
|
"output_tokens": 5,
|
|
"iterations": [
|
|
{"type": "compaction", "input_tokens": 100, "output_tokens": 7},
|
|
{"type": "message", "input_tokens": 23, "output_tokens": 5},
|
|
],
|
|
},
|
|
},
|
|
{"type": "message_stop"},
|
|
]
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(
|
|
200,
|
|
headers={"content-type": "text/event-stream"},
|
|
content=_sse_body(sse_events),
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def patched_async_client(*args, **kwargs):
|
|
kwargs["transport"] = transport
|
|
return real_async_client(*args, **kwargs)
|
|
|
|
monkeypatch.setattr("opensquilla.provider.anthropic.httpx.AsyncClient", patched_async_client)
|
|
|
|
provider = AnthropicProvider(api_key="test", model="claude-opus-4-7")
|
|
|
|
async def _collect() -> DoneEvent:
|
|
done: DoneEvent | None = None
|
|
async for ev in provider.chat([Message(role="user", content="hi")], config=ChatConfig()):
|
|
if isinstance(ev, DoneEvent):
|
|
done = ev
|
|
assert done is not None
|
|
return done
|
|
|
|
done = asyncio.run(_collect())
|
|
assert done.input_tokens == 123
|
|
assert done.output_tokens == 12
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"creation_payload,expected",
|
|
[
|
|
({"cache_creation_input_tokens": 250}, 250),
|
|
(
|
|
{"cache_creation": {"ephemeral_5m_input_tokens": 100, "ephemeral_1h_input_tokens": 50}},
|
|
150,
|
|
),
|
|
],
|
|
)
|
|
def test_anthropic_done_event_cache_write_handles_both_shapes(
|
|
monkeypatch, creation_payload, expected
|
|
) -> None:
|
|
sse_events = [
|
|
{
|
|
"type": "message_start",
|
|
"message": {
|
|
"id": "msg_1",
|
|
"model": "claude-opus-4-7",
|
|
"usage": {"input_tokens": 1, **creation_payload},
|
|
},
|
|
},
|
|
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
|
|
{"type": "content_block_stop", "index": 0},
|
|
{
|
|
"type": "message_delta",
|
|
"delta": {"stop_reason": "end_turn"},
|
|
"usage": {"output_tokens": 1, **creation_payload},
|
|
},
|
|
{"type": "message_stop"},
|
|
]
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(
|
|
200,
|
|
headers={"content-type": "text/event-stream"},
|
|
content=_sse_body(sse_events),
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def patched_async_client(*args, **kwargs):
|
|
kwargs["transport"] = transport
|
|
return real_async_client(*args, **kwargs)
|
|
|
|
monkeypatch.setattr("opensquilla.provider.anthropic.httpx.AsyncClient", patched_async_client)
|
|
|
|
provider = AnthropicProvider(api_key="test", model="claude-opus-4-7")
|
|
|
|
async def _collect() -> DoneEvent:
|
|
done: DoneEvent | None = None
|
|
async for ev in provider.chat([Message(role="user", content="hi")], config=ChatConfig()):
|
|
if isinstance(ev, DoneEvent):
|
|
done = ev
|
|
assert done is not None
|
|
return done
|
|
|
|
done = asyncio.run(_collect())
|
|
assert done.cache_write_tokens == expected
|
|
|
|
|
|
def test_anthropic_http_error_with_non_utf8_body_yields_error_event(monkeypatch) -> None:
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(
|
|
429,
|
|
headers={"content-type": "application/json"},
|
|
content=b"\xffrate limited",
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def patched_async_client(*args, **kwargs):
|
|
kwargs["transport"] = transport
|
|
return real_async_client(*args, **kwargs)
|
|
|
|
monkeypatch.setattr("opensquilla.provider.anthropic.httpx.AsyncClient", patched_async_client)
|
|
provider = AnthropicProvider(api_key="test", model="claude-opus-4-7")
|
|
|
|
async def _collect() -> list[object]:
|
|
return [
|
|
event
|
|
async for event in provider.chat(
|
|
[Message(role="user", content="hi")],
|
|
config=ChatConfig(),
|
|
)
|
|
]
|
|
|
|
events = asyncio.run(_collect())
|
|
|
|
assert len(events) == 1
|
|
error = events[0]
|
|
assert isinstance(error, ErrorEvent)
|
|
assert error.code == "429"
|
|
assert error.message.startswith("HTTP 429:")
|
|
|
|
|
|
def test_list_models_rows_match_retired_known_models_table() -> None:
|
|
# list_models now builds its rows from the shared catalog's corrections
|
|
# data instead of the retired _KNOWN_MODELS adapter table. These literals
|
|
# are the exact rows that table produced (per-1k costs on the wire).
|
|
expected = [
|
|
ModelInfo(
|
|
provider="anthropic",
|
|
model_id="claude-opus-4-6",
|
|
display_name="Claude Opus 4.6",
|
|
context_window=200000,
|
|
max_output_tokens=32000,
|
|
input_cost_per_1k=0.005,
|
|
output_cost_per_1k=0.025,
|
|
),
|
|
ModelInfo(
|
|
provider="anthropic",
|
|
model_id="claude-sonnet-4-6",
|
|
display_name="Claude Sonnet 4.6",
|
|
context_window=200000,
|
|
max_output_tokens=16000,
|
|
input_cost_per_1k=0.003,
|
|
output_cost_per_1k=0.015,
|
|
),
|
|
ModelInfo(
|
|
provider="anthropic",
|
|
model_id="claude-haiku-4-5-20251001",
|
|
display_name="Claude Haiku 4.5",
|
|
context_window=200000,
|
|
max_output_tokens=8192,
|
|
input_cost_per_1k=0.001,
|
|
output_cost_per_1k=0.005,
|
|
),
|
|
]
|
|
|
|
rows = asyncio.run(AnthropicProvider(api_key="test").list_models())
|
|
|
|
assert rows == expected
|