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