返回 CodeWhale
tests.rs
根目录 / crates / tui / src / client / responses / tests.rs
1 use super::*;
2
3 use std::sync::Arc;
4 use std::sync::atomic::{AtomicUsize, Ordering};
5
6 use futures_util::StreamExt;
7
8 use crate::config::{Config, ProviderConfig, ProvidersConfig, RetryConfig};
9 use crate::models::Message;
10 use wiremock::matchers::{method, path};
11 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
12
13 #[derive(Clone)]
14 struct RetryThenSuccess {
15 attempts: Arc<AtomicUsize>,
16 retry_status: u16,
17 retry_body: &'static str,
18 }
19
20 impl Respond for RetryThenSuccess {
21 fn respond(&self, _request: &Request) -> ResponseTemplate {
22 if self.attempts.fetch_add(1, Ordering::SeqCst) == 0 {
23 let mut response =
24 ResponseTemplate::new(self.retry_status).set_body_string(self.retry_body);
25 if self.retry_status == 429 {
26 response = response.insert_header("Retry-After", "0");
27 }
28 return response;
29 }
30
31 ResponseTemplate::new(200)
32 .insert_header("Content-Type", "text/event-stream")
33 .set_body_string("data: [DONE]\n\n")
34 }
35 }
36
37 #[derive(Clone)]
38 struct AlwaysError {
39 attempts: Arc<AtomicUsize>,
40 status: u16,
41 body: &'static str,
42 }
43
44 impl Respond for AlwaysError {
45 fn respond(&self, _request: &Request) -> ResponseTemplate {
46 self.attempts.fetch_add(1, Ordering::SeqCst);
47 ResponseTemplate::new(self.status).set_body_string(self.body)
48 }
49 }
50
51 fn minimal_responses_request() -> MessageRequest {
52 MessageRequest {
53 model: "gpt-5.5".to_string(),
54 messages: vec![Message {
55 role: "user".to_string(),
56 content: vec![ContentBlock::Text {
57 text: "hello".to_string(),
58 cache_control: None,
59 }],
60 }],
61 max_tokens: 128,
62 system: None,
63 tools: None,
64 tool_choice: None,
65 metadata: None,
66 thinking: None,
67 reasoning_effort: None,
68 stream: None,
69 temperature: None,
70 top_p: None,
71 }
72 }
73
74 fn test_codex_config(server: &MockServer) -> Config {
75 Config {
76 provider: Some("openai-codex".to_string()),
77 retry: Some(RetryConfig {
78 enabled: Some(true),
79 max_retries: Some(1),
80 initial_delay: Some(0.0),
81 max_delay: Some(0.0),
82 exponential_base: Some(1.0),
83 }),
84 providers: Some(ProvidersConfig {
85 openai_codex: ProviderConfig {
86 base_url: Some(server.uri()),
87 ..ProviderConfig::default()
88 },
89 ..ProvidersConfig::default()
90 }),
91 ..Config::default()
92 }
93 }
94
95 #[tokio::test]
96 async fn responses_stream_retries_rate_limited_request() {
97 let server = MockServer::start().await;
98 let attempts = Arc::new(AtomicUsize::new(0));
99 Mock::given(method("POST"))
100 .and(path(CODEX_RESPONSES_PATH))
101 .respond_with(RetryThenSuccess {
102 attempts: Arc::clone(&attempts),
103 retry_status: 429,
104 retry_body: "rate limited",
105 })
106 .mount(&server)
107 .await;
108
109 let client = {
110 let _env_lock = crate::test_support::lock_test_env();
111 let _codex_token =
112 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-token");
113 let _legacy_codex_token = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
114 DeepSeekClient::new(&test_codex_config(&server)).unwrap()
115 };
116 let mut stream = client
117 .handle_responses_stream(
118 &client
119 .prepare_outbound_request(minimal_responses_request(), true)
120 .expect("responses request prepares"),
121 )
122 .await
123 .unwrap();
124
125 tokio::time::timeout(std::time::Duration::from_secs(5), async {
126 while let Some(event) = stream.next().await {
127 event.unwrap();
128 }
129 })
130 .await
131 .expect("Responses retry stream should finish after [DONE]");
132
133 assert_eq!(attempts.load(Ordering::SeqCst), 2);
134 }
135
136 #[tokio::test]
137 async fn responses_stream_retries_transient_server_error() {
138 let server = MockServer::start().await;
139 let attempts = Arc::new(AtomicUsize::new(0));
140 Mock::given(method("POST"))
141 .and(path(CODEX_RESPONSES_PATH))
142 .respond_with(RetryThenSuccess {
143 attempts: Arc::clone(&attempts),
144 retry_status: 503,
145 retry_body: "temporarily unavailable",
146 })
147 .mount(&server)
148 .await;
149
150 let client = {
151 let _env_lock = crate::test_support::lock_test_env();
152 let _codex_token =
153 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-token");
154 let _legacy_codex_token = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
155 DeepSeekClient::new(&test_codex_config(&server)).unwrap()
156 };
157 let mut stream = client
158 .handle_responses_stream(
159 &client
160 .prepare_outbound_request(minimal_responses_request(), true)
161 .expect("responses request prepares"),
162 )
163 .await
164 .unwrap();
165
166 tokio::time::timeout(std::time::Duration::from_secs(5), async {
167 while let Some(event) = stream.next().await {
168 event.unwrap();
169 }
170 })
171 .await
172 .expect("Responses retry stream should finish after [DONE]");
173
174 assert_eq!(attempts.load(Ordering::SeqCst), 2);
175 }
176
177 #[tokio::test]
178 async fn responses_stream_retries_upstream_499_before_streaming() {
179 let server = MockServer::start().await;
180 let attempts = Arc::new(AtomicUsize::new(0));
181 Mock::given(method("POST"))
182 .and(path(CODEX_RESPONSES_PATH))
183 .respond_with(RetryThenSuccess {
184 attempts: Arc::clone(&attempts),
185 retry_status: 499,
186 retry_body: "upstream request cancelled",
187 })
188 .mount(&server)
189 .await;
190
191 let client = {
192 let _env_lock = crate::test_support::lock_test_env();
193 let _codex_token =
194 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-token");
195 let _legacy_codex_token = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
196 DeepSeekClient::new(&test_codex_config(&server)).unwrap()
197 };
198 let mut stream = client
199 .handle_responses_stream(
200 &client
201 .prepare_outbound_request(minimal_responses_request(), true)
202 .expect("responses request prepares"),
203 )
204 .await
205 .unwrap();
206
207 tokio::time::timeout(std::time::Duration::from_secs(5), async {
208 while let Some(event) = stream.next().await {
209 event.unwrap();
210 }
211 })
212 .await
213 .expect("Responses retry stream should finish after [DONE]");
214
215 assert_eq!(attempts.load(Ordering::SeqCst), 2);
216 }
217
218 #[tokio::test]
219 async fn responses_stream_finishes_on_semantic_terminal_event_without_done_marker() {
220 let server = MockServer::start().await;
221 let sse_body = concat!(
222 "data: {\"type\":\"response.created\",\"response\":{\"status\":\"in_progress\"}}\n\n",
223 "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":3,\"output_tokens\":2}}}\n\n",
224 );
225 Mock::given(method("POST"))
226 .and(path(CODEX_RESPONSES_PATH))
227 .respond_with(
228 ResponseTemplate::new(200)
229 .insert_header("Content-Type", "text/event-stream")
230 .set_body_string(sse_body),
231 )
232 .mount(&server)
233 .await;
234
235 let client = {
236 let _env_lock = crate::test_support::lock_test_env();
237 let _codex_token =
238 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-token");
239 let _legacy_codex_token = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
240 DeepSeekClient::new(&test_codex_config(&server)).unwrap()
241 };
242 let mut stream = client
243 .handle_responses_stream(
244 &client
245 .prepare_outbound_request(minimal_responses_request(), true)
246 .expect("responses request prepares"),
247 )
248 .await
249 .expect("semantic Responses stream opens");
250
251 let mut saw_stop = false;
252 tokio::time::timeout(std::time::Duration::from_secs(5), async {
253 while let Some(event) = stream.next().await {
254 if matches!(event.unwrap(), StreamEvent::MessageStop) {
255 saw_stop = true;
256 }
257 }
258 })
259 .await
260 .expect("terminal event ends the stream without [DONE]");
261 assert!(saw_stop);
262 }
263
264 #[tokio::test]
265 async fn responses_stream_surfaces_notice_for_web_search_call_items() {
266 let server = MockServer::start().await;
267 let sse_body = concat!(
268 "data: {\"type\":\"response.created\",\"response\":{\"status\":\"in_progress\"}}\n\n",
269 "data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"web_search_call\",\"id\":\"ws_1\",\"call_id\":\"call_1\"}}\n\n",
270 "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"web_search_call\",\"id\":\"ws_1\"}}\n\n",
271 "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":3,\"output_tokens\":2}}}\n\n",
272 );
273 Mock::given(method("POST"))
274 .and(path(CODEX_RESPONSES_PATH))
275 .respond_with(
276 ResponseTemplate::new(200)
277 .insert_header("Content-Type", "text/event-stream")
278 .set_body_string(sse_body),
279 )
280 .mount(&server)
281 .await;
282
283 let client = {
284 let _env_lock = crate::test_support::lock_test_env();
285 let _codex_token =
286 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-token");
287 let _legacy_codex_token = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
288 DeepSeekClient::new(&test_codex_config(&server)).unwrap()
289 };
290 let mut stream = client
291 .handle_responses_stream(
292 &client
293 .prepare_outbound_request(minimal_responses_request(), true)
294 .expect("responses request prepares"),
295 )
296 .await
297 .expect("semantic Responses stream opens");
298
299 let mut saw_notice = false;
300 tokio::time::timeout(std::time::Duration::from_secs(5), async {
301 while let Some(event) = stream.next().await {
302 if let Ok(StreamEvent::ContentBlockStart {
303 content_block: ContentBlockStart::Text { text },
304 ..
305 }) = event
306 && text.contains("not replayed")
307 {
308 saw_notice = true;
309 }
310 }
311 })
312 .await
313 .expect("stream terminates");
314 assert!(saw_notice, "web_search_call must surface a visible notice");
315 }
316
317 #[tokio::test]
318 async fn responses_stream_fails_fast_on_non_retryable_provider_error() {
319 let server = MockServer::start().await;
320 let attempts = Arc::new(AtomicUsize::new(0));
321 Mock::given(method("POST"))
322 .and(path(CODEX_RESPONSES_PATH))
323 .respond_with(AlwaysError {
324 attempts: Arc::clone(&attempts),
325 status: 403,
326 body: "<html><title>Access Denied</title><body>Security alert. Contact support. Ray ID 1234abcd.</body></html>",
327 })
328 .mount(&server)
329 .await;
330
331 let client = {
332 let _env_lock = crate::test_support::lock_test_env();
333 let _codex_token =
334 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-token");
335 let _legacy_codex_token = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
336 DeepSeekClient::new(&test_codex_config(&server)).unwrap()
337 };
338
339 let err = match client
340 .handle_responses_stream(
341 &client
342 .prepare_outbound_request(minimal_responses_request(), true)
343 .expect("responses request prepares"),
344 )
345 .await
346 {
347 Ok(_) => panic!("non-retryable Responses errors should fail fast"),
348 Err(err) => err,
349 };
350
351 assert_eq!(attempts.load(Ordering::SeqCst), 1);
352 let message = format!("{err:#}");
353 assert!(
354 message.contains("Responses API request failed"),
355 "{message}"
356 );
357 assert!(message.contains("OpenAI Codex"), "{message}");
358 assert!(message.contains("Access Denied"), "{message}");
359 assert!(
360 message.contains("blocked before it reached the model"),
361 "{message}"
362 );
363 // #3884: the structured LlmError must stay downcastable through the
364 // context layers so sub-agent failure records can classify it.
365 assert!(
366 err.downcast_ref::<crate::llm_client::LlmError>().is_some(),
367 "LlmError should survive the anyhow chain"
368 );
369 }
370
371 #[test]
372 fn responses_body_serializes_exactly_one_load_skill_definition() {
373 // Mirror of the Anthropic contract: the real child catalog fixture
374 // maps 1:1 into Responses function tools with one load_skill entry.
375 let tools = crate::tools::subagent::kimi_general_child_request_tools_fixture();
376 let mut request = minimal_responses_request();
377 request.tools = Some(tools);
378 let body = build_responses_body(&request);
379 let serialized = body["tools"]
380 .as_array()
381 .expect("tools serialize as an array");
382 let load_skills: Vec<_> = serialized
383 .iter()
384 .filter(|tool| tool["name"] == "load_skill")
385 .collect();
386 assert_eq!(
387 load_skills.len(),
388 1,
389 "exactly one load_skill definition reaches the Responses wire"
390 );
391 assert!(
392 load_skills[0]["parameters"]["properties"].is_object(),
393 "load_skill keeps a valid parameters schema: {}",
394 load_skills[0]
395 );
396 }
397
398 #[tokio::test]
399 async fn responses_stream_open_preserves_wire_headers_through_shared_seam() {
400 use wiremock::matchers::header;
401
402 let server = MockServer::start().await;
403 // Every wire-specific header (SSE accept, Responses beta opt-in,
404 // originator, bearer auth from the default headers) must survive the
405 // shared stream-entry open path; the mock only answers when all are
406 // present.
407 Mock::given(method("POST"))
408 .and(path(CODEX_RESPONSES_PATH))
409 .and(header("Accept", "text/event-stream"))
410 .and(header("OpenAI-Beta", "responses=experimental"))
411 .and(header("originator", "codex_cli_rs"))
412 .and(header("Authorization", "Bearer test-token"))
413 .respond_with(
414 ResponseTemplate::new(200)
415 .insert_header("Content-Type", "text/event-stream")
416 .set_body_string("data: [DONE]\n\n"),
417 )
418 .expect(1)
419 .mount(&server)
420 .await;
421
422 let client = {
423 let _env_lock = crate::test_support::lock_test_env();
424 let _codex_token =
425 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-token");
426 let _legacy_codex_token = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
427 DeepSeekClient::new(&test_codex_config(&server)).unwrap()
428 };
429 let mut stream = client
430 .handle_responses_stream(
431 &client
432 .prepare_outbound_request(minimal_responses_request(), true)
433 .expect("responses request prepares"),
434 )
435 .await
436 .expect("stream opens with preserved headers");
437
438 tokio::time::timeout(std::time::Duration::from_secs(5), async {
439 while let Some(event) = stream.next().await {
440 event.unwrap();
441 }
442 })
443 .await
444 .expect("stream should finish after [DONE]");
445 }
446
447 #[tokio::test]
448 async fn responses_stream_inserts_boundary_between_reasoning_summary_parts() {
449 let server = MockServer::start().await;
450 let sse_body = concat!(
451 "data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"reasoning\",\"id\":\"rs_1\"}}\n\n",
452 "data: {\"type\":\"response.reasoning_summary_part.added\",\"item_id\":\"rs_1\",\"summary_index\":0,\"part\":{\"type\":\"summary_text\",\"text\":\"\"}}\n\n",
453 "data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"partA\"}\n\n",
454 "data: {\"type\":\"response.reasoning_summary_part.added\",\"item_id\":\"rs_1\",\"summary_index\":1,\"part\":{\"type\":\"summary_text\",\"text\":\"\"}}\n\n",
455 "data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"partB\"}\n\n",
456 "data: {\"type\":\"response.output_item.done\"}\n\n",
457 "data: [DONE]\n\n",
458 );
459 Mock::given(method("POST"))
460 .and(path(CODEX_RESPONSES_PATH))
461 .respond_with(
462 ResponseTemplate::new(200)
463 .insert_header("Content-Type", "text/event-stream")
464 .set_body_string(sse_body),
465 )
466 .mount(&server)
467 .await;
468
469 let client = {
470 let _env_lock = crate::test_support::lock_test_env();
471 let _codex_token =
472 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-token");
473 let _legacy_codex_token = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
474 DeepSeekClient::new(&test_codex_config(&server)).unwrap()
475 };
476 let mut stream = client
477 .handle_responses_stream(
478 &client
479 .prepare_outbound_request(minimal_responses_request(), true)
480 .expect("responses request prepares"),
481 )
482 .await
483 .unwrap();
484
485 let mut thinking = String::new();
486 tokio::time::timeout(std::time::Duration::from_secs(5), async {
487 while let Some(event) = stream.next().await {
488 if let StreamEvent::ContentBlockDelta {
489 delta: Delta::ThinkingDelta { thinking: chunk },
490 ..
491 } = event.unwrap()
492 {
493 thinking.push_str(&chunk);
494 }
495 }
496 })
497 .await
498 .expect("Responses reasoning stream should finish after [DONE]");
499
500 // The second summary part must be separated from the first by a
501 // paragraph break, and no separator may precede the first part.
502 assert_eq!(thinking, "partA\n\npartB");
503 }
504
505 #[test]
506 fn codex_reasoning_effort_uses_responses_labels() {
507 assert_eq!(codex_responses_reasoning_effort("max"), Some("xhigh"));
508 assert_eq!(codex_responses_reasoning_effort("maximum"), Some("xhigh"));
509 assert_eq!(codex_responses_reasoning_effort("xhigh"), Some("xhigh"));
510 assert_eq!(codex_responses_reasoning_effort("ultracode"), Some("xhigh"));
511 assert_eq!(codex_responses_reasoning_effort("high"), Some("high"));
512 assert_eq!(codex_responses_reasoning_effort("medium"), Some("medium"));
513 assert_eq!(codex_responses_reasoning_effort("minimal"), Some("low"));
514 assert_eq!(codex_responses_reasoning_effort("auto"), Some("medium"));
515 assert_eq!(codex_responses_reasoning_effort("off"), Some("low"));
516 }
517
518 #[test]
519 fn deepseek_flash_responses_body_uses_stateless_0731_contract() {
520 let mut request = minimal_responses_request();
521 request.model = "deepseek-v4-flash".to_string();
522 request.reasoning_effort = Some("xhigh".to_string());
523 request.temperature = Some(1.0);
524 request.top_p = Some(0.95);
525 request.messages.insert(
526 0,
527 Message {
528 role: "assistant".to_string(),
529 content: vec![ContentBlock::Thinking {
530 thinking: "preserve this tool-loop reasoning".to_string(),
531 signature: None,
532 }],
533 },
534 );
535
536 let body = build_responses_body_for_provider(&request, ApiProvider::Deepseek);
537
538 assert_eq!(body["model"], "deepseek-v4-flash");
539 assert_eq!(body["max_output_tokens"], 128);
540 assert_eq!(body["temperature"], 1.0);
541 assert!(
542 (body["top_p"].as_f64().expect("top_p number") - 0.95).abs() < 1e-6,
543 "{}",
544 body["top_p"]
545 );
546 assert_eq!(body.pointer("/reasoning/effort"), Some(&json!("max")));
547 assert!(body.pointer("/reasoning/summary").is_none());
548 assert!(body.get("include").is_none());
549 assert!(body.get("store").is_none());
550 assert_eq!(
551 body.pointer("/input/0/content/0/type"),
552 Some(&json!("reasoning_text"))
553 );
554 assert_eq!(
555 body.pointer("/input/0/content/0/text"),
556 Some(&json!("preserve this tool-loop reasoning"))
557 );
558 }
559
560 #[test]
561 fn deepseek_responses_reasoning_effort_uses_documented_labels() {
562 assert_eq!(responses_reasoning_effort("low", true), Some("low"));
563 assert_eq!(responses_reasoning_effort("medium", true), Some("high"));
564 assert_eq!(responses_reasoning_effort("high", true), Some("high"));
565 assert_eq!(responses_reasoning_effort("xhigh", true), Some("max"));
566 assert_eq!(responses_reasoning_effort("max", true), Some("max"));
567 // The off tier must disable thinking on the wire, not collapse into
568 // low: DeepSeek documents `reasoning.effort: "none"` as the off value.
569 assert_eq!(responses_reasoning_effort("off", true), Some("none"));
570 assert_eq!(responses_reasoning_effort("disabled", true), Some("none"));
571 assert_eq!(responses_reasoning_effort("none", true), Some("none"));
572 assert_eq!(responses_reasoning_effort("false", true), Some("none"));
573 // minimal stays a low tier for DeepSeek (undocumented label preserved
574 // for Codex compatibility).
575 assert_eq!(responses_reasoning_effort("minimal", true), Some("low"));
576 }
577
578 #[test]
579 fn codex_responses_body_uses_responses_reasoning_not_deepseek_thinking() {
580 let request = MessageRequest {
581 model: "gpt-5.5".to_string(),
582 messages: vec![Message {
583 role: "user".to_string(),
584 content: vec![ContentBlock::Text {
585 text: "hello".to_string(),
586 cache_control: None,
587 }],
588 }],
589 max_tokens: 128,
590 system: None,
591 tools: None,
592 tool_choice: None,
593 metadata: None,
594 thinking: None,
595 reasoning_effort: Some("max".to_string()),
596 stream: None,
597 temperature: None,
598 top_p: None,
599 };
600
601 let body = build_responses_body(&request);
602
603 assert_eq!(
604 body.pointer("/reasoning/effort").and_then(Value::as_str),
605 Some("xhigh")
606 );
607 assert_eq!(
608 body.pointer("/reasoning/summary").and_then(Value::as_str),
609 Some("auto")
610 );
611 assert!(body.get("thinking").is_none());
612 assert!(body.get("reasoning_effort").is_none());
613 }
614
615 #[test]
616 fn responses_failed_event_reports_nested_error() {
617 let event = json!({
618 "type": "response.failed",
619 "response": {
620 "id": "resp_123",
621 "error": {
622 "code": "rate_limit_exceeded",
623 "message": "Please retry later"
624 }
625 }
626 });
627
628 let (code, message) = responses_event_error_details(&event);
629
630 assert_eq!(code, "rate_limit_exceeded");
631 assert_eq!(message, "Please retry later");
632 }
633
634 #[test]
635 fn responses_incomplete_event_reports_reason() {
636 let event = json!({
637 "type": "response.incomplete",
638 "response": {
639 "id": "resp_123",
640 "status": "incomplete",
641 "error": null,
642 "incomplete_details": {
643 "reason": "content_filter"
644 }
645 }
646 });
647
648 let (code, message) = responses_event_error_details(&event);
649
650 assert_eq!(code, "content_filter");
651 assert_eq!(message, "response incomplete: content_filter");
652 }
653
654 #[test]
655 fn parse_responses_usage_derives_cache_miss_and_reasoning() {
656 let usage = json!({
657 "input_tokens": 1000,
658 "output_tokens": 200,
659 "input_tokens_details": { "cached_tokens": 600 },
660 "output_tokens_details": { "reasoning_tokens": 120 }
661 });
662
663 let parsed = parse_responses_usage(&usage);
664
665 assert_eq!(parsed.input_tokens, 1000);
666 assert_eq!(parsed.output_tokens, 200);
667 assert_eq!(parsed.prompt_cache_hit_tokens, Some(600));
668 // Cache-miss is derived as input minus the cached hit when cached > 0.
669 assert_eq!(parsed.prompt_cache_miss_tokens, Some(400));
670 // Reasoning surfaces from output_tokens_details (Responses dialect).
671 assert_eq!(parsed.reasoning_tokens, Some(120));
672
673 // Without cached/reasoning details, the derived fields stay None.
674 let bare = json!({ "input_tokens": 1000, "output_tokens": 200 });
675 let parsed_bare = parse_responses_usage(&bare);
676 assert_eq!(parsed_bare.prompt_cache_hit_tokens, None);
677 assert_eq!(parsed_bare.prompt_cache_miss_tokens, None);
678 assert_eq!(parsed_bare.reasoning_tokens, None);
679 }
680
681 #[test]
682 fn parse_responses_usage_saturates_u64_fields() {
683 let parsed = parse_responses_usage(&json!({
684 "input_tokens": u64::MAX,
685 "output_tokens": u64::MAX,
686 "input_tokens_details": { "cached_tokens": u64::MAX },
687 "output_tokens_details": { "reasoning_tokens": u64::MAX }
688 }));
689 assert_eq!(parsed.input_tokens, u32::MAX);
690 assert_eq!(parsed.output_tokens, u32::MAX);
691 assert_eq!(parsed.prompt_cache_hit_tokens, Some(u32::MAX));
692 assert_eq!(parsed.prompt_cache_miss_tokens, Some(0));
693 assert_eq!(parsed.reasoning_tokens, Some(u32::MAX));
694 }
695
696 #[test]
697 fn parse_responses_usage_reads_deepseek_top_level_cache_fields() {
698 // DeepSeek's Responses dialect reports cache telemetry as top-level
699 // `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens` with
700 // `cache_write_tokens` nested under `input_tokens_details` -- none of
701 // which the old parser read (it only looked at
702 // `input_tokens_details.cached_tokens`, which DeepSeek leaves unset,
703 // so every V4 Flash turn recorded cache_hit = None).
704 let usage = json!({
705 "input_tokens": 1_000,
706 "output_tokens": 200,
707 "prompt_cache_hit_tokens": 600,
708 "prompt_cache_miss_tokens": 200,
709 "input_tokens_details": { "cached_tokens": 999, "cache_write_tokens": 100 },
710 "output_tokens_details": { "reasoning_tokens": 120 }
711 });
712
713 let parsed = parse_responses_usage(&usage);
714
715 // Top-level DeepSeek fields win over the nested OpenAI-style shape,
716 // and the explicit miss is trusted over the derived fallback.
717 assert_eq!(parsed.prompt_cache_hit_tokens, Some(600));
718 assert_eq!(parsed.prompt_cache_miss_tokens, Some(200));
719 assert_eq!(parsed.prompt_cache_write_tokens, Some(100));
720 // `input_tokens` remains the provider-reported total; the pricing
721 // layer partitions it into hit / miss / write classes.
722 assert_eq!(parsed.input_tokens, 1_000);
723 assert_eq!(parsed.output_tokens, 200);
724 assert_eq!(parsed.reasoning_tokens, Some(120));
725
726 // The parsed fields must reach the pricing classes unchanged: 600 hit
727 // at the cache-read rate, 100 write at the creation rate, and the
728 // remaining 300 (200 reported miss + 100 uncategorized) at the miss
729 // rate -- instead of the pre-fix all-raw-input miss billing.
730 let classes = crate::pricing::token_usage_for_pricing(&parsed);
731 assert_eq!(classes.input, 300);
732 assert_eq!(classes.cache_read, 600);
733 assert_eq!(classes.cache_write, 100);
734 }
735
736 #[test]
737 fn parse_responses_usage_keeps_old_shape_with_cache_write_fallback() {
738 // OpenAI-style payloads still parse from `input_tokens_details` alone:
739 // hit from `cached_tokens` (fallback), miss derived as input minus
740 // hit, and the write class from `cache_write_tokens` when present.
741 let usage = json!({
742 "input_tokens": 1_000,
743 "output_tokens": 200,
744 "input_tokens_details": { "cached_tokens": 600, "cache_write_tokens": 100 }
745 });
746
747 let parsed = parse_responses_usage(&usage);
748
749 assert_eq!(parsed.input_tokens, 1_000);
750 assert_eq!(parsed.prompt_cache_hit_tokens, Some(600));
751 assert_eq!(parsed.prompt_cache_miss_tokens, Some(400));
752 assert_eq!(parsed.prompt_cache_write_tokens, Some(100));
753 assert_eq!(parsed.reasoning_tokens, None);
754 }
755
756 /// Regression fixture for the reasoning double-billing bug: a real
757 /// Responses usage payload has to survive the whole way into the pricing
758 /// conversion without reasoning tokens being charged twice. OpenAI's
759 /// `output_tokens` is already the *total* billable completion count, with
760 /// `output_tokens_details.reasoning_tokens` a subset of it.
761 #[test]
762 fn responses_usage_reaches_pricing_conversion_without_double_billing_reasoning() {
763 use crate::config::ApiProvider;
764 use crate::pricing::{calculate_turn_cost_estimate_for_provider, token_usage_for_pricing};
765
766 let usage = parse_responses_usage(&json!({
767 "input_tokens": 10_000,
768 "output_tokens": 4_000,
769 "total_tokens": 14_000,
770 "input_tokens_details": { "cached_tokens": 6_000 },
771 "output_tokens_details": { "reasoning_tokens": 3_500 }
772 }));
773
774 let classes = token_usage_for_pricing(&usage);
775 assert_eq!(classes.output, 4_000, "reasoning must not inflate output");
776 assert_eq!(classes.input, 4_000);
777 assert_eq!(classes.cache_read, 6_000);
778 assert_eq!(classes.cache_write, 0);
779
780 // gpt-5.5: 0.50 cache-read / 5.00 input / 30.00 output per million.
781 let cost = calculate_turn_cost_estimate_for_provider(ApiProvider::Openai, "gpt-5.5", &usage)
782 .expect("direct OpenAI route is priced");
783 let expected = 0.006 * 0.50 + 0.004 * 5.00 + 0.004 * 30.00;
784 assert!(
785 (cost.usd - expected).abs() < 1e-12,
786 "expected {expected}, got {}",
787 cost.usd
788 );
789
790 // The bug charged the 3_500 reasoning tokens a second time at the
791 // output rate; assert the difference explicitly so a reintroduction is
792 // unambiguous rather than a silent number change.
793 let double_billed = expected + 0.0035 * 30.00;
794 assert!((cost.usd - double_billed).abs() > 1e-6);
795 }
796
797 #[test]
798 fn responses_input_includes_user_role_tool_results() {
799 let request = MessageRequest {
800 model: "gpt-5.5".to_string(),
801 messages: vec![
802 Message {
803 role: "assistant".to_string(),
804 content: vec![ContentBlock::ToolUse {
805 id: "call_abc|fc_123".to_string(),
806 name: "checklist_write".to_string(),
807 input: json!({"items": []}),
808 caller: None,
809 }],
810 },
811 Message {
812 role: "user".to_string(),
813 content: vec![ContentBlock::ToolResult {
814 tool_use_id: "call_abc|fc_123".to_string(),
815 content: "<6 items>".to_string(),
816 is_error: None,
817 content_blocks: None,
818 }],
819 },
820 ],
821 max_tokens: 128,
822 system: None,
823 tools: None,
824 tool_choice: None,
825 metadata: None,
826 thinking: None,
827 reasoning_effort: None,
828 stream: None,
829 temperature: None,
830 top_p: None,
831 };
832
833 let input = convert_messages_to_responses_input(&request, false);
834
835 assert_eq!(input[0]["type"], "function_call");
836 assert_eq!(input[0]["call_id"], "call_abc");
837 assert_eq!(input[0]["name"], "checklist_write");
838 assert_eq!(input[1]["type"], "function_call_output");
839 assert_eq!(input[1]["call_id"], "call_abc");
840 assert_eq!(input[1]["output"], "<6 items>");
841 }
842
843 #[test]
844 fn responses_input_encodes_tool_call_names() {
845 let request = MessageRequest {
846 model: "gpt-5.5".to_string(),
847 messages: vec![Message {
848 role: "assistant".to_string(),
849 content: vec![ContentBlock::ToolUse {
850 id: "call_abc|fc_123".to_string(),
851 name: "web.run".to_string(),
852 input: json!({}),
853 caller: None,
854 }],
855 }],
856 max_tokens: 128,
857 system: None,
858 tools: None,
859 tool_choice: None,
860 metadata: None,
861 thinking: None,
862 reasoning_effort: None,
863 stream: None,
864 temperature: None,
865 top_p: None,
866 };
867
868 let input = convert_messages_to_responses_input(&request, false);
869
870 assert_eq!(input[0]["type"], "function_call");
871 assert_eq!(input[0]["name"], to_api_tool_name("web.run"));
872 }
873
874 #[test]
875 fn responses_function_tool_sanitizes_root_composition_schema() {
876 let tool = Tool {
877 tool_type: None,
878 name: "web.run".to_string(),
879 description: "Apply patch".to_string(),
880 input_schema: json!({
881 "type": "object",
882 "properties": {
883 "patch": {"type": "string"},
884 "replace": {"type": "array"},
885 "changes": {"type": "array"}
886 },
887 "oneOf": [
888 {"required": ["patch"]},
889 {"required": ["replace"]},
890 {"required": ["changes"]}
891 ]
892 }),
893 allowed_callers: None,
894 defer_loading: None,
895 input_examples: None,
896 strict: None,
897 cache_control: None,
898 };
899
900 let payload = tool_to_responses_function(&tool);
901 let parameters = &payload["parameters"];
902
903 assert_eq!(payload["name"], to_api_tool_name("web.run"));
904 assert_eq!(parameters["type"], "object");
905 assert!(parameters.get("oneOf").is_none());
906 assert!(parameters.get("anyOf").is_none());
907 assert!(parameters.get("allOf").is_none());
908 assert!(parameters.get("enum").is_none());
909 assert!(parameters.get("not").is_none());
910 assert!(parameters["properties"].get("patch").is_some());
911 assert!(parameters["properties"].get("replace").is_some());
912 assert!(parameters["properties"].get("changes").is_some());
913 assert_eq!(
914 payload["description"],
915 "Apply patch\n\nExactly one of these parameter groups must be provided: `changes` | `patch` | `replace`."
916 );
917 assert!(tool.input_schema.get("oneOf").is_some());
918 }
919
920 #[test]
921 fn responses_function_tool_trims_description_before_constraint_note() {
922 let tool = Tool {
923 tool_type: None,
924 name: "apply_patch".to_string(),
925 description: "Apply patch\n".to_string(),
926 input_schema: json!({
927 "type": "object",
928 "properties": {
929 "patch": {"type": "string"},
930 "replace": {"type": "array"},
931 "changes": {"type": "array"}
932 },
933 "oneOf": [
934 {"required": ["patch"]},
935 {"required": ["replace"]},
936 {"required": ["changes"]}
937 ]
938 }),
939 allowed_callers: None,
940 defer_loading: None,
941 input_examples: None,
942 strict: None,
943 cache_control: None,
944 };
945
946 let payload = tool_to_responses_function(&tool);
947
948 assert_eq!(
949 payload["description"],
950 "Apply patch\n\nExactly one of these parameter groups must be provided: `changes` | `patch` | `replace`."
951 );
952 }
953
954 #[test]
955 fn responses_function_tool_leaves_description_unchanged_without_constraint_note() {
956 let tool = Tool {
957 tool_type: None,
958 name: "lookup".to_string(),
959 description: "Lookup".to_string(),
960 input_schema: json!({
961 "type": "object",
962 "properties": {
963 "query": {"type": "string"}
964 }
965 }),
966 allowed_callers: None,
967 defer_loading: None,
968 input_examples: None,
969 strict: None,
970 cache_control: None,
971 };
972
973 let payload = tool_to_responses_function(&tool);
974
975 assert_eq!(payload["description"], "Lookup");
976 }
977
978 /// The Responses API projection of [`ContentBlock::ImageUrl`].
979 ///
980 /// Responses is the odd one out: the image part carries `image_url` as a bare
981 /// string rather than the nested object Chat Completions uses. Getting that
982 /// wrong produces a schema error from OpenAI rather than anything that names
983 /// the image, so it is worth pinning explicitly.
984 #[test]
985 fn user_image_becomes_an_input_image_item() {
986 const DATA_URL: &str = "data:image/png;base64,QUJD";
987
988 let mut request = minimal_responses_request();
989 request.messages[0].content.push(ContentBlock::ImageUrl {
990 image_url: crate::models::ImageUrlContent {
991 url: DATA_URL.to_string(),
992 },
993 });
994
995 let items = convert_messages_to_responses_input(&request, false);
996
997 let user = items
998 .iter()
999 .find(|item| item["role"] == "user")
1000 .expect("a user item");
1001 let content = user["content"].as_array().expect("content items");
1002
1003 let image = content
1004 .iter()
1005 .find(|part| part["type"] == "input_image")
1006 .expect("an input_image part");
1007 assert_eq!(
1008 image["image_url"], DATA_URL,
1009 "Responses takes image_url as a bare string, not a nested object: {image}"
1010 );
1011
1012 assert!(
1013 content.iter().any(|part| part["type"] == "input_text"),
1014 "the accompanying question must survive: {user}"
1015 );
1016 }
1017
1017 lines RUST