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