1use crate::attachments::validate_request_attachments;
8use crate::provider::LlmProvider;
9use crate::streaming::{StreamBox, StreamDelta, StreamErrorKind};
10use agent_sdk_foundation::llm::{
11 ChatOutcome, ChatRequest, ChatResponse, Content, ContentBlock, Effort, StopReason,
12 ThinkingConfig, ThinkingMode, Usage,
13};
14use anyhow::Result;
15use async_trait::async_trait;
16use futures::StreamExt;
17use reqwest::StatusCode;
18use serde::{Deserialize, Serialize};
19
20const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
21
22pub const MODEL_GPT53_CODEX: &str = "gpt-5.3-codex";
24
25pub const MODEL_GPT52_CODEX: &str = "gpt-5.2-codex";
27
28#[derive(Clone, Copy, Debug, Default, Serialize)]
30#[serde(rename_all = "lowercase")]
31pub enum ReasoningEffort {
32 Low,
33 #[default]
34 Medium,
35 High,
36 #[serde(rename = "xhigh")]
38 XHigh,
39}
40
41#[derive(Clone)]
46pub struct OpenAIResponsesProvider {
47 client: reqwest::Client,
48 api_key: String,
49 model: String,
50 base_url: String,
51 thinking: Option<ThinkingConfig>,
52}
53
54impl OpenAIResponsesProvider {
55 #[must_use]
57 pub fn new(api_key: String, model: String) -> Self {
58 Self {
59 client: reqwest::Client::new(),
60 api_key,
61 model,
62 base_url: DEFAULT_BASE_URL.to_owned(),
63 thinking: None,
64 }
65 }
66
67 #[must_use]
69 pub fn with_base_url(api_key: String, model: String, base_url: String) -> Self {
70 Self {
71 client: reqwest::Client::new(),
72 api_key,
73 model,
74 base_url,
75 thinking: None,
76 }
77 }
78
79 #[must_use]
81 pub fn gpt53_codex(api_key: String) -> Self {
82 Self::new(api_key, MODEL_GPT53_CODEX.to_owned())
83 }
84
85 #[must_use]
87 pub fn codex(api_key: String) -> Self {
88 Self::gpt53_codex(api_key)
89 }
90
91 #[must_use]
93 pub const fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
94 self.thinking = Some(thinking);
95 self
96 }
97
98 #[must_use]
100 pub fn with_reasoning_effort(self, effort: ReasoningEffort) -> Self {
101 self.with_thinking(ThinkingConfig::default().with_effort(map_reasoning_effort(effort)))
102 }
103}
104
105#[async_trait]
106impl LlmProvider for OpenAIResponsesProvider {
107 async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
108 let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
109 Ok(thinking) => thinking,
110 Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
111 };
112 if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
113 return Ok(ChatOutcome::InvalidRequest(error.to_string()));
114 }
115 let reasoning = build_api_reasoning(thinking_config.as_ref());
116 let input = build_api_input(&request);
117 let tools: Option<Vec<ApiTool>> = request
118 .tools
119 .map(|ts| ts.into_iter().map(convert_tool).collect());
120 let parallel_tool_calls = tools.as_ref().is_some_and(|tools| !tools.is_empty());
121
122 let api_request = ApiResponsesRequest {
123 model: &self.model,
124 input: &input,
125 tools: tools.as_deref(),
126 max_output_tokens: Some(request.max_tokens),
127 reasoning,
128 parallel_tool_calls: parallel_tool_calls.then_some(true),
129 };
130
131 log::debug!(
132 "OpenAI Responses API request model={} max_tokens={}",
133 self.model,
134 request.max_tokens
135 );
136
137 let response = self
138 .client
139 .post(format!("{}/responses", self.base_url))
140 .header("Content-Type", "application/json")
141 .header("Authorization", format!("Bearer {}", self.api_key))
142 .json(&api_request)
143 .send()
144 .await
145 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
146
147 let status = response.status();
148 let bytes = response
149 .bytes()
150 .await
151 .map_err(|e| anyhow::anyhow!("failed to read response body: {e}"))?;
152
153 log::debug!(
154 "OpenAI Responses API response status={} body_len={}",
155 status,
156 bytes.len()
157 );
158
159 if status == StatusCode::TOO_MANY_REQUESTS {
160 return Ok(ChatOutcome::RateLimited);
161 }
162
163 if status.is_server_error() {
164 let body = String::from_utf8_lossy(&bytes);
165 log::error!("OpenAI Responses server error status={status} body={body}");
166 return Ok(ChatOutcome::ServerError(body.into_owned()));
167 }
168
169 if status.is_client_error() {
170 let body = String::from_utf8_lossy(&bytes);
171 log::warn!("OpenAI Responses client error status={status} body={body}");
172 return Ok(ChatOutcome::InvalidRequest(body.into_owned()));
173 }
174
175 let api_response: ApiResponse = serde_json::from_slice(&bytes)
176 .map_err(|e| anyhow::anyhow!("failed to parse response: {e}"))?;
177
178 let content = build_content_blocks(&api_response.output);
179
180 let has_tool_calls = content
182 .iter()
183 .any(|b| matches!(b, ContentBlock::ToolUse { .. }));
184
185 let stop_reason = if has_tool_calls {
186 Some(StopReason::ToolUse)
187 } else {
188 api_response.status.map(|s| match s {
189 ApiStatus::Completed => StopReason::EndTurn,
190 ApiStatus::Incomplete => StopReason::MaxTokens,
191 ApiStatus::Failed => StopReason::StopSequence,
192 })
193 };
194
195 Ok(ChatOutcome::Success(ChatResponse {
196 id: api_response.id,
197 content,
198 model: api_response.model,
199 stop_reason,
200 usage: api_response.usage.map_or(
201 Usage {
202 input_tokens: 0,
203 output_tokens: 0,
204 cached_input_tokens: 0,
205 cache_creation_input_tokens: 0,
206 },
207 |u| Usage {
208 input_tokens: u.input_tokens,
209 output_tokens: u.output_tokens,
210 cached_input_tokens: u
211 .input_tokens_details
212 .as_ref()
213 .map_or(0, |details| details.cached_tokens),
214 cache_creation_input_tokens: 0,
215 },
216 ),
217 }))
218 }
219
220 #[allow(clippy::too_many_lines)]
221 fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
222 Box::pin(async_stream::stream! {
223 let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
224 Ok(thinking) => thinking,
225 Err(error) => {
226 yield Ok(StreamDelta::Error {
227 message: error.to_string(),
228 kind: StreamErrorKind::InvalidRequest,
229 });
230 return;
231 }
232 };
233 if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
234 yield Ok(StreamDelta::Error {
235 message: error.to_string(),
236 kind: StreamErrorKind::InvalidRequest,
237 });
238 return;
239 }
240 let reasoning = build_api_reasoning(thinking_config.as_ref());
241 let input = build_api_input(&request);
242 let tools: Option<Vec<ApiTool>> = request
243 .tools
244 .map(|ts| ts.into_iter().map(convert_tool).collect());
245 let parallel_tool_calls = tools.as_ref().is_some_and(|tools| !tools.is_empty());
246
247 let api_request = ApiResponsesRequestStreaming {
248 model: &self.model,
249 input: &input,
250 tools: tools.as_deref(),
251 max_output_tokens: Some(request.max_tokens),
252 reasoning,
253 parallel_tool_calls: parallel_tool_calls.then_some(true),
254 stream: true,
255 };
256
257 log::debug!("OpenAI Responses API streaming request model={} max_tokens={}", self.model, request.max_tokens);
258
259 let Ok(response) = self.client
260 .post(format!("{}/responses", self.base_url))
261 .header("Content-Type", "application/json")
262 .header("Authorization", format!("Bearer {}", self.api_key))
263 .json(&api_request)
264 .send()
265 .await
266 else {
267 yield Err(anyhow::anyhow!("request failed"));
268 return;
269 };
270
271 let status = response.status();
272
273 if !status.is_success() {
274 let body = response.text().await.unwrap_or_default();
275 let kind = if status == StatusCode::TOO_MANY_REQUESTS {
276 StreamErrorKind::RateLimited
277 } else if status.is_server_error() {
278 StreamErrorKind::ServerError
279 } else {
280 StreamErrorKind::InvalidRequest
281 };
282 log::warn!("OpenAI Responses error status={status} body={body}");
283 yield Ok(StreamDelta::Error { message: body, kind });
284 return;
285 }
286
287 let mut buffer = String::new();
288 let mut stream = response.bytes_stream();
289 let mut usage: Option<Usage> = None;
290 let mut tool_calls: std::collections::HashMap<String, ToolCallAccumulator> =
291 std::collections::HashMap::new();
292
293 while let Some(chunk_result) = stream.next().await {
294 let Ok(chunk) = chunk_result else {
295 yield Err(anyhow::anyhow!("stream error"));
296 return;
297 };
298 buffer.push_str(&String::from_utf8_lossy(&chunk));
299
300 while let Some(pos) = buffer.find('\n') {
301 let line = buffer[..pos].trim().to_string();
302 buffer = buffer[pos + 1..].to_string();
303 if line.is_empty() { continue; }
304
305 let Some(data) = line.strip_prefix("data: ") else {
306 log::trace!("Responses SSE non-data line: {line}");
307 continue;
308 };
309 if log::log_enabled!(log::Level::Trace) {
310 let truncated: String = data.chars().take(200).collect();
311 log::trace!("Responses SSE data: {truncated}");
312 }
313
314 if data == "[DONE]" {
315 for acc in tool_calls.values() {
317 yield Ok(StreamDelta::ToolUseStart {
318 id: acc.id.clone(),
319 name: acc.name.clone(),
320 block_index: 1,
321 thought_signature: None,
322 });
323 yield Ok(StreamDelta::ToolInputDelta {
324 id: acc.id.clone(),
325 delta: acc.arguments.clone(),
326 block_index: 1,
327 });
328 }
329
330 if let Some(u) = usage.take() {
331 yield Ok(StreamDelta::Usage(u));
332 }
333
334 let stop_reason = if tool_calls.is_empty() {
335 StopReason::EndTurn
336 } else {
337 StopReason::ToolUse
338 };
339 yield Ok(StreamDelta::Done { stop_reason: Some(stop_reason) });
340 return;
341 }
342
343 let parse_result = serde_json::from_str::<ApiStreamEvent>(data);
345 if parse_result.is_err() {
346 log::debug!("Failed to parse Responses SSE event: {data}");
347 }
348 if let Ok(event) = parse_result {
349 match event.r#type.as_str() {
350 "response.output_text.delta" => {
352 if let Some(delta) = event.delta {
353 yield Ok(StreamDelta::TextDelta { delta, block_index: 0 });
354 }
355 }
356 "response.output_item.added" => {
357 if let Some(item) = &event.item
360 && item.r#type.as_deref() == Some("function_call")
361 && let (Some(item_id), Some(call_id), Some(name)) =
362 (&item.id, &item.call_id, &item.name)
363 {
364 tool_calls
365 .entry(item_id.clone())
366 .or_insert_with(|| ToolCallAccumulator {
367 id: call_id.clone(),
368 name: name.clone(),
369 arguments: String::new(),
370 });
371 }
372 }
373 "response.function_call_arguments.delta" => {
374 if let (Some(item_id), Some(delta)) =
375 (event.resolve_item_id().map(str::to_owned), event.delta)
376 {
377 let acc =
378 tool_calls.entry(item_id.clone()).or_insert_with(|| {
379 ToolCallAccumulator {
380 id: item_id,
381 name: event.name.unwrap_or_default(),
382 arguments: String::new(),
383 }
384 });
385 acc.arguments.push_str(&delta);
386 }
387 }
388 "response.reasoning.delta" => {
390 if let Some(delta) = event.delta {
391 yield Ok(StreamDelta::ThinkingDelta {
392 delta,
393 block_index: 0,
394 });
395 }
396 }
397 "response.completed" => {
399 if let Some(resp) = event.response
400 && let Some(u) = resp.usage
401 {
402 usage = Some(Usage {
403 input_tokens: u.input_tokens,
404 output_tokens: u.output_tokens,
405 cached_input_tokens: u
406 .input_tokens_details
407 .as_ref()
408 .map_or(0, |details| details.cached_tokens),
409 cache_creation_input_tokens: 0,
410 });
411 }
412 }
413 "error" | "response.failed" => {
415 let is_server_error = data.contains("server_error");
416 let kind = if is_server_error {
417 log::warn!("Responses API server error (recoverable): {data}");
418 StreamErrorKind::ServerError
419 } else {
420 log::error!("Responses API error event: {data}");
421 StreamErrorKind::InvalidRequest
422 };
423 yield Ok(StreamDelta::Error {
424 message: data.to_owned(),
425 kind,
426 });
427 return;
428 }
429 "response.created"
431 | "response.in_progress"
432 | "response.output_item.done"
433 | "response.content_part.added"
434 | "response.content_part.done"
435 | "response.output_text.done"
436 | "response.function_call_arguments.done"
437 | "response.reasoning.done"
438 | "response.reasoning_summary_text.delta"
439 | "response.reasoning_summary_text.done" => {}
440 other => {
442 log::debug!("Unhandled Responses SSE event type: {other}");
443 }
444 }
445 }
446 }
447 }
448
449 for acc in tool_calls.values() {
451 yield Ok(StreamDelta::ToolUseStart {
452 id: acc.id.clone(),
453 name: acc.name.clone(),
454 block_index: 1,
455 thought_signature: None,
456 });
457 yield Ok(StreamDelta::ToolInputDelta {
458 id: acc.id.clone(),
459 delta: acc.arguments.clone(),
460 block_index: 1,
461 });
462 }
463
464 if let Some(u) = usage {
465 yield Ok(StreamDelta::Usage(u));
466 }
467
468 let stop_reason = if tool_calls.is_empty() {
469 StopReason::EndTurn
470 } else {
471 StopReason::ToolUse
472 };
473 yield Ok(StreamDelta::Done { stop_reason: Some(stop_reason) });
474 })
475 }
476
477 fn model(&self) -> &str {
478 &self.model
479 }
480
481 fn provider(&self) -> &'static str {
482 "openai-responses"
483 }
484
485 fn configured_thinking(&self) -> Option<&ThinkingConfig> {
486 self.thinking.as_ref()
487 }
488}
489
490fn build_api_input(request: &ChatRequest) -> Vec<ApiInputItem> {
495 let mut items = Vec::new();
496
497 if !request.system.is_empty() {
499 items.push(ApiInputItem::Message(ApiMessage {
500 role: ApiRole::System,
501 content: ApiMessageContent::Text(request.system.clone()),
502 }));
503 }
504
505 for msg in &request.messages {
507 match &msg.content {
508 Content::Text(text) => {
509 items.push(ApiInputItem::Message(ApiMessage {
510 role: match msg.role {
511 agent_sdk_foundation::llm::Role::User => ApiRole::User,
512 agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
513 },
514 content: ApiMessageContent::Text(text.clone()),
515 }));
516 }
517 Content::Blocks(blocks) => {
518 let mut content_parts = Vec::new();
519
520 for block in blocks {
521 match block {
522 ContentBlock::Text { text } => {
523 let part = match msg.role {
524 agent_sdk_foundation::llm::Role::Assistant => {
525 ApiInputContent::OutputText { text: text.clone() }
526 }
527 agent_sdk_foundation::llm::Role::User => {
528 ApiInputContent::InputText { text: text.clone() }
529 }
530 };
531 content_parts.push(part);
532 }
533 ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => {}
534 ContentBlock::Image { source } => {
535 content_parts.push(ApiInputContent::Image {
536 image_url: format!(
537 "data:{};base64,{}",
538 source.media_type, source.data
539 ),
540 });
541 }
542 ContentBlock::Document { source } => {
543 content_parts.push(ApiInputContent::File {
544 filename: suggested_filename(&source.media_type),
545 file_data: format!(
546 "data:{};base64,{}",
547 source.media_type, source.data
548 ),
549 });
550 }
551 ContentBlock::ToolUse {
552 id, name, input, ..
553 } => {
554 items.push(ApiInputItem::FunctionCall(ApiFunctionCall::new(
555 id.clone(),
556 name.clone(),
557 serde_json::to_string(input).unwrap_or_default(),
558 )));
559 }
560 ContentBlock::ToolResult {
561 tool_use_id,
562 content,
563 ..
564 } => {
565 items.push(ApiInputItem::FunctionCallOutput(
566 ApiFunctionCallOutput::new(tool_use_id.clone(), content.clone()),
567 ));
568 }
569 _ => {
572 log::warn!("Skipping unrecognized OpenAI Responses content block");
573 }
574 }
575 }
576
577 if !content_parts.is_empty() {
578 items.push(ApiInputItem::Message(ApiMessage {
579 role: match msg.role {
580 agent_sdk_foundation::llm::Role::User => ApiRole::User,
581 agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
582 },
583 content: ApiMessageContent::Parts(content_parts),
584 }));
585 }
586 }
587 }
588 }
589
590 items
591}
592
593fn fix_schema_for_strict_mode(schema: &mut serde_json::Value) {
596 let Some(obj) = schema.as_object_mut() else {
597 return;
598 };
599
600 let is_object_type = obj
602 .get("type")
603 .is_some_and(|t| t.as_str() == Some("object"));
604
605 if is_object_type {
606 obj.insert(
608 "additionalProperties".to_owned(),
609 serde_json::Value::Bool(false),
610 );
611
612 obj.entry("properties".to_owned())
614 .or_insert_with(|| serde_json::json!({}));
615 obj.entry("required".to_owned())
616 .or_insert_with(|| serde_json::json!([]));
617
618 let originally_required: std::collections::HashSet<String> = obj
620 .get("required")
621 .and_then(|v| v.as_array())
622 .map(|arr| {
623 arr.iter()
624 .filter_map(|v| v.as_str().map(String::from))
625 .collect()
626 })
627 .unwrap_or_default();
628
629 if let Some(serde_json::Value::Object(props)) = obj.get_mut("properties") {
631 for (key, prop_schema) in props.iter_mut() {
632 if !originally_required.contains(key) {
633 make_nullable(prop_schema);
634 }
635 }
636 }
637
638 if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
640 let all_keys: Vec<serde_json::Value> = props
641 .keys()
642 .map(|k| serde_json::Value::String(k.clone()))
643 .collect();
644 obj.insert("required".to_owned(), serde_json::Value::Array(all_keys));
645 }
646 }
647
648 if let Some(props) = obj.get_mut("properties")
650 && let Some(props_obj) = props.as_object_mut()
651 {
652 for prop_schema in props_obj.values_mut() {
653 fix_schema_for_strict_mode(prop_schema);
654 }
655 }
656
657 if let Some(items) = obj.get_mut("items") {
659 fix_schema_for_strict_mode(items);
660 }
661
662 for key in ["anyOf", "oneOf", "allOf"] {
664 if let Some(arr) = obj.get_mut(key)
665 && let Some(arr_items) = arr.as_array_mut()
666 {
667 for item in arr_items {
668 fix_schema_for_strict_mode(item);
669 }
670 }
671 }
672}
673
674fn convert_tool(tool: agent_sdk_foundation::llm::Tool) -> ApiTool {
675 let mut schema = tool.input_schema;
676
677 let use_strict = if has_freeform_object(&schema) {
682 log::debug!(
683 "Tool '{}' has free-form object schema — disabling strict mode",
684 tool.name
685 );
686 None
687 } else {
688 fix_schema_for_strict_mode(&mut schema);
689 Some(true)
690 };
691
692 ApiTool {
693 r#type: "function".to_owned(),
694 name: tool.name,
695 description: Some(tool.description),
696 parameters: Some(schema),
697 strict: use_strict,
698 }
699}
700
701fn make_nullable(schema: &mut serde_json::Value) {
709 if let Some(any_of) = schema
711 .as_object_mut()
712 .and_then(|o| o.get_mut("anyOf"))
713 .and_then(|v| v.as_array_mut())
714 {
715 let has_null = any_of
716 .iter()
717 .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
718 if !has_null {
719 any_of.push(serde_json::json!({"type": "null"}));
720 }
721 return;
722 }
723
724 let original = schema.clone();
726 *schema = serde_json::json!({
727 "anyOf": [original, {"type": "null"}]
728 });
729}
730
731fn has_freeform_object(schema: &serde_json::Value) -> bool {
732 let Some(obj) = schema.as_object() else {
733 return false;
734 };
735
736 let is_object = obj
737 .get("type")
738 .is_some_and(|t| t.as_str() == Some("object"));
739
740 if is_object && !obj.contains_key("properties") {
741 return true;
742 }
743
744 if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
746 for prop in props.values() {
747 if has_freeform_object(prop) {
748 return true;
749 }
750 }
751 }
752
753 if let Some(items) = obj.get("items")
755 && has_freeform_object(items)
756 {
757 return true;
758 }
759
760 for key in ["anyOf", "oneOf", "allOf"] {
762 if let Some(arr) = obj.get(key).and_then(|v| v.as_array()) {
763 for item in arr {
764 if has_freeform_object(item) {
765 return true;
766 }
767 }
768 }
769 }
770
771 false
772}
773
774fn suggested_filename(media_type: &str) -> String {
775 match media_type {
776 "application/pdf" => "attachment.pdf".to_string(),
777 "image/png" => "image.png".to_string(),
778 "image/jpeg" => "image.jpg".to_string(),
779 "image/gif" => "image.gif".to_string(),
780 "image/webp" => "image.webp".to_string(),
781 _ => "attachment.bin".to_string(),
782 }
783}
784
785fn build_content_blocks(output: &[ApiOutputItem]) -> Vec<ContentBlock> {
786 let mut blocks = Vec::new();
787
788 for item in output {
789 match item {
790 ApiOutputItem::Message { content, .. } => {
791 for c in content {
792 if let ApiOutputContent::Text { text } = c
793 && !text.is_empty()
794 {
795 blocks.push(ContentBlock::Text { text: text.clone() });
796 }
797 }
798 }
799 ApiOutputItem::FunctionCall {
800 call_id,
801 name,
802 arguments,
803 ..
804 } => {
805 let input =
806 serde_json::from_str(arguments).unwrap_or_else(|_| serde_json::json!({}));
807 blocks.push(ContentBlock::ToolUse {
808 id: call_id.clone(),
809 name: name.clone(),
810 input,
811 thought_signature: None,
812 });
813 }
814 ApiOutputItem::Unknown => {
815 }
817 }
818 }
819
820 blocks
821}
822
823fn build_api_reasoning(thinking: Option<&ThinkingConfig>) -> Option<ApiReasoning> {
824 thinking
825 .and_then(resolve_reasoning_effort)
826 .map(|effort| ApiReasoning { effort })
827}
828
829const fn resolve_reasoning_effort(config: &ThinkingConfig) -> Option<ReasoningEffort> {
830 if let Some(effort) = config.effort {
831 return Some(map_effort(effort));
832 }
833
834 match &config.mode {
835 ThinkingMode::Adaptive => None,
836 ThinkingMode::Enabled { budget_tokens } => Some(map_budget_to_reasoning(*budget_tokens)),
837 }
838}
839
840const fn map_effort(effort: Effort) -> ReasoningEffort {
841 match effort {
842 Effort::Low => ReasoningEffort::Low,
843 Effort::Medium => ReasoningEffort::Medium,
844 Effort::High => ReasoningEffort::High,
845 Effort::Max => ReasoningEffort::XHigh,
846 }
847}
848
849const fn map_reasoning_effort(effort: ReasoningEffort) -> Effort {
850 match effort {
851 ReasoningEffort::Low => Effort::Low,
852 ReasoningEffort::Medium => Effort::Medium,
853 ReasoningEffort::High => Effort::High,
854 ReasoningEffort::XHigh => Effort::Max,
855 }
856}
857
858const fn map_budget_to_reasoning(budget_tokens: u32) -> ReasoningEffort {
859 if budget_tokens <= 4_096 {
860 ReasoningEffort::Low
861 } else if budget_tokens <= 16_384 {
862 ReasoningEffort::Medium
863 } else if budget_tokens <= 32_768 {
864 ReasoningEffort::High
865 } else {
866 ReasoningEffort::XHigh
867 }
868}
869
870struct ToolCallAccumulator {
875 id: String,
876 name: String,
877 arguments: String,
878}
879
880#[derive(Serialize)]
885struct ApiResponsesRequest<'a> {
886 model: &'a str,
887 input: &'a [ApiInputItem],
888 #[serde(skip_serializing_if = "Option::is_none")]
889 tools: Option<&'a [ApiTool]>,
890 #[serde(skip_serializing_if = "Option::is_none")]
891 max_output_tokens: Option<u32>,
892 #[serde(skip_serializing_if = "Option::is_none")]
893 reasoning: Option<ApiReasoning>,
894 #[serde(skip_serializing_if = "Option::is_none")]
895 parallel_tool_calls: Option<bool>,
896}
897
898#[derive(Serialize)]
899struct ApiResponsesRequestStreaming<'a> {
900 model: &'a str,
901 input: &'a [ApiInputItem],
902 #[serde(skip_serializing_if = "Option::is_none")]
903 tools: Option<&'a [ApiTool]>,
904 #[serde(skip_serializing_if = "Option::is_none")]
905 max_output_tokens: Option<u32>,
906 #[serde(skip_serializing_if = "Option::is_none")]
907 reasoning: Option<ApiReasoning>,
908 #[serde(skip_serializing_if = "Option::is_none")]
909 parallel_tool_calls: Option<bool>,
910 stream: bool,
911}
912
913#[derive(Serialize)]
914struct ApiReasoning {
915 effort: ReasoningEffort,
916}
917
918#[derive(Serialize)]
919#[serde(untagged)]
920enum ApiInputItem {
921 Message(ApiMessage),
922 FunctionCall(ApiFunctionCall),
923 FunctionCallOutput(ApiFunctionCallOutput),
924}
925
926#[derive(Serialize)]
927struct ApiMessage {
928 role: ApiRole,
929 content: ApiMessageContent,
930}
931
932#[derive(Serialize)]
933#[serde(rename_all = "lowercase")]
934enum ApiRole {
935 System,
936 User,
937 Assistant,
938}
939
940#[derive(Serialize)]
941#[serde(untagged)]
942enum ApiMessageContent {
943 Text(String),
944 Parts(Vec<ApiInputContent>),
945}
946
947#[derive(Serialize)]
948#[serde(tag = "type")]
949enum ApiInputContent {
950 #[serde(rename = "input_text")]
951 InputText { text: String },
952 #[serde(rename = "output_text")]
953 OutputText { text: String },
954 #[serde(rename = "input_image")]
955 Image { image_url: String },
956 #[serde(rename = "input_file")]
957 File { filename: String, file_data: String },
958}
959
960#[derive(Serialize)]
961struct ApiFunctionCall {
962 r#type: &'static str,
963 call_id: String,
964 name: String,
965 arguments: String,
966}
967
968impl ApiFunctionCall {
969 const fn new(call_id: String, name: String, arguments: String) -> Self {
970 Self {
971 r#type: "function_call",
972 call_id,
973 name,
974 arguments,
975 }
976 }
977}
978
979#[derive(Serialize)]
980struct ApiFunctionCallOutput {
981 r#type: &'static str,
982 call_id: String,
983 output: String,
984}
985
986impl ApiFunctionCallOutput {
987 const fn new(call_id: String, output: String) -> Self {
988 Self {
989 r#type: "function_call_output",
990 call_id,
991 output,
992 }
993 }
994}
995
996#[derive(Serialize)]
997struct ApiTool {
998 r#type: String,
999 name: String,
1000 #[serde(skip_serializing_if = "Option::is_none")]
1001 description: Option<String>,
1002 #[serde(skip_serializing_if = "Option::is_none")]
1003 parameters: Option<serde_json::Value>,
1004 #[serde(skip_serializing_if = "Option::is_none")]
1005 strict: Option<bool>,
1006}
1007
1008#[derive(Deserialize)]
1013struct ApiResponse {
1014 id: String,
1015 model: String,
1016 output: Vec<ApiOutputItem>,
1017 #[serde(default)]
1018 status: Option<ApiStatus>,
1019 #[serde(default)]
1020 usage: Option<ApiUsage>,
1021}
1022
1023#[derive(Deserialize)]
1024#[serde(rename_all = "snake_case")]
1025enum ApiStatus {
1026 Completed,
1027 Incomplete,
1028 Failed,
1029}
1030
1031#[derive(Deserialize)]
1032struct ApiUsage {
1033 input_tokens: u32,
1034 output_tokens: u32,
1035 #[serde(default)]
1036 input_tokens_details: Option<ApiInputTokensDetails>,
1037}
1038
1039#[derive(Deserialize)]
1040struct ApiInputTokensDetails {
1041 #[serde(default)]
1042 cached_tokens: u32,
1043}
1044
1045#[derive(Deserialize)]
1046#[serde(tag = "type")]
1047enum ApiOutputItem {
1048 #[serde(rename = "message")]
1049 Message {
1050 #[serde(rename = "role")]
1051 _role: String,
1052 content: Vec<ApiOutputContent>,
1053 },
1054 #[serde(rename = "function_call")]
1055 FunctionCall {
1056 call_id: String,
1057 name: String,
1058 arguments: String,
1059 },
1060 #[serde(other)]
1061 Unknown,
1062}
1063
1064#[derive(Deserialize)]
1065#[serde(tag = "type")]
1066enum ApiOutputContent {
1067 #[serde(rename = "output_text")]
1068 Text { text: String },
1069 #[serde(other)]
1070 Unknown,
1071}
1072
1073#[derive(Deserialize)]
1078struct ApiStreamEvent {
1079 r#type: String,
1080 #[serde(default)]
1081 delta: Option<String>,
1082 #[serde(default)]
1084 item: Option<ApiStreamItem>,
1085 #[serde(default)]
1087 item_id: Option<String>,
1088 #[serde(default)]
1090 call_id: Option<String>,
1091 #[serde(default)]
1092 name: Option<String>,
1093 #[serde(default)]
1094 response: Option<ApiStreamResponse>,
1095}
1096
1097impl ApiStreamEvent {
1098 fn resolve_item_id(&self) -> Option<&str> {
1100 self.item_id
1101 .as_deref()
1102 .or(self.call_id.as_deref())
1103 .or_else(|| self.item.as_ref().and_then(|i| i.id.as_deref()))
1104 }
1105}
1106
1107#[derive(Deserialize)]
1108struct ApiStreamItem {
1109 #[serde(default)]
1110 id: Option<String>,
1111 #[serde(default)]
1112 r#type: Option<String>,
1113 #[serde(default)]
1114 call_id: Option<String>,
1115 #[serde(default)]
1116 name: Option<String>,
1117}
1118
1119#[derive(Deserialize)]
1120struct ApiStreamResponse {
1121 #[serde(default)]
1122 usage: Option<ApiUsage>,
1123}
1124
1125#[cfg(test)]
1130mod tests {
1131 use super::*;
1132
1133 #[test]
1134 fn test_model_constant() {
1135 assert_eq!(MODEL_GPT53_CODEX, "gpt-5.3-codex");
1136 assert_eq!(MODEL_GPT52_CODEX, "gpt-5.2-codex");
1137 }
1138
1139 #[test]
1140 fn test_codex_factory() {
1141 let provider = OpenAIResponsesProvider::codex("test-key".to_string());
1142 assert_eq!(provider.model(), MODEL_GPT53_CODEX);
1143 assert_eq!(provider.provider(), "openai-responses");
1144 }
1145
1146 #[test]
1147 fn test_gpt53_codex_factory() {
1148 let provider = OpenAIResponsesProvider::gpt53_codex("test-key".to_string());
1149 assert_eq!(provider.model(), MODEL_GPT53_CODEX);
1150 assert_eq!(provider.provider(), "openai-responses");
1151 }
1152
1153 #[test]
1154 fn test_reasoning_effort_serialization() {
1155 let low = serde_json::to_string(&ReasoningEffort::Low).unwrap();
1156 assert_eq!(low, "\"low\"");
1157
1158 let xhigh = serde_json::to_string(&ReasoningEffort::XHigh).unwrap();
1159 assert_eq!(xhigh, "\"xhigh\"");
1160 }
1161
1162 #[test]
1163 fn test_with_reasoning_effort() {
1164 let provider = OpenAIResponsesProvider::codex("test-key".to_string())
1165 .with_reasoning_effort(ReasoningEffort::High);
1166 let thinking = provider.thinking.as_ref().unwrap();
1167 assert!(matches!(thinking.effort, Some(Effort::High)));
1168 }
1169
1170 #[test]
1171 fn test_build_api_reasoning_uses_explicit_effort() {
1172 let reasoning =
1173 build_api_reasoning(Some(&ThinkingConfig::adaptive_with_effort(Effort::Low))).unwrap();
1174 assert!(matches!(reasoning.effort, ReasoningEffort::Low));
1175 }
1176
1177 #[test]
1178 fn test_build_api_reasoning_omits_adaptive_without_effort() {
1179 assert!(build_api_reasoning(Some(&ThinkingConfig::adaptive())).is_none());
1180 }
1181
1182 #[test]
1183 fn test_openai_responses_rejects_adaptive_thinking() {
1184 let provider = OpenAIResponsesProvider::codex("test-key".to_string());
1185 let error = provider
1186 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
1187 .unwrap_err();
1188 assert!(
1189 error
1190 .to_string()
1191 .contains("adaptive thinking is not supported")
1192 );
1193 }
1194
1195 #[test]
1196 fn test_api_tool_serialization() {
1197 let tool = ApiTool {
1198 r#type: "function".to_owned(),
1199 name: "get_weather".to_owned(),
1200 description: Some("Get weather".to_owned()),
1201 parameters: Some(serde_json::json!({"type": "object"})),
1202 strict: Some(true),
1203 };
1204
1205 let json = serde_json::to_string(&tool).unwrap();
1206 assert!(json.contains("\"type\":\"function\""));
1207 assert!(json.contains("\"name\":\"get_weather\""));
1208 assert!(json.contains("\"strict\":true"));
1209 }
1210
1211 #[test]
1212 fn test_api_response_deserialization() {
1213 let json = r#"{
1214 "id": "resp_123",
1215 "model": "gpt-5.2-codex",
1216 "output": [
1217 {
1218 "type": "message",
1219 "role": "assistant",
1220 "content": [
1221 {"type": "output_text", "text": "Hello!"}
1222 ]
1223 }
1224 ],
1225 "status": "completed",
1226 "usage": {
1227 "input_tokens": 100,
1228 "output_tokens": 50
1229 }
1230 }"#;
1231
1232 let response: ApiResponse = serde_json::from_str(json).unwrap();
1233 assert_eq!(response.id, "resp_123");
1234 assert_eq!(response.model, "gpt-5.2-codex");
1235 assert_eq!(response.output.len(), 1);
1236 }
1237
1238 #[test]
1239 fn test_api_response_with_function_call() {
1240 let json = r#"{
1241 "id": "resp_456",
1242 "model": "gpt-5.2-codex",
1243 "output": [
1244 {
1245 "type": "function_call",
1246 "call_id": "call_abc",
1247 "name": "read_file",
1248 "arguments": "{\"path\": \"test.txt\"}"
1249 }
1250 ],
1251 "status": "completed"
1252 }"#;
1253
1254 let response: ApiResponse = serde_json::from_str(json).unwrap();
1255 assert_eq!(response.output.len(), 1);
1256
1257 match &response.output[0] {
1258 ApiOutputItem::FunctionCall {
1259 call_id,
1260 name,
1261 arguments,
1262 } => {
1263 assert_eq!(call_id, "call_abc");
1264 assert_eq!(name, "read_file");
1265 assert!(arguments.contains("test.txt"));
1266 }
1267 _ => panic!("Expected FunctionCall"),
1268 }
1269 }
1270
1271 #[test]
1272 fn test_build_content_blocks_text() {
1273 let output = vec![ApiOutputItem::Message {
1274 _role: "assistant".to_owned(),
1275 content: vec![ApiOutputContent::Text {
1276 text: "Hello!".to_owned(),
1277 }],
1278 }];
1279
1280 let blocks = build_content_blocks(&output);
1281 assert_eq!(blocks.len(), 1);
1282 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello!"));
1283 }
1284
1285 #[test]
1286 fn test_build_content_blocks_function_call() {
1287 let output = vec![ApiOutputItem::FunctionCall {
1288 call_id: "call_123".to_owned(),
1289 name: "test_tool".to_owned(),
1290 arguments: r#"{"key": "value"}"#.to_owned(),
1291 }];
1292
1293 let blocks = build_content_blocks(&output);
1294 assert_eq!(blocks.len(), 1);
1295 assert!(
1296 matches!(&blocks[0], ContentBlock::ToolUse { id, name, .. } if id == "call_123" && name == "test_tool")
1297 );
1298 }
1299}