1use async_trait::async_trait;
2use futures_util::StreamExt;
3use regex::Regex;
4use serde::{Deserialize, Serialize};
5use serde_json::json;
6use tokio::sync::mpsc::UnboundedSender;
7use tracing::{debug, error, warn};
8
9use std::collections::HashMap;
10use std::collections::HashSet;
11use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
12use std::sync::Mutex;
13use std::sync::OnceLock;
14use std::time::{Duration, Instant};
15
16use crate::codex_auth::{
17 codex_config_default_openai_base_url, is_openai_codex_provider, is_qwen_portal_provider,
18 refresh_openai_codex_auth_if_needed, resolve_openai_codex_auth, resolve_qwen_portal_auth,
19};
20#[cfg(test)]
21use crate::config::WorkingDirIsolation;
22use crate::config::{resolve_model_name_with_fallback, Config};
23use crate::http_client::llm_user_agent;
24use crate::setup::default_base_url_for_provider;
25use microclaw_core::error::MicroClawError;
26use microclaw_core::llm_types::{
27 ContentBlock, ImageSource, Message, MessageContent, MessagesRequest, MessagesResponse,
28 ResponseContentBlock, ToolDefinition, Usage,
29};
30
31pub(crate) fn is_retryable_status(status: u16) -> bool {
36 matches!(status, 429 | 529 | 500 | 502 | 503 | 504)
37}
38
39pub(crate) fn is_retryable_transport_error(err: &reqwest::Error) -> bool {
43 err.is_timeout() || err.is_connect() || err.is_request()
44}
45
46pub fn is_context_overflow_error(err: µclaw_core::error::MicroClawError) -> bool {
50 let text = err.to_string().to_ascii_lowercase();
51 text.contains("context_length_exceeded") || text.contains("prompt is too long") || text.contains("input is too long") || text.contains("maximum context length")
55 || text.contains("exceed context limit")
56 || text.contains("exceeds the context window")
57}
58
59fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<std::time::Duration> {
62 headers
63 .get(reqwest::header::RETRY_AFTER)?
64 .to_str()
65 .ok()?
66 .trim()
67 .parse::<u64>()
68 .ok()
69 .map(std::time::Duration::from_secs)
70}
71
72fn retry_backoff(attempt: u32, retry_after: Option<std::time::Duration>) -> std::time::Duration {
77 let exp = 2u64.saturating_pow(attempt).min(32);
78 let half = (exp / 2).max(1);
79 let nanos = std::time::SystemTime::now()
82 .duration_since(std::time::UNIX_EPOCH)
83 .map(|d| u64::from(d.subsec_nanos()))
84 .unwrap_or(0);
85 let jittered = half + (nanos % (half + 1));
86 let base = std::time::Duration::from_secs(jittered);
87 match retry_after {
88 Some(ra) => base.max(ra),
89 None => base,
90 }
91}
92
93fn sanitize_messages(messages: Vec<Message>) -> Vec<Message> {
97 let mut pending_tool_ids: HashSet<String> = HashSet::new();
98 let mut sanitized = Vec::new();
99
100 for msg in messages {
101 match msg.content {
102 MessageContent::Text(text) => {
103 pending_tool_ids.clear();
104 sanitized.push(Message {
105 role: msg.role,
106 content: MessageContent::Text(text),
107 });
108 }
109 MessageContent::Blocks(blocks) => {
110 if msg.role == "assistant" {
111 let assistant_tool_ids: HashSet<String> = blocks
112 .iter()
113 .filter_map(|b| match b {
114 ContentBlock::ToolUse { id, .. } => Some(id.clone()),
115 _ => None,
116 })
117 .collect();
118 pending_tool_ids = assistant_tool_ids;
119 sanitized.push(Message {
120 role: msg.role,
121 content: MessageContent::Blocks(blocks),
122 });
123 continue;
124 }
125
126 if msg.role != "user" {
127 pending_tool_ids.clear();
128 sanitized.push(Message {
129 role: msg.role,
130 content: MessageContent::Blocks(blocks),
131 });
132 continue;
133 }
134
135 let has_tool_results = blocks
136 .iter()
137 .any(|b| matches!(b, ContentBlock::ToolResult { .. }));
138 if !has_tool_results {
139 pending_tool_ids.clear();
140 sanitized.push(Message {
141 role: msg.role,
142 content: MessageContent::Blocks(blocks),
143 });
144 continue;
145 }
146
147 let mut filtered = Vec::new();
148 for block in blocks {
149 let keep = match &block {
150 ContentBlock::ToolResult { tool_use_id, .. } => {
151 pending_tool_ids.contains(tool_use_id)
152 }
153 _ => true,
154 };
155 if keep {
156 if let ContentBlock::ToolResult { tool_use_id, .. } = &block {
157 pending_tool_ids.remove(tool_use_id);
158 }
159 filtered.push(block);
160 }
161 }
162
163 if !filtered.is_empty() {
164 sanitized.push(Message {
165 role: msg.role,
166 content: MessageContent::Blocks(filtered),
167 });
168 }
169 }
170 }
171 }
172
173 sanitized
174}
175
176#[async_trait]
177pub trait LlmProvider: Send + Sync {
178 async fn send_message(
179 &self,
180 system: &str,
181 messages: Vec<Message>,
182 tools: Option<Vec<ToolDefinition>>,
183 ) -> Result<MessagesResponse, MicroClawError>;
184
185 async fn send_message_with_model(
186 &self,
187 system: &str,
188 messages: Vec<Message>,
189 tools: Option<Vec<ToolDefinition>>,
190 _model_override: Option<&str>,
191 ) -> Result<MessagesResponse, MicroClawError> {
192 self.send_message(system, messages, tools).await
193 }
194
195 async fn send_message_stream(
196 &self,
197 system: &str,
198 messages: Vec<Message>,
199 tools: Option<Vec<ToolDefinition>>,
200 text_tx: Option<&UnboundedSender<String>>,
201 ) -> Result<MessagesResponse, MicroClawError> {
202 let response = self.send_message(system, messages, tools).await?;
203 if let Some(tx) = text_tx {
204 for block in &response.content {
205 if let ResponseContentBlock::Text { text } = block {
206 let _ = tx.send(text.clone());
207 }
208 }
209 }
210 Ok(response)
211 }
212
213 async fn send_message_stream_with_model(
214 &self,
215 system: &str,
216 messages: Vec<Message>,
217 tools: Option<Vec<ToolDefinition>>,
218 text_tx: Option<&UnboundedSender<String>>,
219 _model_override: Option<&str>,
220 ) -> Result<MessagesResponse, MicroClawError> {
221 self.send_message_stream(system, messages, tools, text_tx)
222 .await
223 }
224}
225
226pub fn create_provider(config: &Config) -> Box<dyn LlmProvider> {
227 let inner: Box<dyn LlmProvider> = match config.llm_provider.trim().to_lowercase().as_str() {
228 "anthropic" => Box::new(AnthropicProvider::new(config)),
229 _ => Box::new(OpenAiProvider::new(config)),
230 };
231 match config.fallback_model.as_deref().map(str::trim) {
234 Some(fb) if !fb.is_empty() && fb != config.model.trim() => {
235 Box::new(ResilientProvider::new(inner, fb.to_string()))
236 }
237 _ => inner,
238 }
239}
240
241pub mod anthropic;
242pub mod key_pool;
243pub mod oai_translate;
244pub mod openai;
245pub mod resilience;
246pub mod sse;
247pub mod stream;
248#[cfg(test)]
249pub(crate) mod test_prelude;
250
251pub use self::anthropic::*;
252pub use self::key_pool::*;
253pub(crate) use self::oai_translate::*;
254pub use self::openai::*;
255pub use self::resilience::*;
256pub(crate) use self::sse::*;
257pub(crate) use self::stream::*;
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 #[allow(unused_imports)]
263 use crate::llm::test_prelude::*;
264
265 #[test]
266 fn test_is_retryable_status_classification() {
267 for s in [429, 529, 500, 502, 503, 504] {
269 assert!(is_retryable_status(s), "{s} should be retryable");
270 }
271 for s in [200, 400, 401, 403, 404, 422] {
273 assert!(!is_retryable_status(s), "{s} should be terminal");
274 }
275 }
276
277 #[test]
278 fn test_retry_backoff_grows_and_is_bounded() {
279 let d1 = retry_backoff(1, None).as_secs();
281 assert!(
282 (1..=2).contains(&d1),
283 "attempt 1 backoff {d1}s out of range"
284 );
285 let d3 = retry_backoff(3, None).as_secs();
286 assert!(
287 (4..=8).contains(&d3),
288 "attempt 3 backoff {d3}s out of range"
289 );
290 let dbig = retry_backoff(20, None).as_secs();
292 assert!(
293 (16..=32).contains(&dbig),
294 "large backoff {dbig}s exceeds cap"
295 );
296 }
297
298 #[test]
299 fn test_create_provider_anthropic() {
300 let mut config = Config::test_defaults();
301 config.data_dir = "/tmp".into();
302 config.working_dir = "/tmp".into();
303 config.working_dir_isolation = WorkingDirIsolation::Shared;
304 config.web_enabled = false;
305 config.web_port = 3900;
306 let _provider = create_provider(&config);
308 }
309
310 #[test]
311 fn test_create_provider_openai() {
312 let mut config = Config::test_defaults();
313 config.llm_provider = "openai".into();
314 config.model = "gpt-5.2".into();
315 config.data_dir = "/tmp".into();
316 config.working_dir = "/tmp".into();
317 config.working_dir_isolation = WorkingDirIsolation::Shared;
318 config.web_enabled = false;
319 config.web_port = 3900;
320 let _provider = create_provider(&config);
321 }
322
323 #[test]
324 fn test_sanitize_messages_removes_orphaned_tool_results() {
325 let msgs = vec![
326 Message {
327 role: "assistant".into(),
328 content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
329 id: "t1".into(),
330 name: "bash".into(),
331 input: json!({}),
332 thought_signature: None,
333 }]),
334 },
335 Message {
336 role: "user".into(),
337 content: MessageContent::Blocks(vec![
338 ContentBlock::ToolResult {
339 tool_use_id: "t1".into(),
340 content: "ok".into(),
341 is_error: None,
342 },
343 ContentBlock::ToolResult {
344 tool_use_id: "orphan".into(),
345 content: "stale".into(),
346 is_error: None,
347 },
348 ]),
349 },
350 ];
351 let sanitized = sanitize_messages(msgs);
352 assert_eq!(sanitized.len(), 2);
353 if let MessageContent::Blocks(blocks) = &sanitized[1].content {
355 assert_eq!(blocks.len(), 1);
356 if let ContentBlock::ToolResult { tool_use_id, .. } = &blocks[0] {
357 assert_eq!(tool_use_id, "t1");
358 } else {
359 panic!("Expected ToolResult");
360 }
361 } else {
362 panic!("Expected Blocks");
363 }
364 }
365
366 #[test]
367 fn test_sanitize_messages_drops_empty_user_message() {
368 let msgs = vec![Message {
370 role: "user".into(),
371 content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
372 tool_use_id: "orphan".into(),
373 content: "stale".into(),
374 is_error: None,
375 }]),
376 }];
377 let sanitized = sanitize_messages(msgs);
378 assert!(sanitized.is_empty());
379 }
380
381 #[test]
382 fn test_sanitize_messages_drops_stale_tool_result_after_intervening_message() {
383 let msgs = vec![
384 Message {
385 role: "assistant".into(),
386 content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
387 id: "t1".into(),
388 name: "bash".into(),
389 input: json!({}),
390 thought_signature: None,
391 }]),
392 },
393 Message {
394 role: "assistant".into(),
395 content: MessageContent::Text("unrelated assistant turn".into()),
396 },
397 Message {
398 role: "user".into(),
399 content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
400 tool_use_id: "t1".into(),
401 content: "stale".into(),
402 is_error: None,
403 }]),
404 },
405 ];
406
407 let sanitized = sanitize_messages(msgs);
408 assert_eq!(sanitized.len(), 2);
409 assert_eq!(sanitized[0].role, "assistant");
410 assert_eq!(sanitized[1].role, "assistant");
411 }
412
413 #[test]
414 fn test_sanitize_messages_preserves_text_messages() {
415 let msgs = vec![
416 Message {
417 role: "user".into(),
418 content: MessageContent::Text("hello".into()),
419 },
420 Message {
421 role: "assistant".into(),
422 content: MessageContent::Text("hi".into()),
423 },
424 ];
425 let sanitized = sanitize_messages(msgs);
426 assert_eq!(sanitized.len(), 2);
427 }
428}