1use tracing::{debug, error, info};
2
3use super::LlmClient;
4use super::error::LlmError;
5use crate::convert::{thinking::build_thinking_params, to_openai};
6use crate::types::anthropic::{Message, MessagesRequest, MessagesResponse};
7use crate::types::common::{Provider, ResponseFormat, ThinkingConfig, ToolDefinition};
8use crate::types::openai::{self, ChatRequest};
9
10#[derive(Default)]
12pub struct ChatOptions<'a> {
13 pub system: Option<&'a str>,
14 pub tools: Option<&'a [ToolDefinition]>,
15 pub thinking: Option<&'a ThinkingConfig>,
16 pub temperature: Option<f32>,
17 pub response_format: Option<&'a ResponseFormat>,
18}
19
20impl LlmClient {
21 pub async fn chat(
26 &self,
27 messages: &[Message],
28 options: ChatOptions<'_>,
29 ) -> Result<MessagesResponse, LlmError> {
30 info!(
31 "Sending request to LLM (provider: {}, model: {}, messages: {})",
32 self.config.provider,
33 self.config.model,
34 messages.len()
35 );
36
37 match self.config.provider {
38 Provider::Anthropic => self.chat_anthropic(messages, &options).await,
39 Provider::OpenAiCompatible => self.chat_openai_compat(messages, &options).await,
40 }
41 }
42
43 pub async fn complete(
48 &self,
49 user: &str,
50 options: ChatOptions<'_>,
51 ) -> Result<MessagesResponse, LlmError> {
52 let messages = vec![Message::user_text(user)];
53 self.chat(&messages, options).await
54 }
55
56 pub async fn chat_openai_raw(
60 &self,
61 request: &ChatRequest,
62 ) -> Result<openai::ChatResponse, LlmError> {
63 let url = format!("{}/v1/chat/completions", self.config.base_url);
64 debug!("POST {url} (model: {})", request.model);
65
66 let response = self
67 .http
68 .post(&url)
69 .header("Authorization", format!("Bearer {}", self.config.api_key))
70 .header("content-type", "application/json")
71 .json(request)
72 .send()
73 .await?;
74
75 let status = response.status();
76 if !status.is_success() {
77 let body = response.text().await.unwrap_or_default();
78 error!("API error {status}: {body}");
79 return Err(LlmError::ApiError {
80 status: status.as_u16(),
81 body,
82 });
83 }
84
85 let resp: openai::ChatResponse = response.json().await.map_err(|e| {
86 error!("Failed to parse response: {e}");
87 LlmError::ParseResponse(e.to_string())
88 })?;
89
90 Ok(resp)
91 }
92
93 async fn chat_anthropic(
96 &self,
97 messages: &[Message],
98 options: &ChatOptions<'_>,
99 ) -> Result<MessagesResponse, LlmError> {
100 let (thinking, output_config) = build_thinking_params(options.thinking);
101
102 let request_body = MessagesRequest {
103 model: self.config.model.clone(),
104 max_tokens: self.config.max_tokens,
105 system: options.system.map(|s| s.to_string()),
106 messages: messages.to_vec(),
107 tools: options.tools.map(|t| t.to_vec()),
108 thinking,
109 output_config,
110 };
111
112 let url = format!("{}/v1/messages", self.config.base_url);
113 debug!("POST {url} (model: {})", self.config.model);
114
115 let response = self
116 .http
117 .post(&url)
118 .header("x-api-key", &self.config.api_key)
119 .header("anthropic-version", "2023-06-01")
120 .header("content-type", "application/json")
121 .json(&request_body)
122 .send()
123 .await?;
124
125 let status = response.status();
126 if !status.is_success() {
127 let body = response.text().await.unwrap_or_default();
128 error!("API error {status}: {body}");
129 return Err(LlmError::ApiError {
130 status: status.as_u16(),
131 body,
132 });
133 }
134
135 let resp: MessagesResponse = response.json().await.map_err(|e| {
136 error!("Failed to parse response: {e}");
137 LlmError::ParseResponse(e.to_string())
138 })?;
139
140 info!(
141 "LLM responded (stop_reason: {}, content blocks: {})",
142 resp.stop_reason,
143 resp.content.len()
144 );
145 Ok(resp)
146 }
147
148 async fn chat_openai_compat(
149 &self,
150 messages: &[Message],
151 options: &ChatOptions<'_>,
152 ) -> Result<MessagesResponse, LlmError> {
153 let openai_messages = to_openai::messages_to_openai(options.system, messages);
154
155 let tools = options.tools.map(to_openai::tools_to_openai);
156
157 let request_body = openai::ChatRequest {
158 model: self.config.model.clone(),
159 max_tokens: Some(self.config.max_tokens),
160 messages: openai_messages,
161 temperature: options.temperature,
162 tools,
163 response_format: options.response_format.cloned(),
164 };
165
166 let url = format!("{}/v1/chat/completions", self.config.base_url);
167 debug!("POST {url} (model: {})", self.config.model);
168
169 let response = self
170 .http
171 .post(&url)
172 .header("Authorization", format!("Bearer {}", self.config.api_key))
173 .header("content-type", "application/json")
174 .json(&request_body)
175 .send()
176 .await?;
177
178 let status = response.status();
179 if !status.is_success() {
180 let body = response.text().await.unwrap_or_default();
181 error!("API error {status}: {body}");
182 return Err(LlmError::ApiError {
183 status: status.as_u16(),
184 body,
185 });
186 }
187
188 let openai_resp: openai::ChatResponse = response.json().await.map_err(|e| {
189 error!("Failed to parse response: {e}");
190 LlmError::ParseResponse(e.to_string())
191 })?;
192
193 let resp = to_openai::response_to_anthropic(openai_resp).map_err(LlmError::Conversion)?;
194
195 info!(
196 "LLM responded (stop_reason: {}, content blocks: {})",
197 resp.stop_reason,
198 resp.content.len()
199 );
200 Ok(resp)
201 }
202}