1use crate::attachments::validate_request_attachments;
8use crate::provider::LlmProvider;
9use crate::streaming::{
10 SseLineBuffer, StreamBox, StreamDelta, StreamErrorKind, reqwest_body_error_delta,
11 reqwest_error_delta,
12};
13use agent_sdk_foundation::llm::{
14 ChatOutcome, ChatRequest, ChatResponse, Content, ContentBlock, ResponseFormat, StopReason,
15 ThinkingConfig, ToolChoice, Usage,
16};
17use anyhow::Result;
18use async_trait::async_trait;
19use futures::StreamExt;
20use reqwest::StatusCode;
21use serde::{Deserialize, Serialize};
22
23use super::openai_reasoning::{
24 OpenAIAllowedToolsMode, OpenAIPromptCacheMode, OpenAIPromptCacheTtl, OpenAIReasoningConfig,
25 OpenAIReasoningContext, OpenAIReasoningEffort, OpenAIReasoningMode, OpenAIReasoningSummary,
26 OpenAITextVerbosity, OpenAIToolChoice, is_gpt56_model, legacy_reasoning_effort,
27 validate_reasoning_config, validate_tool_choice,
28};
29use super::openai_schema::normalize_strict_schema;
30
31const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
32const ENCRYPTED_REASONING_INCLUDE: &[&str] = &["reasoning.encrypted_content"];
33const OPENAI_RESPONSES_STATE_PROVIDER: &str = "openai-responses";
34const OPENAI_MESSAGE_ITEM_TYPE: &str = "message";
35
36fn build_http_client() -> reqwest::Client {
41 reqwest::Client::builder()
42 .connect_timeout(std::time::Duration::from_secs(30))
43 .tcp_keepalive(std::time::Duration::from_secs(30))
44 .build()
45 .unwrap_or_default()
46}
47
48pub const MODEL_GPT56: &str = "gpt-5.6";
50pub const MODEL_GPT56_SOL: &str = "gpt-5.6-sol";
51pub const MODEL_GPT56_TERRA: &str = "gpt-5.6-terra";
52pub const MODEL_GPT56_LUNA: &str = "gpt-5.6-luna";
53
54pub const MODEL_GPT53_CODEX: &str = "gpt-5.3-codex";
56
57pub const MODEL_GPT52_CODEX: &str = "gpt-5.2-codex";
59
60#[derive(Clone, Copy, Debug, Default, Serialize)]
62#[serde(rename_all = "lowercase")]
63pub enum ReasoningEffort {
64 Low,
65 #[default]
66 Medium,
67 High,
68 #[serde(rename = "xhigh")]
70 XHigh,
71 Max,
73}
74
75#[derive(Clone)]
80pub struct OpenAIResponsesProvider {
81 client: reqwest::Client,
82 api_key: String,
83 model: String,
84 base_url: String,
85 thinking: Option<ThinkingConfig>,
86 reasoning: Option<OpenAIReasoningConfig>,
87 extra_headers: Vec<(String, String)>,
89}
90
91impl OpenAIResponsesProvider {
92 #[must_use]
94 pub fn new(api_key: String, model: String) -> Self {
95 Self {
96 client: build_http_client(),
97 api_key,
98 model,
99 base_url: DEFAULT_BASE_URL.to_owned(),
100 thinking: None,
101 reasoning: None,
102 extra_headers: Vec::new(),
103 }
104 }
105
106 #[must_use]
108 pub fn with_base_url(api_key: String, model: String, base_url: String) -> Self {
109 Self {
110 client: build_http_client(),
111 api_key,
112 model,
113 base_url,
114 thinking: None,
115 reasoning: None,
116 extra_headers: Vec::new(),
117 }
118 }
119
120 #[must_use]
126 pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
127 self.extra_headers = headers;
128 self
129 }
130
131 #[must_use]
137 pub(crate) fn with_client(mut self, client: reqwest::Client) -> Self {
138 self.client = client;
139 self
140 }
141
142 fn apply_headers(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
146 let builder = if self.api_key.is_empty() {
147 builder
148 } else {
149 builder.header("Authorization", format!("Bearer {}", self.api_key))
150 };
151 self.extra_headers
152 .iter()
153 .fold(builder, |b, (k, v)| b.header(k.as_str(), v.as_str()))
154 }
155
156 #[must_use]
158 pub fn gpt56(api_key: String) -> Self {
159 Self::new(api_key, MODEL_GPT56.to_owned())
160 }
161
162 #[must_use]
164 pub fn gpt56_sol(api_key: String) -> Self {
165 Self::new(api_key, MODEL_GPT56_SOL.to_owned())
166 }
167
168 #[must_use]
170 pub fn gpt56_terra(api_key: String) -> Self {
171 Self::new(api_key, MODEL_GPT56_TERRA.to_owned())
172 }
173
174 #[must_use]
176 pub fn gpt56_luna(api_key: String) -> Self {
177 Self::new(api_key, MODEL_GPT56_LUNA.to_owned())
178 }
179
180 #[must_use]
182 pub fn gpt53_codex(api_key: String) -> Self {
183 Self::new(api_key, MODEL_GPT53_CODEX.to_owned())
184 }
185
186 #[must_use]
188 pub fn codex(api_key: String) -> Self {
189 Self::gpt53_codex(api_key)
190 }
191
192 #[must_use]
194 pub fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
195 self.thinking = Some(thinking);
196 self.reasoning = None;
197 self
198 }
199
200 #[must_use]
205 pub fn with_reasoning(mut self, reasoning: OpenAIReasoningConfig) -> Self {
206 self.reasoning = Some(reasoning);
207 self.thinking = None;
208 self
209 }
210
211 #[must_use]
213 pub fn with_reasoning_effort(self, effort: ReasoningEffort) -> Self {
214 let effort = match effort {
215 ReasoningEffort::Low => OpenAIReasoningEffort::Low,
216 ReasoningEffort::Medium => OpenAIReasoningEffort::Medium,
217 ReasoningEffort::High => OpenAIReasoningEffort::High,
218 ReasoningEffort::XHigh => OpenAIReasoningEffort::XHigh,
219 ReasoningEffort::Max => OpenAIReasoningEffort::Max,
220 };
221 self.with_reasoning(OpenAIReasoningConfig::new().with_effort(effort))
222 }
223
224 fn effective_max_tokens(&self, request: &ChatRequest) -> u32 {
225 if request.max_tokens_explicit {
226 request.max_tokens
227 } else {
228 self.default_max_tokens()
229 }
230 }
231
232 fn resolve_openai_reasoning(
233 &self,
234 request_thinking: Option<&ThinkingConfig>,
235 ) -> Result<Option<OpenAIReasoningConfig>> {
236 let legacy = if request_thinking.is_some() || self.reasoning.is_none() {
237 self.resolve_thinking_config(request_thinking)?
238 } else {
239 None
240 };
241
242 let config = match (self.reasoning.clone(), legacy.as_ref()) {
243 (Some(config), Some(thinking)) => {
244 Some(config.with_optional_effort(legacy_reasoning_effort(thinking)))
245 }
246 (Some(config), None) => Some(config),
247 (None, Some(thinking)) => Some(
248 OpenAIReasoningConfig::new()
249 .with_optional_effort(legacy_reasoning_effort(thinking)),
250 ),
251 (None, None) => None,
252 };
253
254 if let Some(config) = &config {
255 validate_reasoning_config(&self.model, config)?;
256 }
257 Ok(config)
258 }
259
260 async fn send_responses_request(
261 &self,
262 api_request: &ApiResponsesRequest<'_>,
263 ) -> Result<(StatusCode, Vec<u8>, Option<std::time::Duration>)> {
264 let builder = self
265 .client
266 .post(format!("{}/responses", self.base_url))
267 .header("Content-Type", "application/json");
268 let response = self
269 .apply_headers(builder)
270 .json(api_request)
271 .send()
272 .await
273 .map_err(|error| anyhow::anyhow!("request failed: {error}"))?;
274 let status = response.status();
275 let retry_after = (status == StatusCode::TOO_MANY_REQUESTS)
276 .then(|| crate::http::retry_after_from_headers(response.headers()))
277 .flatten();
278 let bytes = response
279 .bytes()
280 .await
281 .map_err(|error| anyhow::anyhow!("failed to read response body: {error}"))?
282 .to_vec();
283 Ok((status, bytes, retry_after))
284 }
285}
286
287#[async_trait]
288impl LlmProvider for OpenAIResponsesProvider {
289 async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
290 let reasoning_config = match self.resolve_openai_reasoning(request.thinking.as_ref()) {
291 Ok(reasoning) => reasoning,
292 Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
293 };
294 if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
295 return Ok(ChatOutcome::InvalidRequest(error.to_string()));
296 }
297 if let Err(error) = validate_responses_tool_choice(
298 request.tool_choice.as_ref(),
299 reasoning_config.as_ref(),
300 request.tools.as_deref(),
301 ) {
302 return Ok(ChatOutcome::InvalidRequest(error.to_string()));
303 }
304 if let Err(error) = validate_response_format(request.response_format.as_ref()) {
305 return Ok(ChatOutcome::InvalidRequest(error.to_string()));
306 }
307 let prompt_cache =
308 match resolve_prompt_cache_plan(&self.model, &request, reasoning_config.as_ref()) {
309 Ok(plan) => plan,
310 Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
311 };
312 let reasoning = build_api_reasoning(reasoning_config.as_ref());
313 let store = reasoning_config
314 .as_ref()
315 .and_then(OpenAIReasoningConfig::store)
316 .unwrap_or(false);
317 let include_encrypted_reasoning = !store
318 && (reasoning.is_some()
319 || self
320 .capabilities()
321 .is_some_and(|capabilities| capabilities.supports_thinking));
322 let input = build_api_input(&request, prompt_cache.explicit_breakpoints);
323 let text = ApiResponseText::from_options(
324 request.response_format.as_ref(),
325 reasoning_config
326 .as_ref()
327 .and_then(OpenAIReasoningConfig::verbosity),
328 );
329 let prompt_cache_options = prompt_cache.options;
330 let max_tokens = self.effective_max_tokens(&request);
331 let tool_choice =
332 resolve_api_tool_choice(request.tool_choice.as_ref(), reasoning_config.as_ref());
333 let tools: Option<Vec<ApiTool>> = request
334 .tools
335 .map(|ts| ts.into_iter().map(convert_tool).collect());
336 let parallel_tool_calls = reasoning_config
337 .as_ref()
338 .and_then(OpenAIReasoningConfig::parallel_tool_calls)
339 .or_else(|| {
340 tools
341 .as_ref()
342 .is_some_and(|tools| !tools.is_empty())
343 .then_some(true)
344 });
345
346 let api_request = ApiResponsesRequest {
347 model: &self.model,
348 input: &input,
349 tools: tools.as_deref(),
350 max_output_tokens: Some(max_tokens),
351 reasoning,
352 parallel_tool_calls,
353 text,
354 tool_choice,
355 prompt_cache_options,
356 store,
357 include: include_encrypted_reasoning.then_some(ENCRYPTED_REASONING_INCLUDE),
358 prompt_cache_key: request.session_id.as_deref(),
359 safety_identifier: reasoning_config
360 .as_ref()
361 .and_then(OpenAIReasoningConfig::safety_identifier),
362 };
363
364 log::debug!(
365 "OpenAI Responses API request model={} max_tokens={}",
366 self.model,
367 max_tokens
368 );
369
370 let (status, bytes, retry_after) = self.send_responses_request(&api_request).await?;
371
372 log::debug!(
373 "OpenAI Responses API response status={} body_len={}",
374 status,
375 bytes.len()
376 );
377
378 if let Some(outcome) = classify_responses_status(status, &bytes, retry_after) {
379 return Ok(outcome);
380 }
381
382 let api_response: ApiResponse = serde_json::from_slice(&bytes)
383 .map_err(|e| anyhow::anyhow!("failed to parse response: {e}"))?;
384
385 Ok(build_responses_outcome(api_response))
386 }
387
388 #[allow(clippy::too_many_lines)]
389 fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
390 let served_route = self.route().to_owned();
391 Box::pin(async_stream::stream! {
392 let reasoning_config = match self.resolve_openai_reasoning(request.thinking.as_ref()) {
393 Ok(reasoning) => reasoning,
394 Err(error) => {
395 yield Ok(StreamDelta::Error {
396 message: error.to_string(),
397 kind: StreamErrorKind::InvalidRequest,
398 });
399 return;
400 }
401 };
402 if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
403 yield Ok(StreamDelta::Error {
404 message: error.to_string(),
405 kind: StreamErrorKind::InvalidRequest,
406 });
407 return;
408 }
409 if let Err(error) = validate_responses_tool_choice(
410 request.tool_choice.as_ref(),
411 reasoning_config.as_ref(),
412 request.tools.as_deref(),
413 ) {
414 yield Ok(StreamDelta::Error {
415 message: error.to_string(),
416 kind: StreamErrorKind::InvalidRequest,
417 });
418 return;
419 }
420 if let Err(error) = validate_response_format(request.response_format.as_ref()) {
421 yield Ok(StreamDelta::Error {
422 message: error.to_string(),
423 kind: StreamErrorKind::InvalidRequest,
424 });
425 return;
426 }
427 let prompt_cache = match resolve_prompt_cache_plan(
428 &self.model,
429 &request,
430 reasoning_config.as_ref(),
431 ) {
432 Ok(plan) => plan,
433 Err(error) => {
434 yield Ok(StreamDelta::Error {
435 message: error.to_string(),
436 kind: StreamErrorKind::InvalidRequest,
437 });
438 return;
439 }
440 };
441 let reasoning = build_api_reasoning(reasoning_config.as_ref());
442 let store = reasoning_config
443 .as_ref()
444 .and_then(OpenAIReasoningConfig::store)
445 .unwrap_or(false);
446 let include_encrypted_reasoning = !store
447 && (reasoning.is_some()
448 || self
449 .capabilities()
450 .is_some_and(|capabilities| capabilities.supports_thinking));
451 let input = build_api_input(&request, prompt_cache.explicit_breakpoints);
452 let text = ApiResponseText::from_options(
453 request.response_format.as_ref(),
454 reasoning_config
455 .as_ref()
456 .and_then(OpenAIReasoningConfig::verbosity),
457 );
458 let prompt_cache_options = prompt_cache.options;
459 let max_tokens = self.effective_max_tokens(&request);
460 let tool_choice = resolve_api_tool_choice(request.tool_choice.as_ref(), reasoning_config.as_ref());
461 let tools: Option<Vec<ApiTool>> = request
462 .tools
463 .map(|ts| ts.into_iter().map(convert_tool).collect());
464 let parallel_tool_calls = reasoning_config
465 .as_ref()
466 .and_then(OpenAIReasoningConfig::parallel_tool_calls)
467 .or_else(|| tools.as_ref().is_some_and(|tools| !tools.is_empty()).then_some(true));
468
469 let api_request = ApiResponsesRequestStreaming {
470 model: &self.model,
471 input: &input,
472 tools: tools.as_deref(),
473 max_output_tokens: Some(max_tokens),
474 reasoning,
475 parallel_tool_calls,
476 text,
477 tool_choice,
478 prompt_cache_options,
479 store,
480 include: include_encrypted_reasoning
481 .then(|| ENCRYPTED_REASONING_INCLUDE.iter().map(|value| (*value).to_owned()).collect()),
482 prompt_cache_key: request.session_id.clone(),
483 safety_identifier: reasoning_config
484 .as_ref()
485 .and_then(OpenAIReasoningConfig::safety_identifier)
486 .map(ToOwned::to_owned),
487 stream: true,
488 };
489
490 log::debug!("OpenAI Responses API streaming request model={} max_tokens={}", self.model, max_tokens);
491
492 let stream_builder = self.client
493 .post(format!("{}/responses", self.base_url))
494 .header("Content-Type", "application/json");
495 let response = match self
496 .apply_headers(stream_builder)
497 .json(&api_request)
498 .send()
499 .await
500 {
501 Ok(response) => response,
502 Err(error) => {
503 yield Ok(reqwest_error_delta("request failed", &error));
504 return;
505 }
506 };
507
508 let status = response.status();
509
510 if !status.is_success() {
511 let header_hint = crate::http::retry_after_from_headers(response.headers());
513 let body = response.text().await.unwrap_or_default();
514 let kind = if status == StatusCode::TOO_MANY_REQUESTS {
515 StreamErrorKind::RateLimited(
516 header_hint.or_else(|| crate::retry_hints::openai_retry_delay(&body)),
517 )
518 } else if status.is_server_error() {
519 StreamErrorKind::ServerError
520 } else {
521 StreamErrorKind::InvalidRequest
522 };
523 log::warn!("OpenAI Responses error status={status} body={body}");
524 yield Ok(StreamDelta::Error { message: body, kind });
525 return;
526 }
527
528 let mut sse = SseLineBuffer::new();
529 let mut stream = response.bytes_stream();
530 let mut tool_calls: std::collections::HashMap<String, ToolCallAccumulator> =
531 std::collections::HashMap::new();
532 let mut message_state_markers: std::collections::HashMap<usize, serde_json::Value> =
533 std::collections::HashMap::new();
534 let mut refused = false;
535
536 while let Some(chunk_result) = stream.next().await {
537 let chunk = match chunk_result {
538 Ok(chunk) => chunk,
539 Err(error) => {
540 yield Ok(reqwest_body_error_delta("stream error", &error));
541 return;
542 }
543 };
544 sse.extend(&chunk);
545
546 while let Some(line) = sse.next_line() {
547 let line = line.trim();
548 if line.is_empty() { continue; }
549
550 let Some(data) = line.strip_prefix("data: ") else {
551 log::trace!("Responses SSE non-data line bytes={}", line.len());
552 continue;
553 };
554
555 if data == "[DONE]" {
556 yield Ok(StreamDelta::Error {
557 message: "OpenAI Responses stream ended before a semantic terminal event"
558 .to_owned(),
559 kind: StreamErrorKind::ServerError,
560 });
561 return;
562 }
563
564 let event = match serde_json::from_str::<ApiStreamEvent>(data) {
565 Ok(event) => event,
566 Err(error) => {
567 log::warn!("Failed to parse Responses SSE event: {error}");
568 yield Ok(StreamDelta::Error {
569 message: format!("invalid OpenAI Responses stream event: {error}"),
570 kind: StreamErrorKind::ServerError,
571 });
572 return;
573 }
574 };
575 log::trace!(
576 "Responses SSE event type={} bytes={}",
577 event.r#type,
578 data.len()
579 );
580
581 match event.r#type.as_str() {
582 "response.output_text.delta" => {
584 if let Some(delta) = event.delta {
585 yield Ok(StreamDelta::TextDelta {
586 delta,
587 block_index: output_block_index(event.output_index),
588 });
589 }
590 }
591 "response.refusal.delta" => {
592 refused = true;
593 if let Some(delta) = event.delta {
594 yield Ok(StreamDelta::TextDelta {
595 delta,
596 block_index: output_block_index(event.output_index),
597 });
598 }
599 }
600 "response.output_item.added" => {
601 if let Some(item) = &event.item
602 && let Some((block_index, marker)) =
603 message_state_from_output_item(item, event.output_index)
604 && message_state_markers.get(&block_index) != Some(&marker)
605 {
606 message_state_markers.insert(block_index, marker.clone());
607 yield Ok(StreamDelta::OpaqueReasoning {
608 provider: OPENAI_RESPONSES_STATE_PROVIDER.to_owned(),
609 data: marker,
610 block_index,
611 });
612 }
613 if let Some(item) = &event.item
616 && item.get("type").and_then(serde_json::Value::as_str)
617 == Some("function_call")
618 && let (Some(item_id), Some(call_id), Some(name)) =
619 (
620 json_string(item, "id"),
621 json_string(item, "call_id"),
622 json_string(item, "name"),
623 )
624 {
625 let order = tool_calls.len();
626 tool_calls
627 .entry(item_id.to_owned())
628 .or_insert_with(|| ToolCallAccumulator {
629 id: call_id.to_owned(),
630 name: name.to_owned(),
631 arguments: String::new(),
632 order,
633 block_index: output_block_index(event.output_index),
634 });
635 }
636 }
637 "response.output_item.done" => {
638 if let Some(item) = event.item {
639 if let Some((block_index, marker)) =
640 message_state_from_output_item(&item, event.output_index)
641 && message_state_markers.get(&block_index) != Some(&marker)
642 {
643 message_state_markers.insert(block_index, marker.clone());
644 yield Ok(StreamDelta::OpaqueReasoning {
645 provider: OPENAI_RESPONSES_STATE_PROVIDER.to_owned(),
646 data: marker,
647 block_index,
648 });
649 }
650 match item.get("type").and_then(serde_json::Value::as_str) {
651 Some("reasoning") => {
652 yield Ok(StreamDelta::OpaqueReasoning {
653 provider: OPENAI_RESPONSES_STATE_PROVIDER.to_owned(),
654 data: item,
655 block_index: output_block_index(event.output_index),
656 });
657 }
658 Some("function_call") => {
659 if let (Some(item_id), Some(call_id), Some(name)) = (
660 json_string(&item, "id"),
661 json_string(&item, "call_id"),
662 json_string(&item, "name"),
663 ) {
664 let order = tool_calls.len();
665 let accumulator = tool_calls
666 .entry(item_id.to_owned())
667 .or_insert_with(|| ToolCallAccumulator {
668 id: call_id.to_owned(),
669 name: name.to_owned(),
670 arguments: String::new(),
671 order,
672 block_index: output_block_index(
673 event.output_index,
674 ),
675 });
676 if let Some(arguments) =
677 json_string(&item, "arguments")
678 {
679 arguments.clone_into(&mut accumulator.arguments);
680 }
681 }
682 }
683 _ => {}
684 }
685 }
686 }
687 "response.function_call_arguments.delta" => {
688 if let (Some(item_id), Some(delta)) =
689 (event.resolve_item_id().map(str::to_owned), event.delta)
690 {
691 let order = tool_calls.len();
692 let acc =
693 tool_calls.entry(item_id.clone()).or_insert_with(|| {
694 ToolCallAccumulator {
695 id: item_id,
696 name: event.name.unwrap_or_default(),
697 arguments: String::new(),
698 order,
699 block_index: output_block_index(event.output_index),
700 }
701 });
702 acc.arguments.push_str(&delta);
703 }
704 }
705 "response.reasoning.delta"
707 | "response.reasoning_summary_text.delta" => {
708 if let Some(delta) = event.delta {
709 yield Ok(StreamDelta::ThinkingDelta {
710 delta,
711 block_index: reasoning_summary_block_index(
712 event.output_index,
713 ),
714 });
715 }
716 }
717 "response.completed" => {
719 let response = event.response;
720 if let Some(usage) = response.and_then(|response| response.usage) {
721 yield Ok(StreamDelta::Usage(map_usage(Some(usage))));
722 }
723 let stop_reason = if refused {
724 StopReason::Refusal
725 } else if !tool_calls.is_empty() {
726 StopReason::ToolUse
727 } else {
728 StopReason::EndTurn
729 };
730 if stop_reason == StopReason::ToolUse {
731 for delta in flush_responses_tool_calls(&tool_calls) {
732 yield Ok(delta);
733 }
734 }
735 yield Ok(StreamDelta::Done {
736 stop_reason: Some(stop_reason),
737 served_route: Some(served_route.clone()),
738 });
739 return;
740 }
741 "response.incomplete" => {
742 let response = event.response;
743 let stop_reason = response
744 .as_ref()
745 .and_then(|response| response.incomplete_details.as_ref())
746 .and_then(|details| details.reason.as_deref())
747 .map_or(StopReason::Unknown, incomplete_stop_reason);
748 if let Some(usage) = response.and_then(|response| response.usage) {
749 yield Ok(StreamDelta::Usage(map_usage(Some(usage))));
750 }
751 yield Ok(StreamDelta::Done {
752 stop_reason: Some(stop_reason),
753 served_route: Some(served_route.clone()),
754 });
755 return;
756 }
757 "error" | "response.failed" => {
759 let (failed_usage, response_error) = event
764 .response
765 .map_or((None, None), |response| {
766 (response.usage, response.error)
767 });
768 let failed_usage =
769 failed_usage.map(|usage| map_usage(Some(usage)));
770 let (error_code, error_message) = response_error
771 .map_or((None, None), |error| (error.code, error.message));
772 let code = error_code.or(event.code);
773 let message = error_message
774 .or(event.message)
775 .unwrap_or_else(|| data.to_owned());
776 let kind = responses_stream_error_kind(
777 &event.r#type,
778 code.as_deref(),
779 &message,
780 data,
781 );
782 if let Some(usage) = failed_usage {
783 yield Ok(StreamDelta::Usage(usage));
784 }
785 yield Ok(StreamDelta::Error {
786 message,
787 kind,
788 });
789 return;
790 }
791 "response.created"
793 | "response.in_progress"
794 | "response.content_part.added"
795 | "response.content_part.done"
796 | "response.output_text.done"
797 | "response.refusal.done"
798 | "response.function_call_arguments.done"
799 | "response.reasoning.done"
800 | "response.reasoning_summary_text.done" => {}
801 other => {
803 log::debug!("Unhandled Responses SSE event type: {other}");
804 }
805 }
806 }
807 }
808
809 yield Ok(StreamDelta::Error {
810 message: "OpenAI Responses stream ended without completed, incomplete, or failed"
811 .to_owned(),
812 kind: StreamErrorKind::ServerError,
813 });
814 })
815 }
816
817 async fn probe_connectivity(&self) -> bool {
818 crate::provider::probe_http_reachability(&self.client, &self.base_url).await
819 }
820
821 fn model(&self) -> &str {
822 &self.model
823 }
824
825 fn provider(&self) -> &'static str {
826 "openai-responses"
827 }
828
829 fn configured_thinking(&self) -> Option<&ThinkingConfig> {
830 self.thinking.as_ref()
831 }
832}
833
834fn build_api_input(request: &ChatRequest, explicit_cache_breakpoints: usize) -> Vec<ApiInputItem> {
839 let mut items = Vec::new();
840
841 if !request.system.is_empty() {
842 items.push(ApiInputItem::Message(ApiMessage {
843 role: ApiRole::System,
844 content: ApiMessageContent::Parts(vec![ApiInputContent::input_text(
845 request.system.clone(),
846 )]),
847 phase: None,
848 }));
849 }
850
851 for msg in &request.messages {
852 let role = api_role(msg.role);
853 match &msg.content {
854 Content::Text(text) => {
855 items.push(ApiInputItem::Message(ApiMessage {
856 role,
857 content: ApiMessageContent::Parts(vec![ApiInputContent::text(
858 role,
859 text.clone(),
860 )]),
861 phase: api_message_phase(role, false),
862 }));
863 }
864 Content::Blocks(blocks) => append_block_input(&mut items, role, blocks),
865 }
866 }
867
868 apply_explicit_cache_breakpoints(&mut items, explicit_cache_breakpoints);
869 items
870}
871
872fn append_block_input(items: &mut Vec<ApiInputItem>, role: ApiRole, blocks: &[ContentBlock]) {
873 let mut content_parts = Vec::new();
874 let mut phase = api_message_phase(
875 role,
876 blocks
877 .iter()
878 .any(|block| matches!(block, ContentBlock::ToolUse { .. })),
879 );
880
881 for block in blocks {
882 match block {
883 ContentBlock::Text { text } => {
884 content_parts.push(ApiInputContent::text(role, text.clone()));
885 }
886 ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => {}
887 ContentBlock::OpaqueReasoning { provider, data }
888 if matches!(role, ApiRole::Assistant)
889 && is_message_state_marker(provider, data) =>
890 {
891 flush_message_parts(items, role, phase.clone(), &mut content_parts);
892 phase = data
893 .get("phase")
894 .and_then(serde_json::Value::as_str)
895 .map(ToOwned::to_owned);
896 }
897 ContentBlock::OpaqueReasoning { provider, data }
898 if provider == OPENAI_RESPONSES_STATE_PROVIDER
899 && data.get("type").and_then(serde_json::Value::as_str)
900 == Some("reasoning") =>
901 {
902 flush_message_parts(items, role, phase.clone(), &mut content_parts);
903 items.push(ApiInputItem::Opaque(data.clone()));
904 }
905 ContentBlock::OpaqueReasoning { provider, .. } => {
906 log::warn!(
907 "Ignoring opaque reasoning owned by provider={provider} in OpenAI Responses input"
908 );
909 }
910 ContentBlock::Image { source } => content_parts.push(ApiInputContent::Image {
911 image_url: format!("data:{};base64,{}", source.media_type, source.data),
912 prompt_cache_breakpoint: None,
913 }),
914 ContentBlock::Document { source } => content_parts.push(ApiInputContent::File {
915 filename: suggested_filename(&source.media_type),
916 file_data: format!("data:{};base64,{}", source.media_type, source.data),
917 prompt_cache_breakpoint: None,
918 }),
919 ContentBlock::ToolUse {
920 id, name, input, ..
921 } => {
922 flush_message_parts(items, role, phase.clone(), &mut content_parts);
923 items.push(ApiInputItem::FunctionCall(ApiFunctionCall::new(
924 id.clone(),
925 name.clone(),
926 input.to_string(),
927 )));
928 }
929 ContentBlock::ToolResult {
930 tool_use_id,
931 content,
932 ..
933 } => {
934 flush_message_parts(items, role, phase.clone(), &mut content_parts);
935 items.push(ApiInputItem::FunctionCallOutput(
936 ApiFunctionCallOutput::new(tool_use_id.clone(), content.clone()),
937 ));
938 }
939 _ => log::warn!("Skipping unrecognized OpenAI Responses content block"),
940 }
941 }
942
943 flush_message_parts(items, role, phase, &mut content_parts);
944}
945
946const fn api_role(role: agent_sdk_foundation::llm::Role) -> ApiRole {
947 match role {
948 agent_sdk_foundation::llm::Role::User => ApiRole::User,
949 agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
950 }
951}
952
953fn message_state_marker(role: &str, phase: Option<&str>) -> serde_json::Value {
954 let mut marker = serde_json::Map::new();
955 marker.insert(
956 "type".to_owned(),
957 serde_json::Value::String(OPENAI_MESSAGE_ITEM_TYPE.to_owned()),
958 );
959 marker.insert(
960 "role".to_owned(),
961 serde_json::Value::String(role.to_owned()),
962 );
963 if let Some(phase) = phase {
964 marker.insert(
965 "phase".to_owned(),
966 serde_json::Value::String(phase.to_owned()),
967 );
968 }
969 serde_json::Value::Object(marker)
970}
971
972fn is_message_state_marker(provider: &str, data: &serde_json::Value) -> bool {
973 provider == OPENAI_RESPONSES_STATE_PROVIDER
974 && data.get("type").and_then(serde_json::Value::as_str) == Some(OPENAI_MESSAGE_ITEM_TYPE)
975 && data.get("content").is_none()
976}
977
978fn api_message_phase(role: ApiRole, has_tool_use: bool) -> Option<String> {
979 match (role, has_tool_use) {
980 (ApiRole::Assistant, true) => Some("commentary".to_owned()),
981 (ApiRole::Assistant, false) => Some("final_answer".to_owned()),
982 (ApiRole::System | ApiRole::User, _) => None,
983 }
984}
985
986fn flush_message_parts(
987 items: &mut Vec<ApiInputItem>,
988 role: ApiRole,
989 phase: Option<String>,
990 content_parts: &mut Vec<ApiInputContent>,
991) {
992 if content_parts.is_empty() {
993 return;
994 }
995
996 items.push(ApiInputItem::Message(ApiMessage {
997 role,
998 content: ApiMessageContent::Parts(std::mem::take(content_parts)),
999 phase,
1000 }));
1001}
1002
1003fn apply_explicit_cache_breakpoints(items: &mut [ApiInputItem], max_breakpoints: usize) {
1004 if max_breakpoints == 0 {
1005 return;
1006 }
1007
1008 let mut candidates: Vec<(usize, usize)> = items
1009 .iter()
1010 .enumerate()
1011 .filter_map(|(index, item)| match item {
1012 ApiInputItem::Message(ApiMessage {
1013 content: ApiMessageContent::Parts(parts),
1014 ..
1015 }) => parts
1016 .iter()
1017 .rposition(ApiInputContent::supports_explicit_cache_breakpoint)
1018 .map(|part_index| (index, part_index)),
1019 _ => None,
1020 })
1021 .collect();
1022 let keep_from = candidates.len().saturating_sub(max_breakpoints.min(4));
1023
1024 for (item_index, part_index) in candidates.drain(keep_from..) {
1025 if let ApiInputItem::Message(ApiMessage {
1026 content: ApiMessageContent::Parts(parts),
1027 ..
1028 }) = &mut items[item_index]
1029 && let Some(part) = parts.get_mut(part_index)
1030 {
1031 part.set_explicit_cache_breakpoint();
1032 }
1033 }
1034}
1035
1036fn convert_tool(tool: agent_sdk_foundation::llm::Tool) -> ApiTool {
1037 let mut schema = tool.input_schema;
1038
1039 let use_strict = if normalize_strict_schema(&mut schema) {
1044 Some(true)
1045 } else {
1046 log::debug!(
1047 "Tool '{}' has free-form object schema — disabling strict mode",
1048 tool.name
1049 );
1050 None
1051 };
1052
1053 ApiTool {
1054 r#type: "function".to_owned(),
1055 name: tool.name,
1056 description: Some(tool.description),
1057 parameters: Some(schema),
1058 strict: use_strict,
1059 }
1060}
1061
1062fn validate_response_format(response_format: Option<&ResponseFormat>) -> Result<()> {
1063 let Some(response_format) = response_format.filter(|format| format.strict) else {
1064 return Ok(());
1065 };
1066
1067 let mut schema = response_format.schema.clone();
1068 if !normalize_strict_schema(&mut schema) {
1069 anyhow::bail!(
1070 "OpenAI strict structured output `{}` contains a free-form object schema",
1071 response_format.name
1072 );
1073 }
1074 Ok(())
1075}
1076
1077fn suggested_filename(media_type: &str) -> String {
1078 match media_type {
1079 "application/pdf" => "attachment.pdf".to_string(),
1080 "image/png" => "image.png".to_string(),
1081 "image/jpeg" => "image.jpg".to_string(),
1082 "image/gif" => "image.gif".to_string(),
1083 "image/webp" => "image.webp".to_string(),
1084 _ => "attachment.bin".to_string(),
1085 }
1086}
1087
1088fn build_content_blocks(output: &[ApiOutputItem]) -> Vec<ContentBlock> {
1089 let mut blocks = Vec::new();
1090
1091 for item in output {
1092 match item {
1093 ApiOutputItem::Reasoning { data } => {
1094 let mut raw = data.clone();
1095 raw.insert(
1096 "type".to_owned(),
1097 serde_json::Value::String("reasoning".to_owned()),
1098 );
1099 blocks.push(ContentBlock::OpaqueReasoning {
1100 provider: "openai-responses".to_owned(),
1101 data: serde_json::Value::Object(raw),
1102 });
1103
1104 if let Some(summaries) = data.get("summary").and_then(serde_json::Value::as_array) {
1105 for summary in summaries {
1106 if let Some(thinking) = summary
1107 .get("text")
1108 .and_then(serde_json::Value::as_str)
1109 .filter(|text| !text.is_empty())
1110 {
1111 blocks.push(ContentBlock::Thinking {
1112 thinking: thinking.to_owned(),
1113 signature: None,
1114 });
1115 }
1116 }
1117 }
1118 }
1119 ApiOutputItem::Message {
1120 role,
1121 phase,
1122 content,
1123 } => {
1124 blocks.push(ContentBlock::OpaqueReasoning {
1125 provider: OPENAI_RESPONSES_STATE_PROVIDER.to_owned(),
1126 data: message_state_marker(role, phase.as_deref()),
1127 });
1128 for c in content {
1129 match c {
1130 ApiOutputContent::Text { text }
1131 | ApiOutputContent::Refusal { refusal: text }
1132 if !text.is_empty() =>
1133 {
1134 blocks.push(ContentBlock::Text { text: text.clone() });
1135 }
1136 ApiOutputContent::Text { .. }
1137 | ApiOutputContent::Refusal { .. }
1138 | ApiOutputContent::Unknown => {}
1139 }
1140 }
1141 }
1142 ApiOutputItem::FunctionCall {
1143 call_id,
1144 name,
1145 arguments,
1146 ..
1147 } => {
1148 let input =
1149 serde_json::from_str(arguments).unwrap_or_else(|_| serde_json::json!({}));
1150 blocks.push(ContentBlock::ToolUse {
1151 id: call_id.clone(),
1152 name: name.clone(),
1153 input,
1154 thought_signature: None,
1155 });
1156 }
1157 ApiOutputItem::Unknown => {
1158 }
1160 }
1161 }
1162
1163 blocks
1164}
1165
1166fn output_contains_refusal(output: &[ApiOutputItem]) -> bool {
1167 output.iter().any(|item| {
1168 matches!(
1169 item,
1170 ApiOutputItem::Message { content, .. }
1171 if content
1172 .iter()
1173 .any(|content| matches!(content, ApiOutputContent::Refusal { .. }))
1174 )
1175 })
1176}
1177
1178fn responses_stream_error_kind(
1186 event_type: &str,
1187 code: Option<&str>,
1188 message: &str,
1189 raw: &str,
1190) -> StreamErrorKind {
1191 if matches!(code, Some("rate_limit_exceeded" | "rate_limit_error")) {
1192 log::warn!("Responses API rate limited (recoverable): {message}");
1193 return StreamErrorKind::RateLimited(crate::retry_hints::openai_retry_delay(message));
1194 }
1195 if is_context_window_rejection(code, message) {
1196 log::warn!("Responses API context window exceeded (fatal): {message}");
1200 return StreamErrorKind::InvalidRequest;
1201 }
1202 if event_type == "response.failed"
1203 || code == Some("server_error")
1204 || raw.contains("server_error")
1205 {
1206 log::warn!("Responses API server error (recoverable): {message}");
1207 return StreamErrorKind::ServerError;
1208 }
1209 log::error!("Responses API error event: {message}");
1210 StreamErrorKind::InvalidRequest
1211}
1212
1213fn is_context_window_rejection(code: Option<&str>, message: &str) -> bool {
1219 if matches!(code, Some("context_length_exceeded")) {
1220 return true;
1221 }
1222 let lower = message.to_lowercase();
1223 lower.contains("exceeds the context window")
1224 || lower.contains("maximum context length")
1225 || lower.contains("context_length_exceeded")
1226}
1227
1228fn classify_responses_status(
1233 status: StatusCode,
1234 bytes: &[u8],
1235 retry_after: Option<std::time::Duration>,
1236) -> Option<ChatOutcome> {
1237 if status == StatusCode::TOO_MANY_REQUESTS {
1238 let retry_after = retry_after
1239 .or_else(|| crate::retry_hints::openai_retry_delay(&String::from_utf8_lossy(bytes)));
1240 return Some(ChatOutcome::RateLimited(retry_after));
1241 }
1242 if status.is_server_error() {
1243 let body = String::from_utf8_lossy(bytes);
1244 log::error!("OpenAI Responses server error status={status} body={body}");
1245 return Some(ChatOutcome::ServerError(body.into_owned()));
1246 }
1247 if status.is_client_error() {
1248 let body = String::from_utf8_lossy(bytes);
1249 log::warn!("OpenAI Responses client error status={status} body={body}");
1250 return Some(ChatOutcome::InvalidRequest(body.into_owned()));
1251 }
1252 None
1253}
1254
1255fn build_responses_outcome(api_response: ApiResponse) -> ChatOutcome {
1262 if matches!(api_response.status, Some(ApiStatus::Failed)) {
1263 let message = api_response
1264 .error
1265 .and_then(|error| error.message)
1266 .unwrap_or_else(|| "OpenAI Responses API reported status=failed".to_owned());
1267 log::error!("OpenAI Responses generation failed: {message}");
1268 return ChatOutcome::ServerError(message);
1269 }
1270
1271 let refused = output_contains_refusal(&api_response.output);
1272 let mut content = build_content_blocks(&api_response.output);
1273
1274 let has_tool_calls = content
1276 .iter()
1277 .any(|b| matches!(b, ContentBlock::ToolUse { .. }));
1278
1279 let stop_reason = if matches!(api_response.status, Some(ApiStatus::Incomplete)) {
1280 Some(
1281 api_response
1282 .incomplete_details
1283 .as_ref()
1284 .and_then(|details| details.reason.as_deref())
1285 .map_or(StopReason::Unknown, incomplete_stop_reason),
1286 )
1287 } else if refused {
1288 Some(StopReason::Refusal)
1289 } else if has_tool_calls {
1290 Some(StopReason::ToolUse)
1291 } else {
1292 api_response.status.map(|s| match s {
1293 ApiStatus::Completed => StopReason::EndTurn,
1294 ApiStatus::Incomplete | ApiStatus::Failed => StopReason::Unknown,
1296 })
1297 };
1298
1299 if stop_reason != Some(StopReason::ToolUse) {
1300 content.retain(|block| !matches!(block, ContentBlock::ToolUse { .. }));
1301 }
1302
1303 ChatOutcome::Success(ChatResponse {
1304 id: api_response.id,
1305 content,
1306 model: api_response.model,
1307 stop_reason,
1308 usage: map_usage(api_response.usage),
1309 })
1310}
1311
1312fn map_usage(usage: Option<ApiUsage>) -> Usage {
1314 usage.map_or(
1315 Usage {
1316 input_tokens: 0,
1317 output_tokens: 0,
1318 cached_input_tokens: 0,
1319 cache_creation_input_tokens: 0,
1320 },
1321 |u| Usage {
1322 input_tokens: u.input_tokens,
1323 output_tokens: u.output_tokens,
1324 cached_input_tokens: u
1325 .input_tokens_details
1326 .as_ref()
1327 .map_or(0, |details| details.cached_tokens),
1328 cache_creation_input_tokens: u
1329 .input_tokens_details
1330 .as_ref()
1331 .map_or(0, |details| details.cache_write_tokens),
1332 },
1333 )
1334}
1335
1336fn build_api_reasoning(config: Option<&OpenAIReasoningConfig>) -> Option<ApiReasoning> {
1337 let config = config?;
1338 let reasoning = ApiReasoning {
1339 effort: config.effort(),
1340 mode: config.mode(),
1341 context: config.context(),
1342 summary: config.summary(),
1343 };
1344 reasoning.has_fields().then_some(reasoning)
1345}
1346
1347struct ToolCallAccumulator {
1352 id: String,
1353 name: String,
1354 arguments: String,
1355 order: usize,
1358 block_index: usize,
1360}
1361
1362fn output_block_index(output_index: Option<usize>) -> usize {
1363 output_index.unwrap_or(0).saturating_mul(2)
1364}
1365
1366fn reasoning_summary_block_index(output_index: Option<usize>) -> usize {
1367 output_block_index(output_index).saturating_add(1)
1368}
1369
1370fn message_state_from_output_item(
1371 item: &serde_json::Value,
1372 output_index: Option<usize>,
1373) -> Option<(usize, serde_json::Value)> {
1374 (item.get("type").and_then(serde_json::Value::as_str) == Some(OPENAI_MESSAGE_ITEM_TYPE)).then(
1375 || {
1376 let role = json_string(item, "role").unwrap_or("assistant");
1377 let phase = json_string(item, "phase");
1378 (
1379 output_block_index(output_index),
1380 message_state_marker(role, phase),
1381 )
1382 },
1383 )
1384}
1385
1386fn json_string<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a str> {
1387 value.get(key).and_then(serde_json::Value::as_str)
1388}
1389
1390fn incomplete_stop_reason(reason: &str) -> StopReason {
1391 match reason {
1392 "max_output_tokens" => StopReason::MaxTokens,
1393 "content_filter" => StopReason::Refusal,
1394 "model_context_window_exceeded" => StopReason::ModelContextWindowExceeded,
1395 _ => StopReason::Unknown,
1396 }
1397}
1398
1399fn flush_responses_tool_calls(
1408 tool_calls: &std::collections::HashMap<String, ToolCallAccumulator>,
1409) -> Vec<StreamDelta> {
1410 let mut accs: Vec<&ToolCallAccumulator> = tool_calls.values().collect();
1411 accs.sort_by_key(|acc| (acc.block_index, acc.order));
1412
1413 let mut deltas = Vec::with_capacity(accs.len() * 2);
1414 for acc in accs {
1415 deltas.push(StreamDelta::ToolUseStart {
1416 id: acc.id.clone(),
1417 name: acc.name.clone(),
1418 block_index: acc.block_index,
1419 thought_signature: None,
1420 });
1421 deltas.push(StreamDelta::ToolInputDelta {
1422 id: acc.id.clone(),
1423 delta: acc.arguments.clone(),
1424 block_index: acc.block_index,
1425 });
1426 }
1427 deltas
1428}
1429
1430#[derive(Serialize)]
1435struct ApiResponsesRequest<'a> {
1436 model: &'a str,
1437 input: &'a [ApiInputItem],
1438 #[serde(skip_serializing_if = "Option::is_none")]
1439 tools: Option<&'a [ApiTool]>,
1440 #[serde(skip_serializing_if = "Option::is_none")]
1441 max_output_tokens: Option<u32>,
1442 #[serde(skip_serializing_if = "Option::is_none")]
1443 reasoning: Option<ApiReasoning>,
1444 #[serde(skip_serializing_if = "Option::is_none")]
1445 parallel_tool_calls: Option<bool>,
1446 #[serde(skip_serializing_if = "Option::is_none")]
1447 text: Option<ApiResponseText>,
1448 #[serde(skip_serializing_if = "Option::is_none")]
1449 tool_choice: Option<ApiToolChoice>,
1450 #[serde(skip_serializing_if = "Option::is_none")]
1451 prompt_cache_options: Option<ApiPromptCacheOptions>,
1452 store: bool,
1453 #[serde(skip_serializing_if = "Option::is_none")]
1454 include: Option<&'a [&'a str]>,
1455 #[serde(skip_serializing_if = "Option::is_none")]
1456 prompt_cache_key: Option<&'a str>,
1457 #[serde(skip_serializing_if = "Option::is_none")]
1458 safety_identifier: Option<&'a str>,
1459}
1460
1461#[derive(Serialize)]
1462struct ApiResponsesRequestStreaming<'a> {
1463 model: &'a str,
1464 input: &'a [ApiInputItem],
1465 #[serde(skip_serializing_if = "Option::is_none")]
1466 tools: Option<&'a [ApiTool]>,
1467 #[serde(skip_serializing_if = "Option::is_none")]
1468 max_output_tokens: Option<u32>,
1469 #[serde(skip_serializing_if = "Option::is_none")]
1470 reasoning: Option<ApiReasoning>,
1471 #[serde(skip_serializing_if = "Option::is_none")]
1472 parallel_tool_calls: Option<bool>,
1473 #[serde(skip_serializing_if = "Option::is_none")]
1474 text: Option<ApiResponseText>,
1475 #[serde(skip_serializing_if = "Option::is_none")]
1476 tool_choice: Option<ApiToolChoice>,
1477 #[serde(skip_serializing_if = "Option::is_none")]
1478 prompt_cache_options: Option<ApiPromptCacheOptions>,
1479 store: bool,
1480 #[serde(skip_serializing_if = "Option::is_none")]
1481 include: Option<Vec<String>>,
1482 #[serde(skip_serializing_if = "Option::is_none")]
1483 prompt_cache_key: Option<String>,
1484 #[serde(skip_serializing_if = "Option::is_none")]
1485 safety_identifier: Option<String>,
1486 stream: bool,
1487}
1488
1489#[derive(Serialize)]
1490struct ApiReasoning {
1491 #[serde(skip_serializing_if = "Option::is_none")]
1492 effort: Option<OpenAIReasoningEffort>,
1493 #[serde(skip_serializing_if = "Option::is_none")]
1494 mode: Option<OpenAIReasoningMode>,
1495 #[serde(skip_serializing_if = "Option::is_none")]
1496 context: Option<OpenAIReasoningContext>,
1497 #[serde(skip_serializing_if = "Option::is_none")]
1498 summary: Option<OpenAIReasoningSummary>,
1499}
1500
1501impl ApiReasoning {
1502 const fn has_fields(&self) -> bool {
1503 self.effort.is_some()
1504 || self.mode.is_some()
1505 || self.context.is_some()
1506 || self.summary.is_some()
1507 }
1508}
1509
1510#[derive(Serialize)]
1516struct ApiResponseText {
1517 #[serde(skip_serializing_if = "Option::is_none")]
1518 format: Option<ApiResponseTextFormat>,
1519 #[serde(skip_serializing_if = "Option::is_none")]
1520 verbosity: Option<OpenAITextVerbosity>,
1521}
1522
1523#[derive(Serialize)]
1524struct ApiResponseTextFormat {
1525 #[serde(rename = "type")]
1526 format_type: &'static str,
1527 name: String,
1528 schema: serde_json::Value,
1529 strict: bool,
1530}
1531
1532impl From<&ResponseFormat> for ApiResponseText {
1533 fn from(rf: &ResponseFormat) -> Self {
1534 let mut schema = rf.schema.clone();
1535 if rf.strict {
1536 let _ = normalize_strict_schema(&mut schema);
1537 }
1538 Self {
1539 format: Some(ApiResponseTextFormat {
1540 format_type: "json_schema",
1541 name: rf.name.clone(),
1542 schema,
1543 strict: rf.strict,
1544 }),
1545 verbosity: None,
1546 }
1547 }
1548}
1549
1550impl ApiResponseText {
1551 fn from_options(
1552 response_format: Option<&ResponseFormat>,
1553 verbosity: Option<OpenAITextVerbosity>,
1554 ) -> Option<Self> {
1555 if response_format.is_none() && verbosity.is_none() {
1556 return None;
1557 }
1558
1559 Some(Self {
1560 format: response_format.map(|rf| {
1561 let mut schema = rf.schema.clone();
1562 if rf.strict {
1563 let _ = normalize_strict_schema(&mut schema);
1564 }
1565 ApiResponseTextFormat {
1566 format_type: "json_schema",
1567 name: rf.name.clone(),
1568 schema,
1569 strict: rf.strict,
1570 }
1571 }),
1572 verbosity,
1573 })
1574 }
1575}
1576
1577#[derive(Clone, Copy, Serialize)]
1578struct ApiPromptCacheOptions {
1579 #[serde(skip_serializing_if = "Option::is_none")]
1580 mode: Option<OpenAIPromptCacheMode>,
1581 #[serde(skip_serializing_if = "Option::is_none")]
1582 ttl: Option<OpenAIPromptCacheTtl>,
1583}
1584
1585impl ApiPromptCacheOptions {
1586 const fn new(
1587 mode: Option<OpenAIPromptCacheMode>,
1588 ttl: Option<OpenAIPromptCacheTtl>,
1589 ) -> Option<Self> {
1590 if mode.is_none() && ttl.is_none() {
1591 None
1592 } else {
1593 Some(Self { mode, ttl })
1594 }
1595 }
1596}
1597
1598#[derive(Clone, Copy)]
1599struct PromptCachePlan {
1600 options: Option<ApiPromptCacheOptions>,
1601 explicit_breakpoints: usize,
1602}
1603
1604fn resolve_prompt_cache_plan(
1605 model: &str,
1606 request: &ChatRequest,
1607 config: Option<&OpenAIReasoningConfig>,
1608) -> Result<PromptCachePlan> {
1609 let exact_mode = config.and_then(OpenAIReasoningConfig::prompt_cache_mode);
1610 let exact_ttl = config.and_then(OpenAIReasoningConfig::prompt_cache_ttl);
1611
1612 if !is_gpt56_model(model) && request.cache.is_some() {
1613 return Ok(PromptCachePlan {
1614 options: ApiPromptCacheOptions::new(exact_mode, exact_ttl),
1615 explicit_breakpoints: 0,
1616 });
1617 }
1618
1619 let Some(cache) = request.cache.as_ref() else {
1620 return Ok(PromptCachePlan {
1621 options: ApiPromptCacheOptions::new(exact_mode, exact_ttl),
1622 explicit_breakpoints: 0,
1623 });
1624 };
1625
1626 if let Some(ttl) = cache.ttl {
1627 anyhow::bail!(
1628 "OpenAI GPT-5.6 prompt caching supports only ttl=30m; shared cache TTL {} cannot be mapped losslessly. Use OpenAIReasoningConfig::with_prompt_cache_ttl(OpenAIPromptCacheTtl::ThirtyMinutes)",
1629 ttl.as_wire_str()
1630 );
1631 }
1632
1633 if !cache.enabled {
1634 return Ok(PromptCachePlan {
1635 options: ApiPromptCacheOptions::new(Some(OpenAIPromptCacheMode::Explicit), None),
1636 explicit_breakpoints: 0,
1637 });
1638 }
1639
1640 if let Some(max_breakpoints) = cache.max_breakpoints {
1641 return Ok(PromptCachePlan {
1642 options: ApiPromptCacheOptions::new(Some(OpenAIPromptCacheMode::Explicit), exact_ttl),
1643 explicit_breakpoints: usize::from(max_breakpoints.min(4)),
1644 });
1645 }
1646
1647 Ok(PromptCachePlan {
1648 options: ApiPromptCacheOptions::new(exact_mode, exact_ttl),
1649 explicit_breakpoints: 0,
1650 })
1651}
1652
1653#[derive(Serialize)]
1658#[serde(untagged)]
1659enum ApiToolChoice {
1660 Mode(&'static str),
1661 Function {
1662 #[serde(rename = "type")]
1663 choice_type: &'static str,
1664 name: String,
1665 },
1666 AllowedTools {
1667 #[serde(rename = "type")]
1668 choice_type: &'static str,
1669 mode: OpenAIAllowedToolsMode,
1670 tools: Vec<ApiAllowedTool>,
1671 },
1672}
1673
1674#[derive(Serialize)]
1675struct ApiAllowedTool {
1676 #[serde(rename = "type")]
1677 tool_type: &'static str,
1678 name: String,
1679}
1680
1681impl From<&ToolChoice> for ApiToolChoice {
1682 fn from(tc: &ToolChoice) -> Self {
1683 match tc {
1684 ToolChoice::Auto => Self::Mode("auto"),
1685 ToolChoice::Tool(name) => Self::Function {
1686 choice_type: "function",
1687 name: name.clone(),
1688 },
1689 }
1690 }
1691}
1692
1693impl From<&OpenAIToolChoice> for ApiToolChoice {
1694 fn from(choice: &OpenAIToolChoice) -> Self {
1695 match choice {
1696 OpenAIToolChoice::None => Self::Mode("none"),
1697 OpenAIToolChoice::Auto => Self::Mode("auto"),
1698 OpenAIToolChoice::Required => Self::Mode("required"),
1699 OpenAIToolChoice::Function(name) => Self::Function {
1700 choice_type: "function",
1701 name: name.clone(),
1702 },
1703 OpenAIToolChoice::AllowedTools { mode, tools } => Self::AllowedTools {
1704 choice_type: "allowed_tools",
1705 mode: *mode,
1706 tools: tools
1707 .iter()
1708 .map(|name| ApiAllowedTool {
1709 tool_type: "function",
1710 name: name.clone(),
1711 })
1712 .collect(),
1713 },
1714 }
1715 }
1716}
1717
1718fn resolve_api_tool_choice(
1719 request_choice: Option<&ToolChoice>,
1720 config: Option<&OpenAIReasoningConfig>,
1721) -> Option<ApiToolChoice> {
1722 request_choice.map(ApiToolChoice::from).or_else(|| {
1723 config
1724 .and_then(OpenAIReasoningConfig::tool_choice)
1725 .map(ApiToolChoice::from)
1726 })
1727}
1728
1729fn validate_responses_tool_choice(
1730 request_choice: Option<&ToolChoice>,
1731 config: Option<&OpenAIReasoningConfig>,
1732 tools: Option<&[agent_sdk_foundation::llm::Tool]>,
1733) -> Result<()> {
1734 if let Some(request_choice) = request_choice {
1735 if let ToolChoice::Tool(name) = request_choice
1736 && !tools
1737 .unwrap_or_default()
1738 .iter()
1739 .any(|tool| tool.name == *name)
1740 {
1741 anyhow::bail!("OpenAI tool_choice names unknown function `{name}`");
1742 }
1743 return Ok(());
1744 }
1745
1746 validate_tool_choice(config, tools)
1747}
1748
1749#[derive(Serialize)]
1750#[serde(untagged)]
1751enum ApiInputItem {
1752 Message(ApiMessage),
1753 FunctionCall(ApiFunctionCall),
1754 FunctionCallOutput(ApiFunctionCallOutput),
1755 Opaque(serde_json::Value),
1756}
1757
1758#[derive(Serialize)]
1759struct ApiMessage {
1760 role: ApiRole,
1761 content: ApiMessageContent,
1762 #[serde(skip_serializing_if = "Option::is_none")]
1763 phase: Option<String>,
1764}
1765
1766#[derive(Clone, Copy, Serialize)]
1767#[serde(rename_all = "lowercase")]
1768enum ApiRole {
1769 System,
1770 User,
1771 Assistant,
1772}
1773
1774#[derive(Serialize)]
1775#[serde(untagged)]
1776enum ApiMessageContent {
1777 Parts(Vec<ApiInputContent>),
1778}
1779
1780#[derive(Serialize)]
1781#[serde(tag = "type")]
1782enum ApiInputContent {
1783 #[serde(rename = "input_text")]
1784 InputText {
1785 text: String,
1786 #[serde(skip_serializing_if = "Option::is_none")]
1787 prompt_cache_breakpoint: Option<ApiPromptCacheBreakpoint>,
1788 },
1789 #[serde(rename = "output_text")]
1790 OutputText { text: String },
1791 #[serde(rename = "input_image")]
1792 Image {
1793 image_url: String,
1794 #[serde(skip_serializing_if = "Option::is_none")]
1795 prompt_cache_breakpoint: Option<ApiPromptCacheBreakpoint>,
1796 },
1797 #[serde(rename = "input_file")]
1798 File {
1799 filename: String,
1800 file_data: String,
1801 #[serde(skip_serializing_if = "Option::is_none")]
1802 prompt_cache_breakpoint: Option<ApiPromptCacheBreakpoint>,
1803 },
1804}
1805
1806impl ApiInputContent {
1807 const fn input_text(text: String) -> Self {
1808 Self::InputText {
1809 text,
1810 prompt_cache_breakpoint: None,
1811 }
1812 }
1813
1814 const fn text(role: ApiRole, text: String) -> Self {
1815 match role {
1816 ApiRole::Assistant => Self::OutputText { text },
1817 ApiRole::System | ApiRole::User => Self::input_text(text),
1818 }
1819 }
1820
1821 const fn supports_explicit_cache_breakpoint(&self) -> bool {
1822 matches!(
1823 self,
1824 Self::InputText { .. } | Self::Image { .. } | Self::File { .. }
1825 )
1826 }
1827
1828 const fn set_explicit_cache_breakpoint(&mut self) {
1829 let breakpoint = Some(ApiPromptCacheBreakpoint {
1830 mode: OpenAIPromptCacheMode::Explicit,
1831 });
1832 match self {
1833 Self::InputText {
1834 prompt_cache_breakpoint,
1835 ..
1836 }
1837 | Self::Image {
1838 prompt_cache_breakpoint,
1839 ..
1840 }
1841 | Self::File {
1842 prompt_cache_breakpoint,
1843 ..
1844 } => *prompt_cache_breakpoint = breakpoint,
1845 Self::OutputText { .. } => {}
1846 }
1847 }
1848}
1849
1850#[derive(Clone, Copy, Serialize)]
1851struct ApiPromptCacheBreakpoint {
1852 mode: OpenAIPromptCacheMode,
1853}
1854
1855#[derive(Serialize)]
1856struct ApiFunctionCall {
1857 r#type: &'static str,
1858 call_id: String,
1859 name: String,
1860 arguments: String,
1861}
1862
1863impl ApiFunctionCall {
1864 const fn new(call_id: String, name: String, arguments: String) -> Self {
1865 Self {
1866 r#type: "function_call",
1867 call_id,
1868 name,
1869 arguments,
1870 }
1871 }
1872}
1873
1874#[derive(Serialize)]
1875struct ApiFunctionCallOutput {
1876 r#type: &'static str,
1877 call_id: String,
1878 output: String,
1879}
1880
1881impl ApiFunctionCallOutput {
1882 const fn new(call_id: String, output: String) -> Self {
1883 Self {
1884 r#type: "function_call_output",
1885 call_id,
1886 output,
1887 }
1888 }
1889}
1890
1891#[derive(Serialize)]
1892struct ApiTool {
1893 r#type: String,
1894 name: String,
1895 #[serde(skip_serializing_if = "Option::is_none")]
1896 description: Option<String>,
1897 #[serde(skip_serializing_if = "Option::is_none")]
1898 parameters: Option<serde_json::Value>,
1899 #[serde(skip_serializing_if = "Option::is_none")]
1900 strict: Option<bool>,
1901}
1902
1903#[derive(Deserialize)]
1908struct ApiResponse {
1909 id: String,
1910 model: String,
1911 output: Vec<ApiOutputItem>,
1912 #[serde(default)]
1913 status: Option<ApiStatus>,
1914 #[serde(default)]
1915 usage: Option<ApiUsage>,
1916 #[serde(default)]
1917 error: Option<ApiResponseError>,
1918 #[serde(default)]
1919 incomplete_details: Option<ApiIncompleteDetails>,
1920}
1921
1922#[derive(Deserialize)]
1923struct ApiResponseError {
1924 #[serde(default)]
1925 message: Option<String>,
1926 #[serde(default)]
1930 code: Option<String>,
1931}
1932
1933#[derive(Deserialize)]
1934struct ApiIncompleteDetails {
1935 #[serde(default)]
1936 reason: Option<String>,
1937}
1938
1939#[derive(Clone, Copy, Deserialize)]
1940#[serde(rename_all = "snake_case")]
1941enum ApiStatus {
1942 Completed,
1943 Incomplete,
1944 Failed,
1945}
1946
1947#[derive(Deserialize)]
1948struct ApiUsage {
1949 input_tokens: u32,
1950 output_tokens: u32,
1951 #[serde(default)]
1952 input_tokens_details: Option<ApiInputTokensDetails>,
1953}
1954
1955#[derive(Deserialize)]
1956struct ApiInputTokensDetails {
1957 #[serde(default)]
1958 cached_tokens: u32,
1959 #[serde(default)]
1960 cache_write_tokens: u32,
1961}
1962
1963#[derive(Deserialize)]
1964#[serde(tag = "type")]
1965enum ApiOutputItem {
1966 #[serde(rename = "reasoning")]
1967 Reasoning {
1968 #[serde(flatten)]
1969 data: serde_json::Map<String, serde_json::Value>,
1970 },
1971 #[serde(rename = "message")]
1972 Message {
1973 role: String,
1974 #[serde(default)]
1975 phase: Option<String>,
1976 content: Vec<ApiOutputContent>,
1977 },
1978 #[serde(rename = "function_call")]
1979 FunctionCall {
1980 call_id: String,
1981 name: String,
1982 arguments: String,
1983 },
1984 #[serde(other)]
1985 Unknown,
1986}
1987
1988#[derive(Deserialize)]
1989#[serde(tag = "type")]
1990enum ApiOutputContent {
1991 #[serde(rename = "output_text")]
1992 Text { text: String },
1993 #[serde(rename = "refusal")]
1994 Refusal { refusal: String },
1995 #[serde(other)]
1996 Unknown,
1997}
1998
1999#[derive(Deserialize)]
2004struct ApiStreamEvent {
2005 r#type: String,
2006 #[serde(default)]
2007 delta: Option<String>,
2008 #[serde(default)]
2010 item: Option<serde_json::Value>,
2011 #[serde(default)]
2013 output_index: Option<usize>,
2014 #[serde(default)]
2016 item_id: Option<String>,
2017 #[serde(default)]
2019 call_id: Option<String>,
2020 #[serde(default)]
2021 name: Option<String>,
2022 #[serde(default)]
2023 code: Option<String>,
2024 #[serde(default)]
2025 message: Option<String>,
2026 #[serde(default)]
2027 response: Option<ApiStreamResponse>,
2028}
2029
2030impl ApiStreamEvent {
2031 fn resolve_item_id(&self) -> Option<&str> {
2033 self.item_id
2034 .as_deref()
2035 .or(self.call_id.as_deref())
2036 .or_else(|| self.item.as_ref().and_then(|item| json_string(item, "id")))
2037 }
2038}
2039
2040#[derive(Deserialize)]
2041struct ApiStreamResponse {
2042 #[serde(default)]
2043 usage: Option<ApiUsage>,
2044 #[serde(default)]
2045 incomplete_details: Option<ApiIncompleteDetails>,
2046 #[serde(default)]
2047 error: Option<ApiResponseError>,
2048}
2049
2050#[cfg(test)]
2055mod tests {
2056 use super::*;
2057 use agent_sdk_foundation::llm::{CacheConfig, CacheTtl, Message};
2058 use anyhow::{Context as _, bail};
2059 use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
2060
2061 async fn stream_deltas(body: &str) -> anyhow::Result<Vec<StreamDelta>> {
2062 let server = MockServer::start().await;
2063 Mock::given(matchers::method("POST"))
2064 .and(matchers::path("/responses"))
2065 .respond_with(
2066 ResponseTemplate::new(200)
2067 .insert_header("content-type", "text/event-stream")
2068 .set_body_string(body),
2069 )
2070 .mount(&server)
2071 .await;
2072 let provider = OpenAIResponsesProvider::with_base_url(
2073 "test-key".to_owned(),
2074 MODEL_GPT56.to_owned(),
2075 server.uri(),
2076 );
2077 let mut stream = std::pin::pin!(provider.chat_stream(ChatRequest::new(
2078 String::new(),
2079 vec![Message::user("hello")],
2080 )));
2081 let mut deltas = Vec::new();
2082 while let Some(delta) = stream.next().await {
2083 deltas.push(delta?);
2084 }
2085 Ok(deltas)
2086 }
2087
2088 #[test]
2089 fn test_model_constant() {
2090 assert_eq!(MODEL_GPT56, "gpt-5.6");
2091 assert_eq!(MODEL_GPT56_SOL, "gpt-5.6-sol");
2092 assert_eq!(MODEL_GPT56_TERRA, "gpt-5.6-terra");
2093 assert_eq!(MODEL_GPT56_LUNA, "gpt-5.6-luna");
2094 assert_eq!(MODEL_GPT53_CODEX, "gpt-5.3-codex");
2095 assert_eq!(MODEL_GPT52_CODEX, "gpt-5.2-codex");
2096 }
2097
2098 #[test]
2099 fn test_gpt56_factories_create_expected_providers() {
2100 for (provider, expected_model) in [
2101 (
2102 OpenAIResponsesProvider::gpt56("test-key".to_string()),
2103 MODEL_GPT56,
2104 ),
2105 (
2106 OpenAIResponsesProvider::gpt56_sol("test-key".to_string()),
2107 MODEL_GPT56_SOL,
2108 ),
2109 (
2110 OpenAIResponsesProvider::gpt56_terra("test-key".to_string()),
2111 MODEL_GPT56_TERRA,
2112 ),
2113 (
2114 OpenAIResponsesProvider::gpt56_luna("test-key".to_string()),
2115 MODEL_GPT56_LUNA,
2116 ),
2117 ] {
2118 assert_eq!(provider.model(), expected_model);
2119 assert_eq!(provider.provider(), "openai-responses");
2120 assert_eq!(provider.default_max_tokens(), 128_000);
2121 }
2122 }
2123
2124 #[test]
2125 fn test_codex_factory() {
2126 let provider = OpenAIResponsesProvider::codex("test-key".to_string());
2127 assert_eq!(provider.model(), MODEL_GPT53_CODEX);
2128 assert_eq!(provider.provider(), "openai-responses");
2129 }
2130
2131 #[test]
2132 fn test_gpt53_codex_factory() {
2133 let provider = OpenAIResponsesProvider::gpt53_codex("test-key".to_string());
2134 assert_eq!(provider.model(), MODEL_GPT53_CODEX);
2135 assert_eq!(provider.provider(), "openai-responses");
2136 }
2137
2138 #[test]
2139 fn test_reasoning_effort_serialization() -> anyhow::Result<()> {
2140 let low = serde_json::to_string(&ReasoningEffort::Low)?;
2141 assert_eq!(low, "\"low\"");
2142
2143 let xhigh = serde_json::to_string(&ReasoningEffort::XHigh)?;
2144 assert_eq!(xhigh, "\"xhigh\"");
2145 Ok(())
2146 }
2147
2148 #[test]
2149 fn test_with_reasoning_effort() {
2150 let provider = OpenAIResponsesProvider::codex("test-key".to_string())
2151 .with_reasoning_effort(ReasoningEffort::High);
2152 assert!(provider.thinking.is_none());
2153 assert_eq!(
2154 provider
2155 .reasoning
2156 .as_ref()
2157 .and_then(OpenAIReasoningConfig::effort),
2158 Some(OpenAIReasoningEffort::High)
2159 );
2160 }
2161
2162 #[test]
2163 fn test_build_api_reasoning_uses_exact_controls() -> anyhow::Result<()> {
2164 let config = OpenAIReasoningConfig::new()
2165 .with_effort(OpenAIReasoningEffort::Max)
2166 .with_mode(OpenAIReasoningMode::Pro)
2167 .with_context(OpenAIReasoningContext::AllTurns)
2168 .with_summary(OpenAIReasoningSummary::Detailed);
2169 let reasoning = build_api_reasoning(Some(&config))
2170 .ok_or_else(|| anyhow::anyhow!("reasoning config was omitted"))?;
2171 let json = serde_json::to_value(reasoning)?;
2172 assert_eq!(json["effort"], "max");
2173 assert_eq!(json["mode"], "pro");
2174 assert_eq!(json["context"], "all_turns");
2175 assert_eq!(json["summary"], "detailed");
2176 Ok(())
2177 }
2178
2179 #[test]
2180 fn test_build_api_reasoning_omits_adaptive_without_effort() {
2181 let config = OpenAIReasoningConfig::new()
2182 .with_optional_effort(legacy_reasoning_effort(&ThinkingConfig::adaptive()));
2183 assert!(build_api_reasoning(Some(&config)).is_none());
2184 }
2185
2186 #[test]
2187 fn exact_and_legacy_provider_configuration_are_last_call_wins() {
2188 let exact = OpenAIReasoningConfig::new().with_effort(OpenAIReasoningEffort::Max);
2189 let legacy = ThinkingConfig::new(8_192);
2190
2191 let provider = OpenAIResponsesProvider::gpt56("test-key".to_string())
2192 .with_thinking(legacy.clone())
2193 .with_reasoning(exact.clone());
2194 assert!(provider.thinking.is_none());
2195 assert_eq!(provider.reasoning, Some(exact));
2196
2197 let provider = provider.with_thinking(legacy);
2198 assert!(provider.reasoning.is_none());
2199 assert!(provider.thinking.is_some());
2200 }
2201
2202 #[test]
2203 fn effective_max_tokens_uses_model_default_unless_explicit() {
2204 let provider = OpenAIResponsesProvider::gpt56("test-key".to_string());
2205 let implicit = ChatRequest::new(String::new(), vec![]);
2206 assert_eq!(provider.effective_max_tokens(&implicit), 128_000);
2207
2208 let explicit = implicit.with_max_tokens(8_192);
2209 assert_eq!(provider.effective_max_tokens(&explicit), 8_192);
2210 }
2211
2212 #[test]
2213 fn test_openai_responses_accepts_adaptive_thinking_for_codex() {
2214 let provider = OpenAIResponsesProvider::codex("test-key".to_string());
2215 assert!(
2216 provider
2217 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
2218 .is_ok()
2219 );
2220 }
2221
2222 #[test]
2223 fn test_api_tool_serialization() {
2224 let tool = ApiTool {
2225 r#type: "function".to_owned(),
2226 name: "get_weather".to_owned(),
2227 description: Some("Get weather".to_owned()),
2228 parameters: Some(serde_json::json!({"type": "object"})),
2229 strict: Some(true),
2230 };
2231
2232 let json = serde_json::to_string(&tool).unwrap();
2233 assert!(json.contains("\"type\":\"function\""));
2234 assert!(json.contains("\"name\":\"get_weather\""));
2235 assert!(json.contains("\"strict\":true"));
2236 }
2237
2238 #[test]
2239 fn test_api_response_deserialization() {
2240 let json = r#"{
2241 "id": "resp_123",
2242 "model": "gpt-5.2-codex",
2243 "output": [
2244 {
2245 "type": "message",
2246 "role": "assistant",
2247 "content": [
2248 {"type": "output_text", "text": "Hello!"}
2249 ]
2250 }
2251 ],
2252 "status": "completed",
2253 "usage": {
2254 "input_tokens": 100,
2255 "output_tokens": 50
2256 }
2257 }"#;
2258
2259 let response: ApiResponse = serde_json::from_str(json).unwrap();
2260 assert_eq!(response.id, "resp_123");
2261 assert_eq!(response.model, "gpt-5.2-codex");
2262 assert_eq!(response.output.len(), 1);
2263 }
2264
2265 #[test]
2266 fn test_map_usage_preserves_cache_read_and_write_tokens() -> anyhow::Result<()> {
2267 let api_usage: ApiUsage = serde_json::from_value(serde_json::json!({
2268 "input_tokens": 42,
2269 "output_tokens": 7,
2270 "input_tokens_details": {
2271 "cached_tokens": 10,
2272 "cache_write_tokens": 6
2273 }
2274 }))?;
2275
2276 let usage = map_usage(Some(api_usage));
2277 assert_eq!(usage.input_tokens, 42);
2278 assert_eq!(usage.output_tokens, 7);
2279 assert_eq!(usage.cached_input_tokens, 10);
2280 assert_eq!(usage.cache_creation_input_tokens, 6);
2281 Ok(())
2282 }
2283
2284 #[test]
2285 fn test_api_response_with_function_call() {
2286 let json = r#"{
2287 "id": "resp_456",
2288 "model": "gpt-5.2-codex",
2289 "output": [
2290 {
2291 "type": "function_call",
2292 "call_id": "call_abc",
2293 "name": "read_file",
2294 "arguments": "{\"path\": \"test.txt\"}"
2295 }
2296 ],
2297 "status": "completed"
2298 }"#;
2299
2300 let response: ApiResponse = serde_json::from_str(json).unwrap();
2301 assert_eq!(response.output.len(), 1);
2302
2303 match &response.output[0] {
2304 ApiOutputItem::FunctionCall {
2305 call_id,
2306 name,
2307 arguments,
2308 } => {
2309 assert_eq!(call_id, "call_abc");
2310 assert_eq!(name, "read_file");
2311 assert!(arguments.contains("test.txt"));
2312 }
2313 _ => panic!("Expected FunctionCall"),
2314 }
2315 }
2316
2317 #[test]
2318 fn test_build_content_blocks_text() {
2319 let output = vec![ApiOutputItem::Message {
2320 role: "assistant".to_owned(),
2321 phase: Some("final_answer".to_owned()),
2322 content: vec![ApiOutputContent::Text {
2323 text: "Hello!".to_owned(),
2324 }],
2325 }];
2326
2327 let blocks = build_content_blocks(&output);
2328 assert_eq!(blocks.len(), 2);
2329 assert!(matches!(
2330 &blocks[0],
2331 ContentBlock::OpaqueReasoning { data, .. }
2332 if data["phase"] == "final_answer"
2333 ));
2334 assert!(matches!(&blocks[1], ContentBlock::Text { text } if text == "Hello!"));
2335 }
2336
2337 #[test]
2338 fn test_build_content_blocks_function_call() {
2339 let output = vec![ApiOutputItem::FunctionCall {
2340 call_id: "call_123".to_owned(),
2341 name: "test_tool".to_owned(),
2342 arguments: r#"{"key": "value"}"#.to_owned(),
2343 }];
2344
2345 let blocks = build_content_blocks(&output);
2346 assert_eq!(blocks.len(), 1);
2347 assert!(
2348 matches!(&blocks[0], ContentBlock::ToolUse { id, name, .. } if id == "call_123" && name == "test_tool")
2349 );
2350 }
2351
2352 #[test]
2353 fn test_request_serializes_response_format_as_text_format_and_forced_tool_choice() {
2354 let req = ApiResponsesRequest {
2355 model: "gpt-5.3-codex",
2356 input: &[],
2357 tools: None,
2358 max_output_tokens: Some(1024),
2359 reasoning: None,
2360 parallel_tool_calls: None,
2361 text: Some(ApiResponseText::from(&ResponseFormat::new(
2362 "person",
2363 serde_json::json!({"type": "object", "properties": {}}),
2364 ))),
2365 tool_choice: Some(ApiToolChoice::from(&ToolChoice::Tool("respond".to_owned()))),
2366 prompt_cache_options: None,
2367 store: false,
2368 include: None,
2369 prompt_cache_key: None,
2370 safety_identifier: None,
2371 };
2372
2373 let json = serde_json::to_value(&req).unwrap();
2374 assert_eq!(json["text"]["format"]["type"], "json_schema");
2375 assert_eq!(json["text"]["format"]["name"], "person");
2376 assert_eq!(json["text"]["format"]["strict"], true);
2377 assert_eq!(json["text"]["format"]["schema"]["type"], "object");
2378 assert_eq!(json["tool_choice"]["type"], "function");
2379 assert_eq!(json["tool_choice"]["name"], "respond");
2380 }
2381
2382 #[test]
2383 fn test_tool_choice_auto_serializes_as_string() {
2384 let json = serde_json::to_value(ApiToolChoice::from(&ToolChoice::Auto)).unwrap();
2385 assert_eq!(json, serde_json::json!("auto"));
2386 }
2387
2388 #[test]
2389 fn test_api_response_failed_status_carries_error_message() {
2390 let json = r#"{
2391 "id": "resp_fail",
2392 "model": "gpt-5.3-codex",
2393 "output": [],
2394 "status": "failed",
2395 "error": {"message": "model produced no output"}
2396 }"#;
2397
2398 let resp: ApiResponse = serde_json::from_str(json).unwrap();
2399 assert!(matches!(resp.status, Some(ApiStatus::Failed)));
2400 assert_eq!(
2401 resp.error.and_then(|e| e.message).as_deref(),
2402 Some("model produced no output")
2403 );
2404 }
2405
2406 #[test]
2407 fn test_flush_responses_tool_calls_assigns_distinct_ordered_indices() {
2408 let mut tool_calls = std::collections::HashMap::new();
2409 tool_calls.insert(
2410 "b".to_owned(),
2411 ToolCallAccumulator {
2412 id: "b".to_owned(),
2413 name: "second".to_owned(),
2414 arguments: "{}".to_owned(),
2415 order: 1,
2416 block_index: 4,
2417 },
2418 );
2419 tool_calls.insert(
2420 "a".to_owned(),
2421 ToolCallAccumulator {
2422 id: "a".to_owned(),
2423 name: "first".to_owned(),
2424 arguments: "{}".to_owned(),
2425 order: 0,
2426 block_index: 2,
2427 },
2428 );
2429
2430 let deltas = flush_responses_tool_calls(&tool_calls);
2431 let starts: Vec<(String, usize)> = deltas
2432 .iter()
2433 .filter_map(|d| match d {
2434 StreamDelta::ToolUseStart {
2435 name, block_index, ..
2436 } => Some((name.clone(), *block_index)),
2437 _ => None,
2438 })
2439 .collect();
2440 assert_eq!(
2441 starts,
2442 vec![("first".to_owned(), 2), ("second".to_owned(), 4)]
2443 );
2444 }
2445
2446 #[test]
2447 fn input_preserves_reasoning_and_tool_item_order() -> anyhow::Result<()> {
2448 let reasoning = serde_json::json!({
2449 "type": "reasoning",
2450 "id": "rs_1",
2451 "encrypted_content": "opaque-ciphertext",
2452 "summary": []
2453 });
2454 let request = ChatRequest::new(
2455 "system",
2456 vec![Message::assistant_with_content(vec![
2457 ContentBlock::Text {
2458 text: "before".to_owned(),
2459 },
2460 ContentBlock::OpaqueReasoning {
2461 provider: "openai-responses".to_owned(),
2462 data: reasoning.clone(),
2463 },
2464 ContentBlock::ToolUse {
2465 id: "call_1".to_owned(),
2466 name: "lookup".to_owned(),
2467 input: serde_json::json!({"q": "value"}),
2468 thought_signature: None,
2469 },
2470 ContentBlock::Text {
2471 text: "after".to_owned(),
2472 },
2473 ])],
2474 );
2475
2476 let json = serde_json::to_value(build_api_input(&request, 0))?;
2477 let items = json
2478 .as_array()
2479 .context("input must serialize as an array")?;
2480 assert_eq!(items.len(), 5);
2481 assert_eq!(items[0]["role"], "system");
2482 assert_eq!(items[1]["content"][0]["text"], "before");
2483 assert_eq!(items[1]["phase"], "commentary");
2484 assert_eq!(items[2], reasoning);
2485 assert_eq!(items[3]["type"], "function_call");
2486 assert_eq!(items[4]["content"][0]["text"], "after");
2487 Ok(())
2488 }
2489
2490 #[test]
2491 fn assistant_message_phases_round_trip_without_duplicate_text() -> anyhow::Result<()> {
2492 let api_response: ApiResponse = serde_json::from_value(serde_json::json!({
2493 "id": "resp_phases",
2494 "model": "gpt-5.6",
2495 "status": "completed",
2496 "output": [
2497 {
2498 "type": "message",
2499 "role": "assistant",
2500 "phase": "commentary",
2501 "content": [{"type": "output_text", "text": "Working."}]
2502 },
2503 {
2504 "type": "message",
2505 "role": "assistant",
2506 "phase": "final_answer",
2507 "content": [{"type": "output_text", "text": "Done."}]
2508 }
2509 ]
2510 }))?;
2511 let ChatOutcome::Success(response) = build_responses_outcome(api_response) else {
2512 bail!("expected a successful response")
2513 };
2514 let request = ChatRequest::new(
2515 String::new(),
2516 vec![Message::assistant_with_content(response.content)],
2517 );
2518 let value = serde_json::to_value(build_api_input(&request, 0))?;
2519 let items = value.as_array().context("input must be an array")?;
2520
2521 assert_eq!(items.len(), 2);
2522 assert_eq!(items[0]["phase"], "commentary");
2523 assert_eq!(items[0]["content"][0]["text"], "Working.");
2524 assert_eq!(items[1]["phase"], "final_answer");
2525 assert_eq!(items[1]["content"][0]["text"], "Done.");
2526 assert_eq!(value.to_string().matches("Working.").count(), 1);
2527 assert_eq!(value.to_string().matches("Done.").count(), 1);
2528 Ok(())
2529 }
2530
2531 #[tokio::test]
2532 async fn streamed_message_phase_marker_precedes_and_round_trips_text() -> anyhow::Result<()> {
2533 let body = concat!(
2534 "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"phase\":\"commentary\",\"content\":[]}}\n\n",
2535 "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"delta\":\"Working.\"}\n\n",
2536 "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"phase\":\"commentary\",\"content\":[{\"type\":\"output_text\",\"text\":\"Working.\"}]}}\n\n",
2537 "data: {\"type\":\"response.completed\",\"response\":{}}\n\n",
2538 );
2539 let deltas = stream_deltas(body).await?;
2540 let marker_position = deltas
2541 .iter()
2542 .position(|delta| matches!(delta, StreamDelta::OpaqueReasoning { data, .. } if data["type"] == "message"))
2543 .context("stream must contain a message phase marker")?;
2544 let text_position = deltas
2545 .iter()
2546 .position(|delta| matches!(delta, StreamDelta::TextDelta { .. }))
2547 .context("stream must contain text")?;
2548 assert!(marker_position < text_position);
2549
2550 let mut accumulator = crate::streaming::StreamAccumulator::new();
2551 for delta in &deltas {
2552 accumulator.apply(delta);
2553 }
2554 let request = ChatRequest::new(
2555 String::new(),
2556 vec![Message::assistant_with_content(
2557 accumulator.into_content_blocks(),
2558 )],
2559 );
2560 let value = serde_json::to_value(build_api_input(&request, 0))?;
2561 let items = value.as_array().context("input must be an array")?;
2562 assert_eq!(items.len(), 1);
2563 assert_eq!(items[0]["phase"], "commentary");
2564 assert_eq!(items[0]["content"][0]["text"], "Working.");
2565 assert_eq!(value.to_string().matches("Working.").count(), 1);
2566 Ok(())
2567 }
2568
2569 #[test]
2570 fn output_preserves_opaque_reasoning_summary_and_refusal() -> anyhow::Result<()> {
2571 let raw_reasoning = serde_json::json!({
2572 "type": "reasoning",
2573 "id": "rs_1",
2574 "encrypted_content": "opaque-ciphertext",
2575 "summary": [{"type": "summary_text", "text": "Checked constraints."}],
2576 "future_field": {"kept": true}
2577 });
2578 let response: ApiResponse = serde_json::from_value(serde_json::json!({
2579 "id": "resp_1",
2580 "model": "gpt-5.6",
2581 "status": "completed",
2582 "output": [
2583 raw_reasoning,
2584 {
2585 "type": "message",
2586 "role": "assistant",
2587 "content": [{"type": "refusal", "refusal": "I cannot help with that."}]
2588 }
2589 ]
2590 }))?;
2591
2592 let ChatOutcome::Success(response) = build_responses_outcome(response) else {
2593 bail!("expected a successful protocol response carrying a refusal")
2594 };
2595 assert_eq!(response.stop_reason, Some(StopReason::Refusal));
2596 assert!(matches!(
2597 &response.content[0],
2598 ContentBlock::OpaqueReasoning { provider, data }
2599 if provider == "openai-responses"
2600 && data["encrypted_content"] == "opaque-ciphertext"
2601 && data["future_field"]["kept"] == true
2602 ));
2603 assert!(matches!(
2604 &response.content[1],
2605 ContentBlock::Thinking { thinking, .. } if thinking == "Checked constraints."
2606 ));
2607 assert!(matches!(
2608 &response.content[2],
2609 ContentBlock::OpaqueReasoning { data, .. }
2610 if data["type"] == "message"
2611 ));
2612 assert!(matches!(
2613 &response.content[3],
2614 ContentBlock::Text { text } if text == "I cannot help with that."
2615 ));
2616 Ok(())
2617 }
2618
2619 #[test]
2620 fn incomplete_reason_is_not_misreported_as_successful_tool_use() -> anyhow::Result<()> {
2621 let response: ApiResponse = serde_json::from_value(serde_json::json!({
2622 "id": "resp_incomplete",
2623 "model": "gpt-5.6",
2624 "status": "incomplete",
2625 "incomplete_details": {"reason": "max_output_tokens"},
2626 "output": [{
2627 "type": "function_call",
2628 "call_id": "call_partial",
2629 "name": "lookup",
2630 "arguments": "{"
2631 }]
2632 }))?;
2633
2634 let ChatOutcome::Success(response) = build_responses_outcome(response) else {
2635 bail!("expected an incomplete response")
2636 };
2637 assert_eq!(response.stop_reason, Some(StopReason::MaxTokens));
2638 assert!(
2639 !response
2640 .content
2641 .iter()
2642 .any(|block| matches!(block, ContentBlock::ToolUse { .. }))
2643 );
2644 Ok(())
2645 }
2646
2647 #[test]
2648 fn refusal_suppresses_partial_tool_calls() -> anyhow::Result<()> {
2649 let response: ApiResponse = serde_json::from_value(serde_json::json!({
2650 "id": "resp_refusal",
2651 "model": "gpt-5.6",
2652 "status": "completed",
2653 "output": [
2654 {
2655 "type": "function_call",
2656 "call_id": "call_partial",
2657 "name": "lookup",
2658 "arguments": "{}"
2659 },
2660 {
2661 "type": "message",
2662 "role": "assistant",
2663 "phase": "final_answer",
2664 "content": [{"type": "refusal", "refusal": "Cannot comply."}]
2665 }
2666 ]
2667 }))?;
2668
2669 let ChatOutcome::Success(response) = build_responses_outcome(response) else {
2670 bail!("expected a refusal response")
2671 };
2672 assert_eq!(response.stop_reason, Some(StopReason::Refusal));
2673 assert!(
2674 !response
2675 .content
2676 .iter()
2677 .any(|block| matches!(block, ContentBlock::ToolUse { .. }))
2678 );
2679 Ok(())
2680 }
2681
2682 #[test]
2683 fn prompt_cache_plan_maps_exact_controls_and_rejects_anthropic_ttls() -> anyhow::Result<()> {
2684 let exact = OpenAIReasoningConfig::new()
2685 .with_prompt_cache_mode(OpenAIPromptCacheMode::Explicit)
2686 .with_prompt_cache_ttl(OpenAIPromptCacheTtl::ThirtyMinutes);
2687 let request = ChatRequest::new(String::new(), vec![Message::user("hello")]);
2688 let plan = resolve_prompt_cache_plan(MODEL_GPT56, &request, Some(&exact))?;
2689 assert_eq!(plan.explicit_breakpoints, 0);
2690 let options = plan.options.context("cache options missing")?;
2691 let json = serde_json::to_value(options)?;
2692 assert_eq!(json["mode"], "explicit");
2693 assert_eq!(json["ttl"], "30m");
2694
2695 let generic_request = request
2696 .clone()
2697 .with_cache(CacheConfig::enabled().with_max_breakpoints(4));
2698 let generic_plan = resolve_prompt_cache_plan(MODEL_GPT56, &generic_request, Some(&exact))?;
2699 assert_eq!(generic_plan.explicit_breakpoints, 4);
2700
2701 let incompatible = request.with_cache(CacheConfig::enabled().with_ttl(CacheTtl::OneHour));
2702 let error = resolve_prompt_cache_plan(MODEL_GPT56, &incompatible, Some(&exact))
2703 .err()
2704 .context("Anthropic-only TTL should be rejected")?;
2705 assert!(error.to_string().contains("cannot be mapped losslessly"));
2706
2707 let legacy_plan = resolve_prompt_cache_plan(MODEL_GPT53_CODEX, &incompatible, None)?;
2708 assert!(legacy_plan.options.is_none());
2709 assert_eq!(legacy_plan.explicit_breakpoints, 0);
2710 Ok(())
2711 }
2712
2713 #[test]
2714 fn explicit_cache_breakpoints_are_capped_and_applied_to_the_tail() -> anyhow::Result<()> {
2715 let request = ChatRequest::new(
2716 "system",
2717 vec![
2718 Message::user("one"),
2719 Message::assistant("two"),
2720 Message::user("three"),
2721 Message::assistant("four"),
2722 Message::user("five"),
2723 ],
2724 );
2725 let json = serde_json::to_string(&build_api_input(&request, 9))?;
2726 assert_eq!(json.matches("prompt_cache_breakpoint").count(), 4);
2727 assert!(json.contains("\"text\":\"one\",\"prompt_cache_breakpoint\""));
2728 assert!(json.contains("\"text\":\"five\",\"prompt_cache_breakpoint\""));
2729 assert!(!json.contains("\"text\":\"two\",\"prompt_cache_breakpoint\""));
2730 assert!(!json.contains("\"text\":\"four\",\"prompt_cache_breakpoint\""));
2731 Ok(())
2732 }
2733
2734 #[test]
2735 fn explicit_cache_breakpoints_only_annotate_input_parts() -> anyhow::Result<()> {
2736 let request = ChatRequest::new(
2737 "system",
2738 vec![
2739 Message::assistant("assistant output"),
2740 Message::user("user input"),
2741 Message::assistant("final output"),
2742 ],
2743 );
2744 let value = serde_json::to_value(build_api_input(&request, 4))?;
2745 let items = value.as_array().context("input must be an array")?;
2746 let mut marker_count = 0;
2747
2748 for part in items
2749 .iter()
2750 .filter_map(|item| item.get("content").and_then(serde_json::Value::as_array))
2751 .flatten()
2752 {
2753 if part.get("prompt_cache_breakpoint").is_some() {
2754 marker_count += 1;
2755 assert!(matches!(
2756 part.get("type").and_then(serde_json::Value::as_str),
2757 Some("input_text" | "input_image" | "input_file")
2758 ));
2759 }
2760 if part.get("type").and_then(serde_json::Value::as_str) == Some("output_text") {
2761 assert!(part.get("prompt_cache_breakpoint").is_none());
2762 }
2763 }
2764
2765 assert_eq!(marker_count, 2);
2766 Ok(())
2767 }
2768
2769 #[test]
2770 fn strict_response_format_rejects_free_form_objects() {
2771 let format = ResponseFormat::new("freeform", serde_json::json!({"type": "object"}));
2772 assert!(validate_response_format(Some(&format)).is_err());
2773 }
2774
2775 #[test]
2776 fn allowed_tools_serializes_complete_responses_policy() -> anyhow::Result<()> {
2777 let choice = OpenAIToolChoice::AllowedTools {
2778 mode: OpenAIAllowedToolsMode::Required,
2779 tools: vec!["lookup".to_owned(), "search".to_owned()],
2780 };
2781 let json = serde_json::to_value(ApiToolChoice::from(&choice))?;
2782 assert_eq!(json["type"], "allowed_tools");
2783 assert_eq!(json["mode"], "required");
2784 assert_eq!(json["tools"][0]["type"], "function");
2785 assert_eq!(json["tools"][0]["name"], "lookup");
2786 Ok(())
2787 }
2788
2789 #[tokio::test]
2790 async fn in_band_rate_limit_event_is_recoverable_and_keeps_its_hint() -> anyhow::Result<()> {
2791 let body = concat!(
2794 "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"delta\":\"Hi\"}\n\n",
2795 "data: {\"type\":\"error\",\"code\":\"rate_limit_exceeded\",\"message\":\"Rate limit reached for gpt-5.6 in organization org-x on tokens per min. Please try again in 20s.\"}\n\n",
2796 );
2797 let deltas = stream_deltas(body).await?;
2798 let kind = deltas
2799 .iter()
2800 .find_map(|delta| match delta {
2801 StreamDelta::Error { kind, .. } => Some(*kind),
2802 _ => None,
2803 })
2804 .context("expected a stream error delta")?;
2805
2806 assert_eq!(
2807 kind,
2808 StreamErrorKind::RateLimited(Some(std::time::Duration::from_secs(20))),
2809 "an in-band rate limit must be retriable and carry its parsed delay"
2810 );
2811 assert!(kind.is_recoverable());
2812 Ok(())
2813 }
2814
2815 #[tokio::test]
2816 async fn in_band_rate_limit_on_response_failed_is_not_a_server_error() -> anyhow::Result<()> {
2817 let body = "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"Please try again in 1.5s\"}}}\n\n";
2820 let deltas = stream_deltas(body).await?;
2821 let kind = deltas
2822 .iter()
2823 .find_map(|delta| match delta {
2824 StreamDelta::Error { kind, .. } => Some(*kind),
2825 _ => None,
2826 })
2827 .context("expected a stream error delta")?;
2828
2829 assert_eq!(
2830 kind,
2831 StreamErrorKind::RateLimited(Some(std::time::Duration::from_millis(1500)))
2832 );
2833 Ok(())
2834 }
2835
2836 #[tokio::test]
2837 async fn in_band_failed_event_keeps_the_usage_it_reported() -> anyhow::Result<()> {
2838 let body = "data: {\"type\":\"response.failed\",\"response\":{\"usage\":{\"input_tokens\":140,\"output_tokens\":20},\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"Please try again in 12s.\"}}}\n\n";
2842 let deltas = stream_deltas(body).await?;
2843
2844 let usage_at = deltas
2845 .iter()
2846 .position(|delta| matches!(delta, StreamDelta::Usage(_)))
2847 .context("the failed event's usage must not be dropped")?;
2848 let error_at = deltas
2849 .iter()
2850 .position(|delta| matches!(delta, StreamDelta::Error { .. }))
2851 .context("expected a stream error delta")?;
2852 assert!(
2853 usage_at < error_at,
2854 "usage must be emitted before the terminal error, got {deltas:?}"
2855 );
2856
2857 let StreamDelta::Usage(usage) = &deltas[usage_at] else {
2858 bail!("expected a usage delta");
2859 };
2860 assert_eq!(usage.input_tokens, 140);
2861 assert_eq!(usage.output_tokens, 20);
2862 Ok(())
2863 }
2864
2865 #[tokio::test]
2866 async fn in_band_non_rate_limit_error_keeps_its_previous_classification() -> anyhow::Result<()>
2867 {
2868 let body = "data: {\"type\":\"error\",\"code\":\"invalid_prompt\",\"message\":\"bad\"}\n\n";
2869 let deltas = stream_deltas(body).await?;
2870 let kind = deltas
2871 .iter()
2872 .find_map(|delta| match delta {
2873 StreamDelta::Error { kind, .. } => Some(*kind),
2874 _ => None,
2875 })
2876 .context("expected a stream error delta")?;
2877
2878 assert_eq!(kind, StreamErrorKind::InvalidRequest);
2879 assert!(!kind.is_recoverable());
2880 Ok(())
2881 }
2882
2883 #[tokio::test]
2884 async fn in_band_response_failed_context_window_rejection_is_fatal() -> anyhow::Result<()> {
2885 let body = "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"code\":\"context_length_exceeded\",\"message\":\"Your input exceeds the context window of this model. Please adjust your input and try again.\"}}}\n\n";
2889 let deltas = stream_deltas(body).await?;
2890 let kind = deltas
2891 .iter()
2892 .find_map(|delta| match delta {
2893 StreamDelta::Error { kind, .. } => Some(*kind),
2894 _ => None,
2895 })
2896 .context("expected a stream error delta")?;
2897
2898 assert_eq!(kind, StreamErrorKind::InvalidRequest);
2899 assert!(!kind.is_recoverable());
2900 Ok(())
2901 }
2902
2903 #[tokio::test]
2904 async fn semantic_terminal_event_preserves_streamed_reasoning_and_usage() -> anyhow::Result<()>
2905 {
2906 let body = concat!(
2907 "data: {\"type\":\"response.reasoning_summary_text.delta\",\"output_index\":0,\"delta\":\"Checked.\"}\n\n",
2908 "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"reasoning\",\"id\":\"rs_1\",\"encrypted_content\":\"cipher\",\"summary\":[]}}\n\n",
2909 "data: {\"type\":\"response.output_text.delta\",\"output_index\":1,\"delta\":\"Answer\"}\n\n",
2910 "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":12,\"output_tokens\":3,\"input_tokens_details\":{\"cached_tokens\":4,\"cache_write_tokens\":2}}}}\n\n",
2911 "data: [DONE]\n\n",
2912 );
2913 let deltas = stream_deltas(body).await?;
2914 assert!(deltas.iter().any(|delta| matches!(
2915 delta,
2916 StreamDelta::OpaqueReasoning { provider, data, block_index }
2917 if provider == "openai-responses"
2918 && data["encrypted_content"] == "cipher"
2919 && *block_index == 0
2920 )));
2921 assert!(deltas.iter().any(|delta| matches!(
2922 delta,
2923 StreamDelta::ThinkingDelta { delta, block_index }
2924 if delta == "Checked." && *block_index == 1
2925 )));
2926 assert!(deltas.iter().any(|delta| matches!(
2927 delta,
2928 StreamDelta::TextDelta { delta, block_index }
2929 if delta == "Answer" && *block_index == 2
2930 )));
2931 assert!(deltas.iter().any(|delta| matches!(
2932 delta,
2933 StreamDelta::Usage(usage)
2934 if usage.cached_input_tokens == 4 && usage.cache_creation_input_tokens == 2
2935 )));
2936 assert!(matches!(
2937 deltas.last(),
2938 Some(StreamDelta::Done {
2939 stop_reason: Some(StopReason::EndTurn),
2940 ..
2941 })
2942 ));
2943 Ok(())
2944 }
2945
2946 #[tokio::test]
2947 async fn non_tool_terminals_suppress_streamed_partial_tool_calls() -> anyhow::Result<()> {
2948 let incomplete = concat!(
2949 "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"id\":\"fc_1\",\"call_id\":\"call_1\",\"name\":\"lookup\"}}\n\n",
2950 "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"item_id\":\"fc_1\",\"delta\":\"{\"}\n\n",
2951 "data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n",
2952 );
2953 let incomplete_deltas = stream_deltas(incomplete).await?;
2954 assert!(matches!(
2955 incomplete_deltas.last(),
2956 Some(StreamDelta::Done {
2957 stop_reason: Some(StopReason::MaxTokens),
2958 ..
2959 })
2960 ));
2961 assert!(!incomplete_deltas.iter().any(|delta| matches!(
2962 delta,
2963 StreamDelta::ToolUseStart { .. } | StreamDelta::ToolInputDelta { .. }
2964 )));
2965
2966 let refusal = concat!(
2967 "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"id\":\"fc_1\",\"call_id\":\"call_1\",\"name\":\"lookup\"}}\n\n",
2968 "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"item_id\":\"fc_1\",\"delta\":\"{}\"}\n\n",
2969 "data: {\"type\":\"response.refusal.delta\",\"output_index\":1,\"delta\":\"Cannot comply.\"}\n\n",
2970 "data: {\"type\":\"response.completed\",\"response\":{}}\n\n",
2971 );
2972 let refusal_deltas = stream_deltas(refusal).await?;
2973 assert!(matches!(
2974 refusal_deltas.last(),
2975 Some(StreamDelta::Done {
2976 stop_reason: Some(StopReason::Refusal),
2977 ..
2978 })
2979 ));
2980 assert!(!refusal_deltas.iter().any(|delta| matches!(
2981 delta,
2982 StreamDelta::ToolUseStart { .. } | StreamDelta::ToolInputDelta { .. }
2983 )));
2984 Ok(())
2985 }
2986
2987 #[tokio::test]
2988 async fn done_sentinel_without_semantic_terminal_is_a_recoverable_error() -> anyhow::Result<()>
2989 {
2990 let body = concat!(
2991 "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"delta\":\"partial\"}\n\n",
2992 "data: [DONE]\n\n",
2993 );
2994 let deltas = stream_deltas(body).await?;
2995 assert!(matches!(
2996 deltas.last(),
2997 Some(StreamDelta::Error {
2998 kind: StreamErrorKind::ServerError,
2999 ..
3000 })
3001 ));
3002 assert!(
3003 !deltas
3004 .iter()
3005 .any(|delta| matches!(delta, StreamDelta::Done { .. }))
3006 );
3007 Ok(())
3008 }
3009
3010 #[tokio::test]
3011 async fn streamed_refusal_is_visible_and_has_refusal_stop_reason() -> anyhow::Result<()> {
3012 let body = concat!(
3013 "data: {\"type\":\"response.refusal.delta\",\"output_index\":0,\"delta\":\"Cannot comply.\"}\n\n",
3014 "data: {\"type\":\"response.completed\",\"response\":{}}\n\n",
3015 );
3016 let deltas = stream_deltas(body).await?;
3017 assert!(deltas.iter().any(|delta| matches!(
3018 delta,
3019 StreamDelta::TextDelta { delta, .. } if delta == "Cannot comply."
3020 )));
3021 assert!(matches!(
3022 deltas.last(),
3023 Some(StreamDelta::Done {
3024 stop_reason: Some(StopReason::Refusal),
3025 ..
3026 })
3027 ));
3028 Ok(())
3029 }
3030}