1use async_trait::async_trait;
2use eventsource_stream::Eventsource;
3use futures_core::Stream;
4use futures_util::StreamExt;
5use reqwest::Client;
6use serde_json::{Value, json};
7use std::pin::Pin;
8use std::time::Duration;
9
10use super::{LlmCapabilities, LlmClient, ReasoningConfig, ReasoningEffort, StreamChunk, UsageInfo};
11use crate::types::{
12 AgentError, AgentResult, ChatMessage, ImageAttachment, ImageDetail, ResponseFormat,
13 ToolCallMessage,
14};
15
16#[derive(Clone, Debug)]
17pub struct LlmClientConfig {
18 pub connect_timeout: Duration,
19 pub request_timeout: Duration,
20 pub pool_max_idle_per_host: usize,
21 pub pool_idle_timeout: Duration,
22}
23
24impl Default for LlmClientConfig {
25 fn default() -> Self {
26 Self {
27 connect_timeout: Duration::from_secs(15),
28 request_timeout: Duration::from_secs(120),
29 pool_max_idle_per_host: 10,
30 pool_idle_timeout: Duration::from_secs(90),
31 }
32 }
33}
34
35pub struct OpenAiClient {
36 api_key: String,
37 model: String,
38 base_url: String,
39 client: Client,
40}
41
42impl OpenAiClient {
43 pub fn new(api_key: String, model: String, base_url: Option<String>) -> Self {
44 Self::new_with_config(api_key, model, base_url, LlmClientConfig::default())
45 }
46
47 pub fn new_with_config(
48 api_key: String,
49 model: String,
50 base_url: Option<String>,
51 config: LlmClientConfig,
52 ) -> Self {
53 let client = Client::builder()
54 .connect_timeout(config.connect_timeout)
55 .timeout(config.request_timeout)
56 .pool_max_idle_per_host(config.pool_max_idle_per_host)
57 .pool_idle_timeout(config.pool_idle_timeout)
58 .build()
59 .unwrap_or_else(|e| {
60 tracing::warn!(error = %e, "Failed to build reqwest client with custom config, falling back to default");
61 Client::new()
62 });
63 Self {
64 api_key,
65 model,
66 base_url: base_url.unwrap_or_else(|| "https://api.openai.com/v1".to_string()),
67 client,
68 }
69 }
70
71 pub fn with_model(&self, model: impl Into<String>) -> Self {
75 Self {
76 api_key: self.api_key.clone(),
77 model: model.into(),
78 base_url: self.base_url.clone(),
79 client: self.client.clone(), }
81 }
82
83 fn is_qwen_model(&self) -> bool {
84 self.model.starts_with("qwen")
85 }
86
87 fn is_deepseek_model(&self) -> bool {
88 self.model.starts_with("deepseek")
89 }
90
91 fn apply_reasoning_config(
92 &self,
93 request_body: &mut Value,
94 reasoning: Option<&ReasoningConfig>,
95 ) {
96 let Some(config) = reasoning else { return };
97
98 if self.is_qwen_model() {
99 if let Some(enabled) = config.enabled {
102 if let Some(obj) = request_body.as_object_mut() {
103 obj.insert("enable_thinking".to_string(), json!(enabled));
104 }
105 }
106 if let Some(budget) = config.budget_tokens {
107 if let Some(obj) = request_body.as_object_mut() {
108 obj.insert("thinking_budget".to_string(), json!(budget));
109 }
110 }
111 if let Some(effort) = &config.effort {
113 let budget = match effort {
114 ReasoningEffort::None => 0,
115 ReasoningEffort::Low => 500,
116 ReasoningEffort::Medium => 2000,
117 ReasoningEffort::High => 5000,
118 ReasoningEffort::XHigh => 10000,
119 };
120 if let Some(obj) = request_body.as_object_mut() {
121 obj.insert("thinking_budget".to_string(), json!(budget));
122 if matches!(effort, ReasoningEffort::None | ReasoningEffort::Low) {
124 obj.insert("enable_thinking".to_string(), json!(false));
125 } else {
126 obj.insert("enable_thinking".to_string(), json!(true));
127 }
128 }
129 }
130 } else if self.is_deepseek_model() {
131 if let Some(effort) = &config.effort {
132 let effort_str = match effort {
133 ReasoningEffort::None => "none",
134 ReasoningEffort::Low => "low",
135 ReasoningEffort::Medium => "medium",
136 ReasoningEffort::High => "high",
137 ReasoningEffort::XHigh => "high",
138 };
139 if let Some(obj) = request_body.as_object_mut() {
140 obj.insert("reasoning_effort".to_string(), json!(effort_str));
141 }
142 }
143 if config.enabled == Some(true) || config.budget_tokens.is_some() {
144 let mut extra_body = serde_json::Map::new();
145 if let Some(enabled) = config.enabled {
146 extra_body.insert(
147 "thinking".to_string(),
148 json!({"type": if enabled { "enabled" } else { "disabled" }}),
149 );
150 }
151 if let Some(budget) = config.budget_tokens {
152 extra_body.insert("thinking_budget".to_string(), json!(budget));
153 }
154 if !extra_body.is_empty() {
155 if let Some(obj) = request_body.as_object_mut() {
156 obj.insert("extra_body".to_string(), Value::Object(extra_body));
157 }
158 }
159 }
160 } else {
161 if let Some(effort) = &config.effort {
162 let effort_str = match effort {
163 ReasoningEffort::None => "none",
164 ReasoningEffort::Low => "low",
165 ReasoningEffort::Medium => "medium",
166 ReasoningEffort::High => "high",
167 ReasoningEffort::XHigh => "high",
168 };
169 if let Some(obj) = request_body.as_object_mut() {
170 obj.insert("reasoning_effort".to_string(), json!(effort_str));
171 }
172 }
173 }
174 }
175
176 fn chat_message_to_json(msg: &ChatMessage) -> Value {
177 match msg {
178 ChatMessage::System { content, .. } => json!({
179 "role": "system",
180 "content": content,
181 }),
182 ChatMessage::User {
183 content, images, ..
184 } => {
185 if images.is_empty() {
186 json!({
187 "role": "user",
188 "content": content,
189 })
190 } else {
191 let mut content_parts: Vec<Value> = Vec::new();
192 content_parts.push(json!({"type": "text", "text": content}));
193 for img in images {
194 content_parts.push(Self::image_to_json(img));
195 }
196 json!({
197 "role": "user",
198 "content": content_parts,
199 })
200 }
201 }
202 ChatMessage::Assistant {
203 content,
204 reasoning_content,
205 tool_calls,
206 } => {
207 let mut obj = serde_json::Map::new();
208 obj.insert("role".to_string(), json!("assistant"));
209 obj.insert("content".to_string(), json!(content));
210 if let Some(reasoning) = reasoning_content {
211 obj.insert("reasoning_content".to_string(), json!(reasoning));
212 }
213 if let Some(tc) = tool_calls {
214 let tool_calls_json: Vec<Value> =
215 tc.iter().map(|t| Self::tool_call_to_json(t)).collect();
216 obj.insert("tool_calls".to_string(), json!(tool_calls_json));
217 }
218 Value::Object(obj)
219 }
220 ChatMessage::Tool {
221 tool_call_id,
222 content,
223 } => json!({
224 "role": "tool",
225 "tool_call_id": tool_call_id,
226 "content": content,
227 }),
228 }
229 }
230
231 fn tool_call_to_json(tc: &ToolCallMessage) -> Value {
232 json!({
233 "id": tc.id,
234 "type": "function",
235 "function": {
236 "name": tc.name,
237 "arguments": tc.arguments,
238 }
239 })
240 }
241
242 fn image_to_json(img: &ImageAttachment) -> Value {
243 match img {
244 ImageAttachment::Url { url, detail } => {
245 let mut obj = serde_json::Map::new();
246 obj.insert("url".to_string(), json!(url));
247 if let Some(d) = detail {
248 let detail_str = match d {
249 ImageDetail::Low => "low",
250 ImageDetail::High => "high",
251 ImageDetail::Auto => "auto",
252 };
253 obj.insert("detail".to_string(), json!(detail_str));
254 }
255 json!({
256 "type": "image_url",
257 "image_url": Value::Object(obj),
258 })
259 }
260 ImageAttachment::Base64 {
261 data,
262 media_type,
263 detail,
264 } => {
265 let mime = media_type.as_deref().unwrap_or("image/jpeg");
266 let data_url = format!("data:{mime};base64,{data}");
267 let mut obj = serde_json::Map::new();
268 obj.insert("url".to_string(), json!(data_url));
269 if let Some(d) = detail {
270 let detail_str = match d {
271 ImageDetail::Low => "low",
272 ImageDetail::High => "high",
273 ImageDetail::Auto => "auto",
274 };
275 obj.insert("detail".to_string(), json!(detail_str));
276 }
277 json!({
278 "type": "image_url",
279 "image_url": Value::Object(obj),
280 })
281 }
282 }
283 }
284
285 fn messages_to_json(messages: &[ChatMessage]) -> Vec<Value> {
286 messages.iter().map(Self::chat_message_to_json).collect()
287 }
288}
289
290#[async_trait]
291impl LlmClient for OpenAiClient {
292 async fn chat(
293 &self,
294 messages: &[ChatMessage],
295 tools: &[Value],
296 reasoning: Option<&ReasoningConfig>,
297 response_format: Option<&ResponseFormat>,
298 ) -> AgentResult<Value> {
299 let url = format!("{}/chat/completions", self.base_url);
300 let raw_messages = Self::messages_to_json(messages);
301 let mut request_body = json!({
302 "model": self.model,
303 "messages": raw_messages,
304 "tools": tools,
305 "max_tokens": 8192,
306 });
307
308 self.apply_reasoning_config(&mut request_body, reasoning);
309
310 if let Some(rf) = response_format {
311 if let Some(obj) = request_body.as_object_mut() {
312 obj.insert("response_format".to_string(), rf.to_api_value());
313 }
314 }
315
316 tracing::info!(model = %self.model, msg_count = messages.len(), "llm chat request");
317 tracing::debug!(request_body = %serde_json::to_string_pretty(&request_body).unwrap_or_default(), "llm request body");
318
319 let response = self
320 .client
321 .post(&url)
322 .header("Authorization", format!("Bearer {}", self.api_key))
323 .header("Content-Type", "application/json")
324 .json(&request_body)
325 .send()
326 .await
327 .map_err(|e| AgentError::llm(format!("HTTP request failed: {e}")))?;
328
329 let status = response.status();
330 let res_json: Value = response
331 .json()
332 .await
333 .map_err(|e| AgentError::json(format!("Response JSON parse failed: {e}")))?;
334
335 if !status.is_success() {
336 tracing::warn!(%status, "OpenAI API non-success");
337 }
338
339 if let Some(error) = res_json.get("error") {
340 tracing::warn!(?error, "OpenAI API returned error");
341 return Err(AgentError::LlmApi {
342 message: format!("{error:#?}"),
343 });
344 }
345
346 Ok(res_json)
347 }
348
349 async fn chat_stream(
350 &self,
351 messages: &[ChatMessage],
352 tools: &[Value],
353 reasoning: Option<&ReasoningConfig>,
354 response_format: Option<&ResponseFormat>,
355 ) -> AgentResult<Pin<Box<dyn Stream<Item = AgentResult<StreamChunk>> + Send>>> {
356 let url = format!("{}/chat/completions", self.base_url);
357 let raw_messages = Self::messages_to_json(messages);
358 let mut request_body = json!({
359 "model": self.model,
360 "messages": raw_messages,
361 "tools": tools,
362 "stream": true,
363 "stream_options": { "include_usage": true },
364 "max_tokens": 8192,
365 });
366
367 self.apply_reasoning_config(&mut request_body, reasoning);
368
369 if let Some(rf) = response_format {
370 if let Some(obj) = request_body.as_object_mut() {
371 obj.insert("response_format".to_string(), rf.to_api_value());
372 }
373 }
374
375 tracing::debug!(request_body = %serde_json::to_string_pretty(&request_body).unwrap_or_default(), "llm stream request body");
376
377 let response = self
378 .client
379 .post(&url)
380 .header("Authorization", format!("Bearer {}", self.api_key))
381 .header("Content-Type", "application/json")
382 .json(&request_body)
383 .send()
384 .await
385 .map_err(|e| AgentError::llm(format!("HTTP request failed: {e}")))?;
386
387 if !response.status().is_success() {
388 let status = response.status();
389 let err_text = response
390 .text()
391 .await
392 .map_err(|e| AgentError::llm(format!("Failed to read error response: {e}")))?;
393 tracing::warn!(%status, error = %err_text, "OpenAI API stream non-success");
394 return Err(AgentError::LlmApi { message: err_text });
395 }
396
397 let stream = response
398 .bytes_stream()
399 .eventsource()
400 .map(|event| match event {
401 Ok(event) => {
402 if event.data == "[DONE]" {
403 return Ok(StreamChunk::Stop);
404 }
405
406 let data: Value = serde_json::from_str(&event.data)
407 .map_err(|e| AgentError::json(format!("JSON Parse error: {e}")))?;
408
409 let choices = data.get("choices").and_then(Value::as_array);
410
411 if choices.is_none() || choices.map_or(true, |c| c.is_empty()) {
412 if let Some(usage) = data.get("usage") {
413 return Ok(StreamChunk::Usage(UsageInfo {
414 prompt_tokens: usage
415 .get("prompt_tokens")
416 .and_then(Value::as_u64)
417 .map(|v| v as u32),
418 completion_tokens: usage
419 .get("completion_tokens")
420 .and_then(Value::as_u64)
421 .map(|v| v as u32),
422 total_tokens: usage
423 .get("total_tokens")
424 .and_then(Value::as_u64)
425 .map(|v| v as u32),
426 }));
427 }
428 return Ok(StreamChunk::Text(String::new()));
429 }
430
431 let choice = &choices.unwrap()[0];
432 let delta = &choice["delta"];
433 let finish_reason = choice["finish_reason"].as_str().unwrap_or("");
434
435 if finish_reason == "tool_calls" || delta.get("tool_calls").is_some() {
436 return Ok(StreamChunk::ToolCall(choice.clone()));
437 }
438
439 if let Some(reasoning) = delta.get("reasoning_content") {
440 if let Some(text) = reasoning.as_str() {
441 return Ok(StreamChunk::Thought(text.to_string()));
442 }
443 }
444
445 if let Some(content) = delta.get("content") {
446 if let Some(text) = content.as_str() {
447 return Ok(StreamChunk::Text(text.to_string()));
448 }
449 }
450
451 if finish_reason == "stop" {
452 return Ok(StreamChunk::Stop);
453 }
454
455 Ok(StreamChunk::Text(String::new()))
456 }
457 Err(e) => Err(AgentError::LlmStream(format!("SSE Stream error: {e}"))),
458 });
459
460 Ok(Box::pin(stream))
461 }
462
463 fn capabilities(&self) -> LlmCapabilities {
464 LlmCapabilities {
465 supports_streaming: true,
466 supports_tools: true,
467 supports_vision: true,
468 supports_thinking: true,
469 max_context_tokens: Some(128_000),
470 max_output_tokens: Some(16_384),
471 }
472 }
473
474 fn model_name(&self) -> &str {
475 &self.model
476 }
477}