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 for (k, v) in &options.additional_properties {
168 body.entry(k.clone()).or_insert_with(|| v.clone());
169 }
170
171 if stream {
172 body.insert("stream".into(), json!(true));
173 }
174 Value::Object(body)
175 }
176
177 async fn post(&self, body: &Value) -> Result<reqwest::Response> {
178 let url = format!("{}/responses", self.inner.base_url.trim_end_matches('/'));
179 let mut req = self
180 .inner
181 .http
182 .post(&url)
183 .bearer_auth(&self.inner.api_key)
184 .json(body);
185 if let Some(org) = &self.inner.organization {
186 req = req.header("OpenAI-Organization", org);
187 }
188 let resp = req
189 .send()
190 .await
191 .map_err(|e| Error::service(format!("request failed: {e}")))?;
192 if !resp.status().is_success() {
193 let status = resp.status();
194 let retry_after = crate::parse_retry_after(resp.headers());
195 let text = resp.text().await.unwrap_or_default();
196 return Err(crate::classify_service_error(
197 status.as_u16(),
198 &text,
199 format!("OpenAI API error {status}: {text}"),
200 retry_after,
201 ));
202 }
203 Ok(resp)
204 }
205}
206
207#[async_trait::async_trait]
208impl ChatClient for OpenAIChatClient {
209 async fn get_response(
210 &self,
211 messages: Vec<Message>,
212 options: ChatOptions,
213 ) -> Result<ChatResponse> {
214 let body = self.build_body(&messages, &options, false);
215 let resp = self.post(&body).await?;
216 let value: Value = resp
217 .json()
218 .await
219 .map_err(|e| Error::service(format!("invalid response json: {e}")))?;
220 if let Some(err) = response_failure_error(&value) {
221 return Err(err);
222 }
223 Ok(parse_response(&value, options.store))
224 }
225
226 async fn get_streaming_response(
227 &self,
228 messages: Vec<Message>,
229 options: ChatOptions,
230 ) -> Result<ChatStream> {
231 let body = self.build_body(&messages, &options, true);
232 let resp = self.post(&body).await?;
233 Ok(parse_responses_sse_stream(resp, options.store).boxed())
234 }
235
236 fn model(&self) -> Option<&str> {
237 Some(&self.inner.model)
238 }
239}
240
241pub fn extract_instructions<'a>(
252 messages: &'a [Message],
253 options_instructions: Option<&str>,
254) -> (Option<String>, &'a [Message]) {
255 let mut parts = Vec::new();
256 if let Some(instr) = options_instructions {
257 if !instr.is_empty() {
258 parts.push(instr.to_string());
259 }
260 }
261 let mut rest = messages;
262 if let Some(first) = messages.first() {
263 if first.role == Role::system() {
264 let text = first.text();
265 if !text.is_empty() {
266 parts.push(text);
267 }
268 rest = &messages[1..];
269 }
270 }
271 if parts.is_empty() {
272 (None, rest)
273 } else {
274 (Some(parts.join("\n\n")), rest)
275 }
276}
277
278pub fn messages_to_input(messages: &[Message]) -> Vec<Value> {
284 let mut out = Vec::new();
285 for msg in messages {
286 let role = msg.role.as_str();
287 if role == Role::TOOL {
288 for content in &msg.contents {
289 if let Content::FunctionResult(fr) = content {
290 out.push(function_result_to_item(fr));
291 }
292 }
293 continue;
294 }
295
296 let mut buffered: Vec<Value> = Vec::new();
297 for content in &msg.contents {
298 match content {
299 Content::Text(t) => {
300 let text_type = if role == Role::ASSISTANT {
301 "output_text"
302 } else {
303 "input_text"
304 };
305 buffered.push(json!({ "type": text_type, "text": t.text }));
306 }
307 Content::Uri(u) => {
308 if let Some(part) = content_to_input_part(&u.uri, Some(&u.media_type)) {
309 buffered.push(part);
310 }
311 }
312 Content::Data(d) => {
313 if let Some(part) =
314 content_to_input_part(&d.uri, data_content_media_type(d).as_deref())
315 {
316 buffered.push(part);
317 }
318 }
319 Content::HostedFile(h) => {
320 buffered.push(json!({ "type": "input_file", "file_id": h.file_id }));
321 }
322 Content::FunctionCall(fc) => {
323 flush_text(&mut out, &mut buffered, role);
324 out.push(json!({
325 "type": "function_call",
326 "call_id": fc.call_id,
327 "name": fc.name,
328 "arguments": function_arguments_to_string(&fc.arguments),
329 }));
330 }
331 Content::FunctionResult(fr) => {
332 flush_text(&mut out, &mut buffered, role);
333 out.push(function_result_to_item(fr));
334 }
335 Content::FunctionApprovalResponse(r) => {
336 flush_text(&mut out, &mut buffered, role);
337 out.push(json!({
338 "type": "mcp_approval_response",
339 "approval_request_id": r.id,
340 "approve": r.approved,
341 }));
342 }
343 Content::FunctionApprovalRequest(r) => {
344 flush_text(&mut out, &mut buffered, role);
345 out.push(json!({
346 "type": "mcp_approval_request",
347 "id": r.id,
348 "name": r.function_call.name,
349 "arguments": function_arguments_to_string(&r.function_call.arguments),
350 }));
351 }
352 Content::TextReasoning(tr) => {
353 if let Some(raw) = &tr.raw_representation {
358 flush_text(&mut out, &mut buffered, role);
359 out.push(raw.clone());
360 }
361 }
362 _ => {}
363 }
364 }
365 flush_text(&mut out, &mut buffered, role);
366 }
367 out
368}
369
370fn content_to_input_part(uri: &str, media_type: Option<&str>) -> Option<Value> {
375 let media_type = media_type?;
376 match top_level_media_type(media_type).as_str() {
377 "image" => Some(json!({
379 "type": "input_image",
380 "image_url": uri,
381 "detail": "auto",
382 })),
383 "audio" => {
384 let format = audio_format(media_type)?;
385 Some(json!({
388 "type": "input_audio",
389 "input_audio": {
390 "data": crate::convert::strip_data_uri_prefix(uri),
391 "format": format,
392 },
393 }))
394 }
395 "application" => Some(json!({
396 "type": "input_file",
397 "file_data": uri,
398 "filename": DEFAULT_FILENAME,
399 })),
400 _ => None,
401 }
402}
403
404fn flush_text(out: &mut Vec<Value>, buffered: &mut Vec<Value>, role: &str) {
405 if !buffered.is_empty() {
406 out.push(json!({ "type": "message", "role": role, "content": std::mem::take(buffered) }));
407 }
408}
409
410fn function_result_to_item(fr: &FunctionResultContent) -> Value {
411 json!({
412 "type": "function_call_output",
413 "call_id": fr.call_id,
414 "output": result_to_string(fr),
415 })
416}
417
418pub fn tool_to_responses_spec(tool: &ToolDefinition) -> Value {
424 use agent_framework_core::tools::ToolKind;
425 match &tool.kind {
426 ToolKind::HostedWebSearch => {
427 let mut spec = Map::new();
428 spec.insert("type".into(), json!("web_search"));
429 if let Some(loc) = tool.parameters.get("user_location") {
430 spec.insert("user_location".into(), user_location_to_responses(loc));
431 }
432 Value::Object(spec)
433 }
434 ToolKind::HostedCodeInterpreter => {
435 let mut spec = Map::new();
436 spec.insert("type".into(), json!("code_interpreter"));
437 if let Some(container) = tool.parameters.get("container") {
440 spec.insert("container".into(), container.clone());
441 } else {
442 let mut container = Map::new();
443 container.insert("type".into(), json!("auto"));
444 if let Some(file_ids) = tool.parameters.get("file_ids") {
445 container.insert("file_ids".into(), file_ids.clone());
446 }
447 spec.insert("container".into(), Value::Object(container));
448 }
449 Value::Object(spec)
450 }
451 ToolKind::HostedFileSearch { max_results } => {
452 let mut spec = Map::new();
453 spec.insert("type".into(), json!("file_search"));
454 if let Some(ids) = tool.parameters.get("vector_store_ids") {
458 spec.insert("vector_store_ids".into(), ids.clone());
459 }
460 let max = (*max_results)
462 .map(|n| json!(n))
463 .or_else(|| tool.parameters.get("max_results").cloned());
464 if let Some(n) = max {
465 spec.insert("max_num_results".into(), n);
466 }
467 Value::Object(spec)
468 }
469 ToolKind::HostedMcp { url, allowed_tools } => {
470 let mut spec = Map::new();
471 spec.insert("type".into(), json!("mcp"));
472 spec.insert("server_label".into(), json!(tool.name.replace(' ', "_")));
473 spec.insert("server_url".into(), json!(url));
474 if !tool.description.is_empty() {
475 spec.insert("server_description".into(), json!(tool.description));
476 }
477 if let Some(headers) = tool.parameters.get("headers") {
478 spec.insert("headers".into(), headers.clone());
479 }
480 if let Some(allowed) = allowed_tools {
481 spec.insert("allowed_tools".into(), json!(allowed));
482 }
483 spec.insert("require_approval".into(), mcp_require_approval(tool));
484 Value::Object(spec)
485 }
486 ToolKind::HostedImageGeneration => {
487 let mut spec = Map::new();
488 spec.insert("type".into(), json!("image_generation"));
489 if let Value::Object(params) = &tool.parameters {
492 for (k, v) in params {
493 if k != "type" && k != "properties" {
494 spec.insert(k.clone(), v.clone());
495 }
496 }
497 }
498 Value::Object(spec)
499 }
500 ToolKind::Function => json!({
501 "type": "function",
502 "name": tool.name,
503 "description": tool.description,
504 "parameters": tool.parameters,
505 }),
506 }
507}
508
509fn user_location_to_responses(location: &Value) -> Value {
512 let mut loc = Map::new();
513 loc.insert("type".into(), json!("approximate"));
514 for key in ["city", "country", "region", "timezone"] {
515 if let Some(v) = location.get(key) {
516 loc.insert(key.into(), v.clone());
517 }
518 }
519 Value::Object(loc)
520}
521
522fn mcp_require_approval(tool: &ToolDefinition) -> Value {
528 use agent_framework_core::tools::ApprovalMode;
529 match tool.parameters.get("approval_mode") {
530 Some(Value::String(s)) => {
531 return json!(if s == "always_require" {
532 "always"
533 } else {
534 "never"
535 });
536 }
537 Some(Value::Object(modes)) => {
538 let mut req = Map::new();
539 if let Some(always) = modes.get("always") {
540 req.insert("always".into(), json!({ "tool_names": always }));
541 }
542 if let Some(never) = modes.get("never") {
543 req.insert("never".into(), json!({ "tool_names": never }));
544 }
545 if !req.is_empty() {
546 return Value::Object(req);
547 }
548 }
549 _ => {}
550 }
551 json!(match tool.approval_mode {
552 ApprovalMode::AlwaysRequire => "always",
553 ApprovalMode::NeverRequire => "never",
554 })
555}
556
557pub fn tool_choice_to_responses(mode: &ToolMode) -> Value {
560 match mode {
561 ToolMode::Auto => json!("auto"),
562 ToolMode::None => json!("none"),
563 ToolMode::Required(Some(name)) => json!({ "type": "function", "name": name }),
564 ToolMode::Required(None) => json!("required"),
565 }
566}
567
568pub fn response_format_to_text(format: &ResponseFormat) -> Value {
575 match format {
576 ResponseFormat::Text => json!({ "type": "text" }),
577 ResponseFormat::JsonObject => json!({ "type": "json_object" }),
578 ResponseFormat::JsonSchema {
579 name,
580 description,
581 schema,
582 strict,
583 } => {
584 let mut obj = Map::new();
585 obj.insert("type".into(), json!("json_schema"));
586 obj.insert("name".into(), json!(name));
587 if let Some(d) = description {
588 obj.insert("description".into(), json!(d));
589 }
590 obj.insert("schema".into(), schema.clone());
591 if let Some(st) = strict {
592 obj.insert("strict".into(), json!(st));
593 }
594 Value::Object(obj)
595 }
596 }
597}
598
599pub fn response_failure_error(value: &Value) -> Option<Error> {
615 if value.get("status").and_then(Value::as_str) != Some("failed") {
616 return None;
617 }
618 let error = value.get("error");
619 let msg = error
620 .and_then(|e| e.get("message"))
621 .and_then(Value::as_str)
622 .unwrap_or("response failed")
623 .to_string();
624 let code = error.and_then(|e| e.get("code")).and_then(Value::as_str);
625 Some(match code {
626 Some("content_filter") => Error::service_content_filter(msg),
627 _ => Error::service(msg),
628 })
629}
630
631pub fn parse_response(value: &Value, store: Option<bool>) -> ChatResponse {
636 let mut response = ChatResponse {
637 response_id: value.get("id").and_then(Value::as_str).map(String::from),
638 model: value.get("model").and_then(Value::as_str).map(String::from),
639 ..Default::default()
640 };
641
642 let mut contents: Vec<Content> = Vec::new();
643 if let Some(items) = value.get("output").and_then(Value::as_array) {
644 for item in items {
645 parse_output_item(item, &mut contents);
646 }
647 }
648
649 let mut message = Message::with_contents(Role::assistant(), contents);
650 message.message_id = response.response_id.clone();
651 response.messages.push(message);
652
653 response.finish_reason = finish_reason_from_response(value);
654
655 if let Some(usage) = value.get("usage") {
656 response.usage_details = Some(parse_responses_usage(usage));
657 }
658 if store != Some(false) {
659 response.conversation_id = response.response_id.clone();
660 }
661 response
662}
663
664fn parse_output_item(item: &Value, contents: &mut Vec<Content>) {
665 match item.get("type").and_then(Value::as_str) {
666 Some("message") => {
667 if let Some(parts) = item.get("content").and_then(Value::as_array) {
668 for part in parts {
669 match part.get("type").and_then(Value::as_str) {
670 Some("output_text") => {
671 if let Some(text) = part.get("text").and_then(Value::as_str) {
672 let mut tc = TextContent::new(text);
673 tc.annotations = parse_annotations(part);
674 contents.push(Content::Text(tc));
675 }
676 }
677 Some("refusal") => {
678 if let Some(text) = part.get("refusal").and_then(Value::as_str) {
679 contents.push(Content::Text(TextContent::new(text)));
680 }
681 }
682 _ => {}
683 }
684 }
685 }
686 }
687 Some("function_call") => {
688 let call_id = item
689 .get("call_id")
690 .and_then(Value::as_str)
691 .unwrap_or_default()
692 .to_string();
693 let name = item
694 .get("name")
695 .and_then(Value::as_str)
696 .unwrap_or_default()
697 .to_string();
698 let args = item
699 .get("arguments")
700 .and_then(Value::as_str)
701 .unwrap_or("{}")
702 .to_string();
703 contents.push(Content::FunctionCall(FunctionCallContent::new(
704 call_id,
705 name,
706 Some(FunctionArguments::Raw(args)),
707 )));
708 }
709 Some("reasoning") => {
710 let summaries: Vec<String> = item
711 .get("summary")
712 .and_then(Value::as_array)
713 .map(|arr| {
714 arr.iter()
715 .filter_map(|s| s.get("text").and_then(Value::as_str))
716 .map(str::to_string)
717 .collect()
718 })
719 .unwrap_or_default();
720 let raw = Some(item.clone());
727 if summaries.is_empty() {
728 contents.push(Content::TextReasoning(TextReasoningContent {
730 text: String::new(),
731 annotations: None,
732 raw_representation: raw,
733 }));
734 } else {
735 let n = summaries.len();
736 for (i, text) in summaries.into_iter().enumerate() {
737 contents.push(Content::TextReasoning(TextReasoningContent {
738 text,
739 annotations: None,
740 raw_representation: (i == n - 1).then(|| raw.clone()).flatten(),
741 }));
742 }
743 }
744 }
745 Some("code_interpreter_call") => {
749 let outputs = item
750 .get("outputs")
751 .and_then(Value::as_array)
752 .filter(|a| !a.is_empty());
753 if let Some(outputs) = outputs {
754 for output in outputs {
755 match output.get("type").and_then(Value::as_str) {
756 Some("logs") => {
757 if let Some(logs) = output.get("logs").and_then(Value::as_str) {
758 contents.push(Content::Text(TextContent::new(logs)));
759 }
760 }
761 Some("image") => {
762 if let Some(url) = output.get("url").and_then(Value::as_str) {
763 contents.push(Content::Uri(UriContent {
764 uri: url.to_string(),
765 media_type: "image".to_string(),
766 }));
767 }
768 }
769 _ => {}
770 }
771 }
772 } else if let Some(code) = item.get("code").and_then(Value::as_str) {
773 contents.push(Content::Text(TextContent::new(code)));
774 }
775 }
776 Some("image_generation_call") => {
780 if let Some(result) = item.get("result").and_then(Value::as_str) {
781 let (uri, media_type) = if result.starts_with("data:") {
782 let media_type = if result.contains(';') {
783 result
784 .strip_prefix("data:")
785 .and_then(|r| r.split(';').next())
786 .filter(|s| !s.is_empty())
787 .map(String::from)
788 } else {
789 None
790 };
791 (result.to_string(), media_type)
792 } else {
793 (
794 format!("data:image/png;base64,{result}"),
795 Some("image/png".to_string()),
796 )
797 };
798 contents.push(Content::Data(DataContent { uri, media_type }));
799 }
800 }
801 Some("mcp_approval_request") => {
805 let id = item
806 .get("id")
807 .and_then(Value::as_str)
808 .unwrap_or_default()
809 .to_string();
810 let name = item
811 .get("name")
812 .and_then(Value::as_str)
813 .unwrap_or_default()
814 .to_string();
815 let args = item
816 .get("arguments")
817 .and_then(Value::as_str)
818 .unwrap_or("{}")
819 .to_string();
820 contents.push(Content::FunctionApprovalRequest(
821 FunctionApprovalRequestContent {
822 id: id.clone(),
823 function_call: FunctionCallContent::new(
824 id,
825 name,
826 Some(FunctionArguments::Raw(args)),
827 ),
828 },
829 ));
830 }
831 _ => {}
832 }
833}
834
835fn parse_annotations(part: &Value) -> Option<Vec<Annotation>> {
840 let arr = part.get("annotations").and_then(Value::as_array)?;
841 let mut out = Vec::new();
842 for ann in arr {
843 let str_field = |k: &str| ann.get(k).and_then(Value::as_str).map(String::from);
844 let regions = || {
845 Some(vec![TextSpanRegion {
846 start_index: ann.get("start_index").and_then(Value::as_i64),
847 end_index: ann.get("end_index").and_then(Value::as_i64),
848 }])
849 };
850 match ann.get("type").and_then(Value::as_str) {
851 Some("file_path") => out.push(Annotation {
852 file_id: str_field("file_id"),
853 ..Default::default()
854 }),
855 Some("file_citation") => out.push(Annotation {
856 url: str_field("filename"),
857 file_id: str_field("file_id"),
858 ..Default::default()
859 }),
860 Some("url_citation") => out.push(Annotation {
861 title: str_field("title"),
862 url: str_field("url"),
863 annotated_regions: regions(),
864 ..Default::default()
865 }),
866 Some("container_file_citation") => out.push(Annotation {
867 file_id: str_field("file_id"),
868 url: str_field("filename"),
869 annotated_regions: regions(),
870 ..Default::default()
871 }),
872 _ => {}
873 }
874 }
875 if out.is_empty() {
876 None
877 } else {
878 Some(out)
879 }
880}
881
882fn finish_reason_from_response(value: &Value) -> Option<FinishReason> {
883 let has_function_call = value
884 .get("output")
885 .and_then(Value::as_array)
886 .map(|items| {
887 items
888 .iter()
889 .any(|i| i.get("type").and_then(Value::as_str) == Some("function_call"))
890 })
891 .unwrap_or(false);
892 if has_function_call {
893 return Some(FinishReason::tool_calls());
894 }
895 let status = value.get("status").and_then(Value::as_str)?;
896 Some(match status {
897 "completed" => FinishReason::stop(),
898 "incomplete" => match value
899 .get("incomplete_details")
900 .and_then(|d| d.get("reason"))
901 .and_then(Value::as_str)
902 {
903 Some("max_output_tokens") => FinishReason::new(FinishReason::LENGTH),
904 Some("content_filter") => FinishReason::new(FinishReason::CONTENT_FILTER),
905 Some(other) => FinishReason::new(other),
906 None => FinishReason::new("incomplete"),
907 },
908 other => FinishReason::new(other),
909 })
910}
911
912fn parse_responses_usage(usage: &Value) -> UsageDetails {
913 let mut details = UsageDetails {
914 input_token_count: usage.get("input_tokens").and_then(Value::as_u64),
915 output_token_count: usage.get("output_tokens").and_then(Value::as_u64),
916 total_token_count: usage.get("total_tokens").and_then(Value::as_u64),
917 ..Default::default()
918 };
919 if let Some(cached) = usage
920 .get("input_tokens_details")
921 .and_then(|d| d.get("cached_tokens"))
922 .and_then(Value::as_u64)
923 {
924 details
925 .additional_counts
926 .insert("openai.cached_input_tokens".into(), cached);
927 details.cache_read_input_token_count = Some(cached);
929 }
930 if let Some(reasoning) = usage
931 .get("output_tokens_details")
932 .and_then(|d| d.get("reasoning_tokens"))
933 .and_then(Value::as_u64)
934 {
935 details
936 .additional_counts
937 .insert("openai.reasoning_tokens".into(), reasoning);
938 details.reasoning_output_token_count = Some(reasoning);
939 }
940 details
941}
942
943pub fn parse_responses_sse_stream(
953 resp: reqwest::Response,
954 store: Option<bool>,
955) -> impl futures::Stream<Item = Result<ChatResponseUpdate>> + Send {
956 let byte_stream: ByteStream = Box::pin(resp.bytes_stream());
957 futures::stream::unfold(
958 ResponsesSseState {
959 byte_stream,
960 buffer: String::new(),
961 utf8: Utf8StreamDecoder::new(),
962 queued: VecDeque::new(),
963 call_ids: HashMap::new(),
964 done: false,
965 store,
966 },
967 |mut state| async move {
968 loop {
969 if let Some(update) = state.queued.pop_front() {
970 return Some((Ok(update), state));
971 }
972 if state.done {
973 return None;
974 }
975 match state.byte_stream.next().await {
976 Some(Ok(bytes)) => {
977 let decoded = state.utf8.push(&bytes);
978 state.buffer.push_str(&decoded);
979 while let Some(pos) = state.buffer.find('\n') {
980 let line = state.buffer[..pos].trim().to_string();
981 state.buffer.drain(..=pos);
982 let Some(data) = line.strip_prefix("data:") else {
983 continue;
984 };
985 let data = data.trim();
986 if data.is_empty() {
987 continue;
988 }
989 let Ok(value) = serde_json::from_str::<Value>(data) else {
990 continue;
991 };
992 match parse_responses_event(&value, &mut state.call_ids, state.store) {
993 EventOutcome::Update(update) => state.queued.push_back(update),
994 EventOutcome::Error(e) => {
995 state.done = true;
996 return Some((Err(e), state));
997 }
998 EventOutcome::None => {}
999 }
1000 }
1001 }
1002 Some(Err(e)) => {
1003 state.done = true;
1004 return Some((Err(Error::service(format!("stream error: {e}"))), state));
1005 }
1006 None => return None,
1007 }
1008 }
1009 },
1010 )
1011}
1012
1013struct ResponsesSseState {
1014 byte_stream: ByteStream,
1015 buffer: String,
1016 utf8: Utf8StreamDecoder,
1017 queued: VecDeque<ChatResponseUpdate>,
1018 call_ids: HashMap<i64, String>,
1022 done: bool,
1023 store: Option<bool>,
1024}
1025
1026#[allow(clippy::large_enum_variant)]
1031enum EventOutcome {
1032 Update(ChatResponseUpdate),
1033 Error(Error),
1034 None,
1035}
1036
1037fn reasoning_update(text: &str) -> EventOutcome {
1040 if text.is_empty() {
1041 return EventOutcome::None;
1042 }
1043 EventOutcome::Update(ChatResponseUpdate {
1044 contents: vec![Content::TextReasoning(TextReasoningContent {
1045 text: text.to_string(),
1046 annotations: None,
1047 ..Default::default()
1048 })],
1049 role: Some(Role::assistant()),
1050 ..Default::default()
1051 })
1052}
1053
1054fn parse_responses_event(
1056 value: &Value,
1057 call_ids: &mut HashMap<i64, String>,
1058 store: Option<bool>,
1059) -> EventOutcome {
1060 let event_type = value.get("type").and_then(Value::as_str).unwrap_or("");
1061 match event_type {
1062 "response.created" => {
1063 let resp = value.get("response");
1064 let response_id = resp
1065 .and_then(|r| r.get("id"))
1066 .and_then(Value::as_str)
1067 .map(String::from);
1068 let model = resp
1069 .and_then(|r| r.get("model"))
1070 .and_then(Value::as_str)
1071 .map(String::from);
1072 if response_id.is_none() && model.is_none() {
1073 return EventOutcome::None;
1074 }
1075 EventOutcome::Update(ChatResponseUpdate {
1076 role: Some(Role::assistant()),
1077 response_id,
1078 model,
1079 ..Default::default()
1080 })
1081 }
1082 "response.output_text.delta" => {
1083 let text = value.get("delta").and_then(Value::as_str).unwrap_or("");
1084 if text.is_empty() {
1085 return EventOutcome::None;
1086 }
1087 EventOutcome::Update(ChatResponseUpdate {
1088 contents: vec![Content::Text(TextContent::new(text))],
1089 role: Some(Role::assistant()),
1090 ..Default::default()
1091 })
1092 }
1093 "response.reasoning_text.delta" | "response.reasoning_summary_text.delta" => {
1098 reasoning_update(value.get("delta").and_then(Value::as_str).unwrap_or(""))
1099 }
1100 "response.reasoning_text.done" | "response.reasoning_summary_text.done" => {
1106 EventOutcome::None
1107 }
1108 "response.output_item.added" => {
1109 let item = value.get("item");
1110 if item.and_then(|i| i.get("type")).and_then(Value::as_str) != Some("function_call") {
1111 return EventOutcome::None;
1112 }
1113 let output_index = value
1114 .get("output_index")
1115 .and_then(Value::as_i64)
1116 .unwrap_or(0);
1117 let call_id = item
1118 .and_then(|i| i.get("call_id"))
1119 .and_then(Value::as_str)
1120 .unwrap_or_default()
1121 .to_string();
1122 let name = item
1123 .and_then(|i| i.get("name"))
1124 .and_then(Value::as_str)
1125 .unwrap_or_default()
1126 .to_string();
1127 call_ids.insert(output_index, call_id.clone());
1128 EventOutcome::Update(ChatResponseUpdate {
1129 contents: vec![Content::FunctionCall(FunctionCallContent::new(
1130 call_id, name, None,
1131 ))],
1132 role: Some(Role::assistant()),
1133 ..Default::default()
1134 })
1135 }
1136 "response.function_call_arguments.delta" => {
1137 let output_index = value
1138 .get("output_index")
1139 .and_then(Value::as_i64)
1140 .unwrap_or(0);
1141 let delta = value.get("delta").and_then(Value::as_str).unwrap_or("");
1142 match call_ids.get(&output_index) {
1143 Some(call_id) => EventOutcome::Update(ChatResponseUpdate {
1144 contents: vec![Content::FunctionCall(FunctionCallContent::new(
1145 call_id.clone(),
1146 "",
1147 Some(FunctionArguments::Raw(delta.to_string())),
1148 ))],
1149 role: Some(Role::assistant()),
1150 ..Default::default()
1151 }),
1152 None => EventOutcome::None,
1153 }
1154 }
1155 "response.completed" => {
1156 let resp = value.get("response");
1157 let response_id = resp
1158 .and_then(|r| r.get("id"))
1159 .and_then(Value::as_str)
1160 .map(String::from);
1161 let model = resp
1162 .and_then(|r| r.get("model"))
1163 .and_then(Value::as_str)
1164 .map(String::from);
1165 let mut contents = Vec::new();
1166 if let Some(usage) = resp.and_then(|r| r.get("usage")) {
1167 contents.push(Content::Usage(UsageContent {
1168 details: parse_responses_usage(usage),
1169 }));
1170 }
1171 let finish_reason = resp.and_then(finish_reason_from_response);
1172 let conversation_id = if store != Some(false) {
1173 response_id.clone()
1174 } else {
1175 None
1176 };
1177 EventOutcome::Update(ChatResponseUpdate {
1178 contents,
1179 role: Some(Role::assistant()),
1180 response_id,
1181 model,
1182 conversation_id,
1183 finish_reason,
1184 ..Default::default()
1185 })
1186 }
1187 "response.failed" | "error" => {
1188 let resp = value.get("response");
1189 let err_obj = resp
1190 .and_then(|r| r.get("error"))
1191 .or_else(|| value.get("error"));
1192 let msg = err_obj
1193 .and_then(|e| e.get("message"))
1194 .and_then(Value::as_str)
1195 .unwrap_or("response failed")
1196 .to_string();
1197 let code = err_obj.and_then(|e| e.get("code")).and_then(Value::as_str);
1201 EventOutcome::Error(match code {
1202 Some("content_filter") => Error::service_content_filter(msg),
1203 _ => Error::service(msg),
1204 })
1205 }
1206 "response.function_call_arguments.done"
1210 | "response.output_item.done"
1211 | "response.content_part.added"
1212 | "response.content_part.done"
1213 | "response.in_progress" => EventOutcome::None,
1214 _ => EventOutcome::None,
1215 }
1216}
1217
1218#[cfg(test)]
1221mod tests {
1222 use super::*;
1223 use agent_framework_core::tools::{ApprovalMode, ToolDefinition, ToolKind};
1224 use agent_framework_core::types::{
1225 FunctionApprovalResponseContent, FunctionResultContent, HostedFileContent,
1226 };
1227
1228 fn user(text: &str) -> Message {
1229 Message::user(text)
1230 }
1231
1232 fn user_with(contents: Vec<Content>) -> Message {
1233 Message::with_contents(Role::user(), contents)
1234 }
1235
1236 fn parse_item(item: Value) -> Vec<Content> {
1238 let mut contents = Vec::new();
1239 parse_output_item(&item, &mut contents);
1240 contents
1241 }
1242
1243 fn client() -> OpenAIChatClient {
1244 OpenAIChatClient::new("test-key", "gpt-4o-mini")
1245 }
1246
1247 #[test]
1250 fn build_body_simple_text() {
1251 let c = client();
1252 let body = c.build_body(&[user("Hello there")], &ChatOptions::new(), false);
1253 assert_eq!(
1254 body,
1255 json!({
1256 "model": "gpt-4o-mini",
1257 "input": [
1258 { "type": "message", "role": "user", "content": [
1259 { "type": "input_text", "text": "Hello there" }
1260 ]}
1261 ],
1262 })
1263 );
1264 }
1265
1266 #[test]
1267 fn build_body_extracts_leading_system_message_as_instructions() {
1268 let c = client();
1269 let messages = vec![Message::system("Be terse."), user("Hi")];
1270 let body = c.build_body(&messages, &ChatOptions::new(), false);
1271 assert_eq!(body["instructions"], json!("Be terse."));
1272 assert_eq!(
1273 body["input"],
1274 json!([
1275 { "type": "message", "role": "user", "content": [
1276 { "type": "input_text", "text": "Hi" }
1277 ]}
1278 ])
1279 );
1280 }
1281
1282 #[test]
1283 fn build_body_combines_options_instructions_and_system_message() {
1284 let c = client();
1285 let messages = vec![Message::system("Also be nice."), user("Hi")];
1286 let options = ChatOptions::new().with_instructions("Be terse.");
1287 let body = c.build_body(&messages, &options, false);
1288 assert_eq!(body["instructions"], json!("Be terse.\n\nAlso be nice."));
1289 }
1290
1291 #[test]
1292 fn build_body_assistant_text_uses_output_text_type() {
1293 let c = client();
1294 let messages = vec![user("Hi"), Message::assistant("Hello!")];
1295 let body = c.build_body(&messages, &ChatOptions::new(), false);
1296 assert_eq!(
1297 body["input"][1],
1298 json!({ "type": "message", "role": "assistant", "content": [
1299 { "type": "output_text", "text": "Hello!" }
1300 ]})
1301 );
1302 }
1303
1304 #[test]
1305 fn build_body_function_call_round_trip() {
1306 let c = client();
1307 let call = FunctionCallContent::new(
1308 "call_1",
1309 "get_weather",
1310 Some(FunctionArguments::Raw(r#"{"city":"Paris"}"#.to_string())),
1311 );
1312 let assistant_msg =
1313 Message::with_contents(Role::assistant(), vec![Content::FunctionCall(call)]);
1314 let tool_msg = Message::with_contents(
1315 Role::tool(),
1316 vec![Content::FunctionResult(FunctionResultContent::new(
1317 "call_1",
1318 Some(json!("18C and sunny")),
1319 ))],
1320 );
1321 let body = c.build_body(
1322 &[user("weather?"), assistant_msg, tool_msg],
1323 &ChatOptions::new(),
1324 false,
1325 );
1326 assert_eq!(
1327 body["input"],
1328 json!([
1329 { "type": "message", "role": "user", "content": [
1330 { "type": "input_text", "text": "weather?" }
1331 ]},
1332 { "type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}" },
1333 { "type": "function_call_output", "call_id": "call_1", "output": "18C and sunny" },
1334 ])
1335 );
1336 }
1337
1338 #[test]
1339 fn build_body_tools_are_flat_not_nested() {
1340 let c = client();
1341 let tool = ToolDefinition {
1342 name: "get_weather".into(),
1343 description: "Get the weather".into(),
1344 parameters: json!({ "type": "object", "properties": {} }),
1345 kind: ToolKind::Function,
1346 approval_mode: ApprovalMode::NeverRequire,
1347 executor: None,
1348 };
1349 let options = ChatOptions::new().with_tool(tool);
1350 let body = c.build_body(&[user("hi")], &options, false);
1351 assert_eq!(
1352 body["tools"],
1353 json!([{
1354 "type": "function",
1355 "name": "get_weather",
1356 "description": "Get the weather",
1357 "parameters": { "type": "object", "properties": {} },
1358 }])
1359 );
1360 }
1361
1362 #[test]
1363 fn build_body_tool_choice_required_named() {
1364 let c = client();
1365 let options =
1366 ChatOptions::new().with_tool_choice(ToolMode::Required(Some("get_weather".into())));
1367 let body = c.build_body(&[user("hi")], &options, false);
1368 assert_eq!(
1369 body["tool_choice"],
1370 json!({ "type": "function", "name": "get_weather" })
1371 );
1372 }
1373
1374 #[test]
1375 fn build_body_conversation_id_becomes_previous_response_id() {
1376 let c = client();
1377 let mut options = ChatOptions::new();
1378 options.conversation_id = Some("resp_abc123".into());
1379 let body = c.build_body(&[user("hi")], &options, false);
1380 assert_eq!(body["previous_response_id"], json!("resp_abc123"));
1381 }
1382
1383 #[test]
1384 fn build_body_max_tokens_becomes_max_output_tokens() {
1385 let c = client();
1386 let options = ChatOptions::new().with_max_tokens(256);
1387 let body = c.build_body(&[user("hi")], &options, false);
1388 assert_eq!(body["max_output_tokens"], json!(256));
1389 assert!(body.get("max_tokens").is_none());
1390 }
1391
1392 #[test]
1393 fn build_body_response_format_becomes_text_format() {
1394 let c = client();
1395 let mut options = ChatOptions::new();
1396 options.response_format = Some(ResponseFormat::JsonSchema {
1397 name: "answer".into(),
1398 description: None,
1399 schema: json!({"type": "object"}),
1400 strict: Some(true),
1401 });
1402 let body = c.build_body(&[user("hi")], &options, false);
1403 assert_eq!(
1404 body["text"]["format"],
1405 json!({ "type": "json_schema", "name": "answer", "schema": {"type": "object"}, "strict": true })
1406 );
1407 }
1408
1409 #[test]
1410 fn build_body_stream_flag() {
1411 let c = client();
1412 let body = c.build_body(&[user("hi")], &ChatOptions::new(), true);
1413 assert_eq!(body["stream"], json!(true));
1414 }
1415
1416 #[test]
1421 fn parse_response_text_and_usage() {
1422 let value = json!({
1423 "id": "resp_123",
1424 "model": "gpt-4o-mini",
1425 "status": "completed",
1426 "output": [
1427 { "type": "message", "role": "assistant", "content": [
1428 { "type": "output_text", "text": "Hello!" }
1429 ]}
1430 ],
1431 "usage": { "input_tokens": 10, "output_tokens": 5, "total_tokens": 15 },
1432 });
1433 let resp = parse_response(&value, None);
1434 assert_eq!(resp.response_id.as_deref(), Some("resp_123"));
1435 assert_eq!(resp.conversation_id.as_deref(), Some("resp_123"));
1436 assert_eq!(resp.text(), "Hello!");
1437 assert_eq!(resp.finish_reason, Some(FinishReason::stop()));
1438 let usage = resp.usage_details.unwrap();
1439 assert_eq!(usage.input_token_count, Some(10));
1440 assert_eq!(usage.output_token_count, Some(5));
1441 assert_eq!(usage.total_token_count, Some(15));
1442 }
1443
1444 #[test]
1445 fn parse_response_store_false_omits_conversation_id() {
1446 let value = json!({
1447 "id": "resp_123",
1448 "status": "completed",
1449 "output": [],
1450 });
1451 let resp = parse_response(&value, Some(false));
1452 assert_eq!(resp.conversation_id, None);
1453 }
1454
1455 #[test]
1456 fn parse_response_function_call_sets_tool_calls_finish_reason() {
1457 let value = json!({
1458 "id": "resp_123",
1459 "status": "completed",
1460 "output": [
1461 { "type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}" }
1462 ],
1463 });
1464 let resp = parse_response(&value, None);
1465 assert_eq!(resp.finish_reason, Some(FinishReason::tool_calls()));
1466 let calls = resp.function_calls();
1467 assert_eq!(calls.len(), 1);
1468 assert_eq!(calls[0].call_id, "call_1");
1469 assert_eq!(calls[0].name, "get_weather");
1470 }
1471
1472 #[test]
1473 fn parse_response_incomplete_max_output_tokens_is_length() {
1474 let value = json!({
1475 "id": "resp_123",
1476 "status": "incomplete",
1477 "incomplete_details": { "reason": "max_output_tokens" },
1478 "output": [],
1479 });
1480 let resp = parse_response(&value, None);
1481 assert_eq!(
1482 resp.finish_reason,
1483 Some(FinishReason::new(FinishReason::LENGTH))
1484 );
1485 }
1486
1487 #[test]
1488 fn parse_response_reasoning_becomes_text_reasoning() {
1489 let value = json!({
1490 "id": "resp_123",
1491 "status": "completed",
1492 "output": [
1493 { "type": "reasoning", "summary": [{ "type": "summary_text", "text": "thinking..." }] },
1494 { "type": "message", "role": "assistant", "content": [{ "type": "output_text", "text": "done" }] },
1495 ],
1496 });
1497 let resp = parse_response(&value, None);
1498 let contents = &resp.messages[0].contents;
1499 assert!(matches!(&contents[0], Content::TextReasoning(t) if t.text == "thinking..."));
1500 assert!(matches!(&contents[1], Content::Text(t) if t.text == "done"));
1501 }
1502
1503 #[test]
1504 fn reasoning_item_round_trips_through_input_for_store_false_replay() {
1505 let reasoning_item = json!({
1508 "type": "reasoning",
1509 "id": "rs_abc",
1510 "encrypted_content": "ENC",
1511 "summary": [{ "type": "summary_text", "text": "thinking..." }],
1512 });
1513 let value = json!({
1514 "id": "resp_1",
1515 "status": "completed",
1516 "output": [
1517 reasoning_item,
1518 { "type": "function_call", "call_id": "c1", "name": "f", "arguments": "{}" },
1519 ],
1520 });
1521 let resp = parse_response(&value, Some(false));
1522 let reasoning = resp.messages[0]
1524 .contents
1525 .iter()
1526 .find_map(|c| match c {
1527 Content::TextReasoning(t) => Some(t),
1528 _ => None,
1529 })
1530 .expect("reasoning content");
1531 assert_eq!(
1532 reasoning.raw_representation.as_ref().unwrap()["id"],
1533 "rs_abc"
1534 );
1535
1536 let input = messages_to_input(&resp.messages);
1539 let reasoning_pos = input
1540 .iter()
1541 .position(|i| i.get("type") == Some(&json!("reasoning")))
1542 .expect("reasoning item re-emitted");
1543 assert_eq!(input[reasoning_pos]["id"], "rs_abc");
1544 assert_eq!(input[reasoning_pos]["encrypted_content"], "ENC");
1545 let call_pos = input
1546 .iter()
1547 .position(|i| i.get("type") == Some(&json!("function_call")))
1548 .expect("function call present");
1549 assert!(reasoning_pos < call_pos, "reasoning must precede the call");
1550 }
1551
1552 #[test]
1553 fn summary_only_reasoning_is_not_re_emitted_as_input() {
1554 let msg = Message::with_contents(
1558 Role::assistant(),
1559 vec![Content::TextReasoning(TextReasoningContent {
1560 text: "just display".into(),
1561 annotations: None,
1562 raw_representation: None,
1563 })],
1564 );
1565 let input = messages_to_input(&[msg]);
1566 assert!(input
1567 .iter()
1568 .all(|i| i.get("type") != Some(&json!("reasoning"))));
1569 }
1570
1571 fn sse_body(events: &[(&str, Value)]) -> String {
1576 let mut out = String::new();
1577 for (event, data) in events {
1578 out.push_str(&format!("event: {event}\ndata: {}\n\n", data));
1579 }
1580 out
1581 }
1582
1583 async fn collect_updates(text: String) -> Vec<ChatResponseUpdate> {
1584 let stream =
1589 futures::stream::once(async move { Ok::<_, reqwest::Error>(bytes::Bytes::from(text)) });
1590 let byte_stream: ByteStream = Box::pin(stream);
1591 let mut state = ResponsesSseState {
1592 byte_stream,
1593 buffer: String::new(),
1594 utf8: Utf8StreamDecoder::new(),
1595 queued: VecDeque::new(),
1596 call_ids: HashMap::new(),
1597 done: false,
1598 store: None,
1599 };
1600 let mut updates = Vec::new();
1601 if let Some(Ok(bytes)) = state.byte_stream.next().await {
1603 let decoded = state.utf8.push(&bytes);
1604 state.buffer.push_str(&decoded);
1605 while let Some(pos) = state.buffer.find('\n') {
1606 let line = state.buffer[..pos].trim().to_string();
1607 state.buffer.drain(..=pos);
1608 let Some(data) = line.strip_prefix("data:") else {
1609 continue;
1610 };
1611 let data = data.trim();
1612 if data.is_empty() {
1613 continue;
1614 }
1615 let value: Value = serde_json::from_str(data).unwrap();
1616 if let EventOutcome::Update(u) =
1617 parse_responses_event(&value, &mut state.call_ids, state.store)
1618 {
1619 updates.push(u);
1620 }
1621 }
1622 }
1623 updates
1624 }
1625
1626 #[tokio::test]
1627 async fn stream_text_only_accumulates() {
1628 let text = sse_body(&[
1629 (
1630 "response.created",
1631 json!({ "type": "response.created", "response": { "id": "resp_1", "model": "gpt-4o-mini" } }),
1632 ),
1633 (
1634 "response.output_text.delta",
1635 json!({ "type": "response.output_text.delta", "delta": "Hel" }),
1636 ),
1637 (
1638 "response.output_text.delta",
1639 json!({ "type": "response.output_text.delta", "delta": "lo!" }),
1640 ),
1641 (
1642 "response.completed",
1643 json!({ "type": "response.completed", "response": { "id": "resp_1", "model": "gpt-4o-mini", "status": "completed", "output": [], "usage": { "input_tokens": 3, "output_tokens": 2 } } }),
1644 ),
1645 ]);
1646 let updates = collect_updates(text).await;
1647 let resp = ChatResponse::from_updates(updates);
1648 assert_eq!(resp.text(), "Hello!");
1649 assert_eq!(resp.response_id.as_deref(), Some("resp_1"));
1650 assert_eq!(resp.finish_reason, Some(FinishReason::stop()));
1651 let usage = resp.usage_details.unwrap();
1652 assert_eq!(usage.input_token_count, Some(3));
1653 assert_eq!(usage.output_token_count, Some(2));
1654 }
1655
1656 #[tokio::test]
1657 async fn stream_tool_call_accumulates_arguments() {
1658 let text = sse_body(&[
1659 (
1660 "response.output_item.added",
1661 json!({ "type": "response.output_item.added", "output_index": 0, "item": { "type": "function_call", "call_id": "call_1", "name": "get_weather" } }),
1662 ),
1663 (
1664 "response.function_call_arguments.delta",
1665 json!({ "type": "response.function_call_arguments.delta", "output_index": 0, "delta": "{\"city\":" }),
1666 ),
1667 (
1668 "response.function_call_arguments.delta",
1669 json!({ "type": "response.function_call_arguments.delta", "output_index": 0, "delta": "\"Paris\"}" }),
1670 ),
1671 (
1672 "response.completed",
1673 json!({ "type": "response.completed", "response": { "id": "resp_2", "status": "completed", "output": [{"type":"function_call","call_id":"call_1","name":"get_weather","arguments":"{\"city\":\"Paris\"}"}] } }),
1674 ),
1675 ]);
1676 let updates = collect_updates(text).await;
1677 let resp = ChatResponse::from_updates(updates);
1678 let calls = resp.function_calls();
1679 assert_eq!(calls.len(), 1);
1680 assert_eq!(calls[0].call_id, "call_1");
1681 assert_eq!(calls[0].name, "get_weather");
1682 assert_eq!(
1683 calls[0].parse_arguments().unwrap().get("city").unwrap(),
1684 &json!("Paris")
1685 );
1686 assert_eq!(resp.finish_reason, Some(FinishReason::tool_calls()));
1687 }
1688
1689 #[tokio::test]
1690 async fn stream_failed_event_is_error() {
1691 let text = sse_body(&[(
1692 "response.failed",
1693 json!({ "type": "response.failed", "response": { "error": { "message": "boom" } } }),
1694 )]);
1695 let stream =
1696 futures::stream::once(async move { Ok::<_, reqwest::Error>(bytes::Bytes::from(text)) });
1697 let byte_stream: ByteStream = Box::pin(stream);
1698 let mut state = ResponsesSseState {
1699 byte_stream,
1700 buffer: String::new(),
1701 utf8: Utf8StreamDecoder::new(),
1702 queued: VecDeque::new(),
1703 call_ids: HashMap::new(),
1704 done: false,
1705 store: None,
1706 };
1707 let bytes = state.byte_stream.next().await.unwrap().unwrap();
1708 let decoded = state.utf8.push(&bytes);
1709 state.buffer.push_str(&decoded);
1710 let mut saw_error = false;
1711 while let Some(pos) = state.buffer.find('\n') {
1712 let line = state.buffer[..pos].trim().to_string();
1713 state.buffer.drain(..=pos);
1714 let Some(data) = line.strip_prefix("data:") else {
1715 continue;
1716 };
1717 let data = data.trim();
1718 if data.is_empty() {
1719 continue;
1720 }
1721 let value: Value = serde_json::from_str(data).unwrap();
1722 if let EventOutcome::Error(e) =
1723 parse_responses_event(&value, &mut state.call_ids, state.store)
1724 {
1725 assert!(e.to_string().contains("boom"));
1726 saw_error = true;
1727 }
1728 }
1729 assert!(
1730 saw_error,
1731 "expected a response.failed event to surface an error"
1732 );
1733 }
1734
1735 #[test]
1736 fn stream_failed_event_classifies_content_filter() {
1737 let mut call_ids = HashMap::new();
1740 let filtered = parse_responses_event(
1741 &json!({
1742 "type": "response.failed",
1743 "response": { "error": { "code": "content_filter", "message": "blocked" } }
1744 }),
1745 &mut call_ids,
1746 None,
1747 );
1748 let EventOutcome::Error(err) = filtered else {
1749 panic!("expected an error outcome");
1750 };
1751 assert!(matches!(err, Error::ServiceContentFilter { .. }));
1752
1753 let generic = parse_responses_event(
1754 &json!({
1755 "type": "response.failed",
1756 "response": { "error": { "code": "server_error", "message": "boom" } }
1757 }),
1758 &mut call_ids,
1759 None,
1760 );
1761 let EventOutcome::Error(err) = generic else {
1762 panic!("expected an error outcome");
1763 };
1764 assert!(matches!(err, Error::Service(_)));
1765 }
1766
1767 static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
1776
1777 #[test]
1778 fn from_env_reads_api_key_and_base_url() {
1779 let _guard = ENV_MUTEX.lock().unwrap();
1780 unsafe {
1783 std::env::set_var("OPENAI_API_KEY", "sk-test-123");
1784 std::env::set_var("OPENAI_BASE_URL", "https://example.test/v1");
1785 }
1786 let client = OpenAIChatClient::from_env("gpt-4o-mini").unwrap();
1787 assert_eq!(client.inner.api_key, "sk-test-123");
1788 assert_eq!(client.inner.base_url, "https://example.test/v1");
1789 unsafe {
1790 std::env::remove_var("OPENAI_API_KEY");
1791 std::env::remove_var("OPENAI_BASE_URL");
1792 }
1793 }
1794
1795 #[test]
1796 fn from_env_errors_when_api_key_missing() {
1797 let _guard = ENV_MUTEX.lock().unwrap();
1798 unsafe {
1800 std::env::remove_var("OPENAI_API_KEY");
1801 std::env::remove_var("OPENAI_BASE_URL");
1802 }
1803 let result = OpenAIChatClient::from_env("gpt-4o-mini");
1804 assert!(result.is_err());
1805 }
1806 #[test]
1807 fn build_body_maps_hosted_tools_to_responses_types() {
1808 use agent_framework_core::tools::{
1809 hosted_code_interpreter, hosted_file_search, hosted_mcp, hosted_web_search,
1810 };
1811 let c = client();
1812 let mut options = ChatOptions::new();
1813 options.tools = vec![
1814 hosted_web_search(),
1815 hosted_code_interpreter(),
1816 hosted_file_search(Some(7)),
1817 hosted_mcp(
1818 "docs",
1819 "https://mcp.example/sse",
1820 Some(vec!["search".into()]),
1821 ),
1822 ];
1823 let body = c.build_body(&[user("hi")], &options, false);
1824 let tools = body["tools"].as_array().unwrap();
1825 assert_eq!(tools[0], json!({ "type": "web_search" }));
1826 assert_eq!(
1827 tools[1],
1828 json!({ "type": "code_interpreter", "container": { "type": "auto" } })
1829 );
1830 assert_eq!(tools[2]["type"], "file_search");
1831 assert_eq!(tools[2]["max_num_results"], json!(7));
1832 assert_eq!(tools[3]["type"], "mcp");
1833 assert_eq!(tools[3]["server_url"], "https://mcp.example/sse");
1834 assert_eq!(tools[3]["allowed_tools"], json!(["search"]));
1835 assert_eq!(tools[3]["require_approval"], "never");
1836 }
1837
1838 #[test]
1843 fn input_image_uri_becomes_input_image_part() {
1844 let msg = user_with(vec![Content::Uri(UriContent {
1845 uri: "https://example.com/cat.png".into(),
1846 media_type: "image/png".into(),
1847 })]);
1848 let input = messages_to_input(&[msg]);
1849 assert_eq!(
1850 input[0],
1851 json!({ "type": "message", "role": "user", "content": [
1852 { "type": "input_image", "image_url": "https://example.com/cat.png", "detail": "auto" }
1853 ]})
1854 );
1855 }
1856
1857 #[test]
1858 fn input_audio_file_and_hosted_file_parts() {
1859 let msg = user_with(vec![
1860 Content::Data(DataContent {
1861 uri: "data:audio/wav;base64,QQ".into(),
1862 media_type: Some("audio/wav".into()),
1863 }),
1864 Content::Data(DataContent {
1865 uri: "data:application/pdf;base64,JV".into(),
1866 media_type: Some("application/pdf".into()),
1867 }),
1868 Content::HostedFile(HostedFileContent {
1869 file_id: "file-123".into(),
1870 }),
1871 ]);
1872 let input = messages_to_input(&[msg]);
1873 assert_eq!(
1877 input[0]["content"],
1878 json!([
1879 { "type": "input_audio", "input_audio": { "data": "QQ", "format": "wav" } },
1880 { "type": "input_file", "file_data": "data:application/pdf;base64,JV", "filename": "file" },
1881 { "type": "input_file", "file_id": "file-123" },
1882 ])
1883 );
1884 }
1885
1886 #[test]
1887 fn approval_response_becomes_mcp_approval_response_item() {
1888 let resp = FunctionApprovalResponseContent {
1889 approved: true,
1890 id: "appr_1".into(),
1891 function_call: FunctionCallContent::new("appr_1", "search", None),
1892 };
1893 let msg = user_with(vec![Content::FunctionApprovalResponse(resp)]);
1894 let input = messages_to_input(&[msg]);
1895 assert_eq!(
1896 input[0],
1897 json!({
1898 "type": "mcp_approval_response",
1899 "approval_request_id": "appr_1",
1900 "approve": true,
1901 })
1902 );
1903 }
1904
1905 #[test]
1906 fn approval_request_becomes_mcp_approval_request_item() {
1907 let req = FunctionApprovalRequestContent {
1908 id: "appr_1".into(),
1909 function_call: FunctionCallContent::new(
1910 "appr_1",
1911 "search",
1912 Some(FunctionArguments::Raw(r#"{"q":"x"}"#.into())),
1913 ),
1914 };
1915 let msg = Message::with_contents(
1916 Role::assistant(),
1917 vec![Content::FunctionApprovalRequest(req)],
1918 );
1919 let input = messages_to_input(&[msg]);
1920 assert_eq!(
1921 input[0],
1922 json!({
1923 "type": "mcp_approval_request",
1924 "id": "appr_1",
1925 "name": "search",
1926 "arguments": r#"{"q":"x"}"#,
1927 })
1928 );
1929 }
1930
1931 #[test]
1936 fn output_text_url_citation_annotation() {
1937 let contents = parse_item(json!({
1938 "type": "message", "role": "assistant", "content": [{
1939 "type": "output_text", "text": "See source.",
1940 "annotations": [{
1941 "type": "url_citation", "title": "Src", "url": "https://ex.com",
1942 "start_index": 0, "end_index": 3,
1943 }],
1944 }],
1945 }));
1946 let Content::Text(t) = &contents[0] else {
1947 panic!("expected text content");
1948 };
1949 let ann = t.annotations.as_ref().unwrap();
1950 assert_eq!(ann[0].title.as_deref(), Some("Src"));
1951 assert_eq!(ann[0].url.as_deref(), Some("https://ex.com"));
1952 let region = &ann[0].annotated_regions.as_ref().unwrap()[0];
1953 assert_eq!(region.start_index, Some(0));
1954 assert_eq!(region.end_index, Some(3));
1955 }
1956
1957 #[test]
1958 fn output_text_file_and_container_citations() {
1959 let contents = parse_item(json!({
1960 "type": "message", "role": "assistant", "content": [{
1961 "type": "output_text", "text": "x",
1962 "annotations": [
1963 { "type": "file_citation", "filename": "doc.pdf", "file_id": "file-1", "index": 2 },
1964 { "type": "file_path", "file_id": "file-2", "index": 0 },
1965 { "type": "container_file_citation", "filename": "c.txt", "file_id": "file-3",
1966 "container_id": "cont-1", "start_index": 1, "end_index": 4 },
1967 ],
1968 }],
1969 }));
1970 let Content::Text(t) = &contents[0] else {
1971 panic!("expected text content");
1972 };
1973 let ann = t.annotations.as_ref().unwrap();
1974 assert_eq!(ann[0].url.as_deref(), Some("doc.pdf"));
1975 assert_eq!(ann[0].file_id.as_deref(), Some("file-1"));
1976 assert_eq!(ann[1].file_id.as_deref(), Some("file-2"));
1977 assert_eq!(ann[2].file_id.as_deref(), Some("file-3"));
1978 assert_eq!(ann[2].url.as_deref(), Some("c.txt"));
1979 assert_eq!(
1980 ann[2].annotated_regions.as_ref().unwrap()[0].end_index,
1981 Some(4)
1982 );
1983 }
1984
1985 #[test]
1986 fn code_interpreter_outputs_become_text_and_uri() {
1987 let contents = parse_item(json!({
1988 "type": "code_interpreter_call",
1989 "outputs": [
1990 { "type": "logs", "logs": "hello stdout" },
1991 { "type": "image", "url": "https://ex.com/plot.png" },
1992 ],
1993 }));
1994 assert!(matches!(&contents[0], Content::Text(t) if t.text == "hello stdout"));
1995 assert!(
1996 matches!(&contents[1], Content::Uri(u) if u.uri == "https://ex.com/plot.png" && u.media_type == "image")
1997 );
1998 }
1999
2000 #[test]
2001 fn code_interpreter_without_outputs_falls_back_to_code() {
2002 let contents = parse_item(json!({
2003 "type": "code_interpreter_call", "code": "print(1)",
2004 }));
2005 assert!(matches!(&contents[0], Content::Text(t) if t.text == "print(1)"));
2006 }
2007
2008 #[test]
2009 fn image_generation_raw_base64_becomes_png_data() {
2010 let contents = parse_item(json!({
2011 "type": "image_generation_call", "result": "AAAABBBB",
2012 }));
2013 let Content::Data(d) = &contents[0] else {
2014 panic!("expected data content");
2015 };
2016 assert_eq!(d.uri, "data:image/png;base64,AAAABBBB");
2017 assert_eq!(d.media_type.as_deref(), Some("image/png"));
2018 }
2019
2020 #[test]
2021 fn image_generation_data_uri_keeps_stated_media_type() {
2022 let contents = parse_item(json!({
2023 "type": "image_generation_call", "result": "data:image/webp;base64,ZZZ",
2024 }));
2025 let Content::Data(d) = &contents[0] else {
2026 panic!("expected data content");
2027 };
2028 assert_eq!(d.uri, "data:image/webp;base64,ZZZ");
2029 assert_eq!(d.media_type.as_deref(), Some("image/webp"));
2030 }
2031
2032 #[test]
2033 fn mcp_approval_request_output_round_trips_into_response() {
2034 let contents = parse_item(json!({
2035 "type": "mcp_approval_request",
2036 "id": "appr_9", "name": "search", "arguments": r#"{"q":"rust"}"#,
2037 "server_label": "docs",
2038 }));
2039 let Content::FunctionApprovalRequest(req) = &contents[0] else {
2040 panic!("expected approval request");
2041 };
2042 assert_eq!(req.id, "appr_9");
2043 assert_eq!(req.function_call.call_id, "appr_9");
2044 assert_eq!(req.function_call.name, "search");
2045
2046 let msg = user_with(vec![Content::FunctionApprovalResponse(
2048 req.create_response(true),
2049 )]);
2050 let input = messages_to_input(&[msg]);
2051 assert_eq!(input[0]["type"], json!("mcp_approval_response"));
2052 assert_eq!(input[0]["approval_request_id"], json!("appr_9"));
2053 assert_eq!(input[0]["approve"], json!(true));
2054 }
2055
2056 fn reasoning_event(value: Value) -> EventOutcome {
2061 let mut ids = HashMap::new();
2062 parse_responses_event(&value, &mut ids, None)
2063 }
2064
2065 #[test]
2066 fn reasoning_text_delta_streams_and_done_is_terminal_metadata() {
2067 let EventOutcome::Update(delta) =
2068 reasoning_event(json!({ "type": "response.reasoning_text.delta", "delta": "Th" }))
2069 else {
2070 panic!("expected update");
2071 };
2072 assert!(matches!(&delta.contents[0], Content::TextReasoning(t) if t.text == "Th"));
2073
2074 let done =
2077 reasoning_event(json!({ "type": "response.reasoning_text.done", "text": "Think" }));
2078 assert!(matches!(done, EventOutcome::None));
2079 }
2080
2081 #[test]
2082 fn reasoning_summary_text_events_map_to_reasoning_content() {
2083 let EventOutcome::Update(delta) = reasoning_event(
2084 json!({ "type": "response.reasoning_summary_text.delta", "delta": "sum" }),
2085 ) else {
2086 panic!("expected update");
2087 };
2088 assert!(matches!(&delta.contents[0], Content::TextReasoning(t) if t.text == "sum"));
2089
2090 let done = reasoning_event(
2091 json!({ "type": "response.reasoning_summary_text.done", "text": "summary" }),
2092 );
2093 assert!(matches!(done, EventOutcome::None));
2094 }
2095
2096 #[test]
2097 fn response_failure_error_classifies_content_filter() {
2098 let filtered = json!({
2099 "status": "failed",
2100 "error": { "code": "content_filter", "message": "blocked" }
2101 });
2102 assert!(matches!(
2103 response_failure_error(&filtered),
2104 Some(Error::ServiceContentFilter { .. })
2105 ));
2106
2107 let generic = json!({
2108 "status": "failed",
2109 "error": { "code": "server_error", "message": "boom" }
2110 });
2111 assert!(matches!(
2112 response_failure_error(&generic),
2113 Some(Error::Service(_))
2114 ));
2115
2116 assert!(response_failure_error(&json!({ "status": "completed" })).is_none());
2117 }
2118
2119 #[test]
2120 fn input_audio_data_strips_data_uri_prefix() {
2121 let part = content_to_input_part("data:audio/wav;base64,QUJD", Some("audio/wav"))
2122 .expect("audio part");
2123 assert_eq!(part["input_audio"]["data"], "QUJD");
2124 assert_eq!(part["input_audio"]["format"], "wav");
2125 }
2126
2127 fn hosted(kind: ToolKind, name: &str, params: Value) -> ToolDefinition {
2132 ToolDefinition {
2133 name: name.into(),
2134 description: String::new(),
2135 parameters: params,
2136 kind,
2137 approval_mode: ApprovalMode::NeverRequire,
2138 executor: None,
2139 }
2140 }
2141
2142 #[test]
2143 fn web_search_passes_through_user_location() {
2144 let tool = hosted(
2145 ToolKind::HostedWebSearch,
2146 "web_search",
2147 json!({ "user_location": { "city": "Paris", "country": "FR" } }),
2148 );
2149 assert_eq!(
2150 tool_to_responses_spec(&tool),
2151 json!({
2152 "type": "web_search",
2153 "user_location": { "type": "approximate", "city": "Paris", "country": "FR" },
2154 })
2155 );
2156 }
2157
2158 #[test]
2159 fn file_search_passes_vector_store_ids_and_max_results_param() {
2160 let tool = hosted(
2161 ToolKind::HostedFileSearch { max_results: None },
2162 "file_search",
2163 json!({ "vector_store_ids": ["vs_1"], "max_results": 12 }),
2164 );
2165 let spec = tool_to_responses_spec(&tool);
2166 assert_eq!(spec["vector_store_ids"], json!(["vs_1"]));
2167 assert_eq!(spec["max_num_results"], json!(12));
2168 }
2169
2170 #[test]
2171 fn image_generation_maps_to_responses_tool_with_passthrough_params() {
2172 let tool = hosted(
2173 ToolKind::HostedImageGeneration,
2174 "image_generation",
2175 json!({ "size": "1024x1024", "quality": "high" }),
2176 );
2177 let spec = tool_to_responses_spec(&tool);
2178 assert_eq!(spec["type"], json!("image_generation"));
2179 assert_eq!(spec["size"], json!("1024x1024"));
2180 assert_eq!(spec["quality"], json!("high"));
2181 }
2182
2183 #[test]
2184 fn code_interpreter_passes_file_ids_and_container_override() {
2185 let with_files = hosted(
2186 ToolKind::HostedCodeInterpreter,
2187 "ci",
2188 json!({ "file_ids": ["file-1", "file-2"] }),
2189 );
2190 assert_eq!(
2191 tool_to_responses_spec(&with_files)["container"],
2192 json!({ "type": "auto", "file_ids": ["file-1", "file-2"] })
2193 );
2194 let with_container = hosted(
2195 ToolKind::HostedCodeInterpreter,
2196 "ci",
2197 json!({ "container": { "type": "secure", "id": "c1" } }),
2198 );
2199 assert_eq!(
2200 tool_to_responses_spec(&with_container)["container"],
2201 json!({ "type": "secure", "id": "c1" })
2202 );
2203 }
2204
2205 #[test]
2206 fn mcp_passes_headers_and_string_approval_mode_override() {
2207 let tool = hosted(
2208 ToolKind::HostedMcp {
2209 url: "https://mcp/sse".into(),
2210 allowed_tools: None,
2211 },
2212 "docs",
2213 json!({ "headers": { "Authorization": "Bearer x" }, "approval_mode": "always_require" }),
2214 );
2215 let spec = tool_to_responses_spec(&tool);
2217 assert_eq!(spec["headers"], json!({ "Authorization": "Bearer x" }));
2218 assert_eq!(spec["require_approval"], json!("always"));
2219 }
2220
2221 #[test]
2222 fn mcp_object_approval_mode_maps_to_tool_name_lists() {
2223 let tool = hosted(
2224 ToolKind::HostedMcp {
2225 url: "https://mcp/sse".into(),
2226 allowed_tools: None,
2227 },
2228 "docs",
2229 json!({ "approval_mode": { "always": ["delete"], "never": ["read"] } }),
2230 );
2231 assert_eq!(
2232 tool_to_responses_spec(&tool)["require_approval"],
2233 json!({ "always": { "tool_names": ["delete"] }, "never": { "tool_names": ["read"] } })
2234 );
2235 }
2236
2237 }