| 1 | use super::*; |
| 2 | |
| 3 | #[test] |
| 4 | fn model_output_truncated_classifies_as_invalid_input_not_tool() { |
| 5 | // The turn-level "Model output truncated" error is a provider/model |
| 6 | // condition, not a tool failure: it must land in the same bucket as |
| 7 | // `LlmError::ModelError` so the exec termination classifier reduces it |
| 8 | // to `RunTerminationReason::ModelError` (never Resolved). |
| 9 | assert_eq!( |
| 10 | classify_error_message( |
| 11 | "Model output truncated: provider stop reason `max_output_tokens`; no complete response or tool call was accepted." |
| 12 | ), |
| 13 | ErrorCategory::InvalidInput |
| 14 | ); |
| 15 | assert_eq!( |
| 16 | classify_error_message( |
| 17 | "Model output truncated: provider stop reason `max_tokens`; no complete response or tool call was accepted." |
| 18 | ), |
| 19 | ErrorCategory::InvalidInput |
| 20 | ); |
| 21 | assert_eq!( |
| 22 | classify_error_message( |
| 23 | "Model response incomplete: provider stop reason `content_filter`; no complete response or tool call was accepted." |
| 24 | ), |
| 25 | ErrorCategory::InvalidInput |
| 26 | ); |
| 27 | } |
| 28 | |
| 29 | #[test] |
| 30 | fn raw_rate_and_quota_phrases_remain_coarse_rate_limit_diagnostics() { |
| 31 | for message in [ |
| 32 | "Rate limit reached for gpt-4", |
| 33 | "Too Many Requests", |
| 34 | "HTTP 429 from upstream", |
| 35 | "Your quota has been exceeded", |
| 36 | "Authorization failed: You've reached your usage limit for this billing cycle", |
| 37 | ] { |
| 38 | assert_eq!(classify_error_message(message), ErrorCategory::RateLimit); |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | #[test] |
| 43 | fn typed_llm_quota_envelope_is_non_recoverable_and_distinct_from_rate_limit() { |
| 44 | let envelope = ErrorEnvelope::from(LlmError::from_http_response( |
| 45 | 429, |
| 46 | r#"{"error":{"code":"insufficient_quota"}}"#, |
| 47 | )); |
| 48 | assert_eq!(envelope.category, ErrorCategory::RateLimit); |
| 49 | assert_eq!(envelope.severity, ErrorSeverity::Error); |
| 50 | assert!(!envelope.recoverable); |
| 51 | assert_eq!(envelope.code, "llm_quota_exhausted"); |
| 52 | } |
| 53 | |
| 54 | #[test] |
| 55 | fn llm_auth_error_envelope_renders_context_without_secret() { |
| 56 | let api_key = "tp-secret-token-plan-value"; |
| 57 | let envelope = ErrorEnvelope::from(LlmError::from_http_response_with_request_context( |
| 58 | 401, |
| 59 | &format!("Invalid API Key: {api_key}"), |
| 60 | Some("Xiaomi MiMo"), |
| 61 | Some("https://token-plan-sgp.xiaomimimo.com/v1"), |
| 62 | Some("mimo-v2.5"), |
| 63 | Some("env"), |
| 64 | Some(api_key), |
| 65 | )); |
| 66 | assert_eq!(envelope.category, ErrorCategory::Authentication); |
| 67 | assert_eq!(envelope.severity, ErrorSeverity::Critical); |
| 68 | assert!(!envelope.recoverable); |
| 69 | for expected in [ |
| 70 | "provider: Xiaomi MiMo", |
| 71 | "base URL authority: token-plan-sgp.xiaomimimo.com", |
| 72 | "model: mimo-v2.5", |
| 73 | "key source: env", |
| 74 | "key fingerprint: tp-... (len=26)", |
| 75 | "key type: Xiaomi MiMo Token Plan key", |
| 76 | ] { |
| 77 | assert!(envelope.message.contains(expected)); |
| 78 | } |
| 79 | assert!(!envelope.message.contains(api_key)); |
| 80 | assert!(!envelope.message.contains("secret-token-plan-value")); |
| 81 | } |
| 82 | |
| 83 | #[test] |
| 84 | fn model_not_exist_rejection_is_a_terminal_invalid_input_error() { |
| 85 | // The reported incident: a provider switch left GLM-5.3 selected on a |
| 86 | // Model Studio route; the next message failed with |
| 87 | // `Model error: Model not exist.` and the transcript rendered it as a |
| 88 | // dismissable *warning* because the stringified error fell through to |
| 89 | // `Internal` + `recoverable`. A wrong-model rejection is terminal for |
| 90 | // the request: it must classify as InvalidInput so it renders at Error |
| 91 | // severity and never as recoverable noise. |
| 92 | for message in [ |
| 93 | "Model error: Model not exist.", |
| 94 | "Model not exist", |
| 95 | "Model not found: glm-5.3 on this endpoint", |
| 96 | "Unknown model identifier", |
| 97 | "Invalid model: qwen-flash", |
| 98 | ] { |
| 99 | let envelope = ErrorEnvelope::classify(message.to_string(), true); |
| 100 | assert_eq!( |
| 101 | envelope.category, |
| 102 | ErrorCategory::InvalidInput, |
| 103 | "message must classify as InvalidInput: {message}" |
| 104 | ); |
| 105 | assert_eq!( |
| 106 | envelope.severity, |
| 107 | ErrorSeverity::Error, |
| 108 | "wrong-model rejections must render at Error severity: {message}" |
| 109 | ); |
| 110 | // `recoverable` governs offline-mode semantics, not transcript |
| 111 | // severity: a wrong-model rejection keeps the session online so the |
| 112 | // operator can repair the route. |
| 113 | assert!(envelope.recoverable, "session stays online: {message}"); |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | #[test] |
| 118 | fn typed_llm_error_preserves_terminal_severity_across_boundary() { |
| 119 | // Even where the typed error survives to the boundary (the turn loop's |
| 120 | // stream-initiation and mid-stream paths), `envelope_for_llm_error` |
| 121 | // must keep the typed contract instead of re-classifying the string |
| 122 | // with `recoverable = true`. |
| 123 | let typed: anyhow::Error = |
| 124 | crate::llm_client::LlmError::ModelError("Model not exist.".to_string()).into(); |
| 125 | let envelope = envelope_for_llm_error(typed, "Model error: Model not exist.".to_string()); |
| 126 | assert_eq!(envelope.category, ErrorCategory::InvalidInput); |
| 127 | assert_eq!(envelope.severity, ErrorSeverity::Error); |
| 128 | assert_eq!(envelope.code, "llm_model_error"); |
| 129 | assert_eq!(envelope.message, "Model error: Model not exist."); |
| 130 | |
| 131 | // Untyped errors keep the legacy string fallback. |
| 132 | let untyped: anyhow::Error = anyhow::anyhow!("stream read error: connection reset"); |
| 133 | let envelope = envelope_for_llm_error(untyped, "stream read error: connection reset".into()); |
| 134 | assert_eq!(envelope.category, ErrorCategory::Network); |
| 135 | assert!(envelope.recoverable); |
| 136 | } |
| 137 |