Split `AnthropicModel._map_message` into per-part mappers

Merged
DS
9d ago
+654-610

This pull request was posted by Claude Code using claude-opus-5-5 on behalf of David.

Part of #10983.

AnthropicModel._map_message scored 390 on cognitive complexity: every part type was a nested branch in one 640-line function. This splits it into one method per part type, called from one exhaustive dispatch per message kind, with request-wide state in private data-only context dataclasses (the OpenAIChatModel._MapModelResponseContext shape). A new part type now adds one flat arm. Pure refactor: request bodies are byte-identical.

_map_message                                   builds _MapMessagesContext
├─ ModelRequest  → _map_model_request          collects into _MapModelRequestContext
│   ├─ SystemPromptPart            → _map_system_prompt_part
│   ├─ UserPromptPart              → _map_user_prompt_part
│   ├─ ToolAvailabilityDeltaPart   → _map_tool_availability_delta_part
│   ├─ ToolReturnPart              → _map_tool_return_part
│   └─ pass                        → _append_model_request_turns
├─ ModelResponse → _map_model_response
│   ├─ ThinkingPart                → _map_thinking_part
│   ├─ NativeToolCallPart          → _map_native_tool_call_part
│   ├─ NativeToolReturnPart        → _map_native_tool_return_part
│   ├─ CompactionPart              → _map_compaction_part
│   └─ pass                        → _append_model_response_turns
└─ whole-request passes            → _add_container_uploads, _build_system_prompt

New public surface: none.

What changes for existing users: nothing.

Verification

Cognitive complexity (complexipy 8.1.0), merge base dafd842855 vs this branch:

BeforeAfter
AnthropicModel._map_message39010
Highest function this PR adds—15
anthropic.py total12971068
Every function this PR adds or changes
FunctionScore
_build_code_execution_tool_result_block1
AnthropicModel._map_compaction_part4
AnthropicModel._append_model_response_turns5
AnthropicModel._map_system_prompt_part5
AnthropicModel._map_tool_availability_delta_part5
AnthropicModel._map_user_prompt_part7
AnthropicModel._map_thinking_part9
AnthropicModel._map_message10
AnthropicModel._map_model_request10
AnthropicModel._map_tool_search_call_input11
AnthropicModel._build_system_prompt12
AnthropicModel._map_native_tool_call_part12
AnthropicModel._map_tool_return_part12
AnthropicModel._map_model_response14
AnthropicModel._map_native_tool_return_part14
AnthropicModel._add_container_uploads15
AnthropicModel._append_model_request_turns15

_messages_use_anthropic_uploaded_file changes only in its docstring. The # noqa: C901 on _map_message is gone; RUF100 confirms the three left are still needed.

  • Byte-identity: a pytest plugin recorded every outgoing HTTP request body and every MockAnthropic call, per test, over the test files that reference AnthropicModel, _map_message or anthropic:. The base run is this branch with dafd842855's anthropic.py and _agent_graph.py swapped in. After normalizing memory addresses, multipart boundaries and UUIDs, both runs send the same 1869 Anthropic request records across 767 test nodes. The one difference inside them is a random plan-step id in a tool result. Seven of 1634 nodes differ in all; none comes from the mapper.

    The seven differing nodes
    • tests/harness/planning/test_planning.py::TestEndToEnd::test_breakpoint_lands_on_the_last_tool_result_on_the_wire[anthropic]: the plan step id is uuid4().hex[:8]; identical with that id normalized.
    • tests/models/test_google.py::test_google_instructions_only_with_tool_calls: the test's tool returns random.choice(...).
    • tests/harness/logfire_mcp/test_capability.py::TestPerRunAuth::test_concurrent_runs_use_their_own_credentials and three binary-video-google_* cases of tests/models/test_multimodal_tool_returns.py::test_multimodal_tool_return_matrix: cross-test state; identical on both sides when rerun in isolation.
    • tests/models/test_model_settings_support.py::test_max_retries_sets_how_many_attempts_the_sdk_makes[MistralModel[retry_config]]: the retry budget is elapsed time, so the attempt count varies run to run.

    The Temporal tests and the docs-example runner were left out of both runs: they sent no Anthropic request in a full base run, and their local dev server timed out under load.

  • Tests and coverage: the same test files pass on this branch, with 100% line and branch coverage of anthropic.py (plus tests/test_capability_hooks.py::TestCompaction::test_anthropic_compaction_serialization_name for one untouched line). make lint and pyright on both changed files pass.

Follow-ups (not in this PR)

  • Move the inline isinstance(self.client, _REGEX_DEFAULT_TOOL_SEARCH_CLIENTS) in _map_tool_search_call_input into a profile flag. It changes code paths, so it doesn't belong in a byte-identical refactor.
  • Functions this PR doesn't touch keep their scores, e.g. AnthropicStreamedResponse._get_event_iterator (141) and _map_api_errors (24). The gate in #10983 grandfathers them until touched.
  • Split models/anthropic.py into a models/anthropic/ package, proven the same way.
  • #11039 also edits _map_message; whichever lands second ports its hunks onto the other.
PR decisions

2026-10-23 · Complexity scope: branch-touched functions only · iter -

  • Decision: Bring only functions the branch adds or changes in logic to ≤15; untouched functions and the docstring-only edit to _messages_use_anthropic_uploaded_file stay out.
  • Why: Matches the gate's grandfathering in the issue: existing debt is grandfathered until touched.
  • Source: https://github.com/pydantic/pydantic-ai/issues/10983

2026-10-23 · pre-push review 1 of 3 at 26e0051faed63f905fadeac4137aee51b4c87174 · iter -

2026-10-23 · pre-push review 1 of 3 at 26e0051faed63f905fadeac4137aee51b4c87174 · iter -

2026-10-23 · Byte-identity capture set · iter -

  • Decision: Captured the 107 test files referencing AnthropicModel, _map_message or anthropic: minus tests/durable_exec/temporal/ and the docs-example runner (103 run); 7 differing nodes attributed to test randomness, cross-test state or elapsed-time retries, each rechecked
  • Why: The excluded files sent no Anthropic request in the full base run, and under local load their Temporal dev server timed out and the first head run was killed
  • Source: https://github.com/pydantic/pydantic-ai/issues/10983

Checklist

  • No breaking changes in accordance with the version policy.
  • Any permitted compatibility impact has a label, warning, migration, release note, and exact API-check waiver.
  • PR title is fit for the release changelog.