返回 CodeWhale
tests.rs
根目录 / crates / tui / src / llm_client / tests.rs
1 use super::*;
2
3 #[test]
4 fn missing_google_thought_signature_errors_explain_recovery() {
5 for detail in [
6 "Function call is missing a thought_signature in functionCall parts.",
7 "Function call is missing thought_signature.",
8 "The thought_signature is missing from the function call.",
9 ] {
10 for body in [
11 detail.to_string(),
12 serde_json::json!({"error": {"message": detail, "code": 400}}).to_string(),
13 serde_json::json!({"error": "Bad Request", "message": detail}).to_string(),
14 ] {
15 let message = sanitize_http_error_body(Some("Custom"), 400, &body);
16 assert!(message.contains("built-in `google` provider"), "{message}");
17 assert!(message.contains("start a new session"), "{message}");
18 assert!(message.contains(detail), "provider detail must survive");
19 assert_eq!(sanitize_http_error_body(None, 400, &message), message);
20 let error = LlmError::from_http_response(400, &message);
21 assert!(matches!(
22 error,
23 LlmError::InvalidRequest { status: 400, .. }
24 ));
25 assert!(!error.is_retryable());
26 }
27 }
28 }
29
30 #[test]
31 fn google_thought_signature_hint_requires_a_missing_signature_400() {
32 for (status, detail) in [
33 (400, "Invalid model name"),
34 (400, "Invalid thought_signature in functionCall parts"),
35 (400, "Unsupported parameter: thought_signature"),
36 (
37 401,
38 "Function call is missing a thought_signature in functionCall parts.",
39 ),
40 (
41 429,
42 "Function call is missing a thought_signature in functionCall parts.",
43 ),
44 (
45 500,
46 "Function call is missing a thought_signature in functionCall parts.",
47 ),
48 ] {
49 let body = serde_json::json!({"error": {"message": detail}}).to_string();
50 assert_eq!(
51 sanitize_http_error_body(Some("Custom"), status, &body),
52 detail
53 );
54 }
55 }
56
57 #[test]
58 fn google_thought_signature_hint_keeps_large_provider_errors_bounded() {
59 let body = format!(
60 "Function call is missing a thought_signature in functionCall parts. {}",
61 "界".repeat(3_000)
62 );
63 let message = sanitize_http_error_body(None, 400, &body);
64 assert!(message.contains("start a new session"));
65 assert!(message.chars().count() < 2_000);
66 assert_eq!(sanitize_http_error_body(None, 400, &message), message);
67 }
68
69 #[test]
70 fn google_thought_signature_hint_preserves_quota_and_html_handling() {
71 let detail = "Function call is missing a thought_signature in functionCall parts.";
72 let body = serde_json::json!({
73 "error": {"message": detail, "code": "insufficient_quota"}
74 })
75 .to_string();
76 let message = sanitize_http_error_body(None, 400, &body);
77 assert!(matches!(
78 LlmError::from_http_response(400, &message),
79 LlmError::QuotaExhausted(_)
80 ));
81 let html = format!("<!doctype html><html><body>{detail}</body></html>");
82 let message = sanitize_http_error_body(None, 400, &html);
83 assert!(message.contains("HTML error page"));
84 assert!(!message.contains("<html>"));
85 }
86
87 #[test]
88 fn retryability_distinguishes_transient_failures_from_durable_failures() {
89 for error in [
90 LlmError::RateLimited {
91 message: "too many requests".into(),
92 retry_after: None,
93 },
94 LlmError::ServerError {
95 status: 500,
96 message: "internal error".into(),
97 },
98 LlmError::NetworkError("connection refused".into()),
99 LlmError::Timeout(Duration::from_secs(30)),
100 ] {
101 assert!(error.is_retryable(), "expected transient error: {error}");
102 }
103 for error in [
104 LlmError::authentication_error("invalid key"),
105 LlmError::AuthorizationError("blocked".into()),
106 LlmError::InvalidRequest {
107 status: 400,
108 message: "bad json".into(),
109 },
110 LlmError::ContentPolicyError("unsafe content".into()),
111 LlmError::ContextLengthError("too long".into()),
112 ] {
113 assert!(!error.is_retryable(), "expected durable error: {error}");
114 }
115 }
116
117 #[test]
118 fn http_response_boundary_classifies_status_contract() {
119 assert!(matches!(
120 LlmError::from_http_response(429, "rate limit exceeded"),
121 LlmError::RateLimited { .. }
122 ));
123 assert!(matches!(
124 LlmError::from_http_response(401, "invalid api key"),
125 LlmError::AuthenticationError(_)
126 ));
127 assert!(matches!(
128 LlmError::from_http_response(403, "forbidden"),
129 LlmError::AuthorizationError(_)
130 ));
131 assert!(matches!(
132 LlmError::from_http_response(403, "invalid api key"),
133 LlmError::AuthenticationError(_)
134 ));
135 let cancelled = LlmError::from_http_response(499, "upstream request cancelled");
136 assert!(matches!(
137 &cancelled,
138 LlmError::ServerError { status: 499, .. }
139 ));
140 assert!(cancelled.is_retryable());
141 assert!(matches!(
142 LlmError::from_http_response(500, "internal server error"),
143 LlmError::ServerError { status: 500, .. }
144 ));
145 assert!(matches!(
146 LlmError::from_http_response(503, "service unavailable"),
147 LlmError::ServerError { status: 503, .. }
148 ));
149 assert!(matches!(
150 LlmError::from_http_response(400, "context_length_exceeded"),
151 LlmError::ContextLengthError(_)
152 ));
153 assert!(matches!(
154 LlmError::from_http_response(400, "content_policy_violation"),
155 LlmError::ContentPolicyError(_)
156 ));
157 assert!(matches!(
158 LlmError::from_http_response(400, "invalid json"),
159 LlmError::InvalidRequest { status: 400, .. }
160 ));
161 // "Unsupported parameter: max_output_tokens" names a *token* field, which
162 // the generic keyword rules misread as a context-window overflow. It is a
163 // request-shape error, and retrying or compacting cannot fix it.
164 assert!(matches!(
165 LlmError::from_http_response(
166 400,
167 "{\"error\":{\"code\":\"unsupported_parameter\",\"message\":\"Unsupported parameter: max_output_tokens\"}}"
168 ),
169 LlmError::InvalidRequest { status: 400, .. }
170 ));
171 assert!(matches!(
172 LlmError::from_http_response(
173 400,
174 "{\"error\":{\"type\":\"invalid_request_error\",\"message\":\"Unsupported parameter: temperature\"}}"
175 ),
176 LlmError::InvalidRequest { status: 400, .. }
177 ));
178 }
179
180 #[test]
181 fn explicit_400_402_and_429_quota_responses_are_typed_and_non_retryable() {
182 for (status, body) in [
183 (
184 400,
185 r#"{"error":{"code":"insufficient_quota","message":"You exceeded your current quota"}}"#,
186 ),
187 (
188 429,
189 r#"{"error":{"type":"insufficient_quota","message":"Billing limit reached"}}"#,
190 ),
191 (
192 402,
193 r#"{"error":{"code":"billing_hard_limit_reached","message":"Payment required"}}"#,
194 ),
195 (
196 429,
197 "You exceeded your current quota. Please check your plan and billing details.",
198 ),
199 (429, "Account quota exhausted"),
200 ] {
201 let error = LlmError::from_http_response(status, body);
202 assert!(matches!(error, LlmError::QuotaExhausted(_)));
203 assert!(!error.is_retryable());
204 }
205
206 let raw = r#"{"error":{"code":"billing_hard_limit_reached","message":"Account unavailable"}}"#;
207 let safe = sanitize_http_error_body(Some("fixture"), 429, raw);
208 assert!(matches!(
209 LlmError::from_http_response(429, &safe),
210 LlmError::QuotaExhausted(_)
211 ));
212 }
213
214 #[test]
215 fn generic_429_stays_rate_limited_and_retryable() {
216 for body in [
217 "Too Many Requests",
218 "Rate limit on your API quota exceeded",
219 "Requests per minute quota exceeded",
220 "Quota rate limit exceeded; retry after 10 seconds",
221 ] {
222 let error = LlmError::from_http_response(429, body);
223 assert!(
224 matches!(error, LlmError::RateLimited { .. }),
225 "expected transient rate limit for {body:?}, got {error:?}"
226 );
227 assert!(error.is_retryable());
228 }
229
230 let raw = r#"{"error":{"code":"RESOURCE_EXHAUSTED","message":"Rate limit on your API quota exceeded"}}"#;
231 let safe = sanitize_http_error_body(Some("fixture"), 429, raw);
232 let error = LlmError::from_http_response(429, &safe);
233 assert!(matches!(error, LlmError::RateLimited { .. }));
234 assert!(error.is_retryable());
235 }
236
237 #[test]
238 fn missing_google_signature_400_explains_recovery_without_widening_gateway_preflight() {
239 let message = "Function call is missing a thought_signature in functionCall parts";
240 for body in [
241 message.to_string(),
242 serde_json::json!({"error": {"message": message}}).to_string(),
243 format!("{message} {}", "详情".repeat(2_000)),
244 ] {
245 let safe = sanitize_http_error_body(Some("OpenAI-compatible"), 400, &body);
246 assert!(safe.contains(message));
247 assert!(safe.contains("built-in `google` provider"));
248 assert!(safe.contains("start a new session"));
249 assert!(safe.contains("gateway"));
250 assert!(
251 safe.chars().count() <= 2_003,
252 "error bound survives the hint"
253 );
254 }
255 for (status, body) in [
256 (429, message),
257 (200, message),
258 (400, "Invalid thought_signature"),
259 (400, "Missing required parameter: model"),
260 ] {
261 assert_eq!(sanitize_http_error_body(None, status, body), body);
262 }
263 }
264
265 #[tokio::test]
266 async fn retry_loop_stops_after_one_typed_quota_failure() {
267 let mut calls = 0;
268 let result: RetryResult<i32> = with_retry(
269 &RetryConfig::default(),
270 || {
271 calls += 1;
272 async {
273 Err(LlmError::from_http_response(
274 429,
275 r#"{"error":{"code":"insufficient_quota"}}"#,
276 ))
277 }
278 },
279 None,
280 )
281 .await;
282 assert_eq!(result.unwrap_err().attempts, 1);
283 assert_eq!(calls, 1);
284 }
285
286 #[tokio::test]
287 async fn retry_loop_stops_after_one_authentication_failure() {
288 let mut calls = 0;
289 let result: RetryResult<i32> = with_retry(
290 &RetryConfig::default(),
291 || {
292 calls += 1;
293 async { Err(LlmError::authentication_error("bad key")) }
294 },
295 None,
296 )
297 .await;
298 assert!(result.is_err());
299 assert_eq!(calls, 1);
300 }
301
301 lines RUST