1use async_trait::async_trait;
23use futures::StreamExt;
24use reqwest::{
25 Client,
26 header::{HeaderMap, HeaderName, HeaderValue},
27};
28use serde::{Deserialize, Serialize};
29use serde_json::{Value, json};
30use sha2::{Digest, Sha256};
31use std::collections::HashSet;
32use std::sync::{Arc, Mutex};
33
34use crate::driver_registry::{
35 ChatDriver, LlmCallConfig, LlmCompletionMetadata, LlmContentPart, LlmMessage,
36 LlmMessageContent, LlmMessageRole, LlmResponseStream, LlmStreamEvent, disjoint_prompt_tokens,
37 fold_system_messages,
38};
39use crate::error::{AgentLoopError, LlmErrorKind, Result};
40use crate::llm_retry::{
41 LlmRetryConfig, RateLimitInfo, RetryDecision, RetryMetadata, SendOutcome, is_rate_limit_status,
42 retry_request, send_error_message,
43};
44use crate::openai_protocol::{
45 AuthHeaderProvider, is_openai_model_not_found, is_openai_request_too_large,
46 openai_auth_header_pair,
47};
48use crate::openresponses_types::{self as types, StreamingEvent};
49use crate::provider::DriverId;
50use crate::stream_reconnect::connect_sse_with_reconnect;
51use crate::tool_types::{ToolCall, ToolDefinition};
52use crate::user_facing_error::is_provider_quota_message;
53
54const DEFAULT_API_URL: &str = "https://api.openai.com/v1/responses";
55const OPENAI_PROMPT_CACHE_KEY_MAX_LEN: usize = 64;
56const PROMPT_CACHE_KEY_PREFIX: &str = "everruns:";
57
58pub trait OpenResponsesRequestExtension: Send + Sync {
93 fn decorate(&self, body: &mut Value, config: &LlmCallConfig) -> Result<()>;
94
95 fn decorate_headers(&self, _headers: &mut HeaderMap, _config: &LlmCallConfig) -> Result<()> {
104 Ok(())
105 }
106
107 fn update_rate_limit_info(
109 &self,
110 _info: &mut RateLimitInfo,
111 _headers: &HeaderMap,
112 _error_body: &str,
113 ) {
114 }
115}
116
117#[derive(Clone)]
118pub struct OpenResponsesProtocolChatDriver {
119 client: Client,
120 api_key: String,
121 api_url: String,
122 provider_type: DriverId,
123 retry_config: LlmRetryConfig,
125 request_extension: Option<Arc<dyn OpenResponsesRequestExtension>>,
128 auth_provider: Option<Arc<dyn AuthHeaderProvider>>,
133}
134
135impl OpenResponsesProtocolChatDriver {
136 pub fn new(api_key: impl Into<String>) -> Self {
138 Self {
139 client: crate::driver_helpers::shared_streaming_http_client(),
144 api_key: api_key.into(),
145 api_url: DEFAULT_API_URL.to_string(),
146 provider_type: DriverId::OpenAI,
147 retry_config: LlmRetryConfig::default(),
148 request_extension: None,
149 auth_provider: None,
150 }
151 }
152
153 pub fn with_base_url(api_key: impl Into<String>, api_url: impl Into<String>) -> Self {
155 Self {
156 client: crate::driver_helpers::shared_streaming_http_client(),
157 api_key: api_key.into(),
158 api_url: api_url.into(),
159 provider_type: DriverId::OpenAI,
160 retry_config: LlmRetryConfig::default(),
161 request_extension: None,
162 auth_provider: None,
163 }
164 }
165
166 pub fn with_provider_type(mut self, provider_type: DriverId) -> Self {
168 self.provider_type = provider_type;
169 self
170 }
171
172 pub fn with_request_extension(
176 mut self,
177 extension: Arc<dyn OpenResponsesRequestExtension>,
178 ) -> Self {
179 self.request_extension = Some(extension);
180 self
181 }
182
183 pub fn with_auth_provider(mut self, provider: Arc<dyn AuthHeaderProvider>) -> Self {
191 self.auth_provider = Some(provider);
192 self
193 }
194
195 async fn resolve_auth_header(&self, url: &str) -> Result<(HeaderName, HeaderValue)> {
200 let (name, value) = match &self.auth_provider {
201 Some(provider) => provider.auth_header().await?,
202 None => {
203 let (name, value) = openai_auth_header_pair(url, &self.api_key);
204 (name.to_string(), value.into_owned())
205 }
206 };
207 let name = HeaderName::from_bytes(name.as_bytes())
208 .map_err(|e| AgentLoopError::llm(format!("invalid auth header name {name:?}: {e}")))?;
209 let mut value = HeaderValue::from_str(&value)
210 .map_err(|e| AgentLoopError::llm(format!("invalid auth header value: {e}")))?;
211 value.set_sensitive(true);
213 Ok((name, value))
214 }
215
216 pub fn with_retry_config(mut self, config: LlmRetryConfig) -> Self {
218 self.retry_config = config;
219 self
220 }
221
222 async fn send_responses_request(
231 &self,
232 request_body: &Value,
233 extension_headers: &HeaderMap,
234 model: &str,
235 ) -> Result<(reqwest::Response, RetryMetadata)> {
236 let last_error: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
237
238 retry_request(
239 &self.retry_config,
240 "OpenResponsesProtocolDriver",
241 || async {
242 let mut headers = extension_headers.clone();
248 let (auth_name, auth_value) = self
249 .resolve_auth_header(&self.api_url)
250 .await
251 .map_err(SendOutcome::Fatal)?;
252 headers.insert(auth_name, auth_value);
253
254 self.client
255 .post(&self.api_url)
256 .headers(headers)
257 .header("Content-Type", "application/json")
258 .json(request_body)
259 .send()
260 .await
261 .map_err(SendOutcome::Send)
262 },
263 |response, attempts, can_retry| {
264 let last_error = Arc::clone(&last_error);
265 let model = model.to_string();
266 async move {
267 let status = response.status();
268
269 if can_retry {
270 let response_headers = response.headers().clone();
272 let mut rate_limit_info = if is_rate_limit_status(status) {
273 Some(RateLimitInfo::from_openai_headers(&response_headers))
274 } else {
275 None
276 };
277
278 let error_text = response.text().await.unwrap_or_default();
279 if let (Some(extension), Some(info)) =
280 (self.request_extension.as_ref(), rate_limit_info.as_mut())
281 {
282 extension.update_rate_limit_info(info, &response_headers, &error_text);
283 }
284
285 if is_provider_quota_message(&error_text) {
288 return RetryDecision::Terminal(AgentLoopError::llm_kind(
289 LlmErrorKind::QuotaExhausted,
290 format!("OpenAI Responses API error ({}): {}", status, error_text),
291 ));
292 }
293
294 let wait = rate_limit_info
295 .as_ref()
296 .map(|info| info.recommended_wait(&self.retry_config, attempts))
297 .unwrap_or_else(|| self.retry_config.calculate_backoff(attempts));
298
299 *last_error.lock().unwrap() = Some(error_text);
300 return RetryDecision::Retry {
301 wait,
302 rate_limit_info,
303 };
304 }
305
306 let error_text = response.text().await.unwrap_or_default();
308
309 if is_openai_model_not_found(status, &error_text) {
311 return RetryDecision::Terminal(AgentLoopError::model_not_available(model));
312 }
313
314 if is_openai_request_too_large(status, &error_text) {
316 return RetryDecision::Terminal(AgentLoopError::request_too_large(
317 format!("OpenAI Responses API ({}): {}", status, error_text),
318 ));
319 }
320
321 let error_msg =
322 format!("OpenAI Responses API error ({}): {}", status, error_text);
323
324 let kind = LlmErrorKind::from_provider_status(status.as_u16(), &error_text);
327
328 if attempts > 0 {
329 return RetryDecision::Terminal(AgentLoopError::llm_kind(
330 kind,
331 format!(
332 "{} (after {} retries, last error: {})",
333 error_msg,
334 attempts,
335 last_error.lock().unwrap().take().unwrap_or_default()
336 ),
337 ));
338 }
339
340 RetryDecision::Terminal(AgentLoopError::llm_kind(kind, error_msg))
341 }
342 },
343 |e, attempts| AgentLoopError::llm(send_error_message(e, attempts)),
344 )
345 .await
346 }
347
348 pub fn api_url(&self) -> &str {
350 &self.api_url
351 }
352
353 pub fn api_key(&self) -> &str {
355 &self.api_key
356 }
357
358 pub fn client(&self) -> &Client {
360 &self.client
361 }
362
363 pub fn provider_type(&self) -> &DriverId {
365 &self.provider_type
366 }
367
368 fn convert_role(role: &LlmMessageRole) -> &'static str {
369 match role {
370 LlmMessageRole::System => "developer", LlmMessageRole::User => "user",
372 LlmMessageRole::Assistant => "assistant",
373 LlmMessageRole::Tool => "tool",
374 }
375 }
376
377 fn convert_message(msg: &LlmMessage, supports_phases: bool) -> ResponsesInputItem {
378 if msg.role == LlmMessageRole::Tool
382 && let Some(tool_call_id) = &msg.tool_call_id
383 {
384 let mut has_images = false;
385 let output = match &msg.content {
386 LlmMessageContent::Text(text) => text.clone(),
387 LlmMessageContent::Parts(parts) => {
388 has_images = parts
389 .iter()
390 .any(|p| matches!(p, LlmContentPart::Image { .. }));
391 parts
392 .iter()
393 .filter_map(|p| match p {
394 LlmContentPart::Text { text } => Some(text.clone()),
395 _ => None,
396 })
397 .collect::<Vec<_>>()
398 .join("")
399 }
400 };
401 if has_images {
402 tracing::warn!(
403 tool_call_id = %tool_call_id,
404 "OpenResponses API does not support images in tool results; images dropped"
405 );
406 }
407 return ResponsesInputItem::FunctionCallOutput {
408 r#type: "function_call_output".to_string(),
409 call_id: tool_call_id.clone(),
410 output,
411 };
412 }
413
414 let content = match &msg.content {
415 LlmMessageContent::Text(text) => ResponsesContent::Text(text.clone()),
416 LlmMessageContent::Parts(parts) => {
417 let responses_parts: Vec<ResponsesContentPart> = parts
418 .iter()
419 .map(|part| match part {
420 LlmContentPart::Text { text } => ResponsesContentPart::InputText {
421 r#type: "input_text".to_string(),
422 text: text.clone(),
423 },
424 LlmContentPart::Image { url } => ResponsesContentPart::InputImage {
425 r#type: "input_image".to_string(),
426 image_url: url.clone(),
427 },
428 LlmContentPart::Audio { url } => ResponsesContentPart::InputAudio {
429 r#type: "input_audio".to_string(),
430 input_audio: ResponsesInputAudio {
431 data: url.clone(),
432 format: "wav".to_string(),
433 },
434 },
435 })
436 .collect();
437 ResponsesContent::Parts(responses_parts)
438 }
439 };
440
441 let phase = if supports_phases && msg.role == LlmMessageRole::Assistant {
444 msg.phase.map(|p| p.as_provider_str().to_string())
445 } else {
446 None
447 };
448
449 ResponsesInputItem::Message {
450 r#type: "message".to_string(),
451 role: Self::convert_role(&msg.role).to_string(),
452 content,
453 phase,
454 }
455 }
456
457 fn sanitize_parameters(params: &Value) -> Value {
460 let mut p = params.clone();
461 if let Some(obj) = p.as_object_mut()
462 && obj.get("type").and_then(|v| v.as_str()) == Some("object")
463 && !obj.contains_key("properties")
464 {
465 obj.insert(
466 "properties".to_string(),
467 serde_json::Value::Object(serde_json::Map::new()),
468 );
469 }
470 p
471 }
472
473 fn convert_tools(tools: &[ToolDefinition]) -> Vec<ResponsesTool> {
474 tools
475 .iter()
476 .map(|tool| ResponsesTool::Function {
477 r#type: "function".to_string(),
478 name: tool.name().to_string(),
479 description: tool.description().to_string(),
480 parameters: Self::sanitize_parameters(tool.parameters()),
481 defer_loading: None,
482 })
483 .collect()
484 }
485
486 fn convert_tools_with_search(tools: &[ToolDefinition], threshold: usize) -> Vec<ResponsesTool> {
489 use crate::tool_types::DeferrablePolicy;
490 use std::collections::HashMap;
491
492 if tools.len() < threshold {
494 return Self::convert_tools(tools);
495 }
496
497 let mut namespaces: HashMap<String, Vec<ResponsesTool>> = HashMap::new();
498 let mut ungrouped = vec![];
499 let mut never_defer = vec![];
500
501 for tool in tools {
502 let should_defer = match tool.deferrable() {
503 DeferrablePolicy::Never => false,
504 DeferrablePolicy::Automatic | DeferrablePolicy::Always => true,
505 };
506
507 let func = ResponsesTool::Function {
508 r#type: "function".to_string(),
509 name: tool.name().to_string(),
510 description: tool.description().to_string(),
511 parameters: Self::sanitize_parameters(tool.parameters()),
512 defer_loading: if should_defer { Some(true) } else { None },
513 };
514
515 if !should_defer {
516 never_defer.push(func);
517 } else {
518 match tool.category() {
519 Some(cat) => {
520 namespaces.entry(cat.to_string()).or_default().push(func);
521 }
522 None => ungrouped.push(func),
523 }
524 }
525 }
526
527 let mut result: Vec<ResponsesTool> = Vec::new();
528
529 result.extend(never_defer);
531
532 for (name, tools) in namespaces {
534 let description = format!("Tools for {name}");
535 result.push(ResponsesTool::Namespace {
536 r#type: "namespace".to_string(),
537 name,
538 description,
539 tools,
540 });
541 }
542
543 result.extend(ungrouped);
545
546 result.push(ResponsesTool::ToolSearch {
548 r#type: "tool_search".to_string(),
549 });
550
551 result
552 }
553
554 fn build_prompt_cache_key(
555 config: &LlmCallConfig,
556 _input_items: &[ResponsesInputItem],
557 instructions: &Option<String>,
558 tools: &Option<Vec<ResponsesTool>>,
559 ) -> Option<String> {
560 let prompt_cache = config.prompt_cache.as_ref().filter(|cfg| cfg.enabled)?;
561 let cache_family = config
562 .metadata
563 .get("session_id")
564 .or_else(|| config.metadata.get("agent_id"))
565 .or_else(|| config.metadata.get("harness_id"))
566 .or_else(|| config.metadata.get("org_id"));
567 let fingerprint = json!({
568 "strategy": prompt_cache.strategy,
569 "model": config.model,
570 "cache_family": cache_family,
571 "instructions": instructions,
572 "tools": tools,
573 });
574 let payload = serde_json::to_vec(&fingerprint).ok()?;
575 let digest = hex::encode(Sha256::digest(payload));
576 let digest_len = OPENAI_PROMPT_CACHE_KEY_MAX_LEN - PROMPT_CACHE_KEY_PREFIX.len();
577 Some(format!(
578 "{PROMPT_CACHE_KEY_PREFIX}{}",
579 &digest[..digest_len]
580 ))
581 }
582
583 pub async fn compact(&self, request: CompactRequest) -> Result<CompactResponse> {
621 let compact_url = if self.api_url.ends_with("/responses") {
624 format!("{}/compact", self.api_url)
625 } else if self.api_url.ends_with("/responses/") {
626 format!("{}compact", self.api_url)
627 } else {
628 format!("{}/compact", self.api_url.trim_end_matches('/'))
630 };
631
632 let last_error: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
637
638 let (response, _retry_metadata) = retry_request(
639 &self.retry_config,
640 "OpenResponsesProtocolDriver(compact)",
641 || async {
642 let (auth_name, auth_value) = self
645 .resolve_auth_header(&compact_url)
646 .await
647 .map_err(SendOutcome::Fatal)?;
648 self.client
649 .post(&compact_url)
650 .header(auth_name, auth_value)
651 .header("Content-Type", "application/json")
652 .json(&request)
653 .send()
654 .await
655 .map_err(SendOutcome::Send)
656 },
657 |response, attempts, can_retry| {
658 let last_error = Arc::clone(&last_error);
659 let request_model = request.model.clone();
660 async move {
661 let status = response.status();
662
663 if can_retry {
664 let response_headers = response.headers().clone();
665 let mut rate_limit_info = if is_rate_limit_status(status) {
666 Some(RateLimitInfo::from_openai_headers(&response_headers))
667 } else {
668 None
669 };
670
671 let error_text = response.text().await.unwrap_or_default();
672 if let (Some(extension), Some(info)) =
673 (self.request_extension.as_ref(), rate_limit_info.as_mut())
674 {
675 extension.update_rate_limit_info(info, &response_headers, &error_text);
676 }
677
678 let wait = rate_limit_info
679 .as_ref()
680 .map(|info| info.recommended_wait(&self.retry_config, attempts))
681 .unwrap_or_else(|| self.retry_config.calculate_backoff(attempts));
682
683 *last_error.lock().unwrap() = Some(error_text);
684 return RetryDecision::Retry {
685 wait,
686 rate_limit_info,
687 };
688 }
689
690 let error_text = response.text().await.unwrap_or_default();
692
693 if is_openai_model_not_found(status, &error_text) {
695 return RetryDecision::Terminal(AgentLoopError::model_not_available(
696 request_model,
697 ));
698 }
699
700 if is_openai_request_too_large(status, &error_text) {
702 return RetryDecision::Terminal(AgentLoopError::request_too_large(
703 format!("OpenAI Responses compact API ({}): {}", status, error_text),
704 ));
705 }
706
707 let error_msg = format!(
708 "OpenAI Responses compact API error ({}): {}",
709 status, error_text
710 );
711
712 if attempts > 0 {
713 return RetryDecision::Terminal(AgentLoopError::llm(format!(
714 "{} (after {} retries, last error: {})",
715 error_msg,
716 attempts,
717 last_error.lock().unwrap().take().unwrap_or_default()
718 )));
719 }
720
721 RetryDecision::Terminal(AgentLoopError::llm(error_msg))
722 }
723 },
724 |e, attempts| {
725 let suffix = if attempts > 0 {
726 format!(" (after {attempts} retries)")
727 } else {
728 String::new()
729 };
730 AgentLoopError::llm(format!("Failed to send compact request: {e}{suffix}"))
731 },
732 )
733 .await?;
734
735 let compact_response: CompactResponse = response
737 .json()
738 .await
739 .map_err(|e| AgentLoopError::llm(format!("Failed to parse compact response: {}", e)))?;
740
741 Ok(compact_response)
742 }
743
744 pub fn supports_compact(&self) -> bool {
749 self.api_url.starts_with("https://api.openai.com/")
752 }
753
754 fn build_input(
766 messages: &[LlmMessage],
767 supports_phases: bool,
768 ) -> (Option<String>, Vec<ResponsesInputItem>) {
769 let instructions: Option<String> = fold_system_messages(messages);
775 let mut input_items = Vec::new();
776 let mut reasoning_counter = 0u32;
778
779 for msg in messages {
780 if msg.role == LlmMessageRole::System {
781 } else if msg.role == LlmMessageRole::Assistant {
784 if let Some(encrypted_content) = &msg.thinking_signature {
787 reasoning_counter += 1;
788 input_items.push(ResponsesInputItem::Reasoning {
789 r#type: "reasoning".to_string(),
790 id: format!("rs_{:08x}", reasoning_counter),
791 encrypted_content: encrypted_content.clone(),
792 });
793 tracing::debug!(
794 encrypted_len = encrypted_content.len(),
795 "OpenResponses: including reasoning item in request"
796 );
797 }
798
799 if msg.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty()) {
801 let has_content = match &msg.content {
803 LlmMessageContent::Text(text) => !text.is_empty(),
804 LlmMessageContent::Parts(parts) => !parts.is_empty(),
805 };
806 if has_content {
807 input_items.push(Self::convert_message(msg, supports_phases));
808 }
809
810 if let Some(tool_calls) = &msg.tool_calls {
812 for tc in tool_calls {
813 input_items.push(ResponsesInputItem::FunctionCall {
814 r#type: "function_call".to_string(),
815 call_id: tc.id.clone(),
816 name: tc.name.clone(),
817 arguments: tc.arguments.to_string(),
818 });
819 }
820 }
821 } else {
822 input_items.push(Self::convert_message(msg, supports_phases));
823 }
824 } else {
825 input_items.push(Self::convert_message(msg, supports_phases));
826 }
827 }
828
829 (instructions, input_items)
830 }
831}
832
833fn compute_delta_input_items(items: Vec<ResponsesInputItem>) -> Vec<ResponsesInputItem> {
852 let last_assistant_turn_idx = items
854 .iter()
855 .enumerate()
856 .rev()
857 .find_map(|(i, item)| match item {
858 ResponsesInputItem::Message { role, .. } if role == "assistant" => Some(i),
859 ResponsesInputItem::Reasoning { .. } => Some(i),
860 ResponsesInputItem::FunctionCall { .. } => Some(i),
861 _ => None,
862 });
863
864 match last_assistant_turn_idx {
865 Some(idx) => items.into_iter().skip(idx + 1).collect(),
866 None => items,
868 }
869}
870
871fn finalize_input_for_request(
876 input_items: Vec<ResponsesInputItem>,
877 previous_response_id: &Option<String>,
878) -> Vec<ResponsesInputItem> {
879 if previous_response_id.is_some() {
880 compute_delta_input_items(input_items)
881 } else {
882 repair_unpaired_function_call_items(input_items)
883 }
884}
885
886fn unpaired_function_call_ids(items: &[ResponsesInputItem]) -> Vec<String> {
892 let call_ids: HashSet<&str> = items
893 .iter()
894 .filter_map(|item| match item {
895 ResponsesInputItem::FunctionCall { call_id, .. } => Some(call_id.as_str()),
896 _ => None,
897 })
898 .collect();
899 let output_ids: HashSet<&str> = items
900 .iter()
901 .filter_map(|item| match item {
902 ResponsesInputItem::FunctionCallOutput { call_id, .. } => Some(call_id.as_str()),
903 _ => None,
904 })
905 .collect();
906
907 items
908 .iter()
909 .filter_map(|item| match item {
910 ResponsesInputItem::FunctionCall { call_id, .. }
911 if !output_ids.contains(call_id.as_str()) =>
912 {
913 Some(call_id.clone())
914 }
915 ResponsesInputItem::FunctionCallOutput { call_id, .. }
916 if !call_ids.contains(call_id.as_str()) =>
917 {
918 Some(call_id.clone())
919 }
920 _ => None,
921 })
922 .collect()
923}
924
925fn repair_unpaired_function_call_items(
942 input_items: Vec<ResponsesInputItem>,
943) -> Vec<ResponsesInputItem> {
944 let unpaired: HashSet<String> = unpaired_function_call_ids(&input_items)
945 .into_iter()
946 .collect();
947
948 if unpaired.is_empty() {
949 return input_items;
950 }
951
952 tracing::warn!(
953 unpaired_call_ids = ?unpaired,
954 "dropping unpaired function_call / function_call_output items before \
955 stateless Responses replay; one side of the pair was likely evicted by \
956 compaction or model-view masking (EVE-597/EVE-519)"
957 );
958
959 input_items
960 .into_iter()
961 .filter(|item| match item {
962 ResponsesInputItem::FunctionCall { call_id, .. }
963 | ResponsesInputItem::FunctionCallOutput { call_id, .. } => {
964 !unpaired.contains(call_id.as_str())
965 }
966 _ => true,
967 })
968 .collect()
969}
970
971fn endpoint_persists_responses(api_url: &str) -> bool {
981 crate::openai_protocol::is_openai_api_url(api_url)
982 || crate::openai_protocol::is_azure_openai_api_url(api_url)
983}
984
985#[async_trait]
986impl ChatDriver for OpenResponsesProtocolChatDriver {
987 fn supports_stateful_responses(&self) -> bool {
988 endpoint_persists_responses(&self.api_url)
989 }
990
991 async fn chat_completion_stream(
992 &self,
993 messages: Vec<LlmMessage>,
994 config: &LlmCallConfig,
995 ) -> Result<LlmResponseStream> {
996 let model_profile =
1001 crate::model_profiles::get_model_profile(&self.provider_type, &config.model);
1002 let supports_phases = model_profile
1003 .as_ref()
1004 .is_some_and(|profile| profile.supports_phases);
1005 let supports_tool_search = model_profile
1006 .as_ref()
1007 .is_some_and(|profile| profile.tool_search);
1008
1009 let (instructions, transcript_input_items) = Self::build_input(&messages, supports_phases);
1010
1011 let mut previous_response_id = if endpoint_persists_responses(&self.api_url) {
1017 config.previous_response_id.clone()
1018 } else {
1019 None
1020 };
1021
1022 let input_items = match &config.provider_opaque_context {
1027 Some(crate::driver_registry::ProviderOpaqueContext::OpenResponsesCompact {
1028 output,
1029 }) => {
1030 previous_response_id = None;
1031 let mut input_items: Vec<_> = output.iter().map(ResponsesInputItem::from).collect();
1032 input_items.extend(transcript_input_items);
1033 input_items
1034 }
1035 None => finalize_input_for_request(transcript_input_items, &previous_response_id),
1036 };
1037
1038 let tools = if config.tools.is_empty() {
1039 None
1040 } else if let Some(ref ts_config) = config.tool_search {
1041 if ts_config.enabled && supports_tool_search {
1042 Some(Self::convert_tools_with_search(
1043 &config.tools,
1044 ts_config.threshold,
1045 ))
1046 } else {
1047 Some(Self::convert_tools(&config.tools))
1048 }
1049 } else {
1050 Some(Self::convert_tools(&config.tools))
1051 };
1052
1053 let reasoning = config
1057 .reasoning_effort
1058 .as_ref()
1059 .filter(|e| !e.eq_ignore_ascii_case("none"))
1060 .map(|effort| ResponsesReasoning {
1061 effort: effort.clone(),
1062 summary: "detailed".to_string(),
1063 });
1064
1065 let metadata = if config.metadata.is_empty() {
1067 None
1068 } else {
1069 Some(config.metadata.clone())
1070 };
1071 let prompt_cache_key =
1072 Self::build_prompt_cache_key(config, &input_items, &instructions, &tools);
1073 let request = ResponsesRequest {
1074 model: config.model.clone(),
1075 input: input_items,
1076 instructions,
1077 previous_response_id,
1078 temperature: config.temperature,
1079 max_output_tokens: config.max_tokens,
1080 stream: true,
1081 tools,
1082 reasoning,
1083 metadata,
1084 prompt_cache_key,
1085 parallel_tool_calls: config
1086 .resolved_parallel_tool_calls(self.supports_parallel_tool_calls(&config.model)),
1087 service_tier: config.speed.clone(),
1088 text: config.verbosity.clone().map(|verbosity| ResponsesText {
1089 verbosity: Some(verbosity),
1090 }),
1091 };
1092
1093 {
1096 let tool_count = request.tools.as_ref().map_or(0, |t| t.len());
1097 let input_count = request.input.len();
1098 let has_instructions = request.instructions.is_some();
1099 let has_reasoning = request.reasoning.is_some();
1100 let has_previous_response = request.previous_response_id.is_some();
1101 tracing::debug!(
1102 model = %request.model,
1103 input_items = input_count,
1104 tool_count = tool_count,
1105 has_instructions = has_instructions,
1106 has_reasoning = has_reasoning,
1107 has_previous_response = has_previous_response,
1108 api_url = %self.api_url,
1109 "OpenResponsesDriver: sending request"
1110 );
1111 }
1112
1113 let mut request_body = serde_json::to_value(&request)
1116 .map_err(|e| AgentLoopError::llm(format!("Failed to serialize request: {}", e)))?;
1117 if let Some(extension) = &self.request_extension {
1118 extension.decorate(&mut request_body, config)?;
1119 }
1120 let mut extension_headers = HeaderMap::new();
1121 if let Some(extension) = &self.request_extension {
1122 extension.decorate_headers(&mut extension_headers, config)?;
1123 }
1124
1125 let (event_stream, retry_metadata) = connect_sse_with_reconnect(
1130 &self.retry_config,
1131 "OpenResponsesProtocolDriver",
1132 |_attempt| {
1133 self.send_responses_request(&request_body, &extension_headers, &config.model)
1134 },
1135 )
1136 .await?;
1137
1138 let model = config.model.clone();
1139 let input_tokens = Arc::new(Mutex::new(0u32));
1140 let output_tokens = Arc::new(Mutex::new(0u32));
1141 let cache_read_tokens = Arc::new(Mutex::new(Option::<u32>::None));
1142 let accumulated_tool_calls = Arc::new(Mutex::new(Vec::<ToolCallAccumulator>::new()));
1143 let finish_reason = Arc::new(Mutex::new(Option::<String>::None));
1144 let shared_retry_metadata = if retry_metadata.had_retries() {
1146 Some(Arc::new(retry_metadata))
1147 } else {
1148 None
1149 };
1150
1151 let converted_stream: LlmResponseStream = Box::pin(event_stream.then(move |result| {
1152 let model = model.clone();
1153 let input_tokens = Arc::clone(&input_tokens);
1154 let output_tokens = Arc::clone(&output_tokens);
1155 let cache_read_tokens = Arc::clone(&cache_read_tokens);
1156 let accumulated_tool_calls = Arc::clone(&accumulated_tool_calls);
1157 let finish_reason = Arc::clone(&finish_reason);
1158 let retry_metadata_for_done = shared_retry_metadata.clone();
1159
1160 async move {
1161 match result {
1162 Ok(event) => {
1163 let event_data = &event.data;
1164
1165 if event_data == "[DONE]" {
1171 return Ok(LlmStreamEvent::TextDelta(String::new()));
1172 }
1173
1174 if let Ok(streaming_event) =
1176 serde_json::from_str::<StreamingEvent>(event_data)
1177 {
1178 return Ok(handle_streaming_event(
1179 streaming_event,
1180 &input_tokens,
1181 &output_tokens,
1182 &cache_read_tokens,
1183 &accumulated_tool_calls,
1184 &finish_reason,
1185 model,
1186 retry_metadata_for_done,
1187 ));
1188 }
1189
1190 let parsed: std::result::Result<Value, _> =
1192 serde_json::from_str(event_data);
1193
1194 match parsed {
1195 Ok(json) => {
1196 let event_type = json.get("type").and_then(|t| t.as_str());
1197
1198 match event_type {
1199 Some("response.output_text.delta") => {
1200 if let Some(delta) =
1202 json.get("delta").and_then(|d| d.as_str())
1203 {
1204 Ok(LlmStreamEvent::TextDelta(delta.to_string()))
1205 } else {
1206 Ok(LlmStreamEvent::TextDelta(String::new()))
1207 }
1208 }
1209
1210 Some("response.function_call_arguments.delta") => {
1211 if let (Some(item_id), Some(delta)) = (
1213 json.get("item_id").and_then(|c| c.as_str()),
1214 json.get("delta").and_then(|d| d.as_str()),
1215 ) {
1216 let mut acc = accumulated_tool_calls.lock().unwrap();
1217 if let Some(tc) =
1219 acc.iter_mut().find(|t| t.id == item_id)
1220 {
1221 tc.arguments.push_str(delta);
1222 } else {
1223 acc.push(ToolCallAccumulator {
1224 id: item_id.to_string(),
1225 call_id: String::new(),
1226 name: String::new(),
1227 arguments: delta.to_string(),
1228 });
1229 }
1230 }
1231 Ok(LlmStreamEvent::TextDelta(String::new()))
1232 }
1233
1234 Some("response.output_item.added") => {
1235 let item_type = json
1239 .get("item")
1240 .and_then(|i| i.get("type"))
1241 .and_then(|t| t.as_str());
1242 if item_type == Some("function_call") {
1243 let item = json.get("item").unwrap();
1244 let id = item
1245 .get("id")
1246 .and_then(|c| c.as_str())
1247 .unwrap_or("")
1248 .to_string();
1249 let call_id = item
1250 .get("call_id")
1251 .and_then(|c| c.as_str())
1252 .unwrap_or("")
1253 .to_string();
1254 let name = item
1255 .get("name")
1256 .and_then(|n| n.as_str())
1257 .unwrap_or("")
1258 .to_string();
1259
1260 let mut acc = accumulated_tool_calls.lock().unwrap();
1261 if let Some(tc) = acc.iter_mut().find(|t| t.id == id) {
1262 tc.name = name;
1263 tc.call_id = call_id;
1264 } else {
1265 acc.push(ToolCallAccumulator {
1266 id,
1267 call_id,
1268 name,
1269 arguments: String::new(),
1270 });
1271 }
1272 } else if item_type == Some("message") {
1273 if let Some(phase) = json
1278 .get("item")
1279 .and_then(|i| i.get("phase"))
1280 .and_then(|p| p.as_str())
1281 .and_then(
1282 crate::execution_phase::ExecutionPhase::from_provider_str,
1283 )
1284 {
1285 return Ok(LlmStreamEvent::MessagePhase(phase));
1286 }
1287 }
1288 Ok(LlmStreamEvent::TextDelta(String::new()))
1289 }
1290
1291 Some("response.output_item.done") => {
1292 if let Some(item) = json.get("item")
1294 && item.get("type").and_then(|t| t.as_str())
1295 == Some("function_call")
1296 {
1297 let acc = accumulated_tool_calls.lock().unwrap();
1299 if !acc.is_empty() {
1300 let tool_calls: Vec<ToolCall> = acc
1301 .iter()
1302 .filter(|tc| !tc.name.is_empty())
1303 .map(|tc| {
1304 let arguments: Value =
1305 serde_json::from_str(&tc.arguments)
1306 .unwrap_or(json!({}));
1307 ToolCall {
1308 id: tc.call_id.clone(),
1309 name: tc.name.clone(),
1310 arguments,
1311 }
1312 })
1313 .collect();
1314
1315 if !tool_calls.is_empty() {
1316 *finish_reason.lock().unwrap() =
1317 Some("tool_calls".to_string());
1318 return Ok(LlmStreamEvent::ToolCalls(
1319 tool_calls,
1320 ));
1321 }
1322 }
1323 }
1324 Ok(LlmStreamEvent::TextDelta(String::new()))
1325 }
1326
1327 Some("response.completed")
1328 | Some("response.incomplete")
1329 | Some("response.done") => {
1330 let response_obj = json.get("response").unwrap_or(&json);
1332
1333 let mut provider_cost_usd: Option<f64> = None;
1336 if let Some(usage) = response_obj.get("usage") {
1337 if let Some(input) =
1338 usage.get("input_tokens").and_then(|t| t.as_u64())
1339 {
1340 *input_tokens.lock().unwrap() = input as u32;
1341 }
1342 if let Some(output) =
1343 usage.get("output_tokens").and_then(|t| t.as_u64())
1344 {
1345 *output_tokens.lock().unwrap() = output as u32;
1346 }
1347 if let Some(details) = usage.get("input_tokens_details")
1349 && let Some(cached) = details
1350 .get("cached_tokens")
1351 .and_then(|t| t.as_u64())
1352 {
1353 *cache_read_tokens.lock().unwrap() =
1354 Some(cached as u32);
1355 }
1356 provider_cost_usd =
1357 usage.get("cost").and_then(|c| c.as_f64());
1358 }
1359
1360 let status = response_obj
1362 .get("status")
1363 .and_then(|s| s.as_str())
1364 .unwrap_or("completed");
1365
1366 let reason = match status {
1367 "completed" => {
1368 let existing_reason =
1370 finish_reason.lock().unwrap().clone();
1371 existing_reason
1372 .unwrap_or_else(|| "stop".to_string())
1373 }
1374 "failed" => {
1375 let error_detail = response_obj
1376 .get("error")
1377 .map(|e| e.to_string())
1378 .unwrap_or_else(|| "no error detail".into());
1379 tracing::warn!(
1380 response_error = %error_detail,
1381 "OpenResponsesDriver: response completed with 'failed' status (fallback parser)"
1382 );
1383 "error".to_string()
1384 }
1385 "incomplete" => response_obj
1386 .get("incomplete_details")
1387 .and_then(|details| details.get("reason"))
1388 .and_then(|reason| reason.as_str())
1389 .map(|reason| match reason {
1390 "max_output_tokens" | "max_tokens" => "length",
1391 other => other,
1392 })
1393 .unwrap_or("stop")
1394 .to_string(),
1395 "cancelled" => "cancelled".to_string(),
1396 _ => "stop".to_string(),
1397 };
1398
1399 let phase = response_obj
1401 .get("output")
1402 .and_then(|o| o.as_array())
1403 .and_then(|items| {
1404 items.iter().rev().find_map(|item| {
1405 if item.get("type")?.as_str()? == "message"
1406 && item.get("role")?.as_str()?
1407 == "assistant"
1408 {
1409 item.get("phase")?
1410 .as_str()
1411 .map(String::from)
1412 } else {
1413 None
1414 }
1415 })
1416 });
1417
1418 let input = *input_tokens.lock().unwrap();
1419 let output = *output_tokens.lock().unwrap();
1420 let cached = *cache_read_tokens.lock().unwrap();
1421
1422 Ok(LlmStreamEvent::Done(Box::new(LlmCompletionMetadata {
1423 total_tokens: Some(input + output),
1426 prompt_tokens: Some(disjoint_prompt_tokens(input, cached)),
1427 completion_tokens: Some(output),
1428 cache_read_tokens: cached,
1429 cache_creation_tokens: None,
1430 provider_cost_usd,
1431 model: Some(model),
1432 finish_reason: Some(reason),
1433 retry_metadata: retry_metadata_for_done
1434 .map(|arc| (*arc).clone()),
1435 response_id: None,
1436 phase,
1437 })))
1438 }
1439
1440 Some("error") => {
1441 let error_code = json
1443 .get("error")
1444 .and_then(|e| e.get("code"))
1445 .and_then(|c| c.as_str())
1446 .unwrap_or("unknown");
1447 let error_msg = json
1448 .get("error")
1449 .and_then(|e| e.get("message"))
1450 .and_then(|m| m.as_str())
1451 .unwrap_or("Unknown error");
1452 tracing::warn!(
1453 error_code = error_code,
1454 error_message = error_msg,
1455 raw_error = %json.get("error").unwrap_or(&json),
1456 "OpenResponsesDriver: received streaming error event (fallback parser)"
1457 );
1458 Ok(LlmStreamEvent::Error(
1459 crate::driver_registry::LlmStreamError::provider(
1460 (error_code != "unknown")
1461 .then_some(error_code.to_string()),
1462 None,
1463 error_msg,
1464 ),
1465 ))
1466 }
1467
1468 _ => {
1469 Ok(LlmStreamEvent::TextDelta(String::new()))
1471 }
1472 }
1473 }
1474 Err(e) => Ok(LlmStreamEvent::Error(
1475 format!("Failed to parse event: {}", e).into(),
1476 )),
1477 }
1478 }
1479 Err(e) => Ok(LlmStreamEvent::Error(
1480 format!("Stream error: {}", e).into(),
1481 )),
1482 }
1483 }
1484 }));
1485
1486 Ok(converted_stream)
1487 }
1488
1489 fn supports_compact(&self) -> bool {
1490 OpenResponsesProtocolChatDriver::supports_compact(self)
1492 }
1493
1494 fn supports_parallel_tool_calls(&self, _model: &str) -> bool {
1496 true
1497 }
1498
1499 async fn compact(
1500 &self,
1501 request: crate::openresponses_protocol::CompactRequest,
1502 ) -> Result<Option<crate::openresponses_protocol::CompactResponse>> {
1503 Ok(Some(
1505 OpenResponsesProtocolChatDriver::compact(self, request).await?,
1506 ))
1507 }
1508}
1509
1510impl std::fmt::Debug for OpenResponsesProtocolChatDriver {
1511 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1512 f.debug_struct("OpenResponsesProtocolChatDriver")
1513 .field("api_url", &self.api_url)
1514 .field("provider_type", &self.provider_type)
1515 .field("api_key", &"[REDACTED]")
1516 .finish()
1517 }
1518}
1519
1520#[derive(Clone, Default)]
1526struct ToolCallAccumulator {
1527 id: String,
1529 call_id: String,
1531 name: String,
1533 arguments: String,
1535}
1536
1537#[allow(clippy::too_many_arguments)]
1539fn handle_streaming_event(
1540 event: StreamingEvent,
1541 input_tokens: &Mutex<u32>,
1542 output_tokens: &Mutex<u32>,
1543 cache_read_tokens: &Mutex<Option<u32>>,
1544 accumulated_tool_calls: &Mutex<Vec<ToolCallAccumulator>>,
1545 finish_reason: &Mutex<Option<String>>,
1546 model: String,
1547 retry_metadata: Option<Arc<RetryMetadata>>,
1548) -> LlmStreamEvent {
1549 match event {
1550 StreamingEvent::OutputTextDelta { delta, .. } => LlmStreamEvent::TextDelta(delta),
1551
1552 StreamingEvent::ReasoningDelta { delta, .. } => LlmStreamEvent::ThinkingDelta(delta),
1553
1554 StreamingEvent::ReasoningTextDelta { delta, .. } => LlmStreamEvent::ThinkingDelta(delta),
1555
1556 StreamingEvent::ReasoningSummaryDelta { delta, .. } => {
1557 LlmStreamEvent::TextDelta(delta)
1561 }
1562
1563 StreamingEvent::FunctionCallArgumentsDelta { item_id, delta, .. } => {
1564 let mut acc = accumulated_tool_calls.lock().unwrap();
1565 if let Some(tc) = acc.iter_mut().find(|t| t.id == item_id) {
1566 tc.arguments.push_str(&delta);
1567 } else {
1568 acc.push(ToolCallAccumulator {
1569 id: item_id,
1570 call_id: String::new(),
1571 name: String::new(),
1572 arguments: delta,
1573 });
1574 }
1575 LlmStreamEvent::TextDelta(String::new())
1576 }
1577
1578 StreamingEvent::OutputItemAdded { item, .. } => {
1579 match item {
1580 Some(types::OutputItem::FunctionCall {
1581 id, call_id, name, ..
1582 }) => {
1583 let mut acc = accumulated_tool_calls.lock().unwrap();
1584 if let Some(tc) = acc.iter_mut().find(|t| t.id == id) {
1585 tc.name = name;
1586 tc.call_id = call_id;
1587 } else {
1588 acc.push(ToolCallAccumulator {
1589 id,
1590 call_id,
1591 name,
1592 arguments: String::new(),
1593 });
1594 }
1595 LlmStreamEvent::TextDelta(String::new())
1596 }
1597 Some(types::OutputItem::Message {
1603 phase: Some(phase_str),
1604 ..
1605 }) => match crate::execution_phase::ExecutionPhase::from_provider_str(&phase_str) {
1606 Some(phase) => LlmStreamEvent::MessagePhase(phase),
1607 None => LlmStreamEvent::TextDelta(String::new()),
1608 },
1609 _ => LlmStreamEvent::TextDelta(String::new()),
1610 }
1611 }
1612
1613 StreamingEvent::OutputItemDone { item, .. } => {
1614 match item {
1615 Some(types::OutputItem::FunctionCall { .. }) => {
1616 let acc = accumulated_tool_calls.lock().unwrap();
1617 if !acc.is_empty() {
1618 let tool_calls: Vec<ToolCall> = acc
1619 .iter()
1620 .filter(|tc| !tc.name.is_empty())
1621 .map(|tc| {
1622 let arguments: Value =
1623 serde_json::from_str(&tc.arguments).unwrap_or(json!({}));
1624 ToolCall {
1625 id: tc.call_id.clone(),
1626 name: tc.name.clone(),
1627 arguments,
1628 }
1629 })
1630 .collect();
1631
1632 if !tool_calls.is_empty() {
1633 *finish_reason.lock().unwrap() = Some("tool_calls".to_string());
1634 return LlmStreamEvent::ToolCalls(tool_calls);
1635 }
1636 }
1637 LlmStreamEvent::TextDelta(String::new())
1638 }
1639 Some(types::OutputItem::Reasoning {
1640 id,
1641 summary,
1642 content: _, encrypted_content,
1644 }) => {
1645 let safe_summary: Vec<String> = summary
1650 .into_iter()
1651 .filter_map(|part| match part {
1652 types::ContentPart::SummaryText { text } => Some(text),
1653 _ => None,
1654 })
1655 .collect();
1656 tracing::debug!(
1657 encrypted_len = encrypted_content.as_ref().map(|s| s.len()).unwrap_or(0),
1658 summary_segments = safe_summary.len(),
1659 "OpenResponses: received reasoning item"
1660 );
1661 LlmStreamEvent::ReasonItem {
1662 provider: "openai".to_string(),
1663 model: Some(model.clone()),
1664 item_id: id,
1665 encrypted_content,
1666 summary: safe_summary,
1667 token_count: None,
1668 }
1669 }
1670 _ => LlmStreamEvent::TextDelta(String::new()),
1671 }
1672 }
1673
1674 StreamingEvent::ResponseCompleted { response, .. }
1675 | StreamingEvent::ResponseIncomplete { response, .. } => {
1676 if let Some(usage) = &response.usage {
1678 *input_tokens.lock().unwrap() = usage.input_tokens;
1679 *output_tokens.lock().unwrap() = usage.output_tokens;
1680 if let Some(details) = &usage.input_tokens_details {
1681 *cache_read_tokens.lock().unwrap() = Some(details.cached_tokens);
1682 }
1683 }
1684
1685 let reason = match response.status {
1686 types::ResponseStatus::Completed => {
1687 let existing = finish_reason.lock().unwrap().clone();
1688 existing.unwrap_or_else(|| "stop".to_string())
1689 }
1690 types::ResponseStatus::Failed => {
1691 tracing::warn!(
1692 response_id = %response.id,
1693 error = ?response.error,
1694 "OpenResponsesDriver: response completed with 'failed' status"
1695 );
1696 "error".to_string()
1697 }
1698 types::ResponseStatus::Cancelled => "cancelled".to_string(),
1699 types::ResponseStatus::Incomplete => response
1700 .incomplete_details
1701 .as_ref()
1702 .map(|details| match details.reason.as_str() {
1703 "max_output_tokens" | "max_tokens" => "length",
1704 other => other,
1705 })
1706 .unwrap_or("stop")
1707 .to_string(),
1708 _ => "stop".to_string(),
1709 };
1710
1711 let phase = response.output.iter().rev().find_map(|item| {
1714 if let types::OutputItem::Message { phase, .. } = item {
1715 phase.clone()
1716 } else {
1717 None
1718 }
1719 });
1720
1721 let input = *input_tokens.lock().unwrap();
1722 let output = *output_tokens.lock().unwrap();
1723 let cached = *cache_read_tokens.lock().unwrap();
1724 let provider_cost_usd = response.usage.as_ref().and_then(|u| u.cost);
1725
1726 LlmStreamEvent::Done(Box::new(LlmCompletionMetadata {
1727 total_tokens: Some(input + output),
1730 prompt_tokens: Some(disjoint_prompt_tokens(input, cached)),
1731 completion_tokens: Some(output),
1732 cache_read_tokens: cached,
1733 cache_creation_tokens: None,
1734 provider_cost_usd,
1735 model: Some(model),
1736 finish_reason: Some(reason),
1737 retry_metadata: retry_metadata.map(|arc| (*arc).clone()),
1738 response_id: Some(response.id),
1739 phase,
1740 }))
1741 }
1742
1743 StreamingEvent::Error { error, .. } => {
1744 tracing::warn!(
1745 error_code = error.code.as_deref().unwrap_or("none"),
1746 error_message = %error.message,
1747 "OpenResponsesDriver: received streaming error event from provider"
1748 );
1749 LlmStreamEvent::Error(crate::driver_registry::LlmStreamError::provider(
1750 error.code,
1751 None,
1752 error.message,
1753 ))
1754 }
1755
1756 StreamingEvent::ResponseFailed { response, .. } => {
1757 let error = response.error.unwrap_or(types::Error {
1758 code: "processing_error".to_string(),
1759 message: "The provider failed while processing the response".to_string(),
1760 });
1761 tracing::warn!(
1762 response_id = %response.id,
1763 error_code = %error.code,
1764 error_message = %error.message,
1765 "OpenResponsesDriver: response failed in stream"
1766 );
1767 LlmStreamEvent::Error(crate::driver_registry::LlmStreamError::provider(
1768 Some(error.code),
1769 None,
1770 error.message,
1771 ))
1772 }
1773
1774 StreamingEvent::RefusalDelta { delta, .. } => {
1775 LlmStreamEvent::Error(format!("Model refused: {}", delta).into())
1777 }
1778
1779 _ => LlmStreamEvent::TextDelta(String::new()),
1781 }
1782}
1783
1784#[derive(Debug, Clone, Serialize)]
1794pub struct CompactRequest {
1795 pub model: String,
1797 #[serde(skip_serializing_if = "Vec::is_empty")]
1799 pub input: Vec<CompactInputItem>,
1800 #[serde(skip_serializing_if = "Option::is_none")]
1802 pub previous_response_id: Option<String>,
1803 #[serde(skip_serializing_if = "Option::is_none")]
1805 pub instructions: Option<String>,
1806}
1807
1808#[derive(Debug, Clone, Serialize, Deserialize)]
1813#[serde(tag = "type")]
1814pub enum CompactInputItem {
1815 #[serde(rename = "message")]
1817 Message {
1818 role: String,
1819 content: CompactContent,
1820 },
1821 #[serde(rename = "function_call")]
1823 FunctionCall {
1824 call_id: String,
1825 name: String,
1826 arguments: String,
1827 },
1828 #[serde(rename = "function_call_output")]
1830 FunctionCallOutput { call_id: String, output: String },
1831 #[serde(rename = "compaction")]
1833 Compaction { encrypted_content: String },
1834}
1835
1836impl From<&CompactOutputItem> for CompactInputItem {
1837 fn from(item: &CompactOutputItem) -> Self {
1838 match item {
1839 CompactOutputItem::Message { role, content } => Self::Message {
1840 role: role.clone(),
1841 content: content.clone(),
1842 },
1843 CompactOutputItem::Compaction { encrypted_content } => Self::Compaction {
1844 encrypted_content: encrypted_content.clone(),
1845 },
1846 }
1847 }
1848}
1849
1850#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1852#[serde(untagged)]
1853pub enum CompactContent {
1854 Text(String),
1856 Parts(Vec<CompactContentPart>),
1858}
1859
1860#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1862#[serde(tag = "type")]
1863pub enum CompactContentPart {
1864 #[serde(rename = "input_text")]
1866 InputText { text: String },
1867 #[serde(rename = "input_image")]
1869 InputImage { image_url: String },
1870}
1871
1872#[derive(Debug, Clone, Deserialize)]
1874pub struct CompactResponse {
1875 pub output: Vec<CompactOutputItem>,
1877 pub usage: Option<CompactUsage>,
1879}
1880
1881#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1883#[serde(tag = "type")]
1884pub enum CompactOutputItem {
1885 #[serde(rename = "message")]
1887 Message {
1888 role: String,
1889 content: CompactContent,
1890 },
1891 #[serde(rename = "compaction")]
1893 Compaction {
1894 encrypted_content: String,
1896 },
1897}
1898
1899#[derive(Debug, Clone, Deserialize)]
1901pub struct CompactUsage {
1902 pub input_tokens: Option<u32>,
1904 pub output_tokens: Option<u32>,
1906 pub total_tokens: Option<u32>,
1908}
1909
1910impl CompactInputItem {
1915 pub fn from_llm_message(msg: &LlmMessage) -> Vec<Self> {
1920 let mut items = Vec::new();
1921
1922 let role = match msg.role {
1923 LlmMessageRole::System => "developer",
1924 LlmMessageRole::User => "user",
1925 LlmMessageRole::Assistant => "assistant",
1926 LlmMessageRole::Tool => "tool",
1927 };
1928
1929 if msg.role == LlmMessageRole::Tool
1931 && let Some(tool_call_id) = &msg.tool_call_id
1932 {
1933 let output = match &msg.content {
1934 LlmMessageContent::Text(text) => text.clone(),
1935 LlmMessageContent::Parts(parts) => parts
1936 .iter()
1937 .filter_map(|p| match p {
1938 LlmContentPart::Text { text } => Some(text.clone()),
1939 _ => None,
1940 })
1941 .collect::<Vec<_>>()
1942 .join(""),
1943 };
1944 items.push(CompactInputItem::FunctionCallOutput {
1945 call_id: tool_call_id.clone(),
1946 output,
1947 });
1948 return items;
1949 }
1950
1951 let content = Self::content_from_llm_message(msg);
1953 let has_content = match &content {
1954 CompactContent::Text(t) => !t.is_empty(),
1955 CompactContent::Parts(p) => !p.is_empty(),
1956 };
1957
1958 if has_content || msg.tool_calls.is_none() {
1959 items.push(CompactInputItem::Message {
1960 role: role.to_string(),
1961 content,
1962 });
1963 }
1964
1965 if msg.role == LlmMessageRole::Assistant
1967 && let Some(tool_calls) = &msg.tool_calls
1968 {
1969 for tc in tool_calls {
1970 items.push(CompactInputItem::FunctionCall {
1971 call_id: tc.id.clone(),
1972 name: tc.name.clone(),
1973 arguments: tc.arguments.to_string(),
1974 });
1975 }
1976 }
1977
1978 items
1979 }
1980
1981 fn content_from_llm_message(msg: &LlmMessage) -> CompactContent {
1983 match &msg.content {
1984 LlmMessageContent::Text(text) => CompactContent::Text(text.clone()),
1985 LlmMessageContent::Parts(parts) => {
1986 let compact_parts: Vec<CompactContentPart> = parts
1987 .iter()
1988 .filter_map(|part| match part {
1989 LlmContentPart::Text { text } => {
1990 Some(CompactContentPart::InputText { text: text.clone() })
1991 }
1992 LlmContentPart::Image { url } => {
1993 Some(CompactContentPart::InputImage {
1995 image_url: url.clone(),
1996 })
1997 }
1998 LlmContentPart::Audio { .. } => None, })
2000 .collect();
2001 if compact_parts.len() == 1
2002 && let CompactContentPart::InputText { text } = &compact_parts[0]
2003 {
2004 return CompactContent::Text(text.clone());
2005 }
2006 CompactContent::Parts(compact_parts)
2007 }
2008 }
2009 }
2010}
2011
2012pub fn messages_to_compact_input(messages: &[LlmMessage]) -> Vec<CompactInputItem> {
2014 messages
2015 .iter()
2016 .flat_map(CompactInputItem::from_llm_message)
2017 .collect()
2018}
2019
2020#[derive(Debug, Serialize)]
2025struct ResponsesRequest {
2026 model: String,
2027 input: Vec<ResponsesInputItem>,
2028 #[serde(skip_serializing_if = "Option::is_none")]
2029 instructions: Option<String>,
2030 #[serde(skip_serializing_if = "Option::is_none")]
2031 previous_response_id: Option<String>,
2032 #[serde(skip_serializing_if = "Option::is_none")]
2033 temperature: Option<f32>,
2034 #[serde(skip_serializing_if = "Option::is_none")]
2035 max_output_tokens: Option<u32>,
2036 stream: bool,
2037 #[serde(skip_serializing_if = "Option::is_none")]
2038 tools: Option<Vec<ResponsesTool>>,
2039 #[serde(skip_serializing_if = "Option::is_none")]
2040 reasoning: Option<ResponsesReasoning>,
2041 #[serde(skip_serializing_if = "Option::is_none")]
2044 metadata: Option<std::collections::HashMap<String, String>>,
2045 #[serde(skip_serializing_if = "Option::is_none")]
2046 prompt_cache_key: Option<String>,
2047 #[serde(skip_serializing_if = "Option::is_none")]
2050 parallel_tool_calls: Option<bool>,
2051 #[serde(skip_serializing_if = "Option::is_none")]
2054 service_tier: Option<String>,
2055 #[serde(skip_serializing_if = "Option::is_none")]
2058 text: Option<ResponsesText>,
2059}
2060
2061#[derive(Debug, Serialize)]
2064struct ResponsesText {
2065 #[serde(skip_serializing_if = "Option::is_none")]
2066 verbosity: Option<String>,
2067}
2068
2069#[derive(Debug, Serialize)]
2070struct ResponsesReasoning {
2071 effort: String,
2072 summary: String,
2075}
2076
2077#[derive(Debug, Serialize)]
2078#[serde(untagged)]
2079enum ResponsesInputItem {
2080 Message {
2081 r#type: String,
2082 role: String,
2083 content: ResponsesContent,
2084 #[serde(skip_serializing_if = "Option::is_none")]
2088 phase: Option<String>,
2089 },
2090 FunctionCall {
2091 r#type: String,
2092 call_id: String,
2093 name: String,
2094 arguments: String,
2095 },
2096 FunctionCallOutput {
2097 r#type: String,
2098 call_id: String,
2099 output: String,
2100 },
2101 Reasoning {
2111 r#type: String,
2112 id: String,
2114 encrypted_content: String,
2116 },
2117 Compaction {
2119 r#type: String,
2120 encrypted_content: String,
2121 },
2122}
2123
2124impl From<&CompactOutputItem> for ResponsesInputItem {
2125 fn from(item: &CompactOutputItem) -> Self {
2126 match item {
2127 CompactOutputItem::Message { role, content } => Self::Message {
2128 r#type: "message".to_string(),
2129 role: role.clone(),
2130 content: match content {
2131 CompactContent::Text(text) => ResponsesContent::Text(text.clone()),
2132 CompactContent::Parts(parts) => ResponsesContent::Parts(
2133 parts
2134 .iter()
2135 .map(|part| match part {
2136 CompactContentPart::InputText { text } => {
2137 ResponsesContentPart::InputText {
2138 r#type: "input_text".to_string(),
2139 text: text.clone(),
2140 }
2141 }
2142 CompactContentPart::InputImage { image_url } => {
2143 ResponsesContentPart::InputImage {
2144 r#type: "input_image".to_string(),
2145 image_url: image_url.clone(),
2146 }
2147 }
2148 })
2149 .collect(),
2150 ),
2151 },
2152 phase: None,
2153 },
2154 CompactOutputItem::Compaction { encrypted_content } => Self::Compaction {
2155 r#type: "compaction".to_string(),
2156 encrypted_content: encrypted_content.clone(),
2157 },
2158 }
2159 }
2160}
2161
2162#[derive(Debug, Serialize, Deserialize)]
2163#[serde(untagged)]
2164enum ResponsesContent {
2165 Text(String),
2166 Parts(Vec<ResponsesContentPart>),
2167}
2168
2169#[derive(Debug, Serialize, Deserialize)]
2171#[serde(untagged)]
2172#[allow(clippy::enum_variant_names)]
2173enum ResponsesContentPart {
2174 InputText {
2175 r#type: String,
2176 text: String,
2177 },
2178 InputImage {
2179 r#type: String,
2180 image_url: String,
2181 },
2182 InputAudio {
2183 r#type: String,
2184 input_audio: ResponsesInputAudio,
2185 },
2186}
2187
2188#[derive(Debug, Serialize, Deserialize)]
2189struct ResponsesInputAudio {
2190 data: String,
2191 format: String,
2192}
2193
2194#[derive(Debug, Serialize)]
2195#[serde(untagged)]
2196enum ResponsesTool {
2197 Function {
2199 r#type: String,
2200 name: String,
2201 description: String,
2202 parameters: Value,
2203 #[serde(skip_serializing_if = "Option::is_none")]
2204 defer_loading: Option<bool>,
2205 },
2206 Namespace {
2208 r#type: String,
2209 name: String,
2210 description: String,
2211 tools: Vec<ResponsesTool>,
2212 },
2213 ToolSearch { r#type: String },
2215}
2216
2217#[cfg(test)]
2222mod tests {
2223 use super::*;
2224
2225 #[test]
2226 fn test_driver_with_api_key() {
2227 let driver = OpenResponsesProtocolChatDriver::new("test-key");
2228 assert!(format!("{:?}", driver).contains("OpenResponsesProtocolChatDriver"));
2229 }
2230
2231 #[test]
2232 fn test_driver_with_base_url() {
2233 let driver = OpenResponsesProtocolChatDriver::with_base_url(
2234 "test-key",
2235 "https://custom.api.com/v1/responses",
2236 );
2237 assert!(format!("{:?}", driver).contains("OpenResponsesProtocolChatDriver"));
2238 assert_eq!(driver.api_url(), "https://custom.api.com/v1/responses");
2239 }
2240
2241 #[test]
2242 fn test_request_serialization() {
2243 let request = ResponsesRequest {
2244 text: None,
2245 service_tier: None,
2246 model: "gpt-4o".to_string(),
2247 input: vec![ResponsesInputItem::Message {
2248 r#type: "message".to_string(),
2249 role: "user".to_string(),
2250 content: ResponsesContent::Text("Hello".to_string()),
2251 phase: None,
2252 }],
2253 instructions: Some("You are helpful".to_string()),
2254 previous_response_id: None,
2255 temperature: None,
2256 max_output_tokens: None,
2257 stream: true,
2258 tools: None,
2259 reasoning: None,
2260 metadata: None,
2261 prompt_cache_key: None,
2262 parallel_tool_calls: None,
2263 };
2264
2265 let json = serde_json::to_value(&request).unwrap();
2266 assert_eq!(json["model"], "gpt-4o");
2267 assert_eq!(json["stream"], true);
2268 assert_eq!(json["instructions"], "You are helpful");
2269 assert!(json["input"].is_array());
2270 }
2271
2272 #[test]
2273 fn test_request_with_reasoning() {
2274 let request = ResponsesRequest {
2275 text: None,
2276 service_tier: None,
2277 model: "o3".to_string(),
2278 input: vec![ResponsesInputItem::Message {
2279 r#type: "message".to_string(),
2280 role: "user".to_string(),
2281 content: ResponsesContent::Text("Think about this".to_string()),
2282 phase: None,
2283 }],
2284 instructions: None,
2285 previous_response_id: None,
2286 temperature: None,
2287 max_output_tokens: None,
2288 stream: true,
2289 tools: None,
2290 reasoning: Some(ResponsesReasoning {
2291 effort: "high".to_string(),
2292 summary: "detailed".to_string(),
2293 }),
2294 metadata: None,
2295 prompt_cache_key: None,
2296 parallel_tool_calls: None,
2297 };
2298
2299 let json = serde_json::to_value(&request).unwrap();
2300 assert_eq!(json["reasoning"]["effort"], "high");
2301 assert_eq!(json["reasoning"]["summary"], "detailed");
2302 }
2303
2304 #[test]
2305 fn test_request_with_metadata() {
2306 let mut metadata = std::collections::HashMap::new();
2307 metadata.insert("session_id".to_string(), "session_abc123".to_string());
2308 metadata.insert("agent_id".to_string(), "agent_xyz789".to_string());
2309
2310 let request = ResponsesRequest {
2311 text: None,
2312 service_tier: None,
2313 model: "gpt-4o".to_string(),
2314 input: vec![ResponsesInputItem::Message {
2315 r#type: "message".to_string(),
2316 role: "user".to_string(),
2317 content: ResponsesContent::Text("Hello".to_string()),
2318 phase: None,
2319 }],
2320 instructions: None,
2321 previous_response_id: None,
2322 temperature: None,
2323 max_output_tokens: None,
2324 stream: true,
2325 tools: None,
2326 reasoning: None,
2327 metadata: Some(metadata),
2328 prompt_cache_key: None,
2329 parallel_tool_calls: None,
2330 };
2331
2332 let json = serde_json::to_value(&request).unwrap();
2333 assert_eq!(json["metadata"]["session_id"], "session_abc123");
2334 assert_eq!(json["metadata"]["agent_id"], "agent_xyz789");
2335 }
2336
2337 #[test]
2340 fn test_request_serializes_parallel_tool_calls() {
2341 let make = |flag: Option<bool>| ResponsesRequest {
2342 text: None,
2343 service_tier: None,
2344 model: "gpt-5.4".to_string(),
2345 input: vec![ResponsesInputItem::Message {
2346 r#type: "message".to_string(),
2347 role: "user".to_string(),
2348 content: ResponsesContent::Text("Hello".to_string()),
2349 phase: None,
2350 }],
2351 instructions: None,
2352 previous_response_id: None,
2353 temperature: None,
2354 max_output_tokens: None,
2355 stream: true,
2356 tools: None,
2357 reasoning: None,
2358 metadata: None,
2359 prompt_cache_key: None,
2360 parallel_tool_calls: flag,
2361 };
2362
2363 let json = serde_json::to_value(make(None)).unwrap();
2365 assert!(json.get("parallel_tool_calls").is_none());
2366
2367 let json = serde_json::to_value(make(Some(true))).unwrap();
2369 assert_eq!(json["parallel_tool_calls"], true);
2370
2371 let json = serde_json::to_value(make(Some(false))).unwrap();
2373 assert_eq!(json["parallel_tool_calls"], false);
2374 }
2375
2376 #[test]
2379 fn test_request_serializes_service_tier() {
2380 let make = |tier: Option<&str>| ResponsesRequest {
2381 service_tier: tier.map(str::to_string),
2382 model: "gpt-5.4".to_string(),
2383 input: vec![ResponsesInputItem::Message {
2384 r#type: "message".to_string(),
2385 role: "user".to_string(),
2386 content: ResponsesContent::Text("Hello".to_string()),
2387 phase: None,
2388 }],
2389 instructions: None,
2390 previous_response_id: None,
2391 temperature: None,
2392 max_output_tokens: None,
2393 stream: true,
2394 tools: None,
2395 reasoning: None,
2396 metadata: None,
2397 prompt_cache_key: None,
2398 parallel_tool_calls: None,
2399 text: None,
2400 };
2401
2402 let json = serde_json::to_value(make(None)).unwrap();
2403 assert!(json.get("service_tier").is_none());
2404
2405 let json = serde_json::to_value(make(Some("priority"))).unwrap();
2406 assert_eq!(json["service_tier"], "priority");
2407
2408 let json = serde_json::to_value(make(Some("flex"))).unwrap();
2409 assert_eq!(json["service_tier"], "flex");
2410 }
2411
2412 #[test]
2415 fn test_request_serializes_verbosity() {
2416 let make = |verbosity: Option<&str>| ResponsesRequest {
2417 service_tier: None,
2418 text: verbosity.map(|v| ResponsesText {
2419 verbosity: Some(v.to_string()),
2420 }),
2421 model: "gpt-5.6-sol".to_string(),
2422 input: vec![ResponsesInputItem::Message {
2423 r#type: "message".to_string(),
2424 role: "user".to_string(),
2425 content: ResponsesContent::Text("Hello".to_string()),
2426 phase: None,
2427 }],
2428 instructions: None,
2429 previous_response_id: None,
2430 temperature: None,
2431 max_output_tokens: None,
2432 stream: true,
2433 tools: None,
2434 reasoning: None,
2435 metadata: None,
2436 prompt_cache_key: None,
2437 parallel_tool_calls: None,
2438 };
2439
2440 let json = serde_json::to_value(make(None)).unwrap();
2441 assert!(json.get("text").is_none());
2442
2443 let json = serde_json::to_value(make(Some("low"))).unwrap();
2444 assert_eq!(json["text"]["verbosity"], "low");
2445
2446 let json = serde_json::to_value(make(Some("high"))).unwrap();
2447 assert_eq!(json["text"]["verbosity"], "high");
2448 }
2449
2450 #[test]
2451 fn test_build_prompt_cache_key_when_enabled() {
2452 let mut metadata = std::collections::HashMap::new();
2453 metadata.insert("session_id".to_string(), "session_abc123".to_string());
2454 let config = LlmCallConfig {
2455 speed: None,
2456 verbosity: None,
2457 model: "gpt-5.4".to_string(),
2458 temperature: None,
2459 max_tokens: None,
2460 tools: vec![],
2461 reasoning_effort: None,
2462 metadata,
2463 previous_response_id: None,
2464 provider_opaque_context: None,
2465 tool_search: None,
2466 prompt_cache: Some(crate::driver_registry::PromptCacheConfig {
2467 enabled: true,
2468 strategy: crate::driver_registry::PromptCacheStrategy::Auto,
2469 gemini_cached_content: None,
2470 }),
2471 openrouter_routing: None,
2472 parallel_tool_calls: None,
2473 volatile_suffix_len: 0,
2474 };
2475 let input = vec![ResponsesInputItem::Message {
2476 r#type: "message".to_string(),
2477 role: "user".to_string(),
2478 content: ResponsesContent::Text("Hello".to_string()),
2479 phase: None,
2480 }];
2481
2482 let key = OpenResponsesProtocolChatDriver::build_prompt_cache_key(
2483 &config,
2484 &input,
2485 &Some("You are helpful".to_string()),
2486 &None,
2487 );
2488
2489 assert!(key.is_some());
2490 assert!(key.unwrap().starts_with("everruns:"));
2491 }
2492
2493 #[test]
2494 fn test_build_prompt_cache_key_ignores_changing_input() {
2495 let mut metadata = std::collections::HashMap::new();
2496 metadata.insert("session_id".to_string(), "session_abc123".to_string());
2497 let config = LlmCallConfig {
2498 speed: None,
2499 verbosity: None,
2500 model: "gpt-5.4".to_string(),
2501 temperature: None,
2502 max_tokens: None,
2503 tools: vec![],
2504 reasoning_effort: None,
2505 metadata,
2506 previous_response_id: None,
2507 provider_opaque_context: None,
2508 tool_search: None,
2509 prompt_cache: Some(crate::driver_registry::PromptCacheConfig {
2510 enabled: true,
2511 strategy: crate::driver_registry::PromptCacheStrategy::Auto,
2512 gemini_cached_content: None,
2513 }),
2514 openrouter_routing: None,
2515 parallel_tool_calls: None,
2516 volatile_suffix_len: 0,
2517 };
2518 let first_input = vec![ResponsesInputItem::Message {
2519 r#type: "message".to_string(),
2520 role: "user".to_string(),
2521 content: ResponsesContent::Text("first turn".to_string()),
2522 phase: None,
2523 }];
2524 let second_input = vec![ResponsesInputItem::Message {
2525 r#type: "message".to_string(),
2526 role: "user".to_string(),
2527 content: ResponsesContent::Text("second turn with different text".to_string()),
2528 phase: None,
2529 }];
2530
2531 let first = OpenResponsesProtocolChatDriver::build_prompt_cache_key(
2532 &config,
2533 &first_input,
2534 &Some("You are helpful".to_string()),
2535 &None,
2536 );
2537 let second = OpenResponsesProtocolChatDriver::build_prompt_cache_key(
2538 &config,
2539 &second_input,
2540 &Some("You are helpful".to_string()),
2541 &None,
2542 );
2543
2544 assert_eq!(first, second);
2545 }
2546
2547 #[test]
2548 fn test_build_prompt_cache_key_changes_with_cache_family() {
2549 let mut first_metadata = std::collections::HashMap::new();
2550 first_metadata.insert("session_id".to_string(), "session_abc123".to_string());
2551 let mut second_metadata = std::collections::HashMap::new();
2552 second_metadata.insert("session_id".to_string(), "session_xyz789".to_string());
2553 let make_config = |metadata| LlmCallConfig {
2554 speed: None,
2555 verbosity: None,
2556 model: "gpt-5.4".to_string(),
2557 temperature: None,
2558 max_tokens: None,
2559 tools: vec![],
2560 reasoning_effort: None,
2561 metadata,
2562 previous_response_id: None,
2563 provider_opaque_context: None,
2564 tool_search: None,
2565 prompt_cache: Some(crate::driver_registry::PromptCacheConfig {
2566 enabled: true,
2567 strategy: crate::driver_registry::PromptCacheStrategy::Auto,
2568 gemini_cached_content: None,
2569 }),
2570 openrouter_routing: None,
2571 parallel_tool_calls: None,
2572 volatile_suffix_len: 0,
2573 };
2574 let input = vec![ResponsesInputItem::Message {
2575 r#type: "message".to_string(),
2576 role: "user".to_string(),
2577 content: ResponsesContent::Text("same turn".to_string()),
2578 phase: None,
2579 }];
2580
2581 let first = OpenResponsesProtocolChatDriver::build_prompt_cache_key(
2582 &make_config(first_metadata),
2583 &input,
2584 &Some("You are helpful".to_string()),
2585 &None,
2586 );
2587 let second = OpenResponsesProtocolChatDriver::build_prompt_cache_key(
2588 &make_config(second_metadata),
2589 &input,
2590 &Some("You are helpful".to_string()),
2591 &None,
2592 );
2593
2594 assert_ne!(first, second);
2595 }
2596
2597 #[test]
2598 fn test_build_prompt_cache_key_stays_within_openai_limit() {
2599 let config = LlmCallConfig {
2600 speed: None,
2601 verbosity: None,
2602 model: "gpt-5.5".to_string(),
2603 temperature: None,
2604 max_tokens: None,
2605 tools: vec![],
2606 reasoning_effort: None,
2607 metadata: std::collections::HashMap::new(),
2608 previous_response_id: None,
2609 provider_opaque_context: None,
2610 tool_search: None,
2611 prompt_cache: Some(crate::driver_registry::PromptCacheConfig {
2612 enabled: true,
2613 strategy: crate::driver_registry::PromptCacheStrategy::Auto,
2614 gemini_cached_content: None,
2615 }),
2616 openrouter_routing: None,
2617 parallel_tool_calls: None,
2618 volatile_suffix_len: 0,
2619 };
2620 let input = vec![ResponsesInputItem::Message {
2621 r#type: "message".to_string(),
2622 role: "user".to_string(),
2623 content: ResponsesContent::Text("fetch chalyi.name for me".to_string()),
2624 phase: None,
2625 }];
2626
2627 let key = OpenResponsesProtocolChatDriver::build_prompt_cache_key(
2628 &config,
2629 &input,
2630 &Some("You are helpful".to_string()),
2631 &None,
2632 )
2633 .unwrap();
2634
2635 assert!(
2636 key.len() <= 64,
2637 "OpenAI prompt_cache_key limit is 64 characters, got {}",
2638 key.len()
2639 );
2640 }
2641
2642 #[test]
2643 fn test_function_call_output_serialization() {
2644 let item = ResponsesInputItem::FunctionCallOutput {
2645 r#type: "function_call_output".to_string(),
2646 call_id: "call_123".to_string(),
2647 output: r#"{"result": 42}"#.to_string(),
2648 };
2649
2650 let json = serde_json::to_value(&item).unwrap();
2651 assert_eq!(json["type"], "function_call_output");
2652 assert_eq!(json["call_id"], "call_123");
2653 assert_eq!(json["output"], r#"{"result": 42}"#);
2654 }
2655
2656 #[test]
2657 fn test_multipart_content_serialization() {
2658 let content = ResponsesContent::Parts(vec![
2659 ResponsesContentPart::InputText {
2660 r#type: "input_text".to_string(),
2661 text: "Look at this image".to_string(),
2662 },
2663 ResponsesContentPart::InputImage {
2664 r#type: "input_image".to_string(),
2665 image_url: "data:image/png;base64,abc123".to_string(),
2666 },
2667 ]);
2668
2669 let json = serde_json::to_value(&content).unwrap();
2670 assert!(json.is_array());
2671 assert_eq!(json[0]["type"], "input_text");
2672 assert_eq!(json[1]["type"], "input_image");
2673 }
2674
2675 #[test]
2676 fn test_tool_serialization() {
2677 let tool = ResponsesTool::Function {
2678 r#type: "function".to_string(),
2679 name: "get_weather".to_string(),
2680 description: "Get weather for a location".to_string(),
2681 parameters: json!({
2682 "type": "object",
2683 "properties": {
2684 "location": {"type": "string"}
2685 },
2686 "required": ["location"]
2687 }),
2688 defer_loading: None,
2689 };
2690
2691 let json = serde_json::to_value(&tool).unwrap();
2692 assert_eq!(json["type"], "function");
2693 assert_eq!(json["name"], "get_weather");
2694 assert!(json["parameters"]["properties"]["location"].is_object());
2695 }
2696
2697 #[test]
2698 fn test_build_input_extracts_system_as_instructions() {
2699 let messages = vec![
2700 LlmMessage::text(LlmMessageRole::System, "You are a helpful assistant"),
2701 LlmMessage::text(LlmMessageRole::User, "Hello"),
2702 ];
2703
2704 let (instructions, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
2705
2706 assert_eq!(
2707 instructions,
2708 Some("You are a helpful assistant".to_string())
2709 );
2710 assert_eq!(input.len(), 1); }
2712
2713 #[test]
2714 fn test_build_input_concatenates_multiple_system_messages() {
2715 let messages = vec![
2719 LlmMessage::text(LlmMessageRole::System, "You are a helpful assistant"),
2720 LlmMessage::text(LlmMessageRole::User, "Hello"),
2721 LlmMessage::text(
2722 LlmMessageRole::System,
2723 "[IMPORTANT: 3 earlier messages are NOT visible in this context.]",
2724 ),
2725 ];
2726
2727 let (instructions, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
2728
2729 assert_eq!(
2730 instructions,
2731 Some(
2732 "You are a helpful assistant\n\n[IMPORTANT: 3 earlier messages are NOT visible in this context.]"
2733 .to_string()
2734 )
2735 );
2736 assert_eq!(input.len(), 1); }
2738
2739 #[test]
2740 fn test_convert_role() {
2741 assert_eq!(
2742 OpenResponsesProtocolChatDriver::convert_role(&LlmMessageRole::System),
2743 "developer"
2744 );
2745 assert_eq!(
2746 OpenResponsesProtocolChatDriver::convert_role(&LlmMessageRole::User),
2747 "user"
2748 );
2749 assert_eq!(
2750 OpenResponsesProtocolChatDriver::convert_role(&LlmMessageRole::Assistant),
2751 "assistant"
2752 );
2753 assert_eq!(
2754 OpenResponsesProtocolChatDriver::convert_role(&LlmMessageRole::Tool),
2755 "tool"
2756 );
2757 }
2758
2759 #[test]
2760 fn test_function_call_serialization() {
2761 let item = ResponsesInputItem::FunctionCall {
2762 r#type: "function_call".to_string(),
2763 call_id: "call_abc123".to_string(),
2764 name: "get_current_time".to_string(),
2765 arguments: r#"{"timezone":"UTC"}"#.to_string(),
2766 };
2767
2768 let json = serde_json::to_value(&item).unwrap();
2769 assert_eq!(json["type"], "function_call");
2770 assert_eq!(json["call_id"], "call_abc123");
2771 assert_eq!(json["name"], "get_current_time");
2772 assert_eq!(json["arguments"], r#"{"timezone":"UTC"}"#);
2773 }
2774
2775 #[test]
2776 fn test_build_input_with_tool_calls() {
2777 use crate::tool_types::ToolCall;
2778
2779 let messages = vec![
2784 LlmMessage::text(LlmMessageRole::System, "You are helpful"),
2785 LlmMessage::text(LlmMessageRole::User, "What time is it?"),
2786 LlmMessage {
2787 role: LlmMessageRole::Assistant,
2788 content: LlmMessageContent::Text(String::new()),
2789 tool_calls: Some(vec![ToolCall {
2790 id: "call_xyz789".to_string(),
2791 name: "get_current_time".to_string(),
2792 arguments: json!({"timezone": "UTC"}),
2793 }]),
2794 tool_call_id: None,
2795 phase: None,
2796 thinking: None,
2797 thinking_signature: None,
2798 },
2799 LlmMessage {
2800 role: LlmMessageRole::Tool,
2801 content: LlmMessageContent::Text("2025-01-19T10:30:00Z".to_string()),
2802 tool_calls: None,
2803 tool_call_id: Some("call_xyz789".to_string()),
2804 phase: None,
2805 thinking: None,
2806 thinking_signature: None,
2807 },
2808 ];
2809
2810 let (instructions, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
2811
2812 assert_eq!(instructions, Some("You are helpful".to_string()));
2814
2815 assert_eq!(input.len(), 3);
2817
2818 let json = serde_json::to_value(&input[1]).unwrap();
2820 assert_eq!(json["type"], "function_call");
2821 assert_eq!(json["call_id"], "call_xyz789");
2822 assert_eq!(json["name"], "get_current_time");
2823
2824 let json = serde_json::to_value(&input[2]).unwrap();
2826 assert_eq!(json["type"], "function_call_output");
2827 assert_eq!(json["call_id"], "call_xyz789");
2828 assert_eq!(json["output"], "2025-01-19T10:30:00Z");
2829 }
2830
2831 #[test]
2832 fn test_build_input_with_tool_calls_and_text() {
2833 use crate::tool_types::ToolCall;
2834
2835 let messages = vec![
2837 LlmMessage::text(LlmMessageRole::User, "What time is it?"),
2838 LlmMessage {
2839 role: LlmMessageRole::Assistant,
2840 content: LlmMessageContent::Text("Let me check the time for you.".to_string()),
2841 tool_calls: Some(vec![ToolCall {
2842 id: "call_abc".to_string(),
2843 name: "get_time".to_string(),
2844 arguments: json!({}),
2845 }]),
2846 tool_call_id: None,
2847 phase: None,
2848 thinking: None,
2849 thinking_signature: None,
2850 },
2851 ];
2852
2853 let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
2854
2855 assert_eq!(input.len(), 3);
2857
2858 let json = serde_json::to_value(&input[0]).unwrap();
2860 assert_eq!(json["role"], "user");
2861
2862 let json = serde_json::to_value(&input[1]).unwrap();
2864 assert_eq!(json["role"], "assistant");
2865
2866 let json = serde_json::to_value(&input[2]).unwrap();
2868 assert_eq!(json["type"], "function_call");
2869 assert_eq!(json["call_id"], "call_abc");
2870 }
2871
2872 #[test]
2887 fn openresponses_requests_should_not_mix_previous_response_id_with_full_transcript() {
2888 use crate::tool_types::ToolCall;
2889
2890 let messages = vec![
2894 LlmMessage::text(LlmMessageRole::System, "You are helpful"),
2895 LlmMessage::text(LlmMessageRole::User, "What time is it?"),
2896 LlmMessage {
2897 role: LlmMessageRole::Assistant,
2898 content: LlmMessageContent::Text("Let me check.".to_string()),
2899 tool_calls: Some(vec![ToolCall {
2900 id: "call_xyz789".to_string(),
2901 name: "get_current_time".to_string(),
2902 arguments: json!({"timezone": "UTC"}),
2903 }]),
2904 tool_call_id: None,
2905 phase: None,
2906 thinking: None,
2907 thinking_signature: None,
2908 },
2909 LlmMessage {
2910 role: LlmMessageRole::Tool,
2911 content: LlmMessageContent::Text("2025-01-19T10:30:00Z".to_string()),
2912 tool_calls: None,
2913 tool_call_id: Some("call_xyz789".to_string()),
2914 phase: None,
2915 thinking: None,
2916 thinking_signature: None,
2917 },
2918 ];
2919
2920 let (instructions, full_input) =
2922 OpenResponsesProtocolChatDriver::build_input(&messages, false);
2923
2924 assert!(
2927 full_input.len() > 1,
2928 "sanity: full transcript has multi items"
2929 );
2930
2931 let delta = compute_delta_input_items(full_input);
2934
2935 assert_eq!(
2937 delta.len(),
2938 1,
2939 "stateful continuation must only send delta items; got {} items",
2940 delta.len()
2941 );
2942 let json = serde_json::to_value(&delta[0]).unwrap();
2943 assert_eq!(json["type"], "function_call_output");
2944 assert_eq!(json["call_id"], "call_xyz789");
2945 assert_eq!(json["output"], "2025-01-19T10:30:00Z");
2946
2947 assert_eq!(instructions, Some("You are helpful".to_string()));
2950 }
2951
2952 #[test]
2957 fn compute_delta_keeps_tail_after_assistant_message() {
2958 let items = vec![
2959 ResponsesInputItem::Message {
2960 r#type: "message".to_string(),
2961 role: "user".to_string(),
2962 content: ResponsesContent::Text("hi".to_string()),
2963 phase: None,
2964 },
2965 ResponsesInputItem::Message {
2966 r#type: "message".to_string(),
2967 role: "assistant".to_string(),
2968 content: ResponsesContent::Text("hello".to_string()),
2969 phase: None,
2970 },
2971 ResponsesInputItem::Message {
2972 r#type: "message".to_string(),
2973 role: "user".to_string(),
2974 content: ResponsesContent::Text("follow up".to_string()),
2975 phase: None,
2976 },
2977 ];
2978 let trimmed = compute_delta_input_items(items);
2979 assert_eq!(trimmed.len(), 1);
2980 let json = serde_json::to_value(&trimmed[0]).unwrap();
2981 assert_eq!(json["role"], "user");
2982 assert_eq!(
2983 json["content"], "follow up",
2984 "trim keeps the fresh user message that arrived after the assistant turn"
2985 );
2986 }
2987
2988 #[test]
2992 fn compute_delta_keeps_tool_results_after_last_assistant_turn() {
2993 let items = vec![
2994 ResponsesInputItem::Message {
2995 r#type: "message".to_string(),
2996 role: "user".to_string(),
2997 content: ResponsesContent::Text("do two things".to_string()),
2998 phase: None,
2999 },
3000 ResponsesInputItem::Message {
3001 r#type: "message".to_string(),
3002 role: "assistant".to_string(),
3003 content: ResponsesContent::Text("ok".to_string()),
3004 phase: None,
3005 },
3006 ResponsesInputItem::FunctionCall {
3007 r#type: "function_call".to_string(),
3008 call_id: "call_a".to_string(),
3009 name: "tool_a".to_string(),
3010 arguments: "{}".to_string(),
3011 },
3012 ResponsesInputItem::FunctionCall {
3013 r#type: "function_call".to_string(),
3014 call_id: "call_b".to_string(),
3015 name: "tool_b".to_string(),
3016 arguments: "{}".to_string(),
3017 },
3018 ResponsesInputItem::FunctionCallOutput {
3019 r#type: "function_call_output".to_string(),
3020 call_id: "call_a".to_string(),
3021 output: "a result".to_string(),
3022 },
3023 ResponsesInputItem::FunctionCallOutput {
3024 r#type: "function_call_output".to_string(),
3025 call_id: "call_b".to_string(),
3026 output: "b result".to_string(),
3027 },
3028 ];
3029
3030 let trimmed = compute_delta_input_items(items);
3031
3032 assert_eq!(trimmed.len(), 2);
3035 for item in &trimmed {
3036 let json = serde_json::to_value(item).unwrap();
3037 assert_eq!(json["type"], "function_call_output");
3038 }
3039 }
3040
3041 #[test]
3044 fn compute_delta_allows_empty_input_for_stateful_continuation() {
3045 let trimmed = compute_delta_input_items(vec![]);
3046 assert!(trimmed.is_empty());
3047 }
3048
3049 #[test]
3052 fn compute_delta_keeps_all_items_when_no_assistant_turn_present() {
3053 let items = vec![
3054 ResponsesInputItem::Message {
3055 r#type: "message".to_string(),
3056 role: "user".to_string(),
3057 content: ResponsesContent::Text("one".to_string()),
3058 phase: None,
3059 },
3060 ResponsesInputItem::Message {
3061 r#type: "message".to_string(),
3062 role: "user".to_string(),
3063 content: ResponsesContent::Text("two".to_string()),
3064 phase: None,
3065 },
3066 ];
3067 let trimmed = compute_delta_input_items(items);
3068 assert_eq!(trimmed.len(), 2);
3069 }
3070
3071 #[test]
3073 fn compute_delta_drops_prior_reasoning_items() {
3074 let items = vec![
3075 ResponsesInputItem::Reasoning {
3076 r#type: "reasoning".to_string(),
3077 id: "rs_00000001".to_string(),
3078 encrypted_content: "encrypted-blob".to_string(),
3079 },
3080 ResponsesInputItem::Message {
3081 r#type: "message".to_string(),
3082 role: "assistant".to_string(),
3083 content: ResponsesContent::Text("prior".to_string()),
3084 phase: None,
3085 },
3086 ResponsesInputItem::FunctionCallOutput {
3087 r#type: "function_call_output".to_string(),
3088 call_id: "call_z".to_string(),
3089 output: "result".to_string(),
3090 },
3091 ];
3092 let trimmed = compute_delta_input_items(items);
3093 assert_eq!(trimmed.len(), 1);
3094 let json = serde_json::to_value(&trimmed[0]).unwrap();
3095 assert_eq!(json["type"], "function_call_output");
3096 }
3097
3098 fn sample_full_transcript_items() -> Vec<ResponsesInputItem> {
3108 vec![
3109 ResponsesInputItem::Message {
3110 r#type: "message".to_string(),
3111 role: "user".to_string(),
3112 content: ResponsesContent::Text("first request".to_string()),
3113 phase: None,
3114 },
3115 ResponsesInputItem::Message {
3116 r#type: "message".to_string(),
3117 role: "assistant".to_string(),
3118 content: ResponsesContent::Text("first reply".to_string()),
3119 phase: None,
3120 },
3121 ResponsesInputItem::Message {
3122 r#type: "message".to_string(),
3123 role: "user".to_string(),
3124 content: ResponsesContent::Text("follow-up".to_string()),
3125 phase: None,
3126 },
3127 ]
3128 }
3129
3130 #[test]
3131 fn finalize_input_skips_trim_when_previous_response_id_is_none() {
3132 let items = sample_full_transcript_items();
3133 let original_len = items.len();
3134 let out = finalize_input_for_request(items, &None);
3135 assert_eq!(
3136 out.len(),
3137 original_len,
3138 "stateless mode keeps the full transcript so the model has context"
3139 );
3140 }
3141
3142 #[test]
3143 fn finalize_input_drops_locally_orphaned_tool_output_without_previous_response_id() {
3144 let items = vec![
3145 ResponsesInputItem::Message {
3146 r#type: "message".to_string(),
3147 role: "user".to_string(),
3148 content: ResponsesContent::Text("fresh".to_string()),
3149 phase: None,
3150 },
3151 ResponsesInputItem::FunctionCallOutput {
3152 r#type: "function_call_output".to_string(),
3153 call_id: "call_trimmed".to_string(),
3154 output: "result".to_string(),
3155 },
3156 ];
3157
3158 let out = finalize_input_for_request(items, &None);
3159
3160 assert_eq!(out.len(), 1);
3161 let json = serde_json::to_value(&out[0]).unwrap();
3162 assert_eq!(json["type"], "message");
3163 }
3164
3165 #[test]
3166 fn finalize_input_keeps_tool_output_with_previous_response_id_even_without_local_call() {
3167 let items = vec![
3168 ResponsesInputItem::FunctionCallOutput {
3169 r#type: "function_call_output".to_string(),
3170 call_id: "call_server_side".to_string(),
3171 output: "stateful result".to_string(),
3172 },
3173 ResponsesInputItem::Message {
3174 r#type: "message".to_string(),
3175 role: "user".to_string(),
3176 content: ResponsesContent::Text("follow-up".to_string()),
3177 phase: None,
3178 },
3179 ];
3180
3181 let out = finalize_input_for_request(items, &Some("resp_prev_42".to_string()));
3182
3183 assert_eq!(out.len(), 2);
3184 let json = serde_json::to_value(&out[0]).unwrap();
3185 assert_eq!(json["type"], "function_call_output");
3186 assert_eq!(json["call_id"], "call_server_side");
3187 }
3188
3189 #[test]
3190 fn finalize_input_trims_when_previous_response_id_is_set() {
3191 let items = sample_full_transcript_items();
3192 let out = finalize_input_for_request(items, &Some("resp_prev_42".to_string()));
3193 assert_eq!(
3194 out.len(),
3195 1,
3196 "stateful continuation must drop everything up to and including the prior assistant message"
3197 );
3198 let json = serde_json::to_value(&out[0]).unwrap();
3199 assert_eq!(json["type"], "message");
3200 assert_eq!(json["role"], "user");
3201 let txt = json["content"].as_str().unwrap_or("");
3203 assert_eq!(txt, "follow-up");
3204 }
3205
3206 #[test]
3207 fn finalize_input_allows_empty_input_with_previous_response_id() {
3208 let out = finalize_input_for_request(vec![], &Some("resp_anything".to_string()));
3209 assert!(
3210 out.is_empty(),
3211 "empty delta is valid — the provider can resume purely from the response id"
3212 );
3213 }
3214
3215 fn function_call(call_id: &str, name: &str) -> ResponsesInputItem {
3224 ResponsesInputItem::FunctionCall {
3225 r#type: "function_call".to_string(),
3226 call_id: call_id.to_string(),
3227 name: name.to_string(),
3228 arguments: "{}".to_string(),
3229 }
3230 }
3231
3232 fn function_call_output(call_id: &str) -> ResponsesInputItem {
3233 ResponsesInputItem::FunctionCallOutput {
3234 r#type: "function_call_output".to_string(),
3235 call_id: call_id.to_string(),
3236 output: "result".to_string(),
3237 }
3238 }
3239
3240 fn user_message(text: &str) -> ResponsesInputItem {
3241 ResponsesInputItem::Message {
3242 r#type: "message".to_string(),
3243 role: "user".to_string(),
3244 content: ResponsesContent::Text(text.to_string()),
3245 phase: None,
3246 }
3247 }
3248
3249 #[test]
3250 fn finalize_input_drops_dangling_function_call_without_previous_response_id() {
3251 let items = vec![
3255 user_message("fresh"),
3256 function_call("call_pHJNxIuwzLppFsQK5nJrDOpZ", "read_file"),
3257 ];
3258
3259 let out = finalize_input_for_request(items, &None);
3260
3261 assert_eq!(out.len(), 1);
3262 assert!(
3263 unpaired_function_call_ids(&out).is_empty(),
3264 "the dangling function_call must be dropped"
3265 );
3266 let json = serde_json::to_value(&out[0]).unwrap();
3267 assert_eq!(json["type"], "message");
3268 }
3269
3270 #[test]
3271 fn finalize_input_preserves_paired_function_call_and_output() {
3272 let items = vec![
3273 user_message("what time is it?"),
3274 function_call("call_ok", "get_current_time"),
3275 function_call_output("call_ok"),
3276 ];
3277
3278 let out = finalize_input_for_request(items, &None);
3279
3280 assert_eq!(out.len(), 3, "an intact call/output pair must survive");
3281 assert!(unpaired_function_call_ids(&out).is_empty());
3282 }
3283
3284 #[test]
3285 fn finalize_input_compaction_drops_only_the_dangling_old_call() {
3286 let mut items = vec![
3291 user_message("long session"),
3292 function_call("call_old", "read_file"),
3293 ];
3294 for i in 0..3 {
3295 let id = format!("call_recent_{i}");
3296 items.push(function_call(&id, "tool"));
3297 items.push(function_call_output(&id));
3298 }
3299
3300 let out = finalize_input_for_request(items, &None);
3301
3302 assert!(
3303 unpaired_function_call_ids(&out).is_empty(),
3304 "no dangling function_call may remain after repair"
3305 );
3306 assert!(
3307 !out.iter().any(|item| matches!(
3308 item,
3309 ResponsesInputItem::FunctionCall { call_id, .. } if call_id == "call_old"
3310 )),
3311 "the old dangling call must be removed"
3312 );
3313 assert_eq!(out.len(), 7);
3315 }
3316
3317 #[test]
3318 fn unpaired_function_call_ids_reports_both_directions() {
3319 let items = vec![
3320 function_call("call_no_output", "read_file"), function_call_output("out_no_call"), function_call("paired", "tool"),
3323 function_call_output("paired"),
3324 ];
3325
3326 let mut ids = unpaired_function_call_ids(&items);
3327 ids.sort();
3328 assert_eq!(
3329 ids,
3330 vec!["call_no_output".to_string(), "out_no_call".to_string()]
3331 );
3332 }
3333
3334 #[test]
3339 fn endpoint_persists_responses_for_openai_and_azure() {
3340 assert!(endpoint_persists_responses(
3342 "https://api.openai.com/v1/responses"
3343 ));
3344 assert!(endpoint_persists_responses(
3345 "https://api.openai.com:443/v1/responses"
3346 ));
3347 assert!(endpoint_persists_responses(
3349 "https://my-resource.openai.azure.com/openai/v1/responses"
3350 ));
3351 assert!(endpoint_persists_responses(
3352 "https://my-resource.services.ai.azure.com/openai/v1/responses"
3353 ));
3354 assert!(OpenResponsesProtocolChatDriver::new("test").supports_stateful_responses());
3355 }
3356
3357 #[test]
3358 fn endpoint_does_not_persist_for_stateless_gateways() {
3359 assert!(!endpoint_persists_responses(
3363 "https://openrouter.ai/api/v1/responses"
3364 ));
3365 assert!(!endpoint_persists_responses(
3366 "https://generativelanguage.googleapis.com/v1beta/openai/responses"
3367 ));
3368 assert!(!endpoint_persists_responses(
3370 "https://api.openai.example.com/v1/responses"
3371 ));
3372 assert!(
3373 !OpenResponsesProtocolChatDriver::with_base_url(
3374 "test",
3375 "https://openrouter.ai/api/v1/responses"
3376 )
3377 .supports_stateful_responses()
3378 );
3379 }
3380
3381 #[test]
3386 fn stateless_gateway_replays_full_transcript_despite_previous_response_id() {
3387 let api_url = "https://openrouter.ai/api/v1/responses";
3388 let prev_id: Option<String> = Some("gen-turn-1".to_string());
3389
3390 let effective_prev_id = if endpoint_persists_responses(api_url) {
3392 prev_id.clone()
3393 } else {
3394 None
3395 };
3396 assert!(
3397 effective_prev_id.is_none(),
3398 "stateless gateway must not chain via previous_response_id"
3399 );
3400
3401 let items = sample_full_transcript_items();
3402 let original_len = items.len();
3403 let out = finalize_input_for_request(items, &effective_prev_id);
3404 assert_eq!(
3405 out.len(),
3406 original_len,
3407 "stateless gateway must replay the full transcript so the model keeps context"
3408 );
3409 }
3410
3411 #[test]
3415 fn stateful_endpoint_still_trims_and_chains() {
3416 let api_url = "https://api.openai.com/v1/responses";
3417 let prev_id: Option<String> = Some("resp_turn_1".to_string());
3418
3419 let effective_prev_id = if endpoint_persists_responses(api_url) {
3420 prev_id.clone()
3421 } else {
3422 None
3423 };
3424 assert_eq!(
3425 effective_prev_id, prev_id,
3426 "stateful endpoint keeps the continuation handle"
3427 );
3428
3429 let out = finalize_input_for_request(sample_full_transcript_items(), &effective_prev_id);
3430 assert_eq!(out.len(), 1, "stateful endpoint trims to the delta window");
3431 }
3432
3433 #[tokio::test]
3439 async fn stateless_gateway_request_replays_full_transcript_on_the_wire() {
3440 use crate::tool_types::ToolCall;
3441 use serde_json::json;
3442 use wiremock::matchers::method;
3443 use wiremock::{Mock, MockServer, ResponseTemplate};
3444
3445 let server = MockServer::start().await;
3446 Mock::given(method("POST"))
3449 .respond_with(ResponseTemplate::new(200).set_body_string(""))
3450 .mount(&server)
3451 .await;
3452
3453 let api_url = format!("{}/v1/responses", server.uri());
3456 let driver = OpenResponsesProtocolChatDriver::with_base_url("test-key", api_url);
3457
3458 let messages = vec![
3459 LlmMessage::text(LlmMessageRole::System, "You are helpful"),
3460 LlmMessage::text(LlmMessageRole::User, "upgrade dependencies"),
3461 LlmMessage {
3462 role: LlmMessageRole::Assistant,
3463 content: LlmMessageContent::Text("Let me look.".to_string()),
3464 tool_calls: Some(vec![ToolCall {
3465 id: "call_1".to_string(),
3466 name: "read_file".to_string(),
3467 arguments: json!({"path": "Cargo.toml"}),
3468 }]),
3469 tool_call_id: None,
3470 phase: None,
3471 thinking: None,
3472 thinking_signature: None,
3473 },
3474 LlmMessage {
3475 role: LlmMessageRole::Tool,
3476 content: LlmMessageContent::Text("[package]…".to_string()),
3477 tool_calls: None,
3478 tool_call_id: Some("call_1".to_string()),
3479 phase: None,
3480 thinking: None,
3481 thinking_signature: None,
3482 },
3483 ];
3484
3485 let config = LlmCallConfig {
3486 speed: None,
3487 verbosity: None,
3488 model: "some/model".to_string(),
3489 temperature: None,
3490 max_tokens: None,
3491 tools: vec![],
3492 reasoning_effort: None,
3493 metadata: std::collections::HashMap::new(),
3494 previous_response_id: Some("gen-turn-1".to_string()),
3497 provider_opaque_context: None,
3498 tool_search: None,
3499 prompt_cache: None,
3500 openrouter_routing: None,
3501 parallel_tool_calls: None,
3502 volatile_suffix_len: 0,
3503 };
3504
3505 let _ = driver.chat_completion_stream(messages, &config).await;
3507
3508 let requests = server
3509 .received_requests()
3510 .await
3511 .expect("mock server recorded requests");
3512 assert_eq!(requests.len(), 1, "exactly one request should be sent");
3513 let body: serde_json::Value = requests[0].body_json().expect("request body is JSON");
3514
3515 assert!(
3517 body.get("previous_response_id").is_none(),
3518 "stateless gateway request must omit previous_response_id; body: {body}"
3519 );
3520
3521 let input = body["input"].as_array().expect("input is an array");
3524 assert_eq!(
3525 input.len(),
3526 4,
3527 "full transcript must be replayed on a stateless gateway; got {input:?}"
3528 );
3529 assert_eq!(body["instructions"], "You are helpful");
3530 let has_user_task = input
3531 .iter()
3532 .any(|item| item["type"] == "message" && item["role"] == "user");
3533 assert!(
3534 has_user_task,
3535 "the original user task must be replayed; got {input:?}"
3536 );
3537 let has_tool_output = input
3538 .iter()
3539 .any(|item| item["type"] == "function_call_output");
3540 assert!(
3541 has_tool_output,
3542 "the latest tool result must still be present; got {input:?}"
3543 );
3544 }
3545
3546 #[tokio::test]
3547 async fn openrouter_provider_does_not_send_hosted_tool_search() {
3548 use crate::tool_types::DeferrablePolicy;
3549 use serde_json::json;
3550 use wiremock::matchers::method;
3551 use wiremock::{Mock, MockServer, ResponseTemplate};
3552
3553 let server = MockServer::start().await;
3554 Mock::given(method("POST"))
3555 .respond_with(ResponseTemplate::new(200).set_body_string(""))
3556 .mount(&server)
3557 .await;
3558
3559 let api_url = format!("{}/v1/responses", server.uri());
3560 let driver = OpenResponsesProtocolChatDriver::with_base_url("test-key", api_url)
3561 .with_provider_type(DriverId::OpenRouter);
3562
3563 let tools: Vec<ToolDefinition> = (0..16)
3564 .map(|i| {
3565 make_tool(
3566 &format!("tool_{i}"),
3567 Some("General"),
3568 DeferrablePolicy::Automatic,
3569 )
3570 })
3571 .collect();
3572
3573 let config = LlmCallConfig {
3574 speed: None,
3575 verbosity: None,
3576 model: "gpt-5.4".to_string(),
3577 temperature: None,
3578 max_tokens: None,
3579 tools,
3580 reasoning_effort: None,
3581 metadata: std::collections::HashMap::new(),
3582 previous_response_id: None,
3583 provider_opaque_context: None,
3584 tool_search: Some(crate::driver_registry::ToolSearchConfig {
3585 enabled: true,
3586 threshold: 15,
3587 }),
3588 prompt_cache: None,
3589 openrouter_routing: None,
3590 parallel_tool_calls: None,
3591 volatile_suffix_len: 0,
3592 };
3593
3594 let messages = vec![LlmMessage::text(LlmMessageRole::User, "hello")];
3595 let _ = driver.chat_completion_stream(messages, &config).await;
3596
3597 let requests = server
3598 .received_requests()
3599 .await
3600 .expect("mock server recorded requests");
3601 assert_eq!(requests.len(), 1, "exactly one request should be sent");
3602 let body: serde_json::Value = requests[0].body_json().expect("request body is JSON");
3603 let tools = body["tools"].as_array().expect("tools is an array");
3604
3605 assert!(
3606 tools.iter().all(|tool| tool["type"] == "function"),
3607 "OpenRouter should receive regular function tools, not hosted tool_search payloads: {tools:?}"
3608 );
3609 assert!(
3610 tools.iter().all(|tool| tool.get("defer_loading").is_none()),
3611 "OpenRouter tool schemas should not be deferred by hosted tool_search: {tools:?}"
3612 );
3613 assert_eq!(
3614 body["input"],
3615 json!([{"type": "message", "role": "user", "content": "hello"}])
3616 );
3617 }
3618
3619 #[tokio::test]
3620 async fn openai_provider_omits_openrouter_routing_controls() {
3621 use crate::driver_registry::{OpenRouterRoute, OpenRouterRoutingConfig};
3622 use wiremock::matchers::method;
3623 use wiremock::{Mock, MockServer, ResponseTemplate};
3624
3625 let server = MockServer::start().await;
3626 Mock::given(method("POST"))
3627 .respond_with(ResponseTemplate::new(200).set_body_string(""))
3628 .mount(&server)
3629 .await;
3630
3631 let api_url = format!("{}/v1/responses", server.uri());
3632 let driver = OpenResponsesProtocolChatDriver::with_base_url("test-key", api_url);
3633
3634 let mut metadata = std::collections::HashMap::new();
3635 metadata.insert("session_id".to_string(), "session_abc123".to_string());
3636 let config = LlmCallConfig {
3637 speed: None,
3638 verbosity: None,
3639 model: "gpt-5-mini".to_string(),
3640 temperature: None,
3641 max_tokens: None,
3642 tools: vec![],
3643 reasoning_effort: None,
3644 metadata,
3645 previous_response_id: None,
3646 provider_opaque_context: None,
3647 tool_search: None,
3648 prompt_cache: None,
3649 openrouter_routing: Some(OpenRouterRoutingConfig {
3650 models: vec!["openai/gpt-5-mini".to_string()],
3651 route: Some(OpenRouterRoute::Fallback),
3652 provider: None,
3653 ..Default::default()
3654 }),
3655 parallel_tool_calls: None,
3656 volatile_suffix_len: 0,
3657 };
3658
3659 let messages = vec![LlmMessage::text(LlmMessageRole::User, "hello")];
3660 let _ = driver.chat_completion_stream(messages, &config).await;
3661
3662 let requests = server
3663 .received_requests()
3664 .await
3665 .expect("mock server recorded requests");
3666 assert_eq!(requests.len(), 1, "exactly one request should be sent");
3667 let body: serde_json::Value = requests[0].body_json().expect("request body is JSON");
3668
3669 assert!(body.get("models").is_none(), "body: {body}");
3670 assert!(body.get("route").is_none(), "body: {body}");
3671 assert!(body.get("provider").is_none(), "body: {body}");
3672 assert!(body.get("session_id").is_none(), "body: {body}");
3675 assert_eq!(body["metadata"]["session_id"], "session_abc123");
3676 }
3677
3678 #[tokio::test]
3684 async fn openresponses_stream_skips_done_sentinel() {
3685 use futures::StreamExt;
3686 use wiremock::matchers::method;
3687 use wiremock::{Mock, MockServer, ResponseTemplate};
3688
3689 let body =
3691 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\ndata: [DONE]\n\n";
3692 let server = MockServer::start().await;
3693 Mock::given(method("POST"))
3694 .respond_with(
3695 ResponseTemplate::new(200)
3696 .insert_header("content-type", "text/event-stream")
3697 .set_body_string(body),
3698 )
3699 .mount(&server)
3700 .await;
3701
3702 let api_url = format!("{}/v1/responses", server.uri());
3703 let driver = OpenResponsesProtocolChatDriver::with_base_url("test-key", api_url);
3704 let config = LlmCallConfig {
3705 speed: None,
3706 verbosity: None,
3707 model: "openai/gpt-4o-mini".to_string(),
3708 temperature: None,
3709 max_tokens: None,
3710 tools: vec![],
3711 reasoning_effort: None,
3712 metadata: std::collections::HashMap::new(),
3713 previous_response_id: None,
3714 provider_opaque_context: None,
3715 tool_search: None,
3716 prompt_cache: None,
3717 openrouter_routing: None,
3718 parallel_tool_calls: None,
3719 volatile_suffix_len: 0,
3720 };
3721
3722 let stream = driver
3723 .chat_completion_stream(vec![LlmMessage::text(LlmMessageRole::User, "hi")], &config)
3724 .await
3725 .expect("stream should start");
3726 let events: Vec<_> = stream.collect().await;
3727
3728 let mut text = String::new();
3729 for ev in &events {
3730 match ev.as_ref().expect("no transport error") {
3731 LlmStreamEvent::TextDelta(d) => text.push_str(d),
3732 LlmStreamEvent::Error(e) => {
3733 panic!("[DONE] sentinel must not surface as an error: {e}")
3734 }
3735 _ => {}
3736 }
3737 }
3738 assert_eq!(text, "hi");
3739 }
3740
3741 #[test]
3746 fn test_compact_request_serialization() {
3747 let request = CompactRequest {
3748 model: "gpt-4o".to_string(),
3749 input: vec![
3750 CompactInputItem::Message {
3751 role: "user".to_string(),
3752 content: CompactContent::Text("Hello!".to_string()),
3753 },
3754 CompactInputItem::Message {
3755 role: "assistant".to_string(),
3756 content: CompactContent::Text("Hi there!".to_string()),
3757 },
3758 ],
3759 previous_response_id: None,
3760 instructions: Some("Be helpful".to_string()),
3761 };
3762
3763 let json = serde_json::to_value(&request).unwrap();
3764 assert_eq!(json["model"], "gpt-4o");
3765 assert_eq!(json["instructions"], "Be helpful");
3766 assert!(json["input"].is_array());
3767 assert_eq!(json["input"].as_array().unwrap().len(), 2);
3768 }
3769
3770 #[test]
3771 fn test_compact_input_item_message_serialization() {
3772 let item = CompactInputItem::Message {
3773 role: "user".to_string(),
3774 content: CompactContent::Text("Test message".to_string()),
3775 };
3776
3777 let json = serde_json::to_value(&item).unwrap();
3778 assert_eq!(json["type"], "message");
3779 assert_eq!(json["role"], "user");
3780 assert_eq!(json["content"], "Test message");
3781 }
3782
3783 #[test]
3784 fn test_compact_input_item_function_call_serialization() {
3785 let item = CompactInputItem::FunctionCall {
3786 call_id: "call_123".to_string(),
3787 name: "get_weather".to_string(),
3788 arguments: r#"{"city":"NYC"}"#.to_string(),
3789 };
3790
3791 let json = serde_json::to_value(&item).unwrap();
3792 assert_eq!(json["type"], "function_call");
3793 assert_eq!(json["call_id"], "call_123");
3794 assert_eq!(json["name"], "get_weather");
3795 assert_eq!(json["arguments"], r#"{"city":"NYC"}"#);
3796 }
3797
3798 #[test]
3799 fn test_compact_input_item_compaction_serialization() {
3800 let item = CompactInputItem::Compaction {
3801 encrypted_content: "encrypted_data_here".to_string(),
3802 };
3803
3804 let json = serde_json::to_value(&item).unwrap();
3805 assert_eq!(json["type"], "compaction");
3806 assert_eq!(json["encrypted_content"], "encrypted_data_here");
3807 }
3808
3809 #[test]
3810 fn test_compact_output_item_deserialization() {
3811 let json = r#"{
3812 "type": "message",
3813 "role": "user",
3814 "content": "Hello"
3815 }"#;
3816
3817 let item: CompactOutputItem = serde_json::from_str(json).unwrap();
3818 match item {
3819 CompactOutputItem::Message { role, content } => {
3820 assert_eq!(role, "user");
3821 match content {
3822 CompactContent::Text(text) => assert_eq!(text, "Hello"),
3823 _ => panic!("Expected text content"),
3824 }
3825 }
3826 _ => panic!("Expected Message item"),
3827 }
3828 }
3829
3830 #[test]
3831 fn test_compact_output_compaction_deserialization() {
3832 let json = r#"{
3833 "type": "compaction",
3834 "encrypted_content": "abc123encrypted"
3835 }"#;
3836
3837 let item: CompactOutputItem = serde_json::from_str(json).unwrap();
3838 match item {
3839 CompactOutputItem::Compaction { encrypted_content } => {
3840 assert_eq!(encrypted_content, "abc123encrypted");
3841 }
3842 _ => panic!("Expected Compaction item"),
3843 }
3844 }
3845
3846 #[test]
3847 fn test_compact_response_deserialization() {
3848 let json = r#"{
3849 "output": [
3850 {"type": "message", "role": "user", "content": "Hello"},
3851 {"type": "compaction", "encrypted_content": "xyz789"}
3852 ],
3853 "usage": {
3854 "input_tokens": 100,
3855 "output_tokens": 50,
3856 "total_tokens": 150
3857 }
3858 }"#;
3859
3860 let response: CompactResponse = serde_json::from_str(json).unwrap();
3861 assert_eq!(response.output.len(), 2);
3862 assert!(response.usage.is_some());
3863 let usage = response.usage.unwrap();
3864 assert_eq!(usage.input_tokens, Some(100));
3865 assert_eq!(usage.output_tokens, Some(50));
3866 assert_eq!(usage.total_tokens, Some(150));
3867 }
3868
3869 #[test]
3870 fn test_compact_content_parts_serialization() {
3871 let content = CompactContent::Parts(vec![
3872 CompactContentPart::InputText {
3873 text: "Check this image".to_string(),
3874 },
3875 CompactContentPart::InputImage {
3876 image_url: "data:image/png;base64,abc".to_string(),
3877 },
3878 ]);
3879
3880 let json = serde_json::to_value(&content).unwrap();
3881 assert!(json.is_array());
3882 assert_eq!(json[0]["type"], "input_text");
3883 assert_eq!(json[0]["text"], "Check this image");
3884 assert_eq!(json[1]["type"], "input_image");
3885 }
3886
3887 #[test]
3888 fn test_supports_compact_default_url() {
3889 let driver = OpenResponsesProtocolChatDriver::new("test-key");
3890 assert!(driver.supports_compact());
3892 }
3893
3894 #[test]
3895 fn test_supports_compact_custom_url() {
3896 let driver = OpenResponsesProtocolChatDriver::with_base_url(
3897 "test-key",
3898 "https://custom.api.com/v1/responses",
3899 );
3900 assert!(!driver.supports_compact());
3902 }
3903
3904 #[test]
3909 fn test_reasoning_input_item_serialization() {
3910 let item = ResponsesInputItem::Reasoning {
3911 r#type: "reasoning".to_string(),
3912 id: "rs_00000001".to_string(),
3913 encrypted_content: "encrypted_reasoning_context_here".to_string(),
3914 };
3915
3916 let json = serde_json::to_value(&item).unwrap();
3917 assert_eq!(json["type"], "reasoning");
3918 assert_eq!(json["id"], "rs_00000001");
3919 assert_eq!(
3920 json["encrypted_content"],
3921 "encrypted_reasoning_context_here"
3922 );
3923 }
3924
3925 #[test]
3926 fn test_build_input_with_thinking_signature() {
3927 let messages = vec![
3929 LlmMessage::text(LlmMessageRole::User, "Think about this deeply"),
3930 LlmMessage {
3931 role: LlmMessageRole::Assistant,
3932 content: LlmMessageContent::Text("I have thought about this.".to_string()),
3933 tool_calls: None,
3934 tool_call_id: None,
3935 phase: None,
3936 thinking: Some("This is my chain of thought reasoning...".to_string()),
3937 thinking_signature: Some("encrypted_reasoning_token_123".to_string()),
3938 },
3939 LlmMessage::text(LlmMessageRole::User, "What else?"),
3940 ];
3941
3942 let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
3943
3944 assert_eq!(input.len(), 4);
3946
3947 let json = serde_json::to_value(&input[0]).unwrap();
3949 assert_eq!(json["role"], "user");
3950 assert_eq!(json["content"], "Think about this deeply");
3951
3952 let json = serde_json::to_value(&input[1]).unwrap();
3954 assert_eq!(json["type"], "reasoning");
3955 assert_eq!(json["encrypted_content"], "encrypted_reasoning_token_123");
3956
3957 let json = serde_json::to_value(&input[2]).unwrap();
3959 assert_eq!(json["role"], "assistant");
3960 assert_eq!(json["content"], "I have thought about this.");
3961
3962 let json = serde_json::to_value(&input[3]).unwrap();
3964 assert_eq!(json["role"], "user");
3965 }
3966
3967 #[test]
3968 fn test_build_input_with_thinking_signature_and_tool_calls() {
3969 use crate::tool_types::ToolCall;
3970
3971 let messages = vec![
3973 LlmMessage::text(LlmMessageRole::User, "What time is it? Think carefully."),
3974 LlmMessage {
3975 role: LlmMessageRole::Assistant,
3976 content: LlmMessageContent::Text("Let me check.".to_string()),
3977 tool_calls: Some(vec![ToolCall {
3978 id: "call_123".to_string(),
3979 name: "get_time".to_string(),
3980 arguments: json!({}),
3981 }]),
3982 tool_call_id: None,
3983 phase: None,
3984 thinking: Some("I need to call the get_time tool...".to_string()),
3985 thinking_signature: Some("encrypted_token_xyz".to_string()),
3986 },
3987 LlmMessage {
3988 role: LlmMessageRole::Tool,
3989 content: LlmMessageContent::Text("10:30 AM".to_string()),
3990 tool_calls: None,
3991 tool_call_id: Some("call_123".to_string()),
3992 phase: None,
3993 thinking: None,
3994 thinking_signature: None,
3995 },
3996 ];
3997
3998 let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
3999
4000 assert_eq!(input.len(), 5);
4002
4003 let json = serde_json::to_value(&input[1]).unwrap();
4005 assert_eq!(json["type"], "reasoning");
4006 assert_eq!(json["encrypted_content"], "encrypted_token_xyz");
4007
4008 let json = serde_json::to_value(&input[2]).unwrap();
4010 assert_eq!(json["role"], "assistant");
4011
4012 let json = serde_json::to_value(&input[3]).unwrap();
4014 assert_eq!(json["type"], "function_call");
4015 assert_eq!(json["call_id"], "call_123");
4016
4017 let json = serde_json::to_value(&input[4]).unwrap();
4019 assert_eq!(json["type"], "function_call_output");
4020 }
4021
4022 #[test]
4023 fn test_build_input_without_thinking_signature() {
4024 let messages = vec![
4026 LlmMessage::text(LlmMessageRole::User, "Hello"),
4027 LlmMessage {
4028 role: LlmMessageRole::Assistant,
4029 content: LlmMessageContent::Text("Hi there!".to_string()),
4030 tool_calls: None,
4031 tool_call_id: None,
4032 phase: None,
4033 thinking: Some("Some thinking...".to_string()),
4034 thinking_signature: None, },
4036 ];
4037
4038 let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
4039
4040 assert_eq!(input.len(), 2);
4042
4043 let json = serde_json::to_value(&input[0]).unwrap();
4045 assert_eq!(json["role"], "user");
4046
4047 let json = serde_json::to_value(&input[1]).unwrap();
4048 assert_eq!(json["role"], "assistant");
4049 }
4050
4051 #[test]
4052 fn test_handle_streaming_event_reasoning_encrypted_content() {
4053 use std::sync::Mutex;
4054
4055 let input_tokens = Mutex::new(0u32);
4056 let output_tokens = Mutex::new(0u32);
4057 let cache_read_tokens = Mutex::new(None);
4058 let accumulated_tool_calls = Mutex::new(Vec::new());
4059 let finish_reason = Mutex::new(None);
4060
4061 let event = StreamingEvent::OutputItemDone {
4063 sequence_number: 5,
4064 output_index: 0,
4065 item: Some(types::OutputItem::Reasoning {
4066 id: "rs_001".to_string(),
4067 summary: vec![],
4068 content: None,
4069 encrypted_content: Some("encrypted_reasoning_data".to_string()),
4070 }),
4071 };
4072
4073 let result = handle_streaming_event(
4074 event,
4075 &input_tokens,
4076 &output_tokens,
4077 &cache_read_tokens,
4078 &accumulated_tool_calls,
4079 &finish_reason,
4080 "gpt-5".to_string(),
4081 None,
4082 );
4083
4084 match result {
4086 LlmStreamEvent::ReasonItem {
4087 provider,
4088 model,
4089 item_id,
4090 encrypted_content,
4091 summary,
4092 token_count,
4093 } => {
4094 assert_eq!(provider, "openai");
4095 assert_eq!(model.as_deref(), Some("gpt-5"));
4096 assert_eq!(item_id, "rs_001");
4097 assert_eq!(
4098 encrypted_content.as_deref(),
4099 Some("encrypted_reasoning_data")
4100 );
4101 assert!(summary.is_empty());
4102 assert!(token_count.is_none());
4103 }
4104 other => panic!("Expected ReasonItem event, got {:?}", other),
4105 }
4106 }
4107
4108 #[test]
4109 fn output_item_added_message_surfaces_native_phase_hint() {
4110 use std::sync::Mutex;
4111
4112 for (wire, expected) in [
4116 (
4117 "commentary",
4118 crate::execution_phase::ExecutionPhase::Commentary,
4119 ),
4120 (
4121 "final_answer",
4122 crate::execution_phase::ExecutionPhase::FinalAnswer,
4123 ),
4124 ] {
4125 let event: StreamingEvent = serde_json::from_value(serde_json::json!({
4126 "type": "response.output_item.added",
4127 "sequence_number": 1,
4128 "output_index": 0,
4129 "item": {
4130 "type": "message",
4131 "id": "msg_001",
4132 "status": "in_progress",
4133 "role": "assistant",
4134 "content": [],
4135 "phase": wire,
4136 }
4137 }))
4138 .expect("output_item.added should deserialize");
4139
4140 let result = handle_streaming_event(
4141 event,
4142 &Mutex::new(0),
4143 &Mutex::new(0),
4144 &Mutex::new(None),
4145 &Mutex::new(Vec::new()),
4146 &Mutex::new(None),
4147 "gpt-5".to_string(),
4148 None,
4149 );
4150
4151 match result {
4152 LlmStreamEvent::MessagePhase(phase) => assert_eq!(phase, expected),
4153 other => panic!("Expected MessagePhase({expected:?}), got {other:?}"),
4154 }
4155 }
4156 }
4157
4158 #[test]
4159 fn output_item_added_message_without_phase_is_noop() {
4160 use std::sync::Mutex;
4161
4162 let event: StreamingEvent = serde_json::from_value(serde_json::json!({
4165 "type": "response.output_item.added",
4166 "sequence_number": 1,
4167 "output_index": 0,
4168 "item": {
4169 "type": "message",
4170 "id": "msg_002",
4171 "status": "in_progress",
4172 "role": "assistant",
4173 "content": [],
4174 }
4175 }))
4176 .expect("output_item.added should deserialize");
4177
4178 let result = handle_streaming_event(
4179 event,
4180 &Mutex::new(0),
4181 &Mutex::new(0),
4182 &Mutex::new(None),
4183 &Mutex::new(Vec::new()),
4184 &Mutex::new(None),
4185 "gpt-5".to_string(),
4186 None,
4187 );
4188
4189 match result {
4190 LlmStreamEvent::TextDelta(d) => assert!(d.is_empty()),
4191 other => panic!("Expected empty TextDelta, got {other:?}"),
4192 }
4193 }
4194
4195 #[test]
4196 fn response_failed_preserves_provider_error_code() {
4197 use std::sync::Mutex;
4198
4199 let event: StreamingEvent = serde_json::from_value(serde_json::json!({
4200 "type": "response.failed",
4201 "sequence_number": 7,
4202 "response": {
4203 "id": "resp_failed",
4204 "object": "response",
4205 "created_at": 1,
4206 "status": "failed",
4207 "model": "gpt-5",
4208 "output": [],
4209 "tools": [],
4210 "error": {
4211 "code": "processing_error",
4212 "message": "An error occurred while processing your request."
4213 }
4214 }
4215 }))
4216 .expect("response.failed should deserialize");
4217
4218 let result = handle_streaming_event(
4219 event,
4220 &Mutex::new(0),
4221 &Mutex::new(0),
4222 &Mutex::new(None),
4223 &Mutex::new(Vec::new()),
4224 &Mutex::new(None),
4225 "gpt-5".to_string(),
4226 None,
4227 );
4228
4229 let LlmStreamEvent::Error(error) = result else {
4230 panic!("expected structured stream error");
4231 };
4232 assert_eq!(error.code.as_deref(), Some("processing_error"));
4233 assert!(crate::llm_retry::is_transient_stream_error(&error));
4234 }
4235
4236 #[test]
4237 fn test_handle_streaming_event_reasoning_without_encrypted_content() {
4238 use std::sync::Mutex;
4239
4240 let input_tokens = Mutex::new(0u32);
4241 let output_tokens = Mutex::new(0u32);
4242 let cache_read_tokens = Mutex::new(None);
4243 let accumulated_tool_calls = Mutex::new(Vec::new());
4244 let finish_reason = Mutex::new(None);
4245
4246 let event = StreamingEvent::OutputItemDone {
4248 sequence_number: 5,
4249 output_index: 0,
4250 item: Some(types::OutputItem::Reasoning {
4251 id: "rs_001".to_string(),
4252 summary: vec![types::ContentPart::SummaryText {
4253 text: "Some summary".to_string(),
4254 }],
4255 content: None,
4256 encrypted_content: None, }),
4258 };
4259
4260 let result = handle_streaming_event(
4261 event,
4262 &input_tokens,
4263 &output_tokens,
4264 &cache_read_tokens,
4265 &accumulated_tool_calls,
4266 &finish_reason,
4267 "gpt-5".to_string(),
4268 None,
4269 );
4270
4271 match result {
4274 LlmStreamEvent::ReasonItem {
4275 provider,
4276 item_id,
4277 encrypted_content,
4278 summary,
4279 ..
4280 } => {
4281 assert_eq!(provider, "openai");
4282 assert_eq!(item_id, "rs_001");
4283 assert!(encrypted_content.is_none());
4284 assert_eq!(summary, vec!["Some summary".to_string()]);
4285 }
4286 other => panic!("Expected ReasonItem event, got {:?}", other),
4287 }
4288 }
4289
4290 #[test]
4291 fn test_handle_streaming_event_reasoning_drops_plaintext_content() {
4292 use std::sync::Mutex;
4293
4294 let input_tokens = Mutex::new(0u32);
4295 let output_tokens = Mutex::new(0u32);
4296 let cache_read_tokens = Mutex::new(None);
4297 let accumulated_tool_calls = Mutex::new(Vec::new());
4298 let finish_reason = Mutex::new(None);
4299
4300 let event = StreamingEvent::OutputItemDone {
4303 sequence_number: 5,
4304 output_index: 0,
4305 item: Some(types::OutputItem::Reasoning {
4306 id: "rs_002".to_string(),
4307 summary: vec![
4308 types::ContentPart::SummaryText {
4309 text: "safe summary".to_string(),
4310 },
4311 types::ContentPart::ReasoningText {
4312 text: "SECRET hidden reasoning".to_string(),
4313 },
4314 ],
4315 content: Some(vec![types::ContentPart::ReasoningText {
4316 text: "SECRET hidden reasoning".to_string(),
4317 }]),
4318 encrypted_content: Some("opaque".to_string()),
4319 }),
4320 };
4321
4322 let result = handle_streaming_event(
4323 event,
4324 &input_tokens,
4325 &output_tokens,
4326 &cache_read_tokens,
4327 &accumulated_tool_calls,
4328 &finish_reason,
4329 "gpt-5".to_string(),
4330 None,
4331 );
4332
4333 match result {
4334 LlmStreamEvent::ReasonItem {
4335 summary,
4336 encrypted_content,
4337 ..
4338 } => {
4339 assert_eq!(summary, vec!["safe summary".to_string()]);
4340 assert_eq!(encrypted_content.as_deref(), Some("opaque"));
4341 }
4342 other => panic!("Expected ReasonItem event, got {:?}", other),
4343 }
4344 }
4345
4346 #[test]
4347 fn test_handle_streaming_event_reasoning_delta() {
4348 use std::sync::Mutex;
4349
4350 let input_tokens = Mutex::new(0u32);
4351 let output_tokens = Mutex::new(0u32);
4352 let cache_read_tokens = Mutex::new(None);
4353 let accumulated_tool_calls = Mutex::new(Vec::new());
4354 let finish_reason = Mutex::new(None);
4355
4356 let event = StreamingEvent::ReasoningDelta {
4358 sequence_number: 3,
4359 item_id: "rs_001".to_string(),
4360 output_index: 0,
4361 content_index: 0,
4362 delta: "Let me reason about this...".to_string(),
4363 obfuscation: None,
4364 };
4365
4366 let result = handle_streaming_event(
4367 event,
4368 &input_tokens,
4369 &output_tokens,
4370 &cache_read_tokens,
4371 &accumulated_tool_calls,
4372 &finish_reason,
4373 "o3".to_string(),
4374 None,
4375 );
4376
4377 match result {
4378 LlmStreamEvent::ThinkingDelta(text) => {
4379 assert_eq!(text, "Let me reason about this...");
4380 }
4381 _ => panic!("Expected ThinkingDelta, got {:?}", result),
4382 }
4383 }
4384
4385 #[test]
4386 fn test_handle_streaming_event_reasoning_summary_delta() {
4387 use std::sync::Mutex;
4388
4389 let input_tokens = Mutex::new(0u32);
4390 let output_tokens = Mutex::new(0u32);
4391 let cache_read_tokens = Mutex::new(None);
4392 let accumulated_tool_calls = Mutex::new(Vec::new());
4393 let finish_reason = Mutex::new(None);
4394
4395 let event = StreamingEvent::ReasoningSummaryDelta {
4397 sequence_number: 4,
4398 item_id: "rs_002".to_string(),
4399 output_index: 0,
4400 summary_index: 0,
4401 delta: "Breaking down the problem...".to_string(),
4402 obfuscation: None,
4403 };
4404
4405 let result = handle_streaming_event(
4406 event,
4407 &input_tokens,
4408 &output_tokens,
4409 &cache_read_tokens,
4410 &accumulated_tool_calls,
4411 &finish_reason,
4412 "gpt-5.2".to_string(),
4413 None,
4414 );
4415
4416 match result {
4417 LlmStreamEvent::TextDelta(text) => {
4418 assert_eq!(text, "Breaking down the problem...");
4419 }
4420 _ => panic!("Expected TextDelta, got {:?}", result),
4421 }
4422 }
4423
4424 #[test]
4425 fn test_request_reasoning_none_is_omitted() {
4426 let config = LlmCallConfig {
4429 speed: None,
4430 verbosity: None,
4431 model: "gpt-5.2".to_string(),
4432 temperature: None,
4433 max_tokens: None,
4434 tools: vec![],
4435 reasoning_effort: Some("none".to_string()),
4436 metadata: std::collections::HashMap::new(),
4437 previous_response_id: None,
4438 provider_opaque_context: None,
4439 tool_search: None,
4440 prompt_cache: None,
4441 openrouter_routing: None,
4442 parallel_tool_calls: None,
4443 volatile_suffix_len: 0,
4444 };
4445
4446 let reasoning = config
4448 .reasoning_effort
4449 .as_ref()
4450 .filter(|e| !e.eq_ignore_ascii_case("none"))
4451 .map(|effort| ResponsesReasoning {
4452 effort: effort.clone(),
4453 summary: "detailed".to_string(),
4454 });
4455
4456 assert!(
4457 reasoning.is_none(),
4458 "reasoning should be None for effort=none"
4459 );
4460 }
4461
4462 #[test]
4463 fn test_request_reasoning_high_is_included() {
4464 let config = LlmCallConfig {
4466 speed: None,
4467 verbosity: None,
4468 model: "gpt-5.2".to_string(),
4469 temperature: None,
4470 max_tokens: None,
4471 tools: vec![],
4472 reasoning_effort: Some("high".to_string()),
4473 metadata: std::collections::HashMap::new(),
4474 previous_response_id: None,
4475 provider_opaque_context: None,
4476 tool_search: None,
4477 prompt_cache: None,
4478 openrouter_routing: None,
4479 parallel_tool_calls: None,
4480 volatile_suffix_len: 0,
4481 };
4482
4483 let reasoning = config
4484 .reasoning_effort
4485 .as_ref()
4486 .filter(|e| !e.eq_ignore_ascii_case("none"))
4487 .map(|effort| ResponsesReasoning {
4488 effort: effort.clone(),
4489 summary: "detailed".to_string(),
4490 });
4491
4492 assert!(
4493 reasoning.is_some(),
4494 "reasoning should be present for effort=high"
4495 );
4496 let r = reasoning.unwrap();
4497 assert_eq!(r.effort, "high");
4498 assert_eq!(r.summary, "detailed");
4499 }
4500
4501 #[test]
4502 fn test_request_reasoning_none_case_insensitive() {
4503 for effort in &["none", "None", "NONE"] {
4505 let reasoning = Some(effort.to_string())
4506 .as_ref()
4507 .filter(|e| !e.eq_ignore_ascii_case("none"))
4508 .cloned();
4509
4510 assert!(
4511 reasoning.is_none(),
4512 "effort={effort:?} should be filtered out"
4513 );
4514 }
4515 }
4516
4517 #[test]
4518 fn test_build_input_assistant_without_thinking_or_tools() {
4519 let messages = vec![
4521 LlmMessage::text(LlmMessageRole::User, "Hello"),
4522 LlmMessage {
4523 role: LlmMessageRole::Assistant,
4524 content: LlmMessageContent::Text("Hi there!".to_string()),
4525 tool_calls: None,
4526 tool_call_id: None,
4527 phase: None,
4528 thinking: None,
4529 thinking_signature: None,
4530 },
4531 ];
4532
4533 let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
4534
4535 assert_eq!(input.len(), 2);
4536 let json = serde_json::to_value(&input[1]).unwrap();
4537 assert_eq!(json["role"], "assistant");
4538 assert!(json.get("type").is_none() || json["type"] == "message");
4539 }
4540
4541 #[test]
4542 fn test_build_input_multiple_reasoning_items_get_unique_ids() {
4543 let messages = vec![
4545 LlmMessage::text(LlmMessageRole::User, "First question"),
4546 LlmMessage {
4547 role: LlmMessageRole::Assistant,
4548 content: LlmMessageContent::Text("First answer.".to_string()),
4549 tool_calls: None,
4550 tool_call_id: None,
4551 phase: None,
4552 thinking: Some("thinking 1".to_string()),
4553 thinking_signature: Some("encrypted_1".to_string()),
4554 },
4555 LlmMessage::text(LlmMessageRole::User, "Second question"),
4556 LlmMessage {
4557 role: LlmMessageRole::Assistant,
4558 content: LlmMessageContent::Text("Second answer.".to_string()),
4559 tool_calls: None,
4560 tool_call_id: None,
4561 phase: None,
4562 thinking: Some("thinking 2".to_string()),
4563 thinking_signature: Some("encrypted_2".to_string()),
4564 },
4565 ];
4566
4567 let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
4568
4569 assert_eq!(input.len(), 6);
4571
4572 let r1 = serde_json::to_value(&input[1]).unwrap();
4573 let r2 = serde_json::to_value(&input[4]).unwrap();
4574
4575 assert_eq!(r1["type"], "reasoning");
4576 assert_eq!(r2["type"], "reasoning");
4577 assert_ne!(r1["id"], r2["id"], "Reasoning items should have unique IDs");
4578 assert_eq!(r1["encrypted_content"], "encrypted_1");
4579 assert_eq!(r2["encrypted_content"], "encrypted_2");
4580 }
4581
4582 #[test]
4583 fn test_build_input_with_phases_enabled() {
4584 use crate::execution_phase::ExecutionPhase;
4585
4586 let messages = vec![
4587 LlmMessage::text(LlmMessageRole::System, "You are helpful"),
4588 LlmMessage::text(LlmMessageRole::User, "Hello"),
4589 LlmMessage {
4590 role: LlmMessageRole::Assistant,
4591 content: LlmMessageContent::Text("Working on it...".to_string()),
4592 tool_calls: Some(vec![crate::tool_types::ToolCall {
4593 id: "call_1".to_string(),
4594 name: "search".to_string(),
4595 arguments: json!({}),
4596 }]),
4597 tool_call_id: None,
4598 phase: Some(ExecutionPhase::Commentary),
4599 thinking: None,
4600 thinking_signature: None,
4601 },
4602 LlmMessage {
4603 role: LlmMessageRole::Tool,
4604 content: LlmMessageContent::Text("result".to_string()),
4605 tool_calls: None,
4606 tool_call_id: Some("call_1".to_string()),
4607 phase: None,
4608 thinking: None,
4609 thinking_signature: None,
4610 },
4611 ];
4612
4613 let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, true);
4615 let assistant_json = serde_json::to_value(&input[1]).unwrap();
4616 assert_eq!(assistant_json["phase"], "commentary");
4617
4618 let (_, input_no_phases) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
4620 let assistant_json_no = serde_json::to_value(&input_no_phases[1]).unwrap();
4621 assert!(assistant_json_no.get("phase").is_none() || assistant_json_no["phase"].is_null());
4622 }
4623
4624 fn make_tool(
4630 name: &str,
4631 category: Option<&str>,
4632 deferrable: crate::tool_types::DeferrablePolicy,
4633 ) -> ToolDefinition {
4634 ToolDefinition::Builtin(crate::tool_types::BuiltinTool {
4635 name: name.to_string(),
4636 display_name: None,
4637 description: format!("{} description", name),
4638 parameters: json!({"type": "object", "properties": {}}),
4639 policy: crate::tool_types::ToolPolicy::Auto,
4640 category: category.map(|s| s.to_string()),
4641 deferrable,
4642 hints: crate::tool_types::ToolHints::default(),
4643 full_parameters: None,
4644 })
4645 }
4646
4647 #[test]
4648 fn test_convert_tools_with_search_below_threshold_falls_back() {
4649 use crate::tool_types::DeferrablePolicy;
4650
4651 let tools: Vec<ToolDefinition> = (0..5)
4652 .map(|i| {
4653 make_tool(
4654 &format!("tool_{i}"),
4655 Some("cat"),
4656 DeferrablePolicy::Automatic,
4657 )
4658 })
4659 .collect();
4660
4661 let result = OpenResponsesProtocolChatDriver::convert_tools_with_search(&tools, 15);
4663 assert_eq!(result.len(), 5);
4664 let json = serde_json::to_value(&result).unwrap();
4666 for item in json.as_array().unwrap() {
4667 assert_eq!(item["type"], "function");
4668 assert!(item.get("defer_loading").is_none() || item["defer_loading"].is_null());
4669 }
4670 }
4671
4672 #[test]
4673 fn test_convert_tools_with_search_groups_by_category() {
4674 use crate::tool_types::DeferrablePolicy;
4675
4676 let mut tools = vec![];
4677 for i in 0..10 {
4679 tools.push(make_tool(
4680 &format!("fs_tool_{i}"),
4681 Some("FileSystem"),
4682 DeferrablePolicy::Automatic,
4683 ));
4684 }
4685 for i in 0..6 {
4686 tools.push(make_tool(
4687 &format!("weather_tool_{i}"),
4688 Some("Weather"),
4689 DeferrablePolicy::Automatic,
4690 ));
4691 }
4692
4693 let result = OpenResponsesProtocolChatDriver::convert_tools_with_search(&tools, 15);
4694 let json = serde_json::to_value(&result).unwrap();
4695 let arr = json.as_array().unwrap();
4696
4697 assert_eq!(arr.len(), 3);
4699
4700 assert_eq!(arr.last().unwrap()["type"], "tool_search");
4702
4703 let ns: Vec<&Value> = arr.iter().filter(|v| v["type"] == "namespace").collect();
4705 assert_eq!(ns.len(), 2);
4706
4707 let ns_names: Vec<&str> = ns.iter().map(|v| v["name"].as_str().unwrap()).collect();
4708 assert!(ns_names.contains(&"FileSystem"));
4709 assert!(ns_names.contains(&"Weather"));
4710
4711 for n in &ns {
4713 let inner_tools = n["tools"].as_array().unwrap();
4714 match n["name"].as_str().unwrap() {
4715 "FileSystem" => assert_eq!(inner_tools.len(), 10),
4716 "Weather" => assert_eq!(inner_tools.len(), 6),
4717 other => panic!("Unexpected namespace: {other}"),
4718 }
4719 for t in inner_tools {
4721 assert_eq!(t["defer_loading"], true);
4722 }
4723 }
4724 }
4725
4726 #[test]
4727 fn test_convert_tools_with_search_never_defer_stays_top_level() {
4728 use crate::tool_types::DeferrablePolicy;
4729
4730 let mut tools = vec![];
4731 tools.push(make_tool(
4733 "write_todos",
4734 Some("Productivity"),
4735 DeferrablePolicy::Never,
4736 ));
4737 tools.push(make_tool(
4738 "get_session_info",
4739 Some("Session"),
4740 DeferrablePolicy::Never,
4741 ));
4742 for i in 0..14 {
4744 tools.push(make_tool(
4745 &format!("fs_tool_{i}"),
4746 Some("FileSystem"),
4747 DeferrablePolicy::Automatic,
4748 ));
4749 }
4750
4751 let result = OpenResponsesProtocolChatDriver::convert_tools_with_search(&tools, 15);
4752 let json = serde_json::to_value(&result).unwrap();
4753 let arr = json.as_array().unwrap();
4754
4755 assert_eq!(arr.len(), 4);
4757
4758 let funcs: Vec<&Value> = arr.iter().filter(|v| v["type"] == "function").collect();
4760 assert_eq!(funcs.len(), 2);
4761 for f in &funcs {
4762 assert!(f.get("defer_loading").is_none() || f["defer_loading"].is_null());
4764 }
4765
4766 let ns: Vec<&Value> = arr.iter().filter(|v| v["type"] == "namespace").collect();
4768 assert_eq!(ns.len(), 1);
4769 assert_eq!(ns[0]["name"], "FileSystem");
4770 assert_eq!(ns[0]["tools"].as_array().unwrap().len(), 14);
4771 }
4772
4773 #[test]
4774 fn test_convert_tools_with_search_ungrouped_tools() {
4775 use crate::tool_types::DeferrablePolicy;
4776
4777 let mut tools = vec![];
4778 for i in 0..10 {
4780 tools.push(make_tool(
4781 &format!("cat_tool_{i}"),
4782 Some("Cat"),
4783 DeferrablePolicy::Automatic,
4784 ));
4785 }
4786 for i in 0..6 {
4788 tools.push(make_tool(
4789 &format!("misc_tool_{i}"),
4790 None,
4791 DeferrablePolicy::Automatic,
4792 ));
4793 }
4794
4795 let result = OpenResponsesProtocolChatDriver::convert_tools_with_search(&tools, 15);
4796 let json = serde_json::to_value(&result).unwrap();
4797 let arr = json.as_array().unwrap();
4798
4799 assert_eq!(arr.len(), 8);
4801
4802 let ns: Vec<&Value> = arr.iter().filter(|v| v["type"] == "namespace").collect();
4803 assert_eq!(ns.len(), 1);
4804 assert_eq!(ns[0]["tools"].as_array().unwrap().len(), 10);
4805
4806 let funcs: Vec<&Value> = arr.iter().filter(|v| v["type"] == "function").collect();
4807 assert_eq!(funcs.len(), 6);
4808 for f in &funcs {
4810 assert_eq!(f["defer_loading"], true);
4811 }
4812
4813 assert_eq!(arr.last().unwrap()["type"], "tool_search");
4814 }
4815
4816 #[test]
4817 fn test_convert_tools_with_search_always_policy() {
4818 use crate::tool_types::DeferrablePolicy;
4819
4820 let mut tools = vec![];
4821 for i in 0..14 {
4823 tools.push(make_tool(
4824 &format!("tool_{i}"),
4825 Some("General"),
4826 DeferrablePolicy::Automatic,
4827 ));
4828 }
4829 tools.push(make_tool(
4831 "always_tool",
4832 Some("General"),
4833 DeferrablePolicy::Always,
4834 ));
4835
4836 let result = OpenResponsesProtocolChatDriver::convert_tools_with_search(&tools, 15);
4838 let json = serde_json::to_value(&result).unwrap();
4839 let arr = json.as_array().unwrap();
4840
4841 assert_eq!(arr.len(), 2);
4843
4844 let ns = &arr[0];
4845 assert_eq!(ns["type"], "namespace");
4846 let inner = ns["tools"].as_array().unwrap();
4847 assert_eq!(inner.len(), 15);
4848 for t in inner {
4850 assert_eq!(t["defer_loading"], true);
4851 }
4852 }
4853
4854 #[test]
4855 fn test_tool_search_serialization_format() {
4856 let ts = ResponsesTool::ToolSearch {
4858 r#type: "tool_search".to_string(),
4859 };
4860 let json = serde_json::to_value(&ts).unwrap();
4861 assert_eq!(json, json!({"type": "tool_search"}));
4862 }
4863
4864 #[test]
4865 fn test_namespace_serialization_format() {
4866 let ns = ResponsesTool::Namespace {
4867 r#type: "namespace".to_string(),
4868 name: "FileSystem".to_string(),
4869 description: "Tools for FileSystem".to_string(),
4870 tools: vec![ResponsesTool::Function {
4871 r#type: "function".to_string(),
4872 name: "read_file".to_string(),
4873 description: "Read a file".to_string(),
4874 parameters: json!({}),
4875 defer_loading: Some(true),
4876 }],
4877 };
4878 let json = serde_json::to_value(&ns).unwrap();
4879 assert_eq!(json["type"], "namespace");
4880 assert_eq!(json["name"], "FileSystem");
4881 assert_eq!(json["tools"][0]["name"], "read_file");
4882 assert_eq!(json["tools"][0]["defer_loading"], true);
4883 }
4884
4885 #[test]
4886 fn test_hosted_tool_search_completed_event_preserves_response_id() {
4887 let event_json = r#"{
4888 "type": "response.completed",
4889 "sequence_number": 8,
4890 "response": {
4891 "id": "resp_tool_search",
4892 "object": "response",
4893 "created_at": 1780000000,
4894 "status": "completed",
4895 "model": "gpt-5.5",
4896 "output": [
4897 {
4898 "type": "tool_search_call",
4899 "execution": "server",
4900 "call_id": null,
4901 "status": "completed",
4902 "arguments": { "paths": ["Math"] }
4903 },
4904 {
4905 "type": "tool_search_output",
4906 "execution": "server",
4907 "call_id": null,
4908 "status": "completed",
4909 "tools": [
4910 {
4911 "type": "namespace",
4912 "name": "Math",
4913 "description": "Tools for Math",
4914 "tools": [
4915 {
4916 "type": "function",
4917 "name": "add",
4918 "description": "Add numbers.",
4919 "defer_loading": true,
4920 "parameters": {
4921 "type": "object",
4922 "properties": {
4923 "a": { "type": "number" },
4924 "b": { "type": "number" }
4925 },
4926 "required": ["a", "b"],
4927 "additionalProperties": false
4928 }
4929 }
4930 ]
4931 }
4932 ]
4933 },
4934 {
4935 "type": "function_call",
4936 "id": "fc_123",
4937 "call_id": "call_123",
4938 "name": "add",
4939 "namespace": "Math",
4940 "arguments": "{\"a\":7,\"b\":3}",
4941 "status": "completed"
4942 }
4943 ],
4944 "usage": {
4945 "input_tokens": 10,
4946 "output_tokens": 5,
4947 "total_tokens": 15
4948 }
4949 }
4950 }"#;
4951
4952 let event: StreamingEvent = serde_json::from_str(event_json).unwrap();
4953 let stream_event = handle_streaming_event(
4954 event,
4955 &Mutex::new(0),
4956 &Mutex::new(0),
4957 &Mutex::new(None),
4958 &Mutex::new(Vec::new()),
4959 &Mutex::new(Some("tool_calls".to_string())),
4960 "gpt-5.5".to_string(),
4961 None,
4962 );
4963
4964 match stream_event {
4965 LlmStreamEvent::Done(metadata) => {
4966 assert_eq!(metadata.response_id.as_deref(), Some("resp_tool_search"));
4967 assert_eq!(metadata.finish_reason.as_deref(), Some("tool_calls"));
4968 }
4969 other => panic!("expected Done event, got {other:?}"),
4970 }
4971 }
4972
4973 #[test]
4974 fn test_completed_event_normalizes_cache_inclusive_prompt_tokens() {
4975 let event_json = r#"{
4979 "type": "response.completed",
4980 "sequence_number": 9,
4981 "response": {
4982 "id": "resp_cache",
4983 "object": "response",
4984 "created_at": 1780000000,
4985 "status": "completed",
4986 "model": "gpt-5.5",
4987 "output": [],
4988 "usage": {
4989 "input_tokens": 1000,
4990 "output_tokens": 20,
4991 "total_tokens": 1020,
4992 "input_tokens_details": { "cached_tokens": 800 }
4993 }
4994 }
4995 }"#;
4996
4997 let event: StreamingEvent = serde_json::from_str(event_json).unwrap();
4998 let stream_event = handle_streaming_event(
4999 event,
5000 &Mutex::new(0),
5001 &Mutex::new(0),
5002 &Mutex::new(None),
5003 &Mutex::new(Vec::new()),
5004 &Mutex::new(None),
5005 "gpt-5.5".to_string(),
5006 None,
5007 );
5008
5009 match stream_event {
5010 LlmStreamEvent::Done(metadata) => {
5011 assert_eq!(metadata.prompt_tokens, Some(200));
5013 assert_eq!(metadata.cache_read_tokens, Some(800));
5014 assert_eq!(metadata.total_tokens, Some(1020));
5016 }
5017 other => panic!("expected Done event, got {other:?}"),
5018 }
5019 }
5020
5021 #[test]
5022 fn test_incomplete_event_maps_output_limit_to_length() {
5023 let event_json = r#"{
5024 "type": "response.incomplete",
5025 "sequence_number": 10,
5026 "response": {
5027 "id": "resp_incomplete",
5028 "object": "response",
5029 "created_at": 1780000000,
5030 "status": "incomplete",
5031 "incomplete_details": { "reason": "max_output_tokens" },
5032 "model": "gpt-5.5",
5033 "output": [],
5034 "usage": {
5035 "input_tokens": 10,
5036 "output_tokens": 5,
5037 "total_tokens": 15
5038 }
5039 }
5040 }"#;
5041
5042 let event: StreamingEvent = serde_json::from_str(event_json).unwrap();
5043 let stream_event = handle_streaming_event(
5044 event,
5045 &Mutex::new(0),
5046 &Mutex::new(0),
5047 &Mutex::new(None),
5048 &Mutex::new(Vec::new()),
5049 &Mutex::new(None),
5050 "gpt-5.5".to_string(),
5051 None,
5052 );
5053
5054 match stream_event {
5055 LlmStreamEvent::Done(metadata) => {
5056 assert_eq!(metadata.finish_reason.as_deref(), Some("length"));
5057 }
5058 other => panic!("expected Done event, got {other:?}"),
5059 }
5060 }
5061
5062 #[test]
5063 fn test_sanitize_parameters_adds_missing_properties() {
5064 let params = json!({"type": "object", "additionalProperties": false});
5065 let sanitized = OpenResponsesProtocolChatDriver::sanitize_parameters(¶ms);
5066 assert_eq!(
5067 sanitized,
5068 json!({"type": "object", "properties": {}, "additionalProperties": false})
5069 );
5070 }
5071
5072 #[test]
5073 fn test_sanitize_parameters_preserves_existing_properties() {
5074 let params = json!({"type": "object", "properties": {"x": {"type": "string"}}, "additionalProperties": false});
5075 let sanitized = OpenResponsesProtocolChatDriver::sanitize_parameters(¶ms);
5076 assert_eq!(sanitized, params);
5077 }
5078
5079 #[test]
5080 fn test_sanitize_parameters_ignores_non_object_types() {
5081 let params = json!({"type": "string"});
5082 let sanitized = OpenResponsesProtocolChatDriver::sanitize_parameters(¶ms);
5083 assert_eq!(sanitized, params);
5084 }
5085
5086 fn auth_test_config() -> LlmCallConfig {
5092 LlmCallConfig {
5093 speed: None,
5094 verbosity: None,
5095 model: "gpt-5.4".to_string(),
5096 temperature: None,
5097 max_tokens: None,
5098 tools: vec![],
5099 reasoning_effort: None,
5100 metadata: std::collections::HashMap::new(),
5101 previous_response_id: None,
5102 provider_opaque_context: None,
5103 tool_search: None,
5104 prompt_cache: None,
5105 openrouter_routing: None,
5106 parallel_tool_calls: None,
5107 volatile_suffix_len: 0,
5108 }
5109 }
5110
5111 struct CountingAuth {
5114 header: (String, String),
5115 calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
5116 }
5117
5118 #[async_trait::async_trait]
5119 impl AuthHeaderProvider for CountingAuth {
5120 async fn auth_header(&self) -> Result<(String, String)> {
5121 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
5122 Ok(self.header.clone())
5123 }
5124 }
5125
5126 struct HeaderInjectingExtension;
5129
5130 impl OpenResponsesRequestExtension for HeaderInjectingExtension {
5131 fn decorate(&self, _body: &mut Value, _config: &LlmCallConfig) -> Result<()> {
5132 Ok(())
5133 }
5134
5135 fn decorate_headers(&self, headers: &mut HeaderMap, _config: &LlmCallConfig) -> Result<()> {
5136 headers.insert("x-openrouter-route", HeaderValue::from_static("fallback"));
5137 headers.insert(
5139 "authorization",
5140 HeaderValue::from_static("Bearer decoration"),
5141 );
5142 Ok(())
5143 }
5144 }
5145
5146 #[tokio::test]
5147 async fn resolve_auth_header_defaults_to_bearer_on_non_azure() {
5148 let driver = OpenResponsesProtocolChatDriver::new("secret-key");
5149 let (name, value) = driver
5150 .resolve_auth_header("https://api.openai.com/v1/responses")
5151 .await
5152 .expect("auth resolves");
5153 assert_eq!(name.as_str(), "authorization");
5154 assert_eq!(value.to_str().unwrap(), "Bearer secret-key");
5155 }
5156
5157 #[tokio::test]
5158 async fn resolve_auth_header_uses_api_key_header_on_azure() {
5159 let driver = OpenResponsesProtocolChatDriver::new("secret-key");
5160 let (name, value) = driver
5161 .resolve_auth_header("https://my-resource.openai.azure.com/openai/v1/responses")
5162 .await
5163 .expect("auth resolves");
5164 assert_eq!(name.as_str(), "api-key");
5165 assert_eq!(value.to_str().unwrap(), "secret-key");
5166 }
5167
5168 #[tokio::test]
5169 async fn resolve_auth_header_prefers_provider_over_static_key() {
5170 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
5171 let driver = OpenResponsesProtocolChatDriver::new("ignored-key").with_auth_provider(
5172 std::sync::Arc::new(CountingAuth {
5173 header: (
5174 "Authorization".to_string(),
5175 "Bearer minted-token".to_string(),
5176 ),
5177 calls: calls.clone(),
5178 }),
5179 );
5180 let (name, value) = driver
5182 .resolve_auth_header("https://my-resource.openai.azure.com/openai/v1/responses")
5183 .await
5184 .expect("auth resolves");
5185 assert_eq!(name.as_str(), "authorization");
5186 assert_eq!(value.to_str().unwrap(), "Bearer minted-token");
5187 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
5188 }
5189
5190 #[tokio::test]
5191 async fn default_static_auth_applied_on_the_wire() {
5192 use wiremock::matchers::{header, method};
5193 use wiremock::{Mock, MockServer, ResponseTemplate};
5194
5195 let server = MockServer::start().await;
5196 Mock::given(method("POST"))
5197 .and(header("authorization", "Bearer wire-key"))
5198 .respond_with(ResponseTemplate::new(200).set_body_string(""))
5199 .mount(&server)
5200 .await;
5201
5202 let api_url = format!("{}/v1/responses", server.uri());
5203 let driver = OpenResponsesProtocolChatDriver::with_base_url("wire-key", api_url);
5204 let messages = vec![LlmMessage::text(LlmMessageRole::User, "hi")];
5205 let _ = driver
5206 .chat_completion_stream(messages, &auth_test_config())
5207 .await;
5208
5209 let requests = server.received_requests().await.unwrap();
5210 assert_eq!(
5211 requests.len(),
5212 1,
5213 "default static key must authenticate the request"
5214 );
5215 }
5216
5217 #[tokio::test]
5218 async fn auth_provider_header_wins_over_extension_header() {
5219 use wiremock::matchers::{header, method};
5220 use wiremock::{Mock, MockServer, ResponseTemplate};
5221
5222 let server = MockServer::start().await;
5223 Mock::given(method("POST"))
5226 .and(header("authorization", "Bearer minted-token"))
5227 .and(header("x-openrouter-route", "fallback"))
5228 .respond_with(ResponseTemplate::new(200).set_body_string(""))
5229 .mount(&server)
5230 .await;
5231
5232 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
5233 let api_url = format!("{}/v1/responses", server.uri());
5234 let driver = OpenResponsesProtocolChatDriver::with_base_url("ignored", api_url)
5235 .with_request_extension(std::sync::Arc::new(HeaderInjectingExtension))
5236 .with_auth_provider(std::sync::Arc::new(CountingAuth {
5237 header: (
5238 "Authorization".to_string(),
5239 "Bearer minted-token".to_string(),
5240 ),
5241 calls: calls.clone(),
5242 }));
5243
5244 let messages = vec![LlmMessage::text(LlmMessageRole::User, "hi")];
5245 let _ = driver
5246 .chat_completion_stream(messages, &auth_test_config())
5247 .await;
5248
5249 let requests = server.received_requests().await.unwrap();
5250 assert_eq!(
5251 requests.len(),
5252 1,
5253 "auth header must win over a conflicting decoration header"
5254 );
5255 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
5256 }
5257
5258 #[tokio::test]
5259 async fn auth_provider_awaited_on_each_retry_attempt() {
5260 use wiremock::matchers::method;
5261 use wiremock::{Mock, MockServer, ResponseTemplate};
5262
5263 let server = MockServer::start().await;
5264 Mock::given(method("POST"))
5267 .respond_with(ResponseTemplate::new(503).set_body_string("overloaded"))
5268 .mount(&server)
5269 .await;
5270
5271 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
5272 let api_url = format!("{}/v1/responses", server.uri());
5273 let fast_retry = LlmRetryConfig {
5274 max_retries: 1,
5275 initial_backoff: std::time::Duration::from_millis(1),
5276 max_backoff: std::time::Duration::from_millis(1),
5277 backoff_multiplier: 1.0,
5278 jitter_factor: 0.0,
5279 };
5280 let driver = OpenResponsesProtocolChatDriver::with_base_url("ignored", api_url)
5281 .with_retry_config(fast_retry)
5282 .with_auth_provider(std::sync::Arc::new(CountingAuth {
5283 header: (
5284 "Authorization".to_string(),
5285 "Bearer minted-token".to_string(),
5286 ),
5287 calls: calls.clone(),
5288 }));
5289
5290 let messages = vec![LlmMessage::text(LlmMessageRole::User, "hi")];
5291 let _ = driver
5292 .chat_completion_stream(messages, &auth_test_config())
5293 .await;
5294
5295 assert_eq!(
5297 calls.load(std::sync::atomic::Ordering::SeqCst),
5298 2,
5299 "refreshable auth must be resolved per HTTP attempt, including retries"
5300 );
5301 }
5302}