Skip to main content

microclaw_engine/llm/
mod.rs

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
31/// HTTP statuses worth retrying: rate limit (429), Anthropic "overloaded"
32/// (529), and transient server errors (500/502/503/504). Everything else
33/// (400/401/403/404/422 …) is terminal — retrying just fails again and hides
34/// the real problem.
35pub(crate) fn is_retryable_status(status: u16) -> bool {
36    matches!(status, 429 | 529 | 500 | 502 | 503 | 504)
37}
38
39/// Transport-level failures (connection refused, reset, timeout) are transient
40/// and safe to retry on a fresh request — the server never saw a complete
41/// request, so there is no risk of duplicating a side effect.
42pub(crate) fn is_retryable_transport_error(err: &reqwest::Error) -> bool {
43    err.is_timeout() || err.is_connect() || err.is_request()
44}
45
46/// Provider-agnostic detection of a "request exceeds the model context
47/// window" error. The agent loop compacts the session and retries once when
48/// it sees one of these instead of failing the turn.
49pub fn is_context_overflow_error(err: &microclaw_core::error::MicroClawError) -> bool {
50    let text = err.to_string().to_ascii_lowercase();
51    text.contains("context_length_exceeded")     // OpenAI
52        || text.contains("prompt is too long")   // Anthropic
53        || text.contains("input is too long")    // Anthropic (older phrasing)
54        || text.contains("maximum context length")
55        || text.contains("exceed context limit")
56        || text.contains("exceeds the context window")
57}
58
59/// Parse a `Retry-After` header in delta-seconds form (the HTTP-date form is
60/// ignored — providers use seconds in practice).
61fn 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
72/// Backoff for retry `attempt` (1-based): exponential (2^attempt seconds) capped
73/// at 32s, with "equal jitter" (half fixed, half random) so a burst of callers
74/// — e.g. many cron tasks firing at once — don't retry in lockstep and stampede
75/// the provider. Never returns less than a server-provided `Retry-After`.
76fn 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    // Cheap jitter source; randomness quality is irrelevant, we only need to
80    // desynchronize concurrent retriers.
81    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
93/// Remove invalid `ToolResult` blocks that cannot be matched to the most recent
94/// assistant `ToolUse` turn. This can happen after session compaction or
95/// malformed history reconstruction.
96fn 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    // Wrap with the resilience layer only when a distinct fallback model is
232    // configured. Without one, behaviour is identical to the bare provider.
233    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        // Transient: rate limit, overloaded, 5xx.
268        for s in [429, 529, 500, 502, 503, 504] {
269            assert!(is_retryable_status(s), "{s} should be retryable");
270        }
271        // Terminal: client errors and success must not retry.
272        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        // Equal-jitter range is [2^a/2, 2^a], capped at 32s.
280        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        // Cap holds for large attempts.
291        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        // Should not panic
307        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        // The user message should only contain t1's result
354        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        // User message with only orphaned tool_results → dropped entirely
369        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}