1use bytes::Bytes;
11use futures::{Stream, StreamExt};
12use reqwest::Client;
13use serde::Deserialize;
14use serde_json::Value as JsonValue;
15use std::future::Future;
16use std::pin::Pin;
17use std::sync::Arc;
18
19use crate::{
20 Api, AssistantMessage, ContentBlock, Context, Model, Provider, ProviderEvent, StopReason,
21 StreamOptions, StreamResult, Usage, error::ProviderError,
22};
23
24use super::shared_client;
25
26#[derive(Clone)]
28pub struct OpenAiResponsesProvider {
29 client: &'static Client,
30 api_key: Option<String>,
31 base_url: Option<String>,
32}
33
34impl OpenAiResponsesProvider {
35 pub fn new() -> Self {
39 Self {
40 client: shared_client(),
41 api_key: None,
42 base_url: None,
43 }
44 }
45
46 pub fn with_api_key(api_key: impl Into<String>) -> Self {
48 Self {
49 client: shared_client(),
50 api_key: Some(api_key.into()),
51 base_url: None,
52 }
53 }
54
55 pub fn with_base_url_and_key(base_url: &str, api_key: Option<String>) -> Self {
59 Self {
60 client: shared_client(),
61 api_key,
62 base_url: Some(base_url.to_string()),
63 }
64 }
65}
66
67impl Default for OpenAiResponsesProvider {
68 fn default() -> Self {
69 Self::new()
70 }
71}
72
73impl Provider for OpenAiResponsesProvider {
74 fn stream<'a>(
75 &'a self,
76 model: &'a Model,
77 context: &'a Context,
78 options: Option<StreamOptions>,
79 ) -> Pin<Box<dyn Future<Output = StreamResult> + Send + 'a>> {
80 Box::pin(async move {
81 let options = options.unwrap_or_default();
82
83 let effective_base_url = self.base_url.as_deref().unwrap_or(&model.base_url);
85 let url = format!("{}/responses", effective_base_url);
86
87 let api_key = options
89 .api_key
90 .as_ref()
91 .or(self.api_key.as_ref())
92 .ok_or_else(|| ProviderError::MissingApiKey)?;
93
94 let input = build_input(context)?;
96
97 let mut body = serde_json::json!({
99 "model": model.id,
100 "input": input,
101 "stream": true,
102 });
103
104 if let Some(temp) = options.temperature {
106 body["temperature"] = serde_json::json!(temp);
107 }
108
109 if let Some(max) = options.max_tokens {
110 body["max_output_tokens"] = serde_json::json!(max);
111 body["max_tokens"] = serde_json::json!(max);
112 }
113
114 if !context.tools.is_empty() {
116 body["tools"] = build_tools(&context.tools);
117 }
118
119 let openai_opts = options
121 .provider_options
122 .as_ref()
123 .and_then(|po| po.openai.as_ref());
124
125 if let Some(opts) = openai_opts {
126 let effort = opts
128 .reasoning_effort
129 .as_deref()
130 .or_else(|| options.thinking_level.as_ref().and_then(|l| l.as_str()));
131 let summary = opts.reasoning_summary.as_deref().unwrap_or("auto");
132
133 if let Some(effort_str) = effort {
134 body["reasoning"] = serde_json::json!({
135 "effort": effort_str,
136 "summary": summary,
137 });
138 }
139
140 if let Some(store) = opts.store {
142 body["store"] = serde_json::json!(store);
143 }
144
145 if opts.include_encrypted_reasoning.unwrap_or(false) {
147 body["include"] = serde_json::json!(["reasoning.encrypted_content"]);
148 }
149
150 if let Some(ref verbosity) = opts.text_verbosity {
152 body["text"] = serde_json::json!({ "verbosity": verbosity });
153 }
154
155 if let Some(ref key) = opts.prompt_cache_key {
157 body["prompt_cache_key"] = serde_json::json!(key);
158 }
159 } else if let Some(ref thinking_level) = options.thinking_level {
160 if thinking_level != &crate::ThinkingLevel::Off
162 && let Some(effort) = thinking_level.as_str()
163 {
164 body["reasoning"] = serde_json::json!({
165 "effort": effort,
166 "summary": "auto",
167 });
168 }
169
170 if options.thinking_level.is_some() {
172 body["include"] = serde_json::json!(["reasoning.encrypted_content"]);
173 }
174 }
175
176 let mut headers = reqwest::header::HeaderMap::new();
178 headers.insert(
179 reqwest::header::AUTHORIZATION,
180 format!("Bearer {}", api_key).parse().map_err(|e| {
181 ProviderError::InvalidResponse(format!("invalid bearer header: {e}"))
182 })?,
183 );
184 headers.insert(
185 reqwest::header::CONTENT_TYPE,
186 "application/json".parse().map_err(|e| {
187 ProviderError::InvalidResponse(format!("invalid header value: {e}"))
188 })?,
189 );
190
191 for (k, v) in &options.headers {
193 if let (Ok(name), Ok(value)) = (
194 k.parse::<reqwest::header::HeaderName>(),
195 v.parse::<reqwest::header::HeaderValue>(),
196 ) {
197 headers.insert(name, value);
198 }
199 }
200
201 let response = self
203 .client
204 .post(&url)
205 .headers(headers)
206 .json(&body)
207 .send()
208 .await
209 .map_err(ProviderError::RequestFailed)?;
210
211 if !response.status().is_success() {
212 let status = response.status();
213 let body: String = response.text().await.unwrap_or_default();
214 return Err(ProviderError::HttpError(
215 crate::error::HttpErrorDetail::new(status.as_u16(), body),
216 ));
217 }
218
219 let provider_name = model.provider.clone();
221 let model_id = model.id.clone();
222
223 let stream =
224 response
225 .bytes_stream()
226 .flat_map(move |chunk: Result<Bytes, reqwest::Error>| match chunk {
227 Ok(bytes) => {
228 let text = String::from_utf8_lossy(&bytes).to_string();
229 futures::stream::iter(parse_sse_events(
230 &text,
231 &provider_name,
232 &model_id,
233 ))
234 }
235 Err(e) => futures::stream::iter(vec![ProviderEvent::Error {
236 reason: StopReason::Error,
237 error: create_error_message(&e.to_string(), &provider_name, &model_id),
238 }]),
239 });
240
241 Ok(Box::pin(stream) as Pin<Box<dyn Stream<Item = ProviderEvent> + Send>>)
242 })
243 }
244}
245
246fn build_input(context: &Context) -> Result<Vec<JsonValue>, ProviderError> {
251 let mut input = Vec::new();
252
253 if let Some(ref prompt) = context.system_prompt {
255 input.push(serde_json::json!({
256 "role": "developer",
257 "content": prompt,
258 }));
259 }
260
261 for msg in &context.messages {
263 match msg {
264 crate::Message::User(u) => {
265 let content = match &u.content {
266 crate::MessageContent::Text(s) => serde_json::json!(s.clone()),
267 crate::MessageContent::Blocks(blocks) => blocks_to_json(blocks)?,
268 };
269 input.push(serde_json::json!({
270 "role": "user",
271 "content": content,
272 }));
273 }
274 crate::Message::Assistant(a) => {
275 let content = blocks_to_json(&a.content)?;
276 input.push(serde_json::json!({
277 "role": "assistant",
278 "content": content,
279 }));
280 }
281 crate::Message::ToolResult(t) => {
282 let content = blocks_to_json(&t.content)?;
283 input.push(serde_json::json!({
284 "role": "user",
285 "content": content,
286 }));
287 }
288 }
289 }
290
291 Ok(input)
292}
293
294fn blocks_to_json(blocks: &[ContentBlock]) -> Result<JsonValue, ProviderError> {
296 if blocks.len() == 1
297 && let Some(text) = blocks[0].as_text()
298 {
299 return Ok(JsonValue::String(text.to_string()));
300 }
301
302 let items: Result<Vec<_>, _> = blocks
303 .iter()
304 .map(|block| match block {
305 ContentBlock::Text(t) => Ok(serde_json::json!({
306 "type": "output_text",
307 "text": t.text,
308 })),
309 ContentBlock::ToolCall(tc) => Ok(serde_json::json!({
310 "type": "function_call",
311 "id": tc.id,
312 "name": tc.name,
313 "arguments": tc.arguments.to_string(),
314 })),
315 ContentBlock::Thinking(th) => Ok(serde_json::json!({
316 "type": "reasoning",
317 "summary": [
318 {
319 "type": "summary_text",
320 "text": th.thinking,
321 }
322 ]
323 })),
324 ContentBlock::Image(img) => Ok(serde_json::json!({
325 "type": "input_image",
326 "data": format!("data:{};base64,{}", img.mime_type, img.data),
327 "mime_type": img.mime_type,
328 })),
329 ContentBlock::Unknown(_) => Err(ProviderError::InvalidResponse(
330 "Unknown content block type".into(),
331 )),
332 })
333 .collect();
334
335 Ok(serde_json::json!(items?))
336}
337
338fn build_tools(tools: &[crate::Tool]) -> JsonValue {
340 let items: Vec<_> = tools
341 .iter()
342 .map(|tool| {
343 serde_json::json!({
344 "type": "function",
345 "name": tool.name,
346 "description": tool.description,
347 "parameters": tool.parameters,
348 })
349 })
350 .collect();
351
352 serde_json::json!(items)
353}
354
355fn parse_sse_events(text: &str, provider: &str, model_id: &str) -> Vec<ProviderEvent> {
365 let mut events = Vec::with_capacity(text.len() / 40);
367 let mut partial_message = AssistantMessage::new(Api::OpenAiResponses, provider, model_id);
368 let mut current_text_index: Option<usize> = None;
369 let mut current_tool_call_index: Option<usize> = None;
370 let mut accumulated_usage = Usage::default();
371
372 for line in text.split('\n') {
373 let line = line.trim_end_matches('\r');
374 if line.is_empty() {
375 continue;
376 }
377
378 if line.starts_with("event: ") {
380 let event_name = line.strip_prefix("event: ").unwrap_or(line).trim();
381 match event_name {
383 "response.created"
384 | "response.output_item.added"
385 | "response.content_part.added"
386 | "response.output_text.delta"
387 | "response.function_call_arguments.delta"
388 | "response.completed"
389 | "response.output_text.done"
390 | "response.reasoning.done" => {
391 }
393 _ => {}
394 }
395 continue;
396 }
397
398 if !line.starts_with("data: ") {
400 continue;
401 }
402
403 let data = line[6..].trim();
404 if data.is_empty() || data == "[DONE]" {
405 continue;
406 }
407
408 if let Ok(event) = serde_json::from_str::<ResponsesEvent>(data) {
410 match event {
411 ResponsesEvent::ResponseCreatedData { response } => {
412 if let Some(id) = response.id {
413 partial_message.response_id = Some(id);
414 }
415 events.push(ProviderEvent::Start {
416 partial: Arc::new(partial_message.clone()),
417 });
418 }
419 ResponsesEvent::OutputItemAdded { output_item } => {
420 match output_item.r#type.as_str() {
421 "message" => {
422 events.push(ProviderEvent::ToolCallStart {
423 content_index: output_item.index,
424 tool_call_id: output_item.id.clone(),
425 tool_name: None,
426 partial: Arc::new(partial_message.clone()),
427 });
428 current_tool_call_index = Some(output_item.index);
429 }
430 "function_call" => {
431 events.push(ProviderEvent::ToolCallStart {
432 content_index: output_item.index,
433 tool_call_id: output_item.id.clone(),
434 tool_name: None,
435 partial: Arc::new(partial_message.clone()),
436 });
437 current_tool_call_index = Some(output_item.index);
438 }
439 "reasoning" => {
440 events.push(ProviderEvent::ThinkingStart {
441 content_index: output_item.index,
442 partial: Arc::new(partial_message.clone()),
443 });
444 }
445 t if is_hosted_tool_type(t) => {
450 let tool_name = hosted_tool_name(t);
451 events.push(ProviderEvent::ToolCallStart {
452 content_index: output_item.index,
453 tool_call_id: output_item.id.clone(),
454 tool_name: Some(tool_name.clone()),
455 partial: Arc::new(partial_message.clone()),
456 });
457 current_tool_call_index = Some(output_item.index);
458 }
459 _ => {}
460 }
461 }
462 ResponsesEvent::ContentPartAdded { content_part } => {
463 match content_part.r#type.as_str() {
464 "output_text" => {
465 events.push(ProviderEvent::TextStart {
466 content_index: content_part.index,
467 partial: Arc::new(partial_message.clone()),
468 });
469 current_text_index = Some(content_part.index);
470 }
471 "function_call" => {
472 events.push(ProviderEvent::ToolCallStart {
473 content_index: content_part.index,
474 tool_call_id: None,
475 tool_name: None,
476 partial: Arc::new(partial_message.clone()),
477 });
478 current_tool_call_index = Some(content_part.index);
479 }
480 _ => {}
481 }
482 }
483 ResponsesEvent::OutputTextDelta { output_text: delta } => {
484 let content_idx = delta.content_index.or(current_text_index).unwrap_or(0);
486 let text = delta.slice.unwrap_or_default();
487 let last_text_idx = partial_message
490 .content
491 .iter()
492 .rposition(|b| matches!(b, ContentBlock::Text(_)));
493 if let Some(idx) = last_text_idx
494 && let ContentBlock::Text(t) = &mut partial_message.content[idx]
495 {
496 t.text.push_str(&text);
497 } else {
498 partial_message
499 .content
500 .push(ContentBlock::Text(crate::TextContent::new(text.clone())));
501 }
502 events.push(ProviderEvent::TextDelta {
503 content_index: content_idx,
504 delta: text,
505 partial: Arc::new(partial_message.clone()),
506 });
507 if current_text_index.is_none() {
509 current_text_index = Some(content_idx);
510 }
511 }
512 ResponsesEvent::FunctionCallArgumentsDelta {
513 function_call: delta,
514 } => {
515 let content_idx = delta.content_index.or(current_tool_call_index).unwrap_or(0);
517 events.push(ProviderEvent::ToolCallDelta {
518 content_index: content_idx,
519 delta: delta.arguments.unwrap_or_default(),
520 partial: Arc::new(partial_message.clone()),
521 });
522 if current_tool_call_index.is_none() {
524 current_tool_call_index = Some(content_idx);
525 }
526 }
527 ResponsesEvent::OutputTextDone { output_text } => {
528 if let Some(idx) = current_text_index {
529 let text_content = output_text
530 .content
531 .map(|c| c.text.unwrap_or_default())
532 .unwrap_or_default();
533 events.push(ProviderEvent::TextEnd {
534 content_index: idx,
535 content: text_content,
536 partial: Arc::new(partial_message.clone()),
537 });
538 current_text_index = None;
539 }
540 }
541 ResponsesEvent::ReasoningDone { reasoning } => {
542 if let Some(summary) = reasoning.summary {
543 for item in summary {
544 if item.r#type == "summary_text" {
545 events.push(ProviderEvent::ThinkingEnd {
546 content_index: 0,
547 content: item.text.unwrap_or_default(),
548 partial: Arc::new(partial_message.clone()),
549 });
550 }
551 }
552 }
553 }
554 ResponsesEvent::OutputItemDone { output_item }
557 if is_hosted_tool_type(&output_item.r#type) =>
558 {
559 let tool_name = hosted_tool_name(&output_item.r#type);
560 let tc_id = output_item
561 .call_id
562 .or_else(|| output_item.id.clone())
563 .unwrap_or_default();
564 events.push(ProviderEvent::ToolCallEnd {
565 content_index: output_item.index,
566 tool_call: crate::ToolCall::new(tc_id, tool_name, serde_json::json!({})),
567 partial: Arc::new(partial_message.clone()),
568 });
569 }
570 ResponsesEvent::ResponseWithUsage { response } => {
571 let is_incomplete = response.incomplete_details.is_some();
573
574 if let Some(usage) = response.usage {
576 accumulated_usage.input = usage.input_tokens;
577 accumulated_usage.output = usage.output_tokens;
578 accumulated_usage.total_tokens = usage.total_tokens;
579 if let Some(cached) = usage.input_tokens_details {
580 accumulated_usage.cache_read = cached.cached_tokens;
581 }
582 }
583
584 let stop_reason = if is_incomplete {
586 if let Some(incomplete) = response.incomplete_details {
587 match incomplete.reason.as_str() {
588 "max_output_tokens" => StopReason::Length,
589 "content_filter" => StopReason::Error,
590 _ => StopReason::Stop,
591 }
592 } else {
593 StopReason::Stop
594 }
595 } else {
596 StopReason::Stop
597 };
598
599 let mut done_msg = partial_message.clone();
600 done_msg.usage = accumulated_usage.clone();
601 events.push(ProviderEvent::Done {
602 reason: stop_reason,
603 message: done_msg,
604 });
605 }
606 _ => {}
607 }
608 }
609 }
610
611 events
612}
613
614fn create_error_message(msg: &str, provider: &str, model_id: &str) -> AssistantMessage {
616 let mut message = AssistantMessage::new(Api::OpenAiResponses, provider, model_id);
617 message.stop_reason = StopReason::Error;
618 message.error_message = Some(msg.to_string());
619 message
620}
621
622#[derive(Debug, Deserialize)]
628#[serde(untagged)]
629enum ResponsesEvent {
630 ResponseWithUsage {
632 response: ResponseWithUsageData,
633 },
634 OutputItemAdded {
636 output_item: OutputItem,
637 },
638 ContentPartAdded {
640 content_part: ContentPart,
641 },
642 OutputTextDelta {
644 output_text: TextDelta,
645 },
646 FunctionCallArgumentsDelta {
648 function_call: FunctionCallDelta,
649 },
650 OutputTextDone {
652 output_text: OutputTextDone,
653 },
654 ReasoningDone {
656 reasoning: ReasoningDone,
657 },
658 OutputItemDone {
660 output_item: OutputItemDoneData,
661 },
662 ResponseCreatedData {
664 response: ResponseCreatedData,
665 },
666 #[allow(dead_code)]
668 Unknown(JsonValue),
669}
670
671#[derive(Debug, Deserialize)]
672struct ResponseCreatedData {
674 id: Option<String>,
675 #[serde(rename = "object")]
676 _object: Option<String>,
677 _status: Option<String>,
678 #[serde(rename = "model")]
679 _model: Option<String>,
680 _created_at: Option<i64>,
681}
682
683#[derive(Debug, Deserialize)]
684struct OutputItem {
686 index: usize,
687 #[serde(rename = "type")]
688 r#type: String,
689 id: Option<String>,
690 _status: Option<String>,
691}
692
693#[derive(Debug, Deserialize)]
695#[allow(dead_code)]
696struct OutputItemDoneData {
697 index: usize,
698 #[serde(rename = "type")]
699 r#type: String,
700 id: Option<String>,
701 call_id: Option<String>,
703 name: Option<String>,
705 arguments: Option<String>,
707 encrypted_content: Option<String>,
709 summary: Option<Vec<SummaryItem>>,
711 _status: Option<String>,
713}
714
715fn is_hosted_tool_type(t: &str) -> bool {
717 matches!(
718 t,
719 "web_search_call"
720 | "web_search_preview_call"
721 | "file_search_call"
722 | "code_interpreter_call"
723 | "computer_use_call"
724 | "image_generation_call"
725 | "mcp_call"
726 | "local_shell_call"
727 )
728}
729
730fn hosted_tool_name(t: &str) -> String {
732 match t {
733 "web_search_call" | "web_search_preview_call" => "web_search",
734 "file_search_call" => "file_search",
735 "code_interpreter_call" => "code_interpreter",
736 "computer_use_call" => "computer_use",
737 "image_generation_call" => "image_generation",
738 "mcp_call" => "mcp",
739 "local_shell_call" => "local_shell",
740 _ => "unknown",
741 }
742 .to_string()
743}
744
745#[derive(Debug, Deserialize)]
746struct ContentPart {
747 index: usize,
748 #[serde(rename = "type")]
749 r#type: String,
750}
751
752#[derive(Debug, Deserialize)]
753struct TextDelta {
755 content_index: Option<usize>,
756 _output_index: Option<usize>,
757 slice: Option<String>,
758}
759
760#[derive(Debug, Deserialize)]
761struct FunctionCallDelta {
763 content_index: Option<usize>,
764 _output_index: Option<usize>,
765 _name: Option<String>,
766 arguments: Option<String>,
767 _call_id: Option<String>,
768}
769
770#[derive(Debug, Deserialize)]
771struct OutputTextDone {
773 _content_index: Option<usize>,
774 _output_index: Option<usize>,
775 content: Option<TextContent>,
776}
777
778#[derive(Debug, Deserialize)]
779struct TextContent {
780 text: Option<String>,
781}
782
783#[derive(Debug, Deserialize)]
784struct ReasoningDone {
786 _content_index: Option<usize>,
787 _output_index: Option<usize>,
788 summary: Option<Vec<SummaryItem>>,
789}
790
791#[derive(Debug, Deserialize)]
792struct SummaryItem {
793 #[serde(rename = "type")]
794 r#type: String,
795 text: Option<String>,
796}
797
798#[derive(Debug, Deserialize)]
800struct ResponseWithUsageData {
802 _id: Option<String>,
803 _status: Option<String>,
804 usage: Option<UsageData>,
805 incomplete_details: Option<IncompleteDetails>,
806}
807
808#[derive(Debug, Deserialize)]
809struct IncompleteDetails {
810 reason: String,
811}
812
813#[derive(Debug, Deserialize)]
814struct UsageData {
816 input_tokens: usize,
817 output_tokens: usize,
818 total_tokens: usize,
819 #[serde(rename = "input_tokens_details")]
820 input_tokens_details: Option<InputTokensDetails>,
821}
822
823#[derive(Debug, Deserialize)]
824struct InputTokensDetails {
825 #[serde(rename = "cached_tokens")]
826 cached_tokens: usize,
827}
828
829#[cfg(test)]
834mod tests {
835 use super::*;
836 use crate::{Context, Message, Model, TextContent};
837 use serde_json::json;
838
839 #[allow(dead_code)]
840 fn create_test_model() -> Model {
841 Model::new(
842 "gpt-4o",
843 "GPT-4o",
844 Api::OpenAiResponses,
845 "openai-responses",
846 "https://api.openai.com/v1",
847 )
848 }
849
850 fn create_test_context() -> Context {
851 Context::new()
852 }
853
854 #[test]
855 fn test_build_input_with_text() {
856 let mut context = create_test_context();
857 context.add_message(Message::user("Hello, world!"));
858
859 let input = build_input(&context).unwrap();
860 assert_eq!(input.len(), 1);
861 assert_eq!(input[0]["role"], "user");
862 assert_eq!(input[0]["content"], "Hello, world!");
863 }
864
865 #[test]
866 fn test_build_input_with_system_prompt() {
867 let mut context = create_test_context();
868 context.set_system_prompt("You are a helpful assistant.");
869 context.add_message(Message::user("Hi!"));
870
871 let input = build_input(&context).unwrap();
872 assert_eq!(input.len(), 2);
873 assert_eq!(input[0]["role"], "developer");
874 assert_eq!(input[0]["content"], "You are a helpful assistant.");
875 }
876
877 #[test]
878 fn test_build_input_with_multiple_messages() {
879 let mut context = create_test_context();
880 context.add_message(Message::user("First message"));
881 context.add_message(Message::user("Second message"));
882
883 let input = build_input(&context).unwrap();
884 assert_eq!(input.len(), 2);
885 }
886
887 #[test]
888 fn test_blocks_to_json_text() {
889 let blocks = vec![ContentBlock::Text(TextContent::new("Hello"))];
890 let result = blocks_to_json(&blocks).unwrap();
891 assert_eq!(result, "Hello");
892 }
893
894 #[test]
895 fn test_blocks_to_json_multiple_blocks() {
896 let blocks = vec![
897 ContentBlock::Text(TextContent::new("Hello")),
898 ContentBlock::Text(TextContent::new("World")),
899 ];
900 let result = blocks_to_json(&blocks).unwrap();
901 assert!(result.is_array());
902 assert_eq!(result.as_array().unwrap().len(), 2);
903 }
904
905 #[test]
906 fn test_build_tools() {
907 let tools = vec![crate::Tool {
908 name: "get_weather".to_string(),
909 description: "Get weather for a location".to_string(),
910 parameters: json!({
911 "type": "object",
912 "properties": {
913 "location": {"type": "string"}
914 }
915 }),
916 }];
917
918 let result = build_tools(&tools);
919 assert!(result.is_array());
920 let tool = &result[0];
921 assert_eq!(tool["type"], "function");
922 assert_eq!(tool["name"], "get_weather");
923 }
924
925 #[test]
926 fn test_parse_response_created_event() {
927 let sse_data =
929 r#"data: {"response":{"id":"resp_123","status":"in_progress","model":"gpt-4o"}}"#;
930
931 let events = parse_sse_events(sse_data, "openai-responses", "gpt-4o");
932 assert!(!events.is_empty());
933 if let ProviderEvent::Start { partial } = &events[0] {
934 assert_eq!(partial.api, Api::OpenAiResponses);
935 }
936 }
937
938 #[test]
939 fn test_parse_output_item_added_event() {
940 let sse_data = r#"data: {"output_item":{"index":0,"id":"msg_123","type":"message","status":"in_progress"}}"#;
942
943 let events = parse_sse_events(sse_data, "openai-responses", "gpt-4o");
944 assert!(
946 events
947 .iter()
948 .any(|e| matches!(e, ProviderEvent::ToolCallStart { .. }))
949 );
950 }
951
952 #[test]
953 fn test_parse_text_delta_event() {
954 let sse_data = r#"data: {"output_text":{"content_index":0,"slice":"Hello"}}"#;
956
957 let events = parse_sse_events(sse_data, "openai-responses", "gpt-4o");
958 assert!(
959 events
960 .iter()
961 .any(|e| matches!(e, ProviderEvent::TextDelta { .. }))
962 );
963 }
964
965 #[test]
966 fn test_parse_function_call_delta_event() {
967 let sse_data = r#"data: {"function_call":{"content_index":0,"arguments":"{\"location"}}"#;
969
970 let events = parse_sse_events(sse_data, "openai-responses", "gpt-4o");
971 assert!(
972 events
973 .iter()
974 .any(|e| matches!(e, ProviderEvent::ToolCallDelta { .. }))
975 );
976 }
977
978 #[test]
979 fn test_parse_completed_event_with_usage() {
980 let sse_data = r#"data: {"response":{"id":"resp_123","status":"completed","usage":{"input_tokens":100,"output_tokens":50,"total_tokens":150}}}"#;
982
983 let events = parse_sse_events(sse_data, "openai-responses", "gpt-4o");
984 assert!(events.iter().any(|e| matches!(
985 e,
986 ProviderEvent::Done {
987 reason: StopReason::Stop,
988 ..
989 }
990 )));
991 }
992
993 #[test]
994 fn test_parse_reasoning_event() {
995 let sse_data = r#"data: {"reasoning":{"content_index":0,"summary":[{"type":"summary_text","text":"Thinking process..."}]}}"#;
997
998 let events = parse_sse_events(sse_data, "openai-responses", "gpt-4o");
999 assert!(
1000 events
1001 .iter()
1002 .any(|e| matches!(e, ProviderEvent::ThinkingEnd { .. }))
1003 );
1004 }
1005
1006 #[test]
1007 fn test_provider_with_api_key() {
1008 let _provider = OpenAiResponsesProvider::with_api_key("sk-test-key");
1010 }
1011
1012 #[test]
1013 fn test_multiple_events_in_stream() {
1014 let sse_data = r#"data: {"response":{"id":"resp_123"}}
1016data: {"output_item":{"index":0,"type":"message"}}
1017data: {"output_text":{"slice":"Hello"}}
1018data: {"response":{"status":"completed"}}"#;
1019
1020 let events = parse_sse_events(sse_data, "openai-responses", "gpt-4o");
1021 assert!(events.len() >= 4);
1022 }
1023
1024 #[test]
1025 fn test_invalid_json_skipped() {
1026 let sse_data = r#"event: response.created
1027data: {invalid json here}
1028event: response.created
1029data: {"response":{"id":"resp_123"}}"#;
1030
1031 let events = parse_sse_events(sse_data, "openai-responses", "gpt-4o");
1032 assert!(!events.is_empty());
1034 }
1035
1036 #[test]
1037 fn test_done_marker() {
1038 let sse_data = r#"event: response.created
1039data: {"response":{"id":"resp_123"}}
1040data: [DONE]"#;
1041
1042 let events = parse_sse_events(sse_data, "openai-responses", "gpt-4o");
1043 assert!(events.len() <= 2);
1045 }
1046
1047 #[test]
1048 fn test_incomplete_response() {
1049 let sse_data = r#"data: {"response":{"id":"resp_123","incomplete_details":{"reason":"max_output_tokens"}}}"#;
1051
1052 let events = parse_sse_events(sse_data, "openai-responses", "gpt-4o");
1053 assert!(events.iter().any(|e| matches!(
1054 e,
1055 ProviderEvent::Done {
1056 reason: StopReason::Length,
1057 ..
1058 }
1059 )));
1060 }
1061
1062 #[test]
1069 fn test_codex_responses_fixture_parses_to_provider_events() {
1070 let sse = "\
1071data: {\"response\":{\"id\":\"resp_abc\",\"status\":\"in_progress\",\"model\":\"gpt-5-codex\"}}
1072
1073data: {\"output_item\":{\"index\":0,\"id\":\"msg_1\",\"type\":\"message\",\"status\":\"in_progress\"}}
1074
1075data: {\"output_text\":{\"content_index\":0,\"slice\":\"Hello\"}}
1076
1077data: {\"output_text\":{\"content_index\":0,\"slice\":\" from Codex\"}}
1078
1079data: {\"response\":{\"id\":\"resp_abc\",\"status\":\"completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":7,\"total_tokens\":19}}}
1080";
1081 let events = parse_sse_events(sse, "openai-codex", "gpt-5-codex");
1082 let text: String = events
1090 .iter()
1091 .filter_map(|e| match e {
1092 ProviderEvent::TextDelta { delta, .. } => Some(delta.clone()),
1093 _ => None,
1094 })
1095 .collect();
1096 assert_eq!(text, "Hello from Codex");
1097 assert!(
1098 events.iter().any(|e| matches!(
1099 e,
1100 ProviderEvent::Done {
1101 reason: StopReason::Stop,
1102 ..
1103 }
1104 )),
1105 "missing Done(Stop) event in {events:?}"
1106 );
1107 }
1108}