1use std::future::Future;
16use std::pin::Pin;
17use std::sync::Arc;
18use std::time::Duration;
19
20use serde::{Deserialize, Serialize};
21
22use crate::config::AgentConfig;
23use crate::error::{AgentError, ErrorKind};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ChatRole {
29 System,
31 User,
33 Assistant,
35 Tool,
37}
38
39#[allow(clippy::derive_partial_eq_without_eq)]
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum ContentPart {
49 Text(String),
51 ToolCall {
53 id: String,
55 name: String,
57 arguments: serde_json::Value,
59 },
60 ToolResult {
62 tool_call_id: String,
64 content: String,
66 },
67}
68
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct ChatMessage {
75 pub role: ChatRole,
77 pub content: Vec<ContentPart>,
79}
80
81impl ChatMessage {
82 #[must_use]
84 pub fn text(role: ChatRole, text: impl Into<String>) -> Self {
85 Self {
86 role,
87 content: vec![ContentPart::Text(text.into())],
88 }
89 }
90}
91
92#[allow(clippy::derive_partial_eq_without_eq)]
95#[derive(Debug, Clone, PartialEq)]
96pub struct ToolDefinition {
97 pub name: String,
99 pub description: String,
101 pub input_schema: serde_json::Value,
103}
104
105#[derive(Debug, Clone, PartialEq)]
107pub struct ChatRequest {
108 pub messages: Vec<ChatMessage>,
110 pub tools: Vec<ToolDefinition>,
112 pub max_tokens: Option<u32>,
114 pub temperature: Option<f32>,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum StopReason {
121 EndTurn,
123 ToolUse,
125 MaxTokens,
127 Unknown,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
137pub struct TokenUsage {
138 pub input_tokens: u32,
140 pub output_tokens: u32,
142 #[serde(default)]
144 pub cache_read_tokens: u32,
145 #[serde(default)]
147 pub cache_write_tokens: u32,
148}
149
150impl TokenUsage {
151 #[must_use]
153 pub const fn new(input_tokens: u32, output_tokens: u32) -> Self {
154 Self {
155 input_tokens,
156 output_tokens,
157 cache_read_tokens: 0,
158 cache_write_tokens: 0,
159 }
160 }
161
162 #[must_use]
164 pub const fn saturating_add(self, other: Self) -> Self {
165 Self {
166 input_tokens: self.input_tokens.saturating_add(other.input_tokens),
167 output_tokens: self.output_tokens.saturating_add(other.output_tokens),
168 cache_read_tokens: self
169 .cache_read_tokens
170 .saturating_add(other.cache_read_tokens),
171 cache_write_tokens: self
172 .cache_write_tokens
173 .saturating_add(other.cache_write_tokens),
174 }
175 }
176
177 #[must_use]
179 pub const fn total(self) -> u32 {
180 self.input_tokens.saturating_add(self.output_tokens)
181 }
182}
183
184#[derive(Debug, Clone, PartialEq)]
186pub struct ChatResponse {
187 pub content: Vec<ContentPart>,
189 pub stop_reason: StopReason,
191 pub usage: TokenUsage,
193}
194
195pub trait LlmClient: Send + Sync + std::fmt::Debug {
200 fn chat<'a>(
202 &'a self,
203 request: &'a ChatRequest,
204 ) -> Pin<Box<dyn Future<Output = Result<ChatResponse, AgentError>> + Send + 'a>>;
205
206 fn list_models(
208 &self,
209 ) -> Pin<Box<dyn Future<Output = Result<Vec<String>, AgentError>> + Send + '_>>;
210
211 fn provider_name(&self) -> &'static str;
213}
214
215pub fn client_from_config(config: &AgentConfig) -> Result<Arc<dyn LlmClient>, AgentError> {
227 let api_key = config.api_key()?;
228 client_from_config_with_key(config, &api_key)
229}
230
231pub(crate) fn client_from_config_with_key(
236 config: &AgentConfig,
237 api_key: &str,
238) -> Result<Arc<dyn LlmClient>, AgentError> {
239 let timeout = Duration::from_secs(config.request_timeout_secs);
240 let model = config.resolved_model().to_owned();
241 let base_url = config.resolved_base_url().to_owned();
242 let client: Arc<dyn LlmClient> = match config.provider {
243 crate::config::ProviderKind::OpenAiCompatible => {
244 Arc::new(OpenAiCompatibleClient::new(base_url, model, api_key)?.with_timeout(timeout)?)
245 }
246 crate::config::ProviderKind::Anthropic => Arc::new(
247 AnthropicClient::new(base_url, model, api_key)?
248 .with_timeout(timeout)?
249 .with_prompt_caching(config.prompt_caching),
250 ),
251 };
252 Ok(client)
253}
254
255async fn check_status(
257 response: reqwest::Response,
258 provider: &'static str,
259) -> Result<reqwest::Response, AgentError> {
260 let status = response.status();
261 if status.is_success() {
262 return Ok(response);
263 }
264 let body: String = response.text().await.unwrap_or_default();
265 let snippet: String = body.chars().take(500).collect();
266 let message = format!("{provider} answered {status}: {snippet}");
267 let kind = match status {
268 reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => {
269 ErrorKind::Authentication
270 }
271 reqwest::StatusCode::TOO_MANY_REQUESTS => ErrorKind::RateLimited,
272 reqwest::StatusCode::PAYLOAD_TOO_LARGE => ErrorKind::Budget,
273 _ => ErrorKind::Provider,
274 };
275 Err(AgentError::new(kind, message))
276}
277
278fn model_ids(payload: &serde_json::Value) -> Vec<String> {
280 payload
281 .get("data")
282 .and_then(serde_json::Value::as_array)
283 .map(|items| {
284 items
285 .iter()
286 .filter_map(|item| item.get("id"))
287 .filter_map(serde_json::Value::as_str)
288 .map(str::to_owned)
289 .collect()
290 })
291 .unwrap_or_default()
292}
293
294fn usage_field(usage: &serde_json::Value, key: &str) -> u32 {
296 usage
297 .get(key)
298 .and_then(serde_json::Value::as_u64)
299 .map_or(0, |n| u32::try_from(n).unwrap_or(u32::MAX))
300}
301
302fn http_client(timeout: Duration) -> Result<reqwest::Client, AgentError> {
304 reqwest::Client::builder()
305 .timeout(timeout)
306 .build()
307 .map_err(|err| {
308 AgentError::with_source(ErrorKind::Transport, "cannot build HTTP client", err)
309 })
310}
311
312pub struct OpenAiCompatibleClient {
317 http: reqwest::Client,
318 base_url: String,
319 model: String,
320 api_key: String,
321}
322
323impl std::fmt::Debug for OpenAiCompatibleClient {
324 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
325 f.debug_struct("OpenAiCompatibleClient")
326 .field("base_url", &self.base_url)
327 .field("model", &self.model)
328 .field("api_key", &"<redacted>")
329 .finish_non_exhaustive()
330 }
331}
332
333impl OpenAiCompatibleClient {
334 pub fn new(
340 base_url: impl Into<String>,
341 model: impl Into<String>,
342 api_key: impl Into<String>,
343 ) -> Result<Self, AgentError> {
344 Ok(Self {
345 http: http_client(Duration::from_secs(60))?,
346 base_url: base_url.into().trim_end_matches('/').to_owned(),
347 model: model.into(),
348 api_key: api_key.into(),
349 })
350 }
351
352 pub fn with_timeout(mut self, timeout: Duration) -> Result<Self, AgentError> {
358 self.http = http_client(timeout)?;
359 Ok(self)
360 }
361
362 #[must_use]
364 pub fn chat_url(&self) -> String {
365 format!("{}/chat/completions", self.base_url)
366 }
367
368 async fn chat_inner(&self, request: &ChatRequest) -> Result<ChatResponse, AgentError> {
369 let mut body = serde_json::json!({
370 "model": self.model,
371 "messages": openai_messages(&request.messages),
372 "tools": request.tools.iter().map(openai_tool).collect::<Vec<_>>(),
373 });
374 if request.tools.is_empty() {
375 if let Some(obj) = body.as_object_mut() {
376 obj.remove("tools");
377 }
378 } else {
379 body["tool_choice"] = serde_json::Value::String("auto".to_owned());
380 }
381 if let Some(max_tokens) = request.max_tokens {
382 body["max_tokens"] = serde_json::json!(max_tokens);
383 }
384 if let Some(temperature) = request.temperature {
385 body["temperature"] = serde_json::json!(temperature);
386 }
387 let response = self
388 .http
389 .post(self.chat_url())
390 .bearer_auth(&self.api_key)
391 .json(&body)
392 .send()
393 .await
394 .map_err(AgentError::from)?;
395 let response = check_status(response, "openai-compatible").await?;
396 let payload: serde_json::Value = response.json().await.map_err(AgentError::from)?;
397 openai_response(&payload)
398 }
399}
400
401impl LlmClient for OpenAiCompatibleClient {
402 fn chat<'a>(
403 &'a self,
404 request: &'a ChatRequest,
405 ) -> Pin<Box<dyn Future<Output = Result<ChatResponse, AgentError>> + Send + 'a>> {
406 Box::pin(self.chat_inner(request))
407 }
408
409 fn list_models(
410 &self,
411 ) -> Pin<Box<dyn Future<Output = Result<Vec<String>, AgentError>> + Send + '_>> {
412 Box::pin(async move {
413 let response = self
414 .http
415 .get(format!("{}/models", self.base_url))
416 .bearer_auth(&self.api_key)
417 .send()
418 .await
419 .map_err(AgentError::from)?;
420 let response = check_status(response, "openai-compatible").await?;
421 let payload: serde_json::Value = response.json().await.map_err(AgentError::from)?;
422 Ok(model_ids(&payload))
423 })
424 }
425
426 fn provider_name(&self) -> &'static str {
427 "openai-compatible"
428 }
429}
430
431fn openai_messages(messages: &[ChatMessage]) -> Vec<serde_json::Value> {
433 let mut out = Vec::new();
434 for message in messages {
435 match message.role {
436 ChatRole::System | ChatRole::User => {
437 let role = match message.role {
438 ChatRole::System => "system",
439 _ => "user",
440 };
441 let text = message
442 .content
443 .iter()
444 .filter_map(|part| match part {
445 ContentPart::Text(text) => Some(text.as_str()),
446 _ => None,
447 })
448 .collect::<Vec<_>>()
449 .join("");
450 out.push(serde_json::json!({"role": role, "content": text}));
451 }
452 ChatRole::Assistant => {
453 let text = message
454 .content
455 .iter()
456 .filter_map(|part| match part {
457 ContentPart::Text(text) => Some(text.as_str()),
458 _ => None,
459 })
460 .collect::<Vec<_>>()
461 .join("");
462 let tool_calls = message
463 .content
464 .iter()
465 .filter_map(|part| match part {
466 ContentPart::ToolCall {
467 id,
468 name,
469 arguments,
470 } => Some(serde_json::json!({
471 "id": id,
472 "type": "function",
473 "function": {
474 "name": name,
475 "arguments": arguments.to_string(),
476 }
477 })),
478 _ => None,
479 })
480 .collect::<Vec<_>>();
481 let mut value = serde_json::json!({"role": "assistant", "content": text});
482 if !tool_calls.is_empty() {
483 value["tool_calls"] = serde_json::Value::Array(tool_calls);
484 }
485 out.push(value);
486 }
487 ChatRole::Tool => {
488 for part in &message.content {
489 if let ContentPart::ToolResult {
490 tool_call_id,
491 content,
492 } = part
493 {
494 out.push(serde_json::json!({
495 "role": "tool",
496 "tool_call_id": tool_call_id,
497 "content": content,
498 }));
499 }
500 }
501 }
502 }
503 }
504 out
505}
506
507fn openai_tool(tool: &ToolDefinition) -> serde_json::Value {
509 serde_json::json!({
510 "type": "function",
511 "function": {
512 "name": tool.name,
513 "description": tool.description,
514 "parameters": tool.input_schema,
515 }
516 })
517}
518
519fn openai_response(payload: &serde_json::Value) -> Result<ChatResponse, AgentError> {
521 let choice = payload
522 .get("choices")
523 .and_then(serde_json::Value::as_array)
524 .and_then(|choices| choices.first())
525 .ok_or_else(|| {
526 AgentError::new(
527 ErrorKind::Decode,
528 "openai-compatible response has no choices",
529 )
530 })?;
531 let message = choice.get("message").ok_or_else(|| {
532 AgentError::new(ErrorKind::Decode, "openai-compatible choice has no message")
533 })?;
534 let mut content = Vec::new();
535 if let Some(text) = message.get("content").and_then(serde_json::Value::as_str)
536 && !text.is_empty()
537 {
538 content.push(ContentPart::Text(text.to_owned()));
539 }
540 if let Some(calls) = message
541 .get("tool_calls")
542 .and_then(serde_json::Value::as_array)
543 {
544 for call in calls {
545 let id = call
546 .get("id")
547 .and_then(serde_json::Value::as_str)
548 .unwrap_or_default()
549 .to_owned();
550 let function = call.get("function").ok_or_else(|| {
551 AgentError::new(ErrorKind::Decode, "tool_call has no function payload")
552 })?;
553 let name = function
554 .get("name")
555 .and_then(serde_json::Value::as_str)
556 .unwrap_or_default()
557 .to_owned();
558 let raw_args = function
559 .get("arguments")
560 .and_then(serde_json::Value::as_str)
561 .unwrap_or("{}");
562 let arguments: serde_json::Value = serde_json::from_str(raw_args).map_err(|err| {
563 AgentError::with_source(
564 ErrorKind::Decode,
565 "tool_call arguments are not valid JSON",
566 err,
567 )
568 })?;
569 content.push(ContentPart::ToolCall {
570 id,
571 name,
572 arguments,
573 });
574 }
575 }
576 let stop_reason = match choice
577 .get("finish_reason")
578 .and_then(serde_json::Value::as_str)
579 {
580 Some("tool_calls") => StopReason::ToolUse,
581 Some("length") => StopReason::MaxTokens,
582 Some("stop") => StopReason::EndTurn,
583 _ => StopReason::Unknown,
584 };
585 let usage = payload
588 .get("usage")
589 .map(|usage| TokenUsage {
590 input_tokens: usage_field(usage, "prompt_tokens"),
591 output_tokens: usage_field(usage, "completion_tokens"),
592 cache_read_tokens: usage
593 .get("prompt_tokens_details")
594 .map_or(0, |details| usage_field(details, "cached_tokens")),
595 cache_write_tokens: 0,
596 })
597 .unwrap_or_default();
598 Ok(ChatResponse {
599 content,
600 stop_reason,
601 usage,
602 })
603}
604
605pub struct AnthropicClient {
611 http: reqwest::Client,
612 base_url: String,
613 model: String,
614 api_key: String,
615 prompt_caching: bool,
616}
617
618impl std::fmt::Debug for AnthropicClient {
619 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
620 f.debug_struct("AnthropicClient")
621 .field("base_url", &self.base_url)
622 .field("model", &self.model)
623 .field("prompt_caching", &self.prompt_caching)
624 .field("api_key", &"<redacted>")
625 .finish_non_exhaustive()
626 }
627}
628
629impl AnthropicClient {
630 pub const API_VERSION: &'static str = "2023-06-01";
632
633 pub fn new(
639 base_url: impl Into<String>,
640 model: impl Into<String>,
641 api_key: impl Into<String>,
642 ) -> Result<Self, AgentError> {
643 Ok(Self {
644 http: http_client(Duration::from_secs(60))?,
645 base_url: base_url.into().trim_end_matches('/').to_owned(),
646 model: model.into(),
647 api_key: api_key.into(),
648 prompt_caching: true,
649 })
650 }
651
652 pub fn with_timeout(mut self, timeout: Duration) -> Result<Self, AgentError> {
658 self.http = http_client(timeout)?;
659 Ok(self)
660 }
661
662 #[must_use]
664 pub const fn with_prompt_caching(mut self, enabled: bool) -> Self {
665 self.prompt_caching = enabled;
666 self
667 }
668
669 #[must_use]
671 pub fn messages_url(&self) -> String {
672 format!("{}/v1/messages", self.base_url)
673 }
674
675 async fn chat_inner(&self, request: &ChatRequest) -> Result<ChatResponse, AgentError> {
676 let (system, mut messages) = anthropic_messages(&request.messages);
677 let mut tools = request.tools.iter().map(anthropic_tool).collect::<Vec<_>>();
678 if self.prompt_caching {
679 mark_cache_breakpoints(&mut tools, &mut messages);
680 }
681 let mut body = serde_json::json!({
682 "model": self.model,
683 "max_tokens": request.max_tokens.unwrap_or(1024),
684 "messages": messages,
685 "tools": tools,
686 });
687 if let Some(system) = system {
688 body["system"] = if self.prompt_caching {
689 serde_json::json!([{
690 "type": "text",
691 "text": system,
692 "cache_control": {"type": "ephemeral"},
693 }])
694 } else {
695 serde_json::Value::String(system)
696 };
697 }
698 if let Some(temperature) = request.temperature {
699 body["temperature"] = serde_json::json!(temperature);
700 }
701 let response = self
702 .http
703 .post(self.messages_url())
704 .header("x-api-key", &self.api_key)
705 .header("anthropic-version", Self::API_VERSION)
706 .json(&body)
707 .send()
708 .await
709 .map_err(AgentError::from)?;
710 let response = check_status(response, "anthropic").await?;
711 let payload: serde_json::Value = response.json().await.map_err(AgentError::from)?;
712 anthropic_response(&payload)
713 }
714}
715
716impl LlmClient for AnthropicClient {
717 fn chat<'a>(
718 &'a self,
719 request: &'a ChatRequest,
720 ) -> Pin<Box<dyn Future<Output = Result<ChatResponse, AgentError>> + Send + 'a>> {
721 Box::pin(self.chat_inner(request))
722 }
723
724 fn list_models(
725 &self,
726 ) -> Pin<Box<dyn Future<Output = Result<Vec<String>, AgentError>> + Send + '_>> {
727 Box::pin(async move {
728 let response = self
729 .http
730 .get(format!("{}/v1/models", self.base_url))
731 .header("x-api-key", &self.api_key)
732 .header("anthropic-version", Self::API_VERSION)
733 .send()
734 .await
735 .map_err(AgentError::from)?;
736 let response = check_status(response, "anthropic").await?;
737 let payload: serde_json::Value = response.json().await.map_err(AgentError::from)?;
738 Ok(model_ids(&payload))
739 })
740 }
741
742 fn provider_name(&self) -> &'static str {
743 "anthropic"
744 }
745}
746
747fn anthropic_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<serde_json::Value>) {
750 let mut system_parts = Vec::new();
751 let mut out = Vec::new();
752 for message in messages {
753 match message.role {
754 ChatRole::System => {
755 for part in &message.content {
756 if let ContentPart::Text(text) = part {
757 system_parts.push(text.clone());
758 }
759 }
760 }
761 ChatRole::User | ChatRole::Assistant | ChatRole::Tool => {
762 let role = match message.role {
763 ChatRole::Assistant => "assistant",
764 _ => "user",
765 };
766 let blocks = message
767 .content
768 .iter()
769 .map(|part| match part {
770 ContentPart::Text(text) => {
771 serde_json::json!({"type": "text", "text": text})
772 }
773 ContentPart::ToolCall {
774 id,
775 name,
776 arguments,
777 } => serde_json::json!({
778 "type": "tool_use",
779 "id": id,
780 "name": name,
781 "input": arguments,
782 }),
783 ContentPart::ToolResult {
784 tool_call_id,
785 content,
786 } => serde_json::json!({
787 "type": "tool_result",
788 "tool_use_id": tool_call_id,
789 "content": content,
790 }),
791 })
792 .collect::<Vec<_>>();
793 if !blocks.is_empty() {
794 out.push(serde_json::json!({"role": role, "content": blocks}));
795 }
796 }
797 }
798 }
799 let system = if system_parts.is_empty() {
800 None
801 } else {
802 Some(system_parts.join("\n"))
803 };
804 (system, out)
805}
806
807fn mark_cache_breakpoints(tools: &mut [serde_json::Value], messages: &mut [serde_json::Value]) {
814 let ephemeral = serde_json::json!({"type": "ephemeral"});
815 if let Some(serde_json::Value::Object(tool)) = tools.last_mut() {
816 tool.insert("cache_control".to_owned(), ephemeral.clone());
817 }
818 if let Some(serde_json::Value::Object(block)) = messages
819 .last_mut()
820 .and_then(|message| message.get_mut("content"))
821 .and_then(serde_json::Value::as_array_mut)
822 .and_then(|blocks| blocks.last_mut())
823 {
824 block.insert("cache_control".to_owned(), ephemeral);
825 }
826}
827
828fn anthropic_tool(tool: &ToolDefinition) -> serde_json::Value {
830 serde_json::json!({
831 "name": tool.name,
832 "description": tool.description,
833 "input_schema": tool.input_schema,
834 })
835}
836
837fn anthropic_response(payload: &serde_json::Value) -> Result<ChatResponse, AgentError> {
839 let blocks = payload
840 .get("content")
841 .and_then(serde_json::Value::as_array)
842 .ok_or_else(|| {
843 AgentError::new(
844 ErrorKind::Decode,
845 "anthropic response has no content blocks",
846 )
847 })?;
848 let mut content = Vec::new();
849 for block in blocks {
850 let block_type = block.get("type").and_then(serde_json::Value::as_str);
851 match block_type {
852 Some("text") => {
853 if let Some(text) = block.get("text").and_then(serde_json::Value::as_str) {
854 content.push(ContentPart::Text(text.to_owned()));
855 }
856 }
857 Some("tool_use") => {
858 let id = block
859 .get("id")
860 .and_then(serde_json::Value::as_str)
861 .unwrap_or_default()
862 .to_owned();
863 let name = block
864 .get("name")
865 .and_then(serde_json::Value::as_str)
866 .unwrap_or_default()
867 .to_owned();
868 let arguments = block
869 .get("input")
870 .cloned()
871 .unwrap_or(serde_json::Value::Null);
872 content.push(ContentPart::ToolCall {
873 id,
874 name,
875 arguments,
876 });
877 }
878 _ => {}
879 }
880 }
881 let stop_reason = match payload
882 .get("stop_reason")
883 .and_then(serde_json::Value::as_str)
884 {
885 Some("tool_use") => StopReason::ToolUse,
886 Some("max_tokens") => StopReason::MaxTokens,
887 Some("end_turn") => StopReason::EndTurn,
888 _ => StopReason::Unknown,
889 };
890 let usage = payload
894 .get("usage")
895 .map(|usage| {
896 let cache_read_tokens = usage_field(usage, "cache_read_input_tokens");
897 let cache_write_tokens = usage_field(usage, "cache_creation_input_tokens");
898 TokenUsage {
899 input_tokens: usage_field(usage, "input_tokens")
900 .saturating_add(cache_read_tokens)
901 .saturating_add(cache_write_tokens),
902 output_tokens: usage_field(usage, "output_tokens"),
903 cache_read_tokens,
904 cache_write_tokens,
905 }
906 })
907 .unwrap_or_default();
908 Ok(ChatResponse {
909 content,
910 stop_reason,
911 usage,
912 })
913}
914
915#[cfg(test)]
916mod tests;