Skip to main content

st/proxy/claude/
types.rs

1//! Claude API Types - Complete request/response model for the Messages API
2//!
3//! Every struct here maps 1:1 to the Claude API JSON schema.
4//! Optional fields use `skip_serializing_if` to keep payloads clean.
5//!
6//! Key design: `ContentBlock` is a tagged enum that handles ALL content types
7//! (text, image, thinking, tool_use, tool_result) in both requests and responses.
8
9use crate::proxy::{LlmMessage, LlmRole, LlmUsage};
10use serde::{Deserialize, Serialize};
11
12// ---------------------------------------------------------------------------
13// Model constants - always use these, never construct model IDs manually
14// ---------------------------------------------------------------------------
15
16/// Current Claude model IDs (as of 2026-03)
17pub mod models {
18    /// Most intelligent model - agents, coding, deep reasoning
19    pub const OPUS_4_6: &str = "claude-opus-4-6";
20    /// Best speed/intelligence balance - general purpose
21    pub const SONNET_4_6: &str = "claude-sonnet-4-6";
22    /// Fastest and cheapest - simple tasks
23    pub const HAIKU_4_5: &str = "claude-haiku-4-5";
24
25    // Legacy (still active) - use only when explicitly requested
26    pub const OPUS_4_5: &str = "claude-opus-4-5";
27    pub const SONNET_4_5: &str = "claude-sonnet-4-5";
28}
29
30// ---------------------------------------------------------------------------
31// Request types
32// ---------------------------------------------------------------------------
33
34/// The main request body sent to `POST /v1/messages`
35#[derive(Debug, Clone, Serialize)]
36pub struct MessagesRequest {
37    pub model: String,
38    pub messages: Vec<Message>,
39    pub max_tokens: usize,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub system: Option<SystemContent>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub temperature: Option<f32>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub top_p: Option<f32>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub top_k: Option<usize>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub stop_sequences: Option<Vec<String>>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub thinking: Option<ThinkingConfig>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub tools: Option<Vec<Tool>>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub tool_choice: Option<ToolChoice>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub output_config: Option<OutputConfig>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub metadata: Option<Metadata>,
60    pub stream: bool,
61}
62
63/// System prompt - either a plain string or structured blocks with cache_control.
64/// Serde's `untagged` tries String first, then Vec<SystemBlock>.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(untagged)]
67pub enum SystemContent {
68    Text(String),
69    Blocks(Vec<SystemBlock>),
70}
71
72/// A system block with optional cache_control for prompt caching
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct SystemBlock {
75    #[serde(rename = "type")]
76    pub block_type: String, // always "text"
77    pub text: String,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub cache_control: Option<CacheControl>,
80}
81
82// ---------------------------------------------------------------------------
83// Messages and content blocks
84// ---------------------------------------------------------------------------
85
86/// A single message in the conversation (user or assistant)
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct Message {
89    pub role: MessageRole,
90    pub content: MessageContent,
91}
92
93impl Message {
94    /// Quick constructor for a user text message
95    pub fn user(text: impl Into<String>) -> Self {
96        Self {
97            role: MessageRole::User,
98            content: MessageContent::Text(text.into()),
99        }
100    }
101
102    /// Quick constructor for an assistant text message
103    pub fn assistant(text: impl Into<String>) -> Self {
104        Self {
105            role: MessageRole::Assistant,
106            content: MessageContent::Text(text.into()),
107        }
108    }
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
112#[serde(rename_all = "lowercase")]
113pub enum MessageRole {
114    User,
115    Assistant,
116}
117
118/// Content can be a simple string or an array of typed blocks.
119/// The API accepts both forms; responses always use Blocks.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121#[serde(untagged)]
122pub enum MessageContent {
123    /// Simple string content (convenience for text-only messages)
124    Text(String),
125    /// Array of typed content blocks (full power mode)
126    Blocks(Vec<ContentBlock>),
127}
128
129/// All possible content block types in request AND response messages.
130///
131/// Tagged on the `type` field so `{"type": "text", "text": "hello"}` maps to
132/// `ContentBlock::Text { text: "hello", .. }`.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134#[serde(tag = "type", rename_all = "snake_case")]
135pub enum ContentBlock {
136    /// Plain text content
137    Text {
138        text: String,
139        #[serde(skip_serializing_if = "Option::is_none")]
140        cache_control: Option<CacheControl>,
141    },
142    /// Image content (base64 or URL)
143    Image {
144        source: ImageSource,
145        #[serde(skip_serializing_if = "Option::is_none")]
146        cache_control: Option<CacheControl>,
147    },
148    /// Model's thinking/reasoning (returned with adaptive thinking enabled)
149    Thinking {
150        thinking: String,
151        #[serde(skip_serializing_if = "Option::is_none")]
152        signature: Option<String>,
153    },
154    /// Redacted thinking block (safety-filtered reasoning)
155    RedactedThinking { data: String },
156    /// Model wants to call a tool (response only)
157    ToolUse {
158        id: String,
159        name: String,
160        input: serde_json::Value,
161    },
162    /// Result of a tool call (request only - sent back by the client)
163    ToolResult {
164        tool_use_id: String,
165        #[serde(skip_serializing_if = "Option::is_none")]
166        content: Option<ToolResultContent>,
167        #[serde(skip_serializing_if = "Option::is_none")]
168        is_error: Option<bool>,
169    },
170}
171
172impl ContentBlock {
173    /// Extract text content if this is a Text block
174    pub fn as_text(&self) -> Option<&str> {
175        match self {
176            ContentBlock::Text { text, .. } => Some(text),
177            _ => None,
178        }
179    }
180
181    /// Extract thinking content if this is a Thinking block
182    pub fn as_thinking(&self) -> Option<&str> {
183        match self {
184            ContentBlock::Thinking { thinking, .. } => Some(thinking),
185            _ => None,
186        }
187    }
188}
189
190// ---------------------------------------------------------------------------
191// Image source variants
192// ---------------------------------------------------------------------------
193
194/// How an image is provided: inline base64 or external URL
195#[derive(Debug, Clone, Serialize, Deserialize)]
196#[serde(tag = "type", rename_all = "snake_case")]
197pub enum ImageSource {
198    Base64 {
199        media_type: String, // "image/jpeg", "image/png", "image/gif", "image/webp"
200        data: String,
201    },
202    Url {
203        url: String,
204    },
205}
206
207// ---------------------------------------------------------------------------
208// Tool result content
209// ---------------------------------------------------------------------------
210
211/// Content of a tool result - either a plain string or structured blocks
212#[derive(Debug, Clone, Serialize, Deserialize)]
213#[serde(untagged)]
214pub enum ToolResultContent {
215    Text(String),
216    Blocks(Vec<ContentBlock>),
217}
218
219// ---------------------------------------------------------------------------
220// Thinking configuration
221// ---------------------------------------------------------------------------
222
223/// Controls Claude's internal reasoning.
224/// - `Adaptive`: Claude decides when/how much to think (Opus 4.6 / Sonnet 4.6)
225/// - `Enabled`: Fixed thinking budget (older models only)
226/// - `Disabled`: No thinking
227#[derive(Debug, Clone, Serialize, Deserialize)]
228#[serde(tag = "type", rename_all = "snake_case")]
229pub enum ThinkingConfig {
230    Adaptive,
231    Enabled { budget_tokens: usize },
232    Disabled,
233}
234
235// ---------------------------------------------------------------------------
236// Tool definitions and choice
237// ---------------------------------------------------------------------------
238
239/// A tool definition telling Claude what functions are available
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct Tool {
242    pub name: String,
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub description: Option<String>,
245    pub input_schema: serde_json::Value,
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub cache_control: Option<CacheControl>,
248    /// When true, Claude guarantees valid parameters matching the schema
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub strict: Option<bool>,
251}
252
253/// Controls whether/how Claude selects tools
254#[derive(Debug, Clone, Serialize, Deserialize)]
255#[serde(tag = "type", rename_all = "snake_case")]
256pub enum ToolChoice {
257    Auto {
258        #[serde(skip_serializing_if = "Option::is_none")]
259        disable_parallel_tool_use: Option<bool>,
260    },
261    Any {
262        #[serde(skip_serializing_if = "Option::is_none")]
263        disable_parallel_tool_use: Option<bool>,
264    },
265    Tool {
266        name: String,
267        #[serde(skip_serializing_if = "Option::is_none")]
268        disable_parallel_tool_use: Option<bool>,
269    },
270    None,
271}
272
273// ---------------------------------------------------------------------------
274// Output config (effort + structured outputs)
275// ---------------------------------------------------------------------------
276
277/// Controls output behavior: thinking effort and structured format
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct OutputConfig {
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub effort: Option<Effort>,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub format: Option<OutputFormat>,
284}
285
286/// How hard Claude should think. Default is `High`.
287/// `Max` is Opus 4.6 only.
288#[derive(Debug, Clone, Serialize, Deserialize)]
289#[serde(rename_all = "snake_case")]
290pub enum Effort {
291    Low,
292    Medium,
293    High,
294    Max,
295}
296
297/// Structured output format constraint
298#[derive(Debug, Clone, Serialize, Deserialize)]
299#[serde(tag = "type", rename_all = "snake_case")]
300pub enum OutputFormat {
301    JsonSchema { schema: serde_json::Value },
302}
303
304// ---------------------------------------------------------------------------
305// Cache control and metadata
306// ---------------------------------------------------------------------------
307
308/// Prompt caching directive - attach to system blocks, messages, or tools
309#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct CacheControl {
311    #[serde(rename = "type")]
312    pub control_type: String, // "ephemeral"
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub ttl: Option<String>, // "5m", "1h"
315}
316
317impl CacheControl {
318    /// Default 5-minute ephemeral cache
319    pub fn ephemeral() -> Self {
320        Self {
321            control_type: "ephemeral".to_string(),
322            ttl: None,
323        }
324    }
325
326    /// 1-hour ephemeral cache for large documents
327    pub fn ephemeral_1h() -> Self {
328        Self {
329            control_type: "ephemeral".to_string(),
330            ttl: Some("1h".to_string()),
331        }
332    }
333}
334
335/// Optional metadata for tracking/billing purposes
336#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct Metadata {
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub user_id: Option<String>,
340}
341
342// ---------------------------------------------------------------------------
343// Response types
344// ---------------------------------------------------------------------------
345
346/// Full response from `POST /v1/messages` (non-streaming)
347#[derive(Debug, Clone, Deserialize, Serialize)]
348pub struct MessagesResponse {
349    pub id: String,
350    #[serde(rename = "type")]
351    pub response_type: String, // "message"
352    pub role: String, // "assistant"
353    pub content: Vec<ContentBlock>,
354    pub model: String,
355    pub stop_reason: Option<StopReason>,
356    pub stop_sequence: Option<String>,
357    pub usage: Usage,
358}
359
360impl MessagesResponse {
361    /// Extract the first text content from the response (most common case)
362    pub fn text(&self) -> Option<&str> {
363        self.content.iter().find_map(|b| b.as_text())
364    }
365
366    /// Extract all thinking blocks concatenated
367    pub fn thinking(&self) -> Option<String> {
368        let parts: Vec<&str> = self
369            .content
370            .iter()
371            .filter_map(|b| b.as_thinking())
372            .collect();
373        if parts.is_empty() {
374            None
375        } else {
376            Some(parts.join("\n"))
377        }
378    }
379
380    /// Check if Claude wants to use tools
381    pub fn has_tool_use(&self) -> bool {
382        self.stop_reason == Some(StopReason::ToolUse)
383    }
384
385    /// Extract all tool use blocks from the response
386    pub fn tool_calls(&self) -> Vec<(&str, &str, &serde_json::Value)> {
387        self.content
388            .iter()
389            .filter_map(|b| match b {
390                ContentBlock::ToolUse { id, name, input } => {
391                    Some((id.as_str(), name.as_str(), input))
392                }
393                _ => None,
394            })
395            .collect()
396    }
397}
398
399/// Why Claude stopped generating
400#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
401#[serde(rename_all = "snake_case")]
402pub enum StopReason {
403    EndTurn,
404    MaxTokens,
405    StopSequence,
406    ToolUse,
407    PauseTurn,
408    Refusal,
409}
410
411/// Token usage statistics from the API
412#[derive(Debug, Clone, Deserialize, Serialize)]
413pub struct Usage {
414    pub input_tokens: usize,
415    pub output_tokens: usize,
416    #[serde(skip_serializing_if = "Option::is_none")]
417    pub cache_creation_input_tokens: Option<usize>,
418    #[serde(skip_serializing_if = "Option::is_none")]
419    pub cache_read_input_tokens: Option<usize>,
420}
421
422// ---------------------------------------------------------------------------
423// Conversions to/from the shared LlmMessage/LlmUsage types
424// ---------------------------------------------------------------------------
425
426impl From<LlmMessage> for Message {
427    fn from(msg: LlmMessage) -> Self {
428        Self {
429            role: match msg.role {
430                LlmRole::System => MessageRole::User, // system handled separately
431                LlmRole::User => MessageRole::User,
432                LlmRole::Assistant => MessageRole::Assistant,
433            },
434            content: MessageContent::Text(msg.content),
435        }
436    }
437}
438
439impl From<Usage> for LlmUsage {
440    fn from(u: Usage) -> Self {
441        Self {
442            prompt_tokens: u.input_tokens,
443            completion_tokens: u.output_tokens,
444            total_tokens: u.input_tokens + u.output_tokens,
445        }
446    }
447}