"""Request / response translation of :class:`AnthropicProvider`: headers, max_tokens, system, tools, thinking, stop reasons, usage and HTTP errors. Every call streams, so fixtures that describe a whole ``message`` arrive as the SSE frames that produce it. All HTTP traffic goes through ``respx``. """ from __future__ import annotations from typing import Any import httpx import pytest import respx from noeta.backends.providers.anthropic import AnthropicProvider from noeta.core import models from noeta.core.errors import ( ContextOverflow, Fatal, MalformedToolArguments, RateLimited, StreamBroken, Transient, ) from noeta.core.types import ( Message, SystemPart, Text, Thinking, ToolResult, ToolUse, Usage, user, ) from tests.backends.providers._anthropic_helpers import ( _UNKNOWN_MODEL, _VISION_MODEL, BASE_URL, EPHEMERAL, MESSAGES_ENDPOINT, _call, _provider, _req, _resp, _respond, _result, _send, _stream, _tool, register_text_only_model, ) from tests.backends.providers.sse import sse_response @pytest.fixture(autouse=True) def _text_only_catalog_row(monkeypatch: pytest.MonkeyPatch) -> None: register_text_only_model(monkeypatch) # --------------------------------------------------------------------------- # Plain text round-trip, headers, endpoint # --------------------------------------------------------------------------- @respx.mock async def test_plain_text_response_maps_to_end_turn_text() -> None: body, response = await _send() assert response.stop_reason == "end_turn" assert response.message == Message(role="assistant", content=[Text(text="ok")]) assert response.usage == Usage(uncached=10, output=5) assert response.model == "claude-opus-4-7" assert body["model"] == _UNKNOWN_MODEL assert body["messages"] == [{"role": "user", "content": "hi"}] @respx.mock async def test_request_uses_x_api_key_and_anthropic_version_headers() -> None: route = respx.post(MESSAGES_ENDPOINT).mock(return_value=_stream(_resp())) await _provider().complete(_req()) headers = route.calls.last.request.headers assert headers["x-api-key"] == "sk-ant-test" assert headers["anthropic-version"] == "2023-06-01" assert headers["content-type"] == "application/json" @respx.mock async def test_extra_headers_are_forwarded() -> None: route = respx.post(MESSAGES_ENDPOINT).mock(return_value=_stream(_resp())) await _provider(extra_headers={"anthropic-beta": "x-beta"}).complete(_req()) assert route.calls.last.request.headers["anthropic-beta"] == "x-beta" @respx.mock async def test_request_headers_merge_over_client_headers() -> None: """``ModelRequest.headers`` are per-call and merge over the constructor headers: the auth headers survive, a per-call header can override one.""" route = respx.post(MESSAGES_ENDPOINT).mock(return_value=_stream(_resp())) provider = _provider(extra_headers={"x-trace": "client"}) await provider.complete(_req(headers={"x-task-id": "t-1", "x-trace": "call"})) headers = route.calls.last.request.headers assert headers["x-task-id"] == "t-1" assert headers["x-trace"] == "call" assert headers["x-api-key"] == "sk-ant-test" def test_missing_api_key_raises_at_construction(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) with pytest.raises(ValueError, match="ANTHROPIC_API_KEY"): AnthropicProvider(base_url=BASE_URL) def test_api_key_defaults_to_environment(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("ANTHROPIC_API_KEY", "from-env") assert AnthropicProvider(base_url=BASE_URL).family == "anthropic" @respx.mock async def test_complete_sets_the_stream_flag_on_the_wire() -> None: body, _ = await _send() assert body["stream"] is True @respx.mock async def test_same_request_and_mock_produces_equal_response() -> None: _, first = await _send() _, second = await _send() assert first == second # --------------------------------------------------------------------------- # max_tokens # --------------------------------------------------------------------------- @respx.mock async def test_max_tokens_fail_fast_when_no_source() -> None: route = respx.post(MESSAGES_ENDPOINT).mock(return_value=_stream(_resp())) with pytest.raises(Fatal, match="requires max_tokens"): await _provider(default_max_tokens=None).complete(_req()) assert not route.called @respx.mock async def test_max_tokens_request_wins_over_default() -> None: body, _ = await _send(_req(max_tokens=123)) assert body["max_tokens"] == 123 @respx.mock async def test_max_tokens_uses_constructor_default_when_request_missing() -> None: body, _ = await _send(_req()) assert body["max_tokens"] == 4096 @respx.mock async def test_max_tokens_request_only_works_without_default() -> None: body, _ = await _send(_req(max_tokens=77), default_max_tokens=None) assert body["max_tokens"] == 77 @respx.mock async def test_max_tokens_falls_back_to_the_catalogued_default_output() -> None: body, _ = await _send(_req(model=_VISION_MODEL), default_max_tokens=None) spec = models.find_spec(_VISION_MODEL) assert spec is not None and body["max_tokens"] == spec.output_default == 64_000 # --------------------------------------------------------------------------- # tool_choice # --------------------------------------------------------------------------- @respx.mock async def test_tool_choice_string_wraps_to_vendor_shape() -> None: body, _ = await _send(_req(tool_choice="none")) assert body["tool_choice"] == {"type": "none"} @respx.mock async def test_tool_choice_absent_omits_field() -> None: body, _ = await _send() assert "tool_choice" not in body # --------------------------------------------------------------------------- # system # --------------------------------------------------------------------------- @respx.mock async def test_system_parts_become_top_level_text_blocks() -> None: body, _ = await _send(_req(system=[SystemPart(text="line1"), SystemPart(text="line2")])) assert body["system"] == [ {"type": "text", "text": "line1"}, {"type": "text", "text": "line2"}, ] assert all(msg["role"] != "system" for msg in body["messages"]) @respx.mock async def test_empty_system_part_is_not_sent() -> None: """The API rejects an empty text block; an empty part is simply skipped.""" body, _ = await _send(_req(system=[SystemPart(text=""), SystemPart(text="s", cache=True)])) assert body["system"] == [{"type": "text", "text": "s", "cache_control": EPHEMERAL}] @respx.mock async def test_the_one_hour_cache_marks_every_breakpoint_and_the_reply() -> None: request = _req(system=[SystemPart(text="s", cache=True)], messages=[user("hi")], cache_after=[0]) body, response = await _send(request, prompt_cache_ttl="1h") hour = {"type": "ephemeral", "ttl": "1h"} assert body["system"] == [{"type": "text", "text": "s", "cache_control": hour}] assert body["messages"][0]["content"][-1]["cache_control"] == hour assert response.cache_ttl == 3600 # The default sends the API's own five minutes, as before, and says nothing. default_body, default = await _send(request) assert default_body["system"][0]["cache_control"] == EPHEMERAL assert default.cache_ttl is None with pytest.raises(ValueError, match="prompt_cache_ttl"): _provider(prompt_cache_ttl="2h") @respx.mock async def test_no_system_omits_top_level_system_field() -> None: body, _ = await _send() assert "system" not in body # --------------------------------------------------------------------------- # Assistant content regrouping + tool use # --------------------------------------------------------------------------- @respx.mock async def test_assistant_content_regrouped_thinking_text_tool_use() -> None: """Assistant blocks are regrouped thinking* → text* → tool_use* whatever order the caller supplied; Anthropic rejects other orderings.""" assistant = Message( role="assistant", content=[ ToolUse(id="t1", name="echo", input={"x": 1}), Text(text="reasoning result"), Thinking(text="thinking...", signature="sig-1"), ], ) body, _ = await _send(_req(messages=[user("q"), assistant, user("then")])) assert body["messages"][1]["content"] == [ {"type": "thinking", "thinking": "thinking...", "signature": "sig-1"}, {"type": "text", "text": "reasoning result"}, {"type": "tool_use", "id": "t1", "name": "echo", "input": {"x": 1}}, ] @respx.mock async def test_assistant_content_stable_sort_within_group() -> None: assistant = Message( role="assistant", content=[ ToolUse(id="a", name="echo", input={}), Thinking(text="t1", signature="s1"), Text(text="x1"), ToolUse(id="b", name="echo", input={}), Thinking(text="t2", signature="s2"), Text(text="x2"), ], ) body, _ = await _send(_req(messages=[user("q"), assistant, user("then")])) content = body["messages"][1]["content"] assert [b.get("thinking") or b.get("text") or b.get("id") for b in content] == [ "t1", "t2", "x1", "x2", "a", "b", ] @respx.mock async def test_tool_use_outbound_translation() -> None: assistant = Message( role="assistant", content=[ToolUse(id="toolu_001", name="lookup", input={"q": "x"})] ) body, _ = await _send(_req(messages=[user("ask"), assistant])) assert body["messages"][1]["content"][0] == { "type": "tool_use", "id": "toolu_001", "name": "lookup", "input": {"q": "x"}, } @respx.mock async def test_tool_results_in_a_user_message_become_tool_result_blocks() -> None: results = Message( role="user", content=[ ToolResult(call_id="toolu_001", content=[Text(text="forty-two")]), ToolResult(call_id="toolu_002", content=[Text(text='{"k": "v"}')]), ], ) body, _ = await _send(_req(messages=[user("hi"), _call("toolu_001"), results])) turn = body["messages"][2] assert turn["role"] == "user" assert turn["content"] == [ {"type": "tool_result", "tool_use_id": "toolu_001", "content": "forty-two", "is_error": False}, {"type": "tool_result", "tool_use_id": "toolu_002", "content": '{"k": "v"}', "is_error": False}, ] @respx.mock async def test_tool_results_lead_a_mixed_user_turn() -> None: """Text written alongside tool results follows them: Anthropic wants the ``tool_result`` blocks first, answering the preceding ``tool_use``.""" mixed = Message( role="user", content=[Text(text="and also"), ToolResult(call_id="c1", content=[Text(text="r")])], ) body, _ = await _send(_req(messages=[user("q"), _call(), mixed])) assert [b["type"] for b in body["messages"][2]["content"]] == ["tool_result", "text"] @respx.mock async def test_failed_tool_result_carries_is_error() -> None: body, _ = await _send(_req(messages=[user("q"), _call("t"), _result("t", "boom", is_error=True)])) tr = body["messages"][2]["content"][0] assert tr["content"] == "boom" assert tr["is_error"] is True @respx.mock async def test_tool_result_text_neutralizes_reserved_tags() -> None: """A tool result must not be able to spell a host-turn boundary.""" forged = "xevil" body, _ = await _send(_req(messages=[user("q"), _call(), _result("c1", forged)])) content = body["messages"][2]["content"][0]["content"] assert "" not in content assert "</system-reminder>" in content @respx.mock async def test_tool_use_inbound_translation() -> None: response = await _respond( _resp( content=[{"type": "tool_use", "id": "toolu_xyz", "name": "lookup", "input": {"q": "x"}}], stop_reason="tool_use", ) ) assert response.stop_reason == "tool_use" assert response.message.content == [ToolUse(id="toolu_xyz", name="lookup", input={"q": "x"})] # --------------------------------------------------------------------------- # Thinking # --------------------------------------------------------------------------- @respx.mock async def test_thinking_block_inbound_translation() -> None: response = await _respond( _resp( content=[ {"type": "thinking", "thinking": "let me think", "signature": "sig-abc"}, {"type": "text", "text": "answer"}, ] ) ) assert response.message.content == [ Thinking(text="let me think", signature="sig-abc", family="anthropic"), Text(text="answer"), ] @respx.mock async def test_redacted_thinking_block_inbound_translation() -> None: response = await _respond( _resp(content=[{"type": "redacted_thinking", "data": "ENC"}, {"type": "text", "text": "a"}]) ) assert response.message.content[0] == Thinking(text="", data="ENC", family="anthropic") @respx.mock async def test_redacted_thinking_block_without_data_is_dropped() -> None: response = await _respond( _resp(content=[{"type": "redacted_thinking"}, {"type": "text", "text": "a"}]) ) assert response.message.content == [Text(text="a")] @respx.mock async def test_redacted_thinking_block_outbound_translation() -> None: assistant = Message( role="assistant", content=[Thinking(data="ENC", family="anthropic"), Text(text="a")] ) body, _ = await _send(_req(messages=[user("q"), assistant, user("n")])) assert body["messages"][1]["content"][0] == {"type": "redacted_thinking", "data": "ENC"} @respx.mock async def test_thinking_block_outbound_translation_with_signature() -> None: assistant = Message( role="assistant", content=[Thinking(text="hm", signature="sig"), Text(text="a")] ) body, _ = await _send(_req(messages=[user("q"), assistant, user("n")])) assert body["messages"][1]["content"][0] == {"type": "thinking", "thinking": "hm", "signature": "sig"} @respx.mock async def test_thinking_block_outbound_without_signature_omits_field() -> None: assistant = Message(role="assistant", content=[Thinking(text="hm"), Text(text="a")]) body, _ = await _send(_req(messages=[user("q"), assistant, user("n")])) assert body["messages"][1]["content"][0] == {"type": "thinking", "thinking": "hm"} @respx.mock async def test_foreign_family_thinking_is_dropped_outbound() -> None: """Reasoning another wire family produced never goes back on this one — its signature is not one Anthropic issued. Unknown provenance still goes.""" assistant = Message( role="assistant", content=[ Thinking(text="foreign", signature="enc", family="openai_responses"), Thinking(text="ours", signature="sig", family="anthropic"), Thinking(text="legacy", signature="old"), Text(text="a"), ], ) body, _ = await _send(_req(messages=[user("q"), assistant, user("n")])) thinking = [b["thinking"] for b in body["messages"][1]["content"] if b["type"] == "thinking"] assert thinking == ["ours", "legacy"] # --------------------------------------------------------------------------- # Tools # --------------------------------------------------------------------------- @respx.mock async def test_tool_specs_render_in_anthropic_shape() -> None: body, _ = await _send(_req(tools=[_tool("a", "first"), _tool("z", "")])) assert [{k: v for k, v in t.items() if k != "cache_control"} for t in body["tools"]] == [ {"name": "a", "description": "first", "input_schema": {"type": "object"}}, {"name": "z", "description": "", "input_schema": {"type": "object"}}, ] @respx.mock async def test_tools_empty_omits_tools_field() -> None: body, _ = await _send() assert "tools" not in body # --------------------------------------------------------------------------- # stop_reason mapping # --------------------------------------------------------------------------- @respx.mock @pytest.mark.parametrize( ("wire", "neutral"), [("end_turn", "end_turn"), ("max_tokens", "max_tokens"), ("stop_sequence", "end_turn")], ) async def test_stop_reason_mapping(wire: str, neutral: str) -> None: response = await _respond(_resp(stop_reason=wire)) assert response.stop_reason == neutral @respx.mock async def test_stop_reason_refusal_maps_to_refusal() -> None: """A safety-classifier refusal is a completed HTTP-200 turn; the refusal text reaches the caller as the turn's answer.""" response = await _respond( _resp(content=[{"type": "text", "text": "I can't help with that."}], stop_reason="refusal") ) assert response.stop_reason == "refusal" assert response.message.content == [Text(text="I can't help with that.")] @respx.mock async def test_context_window_exceeded_stop_reason_raises_overflow() -> None: """Overflow can arrive as an HTTP-200 ``stop_reason`` instead of a 400 body; it must surface exactly like the 400 path or compaction never sees it.""" respx.post(MESSAGES_ENDPOINT).mock( return_value=_stream(_resp(content=[], stop_reason="model_context_window_exceeded")) ) with pytest.raises(ContextOverflow): await _provider().complete(_req()) @respx.mock async def test_unknown_stop_reason_is_fatal() -> None: respx.post(MESSAGES_ENDPOINT).mock(return_value=_stream(_resp(stop_reason="weird-future-value"))) with pytest.raises(Fatal, match="weird-future-value"): await _provider().complete(_req()) @respx.mock async def test_pause_turn_is_not_silently_an_end_turn() -> None: respx.post(MESSAGES_ENDPOINT).mock(return_value=_stream(_resp(stop_reason="pause_turn"))) with pytest.raises(Fatal): await _provider().complete(_req()) @respx.mock async def test_missing_stop_reason_is_a_transient_cut_stream() -> None: respx.post(MESSAGES_ENDPOINT).mock(return_value=_stream(_resp(stop_reason=None))) with pytest.raises(Transient, match="no stop_reason"): await _provider().complete(_req()) @respx.mock async def test_inconsistent_tool_use_stop_without_tool_use_block_raises() -> None: respx.post(MESSAGES_ENDPOINT).mock(return_value=_stream(_resp(stop_reason="tool_use"))) with pytest.raises(Fatal, match="stop_reason='tool_use'"): await _provider().complete(_req()) @respx.mock async def test_inconsistent_end_turn_with_tool_use_block_raises() -> None: respx.post(MESSAGES_ENDPOINT).mock( return_value=_stream( _resp(content=[{"type": "tool_use", "id": "t", "name": "e", "input": {}}]) ) ) with pytest.raises(Fatal, match="stop_reason='end_turn'"): await _provider().complete(_req()) # --------------------------------------------------------------------------- # Response defense # --------------------------------------------------------------------------- @respx.mock async def test_response_type_not_message_raises() -> None: respx.post(MESSAGES_ENDPOINT).mock(return_value=_stream(_resp(response_type="other"))) with pytest.raises(Fatal, match="'type' was not 'message'"): await _provider().complete(_req()) @respx.mock async def test_response_role_not_assistant_raises() -> None: respx.post(MESSAGES_ENDPOINT).mock(return_value=_stream(_resp(role="user"))) with pytest.raises(Fatal, match="'role' was not 'assistant'"): await _provider().complete(_req()) @respx.mock async def test_unknown_response_block_type_is_silently_skipped() -> None: """Forward-compat: a new content block type is dropped, not fatal.""" response = await _respond( _resp( content=[ {"type": "text", "text": "before"}, {"type": "future_block_type", "payload": "unknown"}, {"type": "text", "text": "after"}, ] ) ) assert response.message.content == [Text(text="before"), Text(text="after")] @respx.mock async def test_response_tool_use_input_not_dict_raises() -> None: respx.post(MESSAGES_ENDPOINT).mock( return_value=_stream( _resp( content=[{"type": "tool_use", "id": "t", "name": "echo", "input": "not-a-dict"}], stop_reason="tool_use", ) ) ) with pytest.raises(MalformedToolArguments, match=r"tool_use\.input"): await _provider().complete(_req()) @respx.mock async def test_unparseable_success_body_raises_loudly() -> None: """A non-SSE 200 yields no events: a failure, never a hollow reply; one the retry helper answers in one piece (Claude Code's fallback).""" respx.post(MESSAGES_ENDPOINT).mock(return_value=httpx.Response(200, content=b"not-json")) with pytest.raises(StreamBroken, match="without a message_start") as raised: await _provider().complete(_req()) assert raised.value.stream_retries == 0 @respx.mock async def test_malformed_sse_frame_raises_loudly() -> None: respx.post(MESSAGES_ENDPOINT).mock( return_value=sse_response(b"event: message_start\ndata: {not json}\n\n") ) with pytest.raises(StreamBroken, match="not valid JSON") as raised: await _provider().complete(_req()) assert raised.value.stream_retries == 0 # --------------------------------------------------------------------------- # Usage # --------------------------------------------------------------------------- @respx.mock async def test_usage_passes_through_with_cache_fields() -> None: response = await _respond( _resp( usage={ "input_tokens": 100, "output_tokens": 20, "cache_read_input_tokens": 300, "cache_creation_input_tokens": 50, } ) ) assert response.usage == Usage(uncached=100, cache_read=300, cache_write=50, output=20) assert response.usage.input == 450 @respx.mock async def test_usage_without_cache_fields_input_equals_uncached() -> None: response = await _respond(_resp(usage={"input_tokens": 7, "output_tokens": 2})) assert response.usage.input == 7 == response.usage.uncached @respx.mock async def test_usage_missing_yields_empty_usage() -> None: message = _resp() del message["usage"] response = await _respond(message) assert response.usage == Usage() # --------------------------------------------------------------------------- # HTTP error translation # --------------------------------------------------------------------------- def _error(status: int, err_type: str, message: str, **kw: Any) -> httpx.Response: return httpx.Response( status, json={"type": "error", "error": {"type": err_type, "message": message}}, **kw ) @respx.mock async def test_429_with_retry_after_maps_to_rate_limited() -> None: respx.post(MESSAGES_ENDPOINT).mock( return_value=_error(429, "rate_limit_error", "slow down", headers={"retry-after": "5"}) ) with pytest.raises(RateLimited) as ex: await _provider().complete(_req()) assert ex.value.retry_after == 5.0 assert ex.value.status == 429 @respx.mock @pytest.mark.parametrize("status", [500, 503, 529]) async def test_5xx_and_529_overloaded_map_to_transient(status: int) -> None: respx.post(MESSAGES_ENDPOINT).mock(return_value=_error(status, "overloaded_error", "busy")) with pytest.raises(Transient) as ex: await _provider().complete(_req()) assert not isinstance(ex.value, RateLimited) @respx.mock @pytest.mark.parametrize( "message", [ "prompt is too long: 250000 tokens > 200000 maximum", "input length and `max_tokens` exceed context limit: 190000 + 32000 > 200000", ], ) async def test_400_overflow_messages_map_to_context_overflow(message: str) -> None: respx.post(MESSAGES_ENDPOINT).mock(return_value=_error(400, "invalid_request_error", message)) with pytest.raises(ContextOverflow): await _provider().complete(_req()) @respx.mock async def test_413_request_too_large_maps_to_context_overflow() -> None: respx.post(MESSAGES_ENDPOINT).mock( return_value=_error(413, "request_too_large", "Request exceeds the maximum size") ) with pytest.raises(ContextOverflow): await _provider().complete(_req()) @respx.mock async def test_400_invalid_request_non_overflow_maps_to_fatal() -> None: respx.post(MESSAGES_ENDPOINT).mock( return_value=_error(400, "invalid_request_error", "missing field") ) with pytest.raises(Fatal) as ex: await _provider().complete(_req()) assert ex.value.status == 400 assert "missing field" in ex.value.body @respx.mock async def test_401_maps_to_fatal() -> None: respx.post(MESSAGES_ENDPOINT).mock(return_value=_error(401, "authentication_error", "bad key")) with pytest.raises(Fatal, match="bad key"): await _provider().complete(_req()) @respx.mock async def test_fatal_http_error_carries_the_json_error_message() -> None: respx.post(MESSAGES_ENDPOINT).mock( return_value=_error(404, "not_found_error", "model: nope-1 not found") ) with pytest.raises(Fatal) as info: await _provider().complete(_req()) assert str(info.value).endswith(": model: nope-1 not found") assert "404" in str(info.value) @respx.mock async def test_transient_http_error_carries_a_capped_raw_body() -> None: respx.post(MESSAGES_ENDPOINT).mock( return_value=httpx.Response(503, text="upstream gateway melted " + "x" * 2000) ) with pytest.raises(Transient) as info: await _provider().complete(_req()) text = str(info.value) assert "upstream gateway melted" in text assert "x" * 450 in text assert "x" * 500 not in text @respx.mock async def test_http_error_with_empty_body_keeps_the_status_text() -> None: respx.post(MESSAGES_ENDPOINT).mock(return_value=httpx.Response(401, content=b"")) with pytest.raises(Fatal) as info: await _provider().complete(_req()) assert "401" in str(info.value) assert not str(info.value).endswith(": ") @respx.mock async def test_connect_error_maps_to_transient() -> None: respx.post(MESSAGES_ENDPOINT).mock(side_effect=httpx.ConnectError("refused")) with pytest.raises(Transient): await _provider().complete(_req()) @respx.mock async def test_timeout_maps_to_transient() -> None: respx.post(MESSAGES_ENDPOINT).mock(side_effect=httpx.ReadTimeout("slow")) with pytest.raises(Transient): await _provider().complete(_req()) @respx.mock async def test_count_tokens_posts_one_user_message_with_the_counting_beta() -> None: import json route = respx.post(f"{BASE_URL}/v1/messages/count_tokens").mock( return_value=httpx.Response(200, json={"input_tokens": 19_771})) assert await _provider().count_tokens("claude-opus-5-5", "file text") == 19_771 sent = route.calls.last.request assert json.loads(sent.content) == {"model": "claude-opus-5-5", "tools": [], "messages": [{"role": "user", "content": "file text"}]} assert sent.url.params["beta"] == "true" assert "token-counting-2024-11-01" in sent.headers["anthropic-beta"].split(",") route.mock(return_value=httpx.Response(404, json={})) assert await _provider().count_tokens("claude-opus-5-5", "x") is None @respx.mock async def test_a_refused_count_is_not_asked_again_and_a_count_is_bounded_in_time() -> None: """A gateway without ``/count_tokens``: the first refusal is remembered per model, so later borderline results use the estimate without a request; a 429, a 5xx or a timeout is not remembered.""" route = respx.post(f"{BASE_URL}/v1/messages/count_tokens").mock( return_value=httpx.Response(404, json={})) provider = _provider() assert await provider.count_tokens("m1", "x") is None assert await provider.count_tokens("m1", "y") is None assert route.call_count == 1 assert route.calls.last.request.extensions["timeout"]["read"] == 10.0 route.mock(return_value=httpx.Response(200, text="")) assert await provider.count_tokens("m2", "x") is None assert await provider.count_tokens("m2", "x") is None assert route.call_count == 2 for passing in (httpx.Response(429, json={}), httpx.Response(503, json={}), httpx.ReadTimeout("slow")): route.mock(side_effect=[passing]) assert await provider.count_tokens("m3", "x") is None route.mock(return_value=httpx.Response(200, json={"input_tokens": 7})) assert await provider.count_tokens("m3", "x") == 7