Skip to main content

agentctl_auth/
claude.rs

1//! Claude Messages API client with OAuth, stealth headers, and automatic token rotation.
2//!
3//! ## Tool Use Support
4//!
5//! This module supports Claude's tool use API for building agent loops:
6//!
7//! ```no_run
8//! use agentctl_auth::claude::{Client, Tool, ToolHandler, ToolOutput};
9//! use anyhow::Result;
10//! use async_trait::async_trait;
11//!
12//! struct MyHandler;
13//!
14//! #[async_trait]
15//! impl ToolHandler for MyHandler {
16//!     async fn handle(&self, name: &str, input: &serde_json::Value) -> Result<ToolOutput> {
17//!         match name {
18//!             "read_file" => Ok(ToolOutput::success("file contents")),
19//!             _ => Ok(ToolOutput::error("unknown tool")),
20//!         }
21//!     }
22//! }
23//! ```
24
25use crate::pool::AuthPool;
26use anyhow::{Context, Result};
27use async_trait::async_trait;
28use serde::{Deserialize, Serialize};
29use std::sync::{Arc, Mutex};
30
31/// Claude Messages API client.
32pub struct Client {
33    http: reqwest::Client,
34    pool: Option<Arc<Mutex<AuthPool>>>,
35    current_credential: Arc<Mutex<Option<String>>>,
36    base_url: String,
37}
38
39impl std::fmt::Debug for Client {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        f.debug_struct("Client")
42            .field("base_url", &self.base_url)
43            .finish_non_exhaustive()
44    }
45}
46
47/// Claude API message with support for both text and multi-content (tool use).
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Message {
50    pub role: String,
51    #[serde(flatten)]
52    pub content: MessageContent,
53}
54
55/// Message content — either simple text or array of content blocks.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(untagged)]
58pub enum MessageContent {
59    /// Simple text content (common case).
60    Text { content: String },
61    /// Multi-block content for tool use/results.
62    Blocks { content: Vec<ContentBlock> },
63}
64
65impl Message {
66    pub fn user(content: impl Into<String>) -> Self {
67        Self {
68            role: "user".to_string(),
69            content: MessageContent::Text { content: content.into() },
70        }
71    }
72
73    pub fn assistant(content: impl Into<String>) -> Self {
74        Self {
75            role: "assistant".to_string(),
76            content: MessageContent::Text { content: content.into() },
77        }
78    }
79
80    /// Create an assistant message with content blocks (for tool_use responses).
81    pub fn assistant_blocks(blocks: Vec<ContentBlock>) -> Self {
82        Self {
83            role: "assistant".to_string(),
84            content: MessageContent::Blocks { content: blocks },
85        }
86    }
87
88    /// Create a user message with tool results.
89    pub fn tool_results(results: Vec<ToolResultBlock>) -> Self {
90        Self {
91            role: "user".to_string(),
92            content: MessageContent::Blocks {
93                content: results.into_iter().map(|r| ContentBlock::ToolResult { result: r }).collect(),
94            },
95        }
96    }
97}
98
99// ═══════════════════════════════════════════════════════════════════════════════
100// Tool Use Types
101// ═══════════════════════════════════════════════════════════════════════════════
102
103/// Tool definition for Claude API.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct Tool {
106    pub name: String,
107    pub description: String,
108    pub input_schema: serde_json::Value,
109}
110
111impl Tool {
112    /// Create a new tool definition.
113    pub fn new(name: impl Into<String>, description: impl Into<String>, input_schema: serde_json::Value) -> Self {
114        Self {
115            name: name.into(),
116            description: description.into(),
117            input_schema,
118        }
119    }
120}
121
122/// A tool use block from Claude's response.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct ToolUseBlock {
125    pub id: String,
126    pub name: String,
127    pub input: serde_json::Value,
128}
129
130/// A tool result block for user messages.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct ToolResultBlock {
133    pub tool_use_id: String,
134    pub content: String,
135    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
136    pub is_error: bool,
137}
138
139impl ToolResultBlock {
140    pub fn success(tool_use_id: impl Into<String>, content: impl Into<String>) -> Self {
141        Self {
142            tool_use_id: tool_use_id.into(),
143            content: content.into(),
144            is_error: false,
145        }
146    }
147
148    pub fn error(tool_use_id: impl Into<String>, content: impl Into<String>) -> Self {
149        Self {
150            tool_use_id: tool_use_id.into(),
151            content: content.into(),
152            is_error: true,
153        }
154    }
155}
156
157/// Trait for handling tool calls in agent loops.
158#[async_trait]
159pub trait ToolHandler: Send + Sync {
160    /// Handle a tool call and return the result.
161    async fn handle(&self, name: &str, input: &serde_json::Value) -> Result<ToolOutput>;
162}
163
164/// Output from a tool handler.
165#[derive(Debug, Clone)]
166pub struct ToolOutput {
167    pub content: String,
168    pub is_error: bool,
169}
170
171impl ToolOutput {
172    pub fn success(content: impl Into<String>) -> Self {
173        Self { content: content.into(), is_error: false }
174    }
175
176    pub fn error(content: impl Into<String>) -> Self {
177        Self { content: content.into(), is_error: true }
178    }
179}
180
181/// Result of running an agent loop.
182#[derive(Debug, Clone)]
183pub struct AgentLoopResult {
184    /// Final text output from the agent.
185    pub final_text: String,
186    /// Total input tokens used across all turns.
187    pub total_input_tokens: u64,
188    /// Total output tokens used across all turns.
189    pub total_output_tokens: u64,
190    /// Number of conversation turns used.
191    pub turns_used: u32,
192    /// Names of tools that were called.
193    pub tool_calls: Vec<String>,
194}
195
196/// Claude API request body.
197#[derive(Debug, Serialize)]
198struct MessagesRequest<'a> {
199    model: &'a str,
200    messages: &'a [Message],
201    max_tokens: u32,
202    #[serde(skip_serializing_if = "Option::is_none")]
203    system: Option<&'a str>,
204    #[serde(skip_serializing_if = "Option::is_none")]
205    tools: Option<&'a [Tool]>,
206}
207
208/// Claude API response.
209#[derive(Debug, Deserialize)]
210pub struct MessagesResponse {
211    pub id: String,
212    #[serde(rename = "type")]
213    pub response_type: String,
214    pub role: String,
215    pub content: Vec<ContentBlock>,
216    pub model: String,
217    pub stop_reason: Option<String>,
218    pub usage: Usage,
219}
220
221/// Content block in Claude API responses.
222#[derive(Debug, Clone, Serialize, Deserialize)]
223#[serde(tag = "type", rename_all = "snake_case")]
224pub enum ContentBlock {
225    /// Text content.
226    Text {
227        text: String,
228    },
229    /// Tool use request from Claude.
230    ToolUse {
231        id: String,
232        name: String,
233        input: serde_json::Value,
234    },
235    /// Tool result from user (for multi-turn).
236    ToolResult {
237        #[serde(flatten)]
238        result: ToolResultBlock,
239    },
240}
241
242impl ContentBlock {
243    /// Get the text content if this is a text block.
244    pub fn as_text(&self) -> Option<&str> {
245        match self {
246            ContentBlock::Text { text } => Some(text),
247            _ => None,
248        }
249    }
250
251    /// Get the tool use info if this is a tool_use block.
252    pub fn as_tool_use(&self) -> Option<(&str, &str, &serde_json::Value)> {
253        match self {
254            ContentBlock::ToolUse { id, name, input } => Some((id, name, input)),
255            _ => None,
256        }
257    }
258}
259
260#[derive(Debug, Deserialize)]
261pub struct Usage {
262    pub input_tokens: u32,
263    pub output_tokens: u32,
264}
265
266impl Client {
267    /// Create a new client with a single token (no pool/rotation).
268    #[allow(dead_code)]
269    pub fn with_token(_token: impl Into<String>) -> Self {
270        Self {
271            http: Self::build_http_client(),
272            pool: None,
273            current_credential: Arc::new(Mutex::new(None)),
274            base_url: "https://api.anthropic.com".to_string(),
275        }
276    }
277
278    /// Create a client builder.
279    pub fn builder() -> ClientBuilder {
280        ClientBuilder::new()
281    }
282
283    fn build_http_client() -> reqwest::Client {
284        reqwest::Client::builder()
285            .timeout(std::time::Duration::from_secs(120))
286            .build()
287            .expect("Failed to build HTTP client")
288    }
289
290    /// Send a Messages API request.
291    pub async fn message(
292        &self,
293        model: &str,
294        messages: &[Message],
295        max_tokens: u32,
296    ) -> Result<MessagesResponse> {
297        self.message_with_system(model, messages, max_tokens, None)
298            .await
299    }
300
301    /// Send a Messages API request with system prompt.
302    pub async fn message_with_system(
303        &self,
304        model: &str,
305        messages: &[Message],
306        max_tokens: u32,
307        system: Option<&str>,
308    ) -> Result<MessagesResponse> {
309        self.message_with_tools(model, messages, max_tokens, system, None).await
310    }
311
312    /// Send a Messages API request with system prompt and tools.
313    pub async fn message_with_tools(
314        &self,
315        model: &str,
316        messages: &[Message],
317        max_tokens: u32,
318        system: Option<&str>,
319        tools: Option<&[Tool]>,
320    ) -> Result<MessagesResponse> {
321        let body = MessagesRequest {
322            model,
323            messages,
324            max_tokens,
325            system,
326            tools,
327        };
328
329        let mut attempts = 0;
330        let max_attempts = if self.pool.is_some() { 3 } else { 1 };
331
332        loop {
333            attempts += 1;
334            let (token, cred_name) = self.get_current_token()?;
335
336            let response = self
337                .http
338                .post(format!("{}/v1/messages", self.base_url))
339                .header("x-api-key", &token)
340                .header("anthropic-version", "2023-06-01")
341                .header("content-type", "application/json")
342                // Stealth headers (from Pi-AI SDK)
343                .header("anthropic-beta", "claude-code-20250219,oauth-2025-04-20")
344                .header("user-agent", "claude-cli/2.1.39 (external, cli)")
345                .header("x-app", "cli")
346                .header("anthropic-dangerous-direct-browser-access", "true")
347                .json(&body)
348                .send()
349                .await
350                .context("Failed to send request to Claude API")?;
351
352            let status = response.status();
353
354            if status.is_success() {
355                // Success — record usage and return
356                if let Some(ref pool) = self.pool {
357                    if let Some(ref name) = cred_name {
358                        pool.lock().unwrap().record_usage(name, true);
359                    }
360                }
361                let result: MessagesResponse = response
362                    .json()
363                    .await
364                    .context("Failed to parse Claude API response")?;
365                return Ok(result);
366            } else if status.as_u16() == 429 {
367                // Rate limit — record failure and rotate
368                tracing::warn!(
369                    credential = cred_name.as_deref().unwrap_or("<unknown>"),
370                    "Claude API 429 rate limit, rotating credential"
371                );
372
373                if let Some(ref pool) = self.pool {
374                    let mut pool_guard = pool.lock().unwrap();
375                    if let Some(ref name) = cred_name {
376                        pool_guard.record_usage(name, false);
377
378                        // Try to get next credential
379                        if let Some((next_name, _next_cred)) =
380                            pool_guard.next_credential("anthropic", name)
381                        {
382                            tracing::info!(next = next_name, "Rotating to next credential");
383                            *self.current_credential.lock().unwrap() =
384                                Some(next_name.to_string());
385                            if attempts < max_attempts {
386                                continue;
387                            }
388                        }
389                    }
390                }
391
392                // Out of credentials or retries
393                anyhow::bail!("Claude API rate limit (429) and no more credentials to rotate");
394            } else {
395                // Other error
396                let error_text = response
397                    .text()
398                    .await
399                    .unwrap_or_else(|_| "<failed to read error>".to_string());
400                anyhow::bail!("Claude API error {}: {}", status, error_text);
401            }
402        }
403    }
404
405    /// Run an agent loop with tool use support.
406    ///
407    /// This method sends the initial message, handles tool calls via the handler,
408    /// and continues the conversation until Claude stops calling tools or max_turns
409    /// is reached.
410    ///
411    /// # Arguments
412    /// - `model`: The model to use (e.g., "claude-sonnet-4-5")
413    /// - `system`: System prompt
414    /// - `initial_message`: The user's initial request
415    /// - `tools`: Tool definitions
416    /// - `max_turns`: Maximum conversation turns (each turn = one API call)
417    /// - `tool_handler`: Handler for executing tools
418    ///
419    /// # Returns
420    /// An [`AgentLoopResult`] with final text, total tokens, turns used, and tool calls.
421    pub async fn run_agent_loop(
422        &self,
423        model: &str,
424        system: &str,
425        initial_message: &str,
426        tools: &[Tool],
427        max_turns: u32,
428        tool_handler: &dyn ToolHandler,
429    ) -> Result<AgentLoopResult> {
430        let mut messages = vec![Message::user(initial_message)];
431        let mut total_input_tokens: u64 = 0;
432        let mut total_output_tokens: u64 = 0;
433        let mut turns_used: u32 = 0;
434        let mut tool_calls: Vec<String> = Vec::new();
435        let mut final_text = String::new();
436
437        loop {
438            if turns_used >= max_turns {
439                tracing::warn!(turns = turns_used, max = max_turns, "Agent loop hit max turns");
440                break;
441            }
442
443            turns_used += 1;
444            tracing::debug!(turn = turns_used, "Agent loop turn");
445
446            let response = self
447                .message_with_tools(model, &messages, 16384, Some(system), Some(tools))
448                .await?;
449
450            total_input_tokens += response.usage.input_tokens as u64;
451            total_output_tokens += response.usage.output_tokens as u64;
452
453            // Collect tool uses and text from response
454            let mut pending_tool_uses: Vec<(String, String, serde_json::Value)> = Vec::new();
455            let mut response_text = String::new();
456
457            for block in &response.content {
458                match block {
459                    ContentBlock::Text { text } => {
460                        response_text.push_str(text);
461                    }
462                    ContentBlock::ToolUse { id, name, input } => {
463                        pending_tool_uses.push((id.clone(), name.clone(), input.clone()));
464                        tool_calls.push(name.clone());
465                    }
466                    ContentBlock::ToolResult { .. } => {
467                        // Shouldn't appear in response, but ignore
468                    }
469                }
470            }
471
472            final_text = response_text;
473
474            // Check stop reason
475            let stop_reason = response.stop_reason.as_deref().unwrap_or("");
476            if stop_reason == "end_turn" && pending_tool_uses.is_empty() {
477                // Normal completion
478                tracing::debug!("Agent loop completed normally");
479                break;
480            }
481
482            if pending_tool_uses.is_empty() {
483                // No tool calls and not end_turn — unexpected but treat as complete
484                tracing::debug!(stop_reason, "Agent loop ended (no tool calls)");
485                break;
486            }
487
488            // Add assistant message with tool uses to history
489            messages.push(Message::assistant_blocks(response.content.clone()));
490
491            // Execute tools and collect results
492            let mut results: Vec<ToolResultBlock> = Vec::new();
493            for (id, name, input) in pending_tool_uses {
494                tracing::debug!(tool = %name, "Executing tool");
495                let output = tool_handler.handle(&name, &input).await;
496                match output {
497                    Ok(out) => {
498                        if out.is_error {
499                            results.push(ToolResultBlock::error(&id, out.content));
500                        } else {
501                            results.push(ToolResultBlock::success(&id, out.content));
502                        }
503                    }
504                    Err(e) => {
505                        results.push(ToolResultBlock::error(&id, format!("Tool error: {}", e)));
506                    }
507                }
508            }
509
510            // Add tool results as user message
511            messages.push(Message::tool_results(results));
512        }
513
514        Ok(AgentLoopResult {
515            final_text,
516            total_input_tokens,
517            total_output_tokens,
518            turns_used,
519            tool_calls,
520        })
521    }
522
523    fn get_current_token(&self) -> Result<(String, Option<String>)> {
524        if let Some(ref pool) = self.pool {
525            let pool_guard = pool.lock().unwrap();
526
527            // If current_credential is set, use it
528            let current_lock = self.current_credential.lock().unwrap();
529            if let Some(ref name) = *current_lock {
530                if let Some(cred) = pool_guard.get(name) {
531                    let token = cred
532                        .resolved_token()
533                        .ok_or_else(|| anyhow::anyhow!("Credential '{}' has no token", name))?
534                        .to_string();
535                    return Ok((token, Some(name.clone())));
536                }
537            }
538            drop(current_lock);
539
540            // Otherwise, get default
541            if let Some((name, cred)) = pool_guard.get_default("anthropic") {
542                let token = cred
543                    .resolved_token()
544                    .ok_or_else(|| anyhow::anyhow!("Credential '{}' has no token", name))?
545                    .to_string();
546                *self.current_credential.lock().unwrap() = Some(name.to_string());
547                return Ok((token, Some(name.to_string())));
548            }
549
550            anyhow::bail!("No anthropic credentials in pool");
551        } else {
552            // No pool — must have been constructed with with_token (not currently implemented)
553            anyhow::bail!("No pool configured and with_token not yet implemented");
554        }
555    }
556}
557
558/// Builder for Claude client.
559pub struct ClientBuilder {
560    pool: Option<Arc<Mutex<AuthPool>>>,
561    base_url: Option<String>,
562}
563
564impl ClientBuilder {
565    pub fn new() -> Self {
566        Self {
567            pool: None,
568            base_url: None,
569        }
570    }
571
572    /// Use an auth pool for automatic token rotation.
573    pub fn pool(mut self, pool: &AuthPool) -> Self {
574        self.pool = Some(Arc::new(Mutex::new(pool.clone())));
575        self
576    }
577
578    /// Set a custom base URL (e.g., for proxies).
579    #[allow(dead_code)]
580    pub fn base_url(mut self, url: impl Into<String>) -> Self {
581        self.base_url = Some(url.into());
582        self
583    }
584
585    /// Build the client.
586    pub fn build(self) -> Result<Client> {
587        let pool = self
588            .pool
589            .ok_or_else(|| anyhow::anyhow!("Pool is required (use .pool())"))?;
590
591        Ok(Client {
592            http: Client::build_http_client(),
593            pool: Some(pool),
594            current_credential: Arc::new(Mutex::new(None)),
595            base_url: self
596                .base_url
597                .unwrap_or_else(|| "https://api.anthropic.com".to_string()),
598        })
599    }
600}
601
602impl Default for ClientBuilder {
603    fn default() -> Self {
604        Self::new()
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    #[test]
613    fn test_message_construction() {
614        let msg = Message::user("Hello!");
615        assert_eq!(msg.role, "user");
616        match msg.content {
617            MessageContent::Text { content } => assert_eq!(content, "Hello!"),
618            _ => panic!("Expected text content"),
619        }
620
621        let msg = Message::assistant("Hi there");
622        assert_eq!(msg.role, "assistant");
623        match msg.content {
624            MessageContent::Text { content } => assert_eq!(content, "Hi there"),
625            _ => panic!("Expected text content"),
626        }
627    }
628
629    #[test]
630    fn test_tool_result_block() {
631        let success = ToolResultBlock::success("id-123", "file contents");
632        assert_eq!(success.tool_use_id, "id-123");
633        assert_eq!(success.content, "file contents");
634        assert!(!success.is_error);
635
636        let error = ToolResultBlock::error("id-456", "not found");
637        assert!(error.is_error);
638    }
639
640    #[test]
641    fn test_tool_definition() {
642        let tool = Tool::new(
643            "read_file",
644            "Read a file's contents",
645            serde_json::json!({
646                "type": "object",
647                "properties": {
648                    "path": { "type": "string" }
649                },
650                "required": ["path"]
651            }),
652        );
653        assert_eq!(tool.name, "read_file");
654        assert_eq!(tool.description, "Read a file's contents");
655    }
656
657    #[test]
658    fn test_content_block_helpers() {
659        let text = ContentBlock::Text { text: "hello".to_string() };
660        assert_eq!(text.as_text(), Some("hello"));
661        assert!(text.as_tool_use().is_none());
662
663        let tool_use = ContentBlock::ToolUse {
664            id: "id-1".to_string(),
665            name: "bash".to_string(),
666            input: serde_json::json!({"command": "ls"}),
667        };
668        assert!(tool_use.as_text().is_none());
669        let (id, name, input) = tool_use.as_tool_use().unwrap();
670        assert_eq!(id, "id-1");
671        assert_eq!(name, "bash");
672        assert_eq!(input["command"], "ls");
673    }
674
675    #[tokio::test]
676    async fn test_client_requires_pool() {
677        let result = Client::builder().build();
678        assert!(result.is_err());
679        assert!(result.unwrap_err().to_string().contains("Pool is required"));
680    }
681}