mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 04:49:26 +00:00
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.
689 lines
24 KiB
Python
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)
|