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