1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13pub enum ClaudeModel {
14 #[serde(rename = "claude-opus-4-6")]
17 Opus4_6,
18 #[serde(rename = "claude-sonnet-4-6")]
20 Sonnet4_6,
21 #[serde(rename = "claude-haiku-4-5-20251001")]
23 Haiku4_5,
24
25 #[serde(rename = "claude-sonnet-4-5-20250929")]
28 Sonnet4_5,
29 #[serde(rename = "claude-opus-4-5-20251101")]
31 Opus4_5,
32 #[serde(rename = "claude-opus-4-1-20250805")]
34 Opus4_1,
35 #[serde(rename = "claude-sonnet-4-20250514")]
37 Sonnet4,
38 #[serde(rename = "claude-opus-4-20250514")]
40 Opus4,
41 #[serde(rename = "claude-3-haiku-20240307")]
43 Haiku3,
44}
45
46impl ClaudeModel {
47 pub fn as_str(&self) -> &'static str {
49 match self {
50 Self::Opus4_6 => "claude-opus-4-6",
51 Self::Sonnet4_6 => "claude-sonnet-4-6",
52 Self::Haiku4_5 => "claude-haiku-4-5-20251001",
53 Self::Sonnet4_5 => "claude-sonnet-4-5-20250929",
54 Self::Opus4_5 => "claude-opus-4-5-20251101",
55 Self::Opus4_1 => "claude-opus-4-1-20250805",
56 Self::Sonnet4 => "claude-sonnet-4-20250514",
57 Self::Opus4 => "claude-opus-4-20250514",
58 Self::Haiku3 => "claude-3-haiku-20240307",
59 }
60 }
61
62 pub fn input_price_per_mtok(&self) -> f64 {
64 match self {
65 Self::Opus4_6 | Self::Opus4_5 => 5.0,
66 Self::Sonnet4_6 | Self::Sonnet4_5 | Self::Sonnet4 => 3.0,
67 Self::Opus4_1 | Self::Opus4 => 15.0,
68 Self::Haiku4_5 => 1.0,
69 Self::Haiku3 => 0.25,
70 }
71 }
72
73 pub fn output_price_per_mtok(&self) -> f64 {
75 match self {
76 Self::Opus4_6 | Self::Opus4_5 => 25.0,
77 Self::Sonnet4_6 | Self::Sonnet4_5 | Self::Sonnet4 => 15.0,
78 Self::Opus4_1 | Self::Opus4 => 75.0,
79 Self::Haiku4_5 => 5.0,
80 Self::Haiku3 => 1.25,
81 }
82 }
83
84 pub fn max_output_tokens(&self) -> u32 {
86 match self {
87 Self::Opus4_6 => 128_000,
88 Self::Sonnet4_6 | Self::Sonnet4_5 | Self::Sonnet4 | Self::Haiku4_5 => 64_000,
89 Self::Opus4_5 => 64_000,
90 Self::Opus4_1 | Self::Opus4 => 32_000,
91 Self::Haiku3 => 4_096,
92 }
93 }
94}
95
96impl std::fmt::Display for ClaudeModel {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 f.write_str(self.as_str())
99 }
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct ToolDefinition {
109 pub name: String,
110 pub description: String,
111 pub input_schema: serde_json::Value,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116#[serde(rename_all = "camelCase")]
117pub struct ToolCall {
118 pub tool_name: String,
119 pub tool_input: serde_json::Value,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
124#[serde(rename_all = "camelCase")]
125pub struct AgentDecision {
126 pub reasoning: String,
128 pub tool_calls: Vec<ToolCall>,
130 pub confidence: f64,
132 pub raw_content: Vec<serde_json::Value>,
134}
135
136#[derive(Debug, thiserror::Error)]
138pub enum ClaudeError {
139 #[error("API key not set")]
140 ApiKeyNotSet,
141 #[error("HTTP error: {0}")]
142 HttpError(String),
143 #[error("API error (status {status}): {message}")]
144 ApiError { status: u16, message: String },
145 #[error("Rate limited: retry after {retry_after_secs}s")]
146 RateLimited { retry_after_secs: u64 },
147 #[error("Timeout: {0}")]
148 Timeout(String),
149 #[error("Parse error: {0}")]
150 ParseError(String),
151 #[error("Keyring error: {0}")]
152 KeyringError(String),
153 #[error("OAuth error: {0}")]
154 OAuthError(String),
155 #[error("Authentication required — no API key or OAuth token configured")]
156 AuthRequired,
157}
158
159pub async fn call_claude(
169 api_key: &str,
170 model: &str,
171 system: &str,
172 user: &str,
173 tools: &[ToolDefinition],
174 max_retries: u32,
175) -> Result<AgentDecision, ClaudeError> {
176 let retry_policy = motosan_ai::RetryPolicy::new()
177 .max_retries(max_retries)
178 .base_delay_ms(1_000)
179 .max_delay_ms(30_000)
180 .jitter(true)
181 .respect_retry_after(true);
182
183 let client = motosan_ai::Client::builder()
184 .provider(motosan_ai::Provider::Anthropic)
185 .api_key(api_key)
186 .model(model)
187 .retry_policy(retry_policy)
188 .build()
189 .map_err(|e| ClaudeError::HttpError(e.to_string()))?;
190
191 let ai_tools: Vec<motosan_ai::Tool> = tools
192 .iter()
193 .map(|t| motosan_ai::Tool {
194 name: t.name.clone(),
195 description: Some(t.description.clone()),
196 input_schema: Some(t.input_schema.clone()),
197 })
198 .collect();
199
200 let mut req_builder = motosan_ai::ChatRequest::builder()
201 .system(system)
202 .message(motosan_ai::Message::user(user))
203 .max_tokens(4096);
204
205 if !ai_tools.is_empty() {
206 req_builder = req_builder.tools(ai_tools);
207 }
208
209 let req = req_builder.build();
210
211 let resp = client.chat_with(req).await.map_err(map_motosan_error)?;
212
213 let confidence = extract_confidence(&resp.content).unwrap_or(0.5);
214
215 Ok(AgentDecision {
216 reasoning: resp.content,
217 tool_calls: resp
218 .tool_calls
219 .iter()
220 .map(|tc| ToolCall {
221 tool_name: tc.name.clone(),
222 tool_input: tc.input.clone(),
223 })
224 .collect(),
225 confidence,
226 raw_content: vec![],
227 })
228}
229
230fn map_motosan_error(e: motosan_ai::MotosanError) -> ClaudeError {
232 match e {
233 motosan_ai::MotosanError::Auth(_) => ClaudeError::ApiKeyNotSet,
234 motosan_ai::MotosanError::RateLimit(_) => ClaudeError::RateLimited {
235 retry_after_secs: 60,
236 },
237 motosan_ai::MotosanError::Network(msg) => ClaudeError::HttpError(msg),
238 other => ClaudeError::HttpError(other.to_string()),
239 }
240}
241
242pub fn parse_agent_decision(response: &serde_json::Value) -> Result<AgentDecision, ClaudeError> {
248 let content = response
249 .get("content")
250 .and_then(|c| c.as_array())
251 .ok_or_else(|| {
252 ClaudeError::ParseError("Missing 'content' array in response".to_string())
253 })?;
254
255 let mut reasoning = String::new();
256 let mut tool_calls = Vec::new();
257 let mut confidence = 0.5_f64;
258 let raw_content: Vec<serde_json::Value> = content.clone();
259
260 for block in content {
261 let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
262
263 match block_type {
264 "text" => {
265 if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
266 if !reasoning.is_empty() {
267 reasoning.push('\n');
268 }
269 reasoning.push_str(text);
270
271 if let Some(conf) = extract_confidence(text) {
273 confidence = conf;
274 }
275 }
276 }
277 "tool_use" => {
278 let name = block
279 .get("name")
280 .and_then(|n| n.as_str())
281 .unwrap_or("unknown")
282 .to_string();
283 let input = block
284 .get("input")
285 .cloned()
286 .unwrap_or(serde_json::Value::Null);
287 tool_calls.push(ToolCall {
288 tool_name: name,
289 tool_input: input,
290 });
291 }
292 _ => {}
293 }
294 }
295
296 Ok(AgentDecision {
297 reasoning,
298 tool_calls,
299 confidence,
300 raw_content,
301 })
302}
303
304fn extract_confidence(text: &str) -> Option<f64> {
306 let lower = text.to_lowercase();
307 for line in lower.lines() {
309 let line = line.trim();
310 if line.contains("confidence") {
311 for part in line.split_whitespace() {
313 if let Ok(val) = part
314 .trim_matches(|c: char| !c.is_ascii_digit() && c != '.')
315 .parse::<f64>()
316 {
317 if (0.0..=1.0).contains(&val) {
318 return Some(val);
319 }
320 }
321 }
322 }
323 }
324 None
325}
326
327pub fn trading_tools() -> Vec<ToolDefinition> {
333 vec![
334 ToolDefinition {
335 name: "place_order".to_string(),
336 description: "Place a limit or market order on the exchange.".to_string(),
337 input_schema: serde_json::json!({
338 "type": "object",
339 "properties": {
340 "asset": {
341 "type": "integer",
342 "description": "Asset index (e.g., 0 for BTC, 1 for ETH)"
343 },
344 "is_buy": {
345 "type": "boolean",
346 "description": "True for buy, false for sell"
347 },
348 "price": {
349 "type": "string",
350 "description": "Limit price as a decimal string"
351 },
352 "size": {
353 "type": "string",
354 "description": "Order size as a decimal string"
355 },
356 "reduce_only": {
357 "type": "boolean",
358 "description": "If true, only reduce existing position"
359 },
360 "order_type": {
361 "type": "object",
362 "description": "Order type specification, e.g. {\"limit\": {\"tif\": \"Gtc\"}}"
363 }
364 },
365 "required": ["asset", "is_buy", "price", "size", "reduce_only", "order_type"]
366 }),
367 },
368 ToolDefinition {
369 name: "cancel_order".to_string(),
370 description: "Cancel an existing order on the exchange.".to_string(),
371 input_schema: serde_json::json!({
372 "type": "object",
373 "properties": {
374 "asset": {
375 "type": "integer",
376 "description": "Asset index"
377 },
378 "order_id": {
379 "type": "integer",
380 "description": "The order ID to cancel"
381 }
382 },
383 "required": ["asset", "order_id"]
384 }),
385 },
386 ToolDefinition {
387 name: "get_positions".to_string(),
388 description: "Get current open positions and account state.".to_string(),
389 input_schema: serde_json::json!({
390 "type": "object",
391 "properties": {},
392 "required": []
393 }),
394 },
395 ToolDefinition {
396 name: "do_nothing".to_string(),
397 description: "Explicitly decide to take no action. Use this when market conditions don't warrant any trades.".to_string(),
398 input_schema: serde_json::json!({
399 "type": "object",
400 "properties": {
401 "reason": {
402 "type": "string",
403 "description": "Reason for not taking action"
404 }
405 },
406 "required": ["reason"]
407 }),
408 },
409 ToolDefinition {
410 name: "set_stop_loss".to_string(),
411 description: "Set a stop-loss trigger on an existing position. The stop-loss will trigger a market sell (for longs) or market buy (for shorts) when the price reaches the specified trigger price. The position must already exist.".to_string(),
412 input_schema: serde_json::json!({
413 "type": "object",
414 "properties": {
415 "symbol": {
416 "type": "string",
417 "description": "Trading pair symbol of the position, e.g. BTC-PERP"
418 },
419 "trigger_price": {
420 "type": "number",
421 "description": "Price at which the stop-loss triggers. For long positions this should be below entry price; for short positions above entry price."
422 }
423 },
424 "required": ["symbol", "trigger_price"]
425 }),
426 },
427 ToolDefinition {
428 name: "set_take_profit".to_string(),
429 description: "Set a take-profit trigger on an existing position. The take-profit will trigger a market sell (for longs) or market buy (for shorts) when the price reaches the specified trigger price. The position must already exist.".to_string(),
430 input_schema: serde_json::json!({
431 "type": "object",
432 "properties": {
433 "symbol": {
434 "type": "string",
435 "description": "Trading pair symbol of the position, e.g. BTC-PERP"
436 },
437 "trigger_price": {
438 "type": "number",
439 "description": "Price at which the take-profit triggers. For long positions this should be above entry price; for short positions below entry price."
440 }
441 },
442 "required": ["symbol", "trigger_price"]
443 }),
444 },
445 ToolDefinition {
446 name: "get_market_data".to_string(),
447 description: "Get orderbook (bids/asks) for a symbol. Use to check spread and liquidity before placing limit orders.".to_string(),
448 input_schema: serde_json::json!({
449 "type": "object",
450 "properties": {
451 "symbol": {
452 "type": "string",
453 "description": "Trading pair symbol, e.g. BTC-PERP"
454 },
455 "depth": {
456 "type": "integer",
457 "description": "Number of orderbook levels to return (default 5)"
458 }
459 },
460 "required": ["symbol"]
461 }),
462 },
463 ToolDefinition {
464 name: "close_all_positions".to_string(),
465 description: "Emergency: close ALL open positions immediately with market orders. Use this when circuit breaker triggers, major bearish news hits, or system anomaly is detected. This is a one-click panic button that flattens the entire portfolio.".to_string(),
466 input_schema: serde_json::json!({
467 "type": "object",
468 "properties": {
469 "reason": {
470 "type": "string",
471 "description": "Reason for the emergency close (e.g. 'circuit_breaker', 'bearish_news', 'system_anomaly')"
472 }
473 },
474 "required": ["reason"]
475 }),
476 },
477 ToolDefinition {
478 name: "get_funding_rate".to_string(),
479 description: "Get current funding rate for a perpetual contract symbol. Use to assess overnight holding cost. A positive rate means longs pay shorts; negative means shorts pay longs.".to_string(),
480 input_schema: serde_json::json!({
481 "type": "object",
482 "properties": {
483 "symbol": {
484 "type": "string",
485 "description": "Trading pair symbol, e.g. BTC-PERP"
486 }
487 },
488 "required": ["symbol"]
489 }),
490 },
491 ]
492}
493
494#[cfg(test)]
499mod tests {
500 use super::*;
501
502 #[test]
503 fn test_tool_definition_serialization() {
504 let tool = ToolDefinition {
505 name: "test_tool".to_string(),
506 description: "A test tool".to_string(),
507 input_schema: serde_json::json!({
508 "type": "object",
509 "properties": {
510 "param1": {"type": "string"}
511 }
512 }),
513 };
514 let json = serde_json::to_value(&tool).unwrap();
515 assert_eq!(json["name"], "test_tool");
516 assert_eq!(json["description"], "A test tool");
517 }
518
519 #[test]
520 fn test_agent_decision_serialization() {
521 let decision = AgentDecision {
522 reasoning: "Market is bullish".to_string(),
523 tool_calls: vec![ToolCall {
524 tool_name: "place_order".to_string(),
525 tool_input: serde_json::json!({"asset": 0, "is_buy": true}),
526 }],
527 confidence: 0.85,
528 raw_content: vec![],
529 };
530 let json = serde_json::to_value(&decision).unwrap();
531 assert_eq!(json["reasoning"], "Market is bullish");
532 assert_eq!(json["confidence"], 0.85);
533 assert_eq!(json["toolCalls"][0]["toolName"], "place_order");
534 }
535
536 #[test]
537 fn test_agent_decision_deserialization() {
538 let json = serde_json::json!({
539 "reasoning": "Bearish signal",
540 "toolCalls": [],
541 "confidence": 0.3,
542 "rawContent": []
543 });
544 let decision: AgentDecision = serde_json::from_value(json).unwrap();
545 assert_eq!(decision.reasoning, "Bearish signal");
546 assert_eq!(decision.confidence, 0.3);
547 assert!(decision.tool_calls.is_empty());
548 }
549
550 #[test]
551 fn test_parse_agent_decision_text_only() {
552 let response = serde_json::json!({
553 "content": [
554 {
555 "type": "text",
556 "text": "I recommend waiting. The market is uncertain.\nConfidence: 0.3"
557 }
558 ]
559 });
560 let decision = parse_agent_decision(&response).unwrap();
561 assert!(decision.reasoning.contains("recommend waiting"));
562 assert!(decision.tool_calls.is_empty());
563 assert!((decision.confidence - 0.3).abs() < f64::EPSILON);
564 }
565
566 #[test]
567 fn test_parse_agent_decision_with_tool_use() {
568 let response = serde_json::json!({
569 "content": [
570 {
571 "type": "text",
572 "text": "BTC looks strong. Placing a buy order.\nConfidence: 0.85"
573 },
574 {
575 "type": "tool_use",
576 "id": "toolu_123",
577 "name": "place_order",
578 "input": {
579 "asset": 0,
580 "is_buy": true,
581 "price": "65000",
582 "size": "0.01",
583 "reduce_only": false,
584 "order_type": {"limit": {"tif": "Gtc"}}
585 }
586 }
587 ]
588 });
589 let decision = parse_agent_decision(&response).unwrap();
590 assert!(decision.reasoning.contains("BTC looks strong"));
591 assert_eq!(decision.tool_calls.len(), 1);
592 assert_eq!(decision.tool_calls[0].tool_name, "place_order");
593 assert_eq!(decision.tool_calls[0].tool_input["asset"], 0);
594 assert_eq!(decision.tool_calls[0].tool_input["is_buy"], true);
595 assert!((decision.confidence - 0.85).abs() < f64::EPSILON);
596 }
597
598 #[test]
599 fn test_parse_agent_decision_multiple_tool_calls() {
600 let response = serde_json::json!({
601 "content": [
602 {
603 "type": "text",
604 "text": "Rebalancing positions."
605 },
606 {
607 "type": "tool_use",
608 "id": "toolu_1",
609 "name": "cancel_order",
610 "input": {"asset": 0, "order_id": 12345}
611 },
612 {
613 "type": "tool_use",
614 "id": "toolu_2",
615 "name": "place_order",
616 "input": {"asset": 0, "is_buy": false, "price": "70000", "size": "0.05", "reduce_only": true, "order_type": {"limit": {"tif": "Gtc"}}}
617 }
618 ]
619 });
620 let decision = parse_agent_decision(&response).unwrap();
621 assert_eq!(decision.tool_calls.len(), 2);
622 assert_eq!(decision.tool_calls[0].tool_name, "cancel_order");
623 assert_eq!(decision.tool_calls[1].tool_name, "place_order");
624 }
625
626 #[test]
627 fn test_parse_agent_decision_missing_content() {
628 let response = serde_json::json!({});
629 let result = parse_agent_decision(&response);
630 assert!(result.is_err());
631 assert!(matches!(result.unwrap_err(), ClaudeError::ParseError(_)));
632 }
633
634 #[test]
635 fn test_parse_agent_decision_empty_content() {
636 let response = serde_json::json!({"content": []});
637 let decision = parse_agent_decision(&response).unwrap();
638 assert!(decision.reasoning.is_empty());
639 assert!(decision.tool_calls.is_empty());
640 assert!((decision.confidence - 0.5).abs() < f64::EPSILON);
641 }
642
643 #[test]
644 fn test_extract_confidence_basic() {
645 assert_eq!(extract_confidence("Confidence: 0.85"), Some(0.85));
646 assert_eq!(extract_confidence("confidence: 0.7"), Some(0.7));
647 assert_eq!(extract_confidence("My confidence level: 0.9"), Some(0.9));
648 }
649
650 #[test]
651 fn test_extract_confidence_no_match() {
652 assert_eq!(extract_confidence("No confidence here"), None);
653 assert_eq!(extract_confidence("Just some text"), None);
654 }
655
656 #[test]
657 fn test_extract_confidence_out_of_range() {
658 assert_eq!(extract_confidence("Confidence: 5.0"), None);
659 }
660
661 #[test]
662 fn test_extract_confidence_multiline() {
663 let text = "I think we should buy.\nConfidence: 0.8\nEnd of analysis.";
664 assert_eq!(extract_confidence(text), Some(0.8));
665 }
666
667 #[test]
668 fn test_trading_tools_definitions() {
669 let tools = trading_tools();
670 assert_eq!(tools.len(), 9);
671
672 let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
673 assert!(names.contains(&"place_order"));
674 assert!(names.contains(&"cancel_order"));
675 assert!(names.contains(&"get_positions"));
676 assert!(names.contains(&"do_nothing"));
677 assert!(names.contains(&"set_stop_loss"));
678 assert!(names.contains(&"set_take_profit"));
679 assert!(names.contains(&"close_all_positions"));
680 assert!(names.contains(&"get_market_data"));
681 assert!(names.contains(&"get_funding_rate"));
682 }
683
684 #[test]
685 fn test_trading_tools_have_valid_schemas() {
686 let tools = trading_tools();
687 for tool in &tools {
688 assert!(!tool.name.is_empty());
689 assert!(!tool.description.is_empty());
690 assert_eq!(tool.input_schema["type"], "object");
691 assert!(tool.input_schema.get("properties").is_some());
692 }
693 }
694
695 #[test]
696 fn test_tool_call_serialization() {
697 let tc = ToolCall {
698 tool_name: "place_order".to_string(),
699 tool_input: serde_json::json!({"asset": 0}),
700 };
701 let json = serde_json::to_value(&tc).unwrap();
702 assert_eq!(json["toolName"], "place_order");
703 assert_eq!(json["toolInput"]["asset"], 0);
704 }
705
706 #[test]
707 fn test_claude_error_display() {
708 let err = ClaudeError::ApiKeyNotSet;
709 assert_eq!(format!("{}", err), "API key not set");
710
711 let err = ClaudeError::RateLimited {
712 retry_after_secs: 30,
713 };
714 assert_eq!(format!("{}", err), "Rate limited: retry after 30s");
715
716 let err = ClaudeError::ApiError {
717 status: 500,
718 message: "Internal error".to_string(),
719 };
720 assert!(format!("{}", err).contains("500"));
721 assert!(format!("{}", err).contains("Internal error"));
722 }
723
724 #[test]
725 fn test_parse_agent_decision_unknown_block_type_ignored() {
726 let response = serde_json::json!({
727 "content": [
728 {"type": "text", "text": "Analysis done."},
729 {"type": "unknown_type", "data": "ignored"},
730 ]
731 });
732 let decision = parse_agent_decision(&response).unwrap();
733 assert_eq!(decision.reasoning, "Analysis done.");
734 assert!(decision.tool_calls.is_empty());
735 }
736
737 #[test]
740 fn test_claude_model_as_str_all_variants() {
741 assert_eq!(ClaudeModel::Opus4_6.as_str(), "claude-opus-4-6");
742 assert_eq!(ClaudeModel::Sonnet4_6.as_str(), "claude-sonnet-4-6");
743 assert_eq!(ClaudeModel::Haiku4_5.as_str(), "claude-haiku-4-5-20251001");
744 assert_eq!(
745 ClaudeModel::Sonnet4_5.as_str(),
746 "claude-sonnet-4-5-20250929"
747 );
748 assert_eq!(ClaudeModel::Opus4_5.as_str(), "claude-opus-4-5-20251101");
749 assert_eq!(ClaudeModel::Opus4_1.as_str(), "claude-opus-4-1-20250805");
750 assert_eq!(ClaudeModel::Sonnet4.as_str(), "claude-sonnet-4-20250514");
751 assert_eq!(ClaudeModel::Opus4.as_str(), "claude-opus-4-20250514");
752 assert_eq!(ClaudeModel::Haiku3.as_str(), "claude-3-haiku-20240307");
753 }
754
755 #[test]
756 fn test_claude_model_display() {
757 assert_eq!(
758 format!("{}", ClaudeModel::Haiku4_5),
759 "claude-haiku-4-5-20251001"
760 );
761 assert_eq!(
762 format!("{}", ClaudeModel::Sonnet4),
763 "claude-sonnet-4-20250514"
764 );
765 }
766
767 #[test]
768 fn test_claude_model_serde_roundtrip() {
769 let model = ClaudeModel::Haiku4_5;
770 let json = serde_json::to_string(&model).unwrap();
771 assert_eq!(json, "\"claude-haiku-4-5-20251001\"");
772
773 let deserialized: ClaudeModel = serde_json::from_str(&json).unwrap();
774 assert_eq!(deserialized, model);
775 }
776
777 #[test]
778 fn test_claude_model_deserialize_all_variants() {
779 let cases = vec![
780 ("\"claude-opus-4-6\"", ClaudeModel::Opus4_6),
781 ("\"claude-sonnet-4-6\"", ClaudeModel::Sonnet4_6),
782 ("\"claude-haiku-4-5-20251001\"", ClaudeModel::Haiku4_5),
783 ("\"claude-sonnet-4-5-20250929\"", ClaudeModel::Sonnet4_5),
784 ("\"claude-opus-4-5-20251101\"", ClaudeModel::Opus4_5),
785 ("\"claude-opus-4-1-20250805\"", ClaudeModel::Opus4_1),
786 ("\"claude-sonnet-4-20250514\"", ClaudeModel::Sonnet4),
787 ("\"claude-opus-4-20250514\"", ClaudeModel::Opus4),
788 ("\"claude-3-haiku-20240307\"", ClaudeModel::Haiku3),
789 ];
790 for (json_str, expected) in cases {
791 let model: ClaudeModel = serde_json::from_str(json_str).unwrap();
792 assert_eq!(model, expected, "Failed for {}", json_str);
793 }
794 }
795
796 #[test]
797 fn test_claude_model_deserialize_unknown_fails() {
798 let result = serde_json::from_str::<ClaudeModel>("\"claude-unknown-model\"");
799 assert!(result.is_err());
800 }
801
802 #[test]
803 fn test_claude_model_pricing() {
804 assert!(
805 ClaudeModel::Haiku3.input_price_per_mtok()
806 < ClaudeModel::Haiku4_5.input_price_per_mtok()
807 );
808 assert!(
809 ClaudeModel::Haiku4_5.input_price_per_mtok()
810 < ClaudeModel::Sonnet4.input_price_per_mtok()
811 );
812
813 assert_eq!(ClaudeModel::Opus4.input_price_per_mtok(), 15.0);
814 assert_eq!(ClaudeModel::Opus4.output_price_per_mtok(), 75.0);
815
816 let all_models = vec![
817 ClaudeModel::Opus4_6,
818 ClaudeModel::Sonnet4_6,
819 ClaudeModel::Haiku4_5,
820 ClaudeModel::Sonnet4,
821 ClaudeModel::Opus4,
822 ClaudeModel::Haiku3,
823 ];
824 for model in all_models {
825 assert!(
826 model.output_price_per_mtok() > model.input_price_per_mtok(),
827 "{:?} output should cost more than input",
828 model
829 );
830 }
831 }
832
833 #[test]
834 fn test_claude_model_max_output_tokens() {
835 assert_eq!(ClaudeModel::Opus4_6.max_output_tokens(), 128_000);
836 assert_eq!(ClaudeModel::Sonnet4_6.max_output_tokens(), 64_000);
837 assert_eq!(ClaudeModel::Haiku4_5.max_output_tokens(), 64_000);
838 assert_eq!(ClaudeModel::Opus4.max_output_tokens(), 32_000);
839 assert_eq!(ClaudeModel::Haiku3.max_output_tokens(), 4_096);
840 }
841
842 #[test]
843 fn test_claude_model_copy_clone() {
844 let model = ClaudeModel::Sonnet4_6;
845 let copied = model; let cloned = model.clone(); assert_eq!(model, copied);
848 assert_eq!(model, cloned);
849 }
850
851 #[test]
852 fn test_parse_agent_decision_multiple_text_blocks() {
853 let response = serde_json::json!({
854 "content": [
855 {"type": "text", "text": "First thought."},
856 {"type": "text", "text": "Second thought.\nConfidence: 0.6"},
857 ]
858 });
859 let decision = parse_agent_decision(&response).unwrap();
860 assert!(decision.reasoning.contains("First thought."));
861 assert!(decision.reasoning.contains("Second thought."));
862 assert!((decision.confidence - 0.6).abs() < f64::EPSILON);
863 }
864
865 #[test]
868 fn test_retry_policy_default_values() {
869 let policy = motosan_ai::RetryPolicy::new()
870 .max_retries(3)
871 .base_delay_ms(1_000)
872 .max_delay_ms(30_000)
873 .jitter(true)
874 .respect_retry_after(true);
875
876 assert_eq!(policy.max_retries, 3);
877 assert_eq!(policy.base_delay_ms, 1_000);
878 assert_eq!(policy.max_delay_ms, 30_000);
879 assert!(policy.jitter);
880 assert!(policy.respect_retry_after);
881 }
882
883 #[test]
884 fn test_retry_policy_zero_retries() {
885 let policy = motosan_ai::RetryPolicy::new()
886 .max_retries(0)
887 .base_delay_ms(1_000)
888 .max_delay_ms(30_000)
889 .jitter(true)
890 .respect_retry_after(true);
891
892 assert_eq!(policy.max_retries, 0);
893 }
894
895 #[test]
896 fn test_retry_policy_exponential_backoff() {
897 let policy = motosan_ai::RetryPolicy::new()
898 .max_retries(5)
899 .base_delay_ms(1_000)
900 .max_delay_ms(30_000)
901 .jitter(false)
902 .respect_retry_after(false);
903
904 let d1 = policy.delay_for_attempt(1);
905 let d2 = policy.delay_for_attempt(2);
906 let d3 = policy.delay_for_attempt(3);
907
908 assert_eq!(d1.as_millis(), 1_000);
910 assert_eq!(d2.as_millis(), 2_000);
911 assert_eq!(d3.as_millis(), 4_000);
912 }
913
914 #[test]
915 fn test_retry_policy_respects_max_delay() {
916 let policy = motosan_ai::RetryPolicy::new()
917 .max_retries(10)
918 .base_delay_ms(1_000)
919 .max_delay_ms(5_000)
920 .jitter(false)
921 .respect_retry_after(false);
922
923 let d5 = policy.delay_for_attempt(5);
924 assert_eq!(d5.as_millis(), 5_000);
926 }
927
928 #[test]
929 fn test_retry_policy_custom_max_retries() {
930 let policy = motosan_ai::RetryPolicy::new()
931 .max_retries(7)
932 .base_delay_ms(500)
933 .max_delay_ms(10_000)
934 .jitter(false)
935 .respect_retry_after(true);
936
937 assert_eq!(policy.max_retries, 7);
938 assert_eq!(policy.base_delay_ms, 500);
939 assert_eq!(policy.max_delay_ms, 10_000);
940 }
941}