claude-dashboard 0.2.2

A terminal TUI dashboard that renders Claude Code usage reports with token stats, project breakdowns, and language distribution.
Documentation
use super::config::LlmApiConfig;

/// Call the Anthropic-compatible API (e.g. DeepSeek) with the haiku model.
/// Uses blocking reqwest — this must run on a background thread.
pub fn call_haiku_api(prompt: &str, config: &LlmApiConfig, thinking_mode: bool) -> Result<(String, Option<String>), String> {
    let client = reqwest::blocking::Client::new();

    let mut body = serde_json::json!({
        "model": config.haiku_model,
        "max_tokens": if thinking_mode { 8192 } else { 1024 },
        "messages": [
            {"role": "user", "content": prompt}
        ]
    });

    if thinking_mode {
        body["thinking"] = serde_json::json!({
            "type": "enabled",
            "budget_tokens": 4096
        });
    }

    let url = format!("{}/v1/messages", config.base_url.trim_end_matches('/'));

    let resp = client
        .post(&url)
        .header("x-api-key", &config.auth_token)
        .header("anthropic-version", "2023-06-01")
        .header("Content-Type", "application/json")
        .json(&body)
        .send()
        .map_err(|e| format!("API request failed: {}", e))?;

    if !resp.status().is_success() {
        let status = resp.status();
        let text = resp.text().unwrap_or_default();
        return Err(format!("API returned {}: {}", status, text));
    }

    let json: serde_json::Value = resp
        .json()
        .map_err(|e| format!("Failed to parse API response: {}", e))?;

    // Extract text from response — supports multiple API formats
    let blocks = json["content"].as_array();

    // Anthropic format: { "content": [{"type": "text", "text": "..."}] }
    let text = blocks
        .and_then(|bs| {
            let parts: Vec<String> = bs.iter()
                .filter(|b| b["type"].as_str() == Some("text"))
                .filter_map(|b| b["text"].as_str())
                .map(|s| s.to_string())
                .collect();
            if parts.is_empty() { None } else { Some(parts.join("\n")) }
        })
        // OpenAI-compatible fallback: choices[0].message.content
        .or_else(|| {
            json["choices"][0]["message"]["content"]
                .as_str()
                .map(|s| s.to_string())
        })
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "(empty response)".to_string());

    // Extract thinking/reasoning content — supports multiple API formats
    let thinking = if thinking_mode {
        // Format 1: Anthropic-style content blocks with type "thinking"
        let anthropic_thinking = blocks.and_then(|bs| {
            let parts: Vec<String> = bs.iter()
                .filter(|b| b["type"].as_str() == Some("thinking"))
                .filter_map(|b| b["thinking"].as_str())
                .map(|s| s.to_string())
                .collect();
            if parts.is_empty() { None } else { Some(parts.join("\n\n")) }
        });

        // Format 2: DeepSeek/OpenAI — reasoning_content in choices[0].message
        let openai_thinking = json["choices"][0]["message"]["reasoning_content"]
            .as_str()
            .map(|s| s.to_string());

        // Format 3: Some APIs use "reasoning" key instead
        let alt_thinking = json["choices"][0]["message"]["reasoning"]
            .as_str()
            .map(|s| s.to_string());

        anthropic_thinking
            .or(openai_thinking)
            .or(alt_thinking)
    } else {
        None
    };

    Ok((text, thinking))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_call_haiku_api_error_on_bad_url() {
        let config = LlmApiConfig {
            auth_token: "bad-token".into(),
            base_url: "https://invalid.example.com".into(),
            haiku_model: "test-model".into(),
        };
        let result = call_haiku_api("hello", &config, false);
        assert!(result.is_err());
    }
}