返回 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 for context in [
162 "This model's maximum context length is 131072 tokens.",
163 "prompt is too long: 250000 tokens > 200000 maximum",
164 "input tokens exceed the configured limit",
165 "The input token count (2000000) exceeds the maximum number of tokens allowed (1048576).",
166 // xAI, Moonshot, Anthropic and Bedrock wording.
167 "This model's maximum prompt length is 131072 but the request contains 200000 tokens.",
168 "Invalid request: Your request exceeded model token limit: 262144",
169 "input length and `max_tokens` exceed context limit: 187254 + 20000 > 204798",
170 "Input is too long for requested model.",
171 ] {
172 assert!(
173 matches!(
174 LlmError::from_http_response(400, context),
175 LlmError::ContextLengthError(_)
176 ),
177 "{context}"
178 );
179 }
180 for invalid in [
181 "max_tokens must be less than or equal to 8192",
182 "Invalid 'messages[1].name': string too long. Expected a maximum length of 64.",
183 "invalid token in JSON body",
184 ] {
185 assert!(
186 matches!(
187 LlmError::from_http_response(400, invalid),
188 LlmError::InvalidRequest { status: 400, .. }
189 ),
190 "{invalid}"
191 );
192 }
193 // "Unsupported parameter: max_output_tokens" names a *token* field, which
194 // the generic keyword rules misread as a context-window overflow. It is a
195 // request-shape error, and retrying or compacting cannot fix it.
196 assert!(matches!(
197 LlmError::from_http_response(
198 400,
199 "{\"error\":{\"code\":\"unsupported_parameter\",\"message\":\"Unsupported parameter: max_output_tokens\"}}"
200 ),
201 LlmError::InvalidRequest { status: 400, .. }
202 ));
203 assert!(matches!(
204 LlmError::from_http_response(
205 400,
206 "{\"error\":{\"type\":\"invalid_request_error\",\"message\":\"Unsupported parameter: temperature\"}}"
207 ),
208 LlmError::InvalidRequest { status: 400, .. }
209 ));
210 }
211
212 #[test]
213 fn explicit_400_402_and_429_quota_responses_are_typed_and_non_retryable() {
214 for (status, body) in [
215 (
216 400,
217 r#"{"error":{"code":"insufficient_quota","message":"You exceeded your current quota"}}"#,
218 ),
219 (
220 429,
221 r#"{"error":{"type":"insufficient_quota","message":"Billing limit reached"}}"#,
222 ),
223 (
224 402,
225 r#"{"error":{"code":"billing_hard_limit_reached","message":"Payment required"}}"#,
226 ),
227 (
228 429,
229 "You exceeded your current quota. Please check your plan and billing details.",
230 ),
231 (429, "Account quota exhausted"),
232 ] {
233 let error = LlmError::from_http_response(status, body);
234 assert!(matches!(error, LlmError::QuotaExhausted(_)));
235 assert!(!error.is_retryable());
236 }
237
238 let raw = r#"{"error":{"code":"billing_hard_limit_reached","message":"Account unavailable"}}"#;
239 let safe = sanitize_http_error_body(Some("fixture"), 429, raw);
240 assert!(matches!(
241 LlmError::from_http_response(429, &safe),
242 LlmError::QuotaExhausted(_)
243 ));
244 }
245
246 #[test]
247 fn chatgpt_usage_limit_is_quota_and_carries_account_guidance() {
248 // Shape of the ChatGPT Codex backend's subscription-window 429, as
249 // openai/codex `codex-api/src/api_bridge.rs` parses it.
250 let raw = r#"{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached","plan_type":"plus","resets_at":1790000000}}"#;
251 let safe = sanitize_http_error_body(Some("OpenAI Codex"), 429, raw);
252 let error = LlmError::from_http_response(429, &safe);
253 assert!(!error.is_retryable());
254 let LlmError::QuotaExhausted(evidence) = error else {
255 panic!("usage_limit_reached must be typed quota, got {error:?}");
256 };
257 let rendered = LlmError::QuotaExhausted(evidence.with_guidance(
258 "This limit belongs to the ChatGPT account a@example.com (plus). Run `codewhale auth chatgpt`.",
259 ))
260 .to_string();
261 assert!(
262 rendered.contains("The usage limit has been reached"),
263 "{rendered}"
264 );
265 assert!(rendered.contains("a@example.com (plus)"), "{rendered}");
266 assert!(rendered.contains("`codewhale auth chatgpt`"), "{rendered}");
267
268 // Same backend branch: the signed-in plan does not include Codex.
269 // Retrying cannot help, so it must not be a retryable rate limit.
270 let raw =
271 r#"{"error":{"type":"usage_not_included","message":"Your plan does not include Codex"}}"#;
272 let safe = sanitize_http_error_body(Some("OpenAI Codex"), 429, raw);
273 let error = LlmError::from_http_response(429, &safe);
274 assert!(!error.is_retryable());
275 assert!(matches!(error, LlmError::QuotaExhausted(_)), "{error:?}");
276 }
277
278 #[test]
279 fn generic_429_stays_rate_limited_and_retryable() {
280 for body in [
281 "Too Many Requests",
282 "Rate limit on your API quota exceeded",
283 "Requests per minute quota exceeded",
284 "Quota rate limit exceeded; retry after 10 seconds",
285 ] {
286 let error = LlmError::from_http_response(429, body);
287 assert!(
288 matches!(error, LlmError::RateLimited { .. }),
289 "expected transient rate limit for {body:?}, got {error:?}"
290 );
291 assert!(error.is_retryable());
292 }
293
294 let raw = r#"{"error":{"code":"RESOURCE_EXHAUSTED","message":"Rate limit on your API quota exceeded"}}"#;
295 let safe = sanitize_http_error_body(Some("fixture"), 429, raw);
296 let error = LlmError::from_http_response(429, &safe);
297 assert!(matches!(error, LlmError::RateLimited { .. }));
298 assert!(error.is_retryable());
299 }
300
301 #[test]
302 fn missing_google_signature_400_explains_recovery_without_widening_gateway_preflight() {
303 let message = "Function call is missing a thought_signature in functionCall parts";
304 for body in [
305 message.to_string(),
306 serde_json::json!({"error": {"message": message}}).to_string(),
307 format!("{message} {}", "详情".repeat(2_000)),
308 ] {
309 let safe = sanitize_http_error_body(Some("OpenAI-compatible"), 400, &body);
310 assert!(safe.contains(message));
311 assert!(safe.contains("built-in `google` provider"));
312 assert!(safe.contains("start a new session"));
313 assert!(safe.contains("gateway"));
314 assert!(
315 safe.chars().count() <= 2_003,
316 "error bound survives the hint"
317 );
318 }
319 for (status, body) in [
320 (429, message),
321 (200, message),
322 (400, "Invalid thought_signature"),
323 (400, "Missing required parameter: model"),
324 ] {
325 assert_eq!(sanitize_http_error_body(None, status, body), body);
326 }
327 }
328
329 #[tokio::test]
330 async fn retry_loop_stops_after_one_typed_quota_failure() {
331 let mut calls = 0;
332 let result: RetryResult<i32> = with_retry(
333 &RetryConfig::default(),
334 || {
335 calls += 1;
336 async {
337 Err(LlmError::from_http_response(
338 429,
339 r#"{"error":{"code":"insufficient_quota"}}"#,
340 ))
341 }
342 },
343 None,
344 )
345 .await;
346 assert_eq!(result.unwrap_err().attempts, 1);
347 assert_eq!(calls, 1);
348 }
349
350 #[tokio::test]
351 async fn retry_loop_stops_after_one_authentication_failure() {
352 let mut calls = 0;
353 let result: RetryResult<i32> = with_retry(
354 &RetryConfig::default(),
355 || {
356 calls += 1;
357 async { Err(LlmError::authentication_error("bad key")) }
358 },
359 None,
360 )
361 .await;
362 assert!(result.is_err());
363 assert_eq!(calls, 1);
364 }
365
366 fn retry_receipt_observation() -> (
367 RequestRetryObservation,
368 std::sync::Arc<std::sync::Mutex<Vec<String>>>,
369 ) {
370 let receipts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
371 let captured = receipts.clone();
372 (
373 RequestRetryObservation {
374 retries: std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)),
375 emit: std::sync::Arc::new(move |message| {
376 let captured = captured.clone();
377 Box::pin(async move {
378 captured.lock().unwrap().push(message);
379 })
380 }),
381 },
382 receipts,
383 )
384 }
385
386 fn immediate_retry_policy(max_retries: u32) -> RetryConfig {
387 RetryConfig {
388 max_retries,
389 initial_delay: 0.0,
390 jitter: false,
391 ..RetryConfig::default()
392 }
393 }
394
395 #[tokio::test]
396 async fn scoped_retry_records_first_transport_failure_and_recovery_without_remote_payload() {
397 let (observation, receipts) = retry_receipt_observation();
398 let count = observation.retries.clone();
399 let mut calls = 0;
400 let result = observe_request_retries(
401 Some(observation),
402 with_retry(
403 &immediate_retry_policy(2),
404 || {
405 calls += 1;
406 let call = calls;
407 async move {
408 if call == 1 {
409 Err(LlmError::ServerError {
410 status: 503,
411 message: "PRIVATE-PROVIDER-BODY".into(),
412 })
413 } else {
414 Ok(17)
415 }
416 }
417 },
418 None,
419 ),
420 )
421 .await
422 .unwrap();
423 assert_eq!(result, 17);
424 assert_eq!(calls, 2);
425 assert_eq!(count.load(std::sync::atomic::Ordering::Relaxed), 1);
426 assert_eq!(
427 *receipts.lock().unwrap(),
428 [
429 "Retry attempt: transport 1/2; upstream 503; waiting 0.00s",
430 "Retry recovery: transport request recovered after 1 retries",
431 ]
432 );
433 }
434
435 #[tokio::test]
436 async fn scoped_retry_exhaustion_retains_typed_original_error_and_every_attempt() {
437 let (observation, receipts) = retry_receipt_observation();
438 let count = observation.retries.clone();
439 let mut calls = 0;
440 let result: RetryResult<()> = observe_request_retries(
441 Some(observation),
442 with_retry(
443 &immediate_retry_policy(2),
444 || {
445 calls += 1;
446 async {
447 Err(LlmError::ServerError {
448 status: 503,
449 message: "RAW-TERMINAL-PROVIDER-BODY".into(),
450 })
451 }
452 },
453 None,
454 ),
455 )
456 .await;
457 let error = result.unwrap_err();
458 assert_eq!(calls, 3);
459 assert_eq!(error.attempts, 3);
460 assert!(
461 matches!(error.last_error, LlmError::ServerError { status: 503, ref message } if message == "RAW-TERMINAL-PROVIDER-BODY")
462 );
463 assert_eq!(count.load(std::sync::atomic::Ordering::Relaxed), 2);
464 assert_eq!(
465 *receipts.lock().unwrap(),
466 [
467 "Retry attempt: transport 1/2; upstream 503; waiting 0.00s",
468 "Retry attempt: transport 2/2; upstream 503; waiting 0.00s",
469 "Retry exhaustion: transport request stopped after 2 retries; upstream 503",
470 ]
471 );
472 }
473
474 #[tokio::test]
475 async fn isolated_retry_scope_never_observes_foreground_attempts() {
476 let (observation, receipts) = retry_receipt_observation();
477 let count = observation.retries.clone();
478 let mut calls = 0;
479 let result = observe_request_retries(
480 Some(observation),
481 observe_request_retries(
482 None,
483 with_retry(
484 &immediate_retry_policy(2),
485 || {
486 calls += 1;
487 let call = calls;
488 async move {
489 if call == 1 {
490 Err(LlmError::NetworkError("private endpoint".into()))
491 } else {
492 Ok(())
493 }
494 }
495 },
496 None,
497 ),
498 ),
499 )
500 .await;
501 assert!(result.is_ok());
502 assert_eq!(calls, 2);
503 assert_eq!(count.load(std::sync::atomic::Ordering::Relaxed), 0);
504 assert!(receipts.lock().unwrap().is_empty());
505 }
506
507 #[tokio::test]
508 async fn concurrent_request_retry_observations_keep_exact_producing_scope() {
509 let (first, first_receipts) = retry_receipt_observation();
510 let (second, second_receipts) = retry_receipt_observation();
511 let first_count = first.retries.clone();
512 let second_count = second.retries.clone();
513 let mut first_calls = 0;
514 let mut second_calls = 0;
515 let first_policy = immediate_retry_policy(2);
516 let second_policy = immediate_retry_policy(4);
517 let (a, b) = tokio::join!(
518 observe_request_retries(
519 Some(first),
520 with_retry(
521 &first_policy,
522 || {
523 first_calls += 1;
524 let call = first_calls;
525 async move {
526 if call == 1 {
527 Err(LlmError::Timeout(Duration::from_secs(1)))
528 } else {
529 Ok(())
530 }
531 }
532 },
533 None
534 )
535 ),
536 observe_request_retries(
537 Some(second),
538 with_retry(
539 &second_policy,
540 || {
541 second_calls += 1;
542 let call = second_calls;
543 async move {
544 if call == 1 {
545 Err(LlmError::ServerError {
546 status: 502,
547 message: "opaque".into(),
548 })
549 } else {
550 Ok(())
551 }
552 }
553 },
554 None
555 )
556 ),
557 );
558 assert!(a.is_ok() && b.is_ok());
559 assert_eq!((first_calls, second_calls), (2, 2));
560 assert_eq!(first_count.load(std::sync::atomic::Ordering::Relaxed), 1);
561 assert_eq!(second_count.load(std::sync::atomic::Ordering::Relaxed), 1);
562 assert_eq!(
563 first_receipts.lock().unwrap()[0],
564 "Retry attempt: transport 1/2; timeout; waiting 0.00s"
565 );
566 assert_eq!(
567 second_receipts.lock().unwrap()[0],
568 "Retry attempt: transport 1/4; upstream 502; waiting 0.00s"
569 );
570 }
571
571 lines RUST