1use std::collections::{HashMap, VecDeque};
25use std::sync::Arc;
26
27use agent_framework_core::client::{ChatClient, ChatStream};
28use agent_framework_core::error::{Error, Result};
29use agent_framework_core::streaming::Utf8StreamDecoder;
30use agent_framework_core::tools::ToolDefinition;
31use agent_framework_core::types::{
32 Annotation, ChatOptions, ChatResponse, ChatResponseUpdate, Content, DataContent, FinishReason,
33 FunctionApprovalRequestContent, FunctionArguments, FunctionCallContent, FunctionResultContent,
34 Message, ResponseFormat, Role, TextContent, TextReasoningContent, TextSpanRegion, ToolMode,
35 UriContent, UsageContent, UsageDetails,
36};
37use futures::StreamExt;
38use serde_json::{json, Map, Value};
39
40use crate::convert::{
41 audio_format, data_content_media_type, function_arguments_to_string, result_to_string,
42 top_level_media_type, DEFAULT_FILENAME,
43};
44use crate::{ByteStream, DEFAULT_BASE_URL};
45
46#[derive(Clone)]
48pub struct OpenAIChatClient {
49 inner: Arc<Inner>,
50}
51
52#[derive(Clone)]
53struct Inner {
54 http: reqwest::Client,
55 api_key: String,
56 base_url: String,
57 model: String,
58 organization: Option<String>,
59}
60
61impl std::fmt::Debug for OpenAIChatClient {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 f.debug_struct("OpenAIChatClient")
64 .field("base_url", &self.inner.base_url)
65 .field("model", &self.inner.model)
66 .field("organization", &self.inner.organization)
67 .finish_non_exhaustive()
68 }
69}
70
71impl OpenAIChatClient {
72 pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
74 Self {
75 inner: Arc::new(Inner {
76 http: reqwest::Client::new(),
77 api_key: api_key.into(),
78 base_url: DEFAULT_BASE_URL.to_string(),
79 model: model.into(),
80 organization: None,
81 }),
82 }
83 }
84
85 pub fn from_env(model: impl Into<String>) -> Result<Self> {
88 let key = std::env::var("OPENAI_API_KEY")
89 .map_err(|_| Error::Configuration("OPENAI_API_KEY is not set".into()))?;
90 let mut client = Self::new(key, model);
91 if let Ok(base) = std::env::var("OPENAI_BASE_URL") {
92 client = client.with_base_url(base);
93 }
94 Ok(client)
95 }
96
97 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
99 Arc::make_mut(&mut self.inner).base_url = base_url.into();
100 self
101 }
102
103 pub fn with_organization(mut self, org: impl Into<String>) -> Self {
105 Arc::make_mut(&mut self.inner).organization = Some(org.into());
106 self
107 }
108
109 pub fn model(&self) -> &str {
111 &self.inner.model
112 }
113
114 fn build_body(&self, messages: &[Message], options: &ChatOptions, stream: bool) -> Value {
115 let mut body = Map::new();
116 let model = options
117 .model
118 .clone()
119 .unwrap_or_else(|| self.inner.model.clone());
120 body.insert("model".into(), json!(model));
121
122 let (instructions, rest) = extract_instructions(messages, options.instructions.as_deref());
123 if let Some(instructions) = instructions {
124 body.insert("instructions".into(), json!(instructions));
125 }
126 body.insert("input".into(), json!(messages_to_input(rest)));
127
128 if let Some(conversation_id) = &options.conversation_id {
129 body.insert("previous_response_id".into(), json!(conversation_id));
130 }
131 if let Some(t) = options.temperature {
132 body.insert("temperature".into(), json!(t));
133 }
134 if let Some(t) = options.top_p {
135 body.insert("top_p".into(), json!(t));
136 }
137 if let Some(mt) = options.max_tokens {
138 body.insert("max_output_tokens".into(), json!(mt));
139 }
140 if let Some(store) = options.store {
141 body.insert("store".into(), json!(store));
142 }
143 if let Some(user) = &options.user {
144 body.insert("user".into(), json!(user));
145 }
146 if let Some(metadata) = &options.metadata {
147 body.insert("metadata".into(), json!(metadata));
148 }
149
150 if !options.tools.is_empty() {
151 let tools: Vec<Value> = options.tools.iter().map(tool_to_responses_spec).collect();
152 body.insert("tools".into(), json!(tools));
153 if let Some(allow_multi) = options.allow_multiple_tool_calls {
154 body.insert("parallel_tool_calls".into(), json!(allow_multi));
155 }
156 }
157 if let Some(tool_choice) = &options.tool_choice {
158 body.insert("tool_choice".into(), tool_choice_to_responses(tool_choice));
159 }
160 if let Some(fmt) = &options.response_format {
161 body.insert(
162 "text".into(),
163 json!({ "format": response_format_to_text(fmt) }),
164 );
165 }
166
167 if let Some(include) = responses_include(options, true) {
170 body.insert("include".into(), include);
171 }
172
173 for (k, v) in &options.additional_properties {
174 if k == "include" {
179 continue;
180 }
181 body.entry(k.clone()).or_insert_with(|| v.clone());
182 }
183
184 if stream {
185 body.insert("stream".into(), json!(true));
186 }
187 Value::Object(body)
188 }
189
190 async fn post(&self, body: &Value) -> Result<reqwest::Response> {
191 let url = format!("{}/responses", self.inner.base_url.trim_end_matches('/'));
192 let mut req = self
193 .inner
194 .http
195 .post(&url)
196 .bearer_auth(&self.inner.api_key)
197 .json(body);
198 if let Some(org) = &self.inner.organization {
199 req = req.header("OpenAI-Organization", org);
200 }
201 let resp = req
202 .send()
203 .await
204 .map_err(|e| Error::service(format!("request failed: {e}")))?;
205 if !resp.status().is_success() {
206 let status = resp.status();
207 let retry_after = crate::parse_retry_after(resp.headers());
208 let text = resp.text().await.unwrap_or_default();
209 return Err(crate::classify_service_error(
210 status.as_u16(),
211 &text,
212 format!("OpenAI API error {status}: {text}"),
213 retry_after,
214 ));
215 }
216 Ok(resp)
217 }
218}
219
220#[async_trait::async_trait]
221impl ChatClient for OpenAIChatClient {
222 async fn get_response(
223 &self,
224 messages: Vec<Message>,
225 options: ChatOptions,
226 ) -> Result<ChatResponse> {
227 let body = self.build_body(&messages, &options, false);
228 let resp = self.post(&body).await?;
229 let value: Value = resp
230 .json()
231 .await
232 .map_err(|e| Error::service(format!("invalid response json: {e}")))?;
233 if let Some(err) = response_failure_error(&value) {
234 return Err(err);
235 }
236 Ok(parse_response(&value, options.store))
237 }
238
239 async fn get_streaming_response(
240 &self,
241 messages: Vec<Message>,
242 options: ChatOptions,
243 ) -> Result<ChatStream> {
244 let body = self.build_body(&messages, &options, true);
245 let resp = self.post(&body).await?;
246 Ok(parse_responses_sse_stream(resp, options.store).boxed())
247 }
248
249 fn model(&self) -> Option<&str> {
250 Some(&self.inner.model)
251 }
252}
253
254pub const ENCRYPTED_REASONING_INCLUDE: &str = "reasoning.encrypted_content";
259
260pub fn responses_include(options: &ChatOptions, implicit: bool) -> Option<Value> {
285 let mut include: Vec<Value> = options
286 .additional_properties
287 .get("include")
288 .and_then(Value::as_array)
289 .cloned()
290 .unwrap_or_default();
291
292 let uses_service_side_storage = options
295 .conversation_id
296 .as_deref()
297 .is_some_and(|id| !id.is_empty());
298
299 let already_present = include
300 .iter()
301 .any(|v| v.as_str() == Some(ENCRYPTED_REASONING_INCLUDE));
302 if implicit && !uses_service_side_storage && !already_present {
303 include.push(json!(ENCRYPTED_REASONING_INCLUDE));
304 }
305
306 (!include.is_empty()).then(|| json!(include))
307}
308
309pub fn extract_instructions<'a>(
318 messages: &'a [Message],
319 options_instructions: Option<&str>,
320) -> (Option<String>, &'a [Message]) {
321 let mut parts = Vec::new();
322 if let Some(instr) = options_instructions {
323 if !instr.is_empty() {
324 parts.push(instr.to_string());
325 }
326 }
327 let mut rest = messages;
328 if let Some(first) = messages.first() {
329 if first.role == Role::system() {
330 let text = first.text();
331 if !text.is_empty() {
332 parts.push(text);
333 }
334 rest = &messages[1..];
335 }
336 }
337 if parts.is_empty() {
338 (None, rest)
339 } else {
340 (Some(parts.join("\n\n")), rest)
341 }
342}
343
344pub fn messages_to_input(messages: &[Message]) -> Vec<Value> {
350 let mut out = Vec::new();
351 for msg in messages {
352 let role = msg.role.as_str();
353 if role == Role::TOOL {
354 for content in &msg.contents {
355 if let Content::FunctionResult(fr) = content {
356 out.push(function_result_to_item(fr));
357 }
358 }
359 continue;
360 }
361
362 let mut buffered: Vec<Value> = Vec::new();
363 for content in &msg.contents {
364 match content {
365 Content::Text(t) => {
366 let text_type = if role == Role::ASSISTANT {
367 "output_text"
368 } else {
369 "input_text"
370 };
371 buffered.push(json!({ "type": text_type, "text": t.text }));
372 }
373 Content::Uri(u) => {
374 if let Some(part) = content_to_input_part(&u.uri, Some(&u.media_type)) {
375 buffered.push(part);
376 }
377 }
378 Content::Data(d) => {
379 if let Some(part) =
380 content_to_input_part(&d.uri, data_content_media_type(d).as_deref())
381 {
382 buffered.push(part);
383 }
384 }
385 Content::HostedFile(h) => {
386 buffered.push(json!({ "type": "input_file", "file_id": h.file_id }));
387 }
388 Content::FunctionCall(fc) => {
389 flush_text(&mut out, &mut buffered, role);
390 out.push(json!({
391 "type": "function_call",
392 "call_id": fc.call_id,
393 "name": fc.name,
394 "arguments": function_arguments_to_string(&fc.arguments),
395 }));
396 }
397 Content::FunctionResult(fr) => {
398 flush_text(&mut out, &mut buffered, role);
399 out.push(function_result_to_item(fr));
400 }
401 Content::FunctionApprovalResponse(r) => {
402 flush_text(&mut out, &mut buffered, role);
403 out.push(json!({
404 "type": "mcp_approval_response",
405 "approval_request_id": r.id,
406 "approve": r.approved,
407 }));
408 }
409 Content::FunctionApprovalRequest(r) => {
410 flush_text(&mut out, &mut buffered, role);
411 out.push(json!({
412 "type": "mcp_approval_request",
413 "id": r.id,
414 "name": r.function_call.name,
415 "arguments": function_arguments_to_string(&r.function_call.arguments),
416 }));
417 }
418 Content::TextReasoning(tr) => {
419 if let Some(raw) = &tr.raw_representation {
424 flush_text(&mut out, &mut buffered, role);
425 out.push(raw.clone());
426 }
427 }
428 _ => {}
429 }
430 }
431 flush_text(&mut out, &mut buffered, role);
432 }
433 out
434}
435
436fn content_to_input_part(uri: &str, media_type: Option<&str>) -> Option<Value> {
441 let media_type = media_type?;
442 match top_level_media_type(media_type).as_str() {
443 "image" => Some(json!({
445 "type": "input_image",
446 "image_url": uri,
447 "detail": "auto",
448 })),
449 "audio" => {
450 let format = audio_format(media_type)?;
451 Some(json!({
454 "type": "input_audio",
455 "input_audio": {
456 "data": crate::convert::strip_data_uri_prefix(uri),
457 "format": format,
458 },
459 }))
460 }
461 "application" => Some(json!({
462 "type": "input_file",
463 "file_data": uri,
464 "filename": DEFAULT_FILENAME,
465 })),
466 _ => None,
467 }
468}
469
470fn flush_text(out: &mut Vec<Value>, buffered: &mut Vec<Value>, role: &str) {
471 if !buffered.is_empty() {
472 out.push(json!({ "type": "message", "role": role, "content": std::mem::take(buffered) }));
473 }
474}
475
476fn function_result_to_item(fr: &FunctionResultContent) -> Value {
477 json!({
478 "type": "function_call_output",
479 "call_id": fr.call_id,
480 "output": result_to_string(fr),
481 })
482}
483
484pub fn tool_to_responses_spec(tool: &ToolDefinition) -> Value {
490 use agent_framework_core::tools::ToolKind;
491 match &tool.kind {
492 ToolKind::HostedWebSearch => {
493 let mut spec = Map::new();
494 spec.insert("type".into(), json!("web_search"));
495 if let Some(loc) = tool.parameters.get("user_location") {
496 spec.insert("user_location".into(), user_location_to_responses(loc));
497 }
498 Value::Object(spec)
499 }
500 ToolKind::HostedCodeInterpreter => {
501 let mut spec = Map::new();
502 spec.insert("type".into(), json!("code_interpreter"));
503 if let Some(container) = tool.parameters.get("container") {
506 spec.insert("container".into(), container.clone());
507 } else {
508 let mut container = Map::new();
509 container.insert("type".into(), json!("auto"));
510 if let Some(file_ids) = tool.parameters.get("file_ids") {
511 container.insert("file_ids".into(), file_ids.clone());
512 }
513 spec.insert("container".into(), Value::Object(container));
514 }
515 Value::Object(spec)
516 }
517 ToolKind::HostedFileSearch { max_results } => {
518 let mut spec = Map::new();
519 spec.insert("type".into(), json!("file_search"));
520 if let Some(ids) = tool.parameters.get("vector_store_ids") {
524 spec.insert("vector_store_ids".into(), ids.clone());
525 }
526 let max = (*max_results)
528 .map(|n| json!(n))
529 .or_else(|| tool.parameters.get("max_results").cloned());
530 if let Some(n) = max {
531 spec.insert("max_num_results".into(), n);
532 }
533 Value::Object(spec)
534 }
535 ToolKind::HostedMcp { url, allowed_tools } => {
536 let mut spec = Map::new();
537 spec.insert("type".into(), json!("mcp"));
538 spec.insert("server_label".into(), json!(tool.name.replace(' ', "_")));
539 spec.insert("server_url".into(), json!(url));
540 if !tool.description.is_empty() {
541 spec.insert("server_description".into(), json!(tool.description));
542 }
543 if let Some(headers) = tool.parameters.get("headers") {
544 spec.insert("headers".into(), headers.clone());
545 }
546 if let Some(allowed) = allowed_tools {
547 spec.insert("allowed_tools".into(), json!(allowed));
548 }
549 spec.insert("require_approval".into(), mcp_require_approval(tool));
550 Value::Object(spec)
551 }
552 ToolKind::HostedImageGeneration => {
553 let mut spec = Map::new();
554 spec.insert("type".into(), json!("image_generation"));
555 if let Value::Object(params) = &tool.parameters {
558 for (k, v) in params {
559 if k != "type" && k != "properties" {
560 spec.insert(k.clone(), v.clone());
561 }
562 }
563 }
564 Value::Object(spec)
565 }
566 ToolKind::Function => json!({
567 "type": "function",
568 "name": tool.name,
569 "description": tool.description,
570 "parameters": tool.parameters,
571 }),
572 }
573}
574
575fn user_location_to_responses(location: &Value) -> Value {
578 let mut loc = Map::new();
579 loc.insert("type".into(), json!("approximate"));
580 for key in ["city", "country", "region", "timezone"] {
581 if let Some(v) = location.get(key) {
582 loc.insert(key.into(), v.clone());
583 }
584 }
585 Value::Object(loc)
586}
587
588fn mcp_require_approval(tool: &ToolDefinition) -> Value {
594 use agent_framework_core::tools::ApprovalMode;
595 match tool.parameters.get("approval_mode") {
596 Some(Value::String(s)) => {
597 return json!(if s == "always_require" {
598 "always"
599 } else {
600 "never"
601 });
602 }
603 Some(Value::Object(modes)) => {
604 let mut req = Map::new();
605 if let Some(always) = modes.get("always") {
606 req.insert("always".into(), json!({ "tool_names": always }));
607 }
608 if let Some(never) = modes.get("never") {
609 req.insert("never".into(), json!({ "tool_names": never }));
610 }
611 if !req.is_empty() {
612 return Value::Object(req);
613 }
614 }
615 _ => {}
616 }
617 json!(match tool.approval_mode {
618 ApprovalMode::AlwaysRequire => "always",
619 ApprovalMode::NeverRequire => "never",
620 })
621}
622
623pub fn tool_choice_to_responses(mode: &ToolMode) -> Value {
626 match mode {
627 ToolMode::Auto => json!("auto"),
628 ToolMode::None => json!("none"),
629 ToolMode::Required(Some(name)) => json!({ "type": "function", "name": name }),
630 ToolMode::Required(None) => json!("required"),
631 }
632}
633
634pub fn response_format_to_text(format: &ResponseFormat) -> Value {
641 match format {
642 ResponseFormat::Text => json!({ "type": "text" }),
643 ResponseFormat::JsonObject => json!({ "type": "json_object" }),
644 ResponseFormat::JsonSchema {
645 name,
646 description,
647 schema,
648 strict,
649 } => {
650 let mut obj = Map::new();
651 obj.insert("type".into(), json!("json_schema"));
652 obj.insert("name".into(), json!(name));
653 if let Some(d) = description {
654 obj.insert("description".into(), json!(d));
655 }
656 obj.insert("schema".into(), schema.clone());
657 if let Some(st) = strict {
658 obj.insert("strict".into(), json!(st));
659 }
660 Value::Object(obj)
661 }
662 }
663}
664
665pub fn response_failure_error(value: &Value) -> Option<Error> {
681 if value.get("status").and_then(Value::as_str) != Some("failed") {
682 return None;
683 }
684 let error = value.get("error");
685 let msg = error
686 .and_then(|e| e.get("message"))
687 .and_then(Value::as_str)
688 .unwrap_or("response failed")
689 .to_string();
690 let code = error.and_then(|e| e.get("code")).and_then(Value::as_str);
691 Some(match code {
692 Some("content_filter") => Error::service_content_filter(msg),
693 _ => Error::service(msg),
694 })
695}
696
697pub fn parse_response(value: &Value, store: Option<bool>) -> ChatResponse {
702 let mut response = ChatResponse {
703 response_id: value.get("id").and_then(Value::as_str).map(String::from),
704 model: value.get("model").and_then(Value::as_str).map(String::from),
705 ..Default::default()
706 };
707
708 let mut contents: Vec<Content> = Vec::new();
709 if let Some(items) = value.get("output").and_then(Value::as_array) {
710 for item in items {
711 parse_output_item(item, &mut contents);
712 }
713 }
714
715 let mut message = Message::with_contents(Role::assistant(), contents);
716 message.message_id = response.response_id.clone();
717 response.messages.push(message);
718
719 response.finish_reason = finish_reason_from_response(value);
720
721 if let Some(usage) = value.get("usage") {
722 response.usage_details = Some(parse_responses_usage(usage));
723 }
724 if store != Some(false) {
725 response.conversation_id = response.response_id.clone();
726 }
727 response
728}
729
730fn parse_output_item(item: &Value, contents: &mut Vec<Content>) {
731 match item.get("type").and_then(Value::as_str) {
732 Some("message") => {
733 if let Some(parts) = item.get("content").and_then(Value::as_array) {
734 for part in parts {
735 match part.get("type").and_then(Value::as_str) {
736 Some("output_text") => {
737 if let Some(text) = part.get("text").and_then(Value::as_str) {
738 let mut tc = TextContent::new(text);
739 tc.annotations = parse_annotations(part);
740 contents.push(Content::Text(tc));
741 }
742 }
743 Some("refusal") => {
744 if let Some(text) = part.get("refusal").and_then(Value::as_str) {
745 contents.push(Content::Text(TextContent::new(text)));
746 }
747 }
748 _ => {}
749 }
750 }
751 }
752 }
753 Some("function_call") => {
754 let call_id = item
755 .get("call_id")
756 .and_then(Value::as_str)
757 .unwrap_or_default()
758 .to_string();
759 let name = item
760 .get("name")
761 .and_then(Value::as_str)
762 .unwrap_or_default()
763 .to_string();
764 let args = item
765 .get("arguments")
766 .and_then(Value::as_str)
767 .unwrap_or("{}")
768 .to_string();
769 contents.push(Content::FunctionCall(FunctionCallContent::new(
770 call_id,
771 name,
772 Some(FunctionArguments::Raw(args)),
773 )));
774 }
775 Some("reasoning") => {
776 let summaries: Vec<String> = item
777 .get("summary")
778 .and_then(Value::as_array)
779 .map(|arr| {
780 arr.iter()
781 .filter_map(|s| s.get("text").and_then(Value::as_str))
782 .map(str::to_string)
783 .collect()
784 })
785 .unwrap_or_default();
786 let raw = Some(item.clone());
793 if summaries.is_empty() {
794 contents.push(Content::TextReasoning(TextReasoningContent {
796 text: String::new(),
797 annotations: None,
798 raw_representation: raw,
799 protected_data: None,
800 }));
801 } else {
802 let n = summaries.len();
803 for (i, text) in summaries.into_iter().enumerate() {
804 contents.push(Content::TextReasoning(TextReasoningContent {
805 text,
806 annotations: None,
807 raw_representation: (i == n - 1).then(|| raw.clone()).flatten(),
808 protected_data: None,
809 }));
810 }
811 }
812 }
813 Some("code_interpreter_call") => {
817 let outputs = item
818 .get("outputs")
819 .and_then(Value::as_array)
820 .filter(|a| !a.is_empty());
821 if let Some(outputs) = outputs {
822 for output in outputs {
823 match output.get("type").and_then(Value::as_str) {
824 Some("logs") => {
825 if let Some(logs) = output.get("logs").and_then(Value::as_str) {
826 contents.push(Content::Text(TextContent::new(logs)));
827 }
828 }
829 Some("image") => {
830 if let Some(url) = output.get("url").and_then(Value::as_str) {
831 contents.push(Content::Uri(UriContent {
832 uri: url.to_string(),
833 media_type: "image".to_string(),
834 }));
835 }
836 }
837 _ => {}
838 }
839 }
840 } else if let Some(code) = item.get("code").and_then(Value::as_str) {
841 contents.push(Content::Text(TextContent::new(code)));
842 }
843 }
844 Some("image_generation_call") => {
848 if let Some(result) = item.get("result").and_then(Value::as_str) {
849 let (uri, media_type) = if result.starts_with("data:") {
850 let media_type = if result.contains(';') {
851 result
852 .strip_prefix("data:")
853 .and_then(|r| r.split(';').next())
854 .filter(|s| !s.is_empty())
855 .map(String::from)
856 } else {
857 None
858 };
859 (result.to_string(), media_type)
860 } else {
861 (
862 format!("data:image/png;base64,{result}"),
863 Some("image/png".to_string()),
864 )
865 };
866 contents.push(Content::Data(DataContent { uri, media_type }));
867 }
868 }
869 Some("mcp_approval_request") => {
873 let id = item
874 .get("id")
875 .and_then(Value::as_str)
876 .unwrap_or_default()
877 .to_string();
878 let name = item
879 .get("name")
880 .and_then(Value::as_str)
881 .unwrap_or_default()
882 .to_string();
883 let args = item
884 .get("arguments")
885 .and_then(Value::as_str)
886 .unwrap_or("{}")
887 .to_string();
888 contents.push(Content::FunctionApprovalRequest(
889 FunctionApprovalRequestContent {
890 id: id.clone(),
891 function_call: FunctionCallContent::new(
892 id,
893 name,
894 Some(FunctionArguments::Raw(args)),
895 ),
896 },
897 ));
898 }
899 _ => {}
900 }
901}
902
903fn parse_annotations(part: &Value) -> Option<Vec<Annotation>> {
908 let arr = part.get("annotations").and_then(Value::as_array)?;
909 let mut out = Vec::new();
910 for ann in arr {
911 let str_field = |k: &str| ann.get(k).and_then(Value::as_str).map(String::from);
912 let regions = || {
913 Some(vec![TextSpanRegion {
914 start_index: ann.get("start_index").and_then(Value::as_i64),
915 end_index: ann.get("end_index").and_then(Value::as_i64),
916 }])
917 };
918 match ann.get("type").and_then(Value::as_str) {
919 Some("file_path") => out.push(Annotation {
920 file_id: str_field("file_id"),
921 ..Default::default()
922 }),
923 Some("file_citation") => out.push(Annotation {
924 url: str_field("filename"),
925 file_id: str_field("file_id"),
926 ..Default::default()
927 }),
928 Some("url_citation") => out.push(Annotation {
929 title: str_field("title"),
930 url: str_field("url"),
931 annotated_regions: regions(),
932 ..Default::default()
933 }),
934 Some("container_file_citation") => out.push(Annotation {
935 file_id: str_field("file_id"),
936 url: str_field("filename"),
937 annotated_regions: regions(),
938 ..Default::default()
939 }),
940 _ => {}
941 }
942 }
943 if out.is_empty() {
944 None
945 } else {
946 Some(out)
947 }
948}
949
950fn finish_reason_from_response(value: &Value) -> Option<FinishReason> {
951 let has_function_call = value
952 .get("output")
953 .and_then(Value::as_array)
954 .map(|items| {
955 items
956 .iter()
957 .any(|i| i.get("type").and_then(Value::as_str) == Some("function_call"))
958 })
959 .unwrap_or(false);
960 if has_function_call {
961 return Some(FinishReason::tool_calls());
962 }
963 let status = value.get("status").and_then(Value::as_str)?;
964 Some(match status {
965 "completed" => FinishReason::stop(),
966 "incomplete" => match value
967 .get("incomplete_details")
968 .and_then(|d| d.get("reason"))
969 .and_then(Value::as_str)
970 {
971 Some("max_output_tokens") => FinishReason::new(FinishReason::LENGTH),
972 Some("content_filter") => FinishReason::new(FinishReason::CONTENT_FILTER),
973 Some(other) => FinishReason::new(other),
974 None => FinishReason::new("incomplete"),
975 },
976 other => FinishReason::new(other),
977 })
978}
979
980fn parse_responses_usage(usage: &Value) -> UsageDetails {
981 let mut details = UsageDetails {
982 input_token_count: usage.get("input_tokens").and_then(Value::as_u64),
983 output_token_count: usage.get("output_tokens").and_then(Value::as_u64),
984 total_token_count: usage.get("total_tokens").and_then(Value::as_u64),
985 ..Default::default()
986 };
987 if let Some(cached) = usage
988 .get("input_tokens_details")
989 .and_then(|d| d.get("cached_tokens"))
990 .and_then(Value::as_u64)
991 {
992 details
993 .additional_counts
994 .insert("openai.cached_input_tokens".into(), cached);
995 details.cache_read_input_token_count = Some(cached);
997 }
998 if let Some(cache_write) = usage
1001 .get("input_tokens_details")
1002 .and_then(|d| d.get("cache_write_tokens"))
1003 .and_then(Value::as_u64)
1004 {
1005 details
1006 .additional_counts
1007 .insert("openai.cache_write_tokens".into(), cache_write);
1008 details.cache_creation_input_token_count = Some(cache_write);
1009 }
1010 if let Some(reasoning) = usage
1011 .get("output_tokens_details")
1012 .and_then(|d| d.get("reasoning_tokens"))
1013 .and_then(Value::as_u64)
1014 {
1015 details
1016 .additional_counts
1017 .insert("openai.reasoning_tokens".into(), reasoning);
1018 details.reasoning_output_token_count = Some(reasoning);
1019 }
1020 details
1021}
1022
1023pub fn parse_responses_sse_stream(
1033 resp: reqwest::Response,
1034 store: Option<bool>,
1035) -> impl futures::Stream<Item = Result<ChatResponseUpdate>> + Send {
1036 let byte_stream: ByteStream = Box::pin(resp.bytes_stream());
1037 futures::stream::unfold(
1038 ResponsesSseState {
1039 byte_stream,
1040 buffer: String::new(),
1041 utf8: Utf8StreamDecoder::new(),
1042 queued: VecDeque::new(),
1043 call_ids: HashMap::new(),
1044 done: false,
1045 store,
1046 },
1047 |mut state| async move {
1048 loop {
1049 if let Some(update) = state.queued.pop_front() {
1050 return Some((Ok(update), state));
1051 }
1052 if state.done {
1053 return None;
1054 }
1055 match state.byte_stream.next().await {
1056 Some(Ok(bytes)) => {
1057 let decoded = state.utf8.push(&bytes);
1058 state.buffer.push_str(&decoded);
1059 while let Some(pos) = state.buffer.find('\n') {
1060 let line = state.buffer[..pos].trim().to_string();
1061 state.buffer.drain(..=pos);
1062 let Some(data) = line.strip_prefix("data:") else {
1063 continue;
1064 };
1065 let data = data.trim();
1066 if data.is_empty() {
1067 continue;
1068 }
1069 let Ok(value) = serde_json::from_str::<Value>(data) else {
1070 continue;
1071 };
1072 match parse_responses_event(&value, &mut state.call_ids, state.store) {
1073 EventOutcome::Update(update) => state.queued.push_back(update),
1074 EventOutcome::Error(e) => {
1075 state.done = true;
1076 return Some((Err(e), state));
1077 }
1078 EventOutcome::None => {}
1079 }
1080 }
1081 }
1082 Some(Err(e)) => {
1083 state.done = true;
1084 return Some((Err(Error::service(format!("stream error: {e}"))), state));
1085 }
1086 None => return None,
1087 }
1088 }
1089 },
1090 )
1091}
1092
1093struct ResponsesSseState {
1094 byte_stream: ByteStream,
1095 buffer: String,
1096 utf8: Utf8StreamDecoder,
1097 queued: VecDeque<ChatResponseUpdate>,
1098 call_ids: HashMap<i64, String>,
1102 done: bool,
1103 store: Option<bool>,
1104}
1105
1106#[allow(clippy::large_enum_variant)]
1111enum EventOutcome {
1112 Update(ChatResponseUpdate),
1113 Error(Error),
1114 None,
1115}
1116
1117fn reasoning_update(text: &str) -> EventOutcome {
1120 if text.is_empty() {
1121 return EventOutcome::None;
1122 }
1123 EventOutcome::Update(ChatResponseUpdate {
1124 contents: vec![Content::TextReasoning(TextReasoningContent {
1125 text: text.to_string(),
1126 annotations: None,
1127 ..Default::default()
1128 })],
1129 role: Some(Role::assistant()),
1130 ..Default::default()
1131 })
1132}
1133
1134fn parse_responses_event(
1136 value: &Value,
1137 call_ids: &mut HashMap<i64, String>,
1138 store: Option<bool>,
1139) -> EventOutcome {
1140 let event_type = value.get("type").and_then(Value::as_str).unwrap_or("");
1141 match event_type {
1142 "response.created" => {
1143 let resp = value.get("response");
1144 let response_id = resp
1145 .and_then(|r| r.get("id"))
1146 .and_then(Value::as_str)
1147 .map(String::from);
1148 let model = resp
1149 .and_then(|r| r.get("model"))
1150 .and_then(Value::as_str)
1151 .map(String::from);
1152 if response_id.is_none() && model.is_none() {
1153 return EventOutcome::None;
1154 }
1155 EventOutcome::Update(ChatResponseUpdate {
1156 role: Some(Role::assistant()),
1157 response_id,
1158 model,
1159 ..Default::default()
1160 })
1161 }
1162 "response.output_text.delta" => {
1163 let text = value.get("delta").and_then(Value::as_str).unwrap_or("");
1164 if text.is_empty() {
1165 return EventOutcome::None;
1166 }
1167 EventOutcome::Update(ChatResponseUpdate {
1168 contents: vec![Content::Text(TextContent::new(text))],
1169 role: Some(Role::assistant()),
1170 ..Default::default()
1171 })
1172 }
1173 "response.reasoning_text.delta" | "response.reasoning_summary_text.delta" => {
1178 reasoning_update(value.get("delta").and_then(Value::as_str).unwrap_or(""))
1179 }
1180 "response.reasoning_text.done" | "response.reasoning_summary_text.done" => {
1186 EventOutcome::None
1187 }
1188 "response.output_item.added" => {
1189 let item = value.get("item");
1190 if item.and_then(|i| i.get("type")).and_then(Value::as_str) != Some("function_call") {
1191 return EventOutcome::None;
1192 }
1193 let output_index = value
1194 .get("output_index")
1195 .and_then(Value::as_i64)
1196 .unwrap_or(0);
1197 let call_id = item
1198 .and_then(|i| i.get("call_id"))
1199 .and_then(Value::as_str)
1200 .unwrap_or_default()
1201 .to_string();
1202 let name = item
1203 .and_then(|i| i.get("name"))
1204 .and_then(Value::as_str)
1205 .unwrap_or_default()
1206 .to_string();
1207 call_ids.insert(output_index, call_id.clone());
1208 EventOutcome::Update(ChatResponseUpdate {
1209 contents: vec![Content::FunctionCall(FunctionCallContent::new(
1210 call_id, name, None,
1211 ))],
1212 role: Some(Role::assistant()),
1213 ..Default::default()
1214 })
1215 }
1216 "response.function_call_arguments.delta" => {
1217 let output_index = value
1218 .get("output_index")
1219 .and_then(Value::as_i64)
1220 .unwrap_or(0);
1221 let delta = value.get("delta").and_then(Value::as_str).unwrap_or("");
1222 match call_ids.get(&output_index) {
1223 Some(call_id) => EventOutcome::Update(ChatResponseUpdate {
1224 contents: vec![Content::FunctionCall(FunctionCallContent::new(
1225 call_id.clone(),
1226 "",
1227 Some(FunctionArguments::Raw(delta.to_string())),
1228 ))],
1229 role: Some(Role::assistant()),
1230 ..Default::default()
1231 }),
1232 None => EventOutcome::None,
1233 }
1234 }
1235 "response.completed" => {
1236 let resp = value.get("response");
1237 let response_id = resp
1238 .and_then(|r| r.get("id"))
1239 .and_then(Value::as_str)
1240 .map(String::from);
1241 let model = resp
1242 .and_then(|r| r.get("model"))
1243 .and_then(Value::as_str)
1244 .map(String::from);
1245 let mut contents = Vec::new();
1246 if let Some(usage) = resp.and_then(|r| r.get("usage")) {
1247 contents.push(Content::Usage(UsageContent {
1248 details: parse_responses_usage(usage),
1249 }));
1250 }
1251 let finish_reason = resp.and_then(finish_reason_from_response);
1252 let conversation_id = if store != Some(false) {
1253 response_id.clone()
1254 } else {
1255 None
1256 };
1257 EventOutcome::Update(ChatResponseUpdate {
1258 contents,
1259 role: Some(Role::assistant()),
1260 response_id,
1261 model,
1262 conversation_id,
1263 finish_reason,
1264 ..Default::default()
1265 })
1266 }
1267 "response.failed" | "error" => {
1268 let resp = value.get("response");
1269 let err_obj = resp
1270 .and_then(|r| r.get("error"))
1271 .or_else(|| value.get("error"));
1272 let msg = err_obj
1273 .and_then(|e| e.get("message"))
1274 .and_then(Value::as_str)
1275 .unwrap_or("response failed")
1276 .to_string();
1277 let code = err_obj.and_then(|e| e.get("code")).and_then(Value::as_str);
1281 EventOutcome::Error(match code {
1282 Some("content_filter") => Error::service_content_filter(msg),
1283 _ => Error::service(msg),
1284 })
1285 }
1286 "response.function_call_arguments.done"
1290 | "response.output_item.done"
1291 | "response.content_part.added"
1292 | "response.content_part.done"
1293 | "response.in_progress" => EventOutcome::None,
1294 _ => EventOutcome::None,
1295 }
1296}
1297
1298#[cfg(test)]
1301mod tests {
1302 use super::*;
1303 use agent_framework_core::tools::{ApprovalMode, ToolDefinition, ToolKind};
1304 use agent_framework_core::types::{
1305 FunctionApprovalResponseContent, FunctionResultContent, HostedFileContent,
1306 };
1307
1308 fn user(text: &str) -> Message {
1309 Message::user(text)
1310 }
1311
1312 fn user_with(contents: Vec<Content>) -> Message {
1313 Message::with_contents(Role::user(), contents)
1314 }
1315
1316 fn parse_item(item: Value) -> Vec<Content> {
1318 let mut contents = Vec::new();
1319 parse_output_item(&item, &mut contents);
1320 contents
1321 }
1322
1323 fn client() -> OpenAIChatClient {
1324 OpenAIChatClient::new("test-key", "gpt-4o-mini")
1325 }
1326
1327 #[test]
1330 fn build_body_simple_text() {
1331 let c = client();
1332 let body = c.build_body(&[user("Hello there")], &ChatOptions::new(), false);
1333 assert_eq!(
1334 body,
1335 json!({
1336 "model": "gpt-4o-mini",
1337 "input": [
1338 { "type": "message", "role": "user", "content": [
1339 { "type": "input_text", "text": "Hello there" }
1340 ]}
1341 ],
1342 "include": ["reasoning.encrypted_content"],
1345 })
1346 );
1347 }
1348
1349 #[test]
1350 fn responses_usage_parses_cache_write_tokens() {
1351 let d = parse_responses_usage(&json!({
1353 "input_tokens": 2000,
1354 "output_tokens": 60,
1355 "total_tokens": 2060,
1356 "input_tokens_details": { "cached_tokens": 0, "cache_write_tokens": 1024 },
1357 }));
1358 assert_eq!(d.cache_creation_input_token_count, Some(1024));
1359 assert_eq!(d.cache_read_input_token_count, Some(0));
1360 assert_eq!(
1361 d.additional_counts.get("openai.cache_write_tokens"),
1362 Some(&1024)
1363 );
1364 }
1365
1366 #[test]
1367 fn responses_usage_omits_cache_write_tokens_when_not_reported() {
1368 let d = parse_responses_usage(&json!({
1369 "input_tokens": 100,
1370 "output_tokens": 20,
1371 "input_tokens_details": { "cached_tokens": 40 },
1372 }));
1373 assert_eq!(d.cache_creation_input_token_count, None);
1374 assert!(!d
1375 .additional_counts
1376 .contains_key("openai.cache_write_tokens"));
1377 }
1378
1379 #[test]
1380 fn build_body_extracts_leading_system_message_as_instructions() {
1381 let c = client();
1382 let messages = vec![Message::system("Be terse."), user("Hi")];
1383 let body = c.build_body(&messages, &ChatOptions::new(), false);
1384 assert_eq!(body["instructions"], json!("Be terse."));
1385 assert_eq!(
1386 body["input"],
1387 json!([
1388 { "type": "message", "role": "user", "content": [
1389 { "type": "input_text", "text": "Hi" }
1390 ]}
1391 ])
1392 );
1393 }
1394
1395 #[test]
1396 fn build_body_combines_options_instructions_and_system_message() {
1397 let c = client();
1398 let messages = vec![Message::system("Also be nice."), user("Hi")];
1399 let options = ChatOptions::new().with_instructions("Be terse.");
1400 let body = c.build_body(&messages, &options, false);
1401 assert_eq!(body["instructions"], json!("Be terse.\n\nAlso be nice."));
1402 }
1403
1404 #[test]
1405 fn build_body_assistant_text_uses_output_text_type() {
1406 let c = client();
1407 let messages = vec![user("Hi"), Message::assistant("Hello!")];
1408 let body = c.build_body(&messages, &ChatOptions::new(), false);
1409 assert_eq!(
1410 body["input"][1],
1411 json!({ "type": "message", "role": "assistant", "content": [
1412 { "type": "output_text", "text": "Hello!" }
1413 ]})
1414 );
1415 }
1416
1417 #[test]
1418 fn build_body_function_call_round_trip() {
1419 let c = client();
1420 let call = FunctionCallContent::new(
1421 "call_1",
1422 "get_weather",
1423 Some(FunctionArguments::Raw(r#"{"city":"Paris"}"#.to_string())),
1424 );
1425 let assistant_msg =
1426 Message::with_contents(Role::assistant(), vec![Content::FunctionCall(call)]);
1427 let tool_msg = Message::with_contents(
1428 Role::tool(),
1429 vec![Content::FunctionResult(FunctionResultContent::new(
1430 "call_1",
1431 Some(json!("18C and sunny")),
1432 ))],
1433 );
1434 let body = c.build_body(
1435 &[user("weather?"), assistant_msg, tool_msg],
1436 &ChatOptions::new(),
1437 false,
1438 );
1439 assert_eq!(
1440 body["input"],
1441 json!([
1442 { "type": "message", "role": "user", "content": [
1443 { "type": "input_text", "text": "weather?" }
1444 ]},
1445 { "type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}" },
1446 { "type": "function_call_output", "call_id": "call_1", "output": "18C and sunny" },
1447 ])
1448 );
1449 }
1450
1451 #[test]
1452 fn build_body_tools_are_flat_not_nested() {
1453 let c = client();
1454 let tool = ToolDefinition {
1455 name: "get_weather".into(),
1456 description: "Get the weather".into(),
1457 parameters: json!({ "type": "object", "properties": {} }),
1458 kind: ToolKind::Function,
1459 approval_mode: ApprovalMode::NeverRequire,
1460 executor: None,
1461 };
1462 let options = ChatOptions::new().with_tool(tool);
1463 let body = c.build_body(&[user("hi")], &options, false);
1464 assert_eq!(
1465 body["tools"],
1466 json!([{
1467 "type": "function",
1468 "name": "get_weather",
1469 "description": "Get the weather",
1470 "parameters": { "type": "object", "properties": {} },
1471 }])
1472 );
1473 }
1474
1475 #[test]
1476 fn build_body_tool_choice_required_named() {
1477 let c = client();
1478 let options =
1479 ChatOptions::new().with_tool_choice(ToolMode::Required(Some("get_weather".into())));
1480 let body = c.build_body(&[user("hi")], &options, false);
1481 assert_eq!(
1482 body["tool_choice"],
1483 json!({ "type": "function", "name": "get_weather" })
1484 );
1485 }
1486
1487 #[test]
1488 fn stateless_request_asks_for_the_encrypted_reasoning_item() {
1489 let c = client();
1492 let body = c.build_body(&[user("hi")], &ChatOptions::new(), false);
1493 assert_eq!(body["include"], json!(["reasoning.encrypted_content"]));
1494 }
1495
1496 #[test]
1497 fn a_service_side_conversation_needs_no_encrypted_reasoning() {
1498 let mut options = ChatOptions::new();
1502 options.conversation_id = Some("resp_abc123".into());
1503 let body = client().build_body(&[user("hi")], &options, false);
1504 assert!(
1505 body.get("include").is_none(),
1506 "no include expected, got: {}",
1507 body
1508 );
1509 }
1510
1511 #[test]
1512 fn an_empty_conversation_id_is_not_service_side_storage() {
1513 let mut options = ChatOptions::new();
1516 options.conversation_id = Some(String::new());
1517 let body = client().build_body(&[user("hi")], &options, false);
1518 assert_eq!(body["include"], json!(["reasoning.encrypted_content"]));
1519 }
1520
1521 #[test]
1522 fn a_callers_own_include_entries_survive_and_are_not_duplicated() {
1523 let mut options = ChatOptions::new();
1525 options
1526 .additional_properties
1527 .insert("include".into(), json!(["file_search_call.results"]));
1528 let body = client().build_body(&[user("hi")], &options, false);
1529 assert_eq!(
1530 body["include"],
1531 json!(["file_search_call.results", "reasoning.encrypted_content"])
1532 );
1533
1534 let mut options = ChatOptions::new();
1536 options
1537 .additional_properties
1538 .insert("include".into(), json!(["reasoning.encrypted_content"]));
1539 let body = client().build_body(&[user("hi")], &options, false);
1540 assert_eq!(body["include"], json!(["reasoning.encrypted_content"]));
1541 }
1542
1543 #[test]
1544 fn an_explicit_empty_include_is_omitted_not_sent_as_an_empty_array() {
1545 let mut options = ChatOptions::new();
1550 options.conversation_id = Some("resp_abc123".into());
1551 options
1552 .additional_properties
1553 .insert("include".into(), json!([]));
1554 let body = client().build_body(&[user("hi")], &options, false);
1555 assert!(
1556 body.get("include").is_none(),
1557 "an empty include should be omitted entirely, got: {}",
1558 body
1559 );
1560 }
1561
1562 #[test]
1563 fn other_additional_properties_still_pass_through() {
1564 let mut options = ChatOptions::new();
1567 options
1568 .additional_properties
1569 .insert("safety_identifier".into(), json!("user-123"));
1570 let body = client().build_body(&[user("hi")], &options, false);
1571 assert_eq!(body["safety_identifier"], json!("user-123"));
1572 }
1573
1574 #[test]
1575 fn build_body_conversation_id_becomes_previous_response_id() {
1576 let c = client();
1577 let mut options = ChatOptions::new();
1578 options.conversation_id = Some("resp_abc123".into());
1579 let body = c.build_body(&[user("hi")], &options, false);
1580 assert_eq!(body["previous_response_id"], json!("resp_abc123"));
1581 }
1582
1583 #[test]
1584 fn build_body_max_tokens_becomes_max_output_tokens() {
1585 let c = client();
1586 let options = ChatOptions::new().with_max_tokens(256);
1587 let body = c.build_body(&[user("hi")], &options, false);
1588 assert_eq!(body["max_output_tokens"], json!(256));
1589 assert!(body.get("max_tokens").is_none());
1590 }
1591
1592 #[test]
1593 fn build_body_response_format_becomes_text_format() {
1594 let c = client();
1595 let mut options = ChatOptions::new();
1596 options.response_format = Some(ResponseFormat::JsonSchema {
1597 name: "answer".into(),
1598 description: None,
1599 schema: json!({"type": "object"}),
1600 strict: Some(true),
1601 });
1602 let body = c.build_body(&[user("hi")], &options, false);
1603 assert_eq!(
1604 body["text"]["format"],
1605 json!({ "type": "json_schema", "name": "answer", "schema": {"type": "object"}, "strict": true })
1606 );
1607 }
1608
1609 #[test]
1610 fn build_body_stream_flag() {
1611 let c = client();
1612 let body = c.build_body(&[user("hi")], &ChatOptions::new(), true);
1613 assert_eq!(body["stream"], json!(true));
1614 }
1615
1616 #[test]
1621 fn parse_response_text_and_usage() {
1622 let value = json!({
1623 "id": "resp_123",
1624 "model": "gpt-4o-mini",
1625 "status": "completed",
1626 "output": [
1627 { "type": "message", "role": "assistant", "content": [
1628 { "type": "output_text", "text": "Hello!" }
1629 ]}
1630 ],
1631 "usage": { "input_tokens": 10, "output_tokens": 5, "total_tokens": 15 },
1632 });
1633 let resp = parse_response(&value, None);
1634 assert_eq!(resp.response_id.as_deref(), Some("resp_123"));
1635 assert_eq!(resp.conversation_id.as_deref(), Some("resp_123"));
1636 assert_eq!(resp.text(), "Hello!");
1637 assert_eq!(resp.finish_reason, Some(FinishReason::stop()));
1638 let usage = resp.usage_details.unwrap();
1639 assert_eq!(usage.input_token_count, Some(10));
1640 assert_eq!(usage.output_token_count, Some(5));
1641 assert_eq!(usage.total_token_count, Some(15));
1642 }
1643
1644 #[test]
1645 fn parse_response_store_false_omits_conversation_id() {
1646 let value = json!({
1647 "id": "resp_123",
1648 "status": "completed",
1649 "output": [],
1650 });
1651 let resp = parse_response(&value, Some(false));
1652 assert_eq!(resp.conversation_id, None);
1653 }
1654
1655 #[test]
1656 fn parse_response_function_call_sets_tool_calls_finish_reason() {
1657 let value = json!({
1658 "id": "resp_123",
1659 "status": "completed",
1660 "output": [
1661 { "type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}" }
1662 ],
1663 });
1664 let resp = parse_response(&value, None);
1665 assert_eq!(resp.finish_reason, Some(FinishReason::tool_calls()));
1666 let calls = resp.function_calls();
1667 assert_eq!(calls.len(), 1);
1668 assert_eq!(calls[0].call_id, "call_1");
1669 assert_eq!(calls[0].name, "get_weather");
1670 }
1671
1672 #[test]
1673 fn parse_response_incomplete_max_output_tokens_is_length() {
1674 let value = json!({
1675 "id": "resp_123",
1676 "status": "incomplete",
1677 "incomplete_details": { "reason": "max_output_tokens" },
1678 "output": [],
1679 });
1680 let resp = parse_response(&value, None);
1681 assert_eq!(
1682 resp.finish_reason,
1683 Some(FinishReason::new(FinishReason::LENGTH))
1684 );
1685 }
1686
1687 #[test]
1688 fn parse_response_reasoning_becomes_text_reasoning() {
1689 let value = json!({
1690 "id": "resp_123",
1691 "status": "completed",
1692 "output": [
1693 { "type": "reasoning", "summary": [{ "type": "summary_text", "text": "thinking..." }] },
1694 { "type": "message", "role": "assistant", "content": [{ "type": "output_text", "text": "done" }] },
1695 ],
1696 });
1697 let resp = parse_response(&value, None);
1698 let contents = &resp.messages[0].contents;
1699 assert!(matches!(&contents[0], Content::TextReasoning(t) if t.text == "thinking..."));
1700 assert!(matches!(&contents[1], Content::Text(t) if t.text == "done"));
1701 }
1702
1703 #[test]
1704 fn reasoning_item_round_trips_through_input_for_store_false_replay() {
1705 let reasoning_item = json!({
1708 "type": "reasoning",
1709 "id": "rs_abc",
1710 "encrypted_content": "ENC",
1711 "summary": [{ "type": "summary_text", "text": "thinking..." }],
1712 });
1713 let value = json!({
1714 "id": "resp_1",
1715 "status": "completed",
1716 "output": [
1717 reasoning_item,
1718 { "type": "function_call", "call_id": "c1", "name": "f", "arguments": "{}" },
1719 ],
1720 });
1721 let resp = parse_response(&value, Some(false));
1722 let reasoning = resp.messages[0]
1724 .contents
1725 .iter()
1726 .find_map(|c| match c {
1727 Content::TextReasoning(t) => Some(t),
1728 _ => None,
1729 })
1730 .expect("reasoning content");
1731 assert_eq!(
1732 reasoning.raw_representation.as_ref().unwrap()["id"],
1733 "rs_abc"
1734 );
1735
1736 let input = messages_to_input(&resp.messages);
1739 let reasoning_pos = input
1740 .iter()
1741 .position(|i| i.get("type") == Some(&json!("reasoning")))
1742 .expect("reasoning item re-emitted");
1743 assert_eq!(input[reasoning_pos]["id"], "rs_abc");
1744 assert_eq!(input[reasoning_pos]["encrypted_content"], "ENC");
1745 let call_pos = input
1746 .iter()
1747 .position(|i| i.get("type") == Some(&json!("function_call")))
1748 .expect("function call present");
1749 assert!(reasoning_pos < call_pos, "reasoning must precede the call");
1750 }
1751
1752 #[test]
1753 fn summary_only_reasoning_is_not_re_emitted_as_input() {
1754 let msg = Message::with_contents(
1758 Role::assistant(),
1759 vec![Content::TextReasoning(TextReasoningContent {
1760 text: "just display".into(),
1761 annotations: None,
1762 raw_representation: None,
1763 protected_data: None,
1764 })],
1765 );
1766 let input = messages_to_input(&[msg]);
1767 assert!(input
1768 .iter()
1769 .all(|i| i.get("type") != Some(&json!("reasoning"))));
1770 }
1771
1772 fn sse_body(events: &[(&str, Value)]) -> String {
1777 let mut out = String::new();
1778 for (event, data) in events {
1779 out.push_str(&format!("event: {event}\ndata: {}\n\n", data));
1780 }
1781 out
1782 }
1783
1784 async fn collect_updates(text: String) -> Vec<ChatResponseUpdate> {
1785 let stream =
1790 futures::stream::once(async move { Ok::<_, reqwest::Error>(bytes::Bytes::from(text)) });
1791 let byte_stream: ByteStream = Box::pin(stream);
1792 let mut state = ResponsesSseState {
1793 byte_stream,
1794 buffer: String::new(),
1795 utf8: Utf8StreamDecoder::new(),
1796 queued: VecDeque::new(),
1797 call_ids: HashMap::new(),
1798 done: false,
1799 store: None,
1800 };
1801 let mut updates = Vec::new();
1802 if let Some(Ok(bytes)) = state.byte_stream.next().await {
1804 let decoded = state.utf8.push(&bytes);
1805 state.buffer.push_str(&decoded);
1806 while let Some(pos) = state.buffer.find('\n') {
1807 let line = state.buffer[..pos].trim().to_string();
1808 state.buffer.drain(..=pos);
1809 let Some(data) = line.strip_prefix("data:") else {
1810 continue;
1811 };
1812 let data = data.trim();
1813 if data.is_empty() {
1814 continue;
1815 }
1816 let value: Value = serde_json::from_str(data).unwrap();
1817 if let EventOutcome::Update(u) =
1818 parse_responses_event(&value, &mut state.call_ids, state.store)
1819 {
1820 updates.push(u);
1821 }
1822 }
1823 }
1824 updates
1825 }
1826
1827 #[tokio::test]
1828 async fn stream_text_only_accumulates() {
1829 let text = sse_body(&[
1830 (
1831 "response.created",
1832 json!({ "type": "response.created", "response": { "id": "resp_1", "model": "gpt-4o-mini" } }),
1833 ),
1834 (
1835 "response.output_text.delta",
1836 json!({ "type": "response.output_text.delta", "delta": "Hel" }),
1837 ),
1838 (
1839 "response.output_text.delta",
1840 json!({ "type": "response.output_text.delta", "delta": "lo!" }),
1841 ),
1842 (
1843 "response.completed",
1844 json!({ "type": "response.completed", "response": { "id": "resp_1", "model": "gpt-4o-mini", "status": "completed", "output": [], "usage": { "input_tokens": 3, "output_tokens": 2 } } }),
1845 ),
1846 ]);
1847 let updates = collect_updates(text).await;
1848 let resp = ChatResponse::from_updates(updates);
1849 assert_eq!(resp.text(), "Hello!");
1850 assert_eq!(resp.response_id.as_deref(), Some("resp_1"));
1851 assert_eq!(resp.finish_reason, Some(FinishReason::stop()));
1852 let usage = resp.usage_details.unwrap();
1853 assert_eq!(usage.input_token_count, Some(3));
1854 assert_eq!(usage.output_token_count, Some(2));
1855 }
1856
1857 #[tokio::test]
1858 async fn stream_tool_call_accumulates_arguments() {
1859 let text = sse_body(&[
1860 (
1861 "response.output_item.added",
1862 json!({ "type": "response.output_item.added", "output_index": 0, "item": { "type": "function_call", "call_id": "call_1", "name": "get_weather" } }),
1863 ),
1864 (
1865 "response.function_call_arguments.delta",
1866 json!({ "type": "response.function_call_arguments.delta", "output_index": 0, "delta": "{\"city\":" }),
1867 ),
1868 (
1869 "response.function_call_arguments.delta",
1870 json!({ "type": "response.function_call_arguments.delta", "output_index": 0, "delta": "\"Paris\"}" }),
1871 ),
1872 (
1873 "response.completed",
1874 json!({ "type": "response.completed", "response": { "id": "resp_2", "status": "completed", "output": [{"type":"function_call","call_id":"call_1","name":"get_weather","arguments":"{\"city\":\"Paris\"}"}] } }),
1875 ),
1876 ]);
1877 let updates = collect_updates(text).await;
1878 let resp = ChatResponse::from_updates(updates);
1879 let calls = resp.function_calls();
1880 assert_eq!(calls.len(), 1);
1881 assert_eq!(calls[0].call_id, "call_1");
1882 assert_eq!(calls[0].name, "get_weather");
1883 assert_eq!(
1884 calls[0].parse_arguments().unwrap().get("city").unwrap(),
1885 &json!("Paris")
1886 );
1887 assert_eq!(resp.finish_reason, Some(FinishReason::tool_calls()));
1888 }
1889
1890 #[tokio::test]
1891 async fn stream_failed_event_is_error() {
1892 let text = sse_body(&[(
1893 "response.failed",
1894 json!({ "type": "response.failed", "response": { "error": { "message": "boom" } } }),
1895 )]);
1896 let stream =
1897 futures::stream::once(async move { Ok::<_, reqwest::Error>(bytes::Bytes::from(text)) });
1898 let byte_stream: ByteStream = Box::pin(stream);
1899 let mut state = ResponsesSseState {
1900 byte_stream,
1901 buffer: String::new(),
1902 utf8: Utf8StreamDecoder::new(),
1903 queued: VecDeque::new(),
1904 call_ids: HashMap::new(),
1905 done: false,
1906 store: None,
1907 };
1908 let bytes = state.byte_stream.next().await.unwrap().unwrap();
1909 let decoded = state.utf8.push(&bytes);
1910 state.buffer.push_str(&decoded);
1911 let mut saw_error = false;
1912 while let Some(pos) = state.buffer.find('\n') {
1913 let line = state.buffer[..pos].trim().to_string();
1914 state.buffer.drain(..=pos);
1915 let Some(data) = line.strip_prefix("data:") else {
1916 continue;
1917 };
1918 let data = data.trim();
1919 if data.is_empty() {
1920 continue;
1921 }
1922 let value: Value = serde_json::from_str(data).unwrap();
1923 if let EventOutcome::Error(e) =
1924 parse_responses_event(&value, &mut state.call_ids, state.store)
1925 {
1926 assert!(e.to_string().contains("boom"));
1927 saw_error = true;
1928 }
1929 }
1930 assert!(
1931 saw_error,
1932 "expected a response.failed event to surface an error"
1933 );
1934 }
1935
1936 #[test]
1937 fn stream_failed_event_classifies_content_filter() {
1938 let mut call_ids = HashMap::new();
1941 let filtered = parse_responses_event(
1942 &json!({
1943 "type": "response.failed",
1944 "response": { "error": { "code": "content_filter", "message": "blocked" } }
1945 }),
1946 &mut call_ids,
1947 None,
1948 );
1949 let EventOutcome::Error(err) = filtered else {
1950 panic!("expected an error outcome");
1951 };
1952 assert!(matches!(err, Error::ServiceContentFilter { .. }));
1953
1954 let generic = parse_responses_event(
1955 &json!({
1956 "type": "response.failed",
1957 "response": { "error": { "code": "server_error", "message": "boom" } }
1958 }),
1959 &mut call_ids,
1960 None,
1961 );
1962 let EventOutcome::Error(err) = generic else {
1963 panic!("expected an error outcome");
1964 };
1965 assert!(matches!(err, Error::Service(_)));
1966 }
1967
1968 static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
1977
1978 #[test]
1979 fn from_env_reads_api_key_and_base_url() {
1980 let _guard = ENV_MUTEX.lock().unwrap();
1981 unsafe {
1984 std::env::set_var("OPENAI_API_KEY", "sk-test-123");
1985 std::env::set_var("OPENAI_BASE_URL", "https://example.test/v1");
1986 }
1987 let client = OpenAIChatClient::from_env("gpt-4o-mini").unwrap();
1988 assert_eq!(client.inner.api_key, "sk-test-123");
1989 assert_eq!(client.inner.base_url, "https://example.test/v1");
1990 unsafe {
1991 std::env::remove_var("OPENAI_API_KEY");
1992 std::env::remove_var("OPENAI_BASE_URL");
1993 }
1994 }
1995
1996 #[test]
1997 fn from_env_errors_when_api_key_missing() {
1998 let _guard = ENV_MUTEX.lock().unwrap();
1999 unsafe {
2001 std::env::remove_var("OPENAI_API_KEY");
2002 std::env::remove_var("OPENAI_BASE_URL");
2003 }
2004 let result = OpenAIChatClient::from_env("gpt-4o-mini");
2005 assert!(result.is_err());
2006 }
2007 #[test]
2008 fn build_body_maps_hosted_tools_to_responses_types() {
2009 use agent_framework_core::tools::{
2010 hosted_code_interpreter, hosted_file_search, hosted_mcp, hosted_web_search,
2011 };
2012 let c = client();
2013 let mut options = ChatOptions::new();
2014 options.tools = vec![
2015 hosted_web_search(),
2016 hosted_code_interpreter(),
2017 hosted_file_search(Some(7)),
2018 hosted_mcp(
2019 "docs",
2020 "https://mcp.example/sse",
2021 Some(vec!["search".into()]),
2022 ),
2023 ];
2024 let body = c.build_body(&[user("hi")], &options, false);
2025 let tools = body["tools"].as_array().unwrap();
2026 assert_eq!(tools[0], json!({ "type": "web_search" }));
2027 assert_eq!(
2028 tools[1],
2029 json!({ "type": "code_interpreter", "container": { "type": "auto" } })
2030 );
2031 assert_eq!(tools[2]["type"], "file_search");
2032 assert_eq!(tools[2]["max_num_results"], json!(7));
2033 assert_eq!(tools[3]["type"], "mcp");
2034 assert_eq!(tools[3]["server_url"], "https://mcp.example/sse");
2035 assert_eq!(tools[3]["allowed_tools"], json!(["search"]));
2036 assert_eq!(tools[3]["require_approval"], "never");
2037 }
2038
2039 #[test]
2044 fn input_image_uri_becomes_input_image_part() {
2045 let msg = user_with(vec![Content::Uri(UriContent {
2046 uri: "https://example.com/cat.png".into(),
2047 media_type: "image/png".into(),
2048 })]);
2049 let input = messages_to_input(&[msg]);
2050 assert_eq!(
2051 input[0],
2052 json!({ "type": "message", "role": "user", "content": [
2053 { "type": "input_image", "image_url": "https://example.com/cat.png", "detail": "auto" }
2054 ]})
2055 );
2056 }
2057
2058 #[test]
2059 fn input_audio_file_and_hosted_file_parts() {
2060 let msg = user_with(vec![
2061 Content::Data(DataContent {
2062 uri: "data:audio/wav;base64,QQ".into(),
2063 media_type: Some("audio/wav".into()),
2064 }),
2065 Content::Data(DataContent {
2066 uri: "data:application/pdf;base64,JV".into(),
2067 media_type: Some("application/pdf".into()),
2068 }),
2069 Content::HostedFile(HostedFileContent {
2070 file_id: "file-123".into(),
2071 }),
2072 ]);
2073 let input = messages_to_input(&[msg]);
2074 assert_eq!(
2078 input[0]["content"],
2079 json!([
2080 { "type": "input_audio", "input_audio": { "data": "QQ", "format": "wav" } },
2081 { "type": "input_file", "file_data": "data:application/pdf;base64,JV", "filename": "file" },
2082 { "type": "input_file", "file_id": "file-123" },
2083 ])
2084 );
2085 }
2086
2087 #[test]
2088 fn approval_response_becomes_mcp_approval_response_item() {
2089 let resp = FunctionApprovalResponseContent {
2090 approved: true,
2091 id: "appr_1".into(),
2092 function_call: FunctionCallContent::new("appr_1", "search", None),
2093 };
2094 let msg = user_with(vec![Content::FunctionApprovalResponse(resp)]);
2095 let input = messages_to_input(&[msg]);
2096 assert_eq!(
2097 input[0],
2098 json!({
2099 "type": "mcp_approval_response",
2100 "approval_request_id": "appr_1",
2101 "approve": true,
2102 })
2103 );
2104 }
2105
2106 #[test]
2107 fn approval_request_becomes_mcp_approval_request_item() {
2108 let req = FunctionApprovalRequestContent {
2109 id: "appr_1".into(),
2110 function_call: FunctionCallContent::new(
2111 "appr_1",
2112 "search",
2113 Some(FunctionArguments::Raw(r#"{"q":"x"}"#.into())),
2114 ),
2115 };
2116 let msg = Message::with_contents(
2117 Role::assistant(),
2118 vec![Content::FunctionApprovalRequest(req)],
2119 );
2120 let input = messages_to_input(&[msg]);
2121 assert_eq!(
2122 input[0],
2123 json!({
2124 "type": "mcp_approval_request",
2125 "id": "appr_1",
2126 "name": "search",
2127 "arguments": r#"{"q":"x"}"#,
2128 })
2129 );
2130 }
2131
2132 #[test]
2137 fn output_text_url_citation_annotation() {
2138 let contents = parse_item(json!({
2139 "type": "message", "role": "assistant", "content": [{
2140 "type": "output_text", "text": "See source.",
2141 "annotations": [{
2142 "type": "url_citation", "title": "Src", "url": "https://ex.com",
2143 "start_index": 0, "end_index": 3,
2144 }],
2145 }],
2146 }));
2147 let Content::Text(t) = &contents[0] else {
2148 panic!("expected text content");
2149 };
2150 let ann = t.annotations.as_ref().unwrap();
2151 assert_eq!(ann[0].title.as_deref(), Some("Src"));
2152 assert_eq!(ann[0].url.as_deref(), Some("https://ex.com"));
2153 let region = &ann[0].annotated_regions.as_ref().unwrap()[0];
2154 assert_eq!(region.start_index, Some(0));
2155 assert_eq!(region.end_index, Some(3));
2156 }
2157
2158 #[test]
2159 fn output_text_file_and_container_citations() {
2160 let contents = parse_item(json!({
2161 "type": "message", "role": "assistant", "content": [{
2162 "type": "output_text", "text": "x",
2163 "annotations": [
2164 { "type": "file_citation", "filename": "doc.pdf", "file_id": "file-1", "index": 2 },
2165 { "type": "file_path", "file_id": "file-2", "index": 0 },
2166 { "type": "container_file_citation", "filename": "c.txt", "file_id": "file-3",
2167 "container_id": "cont-1", "start_index": 1, "end_index": 4 },
2168 ],
2169 }],
2170 }));
2171 let Content::Text(t) = &contents[0] else {
2172 panic!("expected text content");
2173 };
2174 let ann = t.annotations.as_ref().unwrap();
2175 assert_eq!(ann[0].url.as_deref(), Some("doc.pdf"));
2176 assert_eq!(ann[0].file_id.as_deref(), Some("file-1"));
2177 assert_eq!(ann[1].file_id.as_deref(), Some("file-2"));
2178 assert_eq!(ann[2].file_id.as_deref(), Some("file-3"));
2179 assert_eq!(ann[2].url.as_deref(), Some("c.txt"));
2180 assert_eq!(
2181 ann[2].annotated_regions.as_ref().unwrap()[0].end_index,
2182 Some(4)
2183 );
2184 }
2185
2186 #[test]
2187 fn code_interpreter_outputs_become_text_and_uri() {
2188 let contents = parse_item(json!({
2189 "type": "code_interpreter_call",
2190 "outputs": [
2191 { "type": "logs", "logs": "hello stdout" },
2192 { "type": "image", "url": "https://ex.com/plot.png" },
2193 ],
2194 }));
2195 assert!(matches!(&contents[0], Content::Text(t) if t.text == "hello stdout"));
2196 assert!(
2197 matches!(&contents[1], Content::Uri(u) if u.uri == "https://ex.com/plot.png" && u.media_type == "image")
2198 );
2199 }
2200
2201 #[test]
2202 fn code_interpreter_without_outputs_falls_back_to_code() {
2203 let contents = parse_item(json!({
2204 "type": "code_interpreter_call", "code": "print(1)",
2205 }));
2206 assert!(matches!(&contents[0], Content::Text(t) if t.text == "print(1)"));
2207 }
2208
2209 #[test]
2210 fn image_generation_raw_base64_becomes_png_data() {
2211 let contents = parse_item(json!({
2212 "type": "image_generation_call", "result": "AAAABBBB",
2213 }));
2214 let Content::Data(d) = &contents[0] else {
2215 panic!("expected data content");
2216 };
2217 assert_eq!(d.uri, "data:image/png;base64,AAAABBBB");
2218 assert_eq!(d.media_type.as_deref(), Some("image/png"));
2219 }
2220
2221 #[test]
2222 fn image_generation_data_uri_keeps_stated_media_type() {
2223 let contents = parse_item(json!({
2224 "type": "image_generation_call", "result": "data:image/webp;base64,ZZZ",
2225 }));
2226 let Content::Data(d) = &contents[0] else {
2227 panic!("expected data content");
2228 };
2229 assert_eq!(d.uri, "data:image/webp;base64,ZZZ");
2230 assert_eq!(d.media_type.as_deref(), Some("image/webp"));
2231 }
2232
2233 #[test]
2234 fn mcp_approval_request_output_round_trips_into_response() {
2235 let contents = parse_item(json!({
2236 "type": "mcp_approval_request",
2237 "id": "appr_9", "name": "search", "arguments": r#"{"q":"rust"}"#,
2238 "server_label": "docs",
2239 }));
2240 let Content::FunctionApprovalRequest(req) = &contents[0] else {
2241 panic!("expected approval request");
2242 };
2243 assert_eq!(req.id, "appr_9");
2244 assert_eq!(req.function_call.call_id, "appr_9");
2245 assert_eq!(req.function_call.name, "search");
2246
2247 let msg = user_with(vec![Content::FunctionApprovalResponse(
2249 req.create_response(true),
2250 )]);
2251 let input = messages_to_input(&[msg]);
2252 assert_eq!(input[0]["type"], json!("mcp_approval_response"));
2253 assert_eq!(input[0]["approval_request_id"], json!("appr_9"));
2254 assert_eq!(input[0]["approve"], json!(true));
2255 }
2256
2257 fn reasoning_event(value: Value) -> EventOutcome {
2262 let mut ids = HashMap::new();
2263 parse_responses_event(&value, &mut ids, None)
2264 }
2265
2266 #[test]
2267 fn reasoning_text_delta_streams_and_done_is_terminal_metadata() {
2268 let EventOutcome::Update(delta) =
2269 reasoning_event(json!({ "type": "response.reasoning_text.delta", "delta": "Th" }))
2270 else {
2271 panic!("expected update");
2272 };
2273 assert!(matches!(&delta.contents[0], Content::TextReasoning(t) if t.text == "Th"));
2274
2275 let done =
2278 reasoning_event(json!({ "type": "response.reasoning_text.done", "text": "Think" }));
2279 assert!(matches!(done, EventOutcome::None));
2280 }
2281
2282 #[test]
2283 fn reasoning_summary_text_events_map_to_reasoning_content() {
2284 let EventOutcome::Update(delta) = reasoning_event(
2285 json!({ "type": "response.reasoning_summary_text.delta", "delta": "sum" }),
2286 ) else {
2287 panic!("expected update");
2288 };
2289 assert!(matches!(&delta.contents[0], Content::TextReasoning(t) if t.text == "sum"));
2290
2291 let done = reasoning_event(
2292 json!({ "type": "response.reasoning_summary_text.done", "text": "summary" }),
2293 );
2294 assert!(matches!(done, EventOutcome::None));
2295 }
2296
2297 #[test]
2298 fn response_failure_error_classifies_content_filter() {
2299 let filtered = json!({
2300 "status": "failed",
2301 "error": { "code": "content_filter", "message": "blocked" }
2302 });
2303 assert!(matches!(
2304 response_failure_error(&filtered),
2305 Some(Error::ServiceContentFilter { .. })
2306 ));
2307
2308 let generic = json!({
2309 "status": "failed",
2310 "error": { "code": "server_error", "message": "boom" }
2311 });
2312 assert!(matches!(
2313 response_failure_error(&generic),
2314 Some(Error::Service(_))
2315 ));
2316
2317 assert!(response_failure_error(&json!({ "status": "completed" })).is_none());
2318 }
2319
2320 #[test]
2321 fn input_audio_data_strips_data_uri_prefix() {
2322 let part = content_to_input_part("data:audio/wav;base64,QUJD", Some("audio/wav"))
2323 .expect("audio part");
2324 assert_eq!(part["input_audio"]["data"], "QUJD");
2325 assert_eq!(part["input_audio"]["format"], "wav");
2326 }
2327
2328 fn hosted(kind: ToolKind, name: &str, params: Value) -> ToolDefinition {
2333 ToolDefinition {
2334 name: name.into(),
2335 description: String::new(),
2336 parameters: params,
2337 kind,
2338 approval_mode: ApprovalMode::NeverRequire,
2339 executor: None,
2340 }
2341 }
2342
2343 #[test]
2344 fn web_search_passes_through_user_location() {
2345 let tool = hosted(
2346 ToolKind::HostedWebSearch,
2347 "web_search",
2348 json!({ "user_location": { "city": "Paris", "country": "FR" } }),
2349 );
2350 assert_eq!(
2351 tool_to_responses_spec(&tool),
2352 json!({
2353 "type": "web_search",
2354 "user_location": { "type": "approximate", "city": "Paris", "country": "FR" },
2355 })
2356 );
2357 }
2358
2359 #[test]
2360 fn file_search_passes_vector_store_ids_and_max_results_param() {
2361 let tool = hosted(
2362 ToolKind::HostedFileSearch { max_results: None },
2363 "file_search",
2364 json!({ "vector_store_ids": ["vs_1"], "max_results": 12 }),
2365 );
2366 let spec = tool_to_responses_spec(&tool);
2367 assert_eq!(spec["vector_store_ids"], json!(["vs_1"]));
2368 assert_eq!(spec["max_num_results"], json!(12));
2369 }
2370
2371 #[test]
2372 fn image_generation_maps_to_responses_tool_with_passthrough_params() {
2373 let tool = hosted(
2374 ToolKind::HostedImageGeneration,
2375 "image_generation",
2376 json!({ "size": "1024x1024", "quality": "high" }),
2377 );
2378 let spec = tool_to_responses_spec(&tool);
2379 assert_eq!(spec["type"], json!("image_generation"));
2380 assert_eq!(spec["size"], json!("1024x1024"));
2381 assert_eq!(spec["quality"], json!("high"));
2382 }
2383
2384 #[test]
2385 fn code_interpreter_passes_file_ids_and_container_override() {
2386 let with_files = hosted(
2387 ToolKind::HostedCodeInterpreter,
2388 "ci",
2389 json!({ "file_ids": ["file-1", "file-2"] }),
2390 );
2391 assert_eq!(
2392 tool_to_responses_spec(&with_files)["container"],
2393 json!({ "type": "auto", "file_ids": ["file-1", "file-2"] })
2394 );
2395 let with_container = hosted(
2396 ToolKind::HostedCodeInterpreter,
2397 "ci",
2398 json!({ "container": { "type": "secure", "id": "c1" } }),
2399 );
2400 assert_eq!(
2401 tool_to_responses_spec(&with_container)["container"],
2402 json!({ "type": "secure", "id": "c1" })
2403 );
2404 }
2405
2406 #[test]
2407 fn mcp_passes_headers_and_string_approval_mode_override() {
2408 let tool = hosted(
2409 ToolKind::HostedMcp {
2410 url: "https://mcp/sse".into(),
2411 allowed_tools: None,
2412 },
2413 "docs",
2414 json!({ "headers": { "Authorization": "Bearer x" }, "approval_mode": "always_require" }),
2415 );
2416 let spec = tool_to_responses_spec(&tool);
2418 assert_eq!(spec["headers"], json!({ "Authorization": "Bearer x" }));
2419 assert_eq!(spec["require_approval"], json!("always"));
2420 }
2421
2422 #[test]
2423 fn mcp_object_approval_mode_maps_to_tool_name_lists() {
2424 let tool = hosted(
2425 ToolKind::HostedMcp {
2426 url: "https://mcp/sse".into(),
2427 allowed_tools: None,
2428 },
2429 "docs",
2430 json!({ "approval_mode": { "always": ["delete"], "never": ["read"] } }),
2431 );
2432 assert_eq!(
2433 tool_to_responses_spec(&tool)["require_approval"],
2434 json!({ "always": { "tool_names": ["delete"] }, "never": { "tool_names": ["read"] } })
2435 );
2436 }
2437
2438 }