Files
openworker/tests/test_anthropic_provider.py
T
Rohit C Prasad 8991d303e0 Enable prompt caching on the Anthropic provider
Two ephemeral breakpoints per request (last system block, final message's
last block) so append-only history re-reads the prior turns' cache;
outbound-only, persisted history stays clean.
2026-07-27 22:47:30 -07:00

689 lines
24 KiB
Python

"""Native Anthropic provider — message/tool conversion, complete(), stream(). SDK-free:
the fake client mimics the `anthropic` SDK's `messages.create` surface with SimpleNamespace
objects, the same pattern test_providers.py uses for the OpenAI SDK."""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from coworker.providers import AnthropicProvider, capabilities_for
from coworker.providers.anthropic_provider import (
DEFAULT_MAX_TOKENS,
convert_messages,
convert_tools,
)
# -- fakes ------------------------------------------------------------------------
class _FakeClient:
"""Records the kwargs passed to messages.create and returns a canned response
(or an event iterator when stream=True)."""
def __init__(self, response=None, events=None):
self.kwargs: dict = {}
def create(**kwargs):
self.kwargs = kwargs
if kwargs.get("stream"):
return iter(events or [])
return response
self.messages = SimpleNamespace(create=create)
# Fable/Mythos route through the beta endpoint (server-side refusal fallback).
self.beta = SimpleNamespace(messages=SimpleNamespace(create=create))
def _text_response(text="hello", stop_reason="end_turn"):
return SimpleNamespace(
content=[SimpleNamespace(type="text", text=text)],
stop_reason=stop_reason,
)
# -- message conversion -------------------------------------------------------------
def test_convert_extracts_leading_system():
system, msgs = convert_messages(
[
{"role": "system", "content": "be helpful"},
{"role": "user", "content": "hi"},
]
)
assert system == "be helpful"
assert msgs == [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]
def test_convert_assistant_tool_turn_skips_empty_text():
# The engine persists pure tool turns with content="" — no empty text block may leak.
_, msgs = convert_messages(
[
{"role": "user", "content": "do it"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "c1",
"type": "function",
"function": {"name": "f", "arguments": '{"x": 1}'},
}
],
},
]
)
blocks = msgs[1]["content"]
assert blocks == [{"type": "tool_use", "id": "c1", "name": "f", "input": {"x": 1}}]
def test_convert_merges_tool_result_run_into_one_user_message():
# N parallel calls → N consecutive role:"tool" messages → ONE Anthropic user message.
_, msgs = convert_messages(
[
{"role": "user", "content": "go"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "c1",
"type": "function",
"function": {"name": "a", "arguments": "{}"},
},
{
"id": "c2",
"type": "function",
"function": {"name": "b", "arguments": "{}"},
},
],
},
{"role": "tool", "tool_call_id": "c1", "content": "r1"},
{"role": "tool", "tool_call_id": "c2", "content": "r2"},
]
)
assert [m["role"] for m in msgs] == ["user", "assistant", "user"]
results = msgs[2]["content"]
assert [b["type"] for b in results] == ["tool_result", "tool_result"]
assert [b["tool_use_id"] for b in results] == ["c1", "c2"]
assert [b["content"] for b in results] == ["r1", "r2"]
def test_convert_steering_user_message_merges_after_tool_results():
# Steering appends a user message after tool results; it must land in the SAME user
# message, after the tool_result blocks.
_, msgs = convert_messages(
[
{"role": "user", "content": "go"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "c1",
"type": "function",
"function": {"name": "a", "arguments": "{}"},
}
],
},
{"role": "tool", "tool_call_id": "c1", "content": "r1"},
{"role": "user", "content": "actually, stop"},
]
)
blocks = msgs[2]["content"]
assert [b["type"] for b in blocks] == ["tool_result", "text"]
assert blocks[1]["text"] == "actually, stop"
def test_convert_image_data_url_to_base64_source():
_, msgs = convert_messages(
[
{
"role": "user",
"content": [
{"type": "text", "text": "what is this"},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,iVBORw0KGgo="},
},
],
}
]
)
img = msgs[0]["content"][1]
assert img == {
"type": "image",
"source": {"type": "base64", "media_type": "image/png", "data": "iVBORw0KGgo="},
}
def test_convert_image_http_url_and_malformed():
_, msgs = convert_messages(
[
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": "https://x/y.png"}},
{"type": "image_url", "image_url": {"url": "not-a-url"}},
],
}
]
)
blocks = msgs[0]["content"]
assert blocks[0]["source"] == {"type": "url", "url": "https://x/y.png"}
assert blocks[1] == {"type": "text", "text": "[unsupported image attachment]"}
def test_convert_drops_empty_assistant_and_guards_first_user():
_, msgs = convert_messages(
[
{"role": "assistant", "content": ""}, # fully empty → dropped
{"role": "assistant", "content": "hi"},
]
)
# the surviving assistant message can't be first → "(continued)" user is prepended
assert msgs[0] == {
"role": "user",
"content": [{"type": "text", "text": "(continued)"}],
}
assert msgs[1]["role"] == "assistant"
def test_convert_empty_history_raises():
with pytest.raises(ValueError):
convert_messages([{"role": "assistant", "content": ""}])
def test_convert_malformed_tool_arguments_fall_back_to_raw():
_, msgs = convert_messages(
[
{"role": "user", "content": "go"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "c1",
"type": "function",
"function": {"name": "a", "arguments": "{bad"},
}
],
},
]
)
assert msgs[1]["content"][0]["input"] == {"_raw": "{bad"}
# -- tool schema conversion ----------------------------------------------------------
def test_convert_tools_handles_missing_description_and_parameters():
tools = convert_tools(
[
{"type": "function", "function": {"name": "bare"}},
{
"type": "function",
"function": {
"name": "full",
"description": "does things",
"parameters": {
"type": "object",
"properties": {"x": {"type": "integer"}},
},
},
},
]
)
assert tools[0] == {
"name": "bare",
"input_schema": {"type": "object", "properties": {}},
}
assert tools[1]["description"] == "does things"
assert tools[1]["input_schema"]["properties"] == {"x": {"type": "integer"}}
# -- complete() ----------------------------------------------------------------------
def test_complete_text_turn_with_defaults():
fake = _FakeClient(response=_text_response("hello"))
provider = AnthropicProvider(client=fake)
turn = provider.complete(
model="claude-sonnet-4-6",
messages=[
{"role": "system", "content": "sys"},
{"role": "user", "content": "hi"},
],
)
assert (
turn.text == "hello"
and turn.finish_reason == "stop"
and not turn.has_tool_calls
)
assert fake.kwargs["model"] == "claude-sonnet-4-6"
# System rides as a block list so the last block can carry the prompt-cache
# breakpoint (see _add_cache_breakpoints).
assert fake.kwargs["system"] == [
{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}
]
assert fake.kwargs["max_tokens"] == DEFAULT_MAX_TOKENS # required param, injected
assert "tools" not in fake.kwargs
def test_complete_parses_tool_use_blocks():
response = SimpleNamespace(
content=[
SimpleNamespace(type="text", text="on it"),
SimpleNamespace(
type="tool_use", id="c1", name="write_file", input={"path": "a.txt"}
),
],
stop_reason="tool_use",
)
provider = AnthropicProvider(client=_FakeClient(response=response))
turn = provider.complete(model="m", messages=[{"role": "user", "content": "go"}])
assert turn.text == "on it"
assert turn.finish_reason == "tool_calls"
assert turn.tool_calls[0].id == "c1"
assert turn.tool_calls[0].name == "write_file"
assert turn.tool_calls[0].arguments == {"path": "a.txt"}
@pytest.mark.parametrize(
"stop_reason,expected",
[
("end_turn", "stop"),
("tool_use", "tool_calls"),
("max_tokens", "length"),
("stop_sequence", "stop"),
# "refusal" no longer maps — it raises (see test_whole_chain_refusal_raises…)
("something_new", "something_new"), # unknown passes through
],
)
def test_complete_maps_stop_reasons(stop_reason, expected):
provider = AnthropicProvider(
client=_FakeClient(response=_text_response(stop_reason=stop_reason))
)
turn = provider.complete(model="m", messages=[{"role": "user", "content": "x"}])
assert turn.finish_reason == expected
def test_complete_filters_and_aliases_settings():
fake = _FakeClient(response=_text_response())
provider = AnthropicProvider(client=fake)
provider.complete(
model="m",
messages=[{"role": "user", "content": "x"}],
temperature=0.2,
max_tokens=512, # explicit beats the default
stop="END", # OpenAI alias → stop_sequences
frequency_penalty=0.5, # not a Messages API param → dropped
)
assert fake.kwargs["temperature"] == 0.2
assert fake.kwargs["max_tokens"] == 512
assert fake.kwargs["stop_sequences"] == ["END"]
assert "frequency_penalty" not in fake.kwargs and "stop" not in fake.kwargs
def test_complete_converts_tools():
fake = _FakeClient(response=_text_response())
provider = AnthropicProvider(client=fake)
provider.complete(
model="m",
messages=[{"role": "user", "content": "x"}],
tools=[
{
"type": "function",
"function": {
"name": "f",
"description": "d",
"parameters": {"type": "object"},
},
}
],
)
assert fake.kwargs["tools"] == [
{"name": "f", "description": "d", "input_schema": {"type": "object"}}
]
def test_ensure_client_without_key_raises(monkeypatch):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
with pytest.raises(RuntimeError, match="Anthropic"):
AnthropicProvider()._ensure_client()
# -- stream() ------------------------------------------------------------------------
def _delta(index, **delta_attrs):
return SimpleNamespace(
type="content_block_delta", index=index, delta=SimpleNamespace(**delta_attrs)
)
def test_stream_yields_text_deltas_then_final_turn():
events = [
SimpleNamespace(type="message_start"),
SimpleNamespace(
type="content_block_start",
index=0,
content_block=SimpleNamespace(type="text"),
),
_delta(0, type="text_delta", text="hel"),
_delta(0, type="text_delta", text="lo"),
SimpleNamespace(type="content_block_stop", index=0),
SimpleNamespace(
type="message_delta", delta=SimpleNamespace(stop_reason="end_turn")
),
SimpleNamespace(type="message_stop"),
]
provider = AnthropicProvider(client=_FakeClient(events=events))
chunks = list(
provider.stream(model="m", messages=[{"role": "user", "content": "x"}])
)
assert [c.text_delta for c in chunks[:-1]] == ["hel", "lo"]
final = chunks[-1].turn
assert (
final.text == "hello"
and final.finish_reason == "stop"
and not final.has_tool_calls
)
def test_stream_accumulates_split_tool_json():
events = [
SimpleNamespace(
type="content_block_start",
index=0,
content_block=SimpleNamespace(type="tool_use", id="c1", name="write_file"),
),
_delta(0, type="input_json_delta", partial_json='{"path": "a'),
_delta(0, type="input_json_delta", partial_json='.txt", "content": "hi"}'),
SimpleNamespace(type="content_block_stop", index=0),
SimpleNamespace(
type="message_delta", delta=SimpleNamespace(stop_reason="tool_use")
),
]
provider = AnthropicProvider(client=_FakeClient(events=events))
chunks = list(
provider.stream(model="m", messages=[{"role": "user", "content": "x"}])
)
final = chunks[-1].turn
assert final.finish_reason == "tool_calls"
assert final.tool_calls[0].id == "c1"
assert final.tool_calls[0].arguments == {"path": "a.txt", "content": "hi"}
def test_stream_mixed_text_and_tool_blocks():
events = [
SimpleNamespace(
type="content_block_start",
index=0,
content_block=SimpleNamespace(type="text"),
),
_delta(0, type="text_delta", text="working"),
SimpleNamespace(
type="content_block_start",
index=1,
content_block=SimpleNamespace(type="tool_use", id="c1", name="f"),
),
_delta(1, type="input_json_delta", partial_json=""), # no-args tool call
SimpleNamespace(
type="message_delta", delta=SimpleNamespace(stop_reason="tool_use")
),
]
provider = AnthropicProvider(client=_FakeClient(events=events))
chunks = list(
provider.stream(model="m", messages=[{"role": "user", "content": "x"}])
)
assert chunks[0].text_delta == "working"
final = chunks[-1].turn
assert final.text == "working"
assert final.tool_calls[0].arguments == {} # empty json → {}
def test_stream_passes_stream_flag():
fake = _FakeClient(events=[])
provider = AnthropicProvider(client=fake)
list(provider.stream(model="m", messages=[{"role": "user", "content": "x"}]))
assert fake.kwargs["stream"] is True
assert fake.kwargs["max_tokens"] == DEFAULT_MAX_TOKENS
# -- registry / capabilities ----------------------------------------------------------
def test_registry_builds_native_anthropic_provider():
from coworker.providers.registry import build_provider_client
provider = build_provider_client("anthropic", {"api_key": "sk-ant-x"}, None)
assert isinstance(provider, AnthropicProvider)
assert provider._api_key == "sk-ant-x"
# no key in the profile is fine at build time — resolution is deferred to first call
assert isinstance(build_provider_client("anthropic", {}, None), AnthropicProvider)
def test_resolve_api_key_env_then_secrets(monkeypatch):
from coworker.providers.anthropic_provider import resolve_api_key
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-env")
assert resolve_api_key() == "sk-ant-env"
monkeypatch.delenv("ANTHROPIC_API_KEY")
class _Secrets:
def get(self, name):
return (
{"api_key": "sk-ant-stored"} if name == "provider:anthropic" else None
)
assert resolve_api_key(_Secrets()) == "sk-ant-stored"
assert resolve_api_key(None) is None
def test_anthropic_capabilities_parallel_tool_calls():
caps = capabilities_for("anthropic:claude-sonnet-4-6")
assert caps.tools and caps.vision and caps.streaming
assert caps.parallel_tool_calls is True # native provider folds results correctly
def test_convert_pdf_file_part_to_document_block():
_, msgs = convert_messages(
[
{
"role": "user",
"content": [
{"type": "text", "text": "summarize"},
{
"type": "file",
"file": {
"filename": "report.pdf",
"file_data": "data:application/pdf;base64,JVBERi0=",
},
},
],
}
]
)
doc = msgs[0]["content"][1]
assert doc == {
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": "JVBERi0=",
},
"title": "report.pdf",
}
def test_convert_malformed_file_part_becomes_text_note():
_, msgs = convert_messages(
[
{
"role": "user",
"content": [{"type": "file", "file": {"file_data": "not-a-url"}}],
}
]
)
assert msgs[0]["content"][0] == {
"type": "text",
"text": "[unsupported file attachment]",
}
# -- extended thinking (model-layer roadmap item 4, phase 3) ------------------------
def test_thinking_config_is_model_family_aware():
"""API drift (owner-hit 2026-07-23): budget_tokens 400s on the Claude 5/4.7+ family
('use thinking.type.adaptive'); older models still need enabled+budget. display
must be summarized on the new family or the trace text arrives empty."""
for model in ("claude-fable-5", "claude-opus-4-8", "claude-sonnet-4-6"):
client = _FakeClient(response=_text_response())
AnthropicProvider(client=client, thinking_budget=8192).complete(
model=model,
messages=[{"role": "user", "content": "x"}],
temperature=0.2,
top_p=0.9,
)
assert client.kwargs["thinking"] == {"type": "adaptive", "display": "summarized"}, model
assert "budget_tokens" not in str(client.kwargs["thinking"]), model
assert "temperature" not in client.kwargs and "top_p" not in client.kwargs, model
client = _FakeClient(response=_text_response())
AnthropicProvider(client=client, thinking_budget=8192).complete(
model="claude-haiku-4-5",
messages=[{"role": "user", "content": "x"}],
temperature=0.2,
)
assert client.kwargs["thinking"] == {"type": "enabled", "budget_tokens": 8192}
assert client.kwargs["max_tokens"] > 8192
assert "temperature" not in client.kwargs
# Off (hidden override 0): no thinking param on any family.
client2 = _FakeClient(response=_text_response())
AnthropicProvider(client=client2).complete(
model="claude-fable-5", messages=[{"role": "user", "content": "x"}]
)
assert "thinking" not in client2.kwargs
def test_complete_parses_thinking_blocks_into_reasoning_and_sidecar():
response = SimpleNamespace(
content=[
SimpleNamespace(type="thinking", thinking="pondering...", signature="SIG1"),
SimpleNamespace(type="redacted_thinking", data="OPAQUE"),
SimpleNamespace(type="text", text="the answer"),
],
stop_reason="end_turn",
)
provider = AnthropicProvider(client=_FakeClient(response=response), thinking_budget=4096)
turn = provider.complete(model="claude-fable-5", messages=[{"role": "user", "content": "x"}])
assert turn.text == "the answer"
assert turn.reasoning == "pondering..." # redacted stays out of the display text
assert turn.extras["_anthropic"]["blocks"] == [
{"type": "thinking", "thinking": "pondering...", "signature": "SIG1"},
{"type": "redacted_thinking", "data": "OPAQUE"},
]
def test_stream_accumulates_thinking_and_signature():
events = [
SimpleNamespace(
type="content_block_start",
index=0,
content_block=SimpleNamespace(type="thinking", thinking="", signature=""),
),
_delta(0, type="thinking_delta", thinking="step one. "),
_delta(0, type="thinking_delta", thinking="step two."),
_delta(0, type="signature_delta", signature="SIGSTREAM"),
SimpleNamespace(type="content_block_stop", index=0),
SimpleNamespace(
type="content_block_start",
index=1,
content_block=SimpleNamespace(type="text"),
),
_delta(1, type="text_delta", text="done"),
SimpleNamespace(
type="message_delta", delta=SimpleNamespace(stop_reason="end_turn")
),
]
provider = AnthropicProvider(client=_FakeClient(events=events), thinking_budget=4096)
chunks = list(provider.stream(model="m", messages=[{"role": "user", "content": "x"}]))
assert [c.reasoning_delta for c in chunks if c.reasoning_delta] == ["step one. ", "step two."]
final = chunks[-1].turn
assert final.text == "done" and final.reasoning == "step one. step two."
assert final.extras["_anthropic"]["blocks"] == [
{"type": "thinking", "thinking": "step one. step two.", "signature": "SIGSTREAM"}
]
def test_convert_replays_thinking_blocks_ahead_of_tool_use():
_, msgs = convert_messages(
[
{"role": "user", "content": "go"},
{
"role": "assistant",
"content": "",
"_anthropic": {
"blocks": [
{"type": "thinking", "thinking": "plan", "signature": "S"},
]
},
"tool_calls": [
{"id": "t1", "type": "function", "function": {"name": "a", "arguments": "{}"}},
],
},
{"role": "tool", "tool_call_id": "t1", "content": "ok"},
]
)
assistant = msgs[1]["content"]
assert assistant[0] == {"type": "thinking", "thinking": "plan", "signature": "S"}
assert assistant[1]["type"] == "tool_use"
def test_thinking_defaults_on_with_hidden_profile_override():
"""No user-facing setting (owner call 2026-07-23): thinking is ON by default; the
profile's thinking_budget stays a hidden override, 0 = off."""
from coworker.providers.anthropic_provider import DEFAULT_THINKING_BUDGET
from coworker.providers.registry import build_provider_client
assert build_provider_client("anthropic", {}, None).thinking_budget == DEFAULT_THINKING_BUDGET
assert build_provider_client("anthropic", {"thinking_budget": "2048"}, None).thinking_budget == 2048
assert build_provider_client("anthropic", {"thinking_budget": "0"}, None).thinking_budget == 0
def test_fable_requests_carry_server_side_fallback():
"""Fable/Mythos classifiers can decline benign-adjacent requests — every call opts
into the server-side fallback so Opus 4.8 re-serves declines in the same call."""
client = _FakeClient(response=_text_response())
AnthropicProvider(client=client, thinking_budget=8192).complete(
model="claude-fable-5", messages=[{"role": "user", "content": "x"}]
)
assert client.kwargs["betas"] == ["server-side-fallback-2026-06-01"]
assert client.kwargs["fallbacks"] == [{"model": "claude-opus-4-8"}]
# Other models stay on the plain endpoint with no fallback params.
client2 = _FakeClient(response=_text_response())
AnthropicProvider(client=client2).complete(
model="claude-opus-4-8", messages=[{"role": "user", "content": "x"}]
)
assert "betas" not in client2.kwargs and "fallbacks" not in client2.kwargs
def test_whole_chain_refusal_raises_friendly_error():
"""A refusal that survives the fallback chain must surface as an error (notice +
Retry in the GUI) — not a silent blank reply (owner-hit 2026-07-23)."""
refused = SimpleNamespace(
content=[],
stop_reason="refusal",
stop_details=SimpleNamespace(category="cyber", explanation="blocked"),
)
provider = AnthropicProvider(client=_FakeClient(response=refused), thinking_budget=8192)
with pytest.raises(RuntimeError) as err:
provider.complete(model="claude-fable-5", messages=[{"role": "user", "content": "x"}])
assert "safety filter" in str(err.value) and "cyber" in str(err.value)