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