Skip to main content

gproxy_protocol/protocol/claude/message/
mod.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use super::common::{
6    AnthropicBetaHeaders, AssistantRole, CacheControl, ClaudeModel, Container, ContainerParam,
7    ContentBlock, ContextManagementConfig, ContextManagementResponse, Diagnostics,
8    DiagnosticsParam, FallbackCreditTokenParam, FallbacksParam, InferenceGeo, InputTransformation,
9    JsonObject, JsonSchemaFormat, McpServer, MessageObjectType, MessageParam, Metadata,
10    OutputConfig, RequestServiceTier, Speed, StopDetails, StopReason, SystemPrompt, ThinkingConfig,
11    Tool, ToolChoice, Usage,
12};
13
14pub mod stream;
15
16pub use stream::*;
17
18pub type CreateMessageRequestHeaders = AnthropicBetaHeaders;
19
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, gproxy_protocol_macros::WireBuilder)]
21#[non_exhaustive]
22pub struct CreateMessageRequestBody {
23    pub model: ClaudeModel,
24    pub messages: Vec<MessageParam>,
25    pub max_tokens: u64,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub cache_control: Option<CacheControl>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub container: Option<ContainerParam>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub context_management: Option<ContextManagementConfig>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub diagnostics: Option<DiagnosticsParam>,
34    /// Redeem a prior refusal's fallback credit on retry.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub fallback_credit_token: Option<FallbackCreditTokenParam>,
37    /// Server-side retry routing used when the requested model refuses.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub fallbacks: Option<FallbacksParam>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub inference_geo: Option<InferenceGeo>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub mcp_servers: Option<Vec<McpServer>>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub metadata: Option<Metadata>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub output_config: Option<OutputConfig>,
48    /// Deprecated. Use `output_config.format` instead.
49    #[deprecated(note = "use output_config.format instead")]
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub output_format: Option<JsonSchemaFormat>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub service_tier: Option<RequestServiceTier>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub speed: Option<Speed>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub stop_sequences: Option<Vec<String>>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub stream: Option<bool>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub system: Option<SystemPrompt>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub temperature: Option<f64>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub thinking: Option<ThinkingConfig>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub tool_choice: Option<ToolChoice>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub tools: Option<Vec<Tool>>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub top_k: Option<i64>,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub top_p: Option<f64>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub user_profile_id: Option<String>,
76    #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
77    pub extra: JsonObject,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, gproxy_protocol_macros::WireBuilder)]
81#[non_exhaustive]
82pub struct CreateMessageResponseBody {
83    pub id: String,
84    #[serde(rename = "type")]
85    pub type_: MessageObjectType,
86    pub role: AssistantRole,
87    pub content: Vec<ContentBlock>,
88    pub model: ClaudeModel,
89    pub stop_reason: StopReason,
90    pub stop_sequence: Option<String>,
91    pub usage: Usage,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub container: Option<Container>,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub context_management: Option<ContextManagementResponse>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub diagnostics: Option<Diagnostics>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub input_transformations: Option<Vec<InputTransformation>>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub stop_details: Option<StopDetails>,
102    #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
103    pub extra: JsonObject,
104}
105
106#[cfg(test)]
107mod tests {
108    use serde_json::json;
109
110    use super::*;
111    use crate::protocol::claude::{
112        ContentBlockParam, MidConversationSystemContentBlock, StringOrArray, WebFetchTool,
113        WebSearchTool,
114    };
115
116    #[test]
117    fn parses_default_fallback_and_mid_conversation_tool_changes() {
118        let request: CreateMessageRequestBody = serde_json::from_value(json!({
119            "model": "claude-opus-5",
120            "messages": [{
121                "role": "user",
122                "content": [{
123                    "type": "mid_conv_system",
124                    "content": [
125                        {"type": "text", "text": "Use the newly available tool."},
126                        {"type": "tool_addition", "tool": {"type": "tool_reference", "name": "search"}},
127                        {"type": "tool_removal", "tool": {"type": "mcp_toolset_reference", "server_name": "legacy"}}
128                    ]
129                }]
130            }],
131            "max_tokens": 1024,
132            "fallbacks": "default"
133        }))
134        .unwrap();
135
136        assert!(matches!(
137            request.model,
138            ClaudeModel::Known(super::super::common::ClaudeModelKnown::ClaudeOpus5)
139        ));
140        assert!(matches!(
141            request.fallbacks,
142            Some(FallbacksParam::Default(_))
143        ));
144        let StringOrArray::Array(blocks) = &request.messages[0].content else {
145            panic!("expected content blocks");
146        };
147        let ContentBlockParam::MidConversationSystem(system) = &blocks[0] else {
148            panic!("expected mid-conversation system block");
149        };
150        assert!(matches!(
151            system.content[1],
152            MidConversationSystemContentBlock::ToolAddition(_)
153        ));
154        assert!(matches!(
155            system.content[2],
156            MidConversationSystemContentBlock::ToolRemoval(_)
157        ));
158    }
159
160    #[test]
161    fn parses_ordered_fallbacks_and_latest_web_tools() {
162        let request: CreateMessageRequestBody = serde_json::from_value(json!({
163            "model": "claude-fable-5",
164            "messages": [{"role": "user", "content": "hello"}],
165            "max_tokens": 1024,
166            "fallbacks": [
167                {"model": "claude-opus-5"},
168                {"model": "claude-opus-4-8"},
169                {"model": "claude-sonnet-5"}
170            ],
171            "tools": [
172                {"type": "web_search_20260318", "name": "web_search", "response_inclusion": "excluded"},
173                {"type": "web_fetch_20260318", "name": "web_fetch", "response_inclusion": "full", "use_cache": false}
174            ]
175        }))
176        .unwrap();
177
178        let Some(FallbacksParam::Models(fallbacks)) = request.fallbacks else {
179            panic!("expected explicit fallback chain");
180        };
181        assert_eq!(fallbacks.len(), 3);
182        let tools = request.tools.unwrap();
183        assert!(matches!(
184            tools[0],
185            Tool::WebSearch(WebSearchTool::WebSearch20260318(_))
186        ));
187        assert!(matches!(
188            tools[1],
189            Tool::WebFetch(WebFetchTool::WebFetch20260318(_))
190        ));
191    }
192
193    #[test]
194    fn parses_fable_5_1_message_controls_and_transformations() {
195        let request: CreateMessageRequestBody = serde_json::from_value(json!({
196            "model": "claude-fable-5-1",
197            "max_tokens": 1024,
198            "thinking": {
199                "type": "adaptive",
200                "display": "updates",
201                "block_binding": {"prefix_mismatch_behavior": "drop_block"}
202            },
203            "messages": [
204                {"role": "system", "content": [], "output_config": {"effort": "low"}},
205                {"role": "user", "content": "hello"},
206                {"role": "system", "content": "one turn", "clear_at": "next_user_message"}
207            ]
208        }))
209        .unwrap();
210        assert!(matches!(
211            request.model,
212            ClaudeModel::Known(super::super::common::ClaudeModelKnown::ClaudeFable51)
213        ));
214        assert!(matches!(
215            request.messages[2].clear_at,
216            Some(super::super::common::MessageClearAt::Known(
217                super::super::common::MessageClearAtKnown::NextUserMessage
218            ))
219        ));
220
221        let response: CreateMessageResponseBody = serde_json::from_value(json!({
222            "id": "msg_1", "type": "message", "role": "assistant", "content": [],
223            "model": "claude-fable-5-1", "stop_reason": "end_turn", "stop_sequence": null,
224            "usage": {"input_tokens": 3, "output_tokens": 1},
225            "input_transformations": [{
226                "type": "thinking_dropped", "path": "messages.1.content.0",
227                "reason": "prefix_binding_mismatch"
228            }]
229        }))
230        .unwrap();
231        assert!(matches!(
232            response.input_transformations.as_deref(),
233            Some([super::super::common::InputTransformation::ThinkingDropped(
234                _
235            )])
236        ));
237
238        let event: StreamEvent = serde_json::from_value(json!({
239            "type": "message_delta",
240            "delta": {"stop_reason": "end_turn", "stop_sequence": null},
241            "usage": {"output_tokens": 1},
242            "input_transformations": [{
243                "type": "thinking_dropped", "path": "messages.1.content.0",
244                "reason": "model_binding_mismatch"
245            }]
246        }))
247        .unwrap();
248        assert!(matches!(event, StreamEvent::Known(_)));
249    }
250}