Skip to main content

hyper_agent_ai/
claude.rs

1use serde::{Deserialize, Serialize};
2
3// ---------------------------------------------------------------------------
4// Claude Model Catalog
5// ---------------------------------------------------------------------------
6
7/// All available Claude API models.
8///
9/// Latest generation models are listed first, followed by legacy models.
10/// Use the alias variants for auto-upgrading to the latest snapshot,
11/// or the dated variants for pinned, reproducible behavior.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13pub enum ClaudeModel {
14    // ── Latest generation ─────────────────────────────────────────────
15    /// Opus 4.6 — most intelligent, best for agents & coding.
16    #[serde(rename = "claude-opus-4-6")]
17    Opus4_6,
18    /// Sonnet 4.6 — best speed/intelligence balance.
19    #[serde(rename = "claude-sonnet-4-6")]
20    Sonnet4_6,
21    /// Haiku 4.5 — fastest, near-frontier intelligence.
22    #[serde(rename = "claude-haiku-4-5-20251001")]
23    Haiku4_5,
24
25    // ── Legacy ────────────────────────────────────────────────────────
26    /// Sonnet 4.5
27    #[serde(rename = "claude-sonnet-4-5-20250929")]
28    Sonnet4_5,
29    /// Opus 4.5
30    #[serde(rename = "claude-opus-4-5-20251101")]
31    Opus4_5,
32    /// Opus 4.1
33    #[serde(rename = "claude-opus-4-1-20250805")]
34    Opus4_1,
35    /// Sonnet 4
36    #[serde(rename = "claude-sonnet-4-20250514")]
37    Sonnet4,
38    /// Opus 4
39    #[serde(rename = "claude-opus-4-20250514")]
40    Opus4,
41    /// Haiku 3 (deprecated, retiring 2026-04-19)
42    #[serde(rename = "claude-3-haiku-20240307")]
43    Haiku3,
44}
45
46impl ClaudeModel {
47    /// The exact API model ID string.
48    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    /// Input price per million tokens (USD).
63    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    /// Output price per million tokens (USD).
74    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    /// Max output tokens.
85    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// ---------------------------------------------------------------------------
103// Types
104// ---------------------------------------------------------------------------
105
106/// Represents a tool definition sent to Claude API.
107#[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/// A tool call returned by Claude in a response.
115#[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/// The parsed decision from a Claude API response.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124#[serde(rename_all = "camelCase")]
125pub struct AgentDecision {
126    /// The reasoning / thinking text from the model.
127    pub reasoning: String,
128    /// Optional tool calls the model wants to execute.
129    pub tool_calls: Vec<ToolCall>,
130    /// Confidence level extracted from the response (0.0 - 1.0), default 0.5.
131    pub confidence: f64,
132    /// Raw content blocks for debugging.
133    pub raw_content: Vec<serde_json::Value>,
134}
135
136/// Errors specific to the Claude API client.
137#[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
159// ---------------------------------------------------------------------------
160// motosan-ai backed call_claude
161// ---------------------------------------------------------------------------
162
163/// Call Claude via motosan-ai and return a parsed AgentDecision.
164///
165/// This replaces the former hand-rolled `ClaudeClient::send_and_parse`.
166/// `max_retries` configures exponential-backoff retry for rate-limit (429)
167/// and transient errors. Pass `0` to disable retries.
168pub 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
230/// Map motosan-ai errors into ClaudeError.
231fn 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
242// ---------------------------------------------------------------------------
243// Response Parsing (kept for backward compat / raw JSON responses)
244// ---------------------------------------------------------------------------
245
246/// Parse a Claude API response JSON into an AgentDecision.
247pub 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                    // Try to extract confidence from text (e.g., "Confidence: 0.8")
272                    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
304/// Try to extract a confidence value from text like "Confidence: 0.85" or "confidence: 0.7".
305fn extract_confidence(text: &str) -> Option<f64> {
306    let lower = text.to_lowercase();
307    // Look for patterns like "confidence: 0.85" or "confidence level: 0.9"
308    for line in lower.lines() {
309        let line = line.trim();
310        if line.contains("confidence") {
311            // Try to find a float after "confidence" keyword
312            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
327// ---------------------------------------------------------------------------
328// Trading Tool Definitions
329// ---------------------------------------------------------------------------
330
331/// Returns the standard set of trading tool definitions for the Claude agent.
332pub 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// ---------------------------------------------------------------------------
495// Tests
496// ---------------------------------------------------------------------------
497
498#[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    // ---- ClaudeModel enum tests ----
738
739    #[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; // Copy
846        let cloned = model.clone(); // Clone
847        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    // ---- RetryPolicy integration tests ----
866
867    #[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        // Without jitter, delays should be exponential: 1000, 2000, 4000
909        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        // 1000 * 2^4 = 16000, capped at 5000
925        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}