Coverage for src/lilbee/server/anthropic_api/models.py: 100%
93 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-04 17:08 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-04 17:08 +0000
1"""Pydantic models for the Anthropic Messages API wire shapes."""
3from __future__ import annotations
5from enum import StrEnum
6from typing import Annotated, Any, Literal, get_args
8from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
10ThinkingType = Literal["enabled", "disabled"]
11_THINKING_DISABLED = "disabled"
12_THINKING_TYPES: frozenset[str] = frozenset(get_args(ThinkingType))
14MIN_THINKING_BUDGET_TOKENS = 1024
15"""Anthropic's documented minimum for ``thinking.budget_tokens``."""
18class AnthropicEventType(StrEnum):
19 """SSE event vocabulary of the Anthropic Messages stream."""
21 MESSAGE_START = "message_start"
22 CONTENT_BLOCK_START = "content_block_start"
23 CONTENT_BLOCK_DELTA = "content_block_delta"
24 CONTENT_BLOCK_STOP = "content_block_stop"
25 MESSAGE_DELTA = "message_delta"
26 MESSAGE_STOP = "message_stop"
27 PING = "ping"
28 ERROR = "error"
31class _AnthropicModel(BaseModel):
32 """Base for request models: unknown fields parse and are ignored.
34 Anthropic clients send fields this surface does not act on (``metadata``,
35 ``cache_control``, ``output_config``, ``betas``). Rejecting them with a 400
36 hard-fails Claude Code, so they are tolerated instead.
37 """
39 model_config = ConfigDict(extra="allow")
42class SystemTextBlock(_AnthropicModel):
43 """One text block of a block-form ``system`` prompt."""
45 type: Literal["text"]
46 text: str
49class TextBlockParam(_AnthropicModel):
50 """Text content block inside a request message."""
52 type: Literal["text"]
53 text: str
56class ToolUseBlockParam(_AnthropicModel):
57 """Assistant-side tool invocation replayed in the conversation."""
59 type: Literal["tool_use"]
60 id: str
61 name: str
62 input: dict[str, Any] = Field(default_factory=dict)
65class ImageBlockParam(_AnthropicModel):
66 """Image content block; parsed so the translator can reject it clearly."""
68 type: Literal["image"]
69 source: dict[str, Any] = Field(default_factory=dict)
72class UnknownBlockParam(_AnthropicModel):
73 """Catch-all for block types this surface ignores (``thinking``, ...).
75 Claude Code replays ``thinking``/``redacted_thinking`` blocks from earlier
76 assistant turns; failing validation on them would break every follow-up
77 turn, so they parse here and the translator drops them.
78 """
80 type: str
83class ToolResultBlockParam(_AnthropicModel):
84 """Caller-supplied result for a prior tool_use, paired by id."""
86 type: Literal["tool_result"]
87 tool_use_id: str
88 content: (
89 str
90 | list[
91 Annotated[
92 TextBlockParam | ImageBlockParam | UnknownBlockParam,
93 Field(union_mode="left_to_right"),
94 ]
95 ]
96 | None
97 ) = None
98 is_error: bool = False
101ContentBlockParam = Annotated[
102 TextBlockParam | ToolUseBlockParam | ToolResultBlockParam | ImageBlockParam | UnknownBlockParam,
103 # Left-to-right keeps dispatch deterministic: each known type matches its
104 # literal or fails fast, and anything new lands on the catch-all.
105 Field(union_mode="left_to_right"),
106]
109class AnthropicMessage(_AnthropicModel):
110 """One entry in the request ``messages`` list.
112 ``system`` is Anthropic's mid-conversation operator channel; Claude Code
113 sends it routinely (mode switches, injected context), so rejecting it
114 breaks every session after the first such turn.
115 """
117 role: Literal["user", "assistant", "system"]
118 content: str | list[ContentBlockParam]
121class AnthropicTool(_AnthropicModel):
122 """Tool definition; server-tool entries parse with an empty schema."""
124 name: str
125 description: str | None = None
126 input_schema: dict[str, Any] = Field(default_factory=dict)
129class AnthropicToolChoice(_AnthropicModel):
130 """Tool-choice selector; ``name`` accompanies ``type == "tool"``."""
132 type: Literal["auto", "any", "tool", "none"]
133 name: str | None = None
136class AnthropicThinking(_AnthropicModel):
137 """The ``thinking`` parameter: whether the model may reason on this call.
139 ``budget_tokens`` tightens the reasoning cap for this call; it never
140 loosens it. ``1024`` is Anthropic's documented minimum.
141 """
143 type: ThinkingType
144 budget_tokens: int | None = None
146 @model_validator(mode="after")
147 def _budget_meets_the_floor(self) -> AnthropicThinking:
148 """Hold ``enabled`` to Anthropic's minimum, and ignore a disabled budget.
150 Validating the floor per field would reject
151 ``{"type": "disabled", "budget_tokens": 0}`` -- a request asking for no
152 thinking at all, which is the last body that should 400.
153 """
154 if self.type == _THINKING_DISABLED:
155 object.__setattr__(self, "budget_tokens", None)
156 elif self.budget_tokens is not None and self.budget_tokens < MIN_THINKING_BUDGET_TOKENS:
157 raise ValueError(
158 f"thinking.budget_tokens must be at least {MIN_THINKING_BUDGET_TOKENS}"
159 )
160 return self
163class MessagesRequest(_AnthropicModel):
164 """The ``POST /v1/messages`` request body.
166 ``thinking`` picks the reasoning mode for this call, overriding the
167 ``messages_reasoning`` setting.
168 """
170 model: str
171 max_tokens: int
172 messages: list[AnthropicMessage]
173 system: str | list[SystemTextBlock] | None = None
174 tools: list[AnthropicTool] | None = None
175 tool_choice: AnthropicToolChoice | None = None
176 temperature: float | None = None
177 top_p: float | None = None
178 top_k: int | None = None
179 stop_sequences: list[str] | None = None
180 stream: bool = False
181 thinking: AnthropicThinking | None = None
183 @field_validator("thinking", mode="before")
184 @classmethod
185 def _known_thinking_shapes_only(cls, value: object) -> object:
186 # Anthropic defines enabled/disabled today. A shape this surface does
187 # not know falls back to the setting instead of failing the request,
188 # because a 400 here stops the agent mid-session.
189 if value is None or (isinstance(value, dict) and value.get("type") in _THINKING_TYPES):
190 return value
191 return None
194class AnthropicUsage(BaseModel):
195 """Token counts in the Anthropic response shape."""
197 input_tokens: int
198 output_tokens: int
201class MessagesResponse(BaseModel):
202 """The non-streaming ``/v1/messages`` response body.
204 ``content`` blocks vary in shape (``thinking``/``text``/``tool_use``), so
205 they stay plain dicts; ``stop_sequence`` is emitted as an explicit null
206 because Anthropic SDK clients expect the field present.
207 """
209 id: str
210 model: str
211 content: list[dict[str, Any]]
212 stop_reason: str
213 usage: AnthropicUsage
214 type: Literal["message"] = "message"
215 role: Literal["assistant"] = "assistant"
216 stop_sequence: str | None = None