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