1use crate::attachments::validate_request_attachments;
8use crate::provider::LlmProvider;
9use crate::streaming::{SseLineBuffer, StreamBox, StreamDelta, StreamErrorKind};
10use agent_sdk_foundation::llm::{
11 ChatOutcome, ChatRequest, ChatResponse, Content, ContentBlock, Effort, ResponseFormat,
12 StopReason, ThinkingConfig, ThinkingMode, ToolChoice, 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
22fn build_http_client() -> reqwest::Client {
27 reqwest::Client::builder()
28 .connect_timeout(std::time::Duration::from_secs(30))
29 .tcp_keepalive(std::time::Duration::from_secs(30))
30 .build()
31 .unwrap_or_default()
32}
33
34pub const MODEL_GPT53_CODEX: &str = "gpt-5.3-codex";
36
37pub const MODEL_GPT52_CODEX: &str = "gpt-5.2-codex";
39
40#[derive(Clone, Copy, Debug, Default, Serialize)]
42#[serde(rename_all = "lowercase")]
43pub enum ReasoningEffort {
44 Low,
45 #[default]
46 Medium,
47 High,
48 #[serde(rename = "xhigh")]
50 XHigh,
51}
52
53#[derive(Clone)]
58pub struct OpenAIResponsesProvider {
59 client: reqwest::Client,
60 api_key: String,
61 model: String,
62 base_url: String,
63 thinking: Option<ThinkingConfig>,
64 extra_headers: Vec<(String, String)>,
66}
67
68impl OpenAIResponsesProvider {
69 #[must_use]
71 pub fn new(api_key: String, model: String) -> Self {
72 Self {
73 client: build_http_client(),
74 api_key,
75 model,
76 base_url: DEFAULT_BASE_URL.to_owned(),
77 thinking: None,
78 extra_headers: Vec::new(),
79 }
80 }
81
82 #[must_use]
84 pub fn with_base_url(api_key: String, model: String, base_url: String) -> Self {
85 Self {
86 client: build_http_client(),
87 api_key,
88 model,
89 base_url,
90 thinking: None,
91 extra_headers: Vec::new(),
92 }
93 }
94
95 #[must_use]
101 pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
102 self.extra_headers = headers;
103 self
104 }
105
106 #[must_use]
112 pub(crate) fn with_client(mut self, client: reqwest::Client) -> Self {
113 self.client = client;
114 self
115 }
116
117 fn apply_headers(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
121 let builder = if self.api_key.is_empty() {
122 builder
123 } else {
124 builder.header("Authorization", format!("Bearer {}", self.api_key))
125 };
126 self.extra_headers
127 .iter()
128 .fold(builder, |b, (k, v)| b.header(k.as_str(), v.as_str()))
129 }
130
131 #[must_use]
133 pub fn gpt53_codex(api_key: String) -> Self {
134 Self::new(api_key, MODEL_GPT53_CODEX.to_owned())
135 }
136
137 #[must_use]
139 pub fn codex(api_key: String) -> Self {
140 Self::gpt53_codex(api_key)
141 }
142
143 #[must_use]
145 pub const fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
146 self.thinking = Some(thinking);
147 self
148 }
149
150 #[must_use]
152 pub fn with_reasoning_effort(self, effort: ReasoningEffort) -> Self {
153 self.with_thinking(ThinkingConfig::default().with_effort(map_reasoning_effort(effort)))
154 }
155}
156
157#[async_trait]
158impl LlmProvider for OpenAIResponsesProvider {
159 async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
160 let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
161 Ok(thinking) => thinking,
162 Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
163 };
164 if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
165 return Ok(ChatOutcome::InvalidRequest(error.to_string()));
166 }
167 let reasoning = build_api_reasoning(thinking_config.as_ref());
168 let input = build_api_input(&request);
169 let text = request.response_format.as_ref().map(ApiResponseText::from);
170 let tool_choice = request.tool_choice.as_ref().map(ApiToolChoice::from);
171 let tools: Option<Vec<ApiTool>> = request
172 .tools
173 .map(|ts| ts.into_iter().map(convert_tool).collect());
174 let parallel_tool_calls = tools.as_ref().is_some_and(|tools| !tools.is_empty());
175
176 let api_request = ApiResponsesRequest {
177 model: &self.model,
178 input: &input,
179 tools: tools.as_deref(),
180 max_output_tokens: Some(request.max_tokens),
181 reasoning,
182 parallel_tool_calls: parallel_tool_calls.then_some(true),
183 text,
184 tool_choice,
185 };
186
187 log::debug!(
188 "OpenAI Responses API request model={} max_tokens={}",
189 self.model,
190 request.max_tokens
191 );
192
193 let builder = self
194 .client
195 .post(format!("{}/responses", self.base_url))
196 .header("Content-Type", "application/json");
197 let response = self
198 .apply_headers(builder)
199 .json(&api_request)
200 .send()
201 .await
202 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
203
204 let status = response.status();
205 let retry_after = if status == StatusCode::TOO_MANY_REQUESTS {
207 crate::http::retry_after_from_headers(response.headers())
208 } else {
209 None
210 };
211 let bytes = response
212 .bytes()
213 .await
214 .map_err(|e| anyhow::anyhow!("failed to read response body: {e}"))?;
215
216 log::debug!(
217 "OpenAI Responses API response status={} body_len={}",
218 status,
219 bytes.len()
220 );
221
222 if let Some(outcome) = classify_responses_status(status, &bytes, retry_after) {
223 return Ok(outcome);
224 }
225
226 let api_response: ApiResponse = serde_json::from_slice(&bytes)
227 .map_err(|e| anyhow::anyhow!("failed to parse response: {e}"))?;
228
229 Ok(build_responses_outcome(api_response))
230 }
231
232 #[allow(clippy::too_many_lines)]
233 fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
234 Box::pin(async_stream::stream! {
235 let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
236 Ok(thinking) => thinking,
237 Err(error) => {
238 yield Ok(StreamDelta::Error {
239 message: error.to_string(),
240 kind: StreamErrorKind::InvalidRequest,
241 });
242 return;
243 }
244 };
245 if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
246 yield Ok(StreamDelta::Error {
247 message: error.to_string(),
248 kind: StreamErrorKind::InvalidRequest,
249 });
250 return;
251 }
252 let reasoning = build_api_reasoning(thinking_config.as_ref());
253 let input = build_api_input(&request);
254 let text = request.response_format.as_ref().map(ApiResponseText::from);
255 let tool_choice = request.tool_choice.as_ref().map(ApiToolChoice::from);
256 let tools: Option<Vec<ApiTool>> = request
257 .tools
258 .map(|ts| ts.into_iter().map(convert_tool).collect());
259 let parallel_tool_calls = tools.as_ref().is_some_and(|tools| !tools.is_empty());
260
261 let api_request = ApiResponsesRequestStreaming {
262 model: &self.model,
263 input: &input,
264 tools: tools.as_deref(),
265 max_output_tokens: Some(request.max_tokens),
266 reasoning,
267 parallel_tool_calls: parallel_tool_calls.then_some(true),
268 text,
269 tool_choice,
270 stream: true,
271 };
272
273 log::debug!("OpenAI Responses API streaming request model={} max_tokens={}", self.model, request.max_tokens);
274
275 let stream_builder = self.client
276 .post(format!("{}/responses", self.base_url))
277 .header("Content-Type", "application/json");
278 let Ok(response) = self
279 .apply_headers(stream_builder)
280 .json(&api_request)
281 .send()
282 .await
283 else {
284 yield Err(anyhow::anyhow!("request failed"));
285 return;
286 };
287
288 let status = response.status();
289
290 if !status.is_success() {
291 let body = response.text().await.unwrap_or_default();
292 let kind = if status == StatusCode::TOO_MANY_REQUESTS {
293 StreamErrorKind::RateLimited
294 } else if status.is_server_error() {
295 StreamErrorKind::ServerError
296 } else {
297 StreamErrorKind::InvalidRequest
298 };
299 log::warn!("OpenAI Responses error status={status} body={body}");
300 yield Ok(StreamDelta::Error { message: body, kind });
301 return;
302 }
303
304 let mut sse = SseLineBuffer::new();
305 let mut stream = response.bytes_stream();
306 let mut usage: Option<Usage> = None;
307 let mut tool_calls: std::collections::HashMap<String, ToolCallAccumulator> =
308 std::collections::HashMap::new();
309
310 while let Some(chunk_result) = stream.next().await {
311 let Ok(chunk) = chunk_result else {
312 yield Err(anyhow::anyhow!("stream error"));
313 return;
314 };
315 sse.extend(&chunk);
316
317 while let Some(line) = sse.next_line() {
318 let line = line.trim();
319 if line.is_empty() { continue; }
320
321 let Some(data) = line.strip_prefix("data: ") else {
322 log::trace!("Responses SSE non-data line: {line}");
323 continue;
324 };
325 if log::log_enabled!(log::Level::Trace) {
326 let truncated: String = data.chars().take(200).collect();
327 log::trace!("Responses SSE data: {truncated}");
328 }
329
330 if data == "[DONE]" {
331 for delta in flush_responses_tool_calls(&tool_calls) {
334 yield Ok(delta);
335 }
336
337 if let Some(u) = usage.take() {
338 yield Ok(StreamDelta::Usage(u));
339 }
340
341 let stop_reason = if tool_calls.is_empty() {
342 StopReason::EndTurn
343 } else {
344 StopReason::ToolUse
345 };
346 yield Ok(StreamDelta::Done { stop_reason: Some(stop_reason) });
347 return;
348 }
349
350 let parse_result = serde_json::from_str::<ApiStreamEvent>(data);
352 if parse_result.is_err() {
353 log::debug!("Failed to parse Responses SSE event: {data}");
354 }
355 if let Ok(event) = parse_result {
356 match event.r#type.as_str() {
357 "response.output_text.delta" => {
359 if let Some(delta) = event.delta {
360 yield Ok(StreamDelta::TextDelta { delta, block_index: 0 });
361 }
362 }
363 "response.output_item.added" => {
364 if let Some(item) = &event.item
367 && item.r#type.as_deref() == Some("function_call")
368 && let (Some(item_id), Some(call_id), Some(name)) =
369 (&item.id, &item.call_id, &item.name)
370 {
371 let order = tool_calls.len();
372 tool_calls
373 .entry(item_id.clone())
374 .or_insert_with(|| ToolCallAccumulator {
375 id: call_id.clone(),
376 name: name.clone(),
377 arguments: String::new(),
378 order,
379 });
380 }
381 }
382 "response.function_call_arguments.delta" => {
383 if let (Some(item_id), Some(delta)) =
384 (event.resolve_item_id().map(str::to_owned), event.delta)
385 {
386 let order = tool_calls.len();
387 let acc =
388 tool_calls.entry(item_id.clone()).or_insert_with(|| {
389 ToolCallAccumulator {
390 id: item_id,
391 name: event.name.unwrap_or_default(),
392 arguments: String::new(),
393 order,
394 }
395 });
396 acc.arguments.push_str(&delta);
397 }
398 }
399 "response.reasoning.delta" => {
401 if let Some(delta) = event.delta {
402 yield Ok(StreamDelta::ThinkingDelta {
403 delta,
404 block_index: 0,
405 });
406 }
407 }
408 "response.completed" => {
410 if let Some(resp) = event.response
411 && let Some(u) = resp.usage
412 {
413 usage = Some(Usage {
414 input_tokens: u.input_tokens,
415 output_tokens: u.output_tokens,
416 cached_input_tokens: u
417 .input_tokens_details
418 .as_ref()
419 .map_or(0, |details| details.cached_tokens),
420 cache_creation_input_tokens: 0,
421 });
422 }
423 }
424 "error" | "response.failed" => {
426 let is_server_error = data.contains("server_error");
427 let kind = if is_server_error {
428 log::warn!("Responses API server error (recoverable): {data}");
429 StreamErrorKind::ServerError
430 } else {
431 log::error!("Responses API error event: {data}");
432 StreamErrorKind::InvalidRequest
433 };
434 yield Ok(StreamDelta::Error {
435 message: data.to_owned(),
436 kind,
437 });
438 return;
439 }
440 "response.created"
442 | "response.in_progress"
443 | "response.output_item.done"
444 | "response.content_part.added"
445 | "response.content_part.done"
446 | "response.output_text.done"
447 | "response.function_call_arguments.done"
448 | "response.reasoning.done"
449 | "response.reasoning_summary_text.delta"
450 | "response.reasoning_summary_text.done" => {}
451 other => {
453 log::debug!("Unhandled Responses SSE event type: {other}");
454 }
455 }
456 }
457 }
458 }
459
460 for delta in flush_responses_tool_calls(&tool_calls) {
463 yield Ok(delta);
464 }
465
466 if let Some(u) = usage {
467 yield Ok(StreamDelta::Usage(u));
468 }
469
470 let stop_reason = if tool_calls.is_empty() {
471 StopReason::EndTurn
472 } else {
473 StopReason::ToolUse
474 };
475 yield Ok(StreamDelta::Done { stop_reason: Some(stop_reason) });
476 })
477 }
478
479 fn model(&self) -> &str {
480 &self.model
481 }
482
483 fn provider(&self) -> &'static str {
484 "openai-responses"
485 }
486
487 fn configured_thinking(&self) -> Option<&ThinkingConfig> {
488 self.thinking.as_ref()
489 }
490}
491
492fn build_api_input(request: &ChatRequest) -> Vec<ApiInputItem> {
497 let mut items = Vec::new();
498
499 if !request.system.is_empty() {
501 items.push(ApiInputItem::Message(ApiMessage {
502 role: ApiRole::System,
503 content: ApiMessageContent::Text(request.system.clone()),
504 }));
505 }
506
507 for msg in &request.messages {
509 match &msg.content {
510 Content::Text(text) => {
511 items.push(ApiInputItem::Message(ApiMessage {
512 role: match msg.role {
513 agent_sdk_foundation::llm::Role::User => ApiRole::User,
514 agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
515 },
516 content: ApiMessageContent::Text(text.clone()),
517 }));
518 }
519 Content::Blocks(blocks) => {
520 let mut content_parts = Vec::new();
521
522 for block in blocks {
523 match block {
524 ContentBlock::Text { text } => {
525 let part = match msg.role {
526 agent_sdk_foundation::llm::Role::Assistant => {
527 ApiInputContent::OutputText { text: text.clone() }
528 }
529 agent_sdk_foundation::llm::Role::User => {
530 ApiInputContent::InputText { text: text.clone() }
531 }
532 };
533 content_parts.push(part);
534 }
535 ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => {}
536 ContentBlock::Image { source } => {
537 content_parts.push(ApiInputContent::Image {
538 image_url: format!(
539 "data:{};base64,{}",
540 source.media_type, source.data
541 ),
542 });
543 }
544 ContentBlock::Document { source } => {
545 content_parts.push(ApiInputContent::File {
546 filename: suggested_filename(&source.media_type),
547 file_data: format!(
548 "data:{};base64,{}",
549 source.media_type, source.data
550 ),
551 });
552 }
553 ContentBlock::ToolUse {
554 id, name, input, ..
555 } => {
556 items.push(ApiInputItem::FunctionCall(ApiFunctionCall::new(
557 id.clone(),
558 name.clone(),
559 serde_json::to_string(input).unwrap_or_default(),
560 )));
561 }
562 ContentBlock::ToolResult {
563 tool_use_id,
564 content,
565 ..
566 } => {
567 items.push(ApiInputItem::FunctionCallOutput(
568 ApiFunctionCallOutput::new(tool_use_id.clone(), content.clone()),
569 ));
570 }
571 _ => {
574 log::warn!("Skipping unrecognized OpenAI Responses content block");
575 }
576 }
577 }
578
579 if !content_parts.is_empty() {
580 items.push(ApiInputItem::Message(ApiMessage {
581 role: match msg.role {
582 agent_sdk_foundation::llm::Role::User => ApiRole::User,
583 agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
584 },
585 content: ApiMessageContent::Parts(content_parts),
586 }));
587 }
588 }
589 }
590 }
591
592 items
593}
594
595fn fix_schema_for_strict_mode(schema: &mut serde_json::Value) {
598 let Some(obj) = schema.as_object_mut() else {
599 return;
600 };
601
602 let is_object_type = obj
604 .get("type")
605 .is_some_and(|t| t.as_str() == Some("object"));
606
607 if is_object_type {
608 obj.insert(
610 "additionalProperties".to_owned(),
611 serde_json::Value::Bool(false),
612 );
613
614 obj.entry("properties".to_owned())
616 .or_insert_with(|| serde_json::json!({}));
617 obj.entry("required".to_owned())
618 .or_insert_with(|| serde_json::json!([]));
619
620 let originally_required: std::collections::HashSet<String> = obj
622 .get("required")
623 .and_then(|v| v.as_array())
624 .map(|arr| {
625 arr.iter()
626 .filter_map(|v| v.as_str().map(String::from))
627 .collect()
628 })
629 .unwrap_or_default();
630
631 if let Some(serde_json::Value::Object(props)) = obj.get_mut("properties") {
633 for (key, prop_schema) in props.iter_mut() {
634 if !originally_required.contains(key) {
635 make_nullable(prop_schema);
636 }
637 }
638 }
639
640 if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
642 let all_keys: Vec<serde_json::Value> = props
643 .keys()
644 .map(|k| serde_json::Value::String(k.clone()))
645 .collect();
646 obj.insert("required".to_owned(), serde_json::Value::Array(all_keys));
647 }
648 }
649
650 if let Some(props) = obj.get_mut("properties")
652 && let Some(props_obj) = props.as_object_mut()
653 {
654 for prop_schema in props_obj.values_mut() {
655 fix_schema_for_strict_mode(prop_schema);
656 }
657 }
658
659 if let Some(items) = obj.get_mut("items") {
661 fix_schema_for_strict_mode(items);
662 }
663
664 for key in ["anyOf", "oneOf", "allOf"] {
666 if let Some(arr) = obj.get_mut(key)
667 && let Some(arr_items) = arr.as_array_mut()
668 {
669 for item in arr_items {
670 fix_schema_for_strict_mode(item);
671 }
672 }
673 }
674}
675
676fn convert_tool(tool: agent_sdk_foundation::llm::Tool) -> ApiTool {
677 let mut schema = tool.input_schema;
678
679 let use_strict = if has_freeform_object(&schema) {
684 log::debug!(
685 "Tool '{}' has free-form object schema — disabling strict mode",
686 tool.name
687 );
688 None
689 } else {
690 fix_schema_for_strict_mode(&mut schema);
691 Some(true)
692 };
693
694 ApiTool {
695 r#type: "function".to_owned(),
696 name: tool.name,
697 description: Some(tool.description),
698 parameters: Some(schema),
699 strict: use_strict,
700 }
701}
702
703fn make_nullable(schema: &mut serde_json::Value) {
711 if let Some(any_of) = schema
713 .as_object_mut()
714 .and_then(|o| o.get_mut("anyOf"))
715 .and_then(|v| v.as_array_mut())
716 {
717 let has_null = any_of
718 .iter()
719 .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
720 if !has_null {
721 any_of.push(serde_json::json!({"type": "null"}));
722 }
723 return;
724 }
725
726 let original = schema.clone();
728 *schema = serde_json::json!({
729 "anyOf": [original, {"type": "null"}]
730 });
731}
732
733fn has_freeform_object(schema: &serde_json::Value) -> bool {
734 let Some(obj) = schema.as_object() else {
735 return false;
736 };
737
738 let is_object = obj
739 .get("type")
740 .is_some_and(|t| t.as_str() == Some("object"));
741
742 if is_object && !obj.contains_key("properties") {
743 return true;
744 }
745
746 if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
748 for prop in props.values() {
749 if has_freeform_object(prop) {
750 return true;
751 }
752 }
753 }
754
755 if let Some(items) = obj.get("items")
757 && has_freeform_object(items)
758 {
759 return true;
760 }
761
762 for key in ["anyOf", "oneOf", "allOf"] {
764 if let Some(arr) = obj.get(key).and_then(|v| v.as_array()) {
765 for item in arr {
766 if has_freeform_object(item) {
767 return true;
768 }
769 }
770 }
771 }
772
773 false
774}
775
776fn suggested_filename(media_type: &str) -> String {
777 match media_type {
778 "application/pdf" => "attachment.pdf".to_string(),
779 "image/png" => "image.png".to_string(),
780 "image/jpeg" => "image.jpg".to_string(),
781 "image/gif" => "image.gif".to_string(),
782 "image/webp" => "image.webp".to_string(),
783 _ => "attachment.bin".to_string(),
784 }
785}
786
787fn build_content_blocks(output: &[ApiOutputItem]) -> Vec<ContentBlock> {
788 let mut blocks = Vec::new();
789
790 for item in output {
791 match item {
792 ApiOutputItem::Message { content, .. } => {
793 for c in content {
794 if let ApiOutputContent::Text { text } = c
795 && !text.is_empty()
796 {
797 blocks.push(ContentBlock::Text { text: text.clone() });
798 }
799 }
800 }
801 ApiOutputItem::FunctionCall {
802 call_id,
803 name,
804 arguments,
805 ..
806 } => {
807 let input =
808 serde_json::from_str(arguments).unwrap_or_else(|_| serde_json::json!({}));
809 blocks.push(ContentBlock::ToolUse {
810 id: call_id.clone(),
811 name: name.clone(),
812 input,
813 thought_signature: None,
814 });
815 }
816 ApiOutputItem::Unknown => {
817 }
819 }
820 }
821
822 blocks
823}
824
825fn classify_responses_status(
830 status: StatusCode,
831 bytes: &[u8],
832 retry_after: Option<std::time::Duration>,
833) -> Option<ChatOutcome> {
834 if status == StatusCode::TOO_MANY_REQUESTS {
835 return Some(ChatOutcome::RateLimited(retry_after));
836 }
837 if status.is_server_error() {
838 let body = String::from_utf8_lossy(bytes);
839 log::error!("OpenAI Responses server error status={status} body={body}");
840 return Some(ChatOutcome::ServerError(body.into_owned()));
841 }
842 if status.is_client_error() {
843 let body = String::from_utf8_lossy(bytes);
844 log::warn!("OpenAI Responses client error status={status} body={body}");
845 return Some(ChatOutcome::InvalidRequest(body.into_owned()));
846 }
847 None
848}
849
850fn build_responses_outcome(api_response: ApiResponse) -> ChatOutcome {
857 if matches!(api_response.status, Some(ApiStatus::Failed)) {
858 let message = api_response
859 .error
860 .and_then(|error| error.message)
861 .unwrap_or_else(|| "OpenAI Responses API reported status=failed".to_owned());
862 log::error!("OpenAI Responses generation failed: {message}");
863 return ChatOutcome::ServerError(message);
864 }
865
866 let content = build_content_blocks(&api_response.output);
867
868 let has_tool_calls = content
870 .iter()
871 .any(|b| matches!(b, ContentBlock::ToolUse { .. }));
872
873 let stop_reason = if has_tool_calls {
874 Some(StopReason::ToolUse)
875 } else {
876 api_response.status.map(|s| match s {
877 ApiStatus::Completed => StopReason::EndTurn,
878 ApiStatus::Incomplete => StopReason::MaxTokens,
879 ApiStatus::Failed => StopReason::StopSequence,
881 })
882 };
883
884 ChatOutcome::Success(ChatResponse {
885 id: api_response.id,
886 content,
887 model: api_response.model,
888 stop_reason,
889 usage: map_usage(api_response.usage),
890 })
891}
892
893fn map_usage(usage: Option<ApiUsage>) -> Usage {
895 usage.map_or(
896 Usage {
897 input_tokens: 0,
898 output_tokens: 0,
899 cached_input_tokens: 0,
900 cache_creation_input_tokens: 0,
901 },
902 |u| Usage {
903 input_tokens: u.input_tokens,
904 output_tokens: u.output_tokens,
905 cached_input_tokens: u
906 .input_tokens_details
907 .as_ref()
908 .map_or(0, |details| details.cached_tokens),
909 cache_creation_input_tokens: 0,
910 },
911 )
912}
913
914fn build_api_reasoning(thinking: Option<&ThinkingConfig>) -> Option<ApiReasoning> {
915 thinking
916 .and_then(resolve_reasoning_effort)
917 .map(|effort| ApiReasoning { effort })
918}
919
920const fn resolve_reasoning_effort(config: &ThinkingConfig) -> Option<ReasoningEffort> {
921 if let Some(effort) = config.effort {
922 return Some(map_effort(effort));
923 }
924
925 match &config.mode {
926 ThinkingMode::Adaptive => None,
927 ThinkingMode::Enabled { budget_tokens } => Some(map_budget_to_reasoning(*budget_tokens)),
928 }
929}
930
931const fn map_effort(effort: Effort) -> ReasoningEffort {
932 match effort {
933 Effort::Low => ReasoningEffort::Low,
934 Effort::Medium => ReasoningEffort::Medium,
935 Effort::High => ReasoningEffort::High,
936 Effort::Max => ReasoningEffort::XHigh,
937 }
938}
939
940const fn map_reasoning_effort(effort: ReasoningEffort) -> Effort {
941 match effort {
942 ReasoningEffort::Low => Effort::Low,
943 ReasoningEffort::Medium => Effort::Medium,
944 ReasoningEffort::High => Effort::High,
945 ReasoningEffort::XHigh => Effort::Max,
946 }
947}
948
949const fn map_budget_to_reasoning(budget_tokens: u32) -> ReasoningEffort {
950 if budget_tokens <= 4_096 {
951 ReasoningEffort::Low
952 } else if budget_tokens <= 16_384 {
953 ReasoningEffort::Medium
954 } else if budget_tokens <= 32_768 {
955 ReasoningEffort::High
956 } else {
957 ReasoningEffort::XHigh
958 }
959}
960
961struct ToolCallAccumulator {
966 id: String,
967 name: String,
968 arguments: String,
969 order: usize,
972}
973
974fn flush_responses_tool_calls(
983 tool_calls: &std::collections::HashMap<String, ToolCallAccumulator>,
984) -> Vec<StreamDelta> {
985 let mut accs: Vec<&ToolCallAccumulator> = tool_calls.values().collect();
986 accs.sort_by_key(|acc| acc.order);
987
988 let mut deltas = Vec::with_capacity(accs.len() * 2);
989 for (idx, acc) in accs.iter().enumerate() {
990 let block_index = idx + 1;
991 deltas.push(StreamDelta::ToolUseStart {
992 id: acc.id.clone(),
993 name: acc.name.clone(),
994 block_index,
995 thought_signature: None,
996 });
997 deltas.push(StreamDelta::ToolInputDelta {
998 id: acc.id.clone(),
999 delta: acc.arguments.clone(),
1000 block_index,
1001 });
1002 }
1003 deltas
1004}
1005
1006#[derive(Serialize)]
1011struct ApiResponsesRequest<'a> {
1012 model: &'a str,
1013 input: &'a [ApiInputItem],
1014 #[serde(skip_serializing_if = "Option::is_none")]
1015 tools: Option<&'a [ApiTool]>,
1016 #[serde(skip_serializing_if = "Option::is_none")]
1017 max_output_tokens: Option<u32>,
1018 #[serde(skip_serializing_if = "Option::is_none")]
1019 reasoning: Option<ApiReasoning>,
1020 #[serde(skip_serializing_if = "Option::is_none")]
1021 parallel_tool_calls: Option<bool>,
1022 #[serde(skip_serializing_if = "Option::is_none")]
1023 text: Option<ApiResponseText>,
1024 #[serde(skip_serializing_if = "Option::is_none")]
1025 tool_choice: Option<ApiToolChoice>,
1026}
1027
1028#[derive(Serialize)]
1029struct ApiResponsesRequestStreaming<'a> {
1030 model: &'a str,
1031 input: &'a [ApiInputItem],
1032 #[serde(skip_serializing_if = "Option::is_none")]
1033 tools: Option<&'a [ApiTool]>,
1034 #[serde(skip_serializing_if = "Option::is_none")]
1035 max_output_tokens: Option<u32>,
1036 #[serde(skip_serializing_if = "Option::is_none")]
1037 reasoning: Option<ApiReasoning>,
1038 #[serde(skip_serializing_if = "Option::is_none")]
1039 parallel_tool_calls: Option<bool>,
1040 #[serde(skip_serializing_if = "Option::is_none")]
1041 text: Option<ApiResponseText>,
1042 #[serde(skip_serializing_if = "Option::is_none")]
1043 tool_choice: Option<ApiToolChoice>,
1044 stream: bool,
1045}
1046
1047#[derive(Serialize)]
1048struct ApiReasoning {
1049 effort: ReasoningEffort,
1050}
1051
1052#[derive(Serialize)]
1058struct ApiResponseText {
1059 format: ApiResponseTextFormat,
1060}
1061
1062#[derive(Serialize)]
1063struct ApiResponseTextFormat {
1064 #[serde(rename = "type")]
1065 format_type: &'static str,
1066 name: String,
1067 schema: serde_json::Value,
1068 strict: bool,
1069}
1070
1071impl From<&ResponseFormat> for ApiResponseText {
1072 fn from(rf: &ResponseFormat) -> Self {
1073 Self {
1074 format: ApiResponseTextFormat {
1075 format_type: "json_schema",
1076 name: rf.name.clone(),
1077 schema: rf.schema.clone(),
1078 strict: rf.strict,
1079 },
1080 }
1081 }
1082}
1083
1084#[derive(Serialize)]
1089#[serde(untagged)]
1090enum ApiToolChoice {
1091 Mode(&'static str),
1092 Function {
1093 #[serde(rename = "type")]
1094 choice_type: &'static str,
1095 name: String,
1096 },
1097}
1098
1099impl From<&ToolChoice> for ApiToolChoice {
1100 fn from(tc: &ToolChoice) -> Self {
1101 match tc {
1102 ToolChoice::Auto => Self::Mode("auto"),
1103 ToolChoice::Tool(name) => Self::Function {
1104 choice_type: "function",
1105 name: name.clone(),
1106 },
1107 }
1108 }
1109}
1110
1111#[derive(Serialize)]
1112#[serde(untagged)]
1113enum ApiInputItem {
1114 Message(ApiMessage),
1115 FunctionCall(ApiFunctionCall),
1116 FunctionCallOutput(ApiFunctionCallOutput),
1117}
1118
1119#[derive(Serialize)]
1120struct ApiMessage {
1121 role: ApiRole,
1122 content: ApiMessageContent,
1123}
1124
1125#[derive(Serialize)]
1126#[serde(rename_all = "lowercase")]
1127enum ApiRole {
1128 System,
1129 User,
1130 Assistant,
1131}
1132
1133#[derive(Serialize)]
1134#[serde(untagged)]
1135enum ApiMessageContent {
1136 Text(String),
1137 Parts(Vec<ApiInputContent>),
1138}
1139
1140#[derive(Serialize)]
1141#[serde(tag = "type")]
1142enum ApiInputContent {
1143 #[serde(rename = "input_text")]
1144 InputText { text: String },
1145 #[serde(rename = "output_text")]
1146 OutputText { text: String },
1147 #[serde(rename = "input_image")]
1148 Image { image_url: String },
1149 #[serde(rename = "input_file")]
1150 File { filename: String, file_data: String },
1151}
1152
1153#[derive(Serialize)]
1154struct ApiFunctionCall {
1155 r#type: &'static str,
1156 call_id: String,
1157 name: String,
1158 arguments: String,
1159}
1160
1161impl ApiFunctionCall {
1162 const fn new(call_id: String, name: String, arguments: String) -> Self {
1163 Self {
1164 r#type: "function_call",
1165 call_id,
1166 name,
1167 arguments,
1168 }
1169 }
1170}
1171
1172#[derive(Serialize)]
1173struct ApiFunctionCallOutput {
1174 r#type: &'static str,
1175 call_id: String,
1176 output: String,
1177}
1178
1179impl ApiFunctionCallOutput {
1180 const fn new(call_id: String, output: String) -> Self {
1181 Self {
1182 r#type: "function_call_output",
1183 call_id,
1184 output,
1185 }
1186 }
1187}
1188
1189#[derive(Serialize)]
1190struct ApiTool {
1191 r#type: String,
1192 name: String,
1193 #[serde(skip_serializing_if = "Option::is_none")]
1194 description: Option<String>,
1195 #[serde(skip_serializing_if = "Option::is_none")]
1196 parameters: Option<serde_json::Value>,
1197 #[serde(skip_serializing_if = "Option::is_none")]
1198 strict: Option<bool>,
1199}
1200
1201#[derive(Deserialize)]
1206struct ApiResponse {
1207 id: String,
1208 model: String,
1209 output: Vec<ApiOutputItem>,
1210 #[serde(default)]
1211 status: Option<ApiStatus>,
1212 #[serde(default)]
1213 usage: Option<ApiUsage>,
1214 #[serde(default)]
1215 error: Option<ApiResponseError>,
1216}
1217
1218#[derive(Deserialize)]
1219struct ApiResponseError {
1220 #[serde(default)]
1221 message: Option<String>,
1222}
1223
1224#[derive(Deserialize)]
1225#[serde(rename_all = "snake_case")]
1226enum ApiStatus {
1227 Completed,
1228 Incomplete,
1229 Failed,
1230}
1231
1232#[derive(Deserialize)]
1233struct ApiUsage {
1234 input_tokens: u32,
1235 output_tokens: u32,
1236 #[serde(default)]
1237 input_tokens_details: Option<ApiInputTokensDetails>,
1238}
1239
1240#[derive(Deserialize)]
1241struct ApiInputTokensDetails {
1242 #[serde(default)]
1243 cached_tokens: u32,
1244}
1245
1246#[derive(Deserialize)]
1247#[serde(tag = "type")]
1248enum ApiOutputItem {
1249 #[serde(rename = "message")]
1250 Message {
1251 #[serde(rename = "role")]
1252 _role: String,
1253 content: Vec<ApiOutputContent>,
1254 },
1255 #[serde(rename = "function_call")]
1256 FunctionCall {
1257 call_id: String,
1258 name: String,
1259 arguments: String,
1260 },
1261 #[serde(other)]
1262 Unknown,
1263}
1264
1265#[derive(Deserialize)]
1266#[serde(tag = "type")]
1267enum ApiOutputContent {
1268 #[serde(rename = "output_text")]
1269 Text { text: String },
1270 #[serde(other)]
1271 Unknown,
1272}
1273
1274#[derive(Deserialize)]
1279struct ApiStreamEvent {
1280 r#type: String,
1281 #[serde(default)]
1282 delta: Option<String>,
1283 #[serde(default)]
1285 item: Option<ApiStreamItem>,
1286 #[serde(default)]
1288 item_id: Option<String>,
1289 #[serde(default)]
1291 call_id: Option<String>,
1292 #[serde(default)]
1293 name: Option<String>,
1294 #[serde(default)]
1295 response: Option<ApiStreamResponse>,
1296}
1297
1298impl ApiStreamEvent {
1299 fn resolve_item_id(&self) -> Option<&str> {
1301 self.item_id
1302 .as_deref()
1303 .or(self.call_id.as_deref())
1304 .or_else(|| self.item.as_ref().and_then(|i| i.id.as_deref()))
1305 }
1306}
1307
1308#[derive(Deserialize)]
1309struct ApiStreamItem {
1310 #[serde(default)]
1311 id: Option<String>,
1312 #[serde(default)]
1313 r#type: Option<String>,
1314 #[serde(default)]
1315 call_id: Option<String>,
1316 #[serde(default)]
1317 name: Option<String>,
1318}
1319
1320#[derive(Deserialize)]
1321struct ApiStreamResponse {
1322 #[serde(default)]
1323 usage: Option<ApiUsage>,
1324}
1325
1326#[cfg(test)]
1331mod tests {
1332 use super::*;
1333
1334 #[test]
1335 fn test_model_constant() {
1336 assert_eq!(MODEL_GPT53_CODEX, "gpt-5.3-codex");
1337 assert_eq!(MODEL_GPT52_CODEX, "gpt-5.2-codex");
1338 }
1339
1340 #[test]
1341 fn test_codex_factory() {
1342 let provider = OpenAIResponsesProvider::codex("test-key".to_string());
1343 assert_eq!(provider.model(), MODEL_GPT53_CODEX);
1344 assert_eq!(provider.provider(), "openai-responses");
1345 }
1346
1347 #[test]
1348 fn test_gpt53_codex_factory() {
1349 let provider = OpenAIResponsesProvider::gpt53_codex("test-key".to_string());
1350 assert_eq!(provider.model(), MODEL_GPT53_CODEX);
1351 assert_eq!(provider.provider(), "openai-responses");
1352 }
1353
1354 #[test]
1355 fn test_reasoning_effort_serialization() {
1356 let low = serde_json::to_string(&ReasoningEffort::Low).unwrap();
1357 assert_eq!(low, "\"low\"");
1358
1359 let xhigh = serde_json::to_string(&ReasoningEffort::XHigh).unwrap();
1360 assert_eq!(xhigh, "\"xhigh\"");
1361 }
1362
1363 #[test]
1364 fn test_with_reasoning_effort() {
1365 let provider = OpenAIResponsesProvider::codex("test-key".to_string())
1366 .with_reasoning_effort(ReasoningEffort::High);
1367 let thinking = provider.thinking.as_ref().unwrap();
1368 assert!(matches!(thinking.effort, Some(Effort::High)));
1369 }
1370
1371 #[test]
1372 fn test_build_api_reasoning_uses_explicit_effort() {
1373 let reasoning =
1374 build_api_reasoning(Some(&ThinkingConfig::adaptive_with_effort(Effort::Low))).unwrap();
1375 assert!(matches!(reasoning.effort, ReasoningEffort::Low));
1376 }
1377
1378 #[test]
1379 fn test_build_api_reasoning_omits_adaptive_without_effort() {
1380 assert!(build_api_reasoning(Some(&ThinkingConfig::adaptive())).is_none());
1381 }
1382
1383 #[test]
1384 fn test_openai_responses_rejects_adaptive_thinking() {
1385 let provider = OpenAIResponsesProvider::codex("test-key".to_string());
1386 let error = provider
1387 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
1388 .unwrap_err();
1389 assert!(
1390 error
1391 .to_string()
1392 .contains("adaptive thinking is not supported")
1393 );
1394 }
1395
1396 #[test]
1397 fn test_api_tool_serialization() {
1398 let tool = ApiTool {
1399 r#type: "function".to_owned(),
1400 name: "get_weather".to_owned(),
1401 description: Some("Get weather".to_owned()),
1402 parameters: Some(serde_json::json!({"type": "object"})),
1403 strict: Some(true),
1404 };
1405
1406 let json = serde_json::to_string(&tool).unwrap();
1407 assert!(json.contains("\"type\":\"function\""));
1408 assert!(json.contains("\"name\":\"get_weather\""));
1409 assert!(json.contains("\"strict\":true"));
1410 }
1411
1412 #[test]
1413 fn test_api_response_deserialization() {
1414 let json = r#"{
1415 "id": "resp_123",
1416 "model": "gpt-5.2-codex",
1417 "output": [
1418 {
1419 "type": "message",
1420 "role": "assistant",
1421 "content": [
1422 {"type": "output_text", "text": "Hello!"}
1423 ]
1424 }
1425 ],
1426 "status": "completed",
1427 "usage": {
1428 "input_tokens": 100,
1429 "output_tokens": 50
1430 }
1431 }"#;
1432
1433 let response: ApiResponse = serde_json::from_str(json).unwrap();
1434 assert_eq!(response.id, "resp_123");
1435 assert_eq!(response.model, "gpt-5.2-codex");
1436 assert_eq!(response.output.len(), 1);
1437 }
1438
1439 #[test]
1440 fn test_api_response_with_function_call() {
1441 let json = r#"{
1442 "id": "resp_456",
1443 "model": "gpt-5.2-codex",
1444 "output": [
1445 {
1446 "type": "function_call",
1447 "call_id": "call_abc",
1448 "name": "read_file",
1449 "arguments": "{\"path\": \"test.txt\"}"
1450 }
1451 ],
1452 "status": "completed"
1453 }"#;
1454
1455 let response: ApiResponse = serde_json::from_str(json).unwrap();
1456 assert_eq!(response.output.len(), 1);
1457
1458 match &response.output[0] {
1459 ApiOutputItem::FunctionCall {
1460 call_id,
1461 name,
1462 arguments,
1463 } => {
1464 assert_eq!(call_id, "call_abc");
1465 assert_eq!(name, "read_file");
1466 assert!(arguments.contains("test.txt"));
1467 }
1468 _ => panic!("Expected FunctionCall"),
1469 }
1470 }
1471
1472 #[test]
1473 fn test_build_content_blocks_text() {
1474 let output = vec![ApiOutputItem::Message {
1475 _role: "assistant".to_owned(),
1476 content: vec![ApiOutputContent::Text {
1477 text: "Hello!".to_owned(),
1478 }],
1479 }];
1480
1481 let blocks = build_content_blocks(&output);
1482 assert_eq!(blocks.len(), 1);
1483 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello!"));
1484 }
1485
1486 #[test]
1487 fn test_build_content_blocks_function_call() {
1488 let output = vec![ApiOutputItem::FunctionCall {
1489 call_id: "call_123".to_owned(),
1490 name: "test_tool".to_owned(),
1491 arguments: r#"{"key": "value"}"#.to_owned(),
1492 }];
1493
1494 let blocks = build_content_blocks(&output);
1495 assert_eq!(blocks.len(), 1);
1496 assert!(
1497 matches!(&blocks[0], ContentBlock::ToolUse { id, name, .. } if id == "call_123" && name == "test_tool")
1498 );
1499 }
1500
1501 #[test]
1502 fn test_request_serializes_response_format_as_text_format_and_forced_tool_choice() {
1503 let req = ApiResponsesRequest {
1504 model: "gpt-5.3-codex",
1505 input: &[],
1506 tools: None,
1507 max_output_tokens: Some(1024),
1508 reasoning: None,
1509 parallel_tool_calls: None,
1510 text: Some(ApiResponseText::from(&ResponseFormat::new(
1511 "person",
1512 serde_json::json!({"type": "object"}),
1513 ))),
1514 tool_choice: Some(ApiToolChoice::from(&ToolChoice::Tool("respond".to_owned()))),
1515 };
1516
1517 let json = serde_json::to_value(&req).unwrap();
1518 assert_eq!(json["text"]["format"]["type"], "json_schema");
1519 assert_eq!(json["text"]["format"]["name"], "person");
1520 assert_eq!(json["text"]["format"]["strict"], true);
1521 assert_eq!(json["text"]["format"]["schema"]["type"], "object");
1522 assert_eq!(json["tool_choice"]["type"], "function");
1523 assert_eq!(json["tool_choice"]["name"], "respond");
1524 }
1525
1526 #[test]
1527 fn test_tool_choice_auto_serializes_as_string() {
1528 let json = serde_json::to_value(ApiToolChoice::from(&ToolChoice::Auto)).unwrap();
1529 assert_eq!(json, serde_json::json!("auto"));
1530 }
1531
1532 #[test]
1533 fn test_api_response_failed_status_carries_error_message() {
1534 let json = r#"{
1535 "id": "resp_fail",
1536 "model": "gpt-5.3-codex",
1537 "output": [],
1538 "status": "failed",
1539 "error": {"message": "model produced no output"}
1540 }"#;
1541
1542 let resp: ApiResponse = serde_json::from_str(json).unwrap();
1543 assert!(matches!(resp.status, Some(ApiStatus::Failed)));
1544 assert_eq!(
1545 resp.error.and_then(|e| e.message).as_deref(),
1546 Some("model produced no output")
1547 );
1548 }
1549
1550 #[test]
1551 fn test_flush_responses_tool_calls_assigns_distinct_ordered_indices() {
1552 let mut tool_calls = std::collections::HashMap::new();
1553 tool_calls.insert(
1554 "b".to_owned(),
1555 ToolCallAccumulator {
1556 id: "b".to_owned(),
1557 name: "second".to_owned(),
1558 arguments: "{}".to_owned(),
1559 order: 1,
1560 },
1561 );
1562 tool_calls.insert(
1563 "a".to_owned(),
1564 ToolCallAccumulator {
1565 id: "a".to_owned(),
1566 name: "first".to_owned(),
1567 arguments: "{}".to_owned(),
1568 order: 0,
1569 },
1570 );
1571
1572 let deltas = flush_responses_tool_calls(&tool_calls);
1573 let starts: Vec<(String, usize)> = deltas
1574 .iter()
1575 .filter_map(|d| match d {
1576 StreamDelta::ToolUseStart {
1577 name, block_index, ..
1578 } => Some((name.clone(), *block_index)),
1579 _ => None,
1580 })
1581 .collect();
1582 assert_eq!(
1583 starts,
1584 vec![("first".to_owned(), 1), ("second".to_owned(), 2)]
1585 );
1586 }
1587}