1use crate::pool::AuthPool;
26use anyhow::{Context, Result};
27use async_trait::async_trait;
28use serde::{Deserialize, Serialize};
29use std::sync::{Arc, Mutex};
30
31pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Message {
50 pub role: String,
51 #[serde(flatten)]
52 pub content: MessageContent,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(untagged)]
58pub enum MessageContent {
59 Text { content: String },
61 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 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 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#[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 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#[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#[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#[async_trait]
159pub trait ToolHandler: Send + Sync {
160 async fn handle(&self, name: &str, input: &serde_json::Value) -> Result<ToolOutput>;
162}
163
164#[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#[derive(Debug, Clone)]
183pub struct AgentLoopResult {
184 pub final_text: String,
186 pub total_input_tokens: u64,
188 pub total_output_tokens: u64,
190 pub turns_used: u32,
192 pub tool_calls: Vec<String>,
194}
195
196#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
223#[serde(tag = "type", rename_all = "snake_case")]
224pub enum ContentBlock {
225 Text {
227 text: String,
228 },
229 ToolUse {
231 id: String,
232 name: String,
233 input: serde_json::Value,
234 },
235 ToolResult {
237 #[serde(flatten)]
238 result: ToolResultBlock,
239 },
240}
241
242impl ContentBlock {
243 pub fn as_text(&self) -> Option<&str> {
245 match self {
246 ContentBlock::Text { text } => Some(text),
247 _ => None,
248 }
249 }
250
251 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 #[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 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 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 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 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 .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 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 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 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 anyhow::bail!("Claude API rate limit (429) and no more credentials to rotate");
394 } else {
395 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 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 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 }
469 }
470 }
471
472 final_text = response_text;
473
474 let stop_reason = response.stop_reason.as_deref().unwrap_or("");
476 if stop_reason == "end_turn" && pending_tool_uses.is_empty() {
477 tracing::debug!("Agent loop completed normally");
479 break;
480 }
481
482 if pending_tool_uses.is_empty() {
483 tracing::debug!(stop_reason, "Agent loop ended (no tool calls)");
485 break;
486 }
487
488 messages.push(Message::assistant_blocks(response.content.clone()));
490
491 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 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 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 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 anyhow::bail!("No pool configured and with_token not yet implemented");
554 }
555 }
556}
557
558pub 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 pub fn pool(mut self, pool: &AuthPool) -> Self {
574 self.pool = Some(Arc::new(Mutex::new(pool.clone())));
575 self
576 }
577
578 #[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 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}