1use async_trait::async_trait;
23use futures::StreamExt;
24use reqwest::{Client, header::HeaderMap};
25use serde::{Deserialize, Serialize};
26use serde_json::{Value, json};
27use sha2::{Digest, Sha256};
28use std::collections::HashSet;
29use std::sync::{Arc, Mutex};
30
31use crate::driver_registry::{
32 ChatDriver, LlmCallConfig, LlmCompletionMetadata, LlmContentPart, LlmMessage,
33 LlmMessageContent, LlmMessageRole, LlmResponseStream, LlmStreamEvent, disjoint_prompt_tokens,
34 fold_system_messages,
35};
36use crate::error::{AgentLoopError, LlmErrorKind, Result};
37use crate::llm_retry::{
38 LlmRetryConfig, RateLimitInfo, RetryDecision, RetryMetadata, SendOutcome, is_rate_limit_status,
39 retry_request, send_error_message,
40};
41use crate::openai_protocol::{is_openai_model_not_found, is_openai_request_too_large};
42use crate::openresponses_types::{self as types, StreamingEvent};
43use crate::stream_reconnect::connect_sse_with_reconnect;
44use crate::tool_types::{ToolCall, ToolDefinition};
45use crate::user_facing_error::is_provider_quota_message;
46
47const OPENAI_PROMPT_CACHE_KEY_MAX_LEN: usize = 64;
48const PROMPT_CACHE_KEY_PREFIX: &str = "everruns:";
49
50pub trait OpenResponsesRequestExtension: Send + Sync {
83 fn decorate(&self, body: &mut Value, config: &LlmCallConfig) -> Result<()>;
84
85 fn decorate_headers(&self, _headers: &mut HeaderMap, _config: &LlmCallConfig) -> Result<()> {
92 Ok(())
93 }
94
95 fn update_rate_limit_info(
97 &self,
98 _info: &mut RateLimitInfo,
99 _headers: &HeaderMap,
100 _error_body: &str,
101 ) {
102 }
103}
104
105#[derive(Clone)]
106pub struct OpenResponsesProtocolChatDriver {
107 client: Client,
108 retry_config: LlmRetryConfig,
110 request_extension: Option<Arc<dyn OpenResponsesRequestExtension>>,
113 stateful_responses: Option<bool>,
115 native_phases: bool,
116 hosted_tool_search: bool,
117}
118
119impl OpenResponsesProtocolChatDriver {
120 pub fn new() -> Self {
122 Self {
123 client: crate::driver_helpers::shared_streaming_http_client(),
128 retry_config: LlmRetryConfig::default(),
129 request_extension: None,
130 stateful_responses: None,
131 native_phases: false,
132 hosted_tool_search: false,
133 }
134 }
135
136 pub fn with_native_features(mut self, phases: bool, hosted_tool_search: bool) -> Self {
138 self.native_phases = phases;
139 self.hosted_tool_search = hosted_tool_search;
140 self
141 }
142
143 pub fn with_request_extension(
147 mut self,
148 extension: Arc<dyn OpenResponsesRequestExtension>,
149 ) -> Self {
150 self.request_extension = Some(extension);
151 self
152 }
153
154 pub fn with_stateful_responses(mut self, supported: bool) -> Self {
156 self.stateful_responses = Some(supported);
157 self
158 }
159
160 pub fn with_retry_config(mut self, config: LlmRetryConfig) -> Self {
162 self.retry_config = config;
163 self
164 }
165
166 async fn send_responses_request(
175 &self,
176 endpoint: &crate::runtime_provider::ProviderEndpoint,
177 api_url: &str,
178 request_body: &Value,
179 extension_headers: &HeaderMap,
180 model: &str,
181 retries_consumed: u32,
182 ) -> Result<(reqwest::Response, RetryMetadata)> {
183 let last_error: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
184 let mut retry_config = self.retry_config.clone();
185 retry_config.max_retries = retry_config.max_retries.saturating_sub(retries_consumed);
186
187 let body = serde_json::to_vec(request_body)
188 .map_err(|e| AgentLoopError::llm(format!("failed to serialize request: {e}")))?;
189 retry_request(
190 &retry_config,
191 "OpenResponsesProtocolDriver",
192 || async {
193 let mut headers = extension_headers.clone();
199 let service_headers = headers
200 .iter()
201 .filter_map(|(name, value)| {
202 value
203 .to_str()
204 .ok()
205 .map(|value| (name.to_string(), value.to_string()))
206 })
207 .collect::<Vec<_>>();
208 let resolved = endpoint
209 .resolve("POST", api_url, &body)
210 .await
211 .map_err(SendOutcome::Fatal)?;
212 for (name, value) in service_headers.into_iter().chain(resolved.headers) {
213 let name =
214 reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
215 SendOutcome::Fatal(AgentLoopError::llm(format!(
216 "invalid header name: {e}"
217 )))
218 })?;
219 let mut value =
220 reqwest::header::HeaderValue::from_str(&value).map_err(|e| {
221 SendOutcome::Fatal(AgentLoopError::llm(format!(
222 "invalid header value: {e}"
223 )))
224 })?;
225 value.set_sensitive(true);
226 headers.insert(name, value);
227 }
228
229 self.client
230 .post(&resolved.url)
231 .headers(headers)
232 .header("Content-Type", "application/json")
233 .body(body.clone())
234 .send()
235 .await
236 .map_err(SendOutcome::Send)
237 },
238 |response, attempts, can_retry| {
239 let last_error = Arc::clone(&last_error);
240 let model = model.to_string();
241 async move {
242 let status = response.status();
243
244 if can_retry {
245 let response_headers = response.headers().clone();
247 let mut rate_limit_info = if is_rate_limit_status(status) {
248 Some(RateLimitInfo::from_openai_headers(&response_headers))
249 } else {
250 None
251 };
252
253 let error_text = response.text().await.unwrap_or_default();
254 if let (Some(extension), Some(info)) =
255 (self.request_extension.as_ref(), rate_limit_info.as_mut())
256 {
257 extension.update_rate_limit_info(info, &response_headers, &error_text);
258 }
259
260 if is_provider_quota_message(&error_text) {
263 return RetryDecision::Terminal(AgentLoopError::llm_kind(
264 LlmErrorKind::QuotaExhausted,
265 format!("OpenAI Responses API error ({}): {}", status, error_text),
266 ));
267 }
268
269 let wait = rate_limit_info
270 .as_ref()
271 .map(|info| info.recommended_wait(&self.retry_config, attempts))
272 .unwrap_or_else(|| self.retry_config.calculate_backoff(attempts));
273
274 *last_error.lock().unwrap() = Some(error_text);
275 return RetryDecision::Retry {
276 wait,
277 rate_limit_info,
278 };
279 }
280
281 let error_text = response.text().await.unwrap_or_default();
283
284 if is_openai_model_not_found(status, &error_text) {
286 return RetryDecision::Terminal(AgentLoopError::model_not_available(model));
287 }
288
289 if is_openai_request_too_large(status, &error_text) {
291 return RetryDecision::Terminal(AgentLoopError::request_too_large(
292 format!("OpenAI Responses API ({}): {}", status, error_text),
293 ));
294 }
295
296 let error_msg =
297 format!("OpenAI Responses API error ({}): {}", status, error_text);
298
299 let kind = LlmErrorKind::from_provider_status(status.as_u16(), &error_text);
302
303 if attempts > 0 {
304 return RetryDecision::Terminal(AgentLoopError::llm_kind(
305 kind,
306 format!(
307 "{} (after {} retries, last error: {})",
308 error_msg,
309 attempts,
310 last_error.lock().unwrap().take().unwrap_or_default()
311 ),
312 ));
313 }
314
315 RetryDecision::Terminal(AgentLoopError::llm_kind(kind, error_msg))
316 }
317 },
318 |e, attempts| AgentLoopError::llm(send_error_message(e, attempts)),
319 )
320 .await
321 }
322
323 pub fn client(&self) -> &Client {
325 &self.client
326 }
327
328 fn convert_role(role: &LlmMessageRole) -> &'static str {
329 match role {
330 LlmMessageRole::System => "developer", LlmMessageRole::User => "user",
332 LlmMessageRole::Assistant => "assistant",
333 LlmMessageRole::Tool => "tool",
334 }
335 }
336
337 fn convert_message(msg: &LlmMessage, supports_phases: bool) -> ResponsesInputItem {
338 if msg.role == LlmMessageRole::Tool
342 && let Some(tool_call_id) = &msg.tool_call_id
343 {
344 let mut has_images = false;
345 let output = match &msg.content {
346 LlmMessageContent::Text(text) => text.clone(),
347 LlmMessageContent::Parts(parts) => {
348 has_images = parts
349 .iter()
350 .any(|p| matches!(p, LlmContentPart::Image { .. }));
351 parts
352 .iter()
353 .filter_map(|p| match p {
354 LlmContentPart::Text { text } => Some(text.clone()),
355 _ => None,
356 })
357 .collect::<Vec<_>>()
358 .join("")
359 }
360 };
361 if has_images {
362 tracing::warn!(
363 tool_call_id = %tool_call_id,
364 "OpenResponses API does not support images in tool results; images dropped"
365 );
366 }
367 return ResponsesInputItem::FunctionCallOutput {
368 r#type: "function_call_output".to_string(),
369 call_id: tool_call_id.clone(),
370 output,
371 };
372 }
373
374 let content = match &msg.content {
375 LlmMessageContent::Text(text) => ResponsesContent::Text(text.clone()),
376 LlmMessageContent::Parts(parts) => {
377 let responses_parts: Vec<ResponsesContentPart> = parts
378 .iter()
379 .map(|part| match part {
380 LlmContentPart::Text { text } => ResponsesContentPart::InputText {
381 r#type: "input_text".to_string(),
382 text: text.clone(),
383 },
384 LlmContentPart::Image { url } => ResponsesContentPart::InputImage {
385 r#type: "input_image".to_string(),
386 image_url: url.clone(),
387 },
388 LlmContentPart::Audio { url } => ResponsesContentPart::InputAudio {
389 r#type: "input_audio".to_string(),
390 input_audio: ResponsesInputAudio {
391 data: url.clone(),
392 format: "wav".to_string(),
393 },
394 },
395 })
396 .collect();
397 ResponsesContent::Parts(responses_parts)
398 }
399 };
400
401 let phase = if supports_phases && msg.role == LlmMessageRole::Assistant {
404 msg.phase.map(|p| p.as_provider_str().to_string())
405 } else {
406 None
407 };
408
409 ResponsesInputItem::Message {
410 r#type: "message".to_string(),
411 role: Self::convert_role(&msg.role).to_string(),
412 content,
413 phase,
414 }
415 }
416
417 fn sanitize_parameters(params: &Value) -> Value {
420 let mut p = crate::tool_schema_compat::sanitize_openai_tool_schema(params);
421 if let Some(obj) = p.as_object_mut()
422 && obj.get("type").and_then(|v| v.as_str()) == Some("object")
423 && !obj.contains_key("properties")
424 {
425 obj.insert(
426 "properties".to_string(),
427 serde_json::Value::Object(serde_json::Map::new()),
428 );
429 }
430 p
431 }
432
433 fn convert_tools(tools: &[ToolDefinition]) -> Vec<ResponsesTool> {
434 tools
435 .iter()
436 .map(|tool| ResponsesTool::Function {
437 r#type: "function".to_string(),
438 name: tool.name().to_string(),
439 description: tool.description().to_string(),
440 parameters: Self::sanitize_parameters(tool.parameters()),
441 defer_loading: None,
442 })
443 .collect()
444 }
445
446 fn convert_tools_with_search(tools: &[ToolDefinition], threshold: usize) -> Vec<ResponsesTool> {
449 use crate::tool_types::DeferrablePolicy;
450 use std::collections::HashMap;
451
452 if tools.len() < threshold {
454 return Self::convert_tools(tools);
455 }
456
457 let mut namespaces: HashMap<String, Vec<ResponsesTool>> = HashMap::new();
458 let mut ungrouped = vec![];
459 let mut never_defer = vec![];
460
461 for tool in tools {
462 let should_defer = match tool.deferrable() {
463 DeferrablePolicy::Never => false,
464 DeferrablePolicy::Automatic | DeferrablePolicy::Always => true,
465 };
466
467 let func = ResponsesTool::Function {
468 r#type: "function".to_string(),
469 name: tool.name().to_string(),
470 description: tool.description().to_string(),
471 parameters: Self::sanitize_parameters(tool.parameters()),
472 defer_loading: if should_defer { Some(true) } else { None },
473 };
474
475 if !should_defer {
476 never_defer.push(func);
477 } else {
478 match tool.category() {
479 Some(cat) => {
480 namespaces.entry(cat.to_string()).or_default().push(func);
481 }
482 None => ungrouped.push(func),
483 }
484 }
485 }
486
487 let mut result: Vec<ResponsesTool> = Vec::new();
488
489 result.extend(never_defer);
491
492 for (name, tools) in namespaces {
494 let description = format!("Tools for {name}");
495 result.push(ResponsesTool::Namespace {
496 r#type: "namespace".to_string(),
497 name,
498 description,
499 tools,
500 });
501 }
502
503 result.extend(ungrouped);
505
506 result.push(ResponsesTool::ToolSearch {
508 r#type: "tool_search".to_string(),
509 });
510
511 result
512 }
513
514 fn build_prompt_cache_key(
515 config: &LlmCallConfig,
516 _input_items: &[ResponsesInputItem],
517 instructions: &Option<String>,
518 tools: &Option<Vec<ResponsesTool>>,
519 ) -> Option<String> {
520 let prompt_cache = config.prompt_cache.as_ref().filter(|cfg| cfg.enabled)?;
521 let cache_family = config
522 .metadata
523 .get("session_id")
524 .or_else(|| config.metadata.get("agent_id"))
525 .or_else(|| config.metadata.get("harness_id"))
526 .or_else(|| config.metadata.get("org_id"));
527 let fingerprint = json!({
528 "strategy": prompt_cache.strategy,
529 "model": config.model,
530 "cache_family": cache_family,
531 "instructions": instructions,
532 "tools": tools,
533 });
534 let payload = serde_json::to_vec(&fingerprint).ok()?;
535 let digest = hex::encode(Sha256::digest(payload));
536 let digest_len = OPENAI_PROMPT_CACHE_KEY_MAX_LEN - PROMPT_CACHE_KEY_PREFIX.len();
537 Some(format!(
538 "{PROMPT_CACHE_KEY_PREFIX}{}",
539 &digest[..digest_len]
540 ))
541 }
542
543 pub async fn compact(
581 &self,
582 endpoint: &crate::runtime_provider::ProviderEndpoint,
583 request: CompactRequest,
584 ) -> Result<CompactResponse> {
585 let responses_url = endpoint.url("responses").ok_or_else(|| {
588 AgentLoopError::Configuration("Open Responses provider has no base URL".to_string())
589 })?;
590 let compact_url = if responses_url.ends_with("/responses") {
591 format!("{responses_url}/compact")
592 } else if responses_url.ends_with("/responses/") {
593 format!("{responses_url}compact")
594 } else {
595 format!("{}/compact", responses_url.trim_end_matches('/'))
597 };
598 let body = serde_json::to_vec(&request).map_err(|e| {
599 AgentLoopError::llm(format!("failed to serialize compact request: {e}"))
600 })?;
601
602 let last_error: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
607
608 let (response, _retry_metadata) = retry_request(
609 &self.retry_config,
610 "OpenResponsesProtocolDriver(compact)",
611 || async {
612 let resolved = endpoint
615 .resolve("POST", &compact_url, &body)
616 .await
617 .map_err(SendOutcome::Fatal)?;
618 let mut builder = self.client.post(&resolved.url);
619 for (name, value) in resolved.headers {
620 builder = builder.header(name, value);
621 }
622 builder
623 .header("Content-Type", "application/json")
624 .body(body.clone())
625 .send()
626 .await
627 .map_err(SendOutcome::Send)
628 },
629 |response, attempts, can_retry| {
630 let last_error = Arc::clone(&last_error);
631 let request_model = request.model.clone();
632 async move {
633 let status = response.status();
634
635 if can_retry {
636 let response_headers = response.headers().clone();
637 let mut rate_limit_info = if is_rate_limit_status(status) {
638 Some(RateLimitInfo::from_openai_headers(&response_headers))
639 } else {
640 None
641 };
642
643 let error_text = response.text().await.unwrap_or_default();
644 if let (Some(extension), Some(info)) =
645 (self.request_extension.as_ref(), rate_limit_info.as_mut())
646 {
647 extension.update_rate_limit_info(info, &response_headers, &error_text);
648 }
649
650 let wait = rate_limit_info
651 .as_ref()
652 .map(|info| info.recommended_wait(&self.retry_config, attempts))
653 .unwrap_or_else(|| self.retry_config.calculate_backoff(attempts));
654
655 *last_error.lock().unwrap() = Some(error_text);
656 return RetryDecision::Retry {
657 wait,
658 rate_limit_info,
659 };
660 }
661
662 let error_text = response.text().await.unwrap_or_default();
664
665 if is_openai_model_not_found(status, &error_text) {
667 return RetryDecision::Terminal(AgentLoopError::model_not_available(
668 request_model,
669 ));
670 }
671
672 if is_openai_request_too_large(status, &error_text) {
674 return RetryDecision::Terminal(AgentLoopError::request_too_large(
675 format!("OpenAI Responses compact API ({}): {}", status, error_text),
676 ));
677 }
678
679 let error_msg = format!(
680 "OpenAI Responses compact API error ({}): {}",
681 status, error_text
682 );
683
684 if attempts > 0 {
685 return RetryDecision::Terminal(AgentLoopError::llm(format!(
686 "{} (after {} retries, last error: {})",
687 error_msg,
688 attempts,
689 last_error.lock().unwrap().take().unwrap_or_default()
690 )));
691 }
692
693 RetryDecision::Terminal(AgentLoopError::llm(error_msg))
694 }
695 },
696 |e, attempts| {
697 let suffix = if attempts > 0 {
698 format!(" (after {attempts} retries)")
699 } else {
700 String::new()
701 };
702 AgentLoopError::llm(format!("Failed to send compact request: {e}{suffix}"))
703 },
704 )
705 .await?;
706
707 let compact_response: CompactResponse = response
709 .json()
710 .await
711 .map_err(|e| AgentLoopError::llm(format!("Failed to parse compact response: {}", e)))?;
712
713 Ok(compact_response)
714 }
715
716 pub fn supports_compact(&self) -> bool {
721 true
722 }
723
724 fn build_input(
736 messages: &[LlmMessage],
737 supports_phases: bool,
738 ) -> (Option<String>, Vec<ResponsesInputItem>) {
739 let instructions: Option<String> = fold_system_messages(messages);
745 let mut input_items = Vec::new();
746 let mut reasoning_counter = 0u32;
748
749 for msg in messages {
750 if msg.role == LlmMessageRole::System {
751 } else if msg.role == LlmMessageRole::Assistant {
754 if let Some(encrypted_content) = &msg.thinking_signature {
757 reasoning_counter += 1;
758 input_items.push(ResponsesInputItem::Reasoning {
759 r#type: "reasoning".to_string(),
760 id: format!("rs_{:08x}", reasoning_counter),
761 encrypted_content: encrypted_content.clone(),
762 });
763 tracing::debug!(
764 encrypted_len = encrypted_content.len(),
765 "OpenResponses: including reasoning item in request"
766 );
767 }
768
769 if msg.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty()) {
771 let has_content = match &msg.content {
773 LlmMessageContent::Text(text) => !text.is_empty(),
774 LlmMessageContent::Parts(parts) => !parts.is_empty(),
775 };
776 if has_content {
777 input_items.push(Self::convert_message(msg, supports_phases));
778 }
779
780 if let Some(tool_calls) = &msg.tool_calls {
782 for tc in tool_calls {
783 input_items.push(ResponsesInputItem::FunctionCall {
784 r#type: "function_call".to_string(),
785 call_id: tc.id.clone(),
786 name: tc.name.clone(),
787 arguments: tc.arguments.to_string(),
788 });
789 }
790 }
791 } else {
792 input_items.push(Self::convert_message(msg, supports_phases));
793 }
794 } else {
795 input_items.push(Self::convert_message(msg, supports_phases));
796 }
797 }
798
799 (instructions, input_items)
800 }
801}
802
803impl Default for OpenResponsesProtocolChatDriver {
804 fn default() -> Self {
805 Self::new()
806 }
807}
808
809fn compute_delta_input_items(items: Vec<ResponsesInputItem>) -> Vec<ResponsesInputItem> {
828 let last_assistant_turn_idx = items
830 .iter()
831 .enumerate()
832 .rev()
833 .find_map(|(i, item)| match item {
834 ResponsesInputItem::Message { role, .. } if role == "assistant" => Some(i),
835 ResponsesInputItem::Reasoning { .. } => Some(i),
836 ResponsesInputItem::FunctionCall { .. } => Some(i),
837 _ => None,
838 });
839
840 match last_assistant_turn_idx {
841 Some(idx) => items.into_iter().skip(idx + 1).collect(),
842 None => items,
844 }
845}
846
847fn finalize_input_for_request(
852 input_items: Vec<ResponsesInputItem>,
853 previous_response_id: &Option<String>,
854) -> Vec<ResponsesInputItem> {
855 if previous_response_id.is_some() {
856 compute_delta_input_items(input_items)
857 } else {
858 repair_unpaired_function_call_items(input_items)
859 }
860}
861
862fn unpaired_function_call_ids(items: &[ResponsesInputItem]) -> Vec<String> {
868 let call_ids: HashSet<&str> = items
869 .iter()
870 .filter_map(|item| match item {
871 ResponsesInputItem::FunctionCall { call_id, .. } => Some(call_id.as_str()),
872 _ => None,
873 })
874 .collect();
875 let output_ids: HashSet<&str> = items
876 .iter()
877 .filter_map(|item| match item {
878 ResponsesInputItem::FunctionCallOutput { call_id, .. } => Some(call_id.as_str()),
879 _ => None,
880 })
881 .collect();
882
883 items
884 .iter()
885 .filter_map(|item| match item {
886 ResponsesInputItem::FunctionCall { call_id, .. }
887 if !output_ids.contains(call_id.as_str()) =>
888 {
889 Some(call_id.clone())
890 }
891 ResponsesInputItem::FunctionCallOutput { call_id, .. }
892 if !call_ids.contains(call_id.as_str()) =>
893 {
894 Some(call_id.clone())
895 }
896 _ => None,
897 })
898 .collect()
899}
900
901fn repair_unpaired_function_call_items(
918 input_items: Vec<ResponsesInputItem>,
919) -> Vec<ResponsesInputItem> {
920 let unpaired: HashSet<String> = unpaired_function_call_ids(&input_items)
921 .into_iter()
922 .collect();
923
924 if unpaired.is_empty() {
925 return input_items;
926 }
927
928 tracing::warn!(
929 unpaired_call_ids = ?unpaired,
930 "dropping unpaired function_call / function_call_output items before \
931 stateless Responses replay; one side of the pair was likely evicted by \
932 compaction or model-view masking (EVE-597/EVE-519)"
933 );
934
935 input_items
936 .into_iter()
937 .filter(|item| match item {
938 ResponsesInputItem::FunctionCall { call_id, .. }
939 | ResponsesInputItem::FunctionCallOutput { call_id, .. } => {
940 !unpaired.contains(call_id.as_str())
941 }
942 _ => true,
943 })
944 .collect()
945}
946
947fn is_missing_tool_output_continuation_error(error: &AgentLoopError) -> bool {
948 if !matches!(error.llm_error_kind(), Some(LlmErrorKind::InvalidRequest)) {
949 return false;
950 }
951 let message = error.to_string().to_ascii_lowercase();
952 message.contains("no tool output found for function call")
953 || message.contains("no tool call found for function call output")
954}
955
956#[async_trait]
957impl ChatDriver for OpenResponsesProtocolChatDriver {
958 fn supports_stateful_responses(&self) -> bool {
959 self.stateful_responses.unwrap_or(false)
960 }
961
962 async fn chat_completion_stream(
963 &self,
964 endpoint: &crate::runtime_provider::ProviderEndpoint,
965 messages: Vec<LlmMessage>,
966 config: &LlmCallConfig,
967 ) -> Result<LlmResponseStream> {
968 let api_url = endpoint.url("responses").ok_or_else(|| {
969 AgentLoopError::Configuration("Open Responses provider has no base URL".to_string())
970 })?;
971 let supports_phases = self.native_phases;
976 let supports_tool_search = self.hosted_tool_search;
977
978 let (instructions, transcript_input_items) = Self::build_input(&messages, supports_phases);
979 let full_replay_input_items = transcript_input_items.clone();
980
981 let mut previous_response_id = if self.stateful_responses.unwrap_or(false) {
987 config.previous_response_id.clone()
988 } else {
989 None
990 };
991
992 let input_items = match &config.provider_opaque_context {
997 Some(crate::driver_registry::ProviderOpaqueContext::OpenResponsesCompact {
998 output,
999 }) => {
1000 previous_response_id = None;
1001 let mut input_items: Vec<_> = output.iter().map(ResponsesInputItem::from).collect();
1002 input_items.extend(transcript_input_items);
1003 input_items
1004 }
1005 None => finalize_input_for_request(transcript_input_items, &previous_response_id),
1006 };
1007
1008 let tools = if config.tools.is_empty() {
1009 None
1010 } else if let Some(ref ts_config) = config.tool_search {
1011 if ts_config.enabled && supports_tool_search {
1012 Some(Self::convert_tools_with_search(
1013 &config.tools,
1014 ts_config.threshold,
1015 ))
1016 } else {
1017 Some(Self::convert_tools(&config.tools))
1018 }
1019 } else {
1020 Some(Self::convert_tools(&config.tools))
1021 };
1022
1023 let reasoning = config
1027 .reasoning_effort
1028 .as_ref()
1029 .filter(|e| !e.eq_ignore_ascii_case("none"))
1030 .map(|effort| ResponsesReasoning {
1031 effort: effort.clone(),
1032 summary: "detailed".to_string(),
1033 });
1034
1035 let metadata = if config.metadata.is_empty() {
1037 None
1038 } else {
1039 Some(config.metadata.clone())
1040 };
1041 let prompt_cache_key =
1042 Self::build_prompt_cache_key(config, &input_items, &instructions, &tools);
1043 let mut request = ResponsesRequest {
1044 model: config.model.clone(),
1045 input: input_items,
1046 instructions,
1047 previous_response_id,
1048 temperature: config.temperature,
1049 max_output_tokens: config.max_tokens,
1050 stream: true,
1051 tools,
1052 reasoning,
1053 metadata,
1054 prompt_cache_key,
1055 parallel_tool_calls: config
1056 .resolved_parallel_tool_calls(self.supports_parallel_tool_calls(&config.model)),
1057 service_tier: config.speed.clone(),
1058 text: config.verbosity.clone().map(|verbosity| ResponsesText {
1059 verbosity: Some(verbosity),
1060 }),
1061 };
1062
1063 {
1066 let tool_count = request.tools.as_ref().map_or(0, |t| t.len());
1067 let input_count = request.input.len();
1068 let has_instructions = request.instructions.is_some();
1069 let has_reasoning = request.reasoning.is_some();
1070 let has_previous_response = request.previous_response_id.is_some();
1071 tracing::debug!(
1072 model = %request.model,
1073 input_items = input_count,
1074 tool_count = tool_count,
1075 has_instructions = has_instructions,
1076 has_reasoning = has_reasoning,
1077 has_previous_response = has_previous_response,
1078 api_url = %api_url,
1079 "OpenResponsesDriver: sending request"
1080 );
1081 }
1082
1083 let mut request_body = serde_json::to_value(&request)
1086 .map_err(|e| AgentLoopError::llm(format!("Failed to serialize request: {}", e)))?;
1087 if let Some(extension) = &self.request_extension {
1088 extension.decorate(&mut request_body, config)?;
1089 }
1090 let mut extension_headers = HeaderMap::new();
1091 if let Some(extension) = &self.request_extension {
1092 extension.decorate_headers(&mut extension_headers, config)?;
1093 }
1094
1095 let first_connect = connect_sse_with_reconnect(
1100 &self.retry_config,
1101 "OpenResponsesProtocolDriver",
1102 |attempts| {
1103 self.send_responses_request(
1104 endpoint,
1105 &api_url,
1106 &request_body,
1107 &extension_headers,
1108 &config.model,
1109 attempts,
1110 )
1111 },
1112 )
1113 .await;
1114 let (event_stream, retry_metadata) = match first_connect {
1115 Ok(connected) => connected,
1116 Err(error)
1117 if request.previous_response_id.is_some()
1118 && is_missing_tool_output_continuation_error(&error) =>
1119 {
1120 tracing::warn!(
1127 model = %request.model,
1128 "stateful Responses continuation rejected for missing tool output; retrying once with repaired stateless replay"
1129 );
1130 request.previous_response_id = None;
1131 request.input = repair_unpaired_function_call_items(full_replay_input_items);
1132 request.prompt_cache_key = Self::build_prompt_cache_key(
1133 config,
1134 &request.input,
1135 &request.instructions,
1136 &request.tools,
1137 );
1138 request_body = serde_json::to_value(&request).map_err(|e| {
1139 AgentLoopError::llm(format!("Failed to serialize recovery request: {e}"))
1140 })?;
1141 if let Some(extension) = &self.request_extension {
1142 extension.decorate(&mut request_body, config)?;
1143 }
1144 connect_sse_with_reconnect(
1145 &self.retry_config,
1146 "OpenResponsesProtocolDriver",
1147 |attempts| {
1148 self.send_responses_request(
1149 endpoint,
1150 &api_url,
1151 &request_body,
1152 &extension_headers,
1153 &config.model,
1154 attempts,
1155 )
1156 },
1157 )
1158 .await?
1159 }
1160 Err(error) => return Err(error),
1161 };
1162
1163 let model = config.model.clone();
1164 let input_tokens = Arc::new(Mutex::new(0u32));
1165 let output_tokens = Arc::new(Mutex::new(0u32));
1166 let cache_read_tokens = Arc::new(Mutex::new(Option::<u32>::None));
1167 let accumulated_tool_calls = Arc::new(Mutex::new(Vec::<ToolCallAccumulator>::new()));
1168 let finish_reason = Arc::new(Mutex::new(Option::<String>::None));
1169 let shared_retry_metadata = if retry_metadata.had_retries() {
1171 Some(Arc::new(retry_metadata))
1172 } else {
1173 None
1174 };
1175
1176 let converted_stream: LlmResponseStream = Box::pin(event_stream.then(move |result| {
1177 let model = model.clone();
1178 let input_tokens = Arc::clone(&input_tokens);
1179 let output_tokens = Arc::clone(&output_tokens);
1180 let cache_read_tokens = Arc::clone(&cache_read_tokens);
1181 let accumulated_tool_calls = Arc::clone(&accumulated_tool_calls);
1182 let finish_reason = Arc::clone(&finish_reason);
1183 let retry_metadata_for_done = shared_retry_metadata.clone();
1184
1185 async move {
1186 match result {
1187 Ok(event) => {
1188 let event_data = &event.data;
1189
1190 if event_data == "[DONE]" {
1196 return Ok(LlmStreamEvent::TextDelta(String::new()));
1197 }
1198
1199 if let Ok(streaming_event) =
1201 serde_json::from_str::<StreamingEvent>(event_data)
1202 {
1203 return Ok(handle_streaming_event(
1204 streaming_event,
1205 &input_tokens,
1206 &output_tokens,
1207 &cache_read_tokens,
1208 &accumulated_tool_calls,
1209 &finish_reason,
1210 model,
1211 retry_metadata_for_done,
1212 ));
1213 }
1214
1215 let parsed: std::result::Result<Value, _> =
1217 serde_json::from_str(event_data);
1218
1219 match parsed {
1220 Ok(json) => {
1221 let event_type = json.get("type").and_then(|t| t.as_str());
1222
1223 match event_type {
1224 Some("response.output_text.delta") => {
1225 if let Some(delta) =
1227 json.get("delta").and_then(|d| d.as_str())
1228 {
1229 Ok(LlmStreamEvent::TextDelta(delta.to_string()))
1230 } else {
1231 Ok(LlmStreamEvent::TextDelta(String::new()))
1232 }
1233 }
1234
1235 Some("response.function_call_arguments.delta") => {
1236 if let (Some(item_id), Some(delta)) = (
1238 json.get("item_id").and_then(|c| c.as_str()),
1239 json.get("delta").and_then(|d| d.as_str()),
1240 ) {
1241 let mut acc = accumulated_tool_calls.lock().unwrap();
1242 if let Some(tc) =
1244 acc.iter_mut().find(|t| t.id == item_id)
1245 {
1246 tc.arguments.push_str(delta);
1247 } else {
1248 acc.push(ToolCallAccumulator {
1249 id: item_id.to_string(),
1250 call_id: String::new(),
1251 name: String::new(),
1252 arguments: delta.to_string(),
1253 });
1254 }
1255 }
1256 Ok(LlmStreamEvent::TextDelta(String::new()))
1257 }
1258
1259 Some("response.output_item.added") => {
1260 let item_type = json
1264 .get("item")
1265 .and_then(|i| i.get("type"))
1266 .and_then(|t| t.as_str());
1267 if item_type == Some("function_call") {
1268 let item = json.get("item").unwrap();
1269 let id = item
1270 .get("id")
1271 .and_then(|c| c.as_str())
1272 .unwrap_or("")
1273 .to_string();
1274 let call_id = item
1275 .get("call_id")
1276 .and_then(|c| c.as_str())
1277 .unwrap_or("")
1278 .to_string();
1279 let name = item
1280 .get("name")
1281 .and_then(|n| n.as_str())
1282 .unwrap_or("")
1283 .to_string();
1284
1285 let mut acc = accumulated_tool_calls.lock().unwrap();
1286 if let Some(tc) = acc.iter_mut().find(|t| t.id == id) {
1287 tc.name = name;
1288 tc.call_id = call_id;
1289 } else {
1290 acc.push(ToolCallAccumulator {
1291 id,
1292 call_id,
1293 name,
1294 arguments: String::new(),
1295 });
1296 }
1297 } else if item_type == Some("message") {
1298 if let Some(phase) = json
1303 .get("item")
1304 .and_then(|i| i.get("phase"))
1305 .and_then(|p| p.as_str())
1306 .and_then(
1307 crate::execution_phase::ExecutionPhase::from_provider_str,
1308 )
1309 {
1310 return Ok(LlmStreamEvent::MessagePhase(phase));
1311 }
1312 }
1313 Ok(LlmStreamEvent::TextDelta(String::new()))
1314 }
1315
1316 Some("response.output_item.done") => {
1317 if let Some(item) = json.get("item")
1319 && item.get("type").and_then(|t| t.as_str())
1320 == Some("function_call")
1321 {
1322 let acc = accumulated_tool_calls.lock().unwrap();
1324 if !acc.is_empty() {
1325 let tool_calls: Vec<ToolCall> = acc
1326 .iter()
1327 .filter(|tc| !tc.name.is_empty())
1328 .map(|tc| {
1329 let arguments: Value =
1330 serde_json::from_str(&tc.arguments)
1331 .unwrap_or(json!({}));
1332 ToolCall {
1333 id: tc.call_id.clone(),
1334 name: tc.name.clone(),
1335 arguments,
1336 }
1337 })
1338 .collect();
1339
1340 if !tool_calls.is_empty() {
1341 *finish_reason.lock().unwrap() =
1342 Some("tool_calls".to_string());
1343 return Ok(LlmStreamEvent::ToolCalls(
1344 tool_calls,
1345 ));
1346 }
1347 }
1348 }
1349 Ok(LlmStreamEvent::TextDelta(String::new()))
1350 }
1351
1352 Some("response.completed")
1353 | Some("response.incomplete")
1354 | Some("response.done") => {
1355 let response_obj = json.get("response").unwrap_or(&json);
1357
1358 let mut provider_cost_usd: Option<f64> = None;
1361 if let Some(usage) = response_obj.get("usage") {
1362 if let Some(input) =
1363 usage.get("input_tokens").and_then(|t| t.as_u64())
1364 {
1365 *input_tokens.lock().unwrap() = input as u32;
1366 }
1367 if let Some(output) =
1368 usage.get("output_tokens").and_then(|t| t.as_u64())
1369 {
1370 *output_tokens.lock().unwrap() = output as u32;
1371 }
1372 if let Some(details) = usage.get("input_tokens_details")
1374 && let Some(cached) = details
1375 .get("cached_tokens")
1376 .and_then(|t| t.as_u64())
1377 {
1378 *cache_read_tokens.lock().unwrap() =
1379 Some(cached as u32);
1380 }
1381 provider_cost_usd =
1382 usage.get("cost").and_then(|c| c.as_f64());
1383 }
1384
1385 let status = response_obj
1387 .get("status")
1388 .and_then(|s| s.as_str())
1389 .unwrap_or("completed");
1390
1391 let reason = match status {
1392 "completed" => {
1393 let existing_reason =
1395 finish_reason.lock().unwrap().clone();
1396 existing_reason
1397 .unwrap_or_else(|| "stop".to_string())
1398 }
1399 "failed" => {
1400 let error_detail = response_obj
1401 .get("error")
1402 .map(|e| e.to_string())
1403 .unwrap_or_else(|| "no error detail".into());
1404 tracing::warn!(
1405 response_error = %error_detail,
1406 "OpenResponsesDriver: response completed with 'failed' status (fallback parser)"
1407 );
1408 "error".to_string()
1409 }
1410 "incomplete" => response_obj
1411 .get("incomplete_details")
1412 .and_then(|details| details.get("reason"))
1413 .and_then(|reason| reason.as_str())
1414 .map(|reason| match reason {
1415 "max_output_tokens" | "max_tokens" => "length",
1416 other => other,
1417 })
1418 .unwrap_or("stop")
1419 .to_string(),
1420 "cancelled" => "cancelled".to_string(),
1421 _ => "stop".to_string(),
1422 };
1423
1424 let phase = response_obj
1426 .get("output")
1427 .and_then(|o| o.as_array())
1428 .and_then(|items| {
1429 items.iter().rev().find_map(|item| {
1430 if item.get("type")?.as_str()? == "message"
1431 && item.get("role")?.as_str()?
1432 == "assistant"
1433 {
1434 item.get("phase")?
1435 .as_str()
1436 .map(String::from)
1437 } else {
1438 None
1439 }
1440 })
1441 });
1442
1443 let input = *input_tokens.lock().unwrap();
1444 let output = *output_tokens.lock().unwrap();
1445 let cached = *cache_read_tokens.lock().unwrap();
1446
1447 Ok(LlmStreamEvent::Done(Box::new(LlmCompletionMetadata {
1448 total_tokens: Some(input + output),
1451 prompt_tokens: Some(disjoint_prompt_tokens(input, cached)),
1452 completion_tokens: Some(output),
1453 cache_read_tokens: cached,
1454 cache_creation_tokens: None,
1455 provider_cost_usd,
1456 model: Some(model),
1457 finish_reason: Some(reason),
1458 retry_metadata: retry_metadata_for_done
1459 .map(|arc| (*arc).clone()),
1460 response_id: None,
1461 phase,
1462 })))
1463 }
1464
1465 Some("error") => {
1466 let error_code = json
1468 .get("error")
1469 .and_then(|e| e.get("code"))
1470 .and_then(|c| c.as_str())
1471 .unwrap_or("unknown");
1472 let error_msg = json
1473 .get("error")
1474 .and_then(|e| e.get("message"))
1475 .and_then(|m| m.as_str())
1476 .unwrap_or("Unknown error");
1477 tracing::warn!(
1478 error_code = error_code,
1479 error_message = error_msg,
1480 raw_error = %json.get("error").unwrap_or(&json),
1481 "OpenResponsesDriver: received streaming error event (fallback parser)"
1482 );
1483 Ok(LlmStreamEvent::Error(
1484 crate::driver_registry::LlmStreamError::provider(
1485 (error_code != "unknown")
1486 .then_some(error_code.to_string()),
1487 None,
1488 error_msg,
1489 ),
1490 ))
1491 }
1492
1493 _ => {
1494 Ok(LlmStreamEvent::TextDelta(String::new()))
1496 }
1497 }
1498 }
1499 Err(e) => Ok(LlmStreamEvent::Error(
1500 format!("Failed to parse event: {}", e).into(),
1501 )),
1502 }
1503 }
1504 Err(e) => Ok(LlmStreamEvent::Error(
1505 format!("Stream error: {}", e).into(),
1506 )),
1507 }
1508 }
1509 }));
1510
1511 Ok(converted_stream)
1512 }
1513
1514 fn supports_compact(&self) -> bool {
1515 OpenResponsesProtocolChatDriver::supports_compact(self)
1517 }
1518
1519 fn supports_parallel_tool_calls(&self, _model: &str) -> bool {
1521 true
1522 }
1523
1524 async fn compact(
1525 &self,
1526 endpoint: &crate::runtime_provider::ProviderEndpoint,
1527 request: crate::openresponses_protocol::CompactRequest,
1528 ) -> Result<Option<crate::openresponses_protocol::CompactResponse>> {
1529 Ok(Some(
1531 OpenResponsesProtocolChatDriver::compact(self, endpoint, request).await?,
1532 ))
1533 }
1534}
1535
1536impl std::fmt::Debug for OpenResponsesProtocolChatDriver {
1537 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1538 f.debug_struct("OpenResponsesProtocolChatDriver")
1539 .field("stateful_responses", &self.stateful_responses)
1540 .field("native_phases", &self.native_phases)
1541 .field("hosted_tool_search", &self.hosted_tool_search)
1542 .finish_non_exhaustive()
1543 }
1544}
1545
1546#[derive(Clone, Default)]
1552struct ToolCallAccumulator {
1553 id: String,
1555 call_id: String,
1557 name: String,
1559 arguments: String,
1561}
1562
1563#[allow(clippy::too_many_arguments)]
1565fn handle_streaming_event(
1566 event: StreamingEvent,
1567 input_tokens: &Mutex<u32>,
1568 output_tokens: &Mutex<u32>,
1569 cache_read_tokens: &Mutex<Option<u32>>,
1570 accumulated_tool_calls: &Mutex<Vec<ToolCallAccumulator>>,
1571 finish_reason: &Mutex<Option<String>>,
1572 model: String,
1573 retry_metadata: Option<Arc<RetryMetadata>>,
1574) -> LlmStreamEvent {
1575 match event {
1576 StreamingEvent::OutputTextDelta { delta, .. } => LlmStreamEvent::TextDelta(delta),
1577
1578 StreamingEvent::ReasoningDelta { delta, .. } => LlmStreamEvent::ThinkingDelta(delta),
1579
1580 StreamingEvent::ReasoningTextDelta { delta, .. } => LlmStreamEvent::ThinkingDelta(delta),
1581
1582 StreamingEvent::ReasoningSummaryDelta { delta, .. } => {
1583 LlmStreamEvent::TextDelta(delta)
1587 }
1588
1589 StreamingEvent::FunctionCallArgumentsDelta { item_id, delta, .. } => {
1590 let mut acc = accumulated_tool_calls.lock().unwrap();
1591 if let Some(tc) = acc.iter_mut().find(|t| t.id == item_id) {
1592 tc.arguments.push_str(&delta);
1593 } else {
1594 acc.push(ToolCallAccumulator {
1595 id: item_id,
1596 call_id: String::new(),
1597 name: String::new(),
1598 arguments: delta,
1599 });
1600 }
1601 LlmStreamEvent::TextDelta(String::new())
1602 }
1603
1604 StreamingEvent::OutputItemAdded { item, .. } => {
1605 match item {
1606 Some(types::OutputItem::FunctionCall {
1607 id, call_id, name, ..
1608 }) => {
1609 let mut acc = accumulated_tool_calls.lock().unwrap();
1610 if let Some(tc) = acc.iter_mut().find(|t| t.id == id) {
1611 tc.name = name;
1612 tc.call_id = call_id;
1613 } else {
1614 acc.push(ToolCallAccumulator {
1615 id,
1616 call_id,
1617 name,
1618 arguments: String::new(),
1619 });
1620 }
1621 LlmStreamEvent::TextDelta(String::new())
1622 }
1623 Some(types::OutputItem::Message {
1629 phase: Some(phase_str),
1630 ..
1631 }) => match crate::execution_phase::ExecutionPhase::from_provider_str(&phase_str) {
1632 Some(phase) => LlmStreamEvent::MessagePhase(phase),
1633 None => LlmStreamEvent::TextDelta(String::new()),
1634 },
1635 _ => LlmStreamEvent::TextDelta(String::new()),
1636 }
1637 }
1638
1639 StreamingEvent::OutputItemDone { item, .. } => {
1640 match item {
1641 Some(types::OutputItem::FunctionCall { .. }) => {
1642 let acc = accumulated_tool_calls.lock().unwrap();
1643 if !acc.is_empty() {
1644 let tool_calls: Vec<ToolCall> = acc
1645 .iter()
1646 .filter(|tc| !tc.name.is_empty())
1647 .map(|tc| {
1648 let arguments: Value =
1649 serde_json::from_str(&tc.arguments).unwrap_or(json!({}));
1650 ToolCall {
1651 id: tc.call_id.clone(),
1652 name: tc.name.clone(),
1653 arguments,
1654 }
1655 })
1656 .collect();
1657
1658 if !tool_calls.is_empty() {
1659 *finish_reason.lock().unwrap() = Some("tool_calls".to_string());
1660 return LlmStreamEvent::ToolCalls(tool_calls);
1661 }
1662 }
1663 LlmStreamEvent::TextDelta(String::new())
1664 }
1665 Some(types::OutputItem::Reasoning {
1666 id,
1667 summary,
1668 content: _, encrypted_content,
1670 }) => {
1671 let safe_summary: Vec<String> = summary
1676 .into_iter()
1677 .filter_map(|part| match part {
1678 types::ContentPart::SummaryText { text } => Some(text),
1679 _ => None,
1680 })
1681 .collect();
1682 tracing::debug!(
1683 encrypted_len = encrypted_content.as_ref().map(|s| s.len()).unwrap_or(0),
1684 summary_segments = safe_summary.len(),
1685 "OpenResponses: received reasoning item"
1686 );
1687 LlmStreamEvent::ReasonItem {
1688 provider: "openai".to_string(),
1689 model: Some(model.clone()),
1690 item_id: id,
1691 encrypted_content,
1692 summary: safe_summary,
1693 token_count: None,
1694 }
1695 }
1696 _ => LlmStreamEvent::TextDelta(String::new()),
1697 }
1698 }
1699
1700 StreamingEvent::ResponseCompleted { response, .. }
1701 | StreamingEvent::ResponseIncomplete { response, .. } => {
1702 if let Some(usage) = &response.usage {
1704 *input_tokens.lock().unwrap() = usage.input_tokens;
1705 *output_tokens.lock().unwrap() = usage.output_tokens;
1706 if let Some(details) = &usage.input_tokens_details {
1707 *cache_read_tokens.lock().unwrap() = Some(details.cached_tokens);
1708 }
1709 }
1710
1711 let reason = match response.status {
1712 types::ResponseStatus::Completed => {
1713 let existing = finish_reason.lock().unwrap().clone();
1714 existing.unwrap_or_else(|| "stop".to_string())
1715 }
1716 types::ResponseStatus::Failed => {
1717 tracing::warn!(
1718 response_id = %response.id,
1719 error = ?response.error,
1720 "OpenResponsesDriver: response completed with 'failed' status"
1721 );
1722 "error".to_string()
1723 }
1724 types::ResponseStatus::Cancelled => "cancelled".to_string(),
1725 types::ResponseStatus::Incomplete => response
1726 .incomplete_details
1727 .as_ref()
1728 .map(|details| match details.reason.as_str() {
1729 "max_output_tokens" | "max_tokens" => "length",
1730 other => other,
1731 })
1732 .unwrap_or("stop")
1733 .to_string(),
1734 _ => "stop".to_string(),
1735 };
1736
1737 let phase = response.output.iter().rev().find_map(|item| {
1740 if let types::OutputItem::Message { phase, .. } = item {
1741 phase.clone()
1742 } else {
1743 None
1744 }
1745 });
1746
1747 let input = *input_tokens.lock().unwrap();
1748 let output = *output_tokens.lock().unwrap();
1749 let cached = *cache_read_tokens.lock().unwrap();
1750 let provider_cost_usd = response.usage.as_ref().and_then(|u| u.cost);
1751
1752 LlmStreamEvent::Done(Box::new(LlmCompletionMetadata {
1753 total_tokens: Some(input + output),
1756 prompt_tokens: Some(disjoint_prompt_tokens(input, cached)),
1757 completion_tokens: Some(output),
1758 cache_read_tokens: cached,
1759 cache_creation_tokens: None,
1760 provider_cost_usd,
1761 model: Some(model),
1762 finish_reason: Some(reason),
1763 retry_metadata: retry_metadata.map(|arc| (*arc).clone()),
1764 response_id: Some(response.id),
1765 phase,
1766 }))
1767 }
1768
1769 StreamingEvent::Error { error, .. } => {
1770 tracing::warn!(
1771 error_code = error.code.as_deref().unwrap_or("none"),
1772 error_message = %error.message,
1773 "OpenResponsesDriver: received streaming error event from provider"
1774 );
1775 LlmStreamEvent::Error(crate::driver_registry::LlmStreamError::provider(
1776 error.code,
1777 None,
1778 error.message,
1779 ))
1780 }
1781
1782 StreamingEvent::ResponseFailed { response, .. } => {
1783 let error = response.error.unwrap_or(types::Error {
1784 code: "processing_error".to_string(),
1785 message: "The provider failed while processing the response".to_string(),
1786 });
1787 tracing::warn!(
1788 response_id = %response.id,
1789 error_code = %error.code,
1790 error_message = %error.message,
1791 "OpenResponsesDriver: response failed in stream"
1792 );
1793 LlmStreamEvent::Error(crate::driver_registry::LlmStreamError::provider(
1794 Some(error.code),
1795 None,
1796 error.message,
1797 ))
1798 }
1799
1800 StreamingEvent::RefusalDelta { delta, .. } => {
1801 LlmStreamEvent::Error(format!("Model refused: {}", delta).into())
1803 }
1804
1805 _ => LlmStreamEvent::TextDelta(String::new()),
1807 }
1808}
1809
1810#[derive(Debug, Clone, Serialize)]
1820pub struct CompactRequest {
1821 pub model: String,
1823 #[serde(skip_serializing_if = "Vec::is_empty")]
1825 pub input: Vec<CompactInputItem>,
1826 #[serde(skip_serializing_if = "Option::is_none")]
1828 pub previous_response_id: Option<String>,
1829 #[serde(skip_serializing_if = "Option::is_none")]
1831 pub instructions: Option<String>,
1832}
1833
1834#[derive(Debug, Clone, Serialize, Deserialize)]
1839#[serde(tag = "type")]
1840pub enum CompactInputItem {
1841 #[serde(rename = "message")]
1843 Message {
1844 role: String,
1845 content: CompactContent,
1846 },
1847 #[serde(rename = "function_call")]
1849 FunctionCall {
1850 call_id: String,
1851 name: String,
1852 arguments: String,
1853 },
1854 #[serde(rename = "function_call_output")]
1856 FunctionCallOutput { call_id: String, output: String },
1857 #[serde(rename = "compaction")]
1859 Compaction { encrypted_content: String },
1860}
1861
1862impl From<&CompactOutputItem> for CompactInputItem {
1863 fn from(item: &CompactOutputItem) -> Self {
1864 match item {
1865 CompactOutputItem::Message { role, content } => Self::Message {
1866 role: role.clone(),
1867 content: content.clone(),
1868 },
1869 CompactOutputItem::Compaction { encrypted_content } => Self::Compaction {
1870 encrypted_content: encrypted_content.clone(),
1871 },
1872 }
1873 }
1874}
1875
1876#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1878#[serde(untagged)]
1879pub enum CompactContent {
1880 Text(String),
1882 Parts(Vec<CompactContentPart>),
1884}
1885
1886#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1888#[serde(tag = "type")]
1889pub enum CompactContentPart {
1890 #[serde(rename = "input_text")]
1892 InputText { text: String },
1893 #[serde(rename = "input_image")]
1895 InputImage { image_url: String },
1896}
1897
1898#[derive(Debug, Clone, Deserialize)]
1900pub struct CompactResponse {
1901 pub output: Vec<CompactOutputItem>,
1903 pub usage: Option<CompactUsage>,
1905}
1906
1907#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1909#[serde(tag = "type")]
1910pub enum CompactOutputItem {
1911 #[serde(rename = "message")]
1913 Message {
1914 role: String,
1915 content: CompactContent,
1916 },
1917 #[serde(rename = "compaction")]
1919 Compaction {
1920 encrypted_content: String,
1922 },
1923}
1924
1925#[derive(Debug, Clone, Deserialize)]
1927pub struct CompactUsage {
1928 pub input_tokens: Option<u32>,
1930 pub output_tokens: Option<u32>,
1932 pub total_tokens: Option<u32>,
1934}
1935
1936impl CompactInputItem {
1941 pub fn from_llm_message(msg: &LlmMessage) -> Vec<Self> {
1946 let mut items = Vec::new();
1947
1948 let role = match msg.role {
1949 LlmMessageRole::System => "developer",
1950 LlmMessageRole::User => "user",
1951 LlmMessageRole::Assistant => "assistant",
1952 LlmMessageRole::Tool => "tool",
1953 };
1954
1955 if msg.role == LlmMessageRole::Tool
1957 && let Some(tool_call_id) = &msg.tool_call_id
1958 {
1959 let output = match &msg.content {
1960 LlmMessageContent::Text(text) => text.clone(),
1961 LlmMessageContent::Parts(parts) => parts
1962 .iter()
1963 .filter_map(|p| match p {
1964 LlmContentPart::Text { text } => Some(text.clone()),
1965 _ => None,
1966 })
1967 .collect::<Vec<_>>()
1968 .join(""),
1969 };
1970 items.push(CompactInputItem::FunctionCallOutput {
1971 call_id: tool_call_id.clone(),
1972 output,
1973 });
1974 return items;
1975 }
1976
1977 let content = Self::content_from_llm_message(msg);
1979 let has_content = match &content {
1980 CompactContent::Text(t) => !t.is_empty(),
1981 CompactContent::Parts(p) => !p.is_empty(),
1982 };
1983
1984 if has_content || msg.tool_calls.is_none() {
1985 items.push(CompactInputItem::Message {
1986 role: role.to_string(),
1987 content,
1988 });
1989 }
1990
1991 if msg.role == LlmMessageRole::Assistant
1993 && let Some(tool_calls) = &msg.tool_calls
1994 {
1995 for tc in tool_calls {
1996 items.push(CompactInputItem::FunctionCall {
1997 call_id: tc.id.clone(),
1998 name: tc.name.clone(),
1999 arguments: tc.arguments.to_string(),
2000 });
2001 }
2002 }
2003
2004 items
2005 }
2006
2007 fn content_from_llm_message(msg: &LlmMessage) -> CompactContent {
2009 match &msg.content {
2010 LlmMessageContent::Text(text) => CompactContent::Text(text.clone()),
2011 LlmMessageContent::Parts(parts) => {
2012 let compact_parts: Vec<CompactContentPart> = parts
2013 .iter()
2014 .filter_map(|part| match part {
2015 LlmContentPart::Text { text } => {
2016 Some(CompactContentPart::InputText { text: text.clone() })
2017 }
2018 LlmContentPart::Image { url } => {
2019 Some(CompactContentPart::InputImage {
2021 image_url: url.clone(),
2022 })
2023 }
2024 LlmContentPart::Audio { .. } => None, })
2026 .collect();
2027 if compact_parts.len() == 1
2028 && let CompactContentPart::InputText { text } = &compact_parts[0]
2029 {
2030 return CompactContent::Text(text.clone());
2031 }
2032 CompactContent::Parts(compact_parts)
2033 }
2034 }
2035 }
2036}
2037
2038pub fn messages_to_compact_input(messages: &[LlmMessage]) -> Vec<CompactInputItem> {
2040 messages
2041 .iter()
2042 .flat_map(CompactInputItem::from_llm_message)
2043 .collect()
2044}
2045
2046#[derive(Debug, Clone, Serialize)]
2051struct ResponsesRequest {
2052 model: String,
2053 input: Vec<ResponsesInputItem>,
2054 #[serde(skip_serializing_if = "Option::is_none")]
2055 instructions: Option<String>,
2056 #[serde(skip_serializing_if = "Option::is_none")]
2057 previous_response_id: Option<String>,
2058 #[serde(skip_serializing_if = "Option::is_none")]
2059 temperature: Option<f32>,
2060 #[serde(skip_serializing_if = "Option::is_none")]
2061 max_output_tokens: Option<u32>,
2062 stream: bool,
2063 #[serde(skip_serializing_if = "Option::is_none")]
2064 tools: Option<Vec<ResponsesTool>>,
2065 #[serde(skip_serializing_if = "Option::is_none")]
2066 reasoning: Option<ResponsesReasoning>,
2067 #[serde(skip_serializing_if = "Option::is_none")]
2070 metadata: Option<std::collections::HashMap<String, String>>,
2071 #[serde(skip_serializing_if = "Option::is_none")]
2072 prompt_cache_key: Option<String>,
2073 #[serde(skip_serializing_if = "Option::is_none")]
2076 parallel_tool_calls: Option<bool>,
2077 #[serde(skip_serializing_if = "Option::is_none")]
2080 service_tier: Option<String>,
2081 #[serde(skip_serializing_if = "Option::is_none")]
2084 text: Option<ResponsesText>,
2085}
2086
2087#[derive(Debug, Clone, Serialize)]
2090struct ResponsesText {
2091 #[serde(skip_serializing_if = "Option::is_none")]
2092 verbosity: Option<String>,
2093}
2094
2095#[derive(Debug, Clone, Serialize)]
2096struct ResponsesReasoning {
2097 effort: String,
2098 summary: String,
2101}
2102
2103#[derive(Debug, Clone, Serialize)]
2104#[serde(untagged)]
2105enum ResponsesInputItem {
2106 Message {
2107 r#type: String,
2108 role: String,
2109 content: ResponsesContent,
2110 #[serde(skip_serializing_if = "Option::is_none")]
2114 phase: Option<String>,
2115 },
2116 FunctionCall {
2117 r#type: String,
2118 call_id: String,
2119 name: String,
2120 arguments: String,
2121 },
2122 FunctionCallOutput {
2123 r#type: String,
2124 call_id: String,
2125 output: String,
2126 },
2127 Reasoning {
2137 r#type: String,
2138 id: String,
2140 encrypted_content: String,
2142 },
2143 Compaction {
2145 r#type: String,
2146 encrypted_content: String,
2147 },
2148}
2149
2150impl From<&CompactOutputItem> for ResponsesInputItem {
2151 fn from(item: &CompactOutputItem) -> Self {
2152 match item {
2153 CompactOutputItem::Message { role, content } => Self::Message {
2154 r#type: "message".to_string(),
2155 role: role.clone(),
2156 content: match content {
2157 CompactContent::Text(text) => ResponsesContent::Text(text.clone()),
2158 CompactContent::Parts(parts) => ResponsesContent::Parts(
2159 parts
2160 .iter()
2161 .map(|part| match part {
2162 CompactContentPart::InputText { text } => {
2163 ResponsesContentPart::InputText {
2164 r#type: "input_text".to_string(),
2165 text: text.clone(),
2166 }
2167 }
2168 CompactContentPart::InputImage { image_url } => {
2169 ResponsesContentPart::InputImage {
2170 r#type: "input_image".to_string(),
2171 image_url: image_url.clone(),
2172 }
2173 }
2174 })
2175 .collect(),
2176 ),
2177 },
2178 phase: None,
2179 },
2180 CompactOutputItem::Compaction { encrypted_content } => Self::Compaction {
2181 r#type: "compaction".to_string(),
2182 encrypted_content: encrypted_content.clone(),
2183 },
2184 }
2185 }
2186}
2187
2188#[derive(Debug, Clone, Serialize, Deserialize)]
2189#[serde(untagged)]
2190enum ResponsesContent {
2191 Text(String),
2192 Parts(Vec<ResponsesContentPart>),
2193}
2194
2195#[derive(Debug, Clone, Serialize, Deserialize)]
2197#[serde(untagged)]
2198#[allow(clippy::enum_variant_names)]
2199enum ResponsesContentPart {
2200 InputText {
2201 r#type: String,
2202 text: String,
2203 },
2204 InputImage {
2205 r#type: String,
2206 image_url: String,
2207 },
2208 InputAudio {
2209 r#type: String,
2210 input_audio: ResponsesInputAudio,
2211 },
2212}
2213
2214#[derive(Debug, Clone, Serialize, Deserialize)]
2215struct ResponsesInputAudio {
2216 data: String,
2217 format: String,
2218}
2219
2220#[derive(Debug, Clone, Serialize)]
2221#[serde(untagged)]
2222enum ResponsesTool {
2223 Function {
2225 r#type: String,
2226 name: String,
2227 description: String,
2228 parameters: Value,
2229 #[serde(skip_serializing_if = "Option::is_none")]
2230 defer_loading: Option<bool>,
2231 },
2232 Namespace {
2234 r#type: String,
2235 name: String,
2236 description: String,
2237 tools: Vec<ResponsesTool>,
2238 },
2239 ToolSearch { r#type: String },
2241}
2242
2243#[cfg(test)]
2248mod tests {
2249 use super::*;
2250
2251 #[test]
2252 fn test_driver_is_wire_only() {
2253 let driver = OpenResponsesProtocolChatDriver::new();
2254 assert!(format!("{:?}", driver).contains("OpenResponsesProtocolChatDriver"));
2255 }
2256
2257 #[test]
2258 fn test_request_serialization() {
2259 let request = ResponsesRequest {
2260 text: None,
2261 service_tier: None,
2262 model: "gpt-4o".to_string(),
2263 input: vec![ResponsesInputItem::Message {
2264 r#type: "message".to_string(),
2265 role: "user".to_string(),
2266 content: ResponsesContent::Text("Hello".to_string()),
2267 phase: None,
2268 }],
2269 instructions: Some("You are helpful".to_string()),
2270 previous_response_id: None,
2271 temperature: None,
2272 max_output_tokens: None,
2273 stream: true,
2274 tools: None,
2275 reasoning: None,
2276 metadata: None,
2277 prompt_cache_key: None,
2278 parallel_tool_calls: None,
2279 };
2280
2281 let json = serde_json::to_value(&request).unwrap();
2282 assert_eq!(json["model"], "gpt-4o");
2283 assert_eq!(json["stream"], true);
2284 assert_eq!(json["instructions"], "You are helpful");
2285 assert!(json["input"].is_array());
2286 }
2287
2288 #[test]
2289 fn test_request_with_reasoning() {
2290 let request = ResponsesRequest {
2291 text: None,
2292 service_tier: None,
2293 model: "o3".to_string(),
2294 input: vec![ResponsesInputItem::Message {
2295 r#type: "message".to_string(),
2296 role: "user".to_string(),
2297 content: ResponsesContent::Text("Think about this".to_string()),
2298 phase: None,
2299 }],
2300 instructions: None,
2301 previous_response_id: None,
2302 temperature: None,
2303 max_output_tokens: None,
2304 stream: true,
2305 tools: None,
2306 reasoning: Some(ResponsesReasoning {
2307 effort: "high".to_string(),
2308 summary: "detailed".to_string(),
2309 }),
2310 metadata: None,
2311 prompt_cache_key: None,
2312 parallel_tool_calls: None,
2313 };
2314
2315 let json = serde_json::to_value(&request).unwrap();
2316 assert_eq!(json["reasoning"]["effort"], "high");
2317 assert_eq!(json["reasoning"]["summary"], "detailed");
2318 }
2319
2320 #[test]
2321 fn test_request_with_metadata() {
2322 let mut metadata = std::collections::HashMap::new();
2323 metadata.insert("session_id".to_string(), "session_abc123".to_string());
2324 metadata.insert("agent_id".to_string(), "agent_xyz789".to_string());
2325
2326 let request = ResponsesRequest {
2327 text: None,
2328 service_tier: None,
2329 model: "gpt-4o".to_string(),
2330 input: vec![ResponsesInputItem::Message {
2331 r#type: "message".to_string(),
2332 role: "user".to_string(),
2333 content: ResponsesContent::Text("Hello".to_string()),
2334 phase: None,
2335 }],
2336 instructions: None,
2337 previous_response_id: None,
2338 temperature: None,
2339 max_output_tokens: None,
2340 stream: true,
2341 tools: None,
2342 reasoning: None,
2343 metadata: Some(metadata),
2344 prompt_cache_key: None,
2345 parallel_tool_calls: None,
2346 };
2347
2348 let json = serde_json::to_value(&request).unwrap();
2349 assert_eq!(json["metadata"]["session_id"], "session_abc123");
2350 assert_eq!(json["metadata"]["agent_id"], "agent_xyz789");
2351 }
2352
2353 #[test]
2356 fn test_request_serializes_parallel_tool_calls() {
2357 let make = |flag: Option<bool>| ResponsesRequest {
2358 text: None,
2359 service_tier: None,
2360 model: "gpt-5.4".to_string(),
2361 input: vec![ResponsesInputItem::Message {
2362 r#type: "message".to_string(),
2363 role: "user".to_string(),
2364 content: ResponsesContent::Text("Hello".to_string()),
2365 phase: None,
2366 }],
2367 instructions: None,
2368 previous_response_id: None,
2369 temperature: None,
2370 max_output_tokens: None,
2371 stream: true,
2372 tools: None,
2373 reasoning: None,
2374 metadata: None,
2375 prompt_cache_key: None,
2376 parallel_tool_calls: flag,
2377 };
2378
2379 let json = serde_json::to_value(make(None)).unwrap();
2381 assert!(json.get("parallel_tool_calls").is_none());
2382
2383 let json = serde_json::to_value(make(Some(true))).unwrap();
2385 assert_eq!(json["parallel_tool_calls"], true);
2386
2387 let json = serde_json::to_value(make(Some(false))).unwrap();
2389 assert_eq!(json["parallel_tool_calls"], false);
2390 }
2391
2392 #[test]
2395 fn test_request_serializes_service_tier() {
2396 let make = |tier: Option<&str>| ResponsesRequest {
2397 service_tier: tier.map(str::to_string),
2398 model: "gpt-5.4".to_string(),
2399 input: vec![ResponsesInputItem::Message {
2400 r#type: "message".to_string(),
2401 role: "user".to_string(),
2402 content: ResponsesContent::Text("Hello".to_string()),
2403 phase: None,
2404 }],
2405 instructions: None,
2406 previous_response_id: None,
2407 temperature: None,
2408 max_output_tokens: None,
2409 stream: true,
2410 tools: None,
2411 reasoning: None,
2412 metadata: None,
2413 prompt_cache_key: None,
2414 parallel_tool_calls: None,
2415 text: None,
2416 };
2417
2418 let json = serde_json::to_value(make(None)).unwrap();
2419 assert!(json.get("service_tier").is_none());
2420
2421 let json = serde_json::to_value(make(Some("priority"))).unwrap();
2422 assert_eq!(json["service_tier"], "priority");
2423
2424 let json = serde_json::to_value(make(Some("flex"))).unwrap();
2425 assert_eq!(json["service_tier"], "flex");
2426 }
2427
2428 #[test]
2431 fn test_request_serializes_verbosity() {
2432 let make = |verbosity: Option<&str>| ResponsesRequest {
2433 service_tier: None,
2434 text: verbosity.map(|v| ResponsesText {
2435 verbosity: Some(v.to_string()),
2436 }),
2437 model: "gpt-5.6-sol".to_string(),
2438 input: vec![ResponsesInputItem::Message {
2439 r#type: "message".to_string(),
2440 role: "user".to_string(),
2441 content: ResponsesContent::Text("Hello".to_string()),
2442 phase: None,
2443 }],
2444 instructions: None,
2445 previous_response_id: None,
2446 temperature: None,
2447 max_output_tokens: None,
2448 stream: true,
2449 tools: None,
2450 reasoning: None,
2451 metadata: None,
2452 prompt_cache_key: None,
2453 parallel_tool_calls: None,
2454 };
2455
2456 let json = serde_json::to_value(make(None)).unwrap();
2457 assert!(json.get("text").is_none());
2458
2459 let json = serde_json::to_value(make(Some("low"))).unwrap();
2460 assert_eq!(json["text"]["verbosity"], "low");
2461
2462 let json = serde_json::to_value(make(Some("high"))).unwrap();
2463 assert_eq!(json["text"]["verbosity"], "high");
2464 }
2465
2466 #[test]
2467 fn test_build_prompt_cache_key_when_enabled() {
2468 let mut metadata = std::collections::HashMap::new();
2469 metadata.insert("session_id".to_string(), "session_abc123".to_string());
2470 let config = LlmCallConfig {
2471 speed: None,
2472 verbosity: None,
2473 model: "gpt-5.4".to_string(),
2474 temperature: None,
2475 max_tokens: None,
2476 tools: vec![],
2477 reasoning_effort: None,
2478 metadata,
2479 previous_response_id: None,
2480 provider_opaque_context: None,
2481 tool_search: None,
2482 prompt_cache: Some(crate::driver_registry::PromptCacheConfig {
2483 enabled: true,
2484 strategy: crate::driver_registry::PromptCacheStrategy::Auto,
2485 gemini_cached_content: None,
2486 }),
2487 openrouter_routing: None,
2488 parallel_tool_calls: None,
2489 volatile_suffix_len: 0,
2490 };
2491 let input = vec![ResponsesInputItem::Message {
2492 r#type: "message".to_string(),
2493 role: "user".to_string(),
2494 content: ResponsesContent::Text("Hello".to_string()),
2495 phase: None,
2496 }];
2497
2498 let key = OpenResponsesProtocolChatDriver::build_prompt_cache_key(
2499 &config,
2500 &input,
2501 &Some("You are helpful".to_string()),
2502 &None,
2503 );
2504
2505 assert!(key.is_some());
2506 assert!(key.unwrap().starts_with("everruns:"));
2507 }
2508
2509 #[test]
2510 fn test_build_prompt_cache_key_ignores_changing_input() {
2511 let mut metadata = std::collections::HashMap::new();
2512 metadata.insert("session_id".to_string(), "session_abc123".to_string());
2513 let config = LlmCallConfig {
2514 speed: None,
2515 verbosity: None,
2516 model: "gpt-5.4".to_string(),
2517 temperature: None,
2518 max_tokens: None,
2519 tools: vec![],
2520 reasoning_effort: None,
2521 metadata,
2522 previous_response_id: None,
2523 provider_opaque_context: None,
2524 tool_search: None,
2525 prompt_cache: Some(crate::driver_registry::PromptCacheConfig {
2526 enabled: true,
2527 strategy: crate::driver_registry::PromptCacheStrategy::Auto,
2528 gemini_cached_content: None,
2529 }),
2530 openrouter_routing: None,
2531 parallel_tool_calls: None,
2532 volatile_suffix_len: 0,
2533 };
2534 let first_input = vec![ResponsesInputItem::Message {
2535 r#type: "message".to_string(),
2536 role: "user".to_string(),
2537 content: ResponsesContent::Text("first turn".to_string()),
2538 phase: None,
2539 }];
2540 let second_input = vec![ResponsesInputItem::Message {
2541 r#type: "message".to_string(),
2542 role: "user".to_string(),
2543 content: ResponsesContent::Text("second turn with different text".to_string()),
2544 phase: None,
2545 }];
2546
2547 let first = OpenResponsesProtocolChatDriver::build_prompt_cache_key(
2548 &config,
2549 &first_input,
2550 &Some("You are helpful".to_string()),
2551 &None,
2552 );
2553 let second = OpenResponsesProtocolChatDriver::build_prompt_cache_key(
2554 &config,
2555 &second_input,
2556 &Some("You are helpful".to_string()),
2557 &None,
2558 );
2559
2560 assert_eq!(first, second);
2561 }
2562
2563 #[test]
2564 fn test_build_prompt_cache_key_changes_with_cache_family() {
2565 let mut first_metadata = std::collections::HashMap::new();
2566 first_metadata.insert("session_id".to_string(), "session_abc123".to_string());
2567 let mut second_metadata = std::collections::HashMap::new();
2568 second_metadata.insert("session_id".to_string(), "session_xyz789".to_string());
2569 let make_config = |metadata| LlmCallConfig {
2570 speed: None,
2571 verbosity: None,
2572 model: "gpt-5.4".to_string(),
2573 temperature: None,
2574 max_tokens: None,
2575 tools: vec![],
2576 reasoning_effort: None,
2577 metadata,
2578 previous_response_id: None,
2579 provider_opaque_context: None,
2580 tool_search: None,
2581 prompt_cache: Some(crate::driver_registry::PromptCacheConfig {
2582 enabled: true,
2583 strategy: crate::driver_registry::PromptCacheStrategy::Auto,
2584 gemini_cached_content: None,
2585 }),
2586 openrouter_routing: None,
2587 parallel_tool_calls: None,
2588 volatile_suffix_len: 0,
2589 };
2590 let input = vec![ResponsesInputItem::Message {
2591 r#type: "message".to_string(),
2592 role: "user".to_string(),
2593 content: ResponsesContent::Text("same turn".to_string()),
2594 phase: None,
2595 }];
2596
2597 let first = OpenResponsesProtocolChatDriver::build_prompt_cache_key(
2598 &make_config(first_metadata),
2599 &input,
2600 &Some("You are helpful".to_string()),
2601 &None,
2602 );
2603 let second = OpenResponsesProtocolChatDriver::build_prompt_cache_key(
2604 &make_config(second_metadata),
2605 &input,
2606 &Some("You are helpful".to_string()),
2607 &None,
2608 );
2609
2610 assert_ne!(first, second);
2611 }
2612
2613 #[test]
2614 fn test_build_prompt_cache_key_stays_within_openai_limit() {
2615 let config = LlmCallConfig {
2616 speed: None,
2617 verbosity: None,
2618 model: "gpt-5.5".to_string(),
2619 temperature: None,
2620 max_tokens: None,
2621 tools: vec![],
2622 reasoning_effort: None,
2623 metadata: std::collections::HashMap::new(),
2624 previous_response_id: None,
2625 provider_opaque_context: None,
2626 tool_search: None,
2627 prompt_cache: Some(crate::driver_registry::PromptCacheConfig {
2628 enabled: true,
2629 strategy: crate::driver_registry::PromptCacheStrategy::Auto,
2630 gemini_cached_content: None,
2631 }),
2632 openrouter_routing: None,
2633 parallel_tool_calls: None,
2634 volatile_suffix_len: 0,
2635 };
2636 let input = vec![ResponsesInputItem::Message {
2637 r#type: "message".to_string(),
2638 role: "user".to_string(),
2639 content: ResponsesContent::Text("fetch chalyi.name for me".to_string()),
2640 phase: None,
2641 }];
2642
2643 let key = OpenResponsesProtocolChatDriver::build_prompt_cache_key(
2644 &config,
2645 &input,
2646 &Some("You are helpful".to_string()),
2647 &None,
2648 )
2649 .unwrap();
2650
2651 assert!(
2652 key.len() <= 64,
2653 "OpenAI prompt_cache_key limit is 64 characters, got {}",
2654 key.len()
2655 );
2656 }
2657
2658 #[test]
2659 fn test_function_call_output_serialization() {
2660 let item = ResponsesInputItem::FunctionCallOutput {
2661 r#type: "function_call_output".to_string(),
2662 call_id: "call_123".to_string(),
2663 output: r#"{"result": 42}"#.to_string(),
2664 };
2665
2666 let json = serde_json::to_value(&item).unwrap();
2667 assert_eq!(json["type"], "function_call_output");
2668 assert_eq!(json["call_id"], "call_123");
2669 assert_eq!(json["output"], r#"{"result": 42}"#);
2670 }
2671
2672 #[test]
2673 fn test_multipart_content_serialization() {
2674 let content = ResponsesContent::Parts(vec![
2675 ResponsesContentPart::InputText {
2676 r#type: "input_text".to_string(),
2677 text: "Look at this image".to_string(),
2678 },
2679 ResponsesContentPart::InputImage {
2680 r#type: "input_image".to_string(),
2681 image_url: "data:image/png;base64,abc123".to_string(),
2682 },
2683 ]);
2684
2685 let json = serde_json::to_value(&content).unwrap();
2686 assert!(json.is_array());
2687 assert_eq!(json[0]["type"], "input_text");
2688 assert_eq!(json[1]["type"], "input_image");
2689 }
2690
2691 #[test]
2692 fn test_tool_serialization() {
2693 let tool = ResponsesTool::Function {
2694 r#type: "function".to_string(),
2695 name: "get_weather".to_string(),
2696 description: "Get weather for a location".to_string(),
2697 parameters: json!({
2698 "type": "object",
2699 "properties": {
2700 "location": {"type": "string"}
2701 },
2702 "required": ["location"]
2703 }),
2704 defer_loading: None,
2705 };
2706
2707 let json = serde_json::to_value(&tool).unwrap();
2708 assert_eq!(json["type"], "function");
2709 assert_eq!(json["name"], "get_weather");
2710 assert!(json["parameters"]["properties"]["location"].is_object());
2711 }
2712
2713 #[test]
2714 fn test_build_input_extracts_system_as_instructions() {
2715 let messages = vec![
2716 LlmMessage::text(LlmMessageRole::System, "You are a helpful assistant"),
2717 LlmMessage::text(LlmMessageRole::User, "Hello"),
2718 ];
2719
2720 let (instructions, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
2721
2722 assert_eq!(
2723 instructions,
2724 Some("You are a helpful assistant".to_string())
2725 );
2726 assert_eq!(input.len(), 1); }
2728
2729 #[test]
2730 fn test_build_input_concatenates_multiple_system_messages() {
2731 let messages = vec![
2735 LlmMessage::text(LlmMessageRole::System, "You are a helpful assistant"),
2736 LlmMessage::text(LlmMessageRole::User, "Hello"),
2737 LlmMessage::text(
2738 LlmMessageRole::System,
2739 "[IMPORTANT: 3 earlier messages are NOT visible in this context.]",
2740 ),
2741 ];
2742
2743 let (instructions, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
2744
2745 assert_eq!(
2746 instructions,
2747 Some(
2748 "You are a helpful assistant\n\n[IMPORTANT: 3 earlier messages are NOT visible in this context.]"
2749 .to_string()
2750 )
2751 );
2752 assert_eq!(input.len(), 1); }
2754
2755 #[test]
2756 fn test_convert_role() {
2757 assert_eq!(
2758 OpenResponsesProtocolChatDriver::convert_role(&LlmMessageRole::System),
2759 "developer"
2760 );
2761 assert_eq!(
2762 OpenResponsesProtocolChatDriver::convert_role(&LlmMessageRole::User),
2763 "user"
2764 );
2765 assert_eq!(
2766 OpenResponsesProtocolChatDriver::convert_role(&LlmMessageRole::Assistant),
2767 "assistant"
2768 );
2769 assert_eq!(
2770 OpenResponsesProtocolChatDriver::convert_role(&LlmMessageRole::Tool),
2771 "tool"
2772 );
2773 }
2774
2775 #[test]
2776 fn test_function_call_serialization() {
2777 let item = ResponsesInputItem::FunctionCall {
2778 r#type: "function_call".to_string(),
2779 call_id: "call_abc123".to_string(),
2780 name: "get_current_time".to_string(),
2781 arguments: r#"{"timezone":"UTC"}"#.to_string(),
2782 };
2783
2784 let json = serde_json::to_value(&item).unwrap();
2785 assert_eq!(json["type"], "function_call");
2786 assert_eq!(json["call_id"], "call_abc123");
2787 assert_eq!(json["name"], "get_current_time");
2788 assert_eq!(json["arguments"], r#"{"timezone":"UTC"}"#);
2789 }
2790
2791 #[test]
2792 fn test_build_input_with_tool_calls() {
2793 use crate::tool_types::ToolCall;
2794
2795 let messages = vec![
2800 LlmMessage::text(LlmMessageRole::System, "You are helpful"),
2801 LlmMessage::text(LlmMessageRole::User, "What time is it?"),
2802 LlmMessage {
2803 role: LlmMessageRole::Assistant,
2804 content: LlmMessageContent::Text(String::new()),
2805 tool_calls: Some(vec![ToolCall {
2806 id: "call_xyz789".to_string(),
2807 name: "get_current_time".to_string(),
2808 arguments: json!({"timezone": "UTC"}),
2809 }]),
2810 tool_call_id: None,
2811 phase: None,
2812 thinking: None,
2813 thinking_signature: None,
2814 },
2815 LlmMessage {
2816 role: LlmMessageRole::Tool,
2817 content: LlmMessageContent::Text("2025-01-19T10:30:00Z".to_string()),
2818 tool_calls: None,
2819 tool_call_id: Some("call_xyz789".to_string()),
2820 phase: None,
2821 thinking: None,
2822 thinking_signature: None,
2823 },
2824 ];
2825
2826 let (instructions, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
2827
2828 assert_eq!(instructions, Some("You are helpful".to_string()));
2830
2831 assert_eq!(input.len(), 3);
2833
2834 let json = serde_json::to_value(&input[1]).unwrap();
2836 assert_eq!(json["type"], "function_call");
2837 assert_eq!(json["call_id"], "call_xyz789");
2838 assert_eq!(json["name"], "get_current_time");
2839
2840 let json = serde_json::to_value(&input[2]).unwrap();
2842 assert_eq!(json["type"], "function_call_output");
2843 assert_eq!(json["call_id"], "call_xyz789");
2844 assert_eq!(json["output"], "2025-01-19T10:30:00Z");
2845 }
2846
2847 #[test]
2848 fn test_build_input_with_tool_calls_and_text() {
2849 use crate::tool_types::ToolCall;
2850
2851 let messages = vec![
2853 LlmMessage::text(LlmMessageRole::User, "What time is it?"),
2854 LlmMessage {
2855 role: LlmMessageRole::Assistant,
2856 content: LlmMessageContent::Text("Let me check the time for you.".to_string()),
2857 tool_calls: Some(vec![ToolCall {
2858 id: "call_abc".to_string(),
2859 name: "get_time".to_string(),
2860 arguments: json!({}),
2861 }]),
2862 tool_call_id: None,
2863 phase: None,
2864 thinking: None,
2865 thinking_signature: None,
2866 },
2867 ];
2868
2869 let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
2870
2871 assert_eq!(input.len(), 3);
2873
2874 let json = serde_json::to_value(&input[0]).unwrap();
2876 assert_eq!(json["role"], "user");
2877
2878 let json = serde_json::to_value(&input[1]).unwrap();
2880 assert_eq!(json["role"], "assistant");
2881
2882 let json = serde_json::to_value(&input[2]).unwrap();
2884 assert_eq!(json["type"], "function_call");
2885 assert_eq!(json["call_id"], "call_abc");
2886 }
2887
2888 #[test]
2903 fn openresponses_requests_should_not_mix_previous_response_id_with_full_transcript() {
2904 use crate::tool_types::ToolCall;
2905
2906 let messages = vec![
2910 LlmMessage::text(LlmMessageRole::System, "You are helpful"),
2911 LlmMessage::text(LlmMessageRole::User, "What time is it?"),
2912 LlmMessage {
2913 role: LlmMessageRole::Assistant,
2914 content: LlmMessageContent::Text("Let me check.".to_string()),
2915 tool_calls: Some(vec![ToolCall {
2916 id: "call_xyz789".to_string(),
2917 name: "get_current_time".to_string(),
2918 arguments: json!({"timezone": "UTC"}),
2919 }]),
2920 tool_call_id: None,
2921 phase: None,
2922 thinking: None,
2923 thinking_signature: None,
2924 },
2925 LlmMessage {
2926 role: LlmMessageRole::Tool,
2927 content: LlmMessageContent::Text("2025-01-19T10:30:00Z".to_string()),
2928 tool_calls: None,
2929 tool_call_id: Some("call_xyz789".to_string()),
2930 phase: None,
2931 thinking: None,
2932 thinking_signature: None,
2933 },
2934 ];
2935
2936 let (instructions, full_input) =
2938 OpenResponsesProtocolChatDriver::build_input(&messages, false);
2939
2940 assert!(
2943 full_input.len() > 1,
2944 "sanity: full transcript has multi items"
2945 );
2946
2947 let delta = compute_delta_input_items(full_input);
2950
2951 assert_eq!(
2953 delta.len(),
2954 1,
2955 "stateful continuation must only send delta items; got {} items",
2956 delta.len()
2957 );
2958 let json = serde_json::to_value(&delta[0]).unwrap();
2959 assert_eq!(json["type"], "function_call_output");
2960 assert_eq!(json["call_id"], "call_xyz789");
2961 assert_eq!(json["output"], "2025-01-19T10:30:00Z");
2962
2963 assert_eq!(instructions, Some("You are helpful".to_string()));
2966 }
2967
2968 #[test]
2973 fn compute_delta_keeps_tail_after_assistant_message() {
2974 let items = vec![
2975 ResponsesInputItem::Message {
2976 r#type: "message".to_string(),
2977 role: "user".to_string(),
2978 content: ResponsesContent::Text("hi".to_string()),
2979 phase: None,
2980 },
2981 ResponsesInputItem::Message {
2982 r#type: "message".to_string(),
2983 role: "assistant".to_string(),
2984 content: ResponsesContent::Text("hello".to_string()),
2985 phase: None,
2986 },
2987 ResponsesInputItem::Message {
2988 r#type: "message".to_string(),
2989 role: "user".to_string(),
2990 content: ResponsesContent::Text("follow up".to_string()),
2991 phase: None,
2992 },
2993 ];
2994 let trimmed = compute_delta_input_items(items);
2995 assert_eq!(trimmed.len(), 1);
2996 let json = serde_json::to_value(&trimmed[0]).unwrap();
2997 assert_eq!(json["role"], "user");
2998 assert_eq!(
2999 json["content"], "follow up",
3000 "trim keeps the fresh user message that arrived after the assistant turn"
3001 );
3002 }
3003
3004 #[test]
3008 fn compute_delta_keeps_tool_results_after_last_assistant_turn() {
3009 let items = vec![
3010 ResponsesInputItem::Message {
3011 r#type: "message".to_string(),
3012 role: "user".to_string(),
3013 content: ResponsesContent::Text("do two things".to_string()),
3014 phase: None,
3015 },
3016 ResponsesInputItem::Message {
3017 r#type: "message".to_string(),
3018 role: "assistant".to_string(),
3019 content: ResponsesContent::Text("ok".to_string()),
3020 phase: None,
3021 },
3022 ResponsesInputItem::FunctionCall {
3023 r#type: "function_call".to_string(),
3024 call_id: "call_a".to_string(),
3025 name: "tool_a".to_string(),
3026 arguments: "{}".to_string(),
3027 },
3028 ResponsesInputItem::FunctionCall {
3029 r#type: "function_call".to_string(),
3030 call_id: "call_b".to_string(),
3031 name: "tool_b".to_string(),
3032 arguments: "{}".to_string(),
3033 },
3034 ResponsesInputItem::FunctionCallOutput {
3035 r#type: "function_call_output".to_string(),
3036 call_id: "call_a".to_string(),
3037 output: "a result".to_string(),
3038 },
3039 ResponsesInputItem::FunctionCallOutput {
3040 r#type: "function_call_output".to_string(),
3041 call_id: "call_b".to_string(),
3042 output: "b result".to_string(),
3043 },
3044 ];
3045
3046 let trimmed = compute_delta_input_items(items);
3047
3048 assert_eq!(trimmed.len(), 2);
3051 for item in &trimmed {
3052 let json = serde_json::to_value(item).unwrap();
3053 assert_eq!(json["type"], "function_call_output");
3054 }
3055 }
3056
3057 #[test]
3060 fn compute_delta_allows_empty_input_for_stateful_continuation() {
3061 let trimmed = compute_delta_input_items(vec![]);
3062 assert!(trimmed.is_empty());
3063 }
3064
3065 #[test]
3068 fn compute_delta_keeps_all_items_when_no_assistant_turn_present() {
3069 let items = vec![
3070 ResponsesInputItem::Message {
3071 r#type: "message".to_string(),
3072 role: "user".to_string(),
3073 content: ResponsesContent::Text("one".to_string()),
3074 phase: None,
3075 },
3076 ResponsesInputItem::Message {
3077 r#type: "message".to_string(),
3078 role: "user".to_string(),
3079 content: ResponsesContent::Text("two".to_string()),
3080 phase: None,
3081 },
3082 ];
3083 let trimmed = compute_delta_input_items(items);
3084 assert_eq!(trimmed.len(), 2);
3085 }
3086
3087 #[test]
3089 fn compute_delta_drops_prior_reasoning_items() {
3090 let items = vec![
3091 ResponsesInputItem::Reasoning {
3092 r#type: "reasoning".to_string(),
3093 id: "rs_00000001".to_string(),
3094 encrypted_content: "encrypted-blob".to_string(),
3095 },
3096 ResponsesInputItem::Message {
3097 r#type: "message".to_string(),
3098 role: "assistant".to_string(),
3099 content: ResponsesContent::Text("prior".to_string()),
3100 phase: None,
3101 },
3102 ResponsesInputItem::FunctionCallOutput {
3103 r#type: "function_call_output".to_string(),
3104 call_id: "call_z".to_string(),
3105 output: "result".to_string(),
3106 },
3107 ];
3108 let trimmed = compute_delta_input_items(items);
3109 assert_eq!(trimmed.len(), 1);
3110 let json = serde_json::to_value(&trimmed[0]).unwrap();
3111 assert_eq!(json["type"], "function_call_output");
3112 }
3113
3114 fn sample_full_transcript_items() -> Vec<ResponsesInputItem> {
3124 vec![
3125 ResponsesInputItem::Message {
3126 r#type: "message".to_string(),
3127 role: "user".to_string(),
3128 content: ResponsesContent::Text("first request".to_string()),
3129 phase: None,
3130 },
3131 ResponsesInputItem::Message {
3132 r#type: "message".to_string(),
3133 role: "assistant".to_string(),
3134 content: ResponsesContent::Text("first reply".to_string()),
3135 phase: None,
3136 },
3137 ResponsesInputItem::Message {
3138 r#type: "message".to_string(),
3139 role: "user".to_string(),
3140 content: ResponsesContent::Text("follow-up".to_string()),
3141 phase: None,
3142 },
3143 ]
3144 }
3145
3146 #[test]
3147 fn finalize_input_skips_trim_when_previous_response_id_is_none() {
3148 let items = sample_full_transcript_items();
3149 let original_len = items.len();
3150 let out = finalize_input_for_request(items, &None);
3151 assert_eq!(
3152 out.len(),
3153 original_len,
3154 "stateless mode keeps the full transcript so the model has context"
3155 );
3156 }
3157
3158 #[test]
3159 fn finalize_input_drops_locally_orphaned_tool_output_without_previous_response_id() {
3160 let items = vec![
3161 ResponsesInputItem::Message {
3162 r#type: "message".to_string(),
3163 role: "user".to_string(),
3164 content: ResponsesContent::Text("fresh".to_string()),
3165 phase: None,
3166 },
3167 ResponsesInputItem::FunctionCallOutput {
3168 r#type: "function_call_output".to_string(),
3169 call_id: "call_trimmed".to_string(),
3170 output: "result".to_string(),
3171 },
3172 ];
3173
3174 let out = finalize_input_for_request(items, &None);
3175
3176 assert_eq!(out.len(), 1);
3177 let json = serde_json::to_value(&out[0]).unwrap();
3178 assert_eq!(json["type"], "message");
3179 }
3180
3181 #[test]
3182 fn finalize_input_keeps_tool_output_with_previous_response_id_even_without_local_call() {
3183 let items = vec![
3184 ResponsesInputItem::FunctionCallOutput {
3185 r#type: "function_call_output".to_string(),
3186 call_id: "call_server_side".to_string(),
3187 output: "stateful result".to_string(),
3188 },
3189 ResponsesInputItem::Message {
3190 r#type: "message".to_string(),
3191 role: "user".to_string(),
3192 content: ResponsesContent::Text("follow-up".to_string()),
3193 phase: None,
3194 },
3195 ];
3196
3197 let out = finalize_input_for_request(items, &Some("resp_prev_42".to_string()));
3198
3199 assert_eq!(out.len(), 2);
3200 let json = serde_json::to_value(&out[0]).unwrap();
3201 assert_eq!(json["type"], "function_call_output");
3202 assert_eq!(json["call_id"], "call_server_side");
3203 }
3204
3205 #[test]
3206 fn finalize_input_trims_when_previous_response_id_is_set() {
3207 let items = sample_full_transcript_items();
3208 let out = finalize_input_for_request(items, &Some("resp_prev_42".to_string()));
3209 assert_eq!(
3210 out.len(),
3211 1,
3212 "stateful continuation must drop everything up to and including the prior assistant message"
3213 );
3214 let json = serde_json::to_value(&out[0]).unwrap();
3215 assert_eq!(json["type"], "message");
3216 assert_eq!(json["role"], "user");
3217 let txt = json["content"].as_str().unwrap_or("");
3219 assert_eq!(txt, "follow-up");
3220 }
3221
3222 #[test]
3223 fn finalize_input_allows_empty_input_with_previous_response_id() {
3224 let out = finalize_input_for_request(vec![], &Some("resp_anything".to_string()));
3225 assert!(
3226 out.is_empty(),
3227 "empty delta is valid — the provider can resume purely from the response id"
3228 );
3229 }
3230
3231 fn function_call(call_id: &str, name: &str) -> ResponsesInputItem {
3240 ResponsesInputItem::FunctionCall {
3241 r#type: "function_call".to_string(),
3242 call_id: call_id.to_string(),
3243 name: name.to_string(),
3244 arguments: "{}".to_string(),
3245 }
3246 }
3247
3248 fn function_call_output(call_id: &str) -> ResponsesInputItem {
3249 ResponsesInputItem::FunctionCallOutput {
3250 r#type: "function_call_output".to_string(),
3251 call_id: call_id.to_string(),
3252 output: "result".to_string(),
3253 }
3254 }
3255
3256 fn user_message(text: &str) -> ResponsesInputItem {
3257 ResponsesInputItem::Message {
3258 r#type: "message".to_string(),
3259 role: "user".to_string(),
3260 content: ResponsesContent::Text(text.to_string()),
3261 phase: None,
3262 }
3263 }
3264
3265 #[test]
3266 fn finalize_input_drops_dangling_function_call_without_previous_response_id() {
3267 let items = vec![
3271 user_message("fresh"),
3272 function_call("call_pHJNxIuwzLppFsQK5nJrDOpZ", "read_file"),
3273 ];
3274
3275 let out = finalize_input_for_request(items, &None);
3276
3277 assert_eq!(out.len(), 1);
3278 assert!(
3279 unpaired_function_call_ids(&out).is_empty(),
3280 "the dangling function_call must be dropped"
3281 );
3282 let json = serde_json::to_value(&out[0]).unwrap();
3283 assert_eq!(json["type"], "message");
3284 }
3285
3286 #[test]
3287 fn finalize_input_preserves_paired_function_call_and_output() {
3288 let items = vec![
3289 user_message("what time is it?"),
3290 function_call("call_ok", "get_current_time"),
3291 function_call_output("call_ok"),
3292 ];
3293
3294 let out = finalize_input_for_request(items, &None);
3295
3296 assert_eq!(out.len(), 3, "an intact call/output pair must survive");
3297 assert!(unpaired_function_call_ids(&out).is_empty());
3298 }
3299
3300 #[test]
3301 fn finalize_input_compaction_drops_only_the_dangling_old_call() {
3302 let mut items = vec![
3307 user_message("long session"),
3308 function_call("call_old", "read_file"),
3309 ];
3310 for i in 0..3 {
3311 let id = format!("call_recent_{i}");
3312 items.push(function_call(&id, "tool"));
3313 items.push(function_call_output(&id));
3314 }
3315
3316 let out = finalize_input_for_request(items, &None);
3317
3318 assert!(
3319 unpaired_function_call_ids(&out).is_empty(),
3320 "no dangling function_call may remain after repair"
3321 );
3322 assert!(
3323 !out.iter().any(|item| matches!(
3324 item,
3325 ResponsesInputItem::FunctionCall { call_id, .. } if call_id == "call_old"
3326 )),
3327 "the old dangling call must be removed"
3328 );
3329 assert_eq!(out.len(), 7);
3331 }
3332
3333 #[test]
3334 fn unpaired_function_call_ids_reports_both_directions() {
3335 let items = vec![
3336 function_call("call_no_output", "read_file"), function_call_output("out_no_call"), function_call("paired", "tool"),
3339 function_call_output("paired"),
3340 ];
3341
3342 let mut ids = unpaired_function_call_ids(&items);
3343 ids.sort();
3344 assert_eq!(
3345 ids,
3346 vec!["call_no_output".to_string(), "out_no_call".to_string()]
3347 );
3348 }
3349
3350 #[test]
3355 fn provider_can_enable_stateful_responses() {
3356 assert!(
3357 OpenResponsesProtocolChatDriver::new()
3358 .with_stateful_responses(true)
3359 .supports_stateful_responses()
3360 );
3361 }
3362
3363 #[test]
3364 fn wire_protocol_defaults_to_stateless() {
3365 assert!(!OpenResponsesProtocolChatDriver::new().supports_stateful_responses());
3366 }
3367
3368 #[test]
3373 fn stateless_gateway_replays_full_transcript_despite_previous_response_id() {
3374 let prev_id: Option<String> = Some("gen-turn-1".to_string());
3375
3376 let driver = OpenResponsesProtocolChatDriver::new();
3377 let effective_prev_id = if driver.supports_stateful_responses() {
3378 prev_id.clone()
3379 } else {
3380 None
3381 };
3382 assert!(
3383 effective_prev_id.is_none(),
3384 "stateless gateway must not chain via previous_response_id"
3385 );
3386
3387 let items = sample_full_transcript_items();
3388 let original_len = items.len();
3389 let out = finalize_input_for_request(items, &effective_prev_id);
3390 assert_eq!(
3391 out.len(),
3392 original_len,
3393 "stateless gateway must replay the full transcript so the model keeps context"
3394 );
3395 }
3396
3397 #[test]
3401 fn stateful_endpoint_still_trims_and_chains() {
3402 let prev_id: Option<String> = Some("resp_turn_1".to_string());
3403
3404 let driver = OpenResponsesProtocolChatDriver::new().with_stateful_responses(true);
3405 let effective_prev_id = if driver.supports_stateful_responses() {
3406 prev_id.clone()
3407 } else {
3408 None
3409 };
3410 assert_eq!(
3411 effective_prev_id, prev_id,
3412 "stateful endpoint keeps the continuation handle"
3413 );
3414
3415 let out = finalize_input_for_request(sample_full_transcript_items(), &effective_prev_id);
3416 assert_eq!(out.len(), 1, "stateful endpoint trims to the delta window");
3417 }
3418
3419 #[tokio::test]
3425 async fn stateless_gateway_request_replays_full_transcript_on_the_wire() {
3426 use crate::tool_types::ToolCall;
3427 use serde_json::json;
3428 use wiremock::matchers::method;
3429 use wiremock::{Mock, MockServer, ResponseTemplate};
3430
3431 let server = MockServer::start().await;
3432 Mock::given(method("POST"))
3435 .respond_with(ResponseTemplate::new(200).set_body_string(""))
3436 .mount(&server)
3437 .await;
3438
3439 let endpoint = crate::runtime_provider::RuntimeProvider::new(
3440 "stateless-test",
3441 OpenResponsesProtocolChatDriver::new(),
3442 )
3443 .base_url(format!("{}/v1", server.uri()))
3444 .auth(crate::runtime_provider::BearerAuth::new("test-key"));
3445 let driver = OpenResponsesProtocolChatDriver::new();
3446
3447 let messages = vec![
3448 LlmMessage::text(LlmMessageRole::System, "You are helpful"),
3449 LlmMessage::text(LlmMessageRole::User, "upgrade dependencies"),
3450 LlmMessage {
3451 role: LlmMessageRole::Assistant,
3452 content: LlmMessageContent::Text("Let me look.".to_string()),
3453 tool_calls: Some(vec![ToolCall {
3454 id: "call_1".to_string(),
3455 name: "read_file".to_string(),
3456 arguments: json!({"path": "Cargo.toml"}),
3457 }]),
3458 tool_call_id: None,
3459 phase: None,
3460 thinking: None,
3461 thinking_signature: None,
3462 },
3463 LlmMessage {
3464 role: LlmMessageRole::Tool,
3465 content: LlmMessageContent::Text("[package]…".to_string()),
3466 tool_calls: None,
3467 tool_call_id: Some("call_1".to_string()),
3468 phase: None,
3469 thinking: None,
3470 thinking_signature: None,
3471 },
3472 ];
3473
3474 let config = LlmCallConfig {
3475 speed: None,
3476 verbosity: None,
3477 model: "some/model".to_string(),
3478 temperature: None,
3479 max_tokens: None,
3480 tools: vec![],
3481 reasoning_effort: None,
3482 metadata: std::collections::HashMap::new(),
3483 previous_response_id: Some("gen-turn-1".to_string()),
3486 provider_opaque_context: None,
3487 tool_search: None,
3488 prompt_cache: None,
3489 openrouter_routing: None,
3490 parallel_tool_calls: None,
3491 volatile_suffix_len: 0,
3492 };
3493
3494 let _ = driver
3496 .chat_completion_stream(endpoint.endpoint(), messages, &config)
3497 .await;
3498
3499 let requests = server
3500 .received_requests()
3501 .await
3502 .expect("mock server recorded requests");
3503 assert_eq!(requests.len(), 1, "exactly one request should be sent");
3504 let body: serde_json::Value = requests[0].body_json().expect("request body is JSON");
3505
3506 assert!(
3508 body.get("previous_response_id").is_none(),
3509 "stateless gateway request must omit previous_response_id; body: {body}"
3510 );
3511
3512 let input = body["input"].as_array().expect("input is an array");
3515 assert_eq!(
3516 input.len(),
3517 4,
3518 "full transcript must be replayed on a stateless gateway; got {input:?}"
3519 );
3520 assert_eq!(body["instructions"], "You are helpful");
3521 let has_user_task = input
3522 .iter()
3523 .any(|item| item["type"] == "message" && item["role"] == "user");
3524 assert!(
3525 has_user_task,
3526 "the original user task must be replayed; got {input:?}"
3527 );
3528 let has_tool_output = input
3529 .iter()
3530 .any(|item| item["type"] == "function_call_output");
3531 assert!(
3532 has_tool_output,
3533 "the latest tool result must still be present; got {input:?}"
3534 );
3535 }
3536
3537 #[tokio::test]
3538 async fn rejected_stateful_continuation_replays_repaired_transcript_once() {
3539 use crate::tool_types::ToolCall;
3540 use futures::StreamExt;
3541 use serde_json::json;
3542 use wiremock::matchers::{body_partial_json, method};
3543 use wiremock::{Mock, MockServer, ResponseTemplate};
3544
3545 let server = MockServer::start().await;
3546 Mock::given(method("POST"))
3547 .and(body_partial_json(json!({
3548 "previous_response_id": "resp_tool_turn"
3549 })))
3550 .respond_with(ResponseTemplate::new(400).set_body_json(json!({
3551 "error": {
3552 "type": "invalid_request_error",
3553 "message": "No tool output found for function call call_1"
3554 }
3555 })))
3556 .expect(1)
3557 .mount(&server)
3558 .await;
3559 let completed = r#"data: {"type":"response.completed","response":{"id":"resp_recovered","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":4,"output_tokens":1,"total_tokens":5}}}
3560
3561"#;
3562 Mock::given(method("POST"))
3563 .respond_with(
3564 ResponseTemplate::new(200)
3565 .insert_header("content-type", "text/event-stream")
3566 .set_body_string(completed),
3567 )
3568 .expect(1)
3569 .mount(&server)
3570 .await;
3571
3572 let endpoint = crate::runtime_provider::RuntimeProvider::new(
3573 "stateful-test",
3574 OpenResponsesProtocolChatDriver::new(),
3575 )
3576 .base_url(format!("{}/v1", server.uri()))
3577 .auth(crate::runtime_provider::BearerAuth::new("test-key"));
3578 let driver = OpenResponsesProtocolChatDriver::new()
3579 .with_stateful_responses(true)
3580 .with_retry_config(LlmRetryConfig::no_retry());
3581 let messages = vec![
3582 LlmMessage::text(LlmMessageRole::User, "inspect the project"),
3583 LlmMessage {
3584 role: LlmMessageRole::Assistant,
3585 content: LlmMessageContent::Text(String::new()),
3586 tool_calls: Some(vec![ToolCall {
3587 id: "call_1".to_string(),
3588 name: "read_file".to_string(),
3589 arguments: json!({"path": "Cargo.toml"}),
3590 }]),
3591 tool_call_id: None,
3592 phase: None,
3593 thinking: None,
3594 thinking_signature: None,
3595 },
3596 LlmMessage {
3597 role: LlmMessageRole::Tool,
3598 content: LlmMessageContent::Text("[package]".to_string()),
3599 tool_calls: None,
3600 tool_call_id: Some("call_1".to_string()),
3601 phase: None,
3602 thinking: None,
3603 thinking_signature: None,
3604 },
3605 ];
3606 let config = LlmCallConfig {
3607 speed: None,
3608 verbosity: None,
3609 model: "gpt-5.4".to_string(),
3610 temperature: None,
3611 max_tokens: None,
3612 tools: vec![],
3613 reasoning_effort: None,
3614 metadata: std::collections::HashMap::new(),
3615 previous_response_id: Some("resp_tool_turn".to_string()),
3616 provider_opaque_context: None,
3617 tool_search: None,
3618 prompt_cache: None,
3619 openrouter_routing: None,
3620 parallel_tool_calls: None,
3621 volatile_suffix_len: 0,
3622 };
3623
3624 let mut stream = driver
3625 .chat_completion_stream(endpoint.endpoint(), messages, &config)
3626 .await
3627 .expect("continuation should recover");
3628 while let Some(event) = stream.next().await {
3629 event.expect("valid recovered event");
3630 }
3631
3632 let requests = server.received_requests().await.expect("requests");
3633 assert_eq!(requests.len(), 2);
3634 let first: serde_json::Value = requests[0].body_json().expect("first body");
3635 let second: serde_json::Value = requests[1].body_json().expect("second body");
3636 assert_eq!(first["previous_response_id"], "resp_tool_turn");
3637 assert!(second.get("previous_response_id").is_none());
3638 let replay = second["input"].as_array().expect("replay input");
3639 assert!(replay.iter().any(|item| item["type"] == "function_call"));
3640 assert!(
3641 replay
3642 .iter()
3643 .any(|item| item["type"] == "function_call_output")
3644 );
3645 }
3646
3647 #[tokio::test]
3648 async fn openrouter_provider_does_not_send_hosted_tool_search() {
3649 use crate::tool_types::DeferrablePolicy;
3650 use serde_json::json;
3651 use wiremock::matchers::method;
3652 use wiremock::{Mock, MockServer, ResponseTemplate};
3653
3654 let server = MockServer::start().await;
3655 Mock::given(method("POST"))
3656 .respond_with(ResponseTemplate::new(200).set_body_string(""))
3657 .mount(&server)
3658 .await;
3659
3660 let endpoint = crate::runtime_provider::RuntimeProvider::new(
3661 "openrouter-test",
3662 OpenResponsesProtocolChatDriver::new(),
3663 )
3664 .base_url(format!("{}/v1", server.uri()))
3665 .auth(crate::runtime_provider::BearerAuth::new("test-key"));
3666 let driver = OpenResponsesProtocolChatDriver::new();
3667
3668 let tools: Vec<ToolDefinition> = (0..16)
3669 .map(|i| {
3670 make_tool(
3671 &format!("tool_{i}"),
3672 Some("General"),
3673 DeferrablePolicy::Automatic,
3674 )
3675 })
3676 .collect();
3677
3678 let config = LlmCallConfig {
3679 speed: None,
3680 verbosity: None,
3681 model: "gpt-5.4".to_string(),
3682 temperature: None,
3683 max_tokens: None,
3684 tools,
3685 reasoning_effort: None,
3686 metadata: std::collections::HashMap::new(),
3687 previous_response_id: None,
3688 provider_opaque_context: None,
3689 tool_search: Some(crate::driver_registry::ToolSearchConfig {
3690 enabled: true,
3691 threshold: 15,
3692 }),
3693 prompt_cache: None,
3694 openrouter_routing: None,
3695 parallel_tool_calls: None,
3696 volatile_suffix_len: 0,
3697 };
3698
3699 let messages = vec![LlmMessage::text(LlmMessageRole::User, "hello")];
3700 let _ = driver
3701 .chat_completion_stream(endpoint.endpoint(), messages, &config)
3702 .await;
3703
3704 let requests = server
3705 .received_requests()
3706 .await
3707 .expect("mock server recorded requests");
3708 assert_eq!(requests.len(), 1, "exactly one request should be sent");
3709 let body: serde_json::Value = requests[0].body_json().expect("request body is JSON");
3710 let tools = body["tools"].as_array().expect("tools is an array");
3711
3712 assert!(
3713 tools.iter().all(|tool| tool["type"] == "function"),
3714 "OpenRouter should receive regular function tools, not hosted tool_search payloads: {tools:?}"
3715 );
3716 assert!(
3717 tools.iter().all(|tool| tool.get("defer_loading").is_none()),
3718 "OpenRouter tool schemas should not be deferred by hosted tool_search: {tools:?}"
3719 );
3720 assert_eq!(
3721 body["input"],
3722 json!([{"type": "message", "role": "user", "content": "hello"}])
3723 );
3724 }
3725
3726 #[tokio::test]
3727 async fn openai_provider_omits_openrouter_routing_controls() {
3728 use crate::driver_registry::{OpenRouterRoute, OpenRouterRoutingConfig};
3729 use wiremock::matchers::method;
3730 use wiremock::{Mock, MockServer, ResponseTemplate};
3731
3732 let server = MockServer::start().await;
3733 Mock::given(method("POST"))
3734 .respond_with(ResponseTemplate::new(200).set_body_string(""))
3735 .mount(&server)
3736 .await;
3737
3738 let endpoint = crate::runtime_provider::RuntimeProvider::new(
3739 "openai-test",
3740 OpenResponsesProtocolChatDriver::new(),
3741 )
3742 .base_url(format!("{}/v1", server.uri()))
3743 .auth(crate::runtime_provider::BearerAuth::new("test-key"));
3744 let driver = OpenResponsesProtocolChatDriver::new();
3745
3746 let mut metadata = std::collections::HashMap::new();
3747 metadata.insert("session_id".to_string(), "session_abc123".to_string());
3748 let config = LlmCallConfig {
3749 speed: None,
3750 verbosity: None,
3751 model: "gpt-5-mini".to_string(),
3752 temperature: None,
3753 max_tokens: None,
3754 tools: vec![],
3755 reasoning_effort: None,
3756 metadata,
3757 previous_response_id: None,
3758 provider_opaque_context: None,
3759 tool_search: None,
3760 prompt_cache: None,
3761 openrouter_routing: Some(OpenRouterRoutingConfig {
3762 models: vec!["openai/gpt-5-mini".to_string()],
3763 route: Some(OpenRouterRoute::Fallback),
3764 provider: None,
3765 ..Default::default()
3766 }),
3767 parallel_tool_calls: None,
3768 volatile_suffix_len: 0,
3769 };
3770
3771 let messages = vec![LlmMessage::text(LlmMessageRole::User, "hello")];
3772 let _ = driver
3773 .chat_completion_stream(endpoint.endpoint(), messages, &config)
3774 .await;
3775
3776 let requests = server
3777 .received_requests()
3778 .await
3779 .expect("mock server recorded requests");
3780 assert_eq!(requests.len(), 1, "exactly one request should be sent");
3781 let body: serde_json::Value = requests[0].body_json().expect("request body is JSON");
3782
3783 assert!(body.get("models").is_none(), "body: {body}");
3784 assert!(body.get("route").is_none(), "body: {body}");
3785 assert!(body.get("provider").is_none(), "body: {body}");
3786 assert!(body.get("session_id").is_none(), "body: {body}");
3789 assert_eq!(body["metadata"]["session_id"], "session_abc123");
3790 }
3791
3792 #[tokio::test]
3798 async fn openresponses_stream_skips_done_sentinel() {
3799 use futures::StreamExt;
3800 use wiremock::matchers::method;
3801 use wiremock::{Mock, MockServer, ResponseTemplate};
3802
3803 let body =
3805 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\ndata: [DONE]\n\n";
3806 let server = MockServer::start().await;
3807 Mock::given(method("POST"))
3808 .respond_with(
3809 ResponseTemplate::new(200)
3810 .insert_header("content-type", "text/event-stream")
3811 .set_body_string(body),
3812 )
3813 .mount(&server)
3814 .await;
3815
3816 let endpoint = crate::runtime_provider::RuntimeProvider::new(
3817 "stream-test",
3818 OpenResponsesProtocolChatDriver::new(),
3819 )
3820 .base_url(format!("{}/v1", server.uri()))
3821 .auth(crate::runtime_provider::BearerAuth::new("test-key"));
3822 let driver = OpenResponsesProtocolChatDriver::new();
3823 let config = LlmCallConfig {
3824 speed: None,
3825 verbosity: None,
3826 model: "openai/gpt-4o-mini".to_string(),
3827 temperature: None,
3828 max_tokens: None,
3829 tools: vec![],
3830 reasoning_effort: None,
3831 metadata: std::collections::HashMap::new(),
3832 previous_response_id: None,
3833 provider_opaque_context: None,
3834 tool_search: None,
3835 prompt_cache: None,
3836 openrouter_routing: None,
3837 parallel_tool_calls: None,
3838 volatile_suffix_len: 0,
3839 };
3840
3841 let stream = driver
3842 .chat_completion_stream(
3843 endpoint.endpoint(),
3844 vec![LlmMessage::text(LlmMessageRole::User, "hi")],
3845 &config,
3846 )
3847 .await
3848 .expect("stream should start");
3849 let events: Vec<_> = stream.collect().await;
3850
3851 let mut text = String::new();
3852 for ev in &events {
3853 match ev.as_ref().expect("no transport error") {
3854 LlmStreamEvent::TextDelta(d) => text.push_str(d),
3855 LlmStreamEvent::Error(e) => {
3856 panic!("[DONE] sentinel must not surface as an error: {e}")
3857 }
3858 _ => {}
3859 }
3860 }
3861 assert_eq!(text, "hi");
3862 }
3863
3864 #[test]
3869 fn test_compact_request_serialization() {
3870 let request = CompactRequest {
3871 model: "gpt-4o".to_string(),
3872 input: vec![
3873 CompactInputItem::Message {
3874 role: "user".to_string(),
3875 content: CompactContent::Text("Hello!".to_string()),
3876 },
3877 CompactInputItem::Message {
3878 role: "assistant".to_string(),
3879 content: CompactContent::Text("Hi there!".to_string()),
3880 },
3881 ],
3882 previous_response_id: None,
3883 instructions: Some("Be helpful".to_string()),
3884 };
3885
3886 let json = serde_json::to_value(&request).unwrap();
3887 assert_eq!(json["model"], "gpt-4o");
3888 assert_eq!(json["instructions"], "Be helpful");
3889 assert!(json["input"].is_array());
3890 assert_eq!(json["input"].as_array().unwrap().len(), 2);
3891 }
3892
3893 #[test]
3894 fn test_compact_input_item_message_serialization() {
3895 let item = CompactInputItem::Message {
3896 role: "user".to_string(),
3897 content: CompactContent::Text("Test message".to_string()),
3898 };
3899
3900 let json = serde_json::to_value(&item).unwrap();
3901 assert_eq!(json["type"], "message");
3902 assert_eq!(json["role"], "user");
3903 assert_eq!(json["content"], "Test message");
3904 }
3905
3906 #[test]
3907 fn test_compact_input_item_function_call_serialization() {
3908 let item = CompactInputItem::FunctionCall {
3909 call_id: "call_123".to_string(),
3910 name: "get_weather".to_string(),
3911 arguments: r#"{"city":"NYC"}"#.to_string(),
3912 };
3913
3914 let json = serde_json::to_value(&item).unwrap();
3915 assert_eq!(json["type"], "function_call");
3916 assert_eq!(json["call_id"], "call_123");
3917 assert_eq!(json["name"], "get_weather");
3918 assert_eq!(json["arguments"], r#"{"city":"NYC"}"#);
3919 }
3920
3921 #[test]
3922 fn test_compact_input_item_compaction_serialization() {
3923 let item = CompactInputItem::Compaction {
3924 encrypted_content: "encrypted_data_here".to_string(),
3925 };
3926
3927 let json = serde_json::to_value(&item).unwrap();
3928 assert_eq!(json["type"], "compaction");
3929 assert_eq!(json["encrypted_content"], "encrypted_data_here");
3930 }
3931
3932 #[test]
3933 fn test_compact_output_item_deserialization() {
3934 let json = r#"{
3935 "type": "message",
3936 "role": "user",
3937 "content": "Hello"
3938 }"#;
3939
3940 let item: CompactOutputItem = serde_json::from_str(json).unwrap();
3941 match item {
3942 CompactOutputItem::Message { role, content } => {
3943 assert_eq!(role, "user");
3944 match content {
3945 CompactContent::Text(text) => assert_eq!(text, "Hello"),
3946 _ => panic!("Expected text content"),
3947 }
3948 }
3949 _ => panic!("Expected Message item"),
3950 }
3951 }
3952
3953 #[test]
3954 fn test_compact_output_compaction_deserialization() {
3955 let json = r#"{
3956 "type": "compaction",
3957 "encrypted_content": "abc123encrypted"
3958 }"#;
3959
3960 let item: CompactOutputItem = serde_json::from_str(json).unwrap();
3961 match item {
3962 CompactOutputItem::Compaction { encrypted_content } => {
3963 assert_eq!(encrypted_content, "abc123encrypted");
3964 }
3965 _ => panic!("Expected Compaction item"),
3966 }
3967 }
3968
3969 #[test]
3970 fn test_compact_response_deserialization() {
3971 let json = r#"{
3972 "output": [
3973 {"type": "message", "role": "user", "content": "Hello"},
3974 {"type": "compaction", "encrypted_content": "xyz789"}
3975 ],
3976 "usage": {
3977 "input_tokens": 100,
3978 "output_tokens": 50,
3979 "total_tokens": 150
3980 }
3981 }"#;
3982
3983 let response: CompactResponse = serde_json::from_str(json).unwrap();
3984 assert_eq!(response.output.len(), 2);
3985 assert!(response.usage.is_some());
3986 let usage = response.usage.unwrap();
3987 assert_eq!(usage.input_tokens, Some(100));
3988 assert_eq!(usage.output_tokens, Some(50));
3989 assert_eq!(usage.total_tokens, Some(150));
3990 }
3991
3992 #[test]
3993 fn test_compact_content_parts_serialization() {
3994 let content = CompactContent::Parts(vec![
3995 CompactContentPart::InputText {
3996 text: "Check this image".to_string(),
3997 },
3998 CompactContentPart::InputImage {
3999 image_url: "data:image/png;base64,abc".to_string(),
4000 },
4001 ]);
4002
4003 let json = serde_json::to_value(&content).unwrap();
4004 assert!(json.is_array());
4005 assert_eq!(json[0]["type"], "input_text");
4006 assert_eq!(json[0]["text"], "Check this image");
4007 assert_eq!(json[1]["type"], "input_image");
4008 }
4009
4010 #[test]
4011 fn test_wire_protocol_supports_compact() {
4012 let driver = OpenResponsesProtocolChatDriver::new();
4013 assert!(driver.supports_compact());
4014 }
4015
4016 #[test]
4021 fn test_reasoning_input_item_serialization() {
4022 let item = ResponsesInputItem::Reasoning {
4023 r#type: "reasoning".to_string(),
4024 id: "rs_00000001".to_string(),
4025 encrypted_content: "encrypted_reasoning_context_here".to_string(),
4026 };
4027
4028 let json = serde_json::to_value(&item).unwrap();
4029 assert_eq!(json["type"], "reasoning");
4030 assert_eq!(json["id"], "rs_00000001");
4031 assert_eq!(
4032 json["encrypted_content"],
4033 "encrypted_reasoning_context_here"
4034 );
4035 }
4036
4037 #[test]
4038 fn test_build_input_with_thinking_signature() {
4039 let messages = vec![
4041 LlmMessage::text(LlmMessageRole::User, "Think about this deeply"),
4042 LlmMessage {
4043 role: LlmMessageRole::Assistant,
4044 content: LlmMessageContent::Text("I have thought about this.".to_string()),
4045 tool_calls: None,
4046 tool_call_id: None,
4047 phase: None,
4048 thinking: Some("This is my chain of thought reasoning...".to_string()),
4049 thinking_signature: Some("encrypted_reasoning_token_123".to_string()),
4050 },
4051 LlmMessage::text(LlmMessageRole::User, "What else?"),
4052 ];
4053
4054 let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
4055
4056 assert_eq!(input.len(), 4);
4058
4059 let json = serde_json::to_value(&input[0]).unwrap();
4061 assert_eq!(json["role"], "user");
4062 assert_eq!(json["content"], "Think about this deeply");
4063
4064 let json = serde_json::to_value(&input[1]).unwrap();
4066 assert_eq!(json["type"], "reasoning");
4067 assert_eq!(json["encrypted_content"], "encrypted_reasoning_token_123");
4068
4069 let json = serde_json::to_value(&input[2]).unwrap();
4071 assert_eq!(json["role"], "assistant");
4072 assert_eq!(json["content"], "I have thought about this.");
4073
4074 let json = serde_json::to_value(&input[3]).unwrap();
4076 assert_eq!(json["role"], "user");
4077 }
4078
4079 #[test]
4080 fn test_build_input_with_thinking_signature_and_tool_calls() {
4081 use crate::tool_types::ToolCall;
4082
4083 let messages = vec![
4085 LlmMessage::text(LlmMessageRole::User, "What time is it? Think carefully."),
4086 LlmMessage {
4087 role: LlmMessageRole::Assistant,
4088 content: LlmMessageContent::Text("Let me check.".to_string()),
4089 tool_calls: Some(vec![ToolCall {
4090 id: "call_123".to_string(),
4091 name: "get_time".to_string(),
4092 arguments: json!({}),
4093 }]),
4094 tool_call_id: None,
4095 phase: None,
4096 thinking: Some("I need to call the get_time tool...".to_string()),
4097 thinking_signature: Some("encrypted_token_xyz".to_string()),
4098 },
4099 LlmMessage {
4100 role: LlmMessageRole::Tool,
4101 content: LlmMessageContent::Text("10:30 AM".to_string()),
4102 tool_calls: None,
4103 tool_call_id: Some("call_123".to_string()),
4104 phase: None,
4105 thinking: None,
4106 thinking_signature: None,
4107 },
4108 ];
4109
4110 let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
4111
4112 assert_eq!(input.len(), 5);
4114
4115 let json = serde_json::to_value(&input[1]).unwrap();
4117 assert_eq!(json["type"], "reasoning");
4118 assert_eq!(json["encrypted_content"], "encrypted_token_xyz");
4119
4120 let json = serde_json::to_value(&input[2]).unwrap();
4122 assert_eq!(json["role"], "assistant");
4123
4124 let json = serde_json::to_value(&input[3]).unwrap();
4126 assert_eq!(json["type"], "function_call");
4127 assert_eq!(json["call_id"], "call_123");
4128
4129 let json = serde_json::to_value(&input[4]).unwrap();
4131 assert_eq!(json["type"], "function_call_output");
4132 }
4133
4134 #[test]
4135 fn test_build_input_without_thinking_signature() {
4136 let messages = vec![
4138 LlmMessage::text(LlmMessageRole::User, "Hello"),
4139 LlmMessage {
4140 role: LlmMessageRole::Assistant,
4141 content: LlmMessageContent::Text("Hi there!".to_string()),
4142 tool_calls: None,
4143 tool_call_id: None,
4144 phase: None,
4145 thinking: Some("Some thinking...".to_string()),
4146 thinking_signature: None, },
4148 ];
4149
4150 let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
4151
4152 assert_eq!(input.len(), 2);
4154
4155 let json = serde_json::to_value(&input[0]).unwrap();
4157 assert_eq!(json["role"], "user");
4158
4159 let json = serde_json::to_value(&input[1]).unwrap();
4160 assert_eq!(json["role"], "assistant");
4161 }
4162
4163 #[test]
4164 fn test_handle_streaming_event_reasoning_encrypted_content() {
4165 use std::sync::Mutex;
4166
4167 let input_tokens = Mutex::new(0u32);
4168 let output_tokens = Mutex::new(0u32);
4169 let cache_read_tokens = Mutex::new(None);
4170 let accumulated_tool_calls = Mutex::new(Vec::new());
4171 let finish_reason = Mutex::new(None);
4172
4173 let event = StreamingEvent::OutputItemDone {
4175 sequence_number: 5,
4176 output_index: 0,
4177 item: Some(types::OutputItem::Reasoning {
4178 id: "rs_001".to_string(),
4179 summary: vec![],
4180 content: None,
4181 encrypted_content: Some("encrypted_reasoning_data".to_string()),
4182 }),
4183 };
4184
4185 let result = handle_streaming_event(
4186 event,
4187 &input_tokens,
4188 &output_tokens,
4189 &cache_read_tokens,
4190 &accumulated_tool_calls,
4191 &finish_reason,
4192 "gpt-5".to_string(),
4193 None,
4194 );
4195
4196 match result {
4198 LlmStreamEvent::ReasonItem {
4199 provider,
4200 model,
4201 item_id,
4202 encrypted_content,
4203 summary,
4204 token_count,
4205 } => {
4206 assert_eq!(provider, "openai");
4207 assert_eq!(model.as_deref(), Some("gpt-5"));
4208 assert_eq!(item_id, "rs_001");
4209 assert_eq!(
4210 encrypted_content.as_deref(),
4211 Some("encrypted_reasoning_data")
4212 );
4213 assert!(summary.is_empty());
4214 assert!(token_count.is_none());
4215 }
4216 other => panic!("Expected ReasonItem event, got {:?}", other),
4217 }
4218 }
4219
4220 #[test]
4221 fn output_item_added_message_surfaces_native_phase_hint() {
4222 use std::sync::Mutex;
4223
4224 for (wire, expected) in [
4228 (
4229 "commentary",
4230 crate::execution_phase::ExecutionPhase::Commentary,
4231 ),
4232 (
4233 "final_answer",
4234 crate::execution_phase::ExecutionPhase::FinalAnswer,
4235 ),
4236 ] {
4237 let event: StreamingEvent = serde_json::from_value(serde_json::json!({
4238 "type": "response.output_item.added",
4239 "sequence_number": 1,
4240 "output_index": 0,
4241 "item": {
4242 "type": "message",
4243 "id": "msg_001",
4244 "status": "in_progress",
4245 "role": "assistant",
4246 "content": [],
4247 "phase": wire,
4248 }
4249 }))
4250 .expect("output_item.added should deserialize");
4251
4252 let result = handle_streaming_event(
4253 event,
4254 &Mutex::new(0),
4255 &Mutex::new(0),
4256 &Mutex::new(None),
4257 &Mutex::new(Vec::new()),
4258 &Mutex::new(None),
4259 "gpt-5".to_string(),
4260 None,
4261 );
4262
4263 match result {
4264 LlmStreamEvent::MessagePhase(phase) => assert_eq!(phase, expected),
4265 other => panic!("Expected MessagePhase({expected:?}), got {other:?}"),
4266 }
4267 }
4268 }
4269
4270 #[test]
4271 fn output_item_added_message_without_phase_is_noop() {
4272 use std::sync::Mutex;
4273
4274 let event: StreamingEvent = serde_json::from_value(serde_json::json!({
4277 "type": "response.output_item.added",
4278 "sequence_number": 1,
4279 "output_index": 0,
4280 "item": {
4281 "type": "message",
4282 "id": "msg_002",
4283 "status": "in_progress",
4284 "role": "assistant",
4285 "content": [],
4286 }
4287 }))
4288 .expect("output_item.added should deserialize");
4289
4290 let result = handle_streaming_event(
4291 event,
4292 &Mutex::new(0),
4293 &Mutex::new(0),
4294 &Mutex::new(None),
4295 &Mutex::new(Vec::new()),
4296 &Mutex::new(None),
4297 "gpt-5".to_string(),
4298 None,
4299 );
4300
4301 match result {
4302 LlmStreamEvent::TextDelta(d) => assert!(d.is_empty()),
4303 other => panic!("Expected empty TextDelta, got {other:?}"),
4304 }
4305 }
4306
4307 #[test]
4308 fn response_failed_preserves_provider_error_code() {
4309 use std::sync::Mutex;
4310
4311 let event: StreamingEvent = serde_json::from_value(serde_json::json!({
4312 "type": "response.failed",
4313 "sequence_number": 7,
4314 "response": {
4315 "id": "resp_failed",
4316 "object": "response",
4317 "created_at": 1,
4318 "status": "failed",
4319 "model": "gpt-5",
4320 "output": [],
4321 "tools": [],
4322 "error": {
4323 "code": "processing_error",
4324 "message": "An error occurred while processing your request."
4325 }
4326 }
4327 }))
4328 .expect("response.failed should deserialize");
4329
4330 let result = handle_streaming_event(
4331 event,
4332 &Mutex::new(0),
4333 &Mutex::new(0),
4334 &Mutex::new(None),
4335 &Mutex::new(Vec::new()),
4336 &Mutex::new(None),
4337 "gpt-5".to_string(),
4338 None,
4339 );
4340
4341 let LlmStreamEvent::Error(error) = result else {
4342 panic!("expected structured stream error");
4343 };
4344 assert_eq!(error.code.as_deref(), Some("processing_error"));
4345 assert!(crate::llm_retry::is_transient_stream_error(&error));
4346 }
4347
4348 #[test]
4349 fn test_handle_streaming_event_reasoning_without_encrypted_content() {
4350 use std::sync::Mutex;
4351
4352 let input_tokens = Mutex::new(0u32);
4353 let output_tokens = Mutex::new(0u32);
4354 let cache_read_tokens = Mutex::new(None);
4355 let accumulated_tool_calls = Mutex::new(Vec::new());
4356 let finish_reason = Mutex::new(None);
4357
4358 let event = StreamingEvent::OutputItemDone {
4360 sequence_number: 5,
4361 output_index: 0,
4362 item: Some(types::OutputItem::Reasoning {
4363 id: "rs_001".to_string(),
4364 summary: vec![types::ContentPart::SummaryText {
4365 text: "Some summary".to_string(),
4366 }],
4367 content: None,
4368 encrypted_content: None, }),
4370 };
4371
4372 let result = handle_streaming_event(
4373 event,
4374 &input_tokens,
4375 &output_tokens,
4376 &cache_read_tokens,
4377 &accumulated_tool_calls,
4378 &finish_reason,
4379 "gpt-5".to_string(),
4380 None,
4381 );
4382
4383 match result {
4386 LlmStreamEvent::ReasonItem {
4387 provider,
4388 item_id,
4389 encrypted_content,
4390 summary,
4391 ..
4392 } => {
4393 assert_eq!(provider, "openai");
4394 assert_eq!(item_id, "rs_001");
4395 assert!(encrypted_content.is_none());
4396 assert_eq!(summary, vec!["Some summary".to_string()]);
4397 }
4398 other => panic!("Expected ReasonItem event, got {:?}", other),
4399 }
4400 }
4401
4402 #[test]
4403 fn test_handle_streaming_event_reasoning_drops_plaintext_content() {
4404 use std::sync::Mutex;
4405
4406 let input_tokens = Mutex::new(0u32);
4407 let output_tokens = Mutex::new(0u32);
4408 let cache_read_tokens = Mutex::new(None);
4409 let accumulated_tool_calls = Mutex::new(Vec::new());
4410 let finish_reason = Mutex::new(None);
4411
4412 let event = StreamingEvent::OutputItemDone {
4415 sequence_number: 5,
4416 output_index: 0,
4417 item: Some(types::OutputItem::Reasoning {
4418 id: "rs_002".to_string(),
4419 summary: vec![
4420 types::ContentPart::SummaryText {
4421 text: "safe summary".to_string(),
4422 },
4423 types::ContentPart::ReasoningText {
4424 text: "SECRET hidden reasoning".to_string(),
4425 },
4426 ],
4427 content: Some(vec![types::ContentPart::ReasoningText {
4428 text: "SECRET hidden reasoning".to_string(),
4429 }]),
4430 encrypted_content: Some("opaque".to_string()),
4431 }),
4432 };
4433
4434 let result = handle_streaming_event(
4435 event,
4436 &input_tokens,
4437 &output_tokens,
4438 &cache_read_tokens,
4439 &accumulated_tool_calls,
4440 &finish_reason,
4441 "gpt-5".to_string(),
4442 None,
4443 );
4444
4445 match result {
4446 LlmStreamEvent::ReasonItem {
4447 summary,
4448 encrypted_content,
4449 ..
4450 } => {
4451 assert_eq!(summary, vec!["safe summary".to_string()]);
4452 assert_eq!(encrypted_content.as_deref(), Some("opaque"));
4453 }
4454 other => panic!("Expected ReasonItem event, got {:?}", other),
4455 }
4456 }
4457
4458 #[test]
4459 fn test_handle_streaming_event_reasoning_delta() {
4460 use std::sync::Mutex;
4461
4462 let input_tokens = Mutex::new(0u32);
4463 let output_tokens = Mutex::new(0u32);
4464 let cache_read_tokens = Mutex::new(None);
4465 let accumulated_tool_calls = Mutex::new(Vec::new());
4466 let finish_reason = Mutex::new(None);
4467
4468 let event = StreamingEvent::ReasoningDelta {
4470 sequence_number: 3,
4471 item_id: "rs_001".to_string(),
4472 output_index: 0,
4473 content_index: 0,
4474 delta: "Let me reason about this...".to_string(),
4475 obfuscation: None,
4476 };
4477
4478 let result = handle_streaming_event(
4479 event,
4480 &input_tokens,
4481 &output_tokens,
4482 &cache_read_tokens,
4483 &accumulated_tool_calls,
4484 &finish_reason,
4485 "o3".to_string(),
4486 None,
4487 );
4488
4489 match result {
4490 LlmStreamEvent::ThinkingDelta(text) => {
4491 assert_eq!(text, "Let me reason about this...");
4492 }
4493 _ => panic!("Expected ThinkingDelta, got {:?}", result),
4494 }
4495 }
4496
4497 #[test]
4498 fn test_handle_streaming_event_reasoning_summary_delta() {
4499 use std::sync::Mutex;
4500
4501 let input_tokens = Mutex::new(0u32);
4502 let output_tokens = Mutex::new(0u32);
4503 let cache_read_tokens = Mutex::new(None);
4504 let accumulated_tool_calls = Mutex::new(Vec::new());
4505 let finish_reason = Mutex::new(None);
4506
4507 let event = StreamingEvent::ReasoningSummaryDelta {
4509 sequence_number: 4,
4510 item_id: "rs_002".to_string(),
4511 output_index: 0,
4512 summary_index: 0,
4513 delta: "Breaking down the problem...".to_string(),
4514 obfuscation: None,
4515 };
4516
4517 let result = handle_streaming_event(
4518 event,
4519 &input_tokens,
4520 &output_tokens,
4521 &cache_read_tokens,
4522 &accumulated_tool_calls,
4523 &finish_reason,
4524 "gpt-5.2".to_string(),
4525 None,
4526 );
4527
4528 match result {
4529 LlmStreamEvent::TextDelta(text) => {
4530 assert_eq!(text, "Breaking down the problem...");
4531 }
4532 _ => panic!("Expected TextDelta, got {:?}", result),
4533 }
4534 }
4535
4536 #[test]
4537 fn test_request_reasoning_none_is_omitted() {
4538 let config = LlmCallConfig {
4541 speed: None,
4542 verbosity: None,
4543 model: "gpt-5.2".to_string(),
4544 temperature: None,
4545 max_tokens: None,
4546 tools: vec![],
4547 reasoning_effort: Some("none".to_string()),
4548 metadata: std::collections::HashMap::new(),
4549 previous_response_id: None,
4550 provider_opaque_context: None,
4551 tool_search: None,
4552 prompt_cache: None,
4553 openrouter_routing: None,
4554 parallel_tool_calls: None,
4555 volatile_suffix_len: 0,
4556 };
4557
4558 let reasoning = config
4560 .reasoning_effort
4561 .as_ref()
4562 .filter(|e| !e.eq_ignore_ascii_case("none"))
4563 .map(|effort| ResponsesReasoning {
4564 effort: effort.clone(),
4565 summary: "detailed".to_string(),
4566 });
4567
4568 assert!(
4569 reasoning.is_none(),
4570 "reasoning should be None for effort=none"
4571 );
4572 }
4573
4574 #[test]
4575 fn test_request_reasoning_high_is_included() {
4576 let config = LlmCallConfig {
4578 speed: None,
4579 verbosity: None,
4580 model: "gpt-5.2".to_string(),
4581 temperature: None,
4582 max_tokens: None,
4583 tools: vec![],
4584 reasoning_effort: Some("high".to_string()),
4585 metadata: std::collections::HashMap::new(),
4586 previous_response_id: None,
4587 provider_opaque_context: None,
4588 tool_search: None,
4589 prompt_cache: None,
4590 openrouter_routing: None,
4591 parallel_tool_calls: None,
4592 volatile_suffix_len: 0,
4593 };
4594
4595 let reasoning = config
4596 .reasoning_effort
4597 .as_ref()
4598 .filter(|e| !e.eq_ignore_ascii_case("none"))
4599 .map(|effort| ResponsesReasoning {
4600 effort: effort.clone(),
4601 summary: "detailed".to_string(),
4602 });
4603
4604 assert!(
4605 reasoning.is_some(),
4606 "reasoning should be present for effort=high"
4607 );
4608 let r = reasoning.unwrap();
4609 assert_eq!(r.effort, "high");
4610 assert_eq!(r.summary, "detailed");
4611 }
4612
4613 #[test]
4614 fn test_request_reasoning_none_case_insensitive() {
4615 for effort in &["none", "None", "NONE"] {
4617 let reasoning = Some(effort.to_string())
4618 .as_ref()
4619 .filter(|e| !e.eq_ignore_ascii_case("none"))
4620 .cloned();
4621
4622 assert!(
4623 reasoning.is_none(),
4624 "effort={effort:?} should be filtered out"
4625 );
4626 }
4627 }
4628
4629 #[test]
4630 fn test_build_input_assistant_without_thinking_or_tools() {
4631 let messages = vec![
4633 LlmMessage::text(LlmMessageRole::User, "Hello"),
4634 LlmMessage {
4635 role: LlmMessageRole::Assistant,
4636 content: LlmMessageContent::Text("Hi there!".to_string()),
4637 tool_calls: None,
4638 tool_call_id: None,
4639 phase: None,
4640 thinking: None,
4641 thinking_signature: None,
4642 },
4643 ];
4644
4645 let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
4646
4647 assert_eq!(input.len(), 2);
4648 let json = serde_json::to_value(&input[1]).unwrap();
4649 assert_eq!(json["role"], "assistant");
4650 assert!(json.get("type").is_none() || json["type"] == "message");
4651 }
4652
4653 #[test]
4654 fn test_build_input_multiple_reasoning_items_get_unique_ids() {
4655 let messages = vec![
4657 LlmMessage::text(LlmMessageRole::User, "First question"),
4658 LlmMessage {
4659 role: LlmMessageRole::Assistant,
4660 content: LlmMessageContent::Text("First answer.".to_string()),
4661 tool_calls: None,
4662 tool_call_id: None,
4663 phase: None,
4664 thinking: Some("thinking 1".to_string()),
4665 thinking_signature: Some("encrypted_1".to_string()),
4666 },
4667 LlmMessage::text(LlmMessageRole::User, "Second question"),
4668 LlmMessage {
4669 role: LlmMessageRole::Assistant,
4670 content: LlmMessageContent::Text("Second answer.".to_string()),
4671 tool_calls: None,
4672 tool_call_id: None,
4673 phase: None,
4674 thinking: Some("thinking 2".to_string()),
4675 thinking_signature: Some("encrypted_2".to_string()),
4676 },
4677 ];
4678
4679 let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
4680
4681 assert_eq!(input.len(), 6);
4683
4684 let r1 = serde_json::to_value(&input[1]).unwrap();
4685 let r2 = serde_json::to_value(&input[4]).unwrap();
4686
4687 assert_eq!(r1["type"], "reasoning");
4688 assert_eq!(r2["type"], "reasoning");
4689 assert_ne!(r1["id"], r2["id"], "Reasoning items should have unique IDs");
4690 assert_eq!(r1["encrypted_content"], "encrypted_1");
4691 assert_eq!(r2["encrypted_content"], "encrypted_2");
4692 }
4693
4694 #[test]
4695 fn test_build_input_with_phases_enabled() {
4696 use crate::execution_phase::ExecutionPhase;
4697
4698 let messages = vec![
4699 LlmMessage::text(LlmMessageRole::System, "You are helpful"),
4700 LlmMessage::text(LlmMessageRole::User, "Hello"),
4701 LlmMessage {
4702 role: LlmMessageRole::Assistant,
4703 content: LlmMessageContent::Text("Working on it...".to_string()),
4704 tool_calls: Some(vec![crate::tool_types::ToolCall {
4705 id: "call_1".to_string(),
4706 name: "search".to_string(),
4707 arguments: json!({}),
4708 }]),
4709 tool_call_id: None,
4710 phase: Some(ExecutionPhase::Commentary),
4711 thinking: None,
4712 thinking_signature: None,
4713 },
4714 LlmMessage {
4715 role: LlmMessageRole::Tool,
4716 content: LlmMessageContent::Text("result".to_string()),
4717 tool_calls: None,
4718 tool_call_id: Some("call_1".to_string()),
4719 phase: None,
4720 thinking: None,
4721 thinking_signature: None,
4722 },
4723 ];
4724
4725 let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, true);
4727 let assistant_json = serde_json::to_value(&input[1]).unwrap();
4728 assert_eq!(assistant_json["phase"], "commentary");
4729
4730 let (_, input_no_phases) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
4732 let assistant_json_no = serde_json::to_value(&input_no_phases[1]).unwrap();
4733 assert!(assistant_json_no.get("phase").is_none() || assistant_json_no["phase"].is_null());
4734 }
4735
4736 fn make_tool(
4742 name: &str,
4743 category: Option<&str>,
4744 deferrable: crate::tool_types::DeferrablePolicy,
4745 ) -> ToolDefinition {
4746 ToolDefinition::Builtin(crate::tool_types::BuiltinTool {
4747 name: name.to_string(),
4748 display_name: None,
4749 description: format!("{} description", name),
4750 parameters: json!({"type": "object", "properties": {}}),
4751 policy: crate::tool_types::ToolPolicy::Auto,
4752 category: category.map(|s| s.to_string()),
4753 deferrable,
4754 hints: crate::tool_types::ToolHints::default(),
4755 full_parameters: None,
4756 })
4757 }
4758
4759 #[test]
4760 fn test_convert_tools_with_search_below_threshold_falls_back() {
4761 use crate::tool_types::DeferrablePolicy;
4762
4763 let tools: Vec<ToolDefinition> = (0..5)
4764 .map(|i| {
4765 make_tool(
4766 &format!("tool_{i}"),
4767 Some("cat"),
4768 DeferrablePolicy::Automatic,
4769 )
4770 })
4771 .collect();
4772
4773 let result = OpenResponsesProtocolChatDriver::convert_tools_with_search(&tools, 15);
4775 assert_eq!(result.len(), 5);
4776 let json = serde_json::to_value(&result).unwrap();
4778 for item in json.as_array().unwrap() {
4779 assert_eq!(item["type"], "function");
4780 assert!(item.get("defer_loading").is_none() || item["defer_loading"].is_null());
4781 }
4782 }
4783
4784 #[test]
4785 fn test_convert_tools_with_search_groups_by_category() {
4786 use crate::tool_types::DeferrablePolicy;
4787
4788 let mut tools = vec![];
4789 for i in 0..10 {
4791 tools.push(make_tool(
4792 &format!("fs_tool_{i}"),
4793 Some("FileSystem"),
4794 DeferrablePolicy::Automatic,
4795 ));
4796 }
4797 for i in 0..6 {
4798 tools.push(make_tool(
4799 &format!("weather_tool_{i}"),
4800 Some("Weather"),
4801 DeferrablePolicy::Automatic,
4802 ));
4803 }
4804
4805 let result = OpenResponsesProtocolChatDriver::convert_tools_with_search(&tools, 15);
4806 let json = serde_json::to_value(&result).unwrap();
4807 let arr = json.as_array().unwrap();
4808
4809 assert_eq!(arr.len(), 3);
4811
4812 assert_eq!(arr.last().unwrap()["type"], "tool_search");
4814
4815 let ns: Vec<&Value> = arr.iter().filter(|v| v["type"] == "namespace").collect();
4817 assert_eq!(ns.len(), 2);
4818
4819 let ns_names: Vec<&str> = ns.iter().map(|v| v["name"].as_str().unwrap()).collect();
4820 assert!(ns_names.contains(&"FileSystem"));
4821 assert!(ns_names.contains(&"Weather"));
4822
4823 for n in &ns {
4825 let inner_tools = n["tools"].as_array().unwrap();
4826 match n["name"].as_str().unwrap() {
4827 "FileSystem" => assert_eq!(inner_tools.len(), 10),
4828 "Weather" => assert_eq!(inner_tools.len(), 6),
4829 other => panic!("Unexpected namespace: {other}"),
4830 }
4831 for t in inner_tools {
4833 assert_eq!(t["defer_loading"], true);
4834 }
4835 }
4836 }
4837
4838 #[test]
4839 fn test_convert_tools_with_search_never_defer_stays_top_level() {
4840 use crate::tool_types::DeferrablePolicy;
4841
4842 let mut tools = vec![];
4843 tools.push(make_tool(
4845 "write_todos",
4846 Some("Productivity"),
4847 DeferrablePolicy::Never,
4848 ));
4849 tools.push(make_tool(
4850 "get_session_info",
4851 Some("Session"),
4852 DeferrablePolicy::Never,
4853 ));
4854 for i in 0..14 {
4856 tools.push(make_tool(
4857 &format!("fs_tool_{i}"),
4858 Some("FileSystem"),
4859 DeferrablePolicy::Automatic,
4860 ));
4861 }
4862
4863 let result = OpenResponsesProtocolChatDriver::convert_tools_with_search(&tools, 15);
4864 let json = serde_json::to_value(&result).unwrap();
4865 let arr = json.as_array().unwrap();
4866
4867 assert_eq!(arr.len(), 4);
4869
4870 let funcs: Vec<&Value> = arr.iter().filter(|v| v["type"] == "function").collect();
4872 assert_eq!(funcs.len(), 2);
4873 for f in &funcs {
4874 assert!(f.get("defer_loading").is_none() || f["defer_loading"].is_null());
4876 }
4877
4878 let ns: Vec<&Value> = arr.iter().filter(|v| v["type"] == "namespace").collect();
4880 assert_eq!(ns.len(), 1);
4881 assert_eq!(ns[0]["name"], "FileSystem");
4882 assert_eq!(ns[0]["tools"].as_array().unwrap().len(), 14);
4883 }
4884
4885 #[test]
4886 fn test_convert_tools_with_search_ungrouped_tools() {
4887 use crate::tool_types::DeferrablePolicy;
4888
4889 let mut tools = vec![];
4890 for i in 0..10 {
4892 tools.push(make_tool(
4893 &format!("cat_tool_{i}"),
4894 Some("Cat"),
4895 DeferrablePolicy::Automatic,
4896 ));
4897 }
4898 for i in 0..6 {
4900 tools.push(make_tool(
4901 &format!("misc_tool_{i}"),
4902 None,
4903 DeferrablePolicy::Automatic,
4904 ));
4905 }
4906
4907 let result = OpenResponsesProtocolChatDriver::convert_tools_with_search(&tools, 15);
4908 let json = serde_json::to_value(&result).unwrap();
4909 let arr = json.as_array().unwrap();
4910
4911 assert_eq!(arr.len(), 8);
4913
4914 let ns: Vec<&Value> = arr.iter().filter(|v| v["type"] == "namespace").collect();
4915 assert_eq!(ns.len(), 1);
4916 assert_eq!(ns[0]["tools"].as_array().unwrap().len(), 10);
4917
4918 let funcs: Vec<&Value> = arr.iter().filter(|v| v["type"] == "function").collect();
4919 assert_eq!(funcs.len(), 6);
4920 for f in &funcs {
4922 assert_eq!(f["defer_loading"], true);
4923 }
4924
4925 assert_eq!(arr.last().unwrap()["type"], "tool_search");
4926 }
4927
4928 #[test]
4929 fn test_convert_tools_with_search_always_policy() {
4930 use crate::tool_types::DeferrablePolicy;
4931
4932 let mut tools = vec![];
4933 for i in 0..14 {
4935 tools.push(make_tool(
4936 &format!("tool_{i}"),
4937 Some("General"),
4938 DeferrablePolicy::Automatic,
4939 ));
4940 }
4941 tools.push(make_tool(
4943 "always_tool",
4944 Some("General"),
4945 DeferrablePolicy::Always,
4946 ));
4947
4948 let result = OpenResponsesProtocolChatDriver::convert_tools_with_search(&tools, 15);
4950 let json = serde_json::to_value(&result).unwrap();
4951 let arr = json.as_array().unwrap();
4952
4953 assert_eq!(arr.len(), 2);
4955
4956 let ns = &arr[0];
4957 assert_eq!(ns["type"], "namespace");
4958 let inner = ns["tools"].as_array().unwrap();
4959 assert_eq!(inner.len(), 15);
4960 for t in inner {
4962 assert_eq!(t["defer_loading"], true);
4963 }
4964 }
4965
4966 #[test]
4967 fn test_tool_search_serialization_format() {
4968 let ts = ResponsesTool::ToolSearch {
4970 r#type: "tool_search".to_string(),
4971 };
4972 let json = serde_json::to_value(&ts).unwrap();
4973 assert_eq!(json, json!({"type": "tool_search"}));
4974 }
4975
4976 #[test]
4977 fn test_namespace_serialization_format() {
4978 let ns = ResponsesTool::Namespace {
4979 r#type: "namespace".to_string(),
4980 name: "FileSystem".to_string(),
4981 description: "Tools for FileSystem".to_string(),
4982 tools: vec![ResponsesTool::Function {
4983 r#type: "function".to_string(),
4984 name: "read_file".to_string(),
4985 description: "Read a file".to_string(),
4986 parameters: json!({}),
4987 defer_loading: Some(true),
4988 }],
4989 };
4990 let json = serde_json::to_value(&ns).unwrap();
4991 assert_eq!(json["type"], "namespace");
4992 assert_eq!(json["name"], "FileSystem");
4993 assert_eq!(json["tools"][0]["name"], "read_file");
4994 assert_eq!(json["tools"][0]["defer_loading"], true);
4995 }
4996
4997 #[test]
4998 fn test_hosted_tool_search_completed_event_preserves_response_id() {
4999 let event_json = r#"{
5000 "type": "response.completed",
5001 "sequence_number": 8,
5002 "response": {
5003 "id": "resp_tool_search",
5004 "object": "response",
5005 "created_at": 1780000000,
5006 "status": "completed",
5007 "model": "gpt-5.5",
5008 "output": [
5009 {
5010 "type": "tool_search_call",
5011 "execution": "server",
5012 "call_id": null,
5013 "status": "completed",
5014 "arguments": { "paths": ["Math"] }
5015 },
5016 {
5017 "type": "tool_search_output",
5018 "execution": "server",
5019 "call_id": null,
5020 "status": "completed",
5021 "tools": [
5022 {
5023 "type": "namespace",
5024 "name": "Math",
5025 "description": "Tools for Math",
5026 "tools": [
5027 {
5028 "type": "function",
5029 "name": "add",
5030 "description": "Add numbers.",
5031 "defer_loading": true,
5032 "parameters": {
5033 "type": "object",
5034 "properties": {
5035 "a": { "type": "number" },
5036 "b": { "type": "number" }
5037 },
5038 "required": ["a", "b"],
5039 "additionalProperties": false
5040 }
5041 }
5042 ]
5043 }
5044 ]
5045 },
5046 {
5047 "type": "function_call",
5048 "id": "fc_123",
5049 "call_id": "call_123",
5050 "name": "add",
5051 "namespace": "Math",
5052 "arguments": "{\"a\":7,\"b\":3}",
5053 "status": "completed"
5054 }
5055 ],
5056 "usage": {
5057 "input_tokens": 10,
5058 "output_tokens": 5,
5059 "total_tokens": 15
5060 }
5061 }
5062 }"#;
5063
5064 let event: StreamingEvent = serde_json::from_str(event_json).unwrap();
5065 let stream_event = handle_streaming_event(
5066 event,
5067 &Mutex::new(0),
5068 &Mutex::new(0),
5069 &Mutex::new(None),
5070 &Mutex::new(Vec::new()),
5071 &Mutex::new(Some("tool_calls".to_string())),
5072 "gpt-5.5".to_string(),
5073 None,
5074 );
5075
5076 match stream_event {
5077 LlmStreamEvent::Done(metadata) => {
5078 assert_eq!(metadata.response_id.as_deref(), Some("resp_tool_search"));
5079 assert_eq!(metadata.finish_reason.as_deref(), Some("tool_calls"));
5080 }
5081 other => panic!("expected Done event, got {other:?}"),
5082 }
5083 }
5084
5085 #[test]
5086 fn test_completed_event_normalizes_cache_inclusive_prompt_tokens() {
5087 let event_json = r#"{
5091 "type": "response.completed",
5092 "sequence_number": 9,
5093 "response": {
5094 "id": "resp_cache",
5095 "object": "response",
5096 "created_at": 1780000000,
5097 "status": "completed",
5098 "model": "gpt-5.5",
5099 "output": [],
5100 "usage": {
5101 "input_tokens": 1000,
5102 "output_tokens": 20,
5103 "total_tokens": 1020,
5104 "input_tokens_details": { "cached_tokens": 800 }
5105 }
5106 }
5107 }"#;
5108
5109 let event: StreamingEvent = serde_json::from_str(event_json).unwrap();
5110 let stream_event = handle_streaming_event(
5111 event,
5112 &Mutex::new(0),
5113 &Mutex::new(0),
5114 &Mutex::new(None),
5115 &Mutex::new(Vec::new()),
5116 &Mutex::new(None),
5117 "gpt-5.5".to_string(),
5118 None,
5119 );
5120
5121 match stream_event {
5122 LlmStreamEvent::Done(metadata) => {
5123 assert_eq!(metadata.prompt_tokens, Some(200));
5125 assert_eq!(metadata.cache_read_tokens, Some(800));
5126 assert_eq!(metadata.total_tokens, Some(1020));
5128 }
5129 other => panic!("expected Done event, got {other:?}"),
5130 }
5131 }
5132
5133 #[test]
5134 fn test_incomplete_event_maps_output_limit_to_length() {
5135 let event_json = r#"{
5136 "type": "response.incomplete",
5137 "sequence_number": 10,
5138 "response": {
5139 "id": "resp_incomplete",
5140 "object": "response",
5141 "created_at": 1780000000,
5142 "status": "incomplete",
5143 "incomplete_details": { "reason": "max_output_tokens" },
5144 "model": "gpt-5.5",
5145 "output": [],
5146 "usage": {
5147 "input_tokens": 10,
5148 "output_tokens": 5,
5149 "total_tokens": 15
5150 }
5151 }
5152 }"#;
5153
5154 let event: StreamingEvent = serde_json::from_str(event_json).unwrap();
5155 let stream_event = handle_streaming_event(
5156 event,
5157 &Mutex::new(0),
5158 &Mutex::new(0),
5159 &Mutex::new(None),
5160 &Mutex::new(Vec::new()),
5161 &Mutex::new(None),
5162 "gpt-5.5".to_string(),
5163 None,
5164 );
5165
5166 match stream_event {
5167 LlmStreamEvent::Done(metadata) => {
5168 assert_eq!(metadata.finish_reason.as_deref(), Some("length"));
5169 }
5170 other => panic!("expected Done event, got {other:?}"),
5171 }
5172 }
5173
5174 #[test]
5175 fn test_sanitize_parameters_adds_missing_properties() {
5176 let params = json!({"type": "object", "additionalProperties": false});
5177 let sanitized = OpenResponsesProtocolChatDriver::sanitize_parameters(¶ms);
5178 assert_eq!(
5179 sanitized,
5180 json!({"type": "object", "properties": {}, "additionalProperties": false})
5181 );
5182 }
5183
5184 #[test]
5185 fn test_sanitize_parameters_preserves_existing_properties() {
5186 let params = json!({"type": "object", "properties": {"x": {"type": "string"}}, "additionalProperties": false});
5187 let sanitized = OpenResponsesProtocolChatDriver::sanitize_parameters(¶ms);
5188 assert_eq!(sanitized, params);
5189 }
5190
5191 #[test]
5192 fn test_sanitize_parameters_ignores_non_object_types() {
5193 let params = json!({"type": "string"});
5194 let sanitized = OpenResponsesProtocolChatDriver::sanitize_parameters(¶ms);
5195 assert_eq!(sanitized, params);
5196 }
5197
5198 #[test]
5199 fn test_sanitize_parameters_rewrites_resend_email_lookaround() {
5200 let params = json!({
5201 "type": "object",
5202 "properties": {
5203 "email": {
5204 "type": "string",
5205 "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
5206 }
5207 }
5208 });
5209
5210 let sanitized = OpenResponsesProtocolChatDriver::sanitize_parameters(¶ms);
5211 let pattern = sanitized["properties"]["email"]["pattern"]
5212 .as_str()
5213 .unwrap();
5214
5215 assert!(!pattern.contains("(?!"));
5216 assert!(pattern.contains('@'));
5217 }
5218
5219 fn auth_test_config() -> LlmCallConfig {
5225 LlmCallConfig {
5226 speed: None,
5227 verbosity: None,
5228 model: "gpt-5.4".to_string(),
5229 temperature: None,
5230 max_tokens: None,
5231 tools: vec![],
5232 reasoning_effort: None,
5233 metadata: std::collections::HashMap::new(),
5234 previous_response_id: None,
5235 provider_opaque_context: None,
5236 tool_search: None,
5237 prompt_cache: None,
5238 openrouter_routing: None,
5239 parallel_tool_calls: None,
5240 volatile_suffix_len: 0,
5241 }
5242 }
5243
5244 struct CountingAuth {
5247 header: (String, String),
5248 calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
5249 }
5250
5251 #[async_trait::async_trait]
5252 impl crate::runtime_provider::ProviderAuth for CountingAuth {
5253 async fn headers(
5254 &self,
5255 _request: crate::runtime_provider::ProviderAuthRequest<'_>,
5256 ) -> Result<Vec<(String, String)>> {
5257 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
5258 Ok(vec![self.header.clone()])
5259 }
5260
5261 fn as_any(&self) -> &dyn std::any::Any {
5262 self
5263 }
5264 }
5265
5266 struct HeaderInjectingExtension;
5269
5270 impl OpenResponsesRequestExtension for HeaderInjectingExtension {
5271 fn decorate(&self, _body: &mut Value, _config: &LlmCallConfig) -> Result<()> {
5272 Ok(())
5273 }
5274
5275 fn decorate_headers(&self, headers: &mut HeaderMap, _config: &LlmCallConfig) -> Result<()> {
5276 headers.insert(
5277 "x-openrouter-route",
5278 reqwest::header::HeaderValue::from_static("fallback"),
5279 );
5280 headers.insert(
5282 "authorization",
5283 reqwest::header::HeaderValue::from_static("Bearer decoration"),
5284 );
5285 Ok(())
5286 }
5287 }
5288
5289 #[tokio::test]
5290 async fn provider_resolves_bearer_auth() {
5291 let provider = crate::runtime_provider::RuntimeProvider::new(
5292 "openai-test",
5293 OpenResponsesProtocolChatDriver::new(),
5294 )
5295 .base_url("https://api.openai.com/v1")
5296 .auth(crate::runtime_provider::BearerAuth::new("secret-key"));
5297 let resolved = provider
5298 .endpoint()
5299 .resolve("POST", "https://api.openai.com/v1/responses", b"{}")
5300 .await
5301 .expect("auth resolves");
5302 assert_eq!(
5303 resolved.headers,
5304 vec![("authorization".into(), "Bearer secret-key".into())]
5305 );
5306 }
5307
5308 #[tokio::test]
5309 async fn provider_selects_auth_independently_of_host() {
5310 let provider = crate::runtime_provider::RuntimeProvider::new(
5311 "azure-test",
5312 OpenResponsesProtocolChatDriver::new(),
5313 )
5314 .base_url("https://my-resource.openai.azure.com/openai/v1")
5315 .auth(crate::runtime_provider::StaticHeaderAuth::new(
5316 "api-key",
5317 "secret-key",
5318 ));
5319 let resolved = provider
5320 .endpoint()
5321 .resolve(
5322 "POST",
5323 "https://my-resource.openai.azure.com/openai/v1/responses",
5324 b"{}",
5325 )
5326 .await
5327 .expect("auth resolves");
5328 assert_eq!(
5329 resolved.headers,
5330 vec![("api-key".into(), "secret-key".into())]
5331 );
5332 }
5333
5334 #[tokio::test]
5335 async fn refreshable_provider_auth_is_resolved() {
5336 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
5337 let provider = crate::runtime_provider::RuntimeProvider::new(
5338 "refreshable-test",
5339 OpenResponsesProtocolChatDriver::new(),
5340 )
5341 .base_url("https://service.example/v1")
5342 .auth_arc(std::sync::Arc::new(CountingAuth {
5343 header: (
5344 "Authorization".to_string(),
5345 "Bearer minted-token".to_string(),
5346 ),
5347 calls: calls.clone(),
5348 }));
5349 let resolved = provider
5350 .endpoint()
5351 .resolve("POST", "https://service.example/v1/responses", b"{}")
5352 .await
5353 .expect("auth resolves");
5354 assert_eq!(
5355 resolved.headers,
5356 vec![("authorization".into(), "Bearer minted-token".into())]
5357 );
5358 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
5359 }
5360
5361 #[tokio::test]
5362 async fn default_static_auth_applied_on_the_wire() {
5363 use wiremock::matchers::{header, method};
5364 use wiremock::{Mock, MockServer, ResponseTemplate};
5365
5366 let server = MockServer::start().await;
5367 Mock::given(method("POST"))
5368 .and(header("authorization", "Bearer wire-key"))
5369 .respond_with(ResponseTemplate::new(200).set_body_string(""))
5370 .mount(&server)
5371 .await;
5372
5373 let endpoint = crate::runtime_provider::RuntimeProvider::new(
5374 "wire-test",
5375 OpenResponsesProtocolChatDriver::new(),
5376 )
5377 .base_url(format!("{}/v1", server.uri()))
5378 .auth(crate::runtime_provider::BearerAuth::new("wire-key"));
5379 let driver = OpenResponsesProtocolChatDriver::new();
5380 let messages = vec![LlmMessage::text(LlmMessageRole::User, "hi")];
5381 let _ = driver
5382 .chat_completion_stream(endpoint.endpoint(), messages, &auth_test_config())
5383 .await;
5384
5385 let requests = server.received_requests().await.unwrap();
5386 assert_eq!(
5387 requests.len(),
5388 1,
5389 "default static key must authenticate the request"
5390 );
5391 }
5392
5393 #[tokio::test]
5394 async fn auth_provider_header_wins_over_extension_header() {
5395 use wiremock::matchers::{header, method};
5396 use wiremock::{Mock, MockServer, ResponseTemplate};
5397
5398 let server = MockServer::start().await;
5399 Mock::given(method("POST"))
5402 .and(header("authorization", "Bearer minted-token"))
5403 .and(header("x-openrouter-route", "fallback"))
5404 .respond_with(ResponseTemplate::new(200).set_body_string(""))
5405 .mount(&server)
5406 .await;
5407
5408 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
5409 let endpoint = crate::runtime_provider::RuntimeProvider::new(
5410 "auth-wins-test",
5411 OpenResponsesProtocolChatDriver::new(),
5412 )
5413 .base_url(format!("{}/v1", server.uri()))
5414 .auth_arc(std::sync::Arc::new(CountingAuth {
5415 header: (
5416 "Authorization".to_string(),
5417 "Bearer minted-token".to_string(),
5418 ),
5419 calls: calls.clone(),
5420 }));
5421 let driver = OpenResponsesProtocolChatDriver::new()
5422 .with_request_extension(std::sync::Arc::new(HeaderInjectingExtension));
5423
5424 let messages = vec![LlmMessage::text(LlmMessageRole::User, "hi")];
5425 let _ = driver
5426 .chat_completion_stream(endpoint.endpoint(), messages, &auth_test_config())
5427 .await;
5428
5429 let requests = server.received_requests().await.unwrap();
5430 assert_eq!(
5431 requests.len(),
5432 1,
5433 "auth header must win over a conflicting decoration header"
5434 );
5435 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
5436 }
5437
5438 #[tokio::test]
5439 async fn auth_provider_awaited_on_each_retry_attempt() {
5440 use wiremock::matchers::method;
5441 use wiremock::{Mock, MockServer, ResponseTemplate};
5442
5443 let server = MockServer::start().await;
5444 Mock::given(method("POST"))
5447 .respond_with(ResponseTemplate::new(503).set_body_string("overloaded"))
5448 .mount(&server)
5449 .await;
5450
5451 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
5452 let fast_retry = LlmRetryConfig {
5453 max_retries: 1,
5454 initial_backoff: std::time::Duration::from_millis(1),
5455 max_backoff: std::time::Duration::from_millis(1),
5456 backoff_multiplier: 1.0,
5457 jitter_factor: 0.0,
5458 ..Default::default()
5459 };
5460 let endpoint = crate::runtime_provider::RuntimeProvider::new(
5461 "retry-auth-test",
5462 OpenResponsesProtocolChatDriver::new(),
5463 )
5464 .base_url(format!("{}/v1", server.uri()))
5465 .auth_arc(std::sync::Arc::new(CountingAuth {
5466 header: (
5467 "Authorization".to_string(),
5468 "Bearer minted-token".to_string(),
5469 ),
5470 calls: calls.clone(),
5471 }));
5472 let driver = OpenResponsesProtocolChatDriver::new().with_retry_config(fast_retry);
5473
5474 let messages = vec![LlmMessage::text(LlmMessageRole::User, "hi")];
5475 let _ = driver
5476 .chat_completion_stream(endpoint.endpoint(), messages, &auth_test_config())
5477 .await;
5478
5479 assert_eq!(
5481 calls.load(std::sync::atomic::Ordering::SeqCst),
5482 2,
5483 "refreshable auth must be resolved per HTTP attempt, including retries"
5484 );
5485 }
5486}