1use http::StatusCode;
2
3use crate::claude::count_tokens::types as ct;
4use crate::claude::count_tokens::types::{
5 BetaContentBlockParam, BetaMessageContent, BetaSystemPrompt, Model, ModelKnown,
6};
7use crate::claude::types::{
8 BetaApiError, BetaApiErrorType, BetaAuthenticationError, BetaAuthenticationErrorType,
9 BetaBillingError, BetaBillingErrorType, BetaError, BetaErrorResponse, BetaErrorResponseType,
10 BetaGatewayTimeoutError, BetaGatewayTimeoutErrorType, BetaInvalidRequestError,
11 BetaInvalidRequestErrorType, BetaNotFoundError, BetaNotFoundErrorType, BetaOverloadedError,
12 BetaOverloadedErrorType, BetaPermissionError, BetaPermissionErrorType, BetaRateLimitError,
13 BetaRateLimitErrorType,
14};
15
16pub fn beta_error_response_from_status_message(
17 status_code: StatusCode,
18 message: String,
19) -> BetaErrorResponse {
20 let error = match status_code.as_u16() {
21 400 | 413 => BetaError::InvalidRequest(BetaInvalidRequestError {
22 message,
23 type_: BetaInvalidRequestErrorType::InvalidRequestError,
24 }),
25 401 => BetaError::Authentication(BetaAuthenticationError {
26 message,
27 type_: BetaAuthenticationErrorType::AuthenticationError,
28 }),
29 402 => BetaError::Billing(BetaBillingError {
30 message,
31 type_: BetaBillingErrorType::BillingError,
32 }),
33 403 => BetaError::Permission(BetaPermissionError {
34 message,
35 type_: BetaPermissionErrorType::PermissionError,
36 }),
37 404 => BetaError::NotFound(BetaNotFoundError {
38 message,
39 type_: BetaNotFoundErrorType::NotFoundError,
40 }),
41 429 => BetaError::RateLimit(BetaRateLimitError {
42 message,
43 type_: BetaRateLimitErrorType::RateLimitError,
44 }),
45 504 => BetaError::GatewayTimeout(BetaGatewayTimeoutError {
46 message,
47 type_: BetaGatewayTimeoutErrorType::TimeoutError,
48 }),
49 529 => BetaError::Overloaded(BetaOverloadedError {
50 message,
51 type_: BetaOverloadedErrorType::OverloadedError,
52 }),
53 _ => BetaError::Api(BetaApiError {
54 message,
55 type_: BetaApiErrorType::ApiError,
56 }),
57 };
58
59 BetaErrorResponse {
60 error,
61 request_id: String::new(),
62 type_: BetaErrorResponseType::Error,
63 }
64}
65
66pub fn claude_model_to_string(model: &Model) -> String {
67 match model {
68 Model::Custom(model) => model.clone(),
69 Model::Known(model) => match model {
70 ModelKnown::ClaudeOpus47 => "claude-opus-4-7",
71 ModelKnown::ClaudeOpus46 => "claude-opus-4-6",
72 ModelKnown::ClaudeOpus4520251101 => "claude-opus-4-5-20251101",
73 ModelKnown::ClaudeOpus45 => "claude-opus-4-5",
74 ModelKnown::Claude37SonnetLatest => "claude-3-7-sonnet-latest",
75 ModelKnown::Claude37Sonnet20250219 => "claude-3-7-sonnet-20250219",
76 ModelKnown::Claude35HaikuLatest => "claude-3-5-haiku-latest",
77 ModelKnown::Claude35Haiku20241022 => "claude-3-5-haiku-20241022",
78 ModelKnown::ClaudeHaiku45 => "claude-haiku-4-5",
79 ModelKnown::ClaudeHaiku4520251001 => "claude-haiku-4-5-20251001",
80 ModelKnown::ClaudeSonnet420250514 => "claude-sonnet-4-20250514",
81 ModelKnown::ClaudeSonnet40 => "claude-sonnet-4-0",
82 ModelKnown::Claude4Sonnet20250514 => "claude-4-sonnet-20250514",
83 ModelKnown::ClaudeSonnet45 => "claude-sonnet-4-5",
84 ModelKnown::ClaudeSonnet4520250929 => "claude-sonnet-4-5-20250929",
85 ModelKnown::ClaudeSonnet46 => "claude-sonnet-4-6",
86 ModelKnown::ClaudeOpus40 => "claude-opus-4-0",
87 ModelKnown::ClaudeOpus420250514 => "claude-opus-4-20250514",
88 ModelKnown::Claude4Opus20250514 => "claude-4-opus-20250514",
89 ModelKnown::ClaudeOpus4120250805 => "claude-opus-4-1-20250805",
90 ModelKnown::Claude3OpusLatest => "claude-3-opus-latest",
91 ModelKnown::Claude3Opus20240229 => "claude-3-opus-20240229",
92 ModelKnown::Claude3Haiku20240307 => "claude-3-haiku-20240307",
93 }
94 .to_string(),
95 }
96}
97
98pub fn claude_model_supports_enabled_thinking(model: Option<&Model>) -> bool {
99 !matches!(
100 model.map(claude_model_to_string).as_deref(),
101 Some("claude-opus-4-7")
102 )
103}
104
105pub fn beta_message_content_to_text(content: &BetaMessageContent) -> String {
106 match content {
107 BetaMessageContent::Text(text) => text.clone(),
108 BetaMessageContent::Blocks(blocks) => blocks
109 .iter()
110 .map(beta_content_block_to_text)
111 .collect::<Vec<_>>()
112 .join("\n"),
113 }
114}
115
116pub fn beta_system_prompt_to_text(system: Option<BetaSystemPrompt>) -> Option<String> {
117 let text = match system {
118 Some(BetaSystemPrompt::Text(text)) => text,
119 Some(BetaSystemPrompt::Blocks(blocks)) => blocks
120 .into_iter()
121 .map(|block| block.text)
122 .collect::<Vec<_>>()
123 .join("\n"),
124 None => String::new(),
125 };
126
127 if text.is_empty() { None } else { Some(text) }
128}
129
130fn beta_content_block_to_text(block: &BetaContentBlockParam) -> String {
131 match block {
132 BetaContentBlockParam::Text(block) => block.text.clone(),
133 _ => "[unsupported_content_block]".to_string(),
134 }
135}
136
137pub const ORPHAN_TOOL_USE_PLACEHOLDER_NAME: &str = "tool_use_placeholder";
142
143fn make_placeholder_tool_use(id: String) -> ct::BetaContentBlockParam {
144 ct::BetaContentBlockParam::ToolUse(ct::BetaToolUseBlockParam {
145 id,
146 input: ct::JsonObject::new(),
147 name: ORPHAN_TOOL_USE_PLACEHOLDER_NAME.to_string(),
148 type_: ct::BetaToolUseBlockType::ToolUse,
149 cache_control: None,
150 caller: None,
151 })
152}
153
154fn placeholder_text_block(text: String) -> ct::BetaContentBlockParam {
155 ct::BetaContentBlockParam::Text(ct::BetaTextBlockParam {
156 text,
157 type_: ct::BetaTextBlockType::Text,
158 cache_control: None,
159 citations: None,
160 })
161}
162
163fn promote_content_to_blocks(content: &mut ct::BetaMessageContent) {
164 if let ct::BetaMessageContent::Text(text) = content {
165 let blocks = if text.is_empty() {
166 Vec::new()
167 } else {
168 vec![placeholder_text_block(std::mem::take(text))]
169 };
170 *content = ct::BetaMessageContent::Blocks(blocks);
171 }
172}
173
174fn ensure_tool_use_for(messages: &mut Vec<ct::BetaMessageParam>, id: &str) {
175 let trailing_user = matches!(
180 messages.last(),
181 Some(ct::BetaMessageParam {
182 role: ct::BetaMessageRole::User,
183 ..
184 })
185 );
186 let assistant_idx = if trailing_user {
187 messages.len().checked_sub(2)
188 } else {
189 messages.len().checked_sub(1)
190 };
191
192 let already_paired = matches!(
193 assistant_idx.and_then(|j| messages.get(j)),
194 Some(ct::BetaMessageParam {
195 content: ct::BetaMessageContent::Blocks(blocks),
196 role: ct::BetaMessageRole::Assistant,
197 }) if blocks.iter().any(|b|
198 matches!(b, ct::BetaContentBlockParam::ToolUse(tu) if tu.id == id)
199 )
200 );
201 if already_paired {
202 return;
203 }
204
205 let placeholder = make_placeholder_tool_use(id.to_string());
206
207 if let Some(j) = assistant_idx
208 && matches!(messages[j].role, ct::BetaMessageRole::Assistant)
209 {
210 promote_content_to_blocks(&mut messages[j].content);
211 if let ct::BetaMessageContent::Blocks(blocks) = &mut messages[j].content {
212 blocks.push(placeholder);
213 return;
214 }
215 }
216
217 let insert_at = if trailing_user {
220 messages.len() - 1
221 } else {
222 messages.len()
223 };
224 messages.insert(
225 insert_at,
226 ct::BetaMessageParam {
227 content: ct::BetaMessageContent::Blocks(vec![placeholder]),
228 role: ct::BetaMessageRole::Assistant,
229 },
230 );
231}
232
233pub fn push_message_block(
252 messages: &mut Vec<ct::BetaMessageParam>,
253 role: ct::BetaMessageRole,
254 block: ct::BetaContentBlockParam,
255) {
256 if matches!(role, ct::BetaMessageRole::User)
257 && let ct::BetaContentBlockParam::ToolResult(tr) = &block
258 {
259 ensure_tool_use_for(messages, &tr.tool_use_id);
260 }
261
262 if let Some(last) = messages.last_mut()
263 && last.role == role
264 {
265 promote_content_to_blocks(&mut last.content);
266 if let ct::BetaMessageContent::Blocks(blocks) = &mut last.content {
267 blocks.push(block);
268 return;
269 }
270 }
271
272 messages.push(ct::BetaMessageParam {
273 content: ct::BetaMessageContent::Blocks(vec![block]),
274 role,
275 });
276}
277
278#[cfg(test)]
279mod push_message_block_tests {
280 use super::*;
281
282 fn tool_result_block(id: &str, body: &str) -> ct::BetaContentBlockParam {
283 ct::BetaContentBlockParam::ToolResult(ct::BetaToolResultBlockParam {
284 tool_use_id: id.to_string(),
285 type_: ct::BetaToolResultBlockType::ToolResult,
286 cache_control: None,
287 content: Some(ct::BetaToolResultBlockParamContent::Text(body.to_string())),
288 is_error: None,
289 })
290 }
291
292 fn tool_use_block(id: &str, name: &str) -> ct::BetaContentBlockParam {
293 ct::BetaContentBlockParam::ToolUse(ct::BetaToolUseBlockParam {
294 id: id.to_string(),
295 input: ct::JsonObject::new(),
296 name: name.to_string(),
297 type_: ct::BetaToolUseBlockType::ToolUse,
298 cache_control: None,
299 caller: None,
300 })
301 }
302
303 fn tool_use_ids_in(message: &ct::BetaMessageParam) -> Vec<String> {
304 match &message.content {
305 ct::BetaMessageContent::Blocks(blocks) => blocks
306 .iter()
307 .filter_map(|block| match block {
308 ct::BetaContentBlockParam::ToolUse(tu) => Some(tu.id.clone()),
309 _ => None,
310 })
311 .collect(),
312 _ => Vec::new(),
313 }
314 }
315
316 fn tool_result_ids_in(message: &ct::BetaMessageParam) -> Vec<String> {
317 match &message.content {
318 ct::BetaMessageContent::Blocks(blocks) => blocks
319 .iter()
320 .filter_map(|block| match block {
321 ct::BetaContentBlockParam::ToolResult(tr) => Some(tr.tool_use_id.clone()),
322 _ => None,
323 })
324 .collect(),
325 _ => Vec::new(),
326 }
327 }
328
329 #[test]
330 fn injects_assistant_message_for_orphaned_tool_result_at_start() {
331 let mut messages = Vec::new();
334 push_message_block(
335 &mut messages,
336 ct::BetaMessageRole::User,
337 tool_result_block("toolu_abc", "-0.978"),
338 );
339
340 assert_eq!(
341 messages.len(),
342 2,
343 "expected a synthetic assistant prepended"
344 );
345 assert!(matches!(messages[0].role, ct::BetaMessageRole::Assistant));
346 assert!(matches!(messages[1].role, ct::BetaMessageRole::User));
347 assert_eq!(tool_use_ids_in(&messages[0]), vec!["toolu_abc"]);
348 assert_eq!(tool_result_ids_in(&messages[1]), vec!["toolu_abc"]);
349 }
350
351 #[test]
352 fn merges_consecutive_tool_results_into_one_user_message() {
353 let mut messages = Vec::new();
356 push_message_block(
357 &mut messages,
358 ct::BetaMessageRole::User,
359 tool_result_block("toolu_one", "-0.978"),
360 );
361 push_message_block(
362 &mut messages,
363 ct::BetaMessageRole::User,
364 tool_result_block("toolu_two", "{...}"),
365 );
366
367 assert_eq!(messages.len(), 2);
370 assert!(matches!(messages[0].role, ct::BetaMessageRole::Assistant));
371 assert_eq!(
372 tool_use_ids_in(&messages[0]),
373 vec!["toolu_one".to_string(), "toolu_two".to_string()]
374 );
375 assert!(matches!(messages[1].role, ct::BetaMessageRole::User));
376 assert_eq!(
377 tool_result_ids_in(&messages[1]),
378 vec!["toolu_one".to_string(), "toolu_two".to_string()]
379 );
380 }
381
382 #[test]
383 fn does_not_inject_when_pair_already_exists() {
384 let mut messages = Vec::new();
385 push_message_block(
386 &mut messages,
387 ct::BetaMessageRole::Assistant,
388 tool_use_block("toolu_real", "search"),
389 );
390 push_message_block(
391 &mut messages,
392 ct::BetaMessageRole::User,
393 tool_result_block("toolu_real", "result"),
394 );
395
396 assert_eq!(messages.len(), 2);
397 assert_eq!(tool_use_ids_in(&messages[0]), vec!["toolu_real"]);
398 assert_eq!(
399 tool_use_ids_in(&messages[0])
400 .iter()
401 .filter(|id| id == &"toolu_real")
402 .count(),
403 1,
404 "no duplicate placeholder should have been injected"
405 );
406 }
407
408 #[test]
409 fn appends_to_existing_assistant_text_when_previous_is_text() {
410 let mut messages = vec![ct::BetaMessageParam {
414 content: ct::BetaMessageContent::Text("doing X".to_string()),
415 role: ct::BetaMessageRole::Assistant,
416 }];
417 push_message_block(
418 &mut messages,
419 ct::BetaMessageRole::User,
420 tool_result_block("toolu_xyz", "ok"),
421 );
422
423 assert_eq!(messages.len(), 2);
424 let assistant = &messages[0];
425 assert!(matches!(assistant.role, ct::BetaMessageRole::Assistant));
426 let blocks = match &assistant.content {
427 ct::BetaMessageContent::Blocks(blocks) => blocks,
428 _ => panic!("expected blocks after sanitization"),
429 };
430 assert_eq!(blocks.len(), 2);
431 assert!(matches!(blocks[0], ct::BetaContentBlockParam::Text(_)));
432 match &blocks[1] {
433 ct::BetaContentBlockParam::ToolUse(tu) => {
434 assert_eq!(tu.id, "toolu_xyz");
435 assert_eq!(tu.name, ORPHAN_TOOL_USE_PLACEHOLDER_NAME);
436 }
437 _ => panic!("expected tool_use placeholder"),
438 }
439 }
440
441 #[test]
442 fn inserts_placeholder_when_previous_message_is_user_text() {
443 let mut messages = vec![ct::BetaMessageParam {
444 content: ct::BetaMessageContent::Text("context".to_string()),
445 role: ct::BetaMessageRole::User,
446 }];
447 push_message_block(
448 &mut messages,
449 ct::BetaMessageRole::User,
450 tool_result_block("toolu_orphan", "value"),
451 );
452
453 for window in messages.windows(2) {
454 assert_ne!(
455 window[0].role, window[1].role,
456 "consecutive same-role messages produced: {messages:#?}"
457 );
458 }
459 let result_pos = messages
460 .iter()
461 .position(|m| {
462 matches!(&m.content, ct::BetaMessageContent::Blocks(blocks)
463 if blocks.iter().any(|b| matches!(b, ct::BetaContentBlockParam::ToolResult(_))))
464 })
465 .expect("tool_result message");
466 assert!(result_pos > 0);
467 let prior = &messages[result_pos - 1];
468 assert!(matches!(prior.role, ct::BetaMessageRole::Assistant));
469 assert_eq!(tool_use_ids_in(prior), vec!["toolu_orphan"]);
470 }
471}