Split `AnthropicModel._map_message` into per-part mappers
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:
| Before | After | |
|---|---|---|
AnthropicModel._map_message | 390 | 10 |
| Highest function this PR adds | — | 15 |
anthropic.py total | 1297 | 1068 |
Every function this PR adds or changes
| Function | Score |
|---|---|
_build_code_execution_tool_result_block | 1 |
AnthropicModel._map_compaction_part | 4 |
AnthropicModel._append_model_response_turns | 5 |
AnthropicModel._map_system_prompt_part | 5 |
AnthropicModel._map_tool_availability_delta_part | 5 |
AnthropicModel._map_user_prompt_part | 7 |
AnthropicModel._map_thinking_part | 9 |
AnthropicModel._map_message | 10 |
AnthropicModel._map_model_request | 10 |
AnthropicModel._map_tool_search_call_input | 11 |
AnthropicModel._build_system_prompt | 12 |
AnthropicModel._map_native_tool_call_part | 12 |
AnthropicModel._map_tool_return_part | 12 |
AnthropicModel._map_model_response | 14 |
AnthropicModel._map_native_tool_return_part | 14 |
AnthropicModel._add_container_uploads | 15 |
AnthropicModel._append_model_request_turns | 15 |
_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
MockAnthropiccall, per test, over the test files that referenceAnthropicModel,_map_messageoranthropic:. The base run is this branch withdafd842855'santhropic.pyand_agent_graph.pyswapped 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 isuuid4().hex[:8]; identical with that id normalized.tests/models/test_google.py::test_google_instructions_only_with_tool_calls: the test's tool returnsrandom.choice(...).tests/harness/logfire_mcp/test_capability.py::TestPerRunAuth::test_concurrent_runs_use_their_own_credentialsand threebinary-video-google_*cases oftests/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(plustests/test_capability_hooks.py::TestCompaction::test_anthropic_compaction_serialization_namefor one untouched line).make lintand 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_inputinto 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.pyinto amodels/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 -
- Decision: reserved; dispatched
- Why: tree 2ebc9cba0f2a1994e2386fc9172e8e19cbd66817; scope merge-base dafd842855..26e0051faed63f905fadeac4137aee51b4c87174 (full branch); first review
- Source: https://github.com/pydantic/pydantic-ai/tree/refactor/anthropic-map-message-dispatch
2026-10-23 · pre-push review 1 of 3 at 26e0051faed63f905fadeac4137aee51b4c87174 · iter -
- Decision: remediated: two comments (profile narrowing, request-scoped beta) repointed from _map_model_request to _map_system_prompt_part
- Why: tree 2ebc9cba0f2a1994e2386fc9172e8e19cbd66817; scope dafd842855..26e0051fae full branch; remediation is comment-only, exempt per the comments-only row, chains from this reviewed SHA
- Source: https://github.com/pydantic/pydantic-ai/tree/refactor/anthropic-map-message-dispatch
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.