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