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