返回 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 codewhale_models::Message;
10 use codewhale_models::Role;
11 use codewhale_models::SystemPrompt;
12 use wiremock::matchers::{method, path};
13 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
14
15 #[derive(Clone)]
16 struct RetryThenSuccess {
17 attempts: Arc<AtomicUsize>,
18 retry_status: u16,
19 retry_body: &'static str,
20 }
21
22 impl Respond for RetryThenSuccess {
23 fn respond(&self, _request: &Request) -> ResponseTemplate {
24 if self.attempts.fetch_add(1, Ordering::SeqCst) == 0 {
25 let mut response =
26 ResponseTemplate::new(self.retry_status).set_body_string(self.retry_body);
27 if self.retry_status == 429 {
28 response = response.insert_header("Retry-After", "0");
29 }
30 return response;
31 }
32
33 ResponseTemplate::new(200)
34 .insert_header("Content-Type", "text/event-stream")
35 .set_body_string("data: [DONE]\n\n")
36 }
37 }
38
39 #[derive(Clone)]
40 struct AlwaysError {
41 attempts: Arc<AtomicUsize>,
42 status: u16,
43 body: &'static str,
44 }
45
46 impl Respond for AlwaysError {
47 fn respond(&self, _request: &Request) -> ResponseTemplate {
48 self.attempts.fetch_add(1, Ordering::SeqCst);
49 ResponseTemplate::new(self.status).set_body_string(self.body)
50 }
51 }
52
53 fn minimal_responses_request() -> MessageRequest {
54 MessageRequest {
55 model: "gpt-5.5".to_string(),
56 messages: vec![Message {
57 role: Role::User,
58 content: vec![ContentBlock::Text {
59 text: "hello".to_string(),
60 cache_control: None,
61 }],
62 }],
63 max_tokens: 128,
64 system: None,
65 tools: None,
66 tool_choice: None,
67 metadata: None,
68 thinking: None,
69 reasoning_effort: None,
70 stream: None,
71 temperature: None,
72 top_p: None,
73 }
74 }
75
76 fn test_codex_config(server: &MockServer) -> Config {
77 Config {
78 provider: Some("openai-codex".to_string()),
79 retry: Some(RetryConfig {
80 enabled: Some(true),
81 max_retries: Some(1),
82 initial_delay: Some(0.0),
83 max_delay: Some(0.0),
84 exponential_base: Some(1.0),
85 }),
86 providers: Some(ProvidersConfig {
87 openai_codex: ProviderConfig {
88 base_url: Some(server.uri()),
89 ..ProviderConfig::default()
90 },
91 ..ProvidersConfig::default()
92 }),
93 ..Config::default()
94 }
95 }
96
97 #[tokio::test]
98 async fn responses_stream_retries_rate_limited_request() {
99 let server = MockServer::start().await;
100 let attempts = Arc::new(AtomicUsize::new(0));
101 Mock::given(method("POST"))
102 .and(path(CODEX_RESPONSES_PATH))
103 .respond_with(RetryThenSuccess {
104 attempts: Arc::clone(&attempts),
105 retry_status: 429,
106 retry_body: "rate limited",
107 })
108 .mount(&server)
109 .await;
110
111 let client = {
112 let _env_lock = crate::test_support::lock_test_env();
113 let _codex_token =
114 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-token");
115 let _legacy_codex_token = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
116 CodewhaleClient::new(&test_codex_config(&server)).unwrap()
117 };
118 let mut request = minimal_responses_request();
119 request.max_tokens = 384_000;
120 let prepared = client
121 .prepare_outbound_request(request, true)
122 .expect("responses request prepares");
123 // The Codex OAuth Responses endpoint rejects `max_output_tokens`
124 // ("Unsupported parameter"), so the prepared body must omit it even
125 // though the resolved request envelope carries a cap.
126 assert!(prepared.body.get("max_output_tokens").is_none());
127 let mut stream = client.handle_responses_stream(&prepared).await.unwrap();
128
129 tokio::time::timeout(std::time::Duration::from_secs(5), async {
130 while let Some(event) = stream.next().await {
131 event.unwrap();
132 }
133 })
134 .await
135 .expect("Responses retry stream should finish after [DONE]");
136
137 assert_eq!(attempts.load(Ordering::SeqCst), 2);
138 let requests = server
139 .received_requests()
140 .await
141 .expect("recorded retry requests");
142 assert_eq!(requests.len(), 2);
143 for request in requests {
144 let body: Value = serde_json::from_slice(&request.body).expect("Responses JSON");
145 assert!(
146 body.get("max_output_tokens").is_none(),
147 "Codex Responses body must not name the unsupported output cap: {body}"
148 );
149 }
150 }
151
152 #[tokio::test]
153 async fn responses_stream_retries_transient_server_error() {
154 let server = MockServer::start().await;
155 let attempts = Arc::new(AtomicUsize::new(0));
156 Mock::given(method("POST"))
157 .and(path(CODEX_RESPONSES_PATH))
158 .respond_with(RetryThenSuccess {
159 attempts: Arc::clone(&attempts),
160 retry_status: 503,
161 retry_body: "temporarily unavailable",
162 })
163 .mount(&server)
164 .await;
165
166 let client = {
167 let _env_lock = crate::test_support::lock_test_env();
168 let _codex_token =
169 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-token");
170 let _legacy_codex_token = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
171 CodewhaleClient::new(&test_codex_config(&server)).unwrap()
172 };
173 let mut stream = client
174 .handle_responses_stream(
175 &client
176 .prepare_outbound_request(minimal_responses_request(), true)
177 .expect("responses request prepares"),
178 )
179 .await
180 .unwrap();
181
182 tokio::time::timeout(std::time::Duration::from_secs(5), async {
183 while let Some(event) = stream.next().await {
184 event.unwrap();
185 }
186 })
187 .await
188 .expect("Responses retry stream should finish after [DONE]");
189
190 assert_eq!(attempts.load(Ordering::SeqCst), 2);
191 }
192
193 #[tokio::test]
194 async fn responses_stream_retries_upstream_499_before_streaming() {
195 let server = MockServer::start().await;
196 let attempts = Arc::new(AtomicUsize::new(0));
197 Mock::given(method("POST"))
198 .and(path(CODEX_RESPONSES_PATH))
199 .respond_with(RetryThenSuccess {
200 attempts: Arc::clone(&attempts),
201 retry_status: 499,
202 retry_body: "upstream request cancelled",
203 })
204 .mount(&server)
205 .await;
206
207 let client = {
208 let _env_lock = crate::test_support::lock_test_env();
209 let _codex_token =
210 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-token");
211 let _legacy_codex_token = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
212 CodewhaleClient::new(&test_codex_config(&server)).unwrap()
213 };
214 let mut stream = client
215 .handle_responses_stream(
216 &client
217 .prepare_outbound_request(minimal_responses_request(), true)
218 .expect("responses request prepares"),
219 )
220 .await
221 .unwrap();
222
223 tokio::time::timeout(std::time::Duration::from_secs(5), async {
224 while let Some(event) = stream.next().await {
225 event.unwrap();
226 }
227 })
228 .await
229 .expect("Responses retry stream should finish after [DONE]");
230
231 assert_eq!(attempts.load(Ordering::SeqCst), 2);
232 }
233
234 #[tokio::test]
235 async fn responses_stream_finishes_on_semantic_terminal_event_without_done_marker() {
236 let server = MockServer::start().await;
237 let sse_body = concat!(
238 "data: {\"type\":\"response.created\",\"response\":{\"status\":\"in_progress\"}}\n\n",
239 "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":3,\"output_tokens\":2}}}\n\n",
240 );
241 Mock::given(method("POST"))
242 .and(path(CODEX_RESPONSES_PATH))
243 .respond_with(
244 ResponseTemplate::new(200)
245 .insert_header("Content-Type", "text/event-stream")
246 .set_body_string(sse_body),
247 )
248 .mount(&server)
249 .await;
250
251 let client = {
252 let _env_lock = crate::test_support::lock_test_env();
253 let _codex_token =
254 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-token");
255 let _legacy_codex_token = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
256 CodewhaleClient::new(&test_codex_config(&server)).unwrap()
257 };
258 let mut stream = client
259 .handle_responses_stream(
260 &client
261 .prepare_outbound_request(minimal_responses_request(), true)
262 .expect("responses request prepares"),
263 )
264 .await
265 .expect("semantic Responses stream opens");
266
267 let mut saw_stop = false;
268 tokio::time::timeout(std::time::Duration::from_secs(5), async {
269 while let Some(event) = stream.next().await {
270 if matches!(event.unwrap(), StreamEvent::MessageStop) {
271 saw_stop = true;
272 }
273 }
274 })
275 .await
276 .expect("terminal event ends the stream without [DONE]");
277 assert!(saw_stop);
278 }
279
280 #[tokio::test]
281 async fn responses_stream_surfaces_notice_for_web_search_call_items() {
282 let server = MockServer::start().await;
283 let sse_body = concat!(
284 "data: {\"type\":\"response.created\",\"response\":{\"status\":\"in_progress\"}}\n\n",
285 "data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"web_search_call\",\"id\":\"ws_1\",\"call_id\":\"call_1\"}}\n\n",
286 "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"web_search_call\",\"id\":\"ws_1\"}}\n\n",
287 "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":3,\"output_tokens\":2}}}\n\n",
288 );
289 Mock::given(method("POST"))
290 .and(path(CODEX_RESPONSES_PATH))
291 .respond_with(
292 ResponseTemplate::new(200)
293 .insert_header("Content-Type", "text/event-stream")
294 .set_body_string(sse_body),
295 )
296 .mount(&server)
297 .await;
298
299 let client = {
300 let _env_lock = crate::test_support::lock_test_env();
301 let _codex_token =
302 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-token");
303 let _legacy_codex_token = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
304 CodewhaleClient::new(&test_codex_config(&server)).unwrap()
305 };
306 let mut stream = client
307 .handle_responses_stream(
308 &client
309 .prepare_outbound_request(minimal_responses_request(), true)
310 .expect("responses request prepares"),
311 )
312 .await
313 .expect("semantic Responses stream opens");
314
315 let mut saw_notice = false;
316 tokio::time::timeout(std::time::Duration::from_secs(5), async {
317 while let Some(event) = stream.next().await {
318 if let Ok(StreamEvent::ContentBlockStart {
319 content_block: ContentBlockStart::Text { text },
320 ..
321 }) = event
322 && text.contains("not replayed")
323 {
324 saw_notice = true;
325 }
326 }
327 })
328 .await
329 .expect("stream terminates");
330 assert!(saw_notice, "web_search_call must surface a visible notice");
331 }
332
333 #[tokio::test]
334 async fn responses_stream_fails_fast_on_non_retryable_provider_error() {
335 let server = MockServer::start().await;
336 let attempts = Arc::new(AtomicUsize::new(0));
337 Mock::given(method("POST"))
338 .and(path(CODEX_RESPONSES_PATH))
339 .respond_with(AlwaysError {
340 attempts: Arc::clone(&attempts),
341 status: 403,
342 body: "<html><title>Access Denied</title><body>Security alert. Contact support. Ray ID 1234abcd.</body></html>",
343 })
344 .mount(&server)
345 .await;
346
347 let client = {
348 let _env_lock = crate::test_support::lock_test_env();
349 let _codex_token =
350 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-token");
351 let _legacy_codex_token = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
352 CodewhaleClient::new(&test_codex_config(&server)).unwrap()
353 };
354
355 let err = match client
356 .handle_responses_stream(
357 &client
358 .prepare_outbound_request(minimal_responses_request(), true)
359 .expect("responses request prepares"),
360 )
361 .await
362 {
363 Ok(_) => panic!("non-retryable Responses errors should fail fast"),
364 Err(err) => err,
365 };
366
367 assert_eq!(attempts.load(Ordering::SeqCst), 1);
368 let message = format!("{err:#}");
369 assert!(
370 message.contains("Responses API request failed"),
371 "{message}"
372 );
373 assert!(message.contains("OpenAI Codex"), "{message}");
374 assert!(message.contains("Access Denied"), "{message}");
375 assert!(
376 message.contains("blocked before it reached the model"),
377 "{message}"
378 );
379 // #3884: the structured LlmError must stay downcastable through the
380 // context layers so sub-agent failure records can classify it.
381 assert!(
382 err.downcast_ref::<crate::llm_client::LlmError>().is_some(),
383 "LlmError should survive the anyhow chain"
384 );
385 }
386
387 #[test]
388 fn responses_body_serializes_the_child_catalog_without_duplication() {
389 // Mirror of the Anthropic contract: the real child catalog fixture
390 // maps 1:1 into Responses function tools with one canonical `read` entry.
391 // Skills are discoverable through tool_search, so the child wire catalog
392 // carries no load_skill at all.
393 let tools = crate::tools::subagent::kimi_general_child_request_tools_fixture();
394 let mut request = minimal_responses_request();
395 request.tools = Some(tools);
396 let body = build_responses_body(&request);
397 let serialized = body["tools"]
398 .as_array()
399 .expect("tools serialize as an array");
400 let reads: Vec<_> = serialized
401 .iter()
402 .filter(|tool| tool["name"] == "read")
403 .collect();
404 assert_eq!(
405 reads.len(),
406 1,
407 "exactly one canonical read definition reaches the Responses wire"
408 );
409 assert!(
410 reads[0]["parameters"]["properties"].is_object(),
411 "read keeps a valid parameters schema: {}",
412 reads[0]
413 );
414 assert!(
415 serialized.iter().all(|tool| tool["name"] != "load_skill"),
416 "load_skill must not appear on the child Responses wire"
417 );
418 }
419
420 #[tokio::test]
421 async fn responses_stream_open_preserves_wire_headers_through_shared_seam() {
422 use wiremock::matchers::header;
423
424 let server = MockServer::start().await;
425 // Every wire-specific header (SSE accept, Responses beta opt-in,
426 // originator, bearer auth from the default headers) must survive the
427 // shared stream-entry open path; the mock only answers when all are
428 // present.
429 Mock::given(method("POST"))
430 .and(path(CODEX_RESPONSES_PATH))
431 .and(header("Accept", "text/event-stream"))
432 .and(header("OpenAI-Beta", "responses=experimental"))
433 .and(header("originator", "codex_cli_rs"))
434 .and(header("Authorization", "Bearer test-token"))
435 .respond_with(
436 ResponseTemplate::new(200)
437 .insert_header("Content-Type", "text/event-stream")
438 .set_body_string("data: [DONE]\n\n"),
439 )
440 .expect(1)
441 .mount(&server)
442 .await;
443
444 let client = {
445 let _env_lock = crate::test_support::lock_test_env();
446 let _codex_token =
447 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-token");
448 let _legacy_codex_token = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
449 CodewhaleClient::new(&test_codex_config(&server)).unwrap()
450 };
451 let mut stream = client
452 .handle_responses_stream(
453 &client
454 .prepare_outbound_request(minimal_responses_request(), true)
455 .expect("responses request prepares"),
456 )
457 .await
458 .expect("stream opens with preserved headers");
459
460 tokio::time::timeout(std::time::Duration::from_secs(5), async {
461 while let Some(event) = stream.next().await {
462 event.unwrap();
463 }
464 })
465 .await
466 .expect("stream should finish after [DONE]");
467 }
468
469 #[tokio::test]
470 async fn responses_stream_inserts_boundary_between_reasoning_summary_parts() {
471 let server = MockServer::start().await;
472 let sse_body = concat!(
473 "data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"reasoning\",\"id\":\"rs_1\"}}\n\n",
474 "data: {\"type\":\"response.reasoning_summary_part.added\",\"item_id\":\"rs_1\",\"summary_index\":0,\"part\":{\"type\":\"summary_text\",\"text\":\"\"}}\n\n",
475 "data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"partA\"}\n\n",
476 "data: {\"type\":\"response.reasoning_summary_part.added\",\"item_id\":\"rs_1\",\"summary_index\":1,\"part\":{\"type\":\"summary_text\",\"text\":\"\"}}\n\n",
477 "data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"partB\"}\n\n",
478 "data: {\"type\":\"response.output_item.done\"}\n\n",
479 "data: [DONE]\n\n",
480 );
481 Mock::given(method("POST"))
482 .and(path(CODEX_RESPONSES_PATH))
483 .respond_with(
484 ResponseTemplate::new(200)
485 .insert_header("Content-Type", "text/event-stream")
486 .set_body_string(sse_body),
487 )
488 .mount(&server)
489 .await;
490
491 let client = {
492 let _env_lock = crate::test_support::lock_test_env();
493 let _codex_token =
494 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-token");
495 let _legacy_codex_token = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
496 CodewhaleClient::new(&test_codex_config(&server)).unwrap()
497 };
498 let mut stream = client
499 .handle_responses_stream(
500 &client
501 .prepare_outbound_request(minimal_responses_request(), true)
502 .expect("responses request prepares"),
503 )
504 .await
505 .unwrap();
506
507 let mut thinking = String::new();
508 tokio::time::timeout(std::time::Duration::from_secs(5), async {
509 while let Some(event) = stream.next().await {
510 if let StreamEvent::ContentBlockDelta {
511 delta: Delta::ThinkingDelta { thinking: chunk },
512 ..
513 } = event.unwrap()
514 {
515 thinking.push_str(&chunk);
516 }
517 }
518 })
519 .await
520 .expect("Responses reasoning stream should finish after [DONE]");
521
522 // The second summary part must be separated from the first by a
523 // paragraph break, and no separator may precede the first part.
524 assert_eq!(thinking, "partA\n\npartB");
525 }
526
527 #[test]
528 fn codex_reasoning_effort_uses_responses_labels() {
529 assert_eq!(codex_responses_reasoning_effort("max"), Some("max"));
530 assert_eq!(codex_responses_reasoning_effort("maximum"), Some("max"));
531 assert_eq!(codex_responses_reasoning_effort("xhigh"), Some("xhigh"));
532 assert_eq!(codex_responses_reasoning_effort("ultra"), Some("ultra"));
533 assert_eq!(codex_responses_reasoning_effort("ultracode"), Some("ultra"));
534 assert_eq!(codex_responses_reasoning_effort("high"), Some("high"));
535 assert_eq!(codex_responses_reasoning_effort("medium"), Some("medium"));
536 assert_eq!(codex_responses_reasoning_effort("minimal"), Some("low"));
537 assert_eq!(codex_responses_reasoning_effort("auto"), Some("medium"));
538 assert_eq!(codex_responses_reasoning_effort("off"), Some("low"));
539 }
540
541 #[tokio::test]
542 async fn codex_selected_effort_reaches_preview_wire_and_restored_receipt_unchanged() {
543 use crate::reasoning_preference::{EffectiveReasoningEffort, ReasoningEffort};
544 use crate::work_graph::WorkActivityEvent;
545
546 let server = MockServer::start().await;
547 Mock::given(method("POST"))
548 .and(path(CODEX_RESPONSES_PATH))
549 .respond_with(
550 ResponseTemplate::new(200)
551 .insert_header("Content-Type", "text/event-stream")
552 .set_body_string("data: [DONE]\n\n"),
553 )
554 .expect(6)
555 .mount(&server)
556 .await;
557 let client = {
558 let _lock = crate::test_support::lock_test_env();
559 let _token =
560 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-token");
561 let _legacy = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
562 CodewhaleClient::new(&test_codex_config(&server)).unwrap()
563 };
564 let receipts = tempfile::tempdir().unwrap();
565 for effort in ["low", "medium", "high", "xhigh", "max", "ultra"] {
566 let selected = ReasoningEffort::parse_strict(effort).unwrap();
567 let activity = WorkActivityEvent::ReasoningEffortChanged {
568 requested: selected.into(),
569 effective: selected.into(),
570 provider_kind: Some(ApiProvider::OpenaiCodex),
571 provider: "openai-codex".to_string(),
572 endpoint_identity: Some(crate::config::DEFAULT_OPENAI_CODEX_BASE_URL.to_string()),
573 model: Some("gpt-6-astra".to_string()),
574 ts: 1,
575 operation: None,
576 };
577 let persisted = serde_json::to_value(activity).unwrap();
578 assert_eq!(persisted["requested"], effort);
579 assert_eq!(persisted["effective"], effort);
580 let receipt_path = receipts.path().join(format!("{effort}.json"));
581 std::fs::write(&receipt_path, serde_json::to_vec(&persisted).unwrap()).unwrap();
582 let WorkActivityEvent::ReasoningEffortChanged { effective, .. } =
583 serde_json::from_slice(&std::fs::read(receipt_path).unwrap()).unwrap();
584 let restored = EffectiveReasoningEffort::from(effective)
585 .request_tier_for_replay()
586 .unwrap();
587 assert_eq!(restored, selected);
588 let mut request = minimal_responses_request();
589 request.model = "gpt-6-astra".to_string();
590 request.reasoning_effort = restored
591 .api_value_for_provider(ApiProvider::OpenaiCodex)
592 .map(str::to_string);
593 let prepared = client.prepare_outbound_request(request, true).unwrap();
594 assert_eq!(
595 prepared.reasoning.wire_effort(),
596 Some(("reasoning.effort", effort))
597 );
598 assert_eq!(prepared.body["reasoning"]["effort"], effort);
599 let mut stream = client.handle_responses_stream(&prepared).await.unwrap();
600 while let Some(event) = stream.next().await {
601 event.unwrap();
602 }
603 }
604 let requests = server.received_requests().await.unwrap();
605 assert_eq!(requests.len(), 6);
606 for (request, effort) in requests
607 .iter()
608 .zip(["low", "medium", "high", "xhigh", "max", "ultra"])
609 {
610 let body: Value = serde_json::from_slice(&request.body).unwrap();
611 assert_eq!(body["model"], "gpt-6-astra");
612 assert_eq!(body["reasoning"]["effort"], effort);
613 }
614 }
615
616 #[test]
617 fn codex_tiers_do_not_change_other_responses_provider_dialects() {
618 let mut request = minimal_responses_request();
619 for effort in ["max", "ultra"] {
620 request.reasoning_effort = Some(effort.to_string());
621 assert_eq!(
622 build_responses_body_for_provider(&request, ApiProvider::Concentrate)["reasoning"]["effort"],
623 "xhigh"
624 );
625 assert_eq!(
626 build_responses_body_for_provider(&request, ApiProvider::Deepseek)["reasoning"]["effort"],
627 "max"
628 );
629 }
630 }
631
632 /// Concentrate's parameter reference documents `model`, `input`, `stream`,
633 /// `max_output_tokens`, `tools`/`tool_choice`/`parallel_tool_calls`, and
634 /// `reasoning.effort`; `store`, `include`, `instructions`, and
635 /// `reasoning.summary` are absent. The body sends only documented fields and
636 /// carries the system prompt as a leading `system` input item.
637 /// https://concentrate.ai/docs/api-reference/endpoint/request-parameters
638 #[test]
639 fn concentrate_responses_body_sends_only_documented_fields() {
640 let mut request = minimal_responses_request();
641 request.model = "openai/gpt-5.6-sol".to_string();
642 request.system = Some(SystemPrompt::Text(
643 "You are the Codewhale test system prompt.".to_string(),
644 ));
645 request.reasoning_effort = Some("high".to_string());
646 request.tools = Some(vec![Tool {
647 tool_type: None,
648 name: "read".to_string(),
649 description: "Read a file".to_string(),
650 input_schema: serde_json::json!({
651 "type": "object",
652 "properties": { "path": { "type": "string" } },
653 "required": ["path"]
654 }),
655 allowed_callers: None,
656 defer_loading: None,
657 input_examples: None,
658 strict: None,
659 cache_control: None,
660 }]);
661
662 let body = build_responses_body_for_provider(&request, ApiProvider::Concentrate);
663 let documented = [
664 "model",
665 "input",
666 "max_output_tokens",
667 "temperature",
668 "top_p",
669 "stream",
670 "text",
671 "reasoning",
672 "tools",
673 "tool_choice",
674 "parallel_tool_calls",
675 "routing",
676 "cache_control",
677 "prompt_cache_options",
678 ];
679 for key in body.as_object().expect("object body").keys() {
680 assert!(
681 documented.contains(&key.as_str()),
682 "undocumented top-level field `{key}` reached the Concentrate wire: {body}"
683 );
684 }
685 assert_eq!(
686 body["model"], "openai/gpt-5.6-sol",
687 "provider/model ids pass through verbatim"
688 );
689 assert_eq!(body["stream"], true);
690 assert!(body.get("store").is_none(), "{body}");
691 assert!(body.get("include").is_none(), "{body}");
692 assert!(body.get("instructions").is_none(), "{body}");
693 let input = body["input"].as_array().expect("input array");
694 assert_eq!(input[0]["type"], "message");
695 assert_eq!(input[0]["role"], "system");
696 assert_eq!(input[0]["content"][0]["type"], "input_text");
697 assert_eq!(
698 input[0]["content"][0]["text"],
699 "You are the Codewhale test system prompt."
700 );
701 assert_eq!(input[1]["role"], "user");
702 assert_eq!(body["reasoning"], serde_json::json!({ "effort": "high" }));
703 assert_eq!(body["tools"][0]["type"], "function");
704 assert_eq!(body["tools"][0]["name"], "read");
705 assert_eq!(body["tools"][0]["strict"], false);
706 assert_eq!(body["tool_choice"], "auto");
707 assert_eq!(body["parallel_tool_calls"], true);
708
709 // The same request on the generic Responses path still carries the
710 // OpenAI-only fields, so the Concentrate branch is a deliberate subset.
711 let generic = build_responses_body_for_provider(&request, ApiProvider::Openai);
712 assert!(
713 generic.get("store").is_some()
714 && generic.get("include").is_some()
715 && generic.get("instructions").is_some()
716 );
717 }
718
719 #[test]
720 fn deepseek_flash_responses_body_uses_stateless_0731_contract() {
721 let mut request = minimal_responses_request();
722 request.model = "deepseek-v4-flash".to_string();
723 request.reasoning_effort = Some("xhigh".to_string());
724 request.temperature = Some(1.0);
725 request.top_p = Some(0.95);
726 request.messages.insert(
727 0,
728 Message {
729 role: Role::Assistant,
730 content: vec![ContentBlock::Thinking {
731 thinking: "preserve this tool-loop reasoning".to_string(),
732 signature: None,
733 state: None,
734 }],
735 },
736 );
737
738 let body = build_responses_body_for_provider(&request, ApiProvider::Deepseek);
739
740 assert_eq!(body["model"], "deepseek-v4-flash");
741 assert_eq!(body["max_output_tokens"], 128);
742 assert_eq!(body["temperature"], 1.0);
743 assert!(
744 (body["top_p"].as_f64().expect("top_p number") - 0.95).abs() < 1e-6,
745 "{}",
746 body["top_p"]
747 );
748 assert_eq!(body.pointer("/reasoning/effort"), Some(&json!("high")));
749 assert!(body.pointer("/reasoning/summary").is_none());
750 assert!(body.get("include").is_none());
751 assert!(body.get("store").is_none());
752 assert_eq!(
753 body.pointer("/input/0/content/0/type"),
754 Some(&json!("reasoning_text"))
755 );
756 assert_eq!(
757 body.pointer("/input/0/content/0/text"),
758 Some(&json!("preserve this tool-loop reasoning"))
759 );
760 }
761
762 #[test]
763 fn codex_responses_body_omits_the_output_cap_the_backend_rejects() {
764 // The Codex OAuth Responses endpoint answers `max_output_tokens` with
765 // "Unsupported parameter: max_output_tokens", which killed every
766 // gpt-5.6-sol sub-agent turn. The omission must be route-specific:
767 // other Responses providers keep the central cap on the wire.
768 let mut request = minimal_responses_request();
769 request.max_tokens = 4_096;
770
771 let codex = build_responses_body_for_provider(&request, ApiProvider::OpenaiCodex);
772 assert!(
773 codex.get("max_output_tokens").is_none(),
774 "Codex Responses body names a parameter its backend rejects: {codex}"
775 );
776 assert!(
777 codex.get("max_tokens").is_none() && codex.get("max_completion_tokens").is_none(),
778 "no alternate output-cap spelling may sneak onto the Codex wire: {codex}"
779 );
780
781 let deepseek = build_responses_body_for_provider(&request, ApiProvider::Deepseek);
782 assert_eq!(deepseek["max_output_tokens"], json!(4_096));
783 }
784
785 #[test]
786 fn codex_replays_only_exact_model_opaque_reasoning_state() {
787 const SENTINEL: &str = "readable private reasoning must not be replayed";
788 let state = OpaqueReasoningState {
789 provider: ApiProvider::OpenaiCodex.as_str().to_string(),
790 api: "openai-responses".to_string(),
791 model: "gpt-5.5".to_string(),
792 id: Some("rs_opaque".to_string()),
793 encrypted_content: "enc_opaque_payload".to_string(),
794 };
795 let mut request = minimal_responses_request();
796 request.messages.insert(
797 0,
798 Message {
799 role: Role::Assistant,
800 content: vec![ContentBlock::Thinking {
801 thinking: SENTINEL.to_string(),
802 signature: None,
803 state: Some(state),
804 }],
805 },
806 );
807
808 let exact = build_responses_body_for_provider(&request, ApiProvider::OpenaiCodex);
809 let exact_wire = exact.to_string();
810 assert!(!exact_wire.contains(SENTINEL), "{exact}");
811 assert_eq!(exact.pointer("/input/0/type"), Some(&json!("reasoning")));
812 assert_eq!(exact.pointer("/input/0/id"), Some(&json!("rs_opaque")));
813 assert_eq!(exact.pointer("/input/0/summary"), Some(&json!([])));
814 assert_eq!(
815 exact.pointer("/input/0/encrypted_content"),
816 Some(&json!("enc_opaque_payload"))
817 );
818
819 request.model = "gpt-5.6".to_string();
820 let switched_model = build_responses_body_for_provider(&request, ApiProvider::OpenaiCodex);
821 assert!(!switched_model.to_string().contains(SENTINEL));
822 assert!(
823 switched_model
824 .get("input")
825 .and_then(Value::as_array)
826 .is_some_and(|items| items.iter().all(|item| item["type"] != "reasoning")),
827 "{switched_model}"
828 );
829
830 let switched_provider = build_responses_body_for_provider(&request, ApiProvider::Deepseek);
831 let switched_wire = switched_provider.to_string();
832 assert!(!switched_wire.contains(SENTINEL), "{switched_provider}");
833 assert!(
834 !switched_wire.contains("enc_opaque_payload"),
835 "{switched_provider}"
836 );
837 }
838
839 #[tokio::test]
840 async fn codex_stream_captures_encrypted_reasoning_as_opaque_state() {
841 let server = MockServer::start().await;
842 let sse_body = concat!(
843 "data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"reasoning\",\"id\":\"rs_1\"}}\n\n",
844 "data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"visible summary\"}\n\n",
845 "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"reasoning\",\"id\":\"rs_1\",\"summary\":[],\"encrypted_content\":\"enc_state\"}}\n\n",
846 "data: [DONE]\n\n",
847 );
848 Mock::given(method("POST"))
849 .and(path(CODEX_RESPONSES_PATH))
850 .respond_with(
851 ResponseTemplate::new(200)
852 .insert_header("Content-Type", "text/event-stream")
853 .set_body_string(sse_body),
854 )
855 .mount(&server)
856 .await;
857
858 let client = {
859 let _env_lock = crate::test_support::lock_test_env();
860 let _codex_token =
861 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-token");
862 let _legacy_codex_token = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
863 CodewhaleClient::new(&test_codex_config(&server)).unwrap()
864 };
865 let mut stream = client
866 .handle_responses_stream(
867 &client
868 .prepare_outbound_request(minimal_responses_request(), true)
869 .expect("responses request prepares"),
870 )
871 .await
872 .unwrap();
873 let mut captured = None;
874 while let Some(event) = stream.next().await {
875 if let StreamEvent::ContentBlockDelta {
876 delta: Delta::ReasoningStateDelta { state },
877 ..
878 } = event.unwrap()
879 {
880 captured = Some(state);
881 }
882 }
883
884 let state = captured.expect("encrypted reasoning state delta");
885 assert_eq!(state.provider, ApiProvider::OpenaiCodex.as_str());
886 assert_eq!(state.api, "openai-responses");
887 assert_eq!(state.model, "gpt-5.5");
888 assert_eq!(state.id.as_deref(), Some("rs_1"));
889 assert_eq!(state.encrypted_content, "enc_state");
890 }
891
892 #[test]
893 fn deepseek_responses_reasoning_effort_uses_documented_labels() {
894 assert_eq!(responses_reasoning_effort("low", true), Some("low"));
895 assert_eq!(responses_reasoning_effort("medium", true), Some("high"));
896 assert_eq!(responses_reasoning_effort("high", true), Some("high"));
897 assert_eq!(responses_reasoning_effort("xhigh", true), Some("high"));
898 assert_eq!(responses_reasoning_effort("max", true), Some("max"));
899 // The off tier must disable thinking on the wire, not collapse into
900 // low: DeepSeek documents `reasoning.effort: "none"` as the off value.
901 assert_eq!(responses_reasoning_effort("off", true), Some("none"));
902 assert_eq!(responses_reasoning_effort("disabled", true), Some("none"));
903 assert_eq!(responses_reasoning_effort("none", true), Some("none"));
904 assert_eq!(responses_reasoning_effort("false", true), Some("none"));
905 // minimal stays a low tier for DeepSeek (undocumented label preserved
906 // for Codex compatibility).
907 assert_eq!(responses_reasoning_effort("minimal", true), Some("low"));
908 }
909
910 #[test]
911 fn codex_responses_body_uses_responses_reasoning_not_deepseek_thinking() {
912 let request = MessageRequest {
913 model: "gpt-6-astra".to_string(),
914 messages: vec![Message {
915 role: Role::User,
916 content: vec![ContentBlock::Text {
917 text: "hello".to_string(),
918 cache_control: None,
919 }],
920 }],
921 max_tokens: 128,
922 system: None,
923 tools: None,
924 tool_choice: None,
925 metadata: None,
926 thinking: None,
927 reasoning_effort: Some("max".to_string()),
928 stream: None,
929 temperature: None,
930 top_p: None,
931 };
932
933 let body = build_responses_body(&request);
934
935 assert_eq!(
936 body.pointer("/reasoning/effort").and_then(Value::as_str),
937 Some("max")
938 );
939 assert_eq!(
940 body.pointer("/reasoning/summary").and_then(Value::as_str),
941 Some("auto")
942 );
943 assert!(body.get("thinking").is_none());
944 assert!(body.get("reasoning_effort").is_none());
945 }
946
947 #[test]
948 fn responses_failed_event_reports_nested_error() {
949 let event = json!({
950 "type": "response.failed",
951 "response": {
952 "id": "resp_123",
953 "error": {
954 "code": "rate_limit_exceeded",
955 "message": "Please retry later"
956 }
957 }
958 });
959
960 let (code, message) = responses_event_error_details(&event);
961
962 assert_eq!(code, "rate_limit_exceeded");
963 assert_eq!(message, "Please retry later");
964 }
965
966 #[test]
967 fn responses_incomplete_event_reports_reason() {
968 let event = json!({
969 "type": "response.incomplete",
970 "response": {
971 "id": "resp_123",
972 "status": "incomplete",
973 "error": null,
974 "incomplete_details": {
975 "reason": "content_filter"
976 }
977 }
978 });
979
980 let (code, message) = responses_event_error_details(&event);
981
982 assert_eq!(code, "content_filter");
983 assert_eq!(message, "response incomplete: content_filter");
984 }
985
986 #[test]
987 fn responses_incomplete_stop_reason_preserves_provider_reason() {
988 assert_eq!(
989 responses_stop_reason(
990 &json!({
991 "status": "incomplete",
992 "incomplete_details": { "reason": "max_output_tokens" }
993 }),
994 false,
995 ),
996 "incomplete:max_output_tokens"
997 );
998 assert_eq!(
999 responses_stop_reason(&json!({"status": "incomplete"}), false),
1000 "incomplete:max_tokens"
1001 );
1002 }
1003
1004 #[test]
1005 fn parse_responses_usage_derives_cache_miss_and_reasoning() {
1006 let usage = json!({
1007 "input_tokens": 1000,
1008 "output_tokens": 200,
1009 "input_tokens_details": { "cached_tokens": 600 },
1010 "output_tokens_details": { "reasoning_tokens": 120 }
1011 });
1012
1013 let parsed = parse_responses_usage(&usage);
1014
1015 assert_eq!(parsed.input_tokens, 1000);
1016 assert_eq!(parsed.output_tokens, 200);
1017 assert_eq!(parsed.prompt_cache_hit_tokens, Some(600));
1018 // Cache-miss is derived as input minus the cached hit when cached > 0.
1019 assert_eq!(parsed.prompt_cache_miss_tokens, Some(400));
1020 // Reasoning surfaces from output_tokens_details (Responses dialect).
1021 assert_eq!(parsed.reasoning_tokens, Some(120));
1022
1023 // Without cached/reasoning details, the derived fields stay None.
1024 let bare = json!({ "input_tokens": 1000, "output_tokens": 200 });
1025 let parsed_bare = parse_responses_usage(&bare);
1026 assert_eq!(parsed_bare.prompt_cache_hit_tokens, None);
1027 assert_eq!(parsed_bare.prompt_cache_miss_tokens, None);
1028 assert_eq!(parsed_bare.reasoning_tokens, None);
1029 }
1030
1031 #[test]
1032 fn parse_responses_usage_saturates_u64_fields() {
1033 let parsed = parse_responses_usage(&json!({
1034 "input_tokens": u64::MAX,
1035 "output_tokens": u64::MAX,
1036 "input_tokens_details": { "cached_tokens": u64::MAX },
1037 "output_tokens_details": { "reasoning_tokens": u64::MAX }
1038 }));
1039 assert_eq!(parsed.input_tokens, u32::MAX);
1040 assert_eq!(parsed.output_tokens, u32::MAX);
1041 assert_eq!(parsed.prompt_cache_hit_tokens, Some(u32::MAX));
1042 assert_eq!(parsed.prompt_cache_miss_tokens, Some(0));
1043 assert_eq!(parsed.reasoning_tokens, Some(u32::MAX));
1044 }
1045
1046 #[test]
1047 fn parse_responses_usage_reads_deepseek_top_level_cache_fields() {
1048 // DeepSeek's Responses dialect reports cache telemetry as top-level
1049 // `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens` with
1050 // `cache_write_tokens` nested under `input_tokens_details` -- none of
1051 // which the old parser read (it only looked at
1052 // `input_tokens_details.cached_tokens`, which DeepSeek leaves unset,
1053 // so every V4 Flash turn recorded cache_hit = None).
1054 let usage = json!({
1055 "input_tokens": 1_000,
1056 "output_tokens": 200,
1057 "prompt_cache_hit_tokens": 600,
1058 "prompt_cache_miss_tokens": 200,
1059 "input_tokens_details": { "cached_tokens": 999, "cache_write_tokens": 100 },
1060 "output_tokens_details": { "reasoning_tokens": 120 }
1061 });
1062
1063 let parsed = parse_responses_usage(&usage);
1064
1065 // Top-level DeepSeek fields win over the nested OpenAI-style shape,
1066 // and the explicit miss is trusted over the derived fallback.
1067 assert_eq!(parsed.prompt_cache_hit_tokens, Some(600));
1068 assert_eq!(parsed.prompt_cache_miss_tokens, Some(200));
1069 assert_eq!(parsed.prompt_cache_write_tokens, Some(100));
1070 // `input_tokens` remains the provider-reported total; the pricing
1071 // layer partitions it into hit / miss / write classes.
1072 assert_eq!(parsed.input_tokens, 1_000);
1073 assert_eq!(parsed.output_tokens, 200);
1074 assert_eq!(parsed.reasoning_tokens, Some(120));
1075
1076 // The parsed fields must reach the pricing classes unchanged: 600 hit
1077 // at the cache-read rate, 100 write at the creation rate, and the
1078 // remaining 300 (200 reported miss + 100 uncategorized) at the miss
1079 // rate -- instead of the pre-fix all-raw-input miss billing.
1080 let classes = crate::pricing::token_usage_for_pricing(&parsed);
1081 assert_eq!(classes.input, 300);
1082 assert_eq!(classes.cache_read, 600);
1083 assert_eq!(classes.cache_write, 100);
1084 }
1085
1086 #[test]
1087 fn parse_responses_usage_keeps_old_shape_with_cache_write_fallback() {
1088 // OpenAI-style payloads still parse from `input_tokens_details` alone:
1089 // hit from `cached_tokens` (fallback), miss derived as input minus
1090 // hit, and the write class from `cache_write_tokens` when present.
1091 let usage = json!({
1092 "input_tokens": 1_000,
1093 "output_tokens": 200,
1094 "input_tokens_details": { "cached_tokens": 600, "cache_write_tokens": 100 }
1095 });
1096
1097 let parsed = parse_responses_usage(&usage);
1098
1099 assert_eq!(parsed.input_tokens, 1_000);
1100 assert_eq!(parsed.prompt_cache_hit_tokens, Some(600));
1101 assert_eq!(parsed.prompt_cache_miss_tokens, Some(400));
1102 assert_eq!(parsed.prompt_cache_write_tokens, Some(100));
1103 assert_eq!(parsed.reasoning_tokens, None);
1104 }
1105
1106 /// Regression fixture for the reasoning double-billing bug: a real
1107 /// Responses usage payload has to survive the whole way into the pricing
1108 /// conversion without reasoning tokens being charged twice. OpenAI's
1109 /// `output_tokens` is already the *total* billable completion count, with
1110 /// `output_tokens_details.reasoning_tokens` a subset of it.
1111 #[test]
1112 fn responses_usage_reaches_pricing_conversion_without_double_billing_reasoning() {
1113 use crate::config::ApiProvider;
1114 use crate::pricing::{calculate_turn_cost_estimate_for_provider, token_usage_for_pricing};
1115
1116 let usage = parse_responses_usage(&json!({
1117 "input_tokens": 10_000,
1118 "output_tokens": 4_000,
1119 "total_tokens": 14_000,
1120 "input_tokens_details": { "cached_tokens": 6_000 },
1121 "output_tokens_details": { "reasoning_tokens": 3_500 }
1122 }));
1123
1124 let classes = token_usage_for_pricing(&usage);
1125 assert_eq!(classes.output, 4_000, "reasoning must not inflate output");
1126 assert_eq!(classes.input, 4_000);
1127 assert_eq!(classes.cache_read, 6_000);
1128 assert_eq!(classes.cache_write, 0);
1129
1130 // gpt-5.5: 0.50 cache-read / 5.00 input / 30.00 output per million.
1131 let cost = calculate_turn_cost_estimate_for_provider(ApiProvider::Openai, "gpt-5.5", &usage)
1132 .expect("direct OpenAI route is priced");
1133 let expected = 0.006 * 0.50 + 0.004 * 5.00 + 0.004 * 30.00;
1134 assert!(
1135 (cost.usd - expected).abs() < 1e-12,
1136 "expected {expected}, got {}",
1137 cost.usd
1138 );
1139
1140 // The bug charged the 3_500 reasoning tokens a second time at the
1141 // output rate; assert the difference explicitly so a reintroduction is
1142 // unambiguous rather than a silent number change.
1143 let double_billed = expected + 0.0035 * 30.00;
1144 assert!((cost.usd - double_billed).abs() > 1e-6);
1145 }
1146
1147 #[test]
1148 fn responses_input_includes_user_role_tool_results() {
1149 let request = MessageRequest {
1150 model: "gpt-5.5".to_string(),
1151 messages: vec![
1152 Message {
1153 role: Role::Assistant,
1154 content: vec![ContentBlock::ToolUse {
1155 id: "call_abc|fc_123".to_string(),
1156 name: "checklist_write".to_string(),
1157 input: json!({"items": []}),
1158 caller: None,
1159 thought_signature: None,
1160 }],
1161 },
1162 Message {
1163 role: Role::User,
1164 content: vec![ContentBlock::ToolResult {
1165 tool_use_id: "call_abc|fc_123".to_string(),
1166 content: "<6 items>".to_string(),
1167 is_error: None,
1168 content_blocks: None,
1169 }],
1170 },
1171 ],
1172 max_tokens: 128,
1173 system: None,
1174 tools: None,
1175 tool_choice: None,
1176 metadata: None,
1177 thinking: None,
1178 reasoning_effort: None,
1179 stream: None,
1180 temperature: None,
1181 top_p: None,
1182 };
1183
1184 let input = convert_messages_to_responses_input(&request, ApiProvider::OpenaiCodex);
1185
1186 assert_eq!(input[0]["type"], "function_call");
1187 assert_eq!(input[0]["call_id"], "call_abc");
1188 assert_eq!(input[0]["name"], "checklist_write");
1189 assert_eq!(input[1]["type"], "function_call_output");
1190 assert_eq!(input[1]["call_id"], "call_abc");
1191 assert_eq!(input[1]["output"], "<6 items>");
1192 }
1193
1194 #[test]
1195 fn responses_input_encodes_tool_call_names() {
1196 let request = MessageRequest {
1197 model: "gpt-5.5".to_string(),
1198 messages: vec![Message {
1199 role: Role::Assistant,
1200 content: vec![ContentBlock::ToolUse {
1201 id: "call_abc|fc_123".to_string(),
1202 name: "web.run".to_string(),
1203 input: json!({}),
1204 caller: None,
1205 thought_signature: None,
1206 }],
1207 }],
1208 max_tokens: 128,
1209 system: None,
1210 tools: None,
1211 tool_choice: None,
1212 metadata: None,
1213 thinking: None,
1214 reasoning_effort: None,
1215 stream: None,
1216 temperature: None,
1217 top_p: None,
1218 };
1219
1220 let input = convert_messages_to_responses_input(&request, ApiProvider::OpenaiCodex);
1221
1222 assert_eq!(input[0]["type"], "function_call");
1223 assert_eq!(input[0]["name"], to_api_tool_name("web.run"));
1224 }
1225
1226 #[test]
1227 fn responses_function_tool_sanitizes_root_composition_schema() {
1228 let tool = Tool {
1229 tool_type: None,
1230 name: "web.run".to_string(),
1231 description: "Apply patch".to_string(),
1232 input_schema: json!({
1233 "type": "object",
1234 "properties": {
1235 "patch": {"type": "string"},
1236 "replace": {"type": "array"},
1237 "changes": {"type": "array"}
1238 },
1239 "oneOf": [
1240 {"required": ["patch"]},
1241 {"required": ["replace"]},
1242 {"required": ["changes"]}
1243 ]
1244 }),
1245 allowed_callers: None,
1246 defer_loading: None,
1247 input_examples: None,
1248 strict: None,
1249 cache_control: None,
1250 };
1251
1252 let payload = tool_to_responses_function(&tool);
1253 let parameters = &payload["parameters"];
1254
1255 assert_eq!(payload["name"], to_api_tool_name("web.run"));
1256 assert_eq!(parameters["type"], "object");
1257 assert!(parameters.get("oneOf").is_none());
1258 assert!(parameters.get("anyOf").is_none());
1259 assert!(parameters.get("allOf").is_none());
1260 assert!(parameters.get("enum").is_none());
1261 assert!(parameters.get("not").is_none());
1262 assert!(parameters["properties"].get("patch").is_some());
1263 assert!(parameters["properties"].get("replace").is_some());
1264 assert!(parameters["properties"].get("changes").is_some());
1265 assert_eq!(
1266 payload["description"],
1267 "Apply patch\n\nExactly one of these parameter groups must be provided: `changes` | `patch` | `replace`."
1268 );
1269 assert!(tool.input_schema.get("oneOf").is_some());
1270 }
1271
1272 #[test]
1273 fn responses_function_tool_trims_description_before_constraint_note() {
1274 let tool = Tool {
1275 tool_type: None,
1276 name: "apply_patch".to_string(),
1277 description: "Apply patch\n".to_string(),
1278 input_schema: json!({
1279 "type": "object",
1280 "properties": {
1281 "patch": {"type": "string"},
1282 "replace": {"type": "array"},
1283 "changes": {"type": "array"}
1284 },
1285 "oneOf": [
1286 {"required": ["patch"]},
1287 {"required": ["replace"]},
1288 {"required": ["changes"]}
1289 ]
1290 }),
1291 allowed_callers: None,
1292 defer_loading: None,
1293 input_examples: None,
1294 strict: None,
1295 cache_control: None,
1296 };
1297
1298 let payload = tool_to_responses_function(&tool);
1299
1300 assert_eq!(
1301 payload["description"],
1302 "Apply patch\n\nExactly one of these parameter groups must be provided: `changes` | `patch` | `replace`."
1303 );
1304 }
1305
1306 #[test]
1307 fn responses_function_tool_leaves_description_unchanged_without_constraint_note() {
1308 let tool = Tool {
1309 tool_type: None,
1310 name: "lookup".to_string(),
1311 description: "Lookup".to_string(),
1312 input_schema: json!({
1313 "type": "object",
1314 "properties": {
1315 "query": {"type": "string"}
1316 }
1317 }),
1318 allowed_callers: None,
1319 defer_loading: None,
1320 input_examples: None,
1321 strict: None,
1322 cache_control: None,
1323 };
1324
1325 let payload = tool_to_responses_function(&tool);
1326
1327 assert_eq!(payload["description"], "Lookup");
1328 }
1329
1330 /// The Responses API projection of [`ContentBlock::ImageUrl`].
1331 ///
1332 /// Responses is the odd one out: the image part carries `image_url` as a bare
1333 /// string rather than the nested object Chat Completions uses. Getting that
1334 /// wrong produces a schema error from OpenAI rather than anything that names
1335 /// the image, so it is worth pinning explicitly.
1336 #[test]
1337 fn user_image_becomes_an_input_image_item() {
1338 const DATA_URL: &str = "data:image/png;base64,QUJD";
1339
1340 let mut request = minimal_responses_request();
1341 request.messages[0].content.push(ContentBlock::ImageUrl {
1342 image_url: codewhale_models::ImageUrlContent {
1343 url: DATA_URL.to_string(),
1344 },
1345 });
1346
1347 let items = convert_messages_to_responses_input(&request, ApiProvider::OpenaiCodex);
1348
1349 let user = items
1350 .iter()
1351 .find(|item| item["role"] == "user")
1352 .expect("a user item");
1353 let content = user["content"].as_array().expect("content items");
1354
1355 let image = content
1356 .iter()
1357 .find(|part| part["type"] == "input_image")
1358 .expect("an input_image part");
1359 assert_eq!(
1360 image["image_url"], DATA_URL,
1361 "Responses takes image_url as a bare string, not a nested object: {image}"
1362 );
1363
1364 assert!(
1365 content.iter().any(|part| part["type"] == "input_text"),
1366 "the accompanying question must survive: {user}"
1367 );
1368 }
1369
1370 #[test]
1371 fn tool_result_image_becomes_native_function_output_content() {
1372 let mut request = minimal_responses_request();
1373 request.messages = vec![
1374 Message {
1375 role: Role::Assistant,
1376 content: vec![ContentBlock::ToolUse {
1377 id: "call_image_1".to_string(),
1378 name: "read".to_string(),
1379 input: serde_json::json!({"path": "shot.png"}),
1380 caller: None,
1381 thought_signature: None,
1382 }],
1383 },
1384 Message {
1385 role: Role::User,
1386 content: vec![ContentBlock::ToolResult {
1387 tool_use_id: "call_image_1".to_string(),
1388 content: "screenshot captured".to_string(),
1389 is_error: Some(false),
1390 content_blocks: Some(vec![serde_json::json!({
1391 "type": "image",
1392 "mime_type": "image/png",
1393 "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
1394 })]),
1395 }],
1396 },
1397 ];
1398
1399 let items = convert_messages_to_responses_input(&request, ApiProvider::OpenaiCodex);
1400 let output = items
1401 .iter()
1402 .find(|item| item["type"] == "function_call_output")
1403 .expect("function output");
1404 let content = output["output"].as_array().expect("rich output array");
1405
1406 assert_eq!(
1407 content[0],
1408 serde_json::json!({
1409 "type": "input_text",
1410 "text": "screenshot captured",
1411 })
1412 );
1413 assert_eq!(content[1]["type"], "input_image");
1414 assert_eq!(
1415 content[1]["image_url"],
1416 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="
1417 );
1418 }
1419
1420 /// A `system`-role history message — the shape a compaction summary, a branch
1421 /// summary, or an imported journal `system` entry takes once it reaches
1422 /// `MessageRequest::messages` — must survive the Responses conversion. The
1423 /// Chat Completions adapter already keeps it
1424 /// (`request_builder_preserves_internal_system_messages`); dropping it here
1425 /// silently deletes the only record of everything the compaction replaced.
1426 #[test]
1427 fn responses_input_keeps_system_role_history_messages() {
1428 let mut request = minimal_responses_request();
1429 request.messages.insert(
1430 0,
1431 Message {
1432 role: Role::System,
1433 content: vec![ContentBlock::Text {
1434 text: "[compaction summary] the user is porting the parser".to_string(),
1435 cache_control: None,
1436 }],
1437 },
1438 );
1439
1440 let items = convert_messages_to_responses_input(&request, ApiProvider::OpenaiCodex);
1441
1442 let system = items
1443 .iter()
1444 .find(|item| item["role"] == "system")
1445 .expect("system-role history message survives conversion");
1446 assert_eq!(system["type"], "message");
1447 assert_eq!(
1448 system["content"][0],
1449 serde_json::json!({
1450 "type": "input_text",
1451 "text": "[compaction summary] the user is porting the parser",
1452 })
1453 );
1454 }
1455
1455 lines RUST