//! Claude (Anthropic) adapter. //! //! POST `https://api.anthropic.com/v1/messages`. Auth via `x-api-key` //! header (not bearer) + required `anthropic-version` header. Payload //! is NOT OpenAI-compatible — response text lives at //! `content[0].text`. Phase 40 deliverable. System prompts travel in //! a top-level `system` field, separate from the `messages` array. use std::time::Duration; use serde::{Deserialize, Serialize}; use super::{ChatRequest, ChatResponse, Choice, Message, UsageBlock}; const CLAUDE_BASE_URL: &str = "https://api.anthropic.com/v1"; const CLAUDE_API_VERSION: &str = "2023-06-01"; const CLAUDE_TIMEOUT_SECS: u64 = 180; pub fn resolve_claude_key() -> Option { if let Ok(k) = std::env::var("ANTHROPIC_API_KEY") { if !k.trim().is_empty() { return Some(k.trim().to_string()); } } for path in ["/home/profit/.env", "/root/.env"] { if let Ok(raw) = std::fs::read_to_string(path) { for line in raw.lines() { if let Some(rest) = line.strip_prefix("ANTHROPIC_API_KEY=") { let k = rest.trim().trim_matches('"').trim_matches('\''); if !k.is_empty() { return Some(k.to_string()); } } } } } None } pub async fn chat( key: &str, req: &ChatRequest, ) -> Result { // Strip the "claude/" prefix if the caller used the namespaced form. let model = req.model.strip_prefix("claude/").unwrap_or(&req.model).to_string(); // Anthropic carries system prompts outside the messages array. // Concatenate any system-role messages into a single system string; // keep user + assistant messages in `messages`. let mut system_parts: Vec = Vec::new(); let mut msgs: Vec = Vec::new(); for m in &req.messages { if m.role == "system" { system_parts.push(m.text()); } else { // Anthropic expects strictly "user" or "assistant"; anything // else we normalize to "user". let role = if m.role == "assistant" { "assistant" } else { "user" }; msgs.push(AnMessage { role: role.to_string(), content: m.text() }); } } let system = if system_parts.is_empty() { None } else { Some(system_parts.join("\n\n")) }; let body = AnChatBody { model: model.clone(), messages: msgs, max_tokens: req.max_tokens.unwrap_or(800), temperature: req.temperature.unwrap_or(0.3), system, }; let client = reqwest::Client::builder() .timeout(Duration::from_secs(CLAUDE_TIMEOUT_SECS)) .build() .map_err(|e| format!("build client: {e}"))?; let t0 = std::time::Instant::now(); let resp = client .post(format!("{}/messages", CLAUDE_BASE_URL)) .header("x-api-key", key) .header("anthropic-version", CLAUDE_API_VERSION) .json(&body) .send() .await .map_err(|e| format!("api.anthropic.com unreachable: {e}"))?; let status = resp.status(); if !status.is_success() { let body = resp.text().await.unwrap_or_else(|_| "?".into()); return Err(format!("claude {}: {}", status, body)); } let parsed: AnChatResponse = resp.json().await .map_err(|e| format!("invalid claude response: {e}"))?; let latency_ms = t0.elapsed().as_millis(); let text = parsed.content.into_iter() .find(|b| b.block_type == "text") .map(|b| b.text) .unwrap_or_default(); let prompt_tokens = parsed.usage.as_ref().map(|u| u.input_tokens).unwrap_or_else(|| { let chars: usize = req.messages.iter().map(|m| m.text().chars().count()).sum(); ((chars + 3) / 4) as u32 }); let completion_tokens = parsed.usage.as_ref().map(|u| u.output_tokens).unwrap_or_else(|| { ((text.chars().count() + 3) / 4) as u32 }); tracing::info!( target: "v1.chat", provider = "claude", model = %model, prompt_tokens, completion_tokens, latency_ms = latency_ms as u64, "claude chat completed", ); Ok(ChatResponse { id: format!("chatcmpl-{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)), object: "chat.completion", created: chrono::Utc::now().timestamp(), model, choices: vec![Choice { index: 0, message: Message::new_text("assistant", text), finish_reason: parsed.stop_reason.unwrap_or_else(|| "stop".into()), }], usage: UsageBlock { prompt_tokens, completion_tokens, total_tokens: prompt_tokens + completion_tokens, }, }) } // -- Anthropic Messages API wire shapes -- #[derive(Serialize)] struct AnChatBody { model: String, messages: Vec, max_tokens: u32, temperature: f64, #[serde(skip_serializing_if = "Option::is_none")] system: Option, } #[derive(Serialize)] struct AnMessage { role: String, content: String } #[derive(Deserialize)] struct AnChatResponse { content: Vec, #[serde(default, rename = "stop_reason")] stop_reason: Option, #[serde(default)] usage: Option, } #[derive(Deserialize)] struct AnContentBlock { #[serde(rename = "type")] block_type: String, #[serde(default)] text: String, } #[derive(Deserialize)] struct AnUsage { input_tokens: u32, output_tokens: u32 } #[cfg(test)] mod tests { use super::*; #[test] fn resolve_claude_key_does_not_panic() { let _ = resolve_claude_key(); } #[test] fn chat_body_serializes_with_separate_system() { let body = AnChatBody { model: "claude-3-5-sonnet-latest".into(), messages: vec![ AnMessage { role: "user".into(), content: "hi".into() }, ], max_tokens: 800, temperature: 0.3, system: Some("You are helpful.".into()), }; let json = serde_json::to_string(&body).unwrap(); assert!(json.contains("\"system\":\"You are helpful.\"")); assert!(json.contains("\"messages\"")); assert!(json.contains("\"max_tokens\":800")); } #[test] fn body_omits_system_when_none() { let body = AnChatBody { model: "claude-3-5-sonnet-latest".into(), messages: vec![AnMessage { role: "user".into(), content: "hi".into() }], max_tokens: 800, temperature: 0.3, system: None, }; let json = serde_json::to_string(&body).unwrap(); assert!(!json.contains("\"system\""), "system field should be skipped when None: {json}"); } #[test] fn model_prefix_strip_preserves_bare_names() { let cases = [ ("claude/claude-3-5-sonnet-latest", "claude-3-5-sonnet-latest"), ("claude-3-5-sonnet-latest", "claude-3-5-sonnet-latest"), ]; for (input, expected) in cases { let out = input.strip_prefix("claude/").unwrap_or(input); assert_eq!(out, expected); } } }