返回 CodeWhale
mod.rs
根目录 / crates / tui / src / llm_client / mod.rs
1 //! LLM Client Trait and Retry Logic
2 //!
3 //! This module provides a unified interface for LLM providers with robust retry logic,
4 //! exponential backoff, and proper error classification.
5 //!
6 //! # Architecture
7 //!
8 //! - `LlmClient` trait: Async interface for LLM providers (DeepSeek, `OpenAI`, etc.)
9 //! - `RetryConfig`: Configurable retry behavior with exponential backoff and jitter
10 //! - `LlmError`: Classified errors with retryability information
11
12 //! - `with_retry`: Generic retry wrapper for any async operation
13 //!
14 //! # Example
15 //!
16 //! ```ignore
17 //! use crate::llm_client::{LlmClient, RetryConfig, with_retry};
18 //!
19 //! let config = RetryConfig::default();
20 //! let result = with_retry(&config, || async {
21 //! client.create_message(request).await
22 //! }, None).await;
23 //! ```
24
25 use crate::config::RetryPolicy;
26 use crate::models::{MessageRequest, MessageResponse, StreamEvent};
27 use anyhow::Result;
28 use serde_json::Value;
29 use std::future::Future;
30 use std::pin::Pin;
31 use std::time::{Duration, Instant};
32 use uuid::Uuid;
33
34 #[cfg(test)]
35 pub mod mock;
36
37 // === LlmClient Trait ===
38
39 /// Type alias for boxed stream of SSE events
40 pub type StreamEventBox =
41 Pin<Box<dyn futures_util::Stream<Item = Result<StreamEvent>> + Send + 'static>>;
42
43 /// Unified interface for LLM providers.
44 ///
45 /// This trait abstracts over different LLM APIs (DeepSeek, `OpenAI`, etc.)
46 /// allowing the agent to work with any provider that implements this interface.
47 ///
48 /// # Implementation Notes
49 ///
50 /// - All methods are async and require `Send + Sync` for thread safety
51 /// - The `create_message_stream` method returns a pinned boxed stream for SSE
52 /// - Implementations should handle their own authentication and base URL configuration
53 #[allow(async_fn_in_trait, dead_code)] // Trait methods are part of the LLM provider interface
54 pub trait LlmClient: Send + Sync {
55 /// Returns the provider name (e.g., "openai", "deepseek")
56 fn provider_name(&self) -> &'static str;
57
58 /// Returns the model identifier being used
59 fn model(&self) -> &str;
60
61 /// Creates a non-streaming message completion
62 fn create_message(
63 &self,
64 request: MessageRequest,
65 ) -> impl Future<Output = Result<MessageResponse>> + Send;
66
67 /// Creates a streaming message completion
68 ///
69 /// Returns a stream of SSE events that should be consumed until completion.
70 fn create_message_stream(
71 &self,
72 request: MessageRequest,
73 ) -> impl Future<Output = Result<StreamEventBox>> + Send;
74
75 /// Optional health check to verify API connectivity
76 fn health_check(&self) -> impl Future<Output = Result<bool>> + Send {
77 async { Ok(true) }
78 }
79
80 /// The concrete base URL requests go to, when the implementation knows it.
81 ///
82 /// Background cost accrual uses this for billing provenance only: it is
83 /// reduced to a non-secret surface classification and a SHA-256 fingerprint
84 /// before being recorded, and the URL itself is never persisted or logged
85 /// (#4318). The default is `None` so an implementation that cannot report a
86 /// stable endpoint yields "unknown endpoint" — which fails closed — rather
87 /// than being assumed to be the provider's public API.
88 fn billing_base_url(&self) -> Option<&str> {
89 None
90 }
91
92 /// Freeze the non-secret effective route immediately before a request is
93 /// dispatched. Implementations with richer configured identity/billing
94 /// facts should override this fail-closed default.
95 fn effective_route_envelope(
96 &self,
97 requested_model: &str,
98 dispatched_at: chrono::DateTime<chrono::Utc>,
99 ) -> crate::cost_status::EffectiveRouteEnvelope {
100 let provider = crate::config::ApiProvider::parse(self.provider_name())
101 .unwrap_or(crate::config::ApiProvider::Custom);
102 crate::cost_status::EffectiveRouteEnvelope::capture(
103 None,
104 provider,
105 self.provider_name(),
106 requested_model,
107 self.billing_base_url(),
108 dispatched_at,
109 )
110 }
111 }
112
113 // === Authentication diagnostics ===
114
115 #[derive(Debug, Clone, PartialEq, Eq, Default)]
116 pub struct AuthenticationErrorContext {
117 pub provider: Option<String>,
118 pub base_url_authority: Option<String>,
119 pub model: Option<String>,
120 pub key_source: Option<String>,
121 pub key_fingerprint: Option<String>,
122 pub key_kind: Option<String>,
123 }
124
125 impl AuthenticationErrorContext {
126 #[must_use]
127 pub fn new(
128 provider: &str,
129 base_url: &str,
130 model: &str,
131 key_source: &str,
132 api_key: &str,
133 ) -> Self {
134 Self::from_parts(
135 Some(provider),
136 Some(base_url),
137 Some(model),
138 Some(key_source),
139 Some(api_key),
140 )
141 }
142
143 #[must_use]
144 pub fn from_parts(
145 provider: Option<&str>,
146 base_url: Option<&str>,
147 model: Option<&str>,
148 key_source: Option<&str>,
149 api_key: Option<&str>,
150 ) -> Self {
151 let api_key = api_key.and_then(non_empty_trimmed);
152 Self {
153 provider: provider.and_then(non_empty_trimmed).map(str::to_string),
154 base_url_authority: base_url.and_then(base_url_authority),
155 model: model.and_then(non_empty_trimmed).map(str::to_string),
156 key_source: key_source.and_then(non_empty_trimmed).map(str::to_string),
157 key_fingerprint: api_key.map(redacted_key_fingerprint),
158 key_kind: api_key.map(classify_api_key_prefix).map(str::to_string),
159 }
160 }
161
162 fn is_empty(&self) -> bool {
163 self.provider.is_none()
164 && self.base_url_authority.is_none()
165 && self.model.is_none()
166 && self.key_source.is_none()
167 && self.key_fingerprint.is_none()
168 && self.key_kind.is_none()
169 }
170
171 fn detail_segments(&self) -> Vec<String> {
172 let mut segments = Vec::new();
173 if let Some(provider) = self.provider.as_deref() {
174 segments.push(format!("provider: {provider}"));
175 }
176 if let Some(authority) = self.base_url_authority.as_deref() {
177 segments.push(format!("base URL authority: {authority}"));
178 }
179 if let Some(model) = self.model.as_deref() {
180 segments.push(format!("model: {model}"));
181 }
182 if let Some(source) = self.key_source.as_deref() {
183 segments.push(format!("key source: {source}"));
184 }
185 if let Some(fingerprint) = self.key_fingerprint.as_deref() {
186 segments.push(format!("key fingerprint: {fingerprint}"));
187 }
188 if let Some(kind) = self.key_kind.as_deref() {
189 segments.push(format!("key type: {kind}"));
190 }
191 segments
192 }
193 }
194
195 #[derive(Debug, Clone, PartialEq, Eq)]
196 pub struct AuthenticationErrorDetail {
197 message: String,
198 context: Option<AuthenticationErrorContext>,
199 }
200
201 impl AuthenticationErrorDetail {
202 #[must_use]
203 pub fn new(message: impl Into<String>) -> Self {
204 Self {
205 message: message.into(),
206 context: None,
207 }
208 }
209
210 #[must_use]
211 pub fn with_context(
212 message: impl Into<String>,
213 context: Option<AuthenticationErrorContext>,
214 ) -> Self {
215 let context = context.filter(|context| !context.is_empty());
216 Self {
217 message: message.into(),
218 context,
219 }
220 }
221
222 #[must_use]
223 pub fn message(&self) -> &str {
224 &self.message
225 }
226
227 #[must_use]
228 pub fn to_user_message(&self) -> String {
229 let Some(context) = self.context.as_ref() else {
230 return self.message.clone();
231 };
232 let segments = context.detail_segments();
233 if segments.is_empty() {
234 self.message.clone()
235 } else {
236 format!("{} ({})", self.message, segments.join(", "))
237 }
238 }
239 }
240
241 impl From<String> for AuthenticationErrorDetail {
242 fn from(message: String) -> Self {
243 Self::new(message)
244 }
245 }
246
247 impl From<&str> for AuthenticationErrorDetail {
248 fn from(message: &str) -> Self {
249 Self::new(message)
250 }
251 }
252
253 #[must_use]
254 pub fn classify_api_key_prefix(api_key: &str) -> &'static str {
255 if api_key.starts_with("tp-") {
256 "Xiaomi MiMo Token Plan key"
257 } else {
258 "API key"
259 }
260 }
261
262 fn non_empty_trimmed(value: &str) -> Option<&str> {
263 let value = value.trim();
264 if value.is_empty() { None } else { Some(value) }
265 }
266
267 fn base_url_authority(base_url: &str) -> Option<String> {
268 let base_url = non_empty_trimmed(base_url)?;
269 let without_scheme = base_url
270 .split_once("://")
271 .map_or(base_url, |(_, rest)| rest);
272 let authority = without_scheme.split('/').next().unwrap_or(without_scheme);
273 let authority = authority
274 .rsplit_once('@')
275 .map_or(authority, |(_, authority)| authority);
276 non_empty_trimmed(authority).map(str::to_string)
277 }
278
279 fn redacted_key_fingerprint(api_key: &str) -> String {
280 let api_key = api_key.trim();
281 let len = api_key.chars().count();
282 match public_key_prefix(api_key) {
283 Some(prefix) => format!("{prefix}... (len={len})"),
284 None => format!("unprefixed (len={len})"),
285 }
286 }
287
288 fn public_key_prefix(api_key: &str) -> Option<&str> {
289 ["tp-", "sk-", "hf_", "hf-", "ak-", "rk-"]
290 .into_iter()
291 .find(|prefix| api_key.starts_with(prefix))
292 }
293
294 fn redact_api_key_from_message(message: &str, api_key: Option<&str>) -> String {
295 let Some(api_key) = api_key.and_then(non_empty_trimmed) else {
296 return message.to_string();
297 };
298 message.replace(api_key, "[redacted API key]")
299 }
300
301 // === LlmError - Classified Error Types ===
302
303 /// Evidence captured when an HTTP response explicitly identifies plan quota
304 /// exhaustion. The private field prevents callers outside this parser module
305 /// from manufacturing the durable classification from arbitrary text.
306 #[derive(Debug)]
307 pub struct QuotaExhaustionError {
308 message: String,
309 }
310
311 impl QuotaExhaustionError {
312 fn from_http_message(message: String) -> Self {
313 Self { message }
314 }
315
316 pub(crate) fn into_message(self) -> String {
317 self.message
318 }
319 }
320
321 /// Classified LLM errors with retryability information.
322 ///
323 /// This enum categorizes API errors to enable smart retry decisions.
324 /// Some errors (rate limits, transient server errors) are retryable,
325 /// while others (auth failures, invalid requests) should fail immediately.
326 #[derive(Debug)]
327 pub enum LlmError {
328 /// Rate limit exceeded (HTTP 429)
329 /// Contains optional Retry-After duration from server
330 RateLimited {
331 message: String,
332 retry_after: Option<Duration>,
333 },
334
335 /// The provider explicitly reported that the account's plan quota is exhausted.
336 ///
337 /// Unlike an ordinary 429 rate limit, retrying the same request after a short
338 /// backoff cannot resolve this condition. This variant is constructed only at
339 /// the provider HTTP response boundary from explicit quota evidence.
340 QuotaExhausted(QuotaExhaustionError),
341
342 /// Server error (HTTP 5xx)
343 ServerError { status: u16, message: String },
344
345 /// Network connectivity error
346 NetworkError(String),
347
348 /// Request timed out
349 Timeout(Duration),
350
351 /// Authentication failed (HTTP 401, selected HTTP 403)
352 AuthenticationError(AuthenticationErrorDetail),
353
354 /// Authorization or provider-side blocking failed (HTTP 403)
355 AuthorizationError(String),
356
357 /// Invalid request parameters (HTTP 400)
358 InvalidRequest { status: u16, message: String },
359
360 /// Model-specific error (model not found, etc.)
361 ModelError(String),
362
363 /// Content policy violation (safety filters)
364 ContentPolicyError(String),
365
366 /// Failed to parse API response
367 ParseError(String),
368
369 /// Context length exceeded
370 ContextLengthError(String),
371
372 /// Catch-all for other errors
373 Other(String),
374 }
375
376 impl std::fmt::Display for LlmError {
377 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
378 match self {
379 LlmError::RateLimited { message, .. } => write!(f, "Rate limit exceeded: {message}"),
380 LlmError::QuotaExhausted(error) => {
381 write!(f, "Provider plan quota exhausted: {}", error.message)
382 }
383 LlmError::ServerError { status, message } => {
384 write!(f, "Server error ({status}): {message}")
385 }
386 LlmError::NetworkError(msg) => write!(f, "Network error: {msg}"),
387 LlmError::Timeout(d) => write!(f, "Request timed out after {d:?}"),
388 LlmError::AuthenticationError(auth) => {
389 write!(f, "Authentication failed: {}", auth.to_user_message())
390 }
391 LlmError::AuthorizationError(msg) => write!(f, "Authorization failed: {msg}"),
392 LlmError::InvalidRequest { status, message } => {
393 write!(f, "Invalid request ({status}): {message}")
394 }
395 LlmError::ModelError(msg) => write!(f, "Model error: {msg}"),
396 LlmError::ContentPolicyError(msg) => write!(f, "Content policy violation: {msg}"),
397 LlmError::ParseError(msg) => write!(f, "Response parsing error: {msg}"),
398 LlmError::ContextLengthError(msg) => write!(f, "Context length exceeded: {msg}"),
399 LlmError::Other(msg) => write!(f, "LLM error: {msg}"),
400 }
401 }
402 }
403
404 impl std::error::Error for LlmError {}
405
406 impl LlmError {
407 /// Determines if this error is potentially transient and worth retrying.
408 ///
409 /// Retryable errors:
410 /// - Rate limits (with backoff)
411 /// - Server errors (5xx)
412 /// - Network errors (connection issues)
413 /// - Timeouts
414 ///
415 /// Non-retryable errors:
416 /// - Provider plan quota exhaustion
417 /// - Authentication failures
418 /// - Invalid requests
419 /// - Content policy violations
420 /// - Context length errors
421 pub fn is_retryable(&self) -> bool {
422 matches!(
423 self,
424 LlmError::RateLimited { .. }
425 | LlmError::ServerError { .. }
426 | LlmError::NetworkError(_)
427 | LlmError::Timeout(_)
428 )
429 }
430
431 /// Returns the server-suggested retry delay if available.
432 ///
433 /// This is typically present for rate limit errors when the server
434 /// provides a Retry-After header.
435 pub fn suggested_retry_delay(&self) -> Option<Duration> {
436 match self {
437 LlmError::RateLimited { retry_after, .. } => *retry_after,
438 _ => None,
439 }
440 }
441
442 /// Constructs an `LlmError` from HTTP status code and response body.
443 ///
444 /// Performs heuristic classification based on:
445 /// - Status code (429 = rate limit, 401/403 = auth, 499/5xx = transient upstream error)
446 /// - Response body keywords (`context_length`, `content_policy`, safety, etc.)
447 pub fn from_http_response(status: u16, body: &str) -> Self {
448 if matches!(status, 400 | 402 | 429) && has_explicit_quota_evidence(body) {
449 return LlmError::QuotaExhausted(QuotaExhaustionError::from_http_message(
450 body.to_string(),
451 ));
452 }
453
454 match status {
455 429 => LlmError::RateLimited {
456 message: body.to_string(),
457 retry_after: None,
458 },
459 401 => Self::authentication_error(body),
460 403 => {
461 if looks_like_authentication_failure(body) {
462 Self::authentication_error(body)
463 } else {
464 LlmError::AuthorizationError(body.to_string())
465 }
466 }
467 400 => {
468 // Classify 400 errors by examining the response body
469 let body_lower = body.to_lowercase();
470 if body_lower.contains("context_length")
471 || body_lower.contains("token")
472 || body_lower.contains("too long")
473 || body_lower.contains("maximum")
474 {
475 LlmError::ContextLengthError(body.to_string())
476 } else if body_lower.contains("content_policy")
477 || body_lower.contains("safety")
478 || body_lower.contains("harmful")
479 || body_lower.contains("inappropriate")
480 {
481 LlmError::ContentPolicyError(body.to_string())
482 } else if body_lower.contains("model") && body_lower.contains("not found") {
483 LlmError::ModelError(body.to_string())
484 } else {
485 LlmError::InvalidRequest {
486 status,
487 message: body.to_string(),
488 }
489 }
490 }
491 404 => {
492 if body.to_lowercase().contains("model") {
493 LlmError::ModelError(body.to_string())
494 } else {
495 LlmError::InvalidRequest {
496 status,
497 message: body.to_string(),
498 }
499 }
500 }
501 // Several OpenAI-compatible gateways use nginx's non-standard
502 // 499 for an upstream request that was cancelled before response
503 // streaming began. At this boundary no response body stream has
504 // been exposed, so it is eligible for the same bounded retry
505 // policy as a 5xx gateway failure.
506 499..=599 => LlmError::ServerError {
507 status,
508 message: body.to_string(),
509 },
510 _ => LlmError::Other(format!("HTTP {status}: {body}")),
511 }
512 }
513
514 #[must_use]
515 pub fn authentication_error(message: impl Into<String>) -> Self {
516 LlmError::AuthenticationError(AuthenticationErrorDetail::new(message))
517 }
518
519 #[must_use]
520 pub fn authentication_error_with_context(
521 message: impl Into<String>,
522 context: Option<AuthenticationErrorContext>,
523 ) -> Self {
524 LlmError::AuthenticationError(AuthenticationErrorDetail::with_context(message, context))
525 }
526
527 /// Constructs an `LlmError` from HTTP response data plus request context
528 /// that is safe to display when authentication fails.
529 #[must_use]
530 pub fn from_http_response_with_request_context(
531 status: u16,
532 body: &str,
533 provider: Option<&str>,
534 base_url: Option<&str>,
535 model: Option<&str>,
536 key_source: Option<&str>,
537 api_key: Option<&str>,
538 ) -> Self {
539 let body = redact_api_key_from_message(body, api_key);
540 let context =
541 AuthenticationErrorContext::from_parts(provider, base_url, model, key_source, api_key);
542 Self::from_http_response_with_auth_context(status, &body, Some(context))
543 }
544
545 /// Constructs an `LlmError` from HTTP status code and response body, with
546 /// optional structured details for authentication failures.
547 ///
548 /// The `body` passed here must already be safe for user display. Prefer
549 /// [`Self::from_http_response_with_request_context`] when the raw API key is
550 /// available so the response body can be redacted before rendering.
551 #[must_use]
552 pub fn from_http_response_with_auth_context(
553 status: u16,
554 body: &str,
555 auth_context: Option<AuthenticationErrorContext>,
556 ) -> Self {
557 match status {
558 401 => Self::authentication_error_with_context(body, auth_context),
559 403 => {
560 if looks_like_authentication_failure(body) {
561 Self::authentication_error_with_context(body, auth_context)
562 } else {
563 LlmError::AuthorizationError(body.to_string())
564 }
565 }
566 _ => Self::from_http_response(status, body),
567 }
568 }
569
570 /// Constructs an `LlmError` from HTTP status code, body, and optional Retry-After header.
571 pub fn from_http_response_with_retry_after(
572 status: u16,
573 body: &str,
574 retry_after: Option<Duration>,
575 ) -> Self {
576 let mut error = Self::from_http_response(status, body);
577 if let LlmError::RateLimited {
578 retry_after: ref mut ra,
579 ..
580 } = error
581 {
582 *ra = retry_after;
583 }
584 error
585 }
586
587 /// Constructs an `LlmError` from a reqwest error.
588 pub fn from_reqwest(err: &reqwest::Error) -> Self {
589 if err.is_timeout() {
590 LlmError::Timeout(Duration::from_secs(0))
591 } else if err.is_connect() {
592 LlmError::NetworkError(format!("Connection failed: {err}"))
593 } else if err.is_request() {
594 LlmError::NetworkError(format!("Request failed: {err}"))
595 } else {
596 LlmError::Other(err.to_string())
597 }
598 }
599 }
600
601 /// Format provider HTTP error bodies before they are surfaced in the TUI.
602 ///
603 /// Providers sometimes return whole HTML error pages for gateway/WAF blocks.
604 /// Passing those pages through raw floods the transcript and can also make a
605 /// provider-side 403 look like a broken API key. Keep the useful details and
606 /// cap everything else.
607 #[must_use]
608 pub(crate) fn sanitize_http_error_body(
609 provider_label: Option<&str>,
610 status: u16,
611 body: &str,
612 ) -> String {
613 if let Some(message) = extract_json_error_message(body) {
614 let message = truncate_for_error(&collapse_whitespace(&message), 2_000);
615 if let Some(code) = explicit_quota_code(body) {
616 return format!("{message} (provider error code: {code})");
617 }
618 return message;
619 }
620
621 if is_probably_html(body) {
622 let text = html_to_text(body);
623 let lower = text.to_ascii_lowercase();
624 let provider = provider_label.unwrap_or("Provider");
625
626 // Cloudflare's "Access Denied" interstitial strips the literal word
627 // "cloudflare" once tags are removed (it only survives in `<meta>`
628 // attributes and the `<style>`/`<script>` blocks we discard). Arcee's
629 // 403 page is exactly this shape, so also key off the WAF's stock copy
630 // ("security alert", "contact support") and a Cloudflare error/ray ID.
631 let error_id = extract_cloudflare_error_id(&text);
632 let is_cloudflare = lower.contains("cloudflare");
633 let looks_like_access_denied = lower.contains("access denied")
634 && (is_cloudflare
635 || lower.contains("security alert")
636 || lower.contains("contact support")
637 || lower.contains("contact us")
638 || error_id.is_some());
639 if looks_like_access_denied {
640 let label = if is_cloudflare {
641 "Cloudflare Access Denied"
642 } else {
643 "Access Denied"
644 };
645 let mut message = format!(
646 "{provider} API returned {label} (HTTP {status}). \
647 The request was blocked before it reached the model; retry with a \
648 smaller request or fewer tools, or contact provider support"
649 );
650 if let Some(id) = error_id {
651 message.push_str(&format!(" with ID {id}"));
652 }
653 message.push('.');
654 return message;
655 }
656
657 let text = truncate_for_error(&collapse_whitespace(&text), 900);
658 return format!("{provider} API returned an HTML error page (HTTP {status}): {text}");
659 }
660
661 truncate_for_error(&collapse_whitespace(body), 2_000)
662 }
663
664 fn looks_like_authentication_failure(body: &str) -> bool {
665 let lower = body.to_ascii_lowercase();
666 lower.contains("authentication")
667 || lower.contains("unauthorized")
668 || lower.contains("api key")
669 || lower.contains("invalid key")
670 || lower.contains("invalid token")
671 || lower.contains("bearer token")
672 || lower.contains("missing token")
673 }
674
675 /// Quota exhaustion is a durable account state, not a generic rate-limit
676 /// synonym. Accept only explicit provider evidence at the HTTP/parser boundary;
677 /// callers holding a stringified error must never promote it to this type.
678 fn has_explicit_quota_evidence(body: &str) -> bool {
679 explicit_quota_code(body).is_some()
680 || has_explicit_quota_code_marker(body)
681 || has_explicit_quota_phrase(body)
682 }
683
684 fn explicit_quota_code(body: &str) -> Option<String> {
685 let value: Value = serde_json::from_str(body).ok()?;
686 [
687 "/error/code",
688 "/error/type",
689 "/error/error_code",
690 "/code",
691 "/type",
692 "/error_code",
693 ]
694 .into_iter()
695 .filter_map(|pointer| value.pointer(pointer).and_then(Value::as_str))
696 .find(|code| is_explicit_quota_code(code))
697 .map(ToOwned::to_owned)
698 }
699
700 fn is_explicit_quota_code(code: &str) -> bool {
701 let normalized: String = code
702 .chars()
703 .filter(|ch| ch.is_ascii_alphanumeric())
704 .map(|ch| ch.to_ascii_lowercase())
705 .collect();
706 matches!(
707 normalized.as_str(),
708 "insufficientquota"
709 | "quotaexceeded"
710 | "quotaexhausted"
711 | "billinghardlimitreached"
712 | "billinglimitreached"
713 | "creditbalanceexhausted"
714 )
715 }
716
717 fn has_explicit_quota_code_marker(body: &str) -> bool {
718 let lower = body.to_ascii_lowercase();
719 let Some((_, suffix)) = lower.split_once("provider error code:") else {
720 return false;
721 };
722 let code = suffix
723 .trim_start()
724 .split(|ch: char| !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')))
725 .next()
726 .unwrap_or_default();
727 is_explicit_quota_code(code)
728 }
729
730 fn has_explicit_quota_phrase(body: &str) -> bool {
731 let lower = body.to_ascii_lowercase();
732 let current_quota_exhausted = lower.contains("exceeded your current quota")
733 || lower.contains("current quota has been exceeded");
734 let plan_and_billing_guidance = lower.contains("plan") && lower.contains("billing");
735 let durable_scope_exhausted = [
736 "billing quota exceeded",
737 "billing quota exhausted",
738 "billing quota is exhausted",
739 "billing quota has been exceeded",
740 "billing quota has been exhausted",
741 "account quota exceeded",
742 "account quota exhausted",
743 "account quota is exhausted",
744 "account quota has been exceeded",
745 "account quota has been exhausted",
746 "plan quota exceeded",
747 "plan quota exhausted",
748 "plan quota is exhausted",
749 "plan quota has been exceeded",
750 "plan quota has been exhausted",
751 ]
752 .into_iter()
753 .any(|phrase| lower.contains(phrase));
754
755 lower.contains("billing hard limit has been reached")
756 || lower.contains("credit balance exhausted")
757 || lower.contains("credit balance is exhausted")
758 || durable_scope_exhausted
759 || (current_quota_exhausted && plan_and_billing_guidance)
760 }
761
762 fn extract_json_error_message(body: &str) -> Option<String> {
763 let value: Value = serde_json::from_str(body).ok()?;
764 for pointer in [
765 "/error/message",
766 "/error",
767 "/message",
768 "/detail",
769 "/error_description",
770 ] {
771 let Some(value) = value.pointer(pointer) else {
772 continue;
773 };
774 if let Some(message) = value.as_str() {
775 if !message.trim().is_empty() {
776 return Some(message.to_string());
777 }
778 } else if value.is_object() || value.is_array() {
779 return Some(value.to_string());
780 }
781 }
782 None
783 }
784
785 fn is_probably_html(body: &str) -> bool {
786 let prefix = body
787 .chars()
788 .take(512)
789 .collect::<String>()
790 .to_ascii_lowercase();
791 prefix.contains("<!doctype html") || prefix.contains("<html") || prefix.contains("<head")
792 }
793
794 fn html_to_text(html: &str) -> String {
795 let without_scripts = strip_html_block(html, "script");
796 let without_styles = strip_html_block(&without_scripts, "style");
797 let mut text = String::with_capacity(without_styles.len().min(4096));
798 let mut in_tag = false;
799 for ch in without_styles.chars() {
800 match ch {
801 '<' => {
802 in_tag = true;
803 text.push(' ');
804 }
805 '>' => {
806 in_tag = false;
807 text.push(' ');
808 }
809 _ if !in_tag => text.push(ch),
810 _ => {}
811 }
812 }
813 decode_basic_html_entities(&collapse_whitespace(&text))
814 }
815
816 fn strip_html_block(input: &str, tag: &str) -> String {
817 let mut out = String::with_capacity(input.len());
818 let mut cursor = 0usize;
819 let lower = input.to_ascii_lowercase();
820 let start_marker = format!("<{tag}");
821 let end_marker = format!("</{tag}>");
822
823 while let Some(relative_start) = lower[cursor..].find(&start_marker) {
824 let start = cursor + relative_start;
825 out.push_str(&input[cursor..start]);
826 let after_start = start + start_marker.len();
827 let Some(relative_end) = lower[after_start..].find(&end_marker) else {
828 cursor = input.len();
829 break;
830 };
831 cursor = after_start + relative_end + end_marker.len();
832 out.push(' ');
833 }
834 out.push_str(&input[cursor..]);
835 out
836 }
837
838 fn decode_basic_html_entities(input: &str) -> String {
839 input
840 .replace("&nbsp;", " ")
841 .replace("&amp;", "&")
842 .replace("&lt;", "<")
843 .replace("&gt;", ">")
844 .replace("&quot;", "\"")
845 .replace("&#39;", "'")
846 .replace("&apos;", "'")
847 }
848
849 fn collapse_whitespace(input: &str) -> String {
850 input.split_whitespace().collect::<Vec<_>>().join(" ")
851 }
852
853 fn truncate_for_error(input: &str, max_chars: usize) -> String {
854 let mut out = String::with_capacity(input.len().min(max_chars + 32));
855 for (count, ch) in input.chars().enumerate() {
856 if count >= max_chars {
857 out.push_str("...");
858 return out;
859 }
860 out.push(ch);
861 }
862 out
863 }
864
865 fn extract_cloudflare_error_id(text: &str) -> Option<String> {
866 let mut last = None;
867 for token in text.split(|ch: char| !ch.is_ascii_hexdigit()) {
868 if (16..=64).contains(&token.len()) && token.bytes().any(|b| b.is_ascii_alphabetic()) {
869 last = Some(token.to_string());
870 }
871 }
872 last
873 }
874
875 impl From<reqwest::Error> for LlmError {
876 fn from(err: reqwest::Error) -> Self {
877 LlmError::from_reqwest(&err)
878 }
879 }
880
881 impl From<serde_json::Error> for LlmError {
882 fn from(err: serde_json::Error) -> Self {
883 LlmError::ParseError(err.to_string())
884 }
885 }
886
887 // === RetryConfig - Exponential Backoff Configuration ===
888
889 /// Configuration for retry behavior with exponential backoff.
890 ///
891 /// This struct controls how retries are performed:
892 /// - Number of retry attempts
893 /// - Delay calculation (exponential backoff with optional jitter)
894 /// - Which HTTP status codes are retryable
895 /// - Timeout handling
896 ///
897 /// # Default Values
898 ///
899 /// - `enabled`: true
900 /// - `max_retries`: 3
901 /// - `initial_delay`: 1.0 seconds
902 /// - `max_delay`: 60.0 seconds
903 /// - `exponential_base`: 2.0
904 /// - `jitter`: true (adds randomness to prevent thundering herd)
905 /// - `jitter_factor`: 0.1 (10% variation)
906 /// - `retryable_status_codes`: [429, 499, 500, 502, 503, 504]
907 #[derive(Debug, Clone)]
908 pub struct RetryConfig {
909 /// Whether retry logic is enabled
910 pub enabled: bool,
911
912 /// Maximum number of retry attempts (0 = no retries, 3 = up to 4 total attempts)
913 pub max_retries: u32,
914
915 /// Initial delay before first retry (seconds)
916 pub initial_delay: f64,
917
918 /// Maximum delay between retries (seconds)
919 pub max_delay: f64,
920
921 /// Base for exponential backoff (delay = initial * base^attempt)
922 pub exponential_base: f64,
923
924 /// Whether to add random jitter to delays
925 pub jitter: bool,
926
927 /// Jitter factor (0.1 = +/- 10% variation)
928 pub jitter_factor: f64,
929
930 /// Whether to respect server's Retry-After header
931 pub respect_retry_after: bool,
932
933 /// HTTP status codes that should trigger a retry
934 #[allow(dead_code)] // Used in tests via is_retryable_status()
935 pub retryable_status_codes: Vec<u16>,
936
937 /// Timeout for individual requests (seconds, 0 = no timeout)
938 #[allow(dead_code)] // Configuration field for retry consumers
939 pub request_timeout: f64,
940
941 /// Total timeout for all retry attempts (seconds, 0 = no total timeout)
942 pub total_timeout: f64,
943 }
944
945 impl Default for RetryConfig {
946 fn default() -> Self {
947 Self {
948 enabled: true,
949 max_retries: 3,
950 initial_delay: 1.0,
951 max_delay: 60.0,
952 exponential_base: 2.0,
953 jitter: true,
954 jitter_factor: 0.1,
955 respect_retry_after: true,
956 retryable_status_codes: vec![429, 499, 500, 502, 503, 504],
957 request_timeout: 120.0,
958 total_timeout: 0.0, // No total timeout by default
959 }
960 }
961 }
962
963 #[allow(dead_code)] // Public builder API, used in tests
964 impl RetryConfig {
965 /// Creates a new `RetryConfig` with default values
966 pub fn new() -> Self {
967 Self::default()
968 }
969
970 /// Creates a config with retry disabled
971 pub fn disabled() -> Self {
972 Self {
973 enabled: false,
974 ..Default::default()
975 }
976 }
977
978 /// Builder method to set max retries
979 pub fn with_max_retries(mut self, max_retries: u32) -> Self {
980 self.max_retries = max_retries;
981 self
982 }
983
984 /// Builder method to set initial delay
985 pub fn with_initial_delay(mut self, delay: f64) -> Self {
986 self.initial_delay = delay;
987 self
988 }
989
990 /// Builder method to set max delay
991 pub fn with_max_delay(mut self, delay: f64) -> Self {
992 self.max_delay = delay;
993 self
994 }
995
996 /// Builder method to enable/disable jitter
997 pub fn with_jitter(mut self, enabled: bool) -> Self {
998 self.jitter = enabled;
999 self
1000 }
1001
1002 /// Builder method to set request timeout
1003 pub fn with_request_timeout(mut self, timeout: f64) -> Self {
1004 self.request_timeout = timeout;
1005 self
1006 }
1007
1008 /// Builder method to set total timeout
1009 pub fn with_total_timeout(mut self, timeout: f64) -> Self {
1010 self.total_timeout = timeout;
1011 self
1012 }
1013
1014 /// Calculates the delay for a given retry attempt.
1015 ///
1016 /// Uses exponential backoff: delay = `initial_delay` * `exponential_base^attempt`
1017 /// The result is capped at `max_delay` and optionally has jitter applied.
1018 ///
1019 /// # Arguments
1020 ///
1021 /// * `attempt` - Zero-based attempt number (0 = first retry)
1022 ///
1023 /// # Returns
1024 ///
1025 /// Duration to wait before the next retry attempt
1026 pub fn delay_for_attempt(&self, attempt: u32) -> Duration {
1027 let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);
1028 let base_delay = self.initial_delay * self.exponential_base.powi(exponent);
1029 let capped_delay = base_delay.min(self.max_delay);
1030
1031 let final_delay = if self.jitter {
1032 // Add random jitter to prevent thundering herd problem
1033 let jitter_range = capped_delay * self.jitter_factor;
1034 // Use UUID v4 entropy for jitter randomness.
1035 let bytes = *Uuid::new_v4().as_bytes();
1036 let sample = u16::from_le_bytes([bytes[0], bytes[1]]);
1037 let random_factor = f64::from(sample) / f64::from(u16::MAX); // 0.0 to 1.0
1038 let jitter = jitter_range * (2.0 * random_factor - 1.0); // -range to +range
1039
1040 (capped_delay + jitter).max(0.0)
1041 } else {
1042 capped_delay
1043 };
1044
1045 Duration::from_secs_f64(final_delay)
1046 }
1047
1048 /// Checks if a given HTTP status code should trigger a retry
1049 pub fn is_retryable_status(&self, status: u16) -> bool {
1050 self.retryable_status_codes.contains(&status)
1051 }
1052 }
1053
1054 /// Converts from the existing `RetryPolicy` in config
1055 impl From<RetryPolicy> for RetryConfig {
1056 fn from(policy: RetryPolicy) -> Self {
1057 Self {
1058 enabled: policy.enabled,
1059 max_retries: policy.max_retries,
1060 initial_delay: policy.initial_delay,
1061 max_delay: policy.max_delay,
1062 exponential_base: policy.exponential_base,
1063 ..Default::default()
1064 }
1065 }
1066 }
1067
1068 /// Converts back to `RetryPolicy` for compatibility
1069 impl From<RetryConfig> for RetryPolicy {
1070 fn from(config: RetryConfig) -> Self {
1071 Self {
1072 enabled: config.enabled,
1073 max_retries: config.max_retries,
1074 initial_delay: config.initial_delay,
1075 max_delay: config.max_delay,
1076 exponential_base: config.exponential_base,
1077 }
1078 }
1079 }
1080
1081 // === Retry Error and Result Types ===
1082
1083 /// Error returned when all retry attempts have been exhausted.
1084 #[derive(Debug)]
1085 pub struct RetryError {
1086 /// The last error encountered
1087 pub last_error: LlmError,
1088
1089 /// Total number of attempts made
1090 pub attempts: u32,
1091
1092 /// Total time spent across all attempts
1093 pub total_time: Duration,
1094 }
1095
1096 impl std::fmt::Display for RetryError {
1097 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1098 write!(
1099 f,
1100 "Retry exhausted after {} attempts ({:?}): {}",
1101 self.attempts, self.total_time, self.last_error
1102 )
1103 }
1104 }
1105
1106 impl std::error::Error for RetryError {
1107 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1108 Some(&self.last_error)
1109 }
1110 }
1111
1112 /// Result type for retry operations
1113 pub type RetryResult<T> = Result<T, RetryError>;
1114
1115 /// Callback type for retry notifications
1116 ///
1117 /// Called before each retry with:
1118 /// - The error that triggered the retry
1119 /// - The attempt number (0-based)
1120 /// - The delay before the next attempt
1121 pub type RetryCallback = Box<dyn Fn(&LlmError, u32, Duration) + Send + Sync>;
1122
1123 // === with_retry - Generic Retry Wrapper ===
1124
1125 /// Executes an async operation with configurable retry logic.
1126 ///
1127 /// This function wraps any async operation that returns `Result<T, LlmError>`
1128 /// and automatically retries on transient failures using exponential backoff.
1129 ///
1130 /// # Arguments
1131 ///
1132 /// * `config` - Retry configuration (delays, max attempts, etc.)
1133 /// * `operation` - Async closure to execute (will be called multiple times on retry)
1134 /// * `callback` - Optional callback for retry notifications (logging, metrics, etc.)
1135 ///
1136 /// # Returns
1137 ///
1138 /// * `Ok(T)` - The successful result from the operation
1139 /// * `Err(RetryError)` - All retries exhausted or non-retryable error encountered
1140 ///
1141 /// # Example
1142 ///
1143 /// ```ignore
1144 /// let result = with_retry(
1145 /// &config,
1146 /// || async { client.send_request(&req).await },
1147 /// Some(Box::new(|err, attempt, delay| {
1148 /// eprintln!("Retry {} after {:?}: {}", attempt, delay, err);
1149 /// })),
1150 /// ).await;
1151 /// ```
1152 pub async fn with_retry<F, Fut, T>(
1153 config: &RetryConfig,
1154 mut operation: F,
1155 callback: Option<RetryCallback>,
1156 ) -> RetryResult<T>
1157 where
1158 F: FnMut() -> Fut,
1159 Fut: Future<Output = Result<T, LlmError>>,
1160 {
1161 // If retries are disabled, just run once
1162 if !config.enabled {
1163 return operation().await.map_err(|e| RetryError {
1164 last_error: e,
1165 attempts: 1,
1166 total_time: Duration::ZERO,
1167 });
1168 }
1169
1170 let start_time = Instant::now();
1171 let total_timeout = if config.total_timeout > 0.0 {
1172 Some(Duration::from_secs_f64(config.total_timeout))
1173 } else {
1174 None
1175 };
1176
1177 let mut last_error: Option<LlmError> = None;
1178
1179 // Attempt 0 is the first try, then up to max_retries additional attempts
1180 for attempt in 0..=config.max_retries {
1181 // Check total timeout
1182 if let Some(timeout) = total_timeout
1183 && start_time.elapsed() >= timeout
1184 {
1185 return Err(RetryError {
1186 last_error: last_error.unwrap_or(LlmError::Timeout(timeout)),
1187 attempts: attempt,
1188 total_time: start_time.elapsed(),
1189 });
1190 }
1191
1192 match operation().await {
1193 Ok(result) => return Ok(result),
1194 Err(err) => {
1195 // Non-retryable errors fail immediately
1196 if !err.is_retryable() {
1197 return Err(RetryError {
1198 last_error: err,
1199 attempts: attempt + 1,
1200 total_time: start_time.elapsed(),
1201 });
1202 }
1203
1204 // Last attempt - no more retries
1205 if attempt >= config.max_retries {
1206 return Err(RetryError {
1207 last_error: err,
1208 attempts: attempt + 1,
1209 total_time: start_time.elapsed(),
1210 });
1211 }
1212
1213 // Calculate delay
1214 // Use server's Retry-After if available and configured
1215 let base_delay = config.delay_for_attempt(attempt);
1216 let delay = if config.respect_retry_after {
1217 err.suggested_retry_delay().unwrap_or(base_delay)
1218 } else {
1219 base_delay
1220 };
1221
1222 // Notify callback if provided
1223 if let Some(ref cb) = callback {
1224 cb(&err, attempt, delay);
1225 }
1226
1227 last_error = Some(err);
1228
1229 // Wait before retrying
1230 tokio::time::sleep(delay).await;
1231 }
1232 }
1233 }
1234
1235 // Should not reach here, but handle gracefully
1236 Err(RetryError {
1237 last_error: last_error.unwrap_or(LlmError::Other("Unknown retry error".to_string())),
1238 attempts: config.max_retries + 1,
1239 total_time: start_time.elapsed(),
1240 })
1241 }
1242
1243 // === Utility Functions ===
1244
1245 /// The longest a `Retry-After` value is ever believed. A server (or a proxy
1246 /// in front of it) can send an arbitrarily large delay; without a ceiling a
1247 /// single `Retry-After: 86400` would wedge the turn for a day. One hour is
1248 /// well past any legitimate rate-limit window.
1249 const RETRY_AFTER_MAX: Duration = Duration::from_secs(3600);
1250
1251 /// Parses the Retry-After header value into a Duration.
1252 ///
1253 /// Supports both:
1254 /// - Seconds as integer: "120" -> 120 seconds
1255 /// - HTTP-date format: "Wed, 21 Oct 2015 07:28:00 GMT" (not implemented, returns None)
1256 ///
1257 /// The value is server-controlled, so this never panics and never returns an
1258 /// unbounded delay: negative / NaN / infinite / absurd floats are rejected
1259 /// (`Duration::from_secs_f64` panics on a negative — a remote-triggerable
1260 /// crash before this guard), and any result is clamped to [`RETRY_AFTER_MAX`].
1261 pub fn parse_retry_after(value: &str) -> Option<Duration> {
1262 // Try parsing as seconds
1263 if let Ok(seconds) = value.parse::<u64>() {
1264 return Some(Duration::from_secs(seconds).min(RETRY_AFTER_MAX));
1265 }
1266
1267 // Try parsing as float seconds. Only a finite, non-negative value is a
1268 // meaningful delay; everything else (`-5`, `nan`, `inf`) is "no usable
1269 // hint". Clamp to the ceiling BEFORE `from_secs_f64` so an out-of-range
1270 // float can never reach its overflow-panic path, while keeping the
1271 // sub-second precision a legitimate `1.5` carries.
1272 if let Ok(seconds) = value.parse::<f64>()
1273 && seconds.is_finite()
1274 && seconds >= 0.0
1275 {
1276 let clamped = seconds.min(RETRY_AFTER_MAX.as_secs_f64());
1277 return Some(Duration::from_secs_f64(clamped));
1278 }
1279
1280 // HTTP-date format not supported yet
1281 // Could use chrono or httpdate crate if needed
1282 None
1283 }
1284
1285 /// Extracts Retry-After duration from response headers
1286 pub fn extract_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
1287 headers
1288 .get(reqwest::header::RETRY_AFTER)
1289 .and_then(|v| v.to_str().ok())
1290 .and_then(parse_retry_after)
1291 }
1292
1293 #[cfg(test)]
1294 #[path = "tests.rs"]
1295 mod quota_tests;
1296
1297 // === Tests ===
1298
1299 #[cfg(test)]
1300 mod tests {
1301 use super::*;
1302
1303 fn assert_f64_eq(actual: f64, expected: f64) {
1304 assert!(
1305 (actual - expected).abs() < f64::EPSILON,
1306 "expected {expected}, got {actual}"
1307 );
1308 }
1309
1310 fn auth_user_message(error: LlmError) -> String {
1311 match error {
1312 LlmError::AuthenticationError(auth) => auth.to_user_message(),
1313 other => panic!("expected authentication error, got {other}"),
1314 }
1315 }
1316
1317 #[test]
1318 fn test_retry_config_defaults() {
1319 let config = RetryConfig::default();
1320 assert!(config.enabled);
1321 assert_eq!(config.max_retries, 3);
1322 assert_f64_eq(config.initial_delay, 1.0);
1323 assert_f64_eq(config.max_delay, 60.0);
1324 assert_f64_eq(config.exponential_base, 2.0);
1325 assert!(config.jitter);
1326 }
1327
1328 #[test]
1329 fn test_retry_config_disabled() {
1330 let config = RetryConfig::disabled();
1331 assert!(!config.enabled);
1332 }
1333
1334 #[test]
1335 fn test_retry_config_builder() {
1336 let config = RetryConfig::new()
1337 .with_max_retries(5)
1338 .with_initial_delay(2.0)
1339 .with_max_delay(120.0)
1340 .with_jitter(false);
1341
1342 assert_eq!(config.max_retries, 5);
1343 assert_f64_eq(config.initial_delay, 2.0);
1344 assert_f64_eq(config.max_delay, 120.0);
1345 assert!(!config.jitter);
1346 }
1347
1348 #[test]
1349 fn test_delay_for_attempt_exponential() {
1350 let config = RetryConfig::new().with_jitter(false);
1351
1352 // delay = initial * base^attempt
1353 // 1.0 * 2^0 = 1.0
1354 let d0 = config.delay_for_attempt(0);
1355 assert_eq!(d0, Duration::from_secs_f64(1.0));
1356
1357 // 1.0 * 2^1 = 2.0
1358 let d1 = config.delay_for_attempt(1);
1359 assert_eq!(d1, Duration::from_secs_f64(2.0));
1360
1361 // 1.0 * 2^2 = 4.0
1362 let d2 = config.delay_for_attempt(2);
1363 assert_eq!(d2, Duration::from_secs_f64(4.0));
1364
1365 // 1.0 * 2^3 = 8.0
1366 let d3 = config.delay_for_attempt(3);
1367 assert_eq!(d3, Duration::from_secs_f64(8.0));
1368 }
1369
1370 #[test]
1371 fn test_delay_for_attempt_capped() {
1372 let config = RetryConfig::new().with_jitter(false).with_max_delay(5.0);
1373
1374 // 1.0 * 2^3 = 8.0, but capped at 5.0
1375 let d3 = config.delay_for_attempt(3);
1376 assert_eq!(d3, Duration::from_secs_f64(5.0));
1377 }
1378
1379 #[test]
1380 fn test_delay_for_attempt_with_jitter() {
1381 let config = RetryConfig::new().with_jitter(true);
1382
1383 // With jitter, delays should vary slightly
1384 let d1 = config.delay_for_attempt(1);
1385 let d2 = config.delay_for_attempt(1);
1386
1387 // Both should be close to 2.0 seconds (within 10% jitter)
1388 let base = 2.0;
1389 let range = base * 0.1;
1390 assert!(d1.as_secs_f64() >= base - range);
1391 assert!(d1.as_secs_f64() <= base + range);
1392 assert!(d2.as_secs_f64() >= base - range);
1393 assert!(d2.as_secs_f64() <= base + range);
1394 }
1395
1396 #[test]
1397 fn test_is_retryable_status() {
1398 let config = RetryConfig::default();
1399
1400 assert!(config.is_retryable_status(429)); // Rate limit
1401 assert!(config.is_retryable_status(499)); // Upstream request cancelled
1402 assert!(config.is_retryable_status(500)); // Internal server error
1403 assert!(config.is_retryable_status(502)); // Bad gateway
1404 assert!(config.is_retryable_status(503)); // Service unavailable
1405 assert!(config.is_retryable_status(504)); // Gateway timeout
1406
1407 assert!(!config.is_retryable_status(400)); // Bad request
1408 assert!(!config.is_retryable_status(401)); // Unauthorized
1409 assert!(!config.is_retryable_status(403)); // Forbidden
1410 assert!(!config.is_retryable_status(404)); // Not found
1411 }
1412
1413 #[test]
1414 fn auth_error_with_context_includes_provider_authority_model_and_key_source() {
1415 let err = LlmError::from_http_response_with_request_context(
1416 401,
1417 "Invalid API Key",
1418 Some("Xiaomi MiMo"),
1419 Some("https://token-plan-sgp.xiaomimimo.com/v1"),
1420 Some("mimo-v2.5"),
1421 Some("env"),
1422 Some("tp-secret-token-plan-value"),
1423 );
1424 let message = auth_user_message(err);
1425
1426 assert!(message.contains("Invalid API Key"));
1427 assert!(message.contains("provider: Xiaomi MiMo"));
1428 assert!(message.contains("base URL authority: token-plan-sgp.xiaomimimo.com"));
1429 assert!(message.contains("model: mimo-v2.5"));
1430 assert!(message.contains("key source: env"));
1431 assert!(message.contains("key fingerprint: tp-... (len=26)"));
1432 }
1433
1434 #[test]
1435 fn auth_error_redacts_full_api_key_from_body_and_context() {
1436 let api_key = "tp-secret-token-plan-value";
1437 let err = LlmError::from_http_response_with_request_context(
1438 401,
1439 &format!("Invalid API Key: {api_key}"),
1440 Some("Xiaomi MiMo"),
1441 Some("https://token-plan-sgp.xiaomimimo.com/v1"),
1442 Some("mimo-v2.5"),
1443 Some("config-file"),
1444 Some(api_key),
1445 );
1446 let message = auth_user_message(err);
1447
1448 assert!(!message.contains(api_key));
1449 assert!(!message.contains("secret-token-plan-value"));
1450 assert!(message.contains("[redacted API key]"));
1451 assert!(message.contains("key fingerprint: tp-... (len=26)"));
1452 }
1453
1454 #[test]
1455 fn auth_error_classifies_xiaomi_token_plan_key_prefix() {
1456 let token_plan = AuthenticationErrorContext::from_parts(
1457 None,
1458 None,
1459 None,
1460 Some("session"),
1461 Some("tp-secret-token-plan-value"),
1462 );
1463 let generic = AuthenticationErrorContext::from_parts(
1464 None,
1465 None,
1466 None,
1467 Some("session"),
1468 Some("sk-other"),
1469 );
1470 let unprefixed = AuthenticationErrorContext::from_parts(
1471 None,
1472 None,
1473 None,
1474 Some("session"),
1475 Some("plainsecretvalue"),
1476 );
1477
1478 assert_eq!(
1479 token_plan.key_kind.as_deref(),
1480 Some("Xiaomi MiMo Token Plan key")
1481 );
1482 assert_eq!(generic.key_kind.as_deref(), Some("API key"));
1483 assert_eq!(unprefixed.key_kind.as_deref(), Some("API key"));
1484 assert_eq!(
1485 unprefixed.key_fingerprint.as_deref(),
1486 Some("unprefixed (len=16)")
1487 );
1488 }
1489
1490 #[test]
1491 fn authorization_403_is_not_reclassified_by_auth_context() {
1492 let err = LlmError::from_http_response_with_request_context(
1493 403,
1494 "forbidden",
1495 Some("Arcee AI"),
1496 Some("https://api.arcee.ai/v1"),
1497 Some("auto"),
1498 Some("env"),
1499 Some("sk-arcee-secret"),
1500 );
1501
1502 assert!(matches!(err, LlmError::AuthorizationError(_)));
1503 }
1504
1505 #[test]
1506 fn auth_error_without_context_preserves_bare_message() {
1507 let err = LlmError::from_http_response_with_auth_context(
1508 401,
1509 "Invalid API Key",
1510 Some(AuthenticationErrorContext::default()),
1511 );
1512
1513 assert_eq!(auth_user_message(err), "Invalid API Key");
1514 }
1515
1516 #[test]
1517 fn cloudflare_html_error_is_summarized_without_raw_markup() {
1518 let body = r#"<!DOCTYPE html><html><head><title>Access Denied</title><style>
1519 .hidden { display: none; }
1520 </style></head><body>
1521 <h1>Access Denied</h1>
1522 <p>The action you just performed triggered a security alert.</p>
1523 <script>window.noisy = true;</script>
1524 <span>2600:1700:467:d410:f137:b94f:1dd0:d1e4</span>
1525 <span>a059a2873f3fdf82</span>
1526 <div>Cloudflare Error Pages</div>
1527 </body></html>"#;
1528
1529 let message = sanitize_http_error_body(Some("Arcee AI"), 403, body);
1530
1531 assert!(message.contains("Arcee AI API returned Cloudflare Access Denied"));
1532 assert!(message.contains("ID a059a2873f3fdf82"));
1533 assert!(!message.contains("<!DOCTYPE"));
1534 assert!(!message.contains("tailwindcss"));
1535 assert!(message.len() < 300);
1536 }
1537
1538 #[test]
1539 fn cloudflare_access_denied_403_is_authorization_not_authentication() {
1540 let message = sanitize_http_error_body(
1541 Some("Arcee AI"),
1542 403,
1543 r#"<!doctype html><html><body><h1>Access Denied</h1><p>Cloudflare Error Pages</p></body></html>"#,
1544 );
1545 let err = LlmError::from_http_response(403, &message);
1546
1547 assert!(matches!(err, LlmError::AuthorizationError(_)));
1548 }
1549
1550 #[test]
1551 fn arcee_access_denied_without_literal_cloudflare_is_still_summarized() {
1552 // Mirrors api.arcee.ai's real 403 page: "Cloudflare" appears only in a
1553 // `<meta>` attribute and the `<style>` block, both stripped, so the
1554 // visible text never contains it. The summary must still fire from the
1555 // WAF's stock "security alert" / "Contact Support" copy + error ID.
1556 let body = r#"<!DOCTYPE html><html lang="en"><head>
1557 <meta name="description" content="Cloudflare Error Pages">
1558 <title>Access Denied</title>
1559 <style>:root{--accent:cloudflare}</style></head><body>
1560 <h1>Access Denied</h1>
1561 <p>The action you just performed triggered a security alert.</p>
1562 <p>Please contact us if this was a mistake.</p>
1563 <a>Contact Support</a>
1564 <span>2600:1700:467:d410:f137:b94f:1dd0:d1e4</span>
1565 <span>a059c0d4caf1f9cc</span>
1566 </body></html>"#;
1567
1568 let message = sanitize_http_error_body(Some("Arcee AI"), 403, body);
1569
1570 assert!(
1571 message.contains("Arcee AI API returned Access Denied"),
1572 "got: {message}"
1573 );
1574 assert!(message.contains("ID a059c0d4caf1f9cc"), "got: {message}");
1575 assert!(
1576 !message.to_ascii_lowercase().contains("cloudflare"),
1577 "stripped Arcee page has no literal Cloudflare: {message}"
1578 );
1579 assert!(!message.contains('<'), "no raw markup: {message}");
1580 assert!(message.len() < 300, "stays concise: {message}");
1581
1582 // A WAF block is authorization, not a bad API key.
1583 let err = LlmError::from_http_response(403, &message);
1584 assert!(matches!(err, LlmError::AuthorizationError(_)));
1585 }
1586
1587 #[test]
1588 fn test_llm_error_suggested_retry_delay() {
1589 let err = LlmError::RateLimited {
1590 message: "slow down".to_string(),
1591 retry_after: Some(Duration::from_secs(60)),
1592 };
1593 assert_eq!(err.suggested_retry_delay(), Some(Duration::from_secs(60)));
1594
1595 let err = LlmError::ServerError {
1596 status: 500,
1597 message: "error".to_string(),
1598 };
1599 assert_eq!(err.suggested_retry_delay(), None);
1600 }
1601
1602 #[test]
1603 fn test_parse_retry_after() {
1604 // Integer seconds
1605 assert_eq!(parse_retry_after("120"), Some(Duration::from_secs(120)));
1606 assert_eq!(parse_retry_after("0"), Some(Duration::from_secs(0)));
1607
1608 // Float seconds keep sub-second precision
1609 assert_eq!(parse_retry_after("1.5"), Some(Duration::from_secs_f64(1.5)));
1610
1611 // Invalid
1612 assert_eq!(parse_retry_after("invalid"), None);
1613 assert_eq!(parse_retry_after(""), None);
1614 }
1615
1616 /// A `Retry-After` value is server-controlled. Malformed floats used to
1617 /// reach `Duration::from_secs_f64`, which panics on a negative — a
1618 /// remote-triggerable crash in the request path (2026-08-04 review).
1619 #[test]
1620 fn parse_retry_after_never_panics_and_is_bounded_on_hostile_input() {
1621 // None of these may panic.
1622 assert_eq!(parse_retry_after("-5"), None, "negative is not a delay");
1623 assert_eq!(parse_retry_after("nan"), None);
1624 assert_eq!(parse_retry_after("inf"), None);
1625 assert_eq!(parse_retry_after("-inf"), None);
1626 // Absurdly large values clamp to the ceiling rather than overflowing
1627 // or wedging the turn for a day.
1628 assert_eq!(parse_retry_after("1e300"), Some(RETRY_AFTER_MAX));
1629 assert_eq!(parse_retry_after("86400"), Some(RETRY_AFTER_MAX));
1630 assert_eq!(
1631 parse_retry_after("999999999999"),
1632 Some(RETRY_AFTER_MAX),
1633 "integer path is clamped too"
1634 );
1635 // A normal value still passes through untouched.
1636 assert_eq!(parse_retry_after("30"), Some(Duration::from_secs(30)));
1637 }
1638
1639 #[test]
1640 fn test_retry_policy_conversion() {
1641 let policy = RetryPolicy {
1642 enabled: true,
1643 max_retries: 5,
1644 initial_delay: 2.0,
1645 max_delay: 30.0,
1646 exponential_base: 3.0,
1647 };
1648
1649 let config: RetryConfig = policy.clone().into();
1650 assert_eq!(config.enabled, policy.enabled);
1651 assert_eq!(config.max_retries, policy.max_retries);
1652 assert_f64_eq(config.initial_delay, policy.initial_delay);
1653 assert_f64_eq(config.max_delay, policy.max_delay);
1654 assert_f64_eq(config.exponential_base, policy.exponential_base);
1655
1656 // Convert back
1657 let policy2: RetryPolicy = config.into();
1658 assert_eq!(policy2.enabled, policy.enabled);
1659 assert_eq!(policy2.max_retries, policy.max_retries);
1660 }
1661
1662 #[tokio::test]
1663 async fn test_with_retry_success_first_attempt() {
1664 let config = RetryConfig::default();
1665 let mut call_count = 0;
1666
1667 let result = with_retry(
1668 &config,
1669 || {
1670 call_count += 1;
1671 async { Ok::<_, LlmError>(42) }
1672 },
1673 None,
1674 )
1675 .await;
1676
1677 assert!(result.is_ok());
1678 assert_eq!(result.unwrap(), 42);
1679 assert_eq!(call_count, 1);
1680 }
1681
1682 #[tokio::test]
1683 async fn test_with_retry_disabled() {
1684 let config = RetryConfig::disabled();
1685 let mut call_count = 0;
1686
1687 let result: RetryResult<i32> = with_retry(
1688 &config,
1689 || {
1690 call_count += 1;
1691 async {
1692 Err(LlmError::ServerError {
1693 status: 500,
1694 message: "error".to_string(),
1695 })
1696 }
1697 },
1698 None,
1699 )
1700 .await;
1701
1702 assert!(result.is_err());
1703 assert_eq!(call_count, 1); // No retries when disabled
1704 }
1705
1706 #[tokio::test]
1707 async fn test_with_retry_eventual_success() {
1708 let config = RetryConfig::new()
1709 .with_max_retries(3)
1710 .with_initial_delay(0.01); // Fast for testing
1711
1712 let call_count = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
1713 let cc = call_count.clone();
1714
1715 let result = with_retry(
1716 &config,
1717 || {
1718 let count = cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1719 async move {
1720 if count < 2 {
1721 Err(LlmError::ServerError {
1722 status: 500,
1723 message: "temporary error".to_string(),
1724 })
1725 } else {
1726 Ok::<_, LlmError>(42)
1727 }
1728 }
1729 },
1730 None,
1731 )
1732 .await;
1733
1734 assert!(result.is_ok());
1735 assert_eq!(result.unwrap(), 42);
1736 assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 3); // 2 failures + 1 success
1737 }
1738
1739 #[tokio::test]
1740 async fn test_with_retry_exhausted() {
1741 let config = RetryConfig::new()
1742 .with_max_retries(2)
1743 .with_initial_delay(0.01);
1744
1745 let call_count = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
1746 let cc = call_count.clone();
1747
1748 let result: RetryResult<i32> = with_retry(
1749 &config,
1750 || {
1751 cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1752 async {
1753 Err(LlmError::ServerError {
1754 status: 500,
1755 message: "persistent error".to_string(),
1756 })
1757 }
1758 },
1759 None,
1760 )
1761 .await;
1762
1763 assert!(result.is_err());
1764 let err = result.unwrap_err();
1765 assert_eq!(err.attempts, 3); // 1 initial + 2 retries
1766 assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 3);
1767 }
1768
1769 #[tokio::test]
1770 async fn test_with_retry_callback() {
1771 let config = RetryConfig::new()
1772 .with_max_retries(2)
1773 .with_initial_delay(0.01);
1774
1775 let callback_count = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
1776 let cc = callback_count.clone();
1777
1778 let _: RetryResult<i32> = with_retry(
1779 &config,
1780 || async {
1781 Err(LlmError::ServerError {
1782 status: 500,
1783 message: "error".to_string(),
1784 })
1785 },
1786 Some(Box::new(move |_err, _attempt, _delay| {
1787 cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1788 })),
1789 )
1790 .await;
1791
1792 // Callback called once per retry (not for the final failure)
1793 assert_eq!(callback_count.load(std::sync::atomic::Ordering::SeqCst), 2);
1794 }
1795
1796 #[test]
1797 fn test_retry_error_display() {
1798 let err = RetryError {
1799 last_error: LlmError::ServerError {
1800 status: 500,
1801 message: "internal error".to_string(),
1802 },
1803 attempts: 4,
1804 total_time: Duration::from_secs(10),
1805 };
1806
1807 let display = format!("{err}");
1808 assert!(display.contains("4 attempts"));
1809 assert!(display.contains("10"));
1810 assert!(display.contains("Server error"));
1811 }
1812 }
1813
1813 lines RUST