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 text: config.verbosity.clone().map(|verbosity| ResponsesText {
1076 verbosity: Some(verbosity),
1077 }),
1078 };
1079
1080 {
1083 let tool_count = request.tools.as_ref().map_or(0, |t| t.len());
1084 let input_count = request.input.len();
1085 let has_instructions = request.instructions.is_some();
1086 let has_reasoning = request.reasoning.is_some();
1087 let has_previous_response = request.previous_response_id.is_some();
1088 tracing::debug!(
1089 model = %request.model,
1090 input_items = input_count,
1091 tool_count = tool_count,
1092 has_instructions = has_instructions,
1093 has_reasoning = has_reasoning,
1094 has_previous_response = has_previous_response,
1095 api_url = %self.api_url,
1096 "OpenResponsesDriver: sending request"
1097 );
1098 }
1099
1100 let mut request_body = serde_json::to_value(&request)
1103 .map_err(|e| AgentLoopError::llm(format!("Failed to serialize request: {}", e)))?;
1104 if let Some(extension) = &self.request_extension {
1105 extension.decorate(&mut request_body, config)?;
1106 }
1107 let mut extension_headers = HeaderMap::new();
1108 if let Some(extension) = &self.request_extension {
1109 extension.decorate_headers(&mut extension_headers, config)?;
1110 }
1111
1112 let (event_stream, retry_metadata) = connect_sse_with_reconnect(
1117 &self.retry_config,
1118 "OpenResponsesProtocolDriver",
1119 |_attempt| {
1120 self.send_responses_request(&request_body, &extension_headers, &config.model)
1121 },
1122 )
1123 .await?;
1124
1125 let model = config.model.clone();
1126 let input_tokens = Arc::new(Mutex::new(0u32));
1127 let output_tokens = Arc::new(Mutex::new(0u32));
1128 let cache_read_tokens = Arc::new(Mutex::new(Option::<u32>::None));
1129 let accumulated_tool_calls = Arc::new(Mutex::new(Vec::<ToolCallAccumulator>::new()));
1130 let finish_reason = Arc::new(Mutex::new(Option::<String>::None));
1131 let shared_retry_metadata = if retry_metadata.had_retries() {
1133 Some(Arc::new(retry_metadata))
1134 } else {
1135 None
1136 };
1137
1138 let converted_stream: LlmResponseStream = Box::pin(event_stream.then(move |result| {
1139 let model = model.clone();
1140 let input_tokens = Arc::clone(&input_tokens);
1141 let output_tokens = Arc::clone(&output_tokens);
1142 let cache_read_tokens = Arc::clone(&cache_read_tokens);
1143 let accumulated_tool_calls = Arc::clone(&accumulated_tool_calls);
1144 let finish_reason = Arc::clone(&finish_reason);
1145 let retry_metadata_for_done = shared_retry_metadata.clone();
1146
1147 async move {
1148 match result {
1149 Ok(event) => {
1150 let event_data = &event.data;
1151
1152 if event_data == "[DONE]" {
1158 return Ok(LlmStreamEvent::TextDelta(String::new()));
1159 }
1160
1161 if let Ok(streaming_event) =
1163 serde_json::from_str::<StreamingEvent>(event_data)
1164 {
1165 return Ok(handle_streaming_event(
1166 streaming_event,
1167 &input_tokens,
1168 &output_tokens,
1169 &cache_read_tokens,
1170 &accumulated_tool_calls,
1171 &finish_reason,
1172 model,
1173 retry_metadata_for_done,
1174 ));
1175 }
1176
1177 let parsed: std::result::Result<Value, _> =
1179 serde_json::from_str(event_data);
1180
1181 match parsed {
1182 Ok(json) => {
1183 let event_type = json.get("type").and_then(|t| t.as_str());
1184
1185 match event_type {
1186 Some("response.output_text.delta") => {
1187 if let Some(delta) =
1189 json.get("delta").and_then(|d| d.as_str())
1190 {
1191 Ok(LlmStreamEvent::TextDelta(delta.to_string()))
1192 } else {
1193 Ok(LlmStreamEvent::TextDelta(String::new()))
1194 }
1195 }
1196
1197 Some("response.function_call_arguments.delta") => {
1198 if let (Some(item_id), Some(delta)) = (
1200 json.get("item_id").and_then(|c| c.as_str()),
1201 json.get("delta").and_then(|d| d.as_str()),
1202 ) {
1203 let mut acc = accumulated_tool_calls.lock().unwrap();
1204 if let Some(tc) =
1206 acc.iter_mut().find(|t| t.id == item_id)
1207 {
1208 tc.arguments.push_str(delta);
1209 } else {
1210 acc.push(ToolCallAccumulator {
1211 id: item_id.to_string(),
1212 call_id: String::new(),
1213 name: String::new(),
1214 arguments: delta.to_string(),
1215 });
1216 }
1217 }
1218 Ok(LlmStreamEvent::TextDelta(String::new()))
1219 }
1220
1221 Some("response.output_item.added") => {
1222 if let Some(item) = json.get("item")
1224 && item.get("type").and_then(|t| t.as_str())
1225 == Some("function_call")
1226 {
1227 let id = item
1228 .get("id")
1229 .and_then(|c| c.as_str())
1230 .unwrap_or("")
1231 .to_string();
1232 let call_id = item
1233 .get("call_id")
1234 .and_then(|c| c.as_str())
1235 .unwrap_or("")
1236 .to_string();
1237 let name = item
1238 .get("name")
1239 .and_then(|n| n.as_str())
1240 .unwrap_or("")
1241 .to_string();
1242
1243 let mut acc = accumulated_tool_calls.lock().unwrap();
1244 if let Some(tc) = acc.iter_mut().find(|t| t.id == id) {
1245 tc.name = name;
1246 tc.call_id = call_id;
1247 } else {
1248 acc.push(ToolCallAccumulator {
1249 id,
1250 call_id,
1251 name,
1252 arguments: String::new(),
1253 });
1254 }
1255 }
1256 Ok(LlmStreamEvent::TextDelta(String::new()))
1257 }
1258
1259 Some("response.output_item.done") => {
1260 if let Some(item) = json.get("item")
1262 && item.get("type").and_then(|t| t.as_str())
1263 == Some("function_call")
1264 {
1265 let acc = accumulated_tool_calls.lock().unwrap();
1267 if !acc.is_empty() {
1268 let tool_calls: Vec<ToolCall> = acc
1269 .iter()
1270 .filter(|tc| !tc.name.is_empty())
1271 .map(|tc| {
1272 let arguments: Value =
1273 serde_json::from_str(&tc.arguments)
1274 .unwrap_or(json!({}));
1275 ToolCall {
1276 id: tc.call_id.clone(),
1277 name: tc.name.clone(),
1278 arguments,
1279 }
1280 })
1281 .collect();
1282
1283 if !tool_calls.is_empty() {
1284 *finish_reason.lock().unwrap() =
1285 Some("tool_calls".to_string());
1286 return Ok(LlmStreamEvent::ToolCalls(
1287 tool_calls,
1288 ));
1289 }
1290 }
1291 }
1292 Ok(LlmStreamEvent::TextDelta(String::new()))
1293 }
1294
1295 Some("response.completed") | Some("response.done") => {
1296 let response_obj = json.get("response").unwrap_or(&json);
1298
1299 let mut provider_cost_usd: Option<f64> = None;
1302 if let Some(usage) = response_obj.get("usage") {
1303 if let Some(input) =
1304 usage.get("input_tokens").and_then(|t| t.as_u64())
1305 {
1306 *input_tokens.lock().unwrap() = input as u32;
1307 }
1308 if let Some(output) =
1309 usage.get("output_tokens").and_then(|t| t.as_u64())
1310 {
1311 *output_tokens.lock().unwrap() = output as u32;
1312 }
1313 if let Some(details) = usage.get("input_tokens_details")
1315 && let Some(cached) = details
1316 .get("cached_tokens")
1317 .and_then(|t| t.as_u64())
1318 {
1319 *cache_read_tokens.lock().unwrap() =
1320 Some(cached as u32);
1321 }
1322 provider_cost_usd =
1323 usage.get("cost").and_then(|c| c.as_f64());
1324 }
1325
1326 let status = response_obj
1328 .get("status")
1329 .and_then(|s| s.as_str())
1330 .unwrap_or("completed");
1331
1332 let reason = match status {
1333 "completed" => {
1334 let existing_reason =
1336 finish_reason.lock().unwrap().clone();
1337 existing_reason
1338 .unwrap_or_else(|| "stop".to_string())
1339 }
1340 "failed" => {
1341 let error_detail = response_obj
1342 .get("error")
1343 .map(|e| e.to_string())
1344 .unwrap_or_else(|| "no error detail".into());
1345 tracing::warn!(
1346 response_error = %error_detail,
1347 "OpenResponsesDriver: response completed with 'failed' status (fallback parser)"
1348 );
1349 "error".to_string()
1350 }
1351 "cancelled" => "stop".to_string(),
1352 _ => "stop".to_string(),
1353 };
1354
1355 let phase = response_obj
1357 .get("output")
1358 .and_then(|o| o.as_array())
1359 .and_then(|items| {
1360 items.iter().rev().find_map(|item| {
1361 if item.get("type")?.as_str()? == "message"
1362 && item.get("role")?.as_str()?
1363 == "assistant"
1364 {
1365 item.get("phase")?
1366 .as_str()
1367 .map(String::from)
1368 } else {
1369 None
1370 }
1371 })
1372 });
1373
1374 let input = *input_tokens.lock().unwrap();
1375 let output = *output_tokens.lock().unwrap();
1376 let cached = *cache_read_tokens.lock().unwrap();
1377
1378 Ok(LlmStreamEvent::Done(Box::new(LlmCompletionMetadata {
1379 total_tokens: Some(input + output),
1382 prompt_tokens: Some(disjoint_prompt_tokens(input, cached)),
1383 completion_tokens: Some(output),
1384 cache_read_tokens: cached,
1385 cache_creation_tokens: None,
1386 provider_cost_usd,
1387 model: Some(model),
1388 finish_reason: Some(reason),
1389 retry_metadata: retry_metadata_for_done
1390 .map(|arc| (*arc).clone()),
1391 response_id: None,
1392 phase,
1393 })))
1394 }
1395
1396 Some("error") => {
1397 let error_code = json
1399 .get("error")
1400 .and_then(|e| e.get("code"))
1401 .and_then(|c| c.as_str())
1402 .unwrap_or("unknown");
1403 let error_msg = json
1404 .get("error")
1405 .and_then(|e| e.get("message"))
1406 .and_then(|m| m.as_str())
1407 .unwrap_or("Unknown error");
1408 tracing::warn!(
1409 error_code = error_code,
1410 error_message = error_msg,
1411 raw_error = %json.get("error").unwrap_or(&json),
1412 "OpenResponsesDriver: received streaming error event (fallback parser)"
1413 );
1414 Ok(LlmStreamEvent::Error(
1415 crate::driver_registry::LlmStreamError::provider(
1416 (error_code != "unknown")
1417 .then_some(error_code.to_string()),
1418 None,
1419 error_msg,
1420 ),
1421 ))
1422 }
1423
1424 _ => {
1425 Ok(LlmStreamEvent::TextDelta(String::new()))
1427 }
1428 }
1429 }
1430 Err(e) => Ok(LlmStreamEvent::Error(
1431 format!("Failed to parse event: {}", e).into(),
1432 )),
1433 }
1434 }
1435 Err(e) => Ok(LlmStreamEvent::Error(
1436 format!("Stream error: {}", e).into(),
1437 )),
1438 }
1439 }
1440 }));
1441
1442 Ok(converted_stream)
1443 }
1444
1445 fn supports_compact(&self) -> bool {
1446 OpenResponsesProtocolChatDriver::supports_compact(self)
1448 }
1449
1450 fn supports_parallel_tool_calls(&self, _model: &str) -> bool {
1452 true
1453 }
1454
1455 async fn compact(
1456 &self,
1457 request: crate::openresponses_protocol::CompactRequest,
1458 ) -> Result<Option<crate::openresponses_protocol::CompactResponse>> {
1459 Ok(Some(
1461 OpenResponsesProtocolChatDriver::compact(self, request).await?,
1462 ))
1463 }
1464}
1465
1466impl std::fmt::Debug for OpenResponsesProtocolChatDriver {
1467 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1468 f.debug_struct("OpenResponsesProtocolChatDriver")
1469 .field("api_url", &self.api_url)
1470 .field("provider_type", &self.provider_type)
1471 .field("api_key", &"[REDACTED]")
1472 .finish()
1473 }
1474}
1475
1476#[derive(Clone, Default)]
1482struct ToolCallAccumulator {
1483 id: String,
1485 call_id: String,
1487 name: String,
1489 arguments: String,
1491}
1492
1493#[allow(clippy::too_many_arguments)]
1495fn handle_streaming_event(
1496 event: StreamingEvent,
1497 input_tokens: &Mutex<u32>,
1498 output_tokens: &Mutex<u32>,
1499 cache_read_tokens: &Mutex<Option<u32>>,
1500 accumulated_tool_calls: &Mutex<Vec<ToolCallAccumulator>>,
1501 finish_reason: &Mutex<Option<String>>,
1502 model: String,
1503 retry_metadata: Option<Arc<RetryMetadata>>,
1504) -> LlmStreamEvent {
1505 match event {
1506 StreamingEvent::OutputTextDelta { delta, .. } => LlmStreamEvent::TextDelta(delta),
1507
1508 StreamingEvent::ReasoningDelta { delta, .. } => LlmStreamEvent::ThinkingDelta(delta),
1509
1510 StreamingEvent::ReasoningTextDelta { delta, .. } => LlmStreamEvent::ThinkingDelta(delta),
1511
1512 StreamingEvent::ReasoningSummaryDelta { delta, .. } => {
1513 LlmStreamEvent::TextDelta(delta)
1517 }
1518
1519 StreamingEvent::FunctionCallArgumentsDelta { item_id, delta, .. } => {
1520 let mut acc = accumulated_tool_calls.lock().unwrap();
1521 if let Some(tc) = acc.iter_mut().find(|t| t.id == item_id) {
1522 tc.arguments.push_str(&delta);
1523 } else {
1524 acc.push(ToolCallAccumulator {
1525 id: item_id,
1526 call_id: String::new(),
1527 name: String::new(),
1528 arguments: delta,
1529 });
1530 }
1531 LlmStreamEvent::TextDelta(String::new())
1532 }
1533
1534 StreamingEvent::OutputItemAdded { item, .. } => {
1535 if let Some(types::OutputItem::FunctionCall {
1536 id, call_id, name, ..
1537 }) = item
1538 {
1539 let mut acc = accumulated_tool_calls.lock().unwrap();
1540 if let Some(tc) = acc.iter_mut().find(|t| t.id == id) {
1541 tc.name = name;
1542 tc.call_id = call_id;
1543 } else {
1544 acc.push(ToolCallAccumulator {
1545 id,
1546 call_id,
1547 name,
1548 arguments: String::new(),
1549 });
1550 }
1551 }
1552 LlmStreamEvent::TextDelta(String::new())
1553 }
1554
1555 StreamingEvent::OutputItemDone { item, .. } => {
1556 match item {
1557 Some(types::OutputItem::FunctionCall { .. }) => {
1558 let acc = accumulated_tool_calls.lock().unwrap();
1559 if !acc.is_empty() {
1560 let tool_calls: Vec<ToolCall> = acc
1561 .iter()
1562 .filter(|tc| !tc.name.is_empty())
1563 .map(|tc| {
1564 let arguments: Value =
1565 serde_json::from_str(&tc.arguments).unwrap_or(json!({}));
1566 ToolCall {
1567 id: tc.call_id.clone(),
1568 name: tc.name.clone(),
1569 arguments,
1570 }
1571 })
1572 .collect();
1573
1574 if !tool_calls.is_empty() {
1575 *finish_reason.lock().unwrap() = Some("tool_calls".to_string());
1576 return LlmStreamEvent::ToolCalls(tool_calls);
1577 }
1578 }
1579 LlmStreamEvent::TextDelta(String::new())
1580 }
1581 Some(types::OutputItem::Reasoning {
1582 id,
1583 summary,
1584 content: _, encrypted_content,
1586 }) => {
1587 let safe_summary: Vec<String> = summary
1592 .into_iter()
1593 .filter_map(|part| match part {
1594 types::ContentPart::SummaryText { text } => Some(text),
1595 _ => None,
1596 })
1597 .collect();
1598 tracing::debug!(
1599 encrypted_len = encrypted_content.as_ref().map(|s| s.len()).unwrap_or(0),
1600 summary_segments = safe_summary.len(),
1601 "OpenResponses: received reasoning item"
1602 );
1603 LlmStreamEvent::ReasonItem {
1604 provider: "openai".to_string(),
1605 model: Some(model.clone()),
1606 item_id: id,
1607 encrypted_content,
1608 summary: safe_summary,
1609 token_count: None,
1610 }
1611 }
1612 _ => LlmStreamEvent::TextDelta(String::new()),
1613 }
1614 }
1615
1616 StreamingEvent::ResponseCompleted { response, .. } => {
1617 if let Some(usage) = &response.usage {
1619 *input_tokens.lock().unwrap() = usage.input_tokens;
1620 *output_tokens.lock().unwrap() = usage.output_tokens;
1621 if let Some(details) = &usage.input_tokens_details {
1622 *cache_read_tokens.lock().unwrap() = Some(details.cached_tokens);
1623 }
1624 }
1625
1626 let reason = match response.status {
1627 types::ResponseStatus::Completed => {
1628 let existing = finish_reason.lock().unwrap().clone();
1629 existing.unwrap_or_else(|| "stop".to_string())
1630 }
1631 types::ResponseStatus::Failed => {
1632 tracing::warn!(
1633 response_id = %response.id,
1634 error = ?response.error,
1635 "OpenResponsesDriver: response completed with 'failed' status"
1636 );
1637 "error".to_string()
1638 }
1639 types::ResponseStatus::Cancelled => "stop".to_string(),
1640 _ => "stop".to_string(),
1641 };
1642
1643 let phase = response.output.iter().rev().find_map(|item| {
1646 if let types::OutputItem::Message { phase, .. } = item {
1647 phase.clone()
1648 } else {
1649 None
1650 }
1651 });
1652
1653 let input = *input_tokens.lock().unwrap();
1654 let output = *output_tokens.lock().unwrap();
1655 let cached = *cache_read_tokens.lock().unwrap();
1656 let provider_cost_usd = response.usage.as_ref().and_then(|u| u.cost);
1657
1658 LlmStreamEvent::Done(Box::new(LlmCompletionMetadata {
1659 total_tokens: Some(input + output),
1662 prompt_tokens: Some(disjoint_prompt_tokens(input, cached)),
1663 completion_tokens: Some(output),
1664 cache_read_tokens: cached,
1665 cache_creation_tokens: None,
1666 provider_cost_usd,
1667 model: Some(model),
1668 finish_reason: Some(reason),
1669 retry_metadata: retry_metadata.map(|arc| (*arc).clone()),
1670 response_id: Some(response.id),
1671 phase,
1672 }))
1673 }
1674
1675 StreamingEvent::Error { error, .. } => {
1676 tracing::warn!(
1677 error_code = error.code.as_deref().unwrap_or("none"),
1678 error_message = %error.message,
1679 "OpenResponsesDriver: received streaming error event from provider"
1680 );
1681 LlmStreamEvent::Error(crate::driver_registry::LlmStreamError::provider(
1682 error.code,
1683 None,
1684 error.message,
1685 ))
1686 }
1687
1688 StreamingEvent::ResponseFailed { response, .. } => {
1689 let error = response.error.unwrap_or(types::Error {
1690 code: "processing_error".to_string(),
1691 message: "The provider failed while processing the response".to_string(),
1692 });
1693 tracing::warn!(
1694 response_id = %response.id,
1695 error_code = %error.code,
1696 error_message = %error.message,
1697 "OpenResponsesDriver: response failed in stream"
1698 );
1699 LlmStreamEvent::Error(crate::driver_registry::LlmStreamError::provider(
1700 Some(error.code),
1701 None,
1702 error.message,
1703 ))
1704 }
1705
1706 StreamingEvent::RefusalDelta { delta, .. } => {
1707 LlmStreamEvent::Error(format!("Model refused: {}", delta).into())
1709 }
1710
1711 _ => LlmStreamEvent::TextDelta(String::new()),
1713 }
1714}
1715
1716#[derive(Debug, Clone, Serialize)]
1726pub struct CompactRequest {
1727 pub model: String,
1729 #[serde(skip_serializing_if = "Vec::is_empty")]
1731 pub input: Vec<CompactInputItem>,
1732 #[serde(skip_serializing_if = "Option::is_none")]
1734 pub previous_response_id: Option<String>,
1735 #[serde(skip_serializing_if = "Option::is_none")]
1737 pub instructions: Option<String>,
1738}
1739
1740#[derive(Debug, Clone, Serialize, Deserialize)]
1745#[serde(tag = "type")]
1746pub enum CompactInputItem {
1747 #[serde(rename = "message")]
1749 Message {
1750 role: String,
1751 content: CompactContent,
1752 },
1753 #[serde(rename = "function_call")]
1755 FunctionCall {
1756 call_id: String,
1757 name: String,
1758 arguments: String,
1759 },
1760 #[serde(rename = "function_call_output")]
1762 FunctionCallOutput { call_id: String, output: String },
1763 #[serde(rename = "compaction")]
1765 Compaction { encrypted_content: String },
1766}
1767
1768#[derive(Debug, Clone, Serialize, Deserialize)]
1770#[serde(untagged)]
1771pub enum CompactContent {
1772 Text(String),
1774 Parts(Vec<CompactContentPart>),
1776}
1777
1778#[derive(Debug, Clone, Serialize, Deserialize)]
1780#[serde(tag = "type")]
1781pub enum CompactContentPart {
1782 #[serde(rename = "input_text")]
1784 InputText { text: String },
1785 #[serde(rename = "input_image")]
1787 InputImage { image_url: String },
1788}
1789
1790#[derive(Debug, Clone, Deserialize)]
1792pub struct CompactResponse {
1793 pub output: Vec<CompactOutputItem>,
1795 pub usage: Option<CompactUsage>,
1797}
1798
1799#[derive(Debug, Clone, Serialize, Deserialize)]
1801#[serde(tag = "type")]
1802pub enum CompactOutputItem {
1803 #[serde(rename = "message")]
1805 Message {
1806 role: String,
1807 content: CompactContent,
1808 },
1809 #[serde(rename = "compaction")]
1811 Compaction {
1812 encrypted_content: String,
1814 },
1815}
1816
1817#[derive(Debug, Clone, Deserialize)]
1819pub struct CompactUsage {
1820 pub input_tokens: Option<u32>,
1822 pub output_tokens: Option<u32>,
1824 pub total_tokens: Option<u32>,
1826}
1827
1828impl CompactInputItem {
1833 pub fn from_llm_message(msg: &LlmMessage) -> Vec<Self> {
1838 let mut items = Vec::new();
1839
1840 let role = match msg.role {
1841 LlmMessageRole::System => "developer",
1842 LlmMessageRole::User => "user",
1843 LlmMessageRole::Assistant => "assistant",
1844 LlmMessageRole::Tool => "tool",
1845 };
1846
1847 if msg.role == LlmMessageRole::Tool
1849 && let Some(tool_call_id) = &msg.tool_call_id
1850 {
1851 let output = match &msg.content {
1852 LlmMessageContent::Text(text) => text.clone(),
1853 LlmMessageContent::Parts(parts) => parts
1854 .iter()
1855 .filter_map(|p| match p {
1856 LlmContentPart::Text { text } => Some(text.clone()),
1857 _ => None,
1858 })
1859 .collect::<Vec<_>>()
1860 .join(""),
1861 };
1862 items.push(CompactInputItem::FunctionCallOutput {
1863 call_id: tool_call_id.clone(),
1864 output,
1865 });
1866 return items;
1867 }
1868
1869 let content = Self::content_from_llm_message(msg);
1871 let has_content = match &content {
1872 CompactContent::Text(t) => !t.is_empty(),
1873 CompactContent::Parts(p) => !p.is_empty(),
1874 };
1875
1876 if has_content || msg.tool_calls.is_none() {
1877 items.push(CompactInputItem::Message {
1878 role: role.to_string(),
1879 content,
1880 });
1881 }
1882
1883 if msg.role == LlmMessageRole::Assistant
1885 && let Some(tool_calls) = &msg.tool_calls
1886 {
1887 for tc in tool_calls {
1888 items.push(CompactInputItem::FunctionCall {
1889 call_id: tc.id.clone(),
1890 name: tc.name.clone(),
1891 arguments: tc.arguments.to_string(),
1892 });
1893 }
1894 }
1895
1896 items
1897 }
1898
1899 fn content_from_llm_message(msg: &LlmMessage) -> CompactContent {
1901 match &msg.content {
1902 LlmMessageContent::Text(text) => CompactContent::Text(text.clone()),
1903 LlmMessageContent::Parts(parts) => {
1904 let compact_parts: Vec<CompactContentPart> = parts
1905 .iter()
1906 .filter_map(|part| match part {
1907 LlmContentPart::Text { text } => {
1908 Some(CompactContentPart::InputText { text: text.clone() })
1909 }
1910 LlmContentPart::Image { url } => {
1911 Some(CompactContentPart::InputImage {
1913 image_url: url.clone(),
1914 })
1915 }
1916 LlmContentPart::Audio { .. } => None, })
1918 .collect();
1919 if compact_parts.len() == 1
1920 && let CompactContentPart::InputText { text } = &compact_parts[0]
1921 {
1922 return CompactContent::Text(text.clone());
1923 }
1924 CompactContent::Parts(compact_parts)
1925 }
1926 }
1927 }
1928}
1929
1930impl CompactOutputItem {
1931 pub fn to_llm_message(&self) -> Option<LlmMessage> {
1936 match self {
1937 CompactOutputItem::Message { role, content } => {
1938 let llm_role = match role.as_str() {
1939 "user" => LlmMessageRole::User,
1940 "assistant" => LlmMessageRole::Assistant,
1941 "developer" | "system" => LlmMessageRole::System,
1942 "tool" => LlmMessageRole::Tool,
1943 _ => LlmMessageRole::User, };
1945
1946 let llm_content = match content {
1947 CompactContent::Text(text) => LlmMessageContent::Text(text.clone()),
1948 CompactContent::Parts(parts) => {
1949 let llm_parts: Vec<LlmContentPart> = parts
1950 .iter()
1951 .map(|p| match p {
1952 CompactContentPart::InputText { text } => {
1953 LlmContentPart::Text { text: text.clone() }
1954 }
1955 CompactContentPart::InputImage { image_url } => {
1956 LlmContentPart::Image {
1958 url: image_url.clone(),
1959 }
1960 }
1961 })
1962 .collect();
1963 LlmMessageContent::Parts(llm_parts)
1964 }
1965 };
1966
1967 Some(LlmMessage {
1968 role: llm_role,
1969 content: llm_content,
1970 tool_calls: None,
1971 tool_call_id: None,
1972 phase: None,
1973 thinking: None,
1974 thinking_signature: None,
1975 })
1976 }
1977 CompactOutputItem::Compaction { .. } => {
1978 None
1981 }
1982 }
1983 }
1984}
1985
1986pub fn messages_to_compact_input(messages: &[LlmMessage]) -> Vec<CompactInputItem> {
1988 messages
1989 .iter()
1990 .flat_map(CompactInputItem::from_llm_message)
1991 .collect()
1992}
1993
1994pub fn compact_output_to_messages(
1999 output: &[CompactOutputItem],
2000) -> (Vec<LlmMessage>, Vec<CompactInputItem>) {
2001 let mut messages = Vec::new();
2002 let mut compaction_items = Vec::new();
2003
2004 for item in output {
2005 match item {
2006 CompactOutputItem::Message { role, content } => {
2007 if let Some(msg) = item.to_llm_message() {
2008 messages.push(msg);
2009 } else {
2010 compaction_items.push(CompactInputItem::Message {
2012 role: role.clone(),
2013 content: content.clone(),
2014 });
2015 }
2016 }
2017 CompactOutputItem::Compaction { encrypted_content } => {
2018 compaction_items.push(CompactInputItem::Compaction {
2019 encrypted_content: encrypted_content.clone(),
2020 });
2021 }
2022 }
2023 }
2024
2025 (messages, compaction_items)
2026}
2027
2028#[derive(Debug, Serialize)]
2033struct ResponsesRequest {
2034 model: String,
2035 input: Vec<ResponsesInputItem>,
2036 #[serde(skip_serializing_if = "Option::is_none")]
2037 instructions: Option<String>,
2038 #[serde(skip_serializing_if = "Option::is_none")]
2039 previous_response_id: Option<String>,
2040 #[serde(skip_serializing_if = "Option::is_none")]
2041 temperature: Option<f32>,
2042 #[serde(skip_serializing_if = "Option::is_none")]
2043 max_output_tokens: Option<u32>,
2044 stream: bool,
2045 #[serde(skip_serializing_if = "Option::is_none")]
2046 tools: Option<Vec<ResponsesTool>>,
2047 #[serde(skip_serializing_if = "Option::is_none")]
2048 reasoning: Option<ResponsesReasoning>,
2049 #[serde(skip_serializing_if = "Option::is_none")]
2052 metadata: Option<std::collections::HashMap<String, String>>,
2053 #[serde(skip_serializing_if = "Option::is_none")]
2054 prompt_cache_key: Option<String>,
2055 #[serde(skip_serializing_if = "Option::is_none")]
2058 parallel_tool_calls: Option<bool>,
2059 #[serde(skip_serializing_if = "Option::is_none")]
2062 service_tier: Option<String>,
2063 #[serde(skip_serializing_if = "Option::is_none")]
2066 text: Option<ResponsesText>,
2067}
2068
2069#[derive(Debug, Serialize)]
2072struct ResponsesText {
2073 #[serde(skip_serializing_if = "Option::is_none")]
2074 verbosity: Option<String>,
2075}
2076
2077#[derive(Debug, Serialize)]
2078struct ResponsesReasoning {
2079 effort: String,
2080 summary: String,
2083}
2084
2085#[derive(Debug, Serialize)]
2086#[serde(untagged)]
2087enum ResponsesInputItem {
2088 Message {
2089 r#type: String,
2090 role: String,
2091 content: ResponsesContent,
2092 #[serde(skip_serializing_if = "Option::is_none")]
2096 phase: Option<String>,
2097 },
2098 FunctionCall {
2099 r#type: String,
2100 call_id: String,
2101 name: String,
2102 arguments: String,
2103 },
2104 FunctionCallOutput {
2105 r#type: String,
2106 call_id: String,
2107 output: String,
2108 },
2109 Reasoning {
2119 r#type: String,
2120 id: String,
2122 encrypted_content: String,
2124 },
2125}
2126
2127#[derive(Debug, Serialize, Deserialize)]
2128#[serde(untagged)]
2129enum ResponsesContent {
2130 Text(String),
2131 Parts(Vec<ResponsesContentPart>),
2132}
2133
2134#[derive(Debug, Serialize, Deserialize)]
2136#[serde(untagged)]
2137#[allow(clippy::enum_variant_names)]
2138enum ResponsesContentPart {
2139 InputText {
2140 r#type: String,
2141 text: String,
2142 },
2143 InputImage {
2144 r#type: String,
2145 image_url: String,
2146 },
2147 InputAudio {
2148 r#type: String,
2149 input_audio: ResponsesInputAudio,
2150 },
2151}
2152
2153#[derive(Debug, Serialize, Deserialize)]
2154struct ResponsesInputAudio {
2155 data: String,
2156 format: String,
2157}
2158
2159#[derive(Debug, Serialize)]
2160#[serde(untagged)]
2161enum ResponsesTool {
2162 Function {
2164 r#type: String,
2165 name: String,
2166 description: String,
2167 parameters: Value,
2168 #[serde(skip_serializing_if = "Option::is_none")]
2169 defer_loading: Option<bool>,
2170 },
2171 Namespace {
2173 r#type: String,
2174 name: String,
2175 description: String,
2176 tools: Vec<ResponsesTool>,
2177 },
2178 ToolSearch { r#type: String },
2180}
2181
2182#[cfg(test)]
2187mod tests {
2188 use super::*;
2189
2190 #[test]
2191 fn test_driver_with_api_key() {
2192 let driver = OpenResponsesProtocolChatDriver::new("test-key");
2193 assert!(format!("{:?}", driver).contains("OpenResponsesProtocolChatDriver"));
2194 }
2195
2196 #[test]
2197 fn test_driver_with_base_url() {
2198 let driver = OpenResponsesProtocolChatDriver::with_base_url(
2199 "test-key",
2200 "https://custom.api.com/v1/responses",
2201 );
2202 assert!(format!("{:?}", driver).contains("OpenResponsesProtocolChatDriver"));
2203 assert_eq!(driver.api_url(), "https://custom.api.com/v1/responses");
2204 }
2205
2206 #[test]
2207 fn test_request_serialization() {
2208 let request = ResponsesRequest {
2209 text: None,
2210 service_tier: None,
2211 model: "gpt-4o".to_string(),
2212 input: vec![ResponsesInputItem::Message {
2213 r#type: "message".to_string(),
2214 role: "user".to_string(),
2215 content: ResponsesContent::Text("Hello".to_string()),
2216 phase: None,
2217 }],
2218 instructions: Some("You are helpful".to_string()),
2219 previous_response_id: None,
2220 temperature: None,
2221 max_output_tokens: None,
2222 stream: true,
2223 tools: None,
2224 reasoning: None,
2225 metadata: None,
2226 prompt_cache_key: None,
2227 parallel_tool_calls: None,
2228 };
2229
2230 let json = serde_json::to_value(&request).unwrap();
2231 assert_eq!(json["model"], "gpt-4o");
2232 assert_eq!(json["stream"], true);
2233 assert_eq!(json["instructions"], "You are helpful");
2234 assert!(json["input"].is_array());
2235 }
2236
2237 #[test]
2238 fn test_request_with_reasoning() {
2239 let request = ResponsesRequest {
2240 text: None,
2241 service_tier: None,
2242 model: "o3".to_string(),
2243 input: vec![ResponsesInputItem::Message {
2244 r#type: "message".to_string(),
2245 role: "user".to_string(),
2246 content: ResponsesContent::Text("Think about this".to_string()),
2247 phase: None,
2248 }],
2249 instructions: None,
2250 previous_response_id: None,
2251 temperature: None,
2252 max_output_tokens: None,
2253 stream: true,
2254 tools: None,
2255 reasoning: Some(ResponsesReasoning {
2256 effort: "high".to_string(),
2257 summary: "detailed".to_string(),
2258 }),
2259 metadata: None,
2260 prompt_cache_key: None,
2261 parallel_tool_calls: None,
2262 };
2263
2264 let json = serde_json::to_value(&request).unwrap();
2265 assert_eq!(json["reasoning"]["effort"], "high");
2266 assert_eq!(json["reasoning"]["summary"], "detailed");
2267 }
2268
2269 #[test]
2270 fn test_request_with_metadata() {
2271 let mut metadata = std::collections::HashMap::new();
2272 metadata.insert("session_id".to_string(), "session_abc123".to_string());
2273 metadata.insert("agent_id".to_string(), "agent_xyz789".to_string());
2274
2275 let request = ResponsesRequest {
2276 text: None,
2277 service_tier: None,
2278 model: "gpt-4o".to_string(),
2279 input: vec![ResponsesInputItem::Message {
2280 r#type: "message".to_string(),
2281 role: "user".to_string(),
2282 content: ResponsesContent::Text("Hello".to_string()),
2283 phase: None,
2284 }],
2285 instructions: None,
2286 previous_response_id: None,
2287 temperature: None,
2288 max_output_tokens: None,
2289 stream: true,
2290 tools: None,
2291 reasoning: None,
2292 metadata: Some(metadata),
2293 prompt_cache_key: None,
2294 parallel_tool_calls: None,
2295 };
2296
2297 let json = serde_json::to_value(&request).unwrap();
2298 assert_eq!(json["metadata"]["session_id"], "session_abc123");
2299 assert_eq!(json["metadata"]["agent_id"], "agent_xyz789");
2300 }
2301
2302 #[test]
2305 fn test_request_serializes_parallel_tool_calls() {
2306 let make = |flag: Option<bool>| ResponsesRequest {
2307 text: None,
2308 service_tier: None,
2309 model: "gpt-5.4".to_string(),
2310 input: vec![ResponsesInputItem::Message {
2311 r#type: "message".to_string(),
2312 role: "user".to_string(),
2313 content: ResponsesContent::Text("Hello".to_string()),
2314 phase: None,
2315 }],
2316 instructions: None,
2317 previous_response_id: None,
2318 temperature: None,
2319 max_output_tokens: None,
2320 stream: true,
2321 tools: None,
2322 reasoning: None,
2323 metadata: None,
2324 prompt_cache_key: None,
2325 parallel_tool_calls: flag,
2326 };
2327
2328 let json = serde_json::to_value(make(None)).unwrap();
2330 assert!(json.get("parallel_tool_calls").is_none());
2331
2332 let json = serde_json::to_value(make(Some(true))).unwrap();
2334 assert_eq!(json["parallel_tool_calls"], true);
2335
2336 let json = serde_json::to_value(make(Some(false))).unwrap();
2338 assert_eq!(json["parallel_tool_calls"], false);
2339 }
2340
2341 #[test]
2344 fn test_request_serializes_service_tier() {
2345 let make = |tier: Option<&str>| ResponsesRequest {
2346 service_tier: tier.map(str::to_string),
2347 model: "gpt-5.4".to_string(),
2348 input: vec![ResponsesInputItem::Message {
2349 r#type: "message".to_string(),
2350 role: "user".to_string(),
2351 content: ResponsesContent::Text("Hello".to_string()),
2352 phase: None,
2353 }],
2354 instructions: None,
2355 previous_response_id: None,
2356 temperature: None,
2357 max_output_tokens: None,
2358 stream: true,
2359 tools: None,
2360 reasoning: None,
2361 metadata: None,
2362 prompt_cache_key: None,
2363 parallel_tool_calls: None,
2364 text: None,
2365 };
2366
2367 let json = serde_json::to_value(make(None)).unwrap();
2368 assert!(json.get("service_tier").is_none());
2369
2370 let json = serde_json::to_value(make(Some("priority"))).unwrap();
2371 assert_eq!(json["service_tier"], "priority");
2372
2373 let json = serde_json::to_value(make(Some("flex"))).unwrap();
2374 assert_eq!(json["service_tier"], "flex");
2375 }
2376
2377 #[test]
2380 fn test_request_serializes_verbosity() {
2381 let make = |verbosity: Option<&str>| ResponsesRequest {
2382 service_tier: None,
2383 text: verbosity.map(|v| ResponsesText {
2384 verbosity: Some(v.to_string()),
2385 }),
2386 model: "gpt-5.6-sol".to_string(),
2387 input: vec![ResponsesInputItem::Message {
2388 r#type: "message".to_string(),
2389 role: "user".to_string(),
2390 content: ResponsesContent::Text("Hello".to_string()),
2391 phase: None,
2392 }],
2393 instructions: None,
2394 previous_response_id: None,
2395 temperature: None,
2396 max_output_tokens: None,
2397 stream: true,
2398 tools: None,
2399 reasoning: None,
2400 metadata: None,
2401 prompt_cache_key: None,
2402 parallel_tool_calls: None,
2403 };
2404
2405 let json = serde_json::to_value(make(None)).unwrap();
2406 assert!(json.get("text").is_none());
2407
2408 let json = serde_json::to_value(make(Some("low"))).unwrap();
2409 assert_eq!(json["text"]["verbosity"], "low");
2410
2411 let json = serde_json::to_value(make(Some("high"))).unwrap();
2412 assert_eq!(json["text"]["verbosity"], "high");
2413 }
2414
2415 #[test]
2416 fn test_build_prompt_cache_key_when_enabled() {
2417 let mut metadata = std::collections::HashMap::new();
2418 metadata.insert("session_id".to_string(), "session_abc123".to_string());
2419 let config = LlmCallConfig {
2420 speed: None,
2421 verbosity: None,
2422 model: "gpt-5.4".to_string(),
2423 temperature: None,
2424 max_tokens: None,
2425 tools: vec![],
2426 reasoning_effort: None,
2427 metadata,
2428 previous_response_id: None,
2429 tool_search: None,
2430 prompt_cache: Some(crate::driver_registry::PromptCacheConfig {
2431 enabled: true,
2432 strategy: crate::driver_registry::PromptCacheStrategy::Auto,
2433 gemini_cached_content: None,
2434 }),
2435 openrouter_routing: None,
2436 parallel_tool_calls: None,
2437 volatile_suffix_len: 0,
2438 };
2439 let input = vec![ResponsesInputItem::Message {
2440 r#type: "message".to_string(),
2441 role: "user".to_string(),
2442 content: ResponsesContent::Text("Hello".to_string()),
2443 phase: None,
2444 }];
2445
2446 let key = OpenResponsesProtocolChatDriver::build_prompt_cache_key(
2447 &config,
2448 &input,
2449 &Some("You are helpful".to_string()),
2450 &None,
2451 );
2452
2453 assert!(key.is_some());
2454 assert!(key.unwrap().starts_with("everruns:"));
2455 }
2456
2457 #[test]
2458 fn test_build_prompt_cache_key_ignores_changing_input() {
2459 let mut metadata = std::collections::HashMap::new();
2460 metadata.insert("session_id".to_string(), "session_abc123".to_string());
2461 let config = LlmCallConfig {
2462 speed: None,
2463 verbosity: None,
2464 model: "gpt-5.4".to_string(),
2465 temperature: None,
2466 max_tokens: None,
2467 tools: vec![],
2468 reasoning_effort: None,
2469 metadata,
2470 previous_response_id: None,
2471 tool_search: None,
2472 prompt_cache: Some(crate::driver_registry::PromptCacheConfig {
2473 enabled: true,
2474 strategy: crate::driver_registry::PromptCacheStrategy::Auto,
2475 gemini_cached_content: None,
2476 }),
2477 openrouter_routing: None,
2478 parallel_tool_calls: None,
2479 volatile_suffix_len: 0,
2480 };
2481 let first_input = vec![ResponsesInputItem::Message {
2482 r#type: "message".to_string(),
2483 role: "user".to_string(),
2484 content: ResponsesContent::Text("first turn".to_string()),
2485 phase: None,
2486 }];
2487 let second_input = vec![ResponsesInputItem::Message {
2488 r#type: "message".to_string(),
2489 role: "user".to_string(),
2490 content: ResponsesContent::Text("second turn with different text".to_string()),
2491 phase: None,
2492 }];
2493
2494 let first = OpenResponsesProtocolChatDriver::build_prompt_cache_key(
2495 &config,
2496 &first_input,
2497 &Some("You are helpful".to_string()),
2498 &None,
2499 );
2500 let second = OpenResponsesProtocolChatDriver::build_prompt_cache_key(
2501 &config,
2502 &second_input,
2503 &Some("You are helpful".to_string()),
2504 &None,
2505 );
2506
2507 assert_eq!(first, second);
2508 }
2509
2510 #[test]
2511 fn test_build_prompt_cache_key_changes_with_cache_family() {
2512 let mut first_metadata = std::collections::HashMap::new();
2513 first_metadata.insert("session_id".to_string(), "session_abc123".to_string());
2514 let mut second_metadata = std::collections::HashMap::new();
2515 second_metadata.insert("session_id".to_string(), "session_xyz789".to_string());
2516 let make_config = |metadata| LlmCallConfig {
2517 speed: None,
2518 verbosity: None,
2519 model: "gpt-5.4".to_string(),
2520 temperature: None,
2521 max_tokens: None,
2522 tools: vec![],
2523 reasoning_effort: None,
2524 metadata,
2525 previous_response_id: None,
2526 tool_search: None,
2527 prompt_cache: Some(crate::driver_registry::PromptCacheConfig {
2528 enabled: true,
2529 strategy: crate::driver_registry::PromptCacheStrategy::Auto,
2530 gemini_cached_content: None,
2531 }),
2532 openrouter_routing: None,
2533 parallel_tool_calls: None,
2534 volatile_suffix_len: 0,
2535 };
2536 let input = vec![ResponsesInputItem::Message {
2537 r#type: "message".to_string(),
2538 role: "user".to_string(),
2539 content: ResponsesContent::Text("same turn".to_string()),
2540 phase: None,
2541 }];
2542
2543 let first = OpenResponsesProtocolChatDriver::build_prompt_cache_key(
2544 &make_config(first_metadata),
2545 &input,
2546 &Some("You are helpful".to_string()),
2547 &None,
2548 );
2549 let second = OpenResponsesProtocolChatDriver::build_prompt_cache_key(
2550 &make_config(second_metadata),
2551 &input,
2552 &Some("You are helpful".to_string()),
2553 &None,
2554 );
2555
2556 assert_ne!(first, second);
2557 }
2558
2559 #[test]
2560 fn test_build_prompt_cache_key_stays_within_openai_limit() {
2561 let config = LlmCallConfig {
2562 speed: None,
2563 verbosity: None,
2564 model: "gpt-5.5".to_string(),
2565 temperature: None,
2566 max_tokens: None,
2567 tools: vec![],
2568 reasoning_effort: None,
2569 metadata: std::collections::HashMap::new(),
2570 previous_response_id: None,
2571 tool_search: None,
2572 prompt_cache: Some(crate::driver_registry::PromptCacheConfig {
2573 enabled: true,
2574 strategy: crate::driver_registry::PromptCacheStrategy::Auto,
2575 gemini_cached_content: None,
2576 }),
2577 openrouter_routing: None,
2578 parallel_tool_calls: None,
2579 volatile_suffix_len: 0,
2580 };
2581 let input = vec![ResponsesInputItem::Message {
2582 r#type: "message".to_string(),
2583 role: "user".to_string(),
2584 content: ResponsesContent::Text("fetch chalyi.name for me".to_string()),
2585 phase: None,
2586 }];
2587
2588 let key = OpenResponsesProtocolChatDriver::build_prompt_cache_key(
2589 &config,
2590 &input,
2591 &Some("You are helpful".to_string()),
2592 &None,
2593 )
2594 .unwrap();
2595
2596 assert!(
2597 key.len() <= 64,
2598 "OpenAI prompt_cache_key limit is 64 characters, got {}",
2599 key.len()
2600 );
2601 }
2602
2603 #[test]
2604 fn test_function_call_output_serialization() {
2605 let item = ResponsesInputItem::FunctionCallOutput {
2606 r#type: "function_call_output".to_string(),
2607 call_id: "call_123".to_string(),
2608 output: r#"{"result": 42}"#.to_string(),
2609 };
2610
2611 let json = serde_json::to_value(&item).unwrap();
2612 assert_eq!(json["type"], "function_call_output");
2613 assert_eq!(json["call_id"], "call_123");
2614 assert_eq!(json["output"], r#"{"result": 42}"#);
2615 }
2616
2617 #[test]
2618 fn test_multipart_content_serialization() {
2619 let content = ResponsesContent::Parts(vec![
2620 ResponsesContentPart::InputText {
2621 r#type: "input_text".to_string(),
2622 text: "Look at this image".to_string(),
2623 },
2624 ResponsesContentPart::InputImage {
2625 r#type: "input_image".to_string(),
2626 image_url: "data:image/png;base64,abc123".to_string(),
2627 },
2628 ]);
2629
2630 let json = serde_json::to_value(&content).unwrap();
2631 assert!(json.is_array());
2632 assert_eq!(json[0]["type"], "input_text");
2633 assert_eq!(json[1]["type"], "input_image");
2634 }
2635
2636 #[test]
2637 fn test_tool_serialization() {
2638 let tool = ResponsesTool::Function {
2639 r#type: "function".to_string(),
2640 name: "get_weather".to_string(),
2641 description: "Get weather for a location".to_string(),
2642 parameters: json!({
2643 "type": "object",
2644 "properties": {
2645 "location": {"type": "string"}
2646 },
2647 "required": ["location"]
2648 }),
2649 defer_loading: None,
2650 };
2651
2652 let json = serde_json::to_value(&tool).unwrap();
2653 assert_eq!(json["type"], "function");
2654 assert_eq!(json["name"], "get_weather");
2655 assert!(json["parameters"]["properties"]["location"].is_object());
2656 }
2657
2658 #[test]
2659 fn test_build_input_extracts_system_as_instructions() {
2660 let messages = vec![
2661 LlmMessage::text(LlmMessageRole::System, "You are a helpful assistant"),
2662 LlmMessage::text(LlmMessageRole::User, "Hello"),
2663 ];
2664
2665 let (instructions, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
2666
2667 assert_eq!(
2668 instructions,
2669 Some("You are a helpful assistant".to_string())
2670 );
2671 assert_eq!(input.len(), 1); }
2673
2674 #[test]
2675 fn test_build_input_concatenates_multiple_system_messages() {
2676 let messages = vec![
2680 LlmMessage::text(LlmMessageRole::System, "You are a helpful assistant"),
2681 LlmMessage::text(LlmMessageRole::User, "Hello"),
2682 LlmMessage::text(
2683 LlmMessageRole::System,
2684 "[IMPORTANT: 3 earlier messages are NOT visible in this context.]",
2685 ),
2686 ];
2687
2688 let (instructions, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
2689
2690 assert_eq!(
2691 instructions,
2692 Some(
2693 "You are a helpful assistant\n\n[IMPORTANT: 3 earlier messages are NOT visible in this context.]"
2694 .to_string()
2695 )
2696 );
2697 assert_eq!(input.len(), 1); }
2699
2700 #[test]
2701 fn test_convert_role() {
2702 assert_eq!(
2703 OpenResponsesProtocolChatDriver::convert_role(&LlmMessageRole::System),
2704 "developer"
2705 );
2706 assert_eq!(
2707 OpenResponsesProtocolChatDriver::convert_role(&LlmMessageRole::User),
2708 "user"
2709 );
2710 assert_eq!(
2711 OpenResponsesProtocolChatDriver::convert_role(&LlmMessageRole::Assistant),
2712 "assistant"
2713 );
2714 assert_eq!(
2715 OpenResponsesProtocolChatDriver::convert_role(&LlmMessageRole::Tool),
2716 "tool"
2717 );
2718 }
2719
2720 #[test]
2721 fn test_function_call_serialization() {
2722 let item = ResponsesInputItem::FunctionCall {
2723 r#type: "function_call".to_string(),
2724 call_id: "call_abc123".to_string(),
2725 name: "get_current_time".to_string(),
2726 arguments: r#"{"timezone":"UTC"}"#.to_string(),
2727 };
2728
2729 let json = serde_json::to_value(&item).unwrap();
2730 assert_eq!(json["type"], "function_call");
2731 assert_eq!(json["call_id"], "call_abc123");
2732 assert_eq!(json["name"], "get_current_time");
2733 assert_eq!(json["arguments"], r#"{"timezone":"UTC"}"#);
2734 }
2735
2736 #[test]
2737 fn test_build_input_with_tool_calls() {
2738 use crate::tool_types::ToolCall;
2739
2740 let messages = vec![
2745 LlmMessage::text(LlmMessageRole::System, "You are helpful"),
2746 LlmMessage::text(LlmMessageRole::User, "What time is it?"),
2747 LlmMessage {
2748 role: LlmMessageRole::Assistant,
2749 content: LlmMessageContent::Text(String::new()),
2750 tool_calls: Some(vec![ToolCall {
2751 id: "call_xyz789".to_string(),
2752 name: "get_current_time".to_string(),
2753 arguments: json!({"timezone": "UTC"}),
2754 }]),
2755 tool_call_id: None,
2756 phase: None,
2757 thinking: None,
2758 thinking_signature: None,
2759 },
2760 LlmMessage {
2761 role: LlmMessageRole::Tool,
2762 content: LlmMessageContent::Text("2025-01-19T10:30:00Z".to_string()),
2763 tool_calls: None,
2764 tool_call_id: Some("call_xyz789".to_string()),
2765 phase: None,
2766 thinking: None,
2767 thinking_signature: None,
2768 },
2769 ];
2770
2771 let (instructions, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
2772
2773 assert_eq!(instructions, Some("You are helpful".to_string()));
2775
2776 assert_eq!(input.len(), 3);
2778
2779 let json = serde_json::to_value(&input[1]).unwrap();
2781 assert_eq!(json["type"], "function_call");
2782 assert_eq!(json["call_id"], "call_xyz789");
2783 assert_eq!(json["name"], "get_current_time");
2784
2785 let json = serde_json::to_value(&input[2]).unwrap();
2787 assert_eq!(json["type"], "function_call_output");
2788 assert_eq!(json["call_id"], "call_xyz789");
2789 assert_eq!(json["output"], "2025-01-19T10:30:00Z");
2790 }
2791
2792 #[test]
2793 fn test_build_input_with_tool_calls_and_text() {
2794 use crate::tool_types::ToolCall;
2795
2796 let messages = vec![
2798 LlmMessage::text(LlmMessageRole::User, "What time is it?"),
2799 LlmMessage {
2800 role: LlmMessageRole::Assistant,
2801 content: LlmMessageContent::Text("Let me check the time for you.".to_string()),
2802 tool_calls: Some(vec![ToolCall {
2803 id: "call_abc".to_string(),
2804 name: "get_time".to_string(),
2805 arguments: json!({}),
2806 }]),
2807 tool_call_id: None,
2808 phase: None,
2809 thinking: None,
2810 thinking_signature: None,
2811 },
2812 ];
2813
2814 let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
2815
2816 assert_eq!(input.len(), 3);
2818
2819 let json = serde_json::to_value(&input[0]).unwrap();
2821 assert_eq!(json["role"], "user");
2822
2823 let json = serde_json::to_value(&input[1]).unwrap();
2825 assert_eq!(json["role"], "assistant");
2826
2827 let json = serde_json::to_value(&input[2]).unwrap();
2829 assert_eq!(json["type"], "function_call");
2830 assert_eq!(json["call_id"], "call_abc");
2831 }
2832
2833 #[test]
2848 fn openresponses_requests_should_not_mix_previous_response_id_with_full_transcript() {
2849 use crate::tool_types::ToolCall;
2850
2851 let messages = vec![
2855 LlmMessage::text(LlmMessageRole::System, "You are helpful"),
2856 LlmMessage::text(LlmMessageRole::User, "What time is it?"),
2857 LlmMessage {
2858 role: LlmMessageRole::Assistant,
2859 content: LlmMessageContent::Text("Let me check.".to_string()),
2860 tool_calls: Some(vec![ToolCall {
2861 id: "call_xyz789".to_string(),
2862 name: "get_current_time".to_string(),
2863 arguments: json!({"timezone": "UTC"}),
2864 }]),
2865 tool_call_id: None,
2866 phase: None,
2867 thinking: None,
2868 thinking_signature: None,
2869 },
2870 LlmMessage {
2871 role: LlmMessageRole::Tool,
2872 content: LlmMessageContent::Text("2025-01-19T10:30:00Z".to_string()),
2873 tool_calls: None,
2874 tool_call_id: Some("call_xyz789".to_string()),
2875 phase: None,
2876 thinking: None,
2877 thinking_signature: None,
2878 },
2879 ];
2880
2881 let (instructions, full_input) =
2883 OpenResponsesProtocolChatDriver::build_input(&messages, false);
2884
2885 assert!(
2888 full_input.len() > 1,
2889 "sanity: full transcript has multi items"
2890 );
2891
2892 let delta = compute_delta_input_items(full_input);
2895
2896 assert_eq!(
2898 delta.len(),
2899 1,
2900 "stateful continuation must only send delta items; got {} items",
2901 delta.len()
2902 );
2903 let json = serde_json::to_value(&delta[0]).unwrap();
2904 assert_eq!(json["type"], "function_call_output");
2905 assert_eq!(json["call_id"], "call_xyz789");
2906 assert_eq!(json["output"], "2025-01-19T10:30:00Z");
2907
2908 assert_eq!(instructions, Some("You are helpful".to_string()));
2911 }
2912
2913 #[test]
2918 fn compute_delta_keeps_tail_after_assistant_message() {
2919 let items = vec![
2920 ResponsesInputItem::Message {
2921 r#type: "message".to_string(),
2922 role: "user".to_string(),
2923 content: ResponsesContent::Text("hi".to_string()),
2924 phase: None,
2925 },
2926 ResponsesInputItem::Message {
2927 r#type: "message".to_string(),
2928 role: "assistant".to_string(),
2929 content: ResponsesContent::Text("hello".to_string()),
2930 phase: None,
2931 },
2932 ResponsesInputItem::Message {
2933 r#type: "message".to_string(),
2934 role: "user".to_string(),
2935 content: ResponsesContent::Text("follow up".to_string()),
2936 phase: None,
2937 },
2938 ];
2939 let trimmed = compute_delta_input_items(items);
2940 assert_eq!(trimmed.len(), 1);
2941 let json = serde_json::to_value(&trimmed[0]).unwrap();
2942 assert_eq!(json["role"], "user");
2943 assert_eq!(
2944 json["content"], "follow up",
2945 "trim keeps the fresh user message that arrived after the assistant turn"
2946 );
2947 }
2948
2949 #[test]
2953 fn compute_delta_keeps_tool_results_after_last_assistant_turn() {
2954 let items = vec![
2955 ResponsesInputItem::Message {
2956 r#type: "message".to_string(),
2957 role: "user".to_string(),
2958 content: ResponsesContent::Text("do two things".to_string()),
2959 phase: None,
2960 },
2961 ResponsesInputItem::Message {
2962 r#type: "message".to_string(),
2963 role: "assistant".to_string(),
2964 content: ResponsesContent::Text("ok".to_string()),
2965 phase: None,
2966 },
2967 ResponsesInputItem::FunctionCall {
2968 r#type: "function_call".to_string(),
2969 call_id: "call_a".to_string(),
2970 name: "tool_a".to_string(),
2971 arguments: "{}".to_string(),
2972 },
2973 ResponsesInputItem::FunctionCall {
2974 r#type: "function_call".to_string(),
2975 call_id: "call_b".to_string(),
2976 name: "tool_b".to_string(),
2977 arguments: "{}".to_string(),
2978 },
2979 ResponsesInputItem::FunctionCallOutput {
2980 r#type: "function_call_output".to_string(),
2981 call_id: "call_a".to_string(),
2982 output: "a result".to_string(),
2983 },
2984 ResponsesInputItem::FunctionCallOutput {
2985 r#type: "function_call_output".to_string(),
2986 call_id: "call_b".to_string(),
2987 output: "b result".to_string(),
2988 },
2989 ];
2990
2991 let trimmed = compute_delta_input_items(items);
2992
2993 assert_eq!(trimmed.len(), 2);
2996 for item in &trimmed {
2997 let json = serde_json::to_value(item).unwrap();
2998 assert_eq!(json["type"], "function_call_output");
2999 }
3000 }
3001
3002 #[test]
3005 fn compute_delta_allows_empty_input_for_stateful_continuation() {
3006 let trimmed = compute_delta_input_items(vec![]);
3007 assert!(trimmed.is_empty());
3008 }
3009
3010 #[test]
3013 fn compute_delta_keeps_all_items_when_no_assistant_turn_present() {
3014 let items = vec![
3015 ResponsesInputItem::Message {
3016 r#type: "message".to_string(),
3017 role: "user".to_string(),
3018 content: ResponsesContent::Text("one".to_string()),
3019 phase: None,
3020 },
3021 ResponsesInputItem::Message {
3022 r#type: "message".to_string(),
3023 role: "user".to_string(),
3024 content: ResponsesContent::Text("two".to_string()),
3025 phase: None,
3026 },
3027 ];
3028 let trimmed = compute_delta_input_items(items);
3029 assert_eq!(trimmed.len(), 2);
3030 }
3031
3032 #[test]
3034 fn compute_delta_drops_prior_reasoning_items() {
3035 let items = vec![
3036 ResponsesInputItem::Reasoning {
3037 r#type: "reasoning".to_string(),
3038 id: "rs_00000001".to_string(),
3039 encrypted_content: "encrypted-blob".to_string(),
3040 },
3041 ResponsesInputItem::Message {
3042 r#type: "message".to_string(),
3043 role: "assistant".to_string(),
3044 content: ResponsesContent::Text("prior".to_string()),
3045 phase: None,
3046 },
3047 ResponsesInputItem::FunctionCallOutput {
3048 r#type: "function_call_output".to_string(),
3049 call_id: "call_z".to_string(),
3050 output: "result".to_string(),
3051 },
3052 ];
3053 let trimmed = compute_delta_input_items(items);
3054 assert_eq!(trimmed.len(), 1);
3055 let json = serde_json::to_value(&trimmed[0]).unwrap();
3056 assert_eq!(json["type"], "function_call_output");
3057 }
3058
3059 fn sample_full_transcript_items() -> Vec<ResponsesInputItem> {
3069 vec![
3070 ResponsesInputItem::Message {
3071 r#type: "message".to_string(),
3072 role: "user".to_string(),
3073 content: ResponsesContent::Text("first request".to_string()),
3074 phase: None,
3075 },
3076 ResponsesInputItem::Message {
3077 r#type: "message".to_string(),
3078 role: "assistant".to_string(),
3079 content: ResponsesContent::Text("first reply".to_string()),
3080 phase: None,
3081 },
3082 ResponsesInputItem::Message {
3083 r#type: "message".to_string(),
3084 role: "user".to_string(),
3085 content: ResponsesContent::Text("follow-up".to_string()),
3086 phase: None,
3087 },
3088 ]
3089 }
3090
3091 #[test]
3092 fn finalize_input_skips_trim_when_previous_response_id_is_none() {
3093 let items = sample_full_transcript_items();
3094 let original_len = items.len();
3095 let out = finalize_input_for_request(items, &None);
3096 assert_eq!(
3097 out.len(),
3098 original_len,
3099 "stateless mode keeps the full transcript so the model has context"
3100 );
3101 }
3102
3103 #[test]
3104 fn finalize_input_drops_locally_orphaned_tool_output_without_previous_response_id() {
3105 let items = vec![
3106 ResponsesInputItem::Message {
3107 r#type: "message".to_string(),
3108 role: "user".to_string(),
3109 content: ResponsesContent::Text("fresh".to_string()),
3110 phase: None,
3111 },
3112 ResponsesInputItem::FunctionCallOutput {
3113 r#type: "function_call_output".to_string(),
3114 call_id: "call_trimmed".to_string(),
3115 output: "result".to_string(),
3116 },
3117 ];
3118
3119 let out = finalize_input_for_request(items, &None);
3120
3121 assert_eq!(out.len(), 1);
3122 let json = serde_json::to_value(&out[0]).unwrap();
3123 assert_eq!(json["type"], "message");
3124 }
3125
3126 #[test]
3127 fn finalize_input_keeps_tool_output_with_previous_response_id_even_without_local_call() {
3128 let items = vec![
3129 ResponsesInputItem::FunctionCallOutput {
3130 r#type: "function_call_output".to_string(),
3131 call_id: "call_server_side".to_string(),
3132 output: "stateful result".to_string(),
3133 },
3134 ResponsesInputItem::Message {
3135 r#type: "message".to_string(),
3136 role: "user".to_string(),
3137 content: ResponsesContent::Text("follow-up".to_string()),
3138 phase: None,
3139 },
3140 ];
3141
3142 let out = finalize_input_for_request(items, &Some("resp_prev_42".to_string()));
3143
3144 assert_eq!(out.len(), 2);
3145 let json = serde_json::to_value(&out[0]).unwrap();
3146 assert_eq!(json["type"], "function_call_output");
3147 assert_eq!(json["call_id"], "call_server_side");
3148 }
3149
3150 #[test]
3151 fn finalize_input_trims_when_previous_response_id_is_set() {
3152 let items = sample_full_transcript_items();
3153 let out = finalize_input_for_request(items, &Some("resp_prev_42".to_string()));
3154 assert_eq!(
3155 out.len(),
3156 1,
3157 "stateful continuation must drop everything up to and including the prior assistant message"
3158 );
3159 let json = serde_json::to_value(&out[0]).unwrap();
3160 assert_eq!(json["type"], "message");
3161 assert_eq!(json["role"], "user");
3162 let txt = json["content"].as_str().unwrap_or("");
3164 assert_eq!(txt, "follow-up");
3165 }
3166
3167 #[test]
3168 fn finalize_input_allows_empty_input_with_previous_response_id() {
3169 let out = finalize_input_for_request(vec![], &Some("resp_anything".to_string()));
3170 assert!(
3171 out.is_empty(),
3172 "empty delta is valid — the provider can resume purely from the response id"
3173 );
3174 }
3175
3176 fn function_call(call_id: &str, name: &str) -> ResponsesInputItem {
3185 ResponsesInputItem::FunctionCall {
3186 r#type: "function_call".to_string(),
3187 call_id: call_id.to_string(),
3188 name: name.to_string(),
3189 arguments: "{}".to_string(),
3190 }
3191 }
3192
3193 fn function_call_output(call_id: &str) -> ResponsesInputItem {
3194 ResponsesInputItem::FunctionCallOutput {
3195 r#type: "function_call_output".to_string(),
3196 call_id: call_id.to_string(),
3197 output: "result".to_string(),
3198 }
3199 }
3200
3201 fn user_message(text: &str) -> ResponsesInputItem {
3202 ResponsesInputItem::Message {
3203 r#type: "message".to_string(),
3204 role: "user".to_string(),
3205 content: ResponsesContent::Text(text.to_string()),
3206 phase: None,
3207 }
3208 }
3209
3210 #[test]
3211 fn finalize_input_drops_dangling_function_call_without_previous_response_id() {
3212 let items = vec![
3216 user_message("fresh"),
3217 function_call("call_pHJNxIuwzLppFsQK5nJrDOpZ", "read_file"),
3218 ];
3219
3220 let out = finalize_input_for_request(items, &None);
3221
3222 assert_eq!(out.len(), 1);
3223 assert!(
3224 unpaired_function_call_ids(&out).is_empty(),
3225 "the dangling function_call must be dropped"
3226 );
3227 let json = serde_json::to_value(&out[0]).unwrap();
3228 assert_eq!(json["type"], "message");
3229 }
3230
3231 #[test]
3232 fn finalize_input_preserves_paired_function_call_and_output() {
3233 let items = vec![
3234 user_message("what time is it?"),
3235 function_call("call_ok", "get_current_time"),
3236 function_call_output("call_ok"),
3237 ];
3238
3239 let out = finalize_input_for_request(items, &None);
3240
3241 assert_eq!(out.len(), 3, "an intact call/output pair must survive");
3242 assert!(unpaired_function_call_ids(&out).is_empty());
3243 }
3244
3245 #[test]
3246 fn finalize_input_compaction_drops_only_the_dangling_old_call() {
3247 let mut items = vec![
3252 user_message("long session"),
3253 function_call("call_old", "read_file"),
3254 ];
3255 for i in 0..3 {
3256 let id = format!("call_recent_{i}");
3257 items.push(function_call(&id, "tool"));
3258 items.push(function_call_output(&id));
3259 }
3260
3261 let out = finalize_input_for_request(items, &None);
3262
3263 assert!(
3264 unpaired_function_call_ids(&out).is_empty(),
3265 "no dangling function_call may remain after repair"
3266 );
3267 assert!(
3268 !out.iter().any(|item| matches!(
3269 item,
3270 ResponsesInputItem::FunctionCall { call_id, .. } if call_id == "call_old"
3271 )),
3272 "the old dangling call must be removed"
3273 );
3274 assert_eq!(out.len(), 7);
3276 }
3277
3278 #[test]
3279 fn unpaired_function_call_ids_reports_both_directions() {
3280 let items = vec![
3281 function_call("call_no_output", "read_file"), function_call_output("out_no_call"), function_call("paired", "tool"),
3284 function_call_output("paired"),
3285 ];
3286
3287 let mut ids = unpaired_function_call_ids(&items);
3288 ids.sort();
3289 assert_eq!(
3290 ids,
3291 vec!["call_no_output".to_string(), "out_no_call".to_string()]
3292 );
3293 }
3294
3295 #[test]
3300 fn endpoint_persists_responses_for_openai_and_azure() {
3301 assert!(endpoint_persists_responses(
3303 "https://api.openai.com/v1/responses"
3304 ));
3305 assert!(endpoint_persists_responses(
3306 "https://api.openai.com:443/v1/responses"
3307 ));
3308 assert!(endpoint_persists_responses(
3310 "https://my-resource.openai.azure.com/openai/v1/responses"
3311 ));
3312 assert!(endpoint_persists_responses(
3313 "https://my-resource.services.ai.azure.com/openai/v1/responses"
3314 ));
3315 }
3316
3317 #[test]
3318 fn endpoint_does_not_persist_for_stateless_gateways() {
3319 assert!(!endpoint_persists_responses(
3323 "https://openrouter.ai/api/v1/responses"
3324 ));
3325 assert!(!endpoint_persists_responses(
3326 "https://generativelanguage.googleapis.com/v1beta/openai/responses"
3327 ));
3328 assert!(!endpoint_persists_responses(
3330 "https://api.openai.example.com/v1/responses"
3331 ));
3332 }
3333
3334 #[test]
3339 fn stateless_gateway_replays_full_transcript_despite_previous_response_id() {
3340 let api_url = "https://openrouter.ai/api/v1/responses";
3341 let prev_id: Option<String> = Some("gen-turn-1".to_string());
3342
3343 let effective_prev_id = if endpoint_persists_responses(api_url) {
3345 prev_id.clone()
3346 } else {
3347 None
3348 };
3349 assert!(
3350 effective_prev_id.is_none(),
3351 "stateless gateway must not chain via previous_response_id"
3352 );
3353
3354 let items = sample_full_transcript_items();
3355 let original_len = items.len();
3356 let out = finalize_input_for_request(items, &effective_prev_id);
3357 assert_eq!(
3358 out.len(),
3359 original_len,
3360 "stateless gateway must replay the full transcript so the model keeps context"
3361 );
3362 }
3363
3364 #[test]
3368 fn stateful_endpoint_still_trims_and_chains() {
3369 let api_url = "https://api.openai.com/v1/responses";
3370 let prev_id: Option<String> = Some("resp_turn_1".to_string());
3371
3372 let effective_prev_id = if endpoint_persists_responses(api_url) {
3373 prev_id.clone()
3374 } else {
3375 None
3376 };
3377 assert_eq!(
3378 effective_prev_id, prev_id,
3379 "stateful endpoint keeps the continuation handle"
3380 );
3381
3382 let out = finalize_input_for_request(sample_full_transcript_items(), &effective_prev_id);
3383 assert_eq!(out.len(), 1, "stateful endpoint trims to the delta window");
3384 }
3385
3386 #[tokio::test]
3392 async fn stateless_gateway_request_replays_full_transcript_on_the_wire() {
3393 use crate::tool_types::ToolCall;
3394 use serde_json::json;
3395 use wiremock::matchers::method;
3396 use wiremock::{Mock, MockServer, ResponseTemplate};
3397
3398 let server = MockServer::start().await;
3399 Mock::given(method("POST"))
3402 .respond_with(ResponseTemplate::new(200).set_body_string(""))
3403 .mount(&server)
3404 .await;
3405
3406 let api_url = format!("{}/v1/responses", server.uri());
3409 let driver = OpenResponsesProtocolChatDriver::with_base_url("test-key", api_url);
3410
3411 let messages = vec![
3412 LlmMessage::text(LlmMessageRole::System, "You are helpful"),
3413 LlmMessage::text(LlmMessageRole::User, "upgrade dependencies"),
3414 LlmMessage {
3415 role: LlmMessageRole::Assistant,
3416 content: LlmMessageContent::Text("Let me look.".to_string()),
3417 tool_calls: Some(vec![ToolCall {
3418 id: "call_1".to_string(),
3419 name: "read_file".to_string(),
3420 arguments: json!({"path": "Cargo.toml"}),
3421 }]),
3422 tool_call_id: None,
3423 phase: None,
3424 thinking: None,
3425 thinking_signature: None,
3426 },
3427 LlmMessage {
3428 role: LlmMessageRole::Tool,
3429 content: LlmMessageContent::Text("[package]…".to_string()),
3430 tool_calls: None,
3431 tool_call_id: Some("call_1".to_string()),
3432 phase: None,
3433 thinking: None,
3434 thinking_signature: None,
3435 },
3436 ];
3437
3438 let config = LlmCallConfig {
3439 speed: None,
3440 verbosity: None,
3441 model: "some/model".to_string(),
3442 temperature: None,
3443 max_tokens: None,
3444 tools: vec![],
3445 reasoning_effort: None,
3446 metadata: std::collections::HashMap::new(),
3447 previous_response_id: Some("gen-turn-1".to_string()),
3450 tool_search: None,
3451 prompt_cache: None,
3452 openrouter_routing: None,
3453 parallel_tool_calls: None,
3454 volatile_suffix_len: 0,
3455 };
3456
3457 let _ = driver.chat_completion_stream(messages, &config).await;
3459
3460 let requests = server
3461 .received_requests()
3462 .await
3463 .expect("mock server recorded requests");
3464 assert_eq!(requests.len(), 1, "exactly one request should be sent");
3465 let body: serde_json::Value = requests[0].body_json().expect("request body is JSON");
3466
3467 assert!(
3469 body.get("previous_response_id").is_none(),
3470 "stateless gateway request must omit previous_response_id; body: {body}"
3471 );
3472
3473 let input = body["input"].as_array().expect("input is an array");
3476 assert_eq!(
3477 input.len(),
3478 4,
3479 "full transcript must be replayed on a stateless gateway; got {input:?}"
3480 );
3481 assert_eq!(body["instructions"], "You are helpful");
3482 let has_user_task = input
3483 .iter()
3484 .any(|item| item["type"] == "message" && item["role"] == "user");
3485 assert!(
3486 has_user_task,
3487 "the original user task must be replayed; got {input:?}"
3488 );
3489 let has_tool_output = input
3490 .iter()
3491 .any(|item| item["type"] == "function_call_output");
3492 assert!(
3493 has_tool_output,
3494 "the latest tool result must still be present; got {input:?}"
3495 );
3496 }
3497
3498 #[tokio::test]
3499 async fn openrouter_provider_does_not_send_hosted_tool_search() {
3500 use crate::tool_types::DeferrablePolicy;
3501 use serde_json::json;
3502 use wiremock::matchers::method;
3503 use wiremock::{Mock, MockServer, ResponseTemplate};
3504
3505 let server = MockServer::start().await;
3506 Mock::given(method("POST"))
3507 .respond_with(ResponseTemplate::new(200).set_body_string(""))
3508 .mount(&server)
3509 .await;
3510
3511 let api_url = format!("{}/v1/responses", server.uri());
3512 let driver = OpenResponsesProtocolChatDriver::with_base_url("test-key", api_url)
3513 .with_provider_type(DriverId::OpenRouter);
3514
3515 let tools: Vec<ToolDefinition> = (0..16)
3516 .map(|i| {
3517 make_tool(
3518 &format!("tool_{i}"),
3519 Some("General"),
3520 DeferrablePolicy::Automatic,
3521 )
3522 })
3523 .collect();
3524
3525 let config = LlmCallConfig {
3526 speed: None,
3527 verbosity: None,
3528 model: "gpt-5.4".to_string(),
3529 temperature: None,
3530 max_tokens: None,
3531 tools,
3532 reasoning_effort: None,
3533 metadata: std::collections::HashMap::new(),
3534 previous_response_id: None,
3535 tool_search: Some(crate::driver_registry::ToolSearchConfig {
3536 enabled: true,
3537 threshold: 15,
3538 }),
3539 prompt_cache: None,
3540 openrouter_routing: None,
3541 parallel_tool_calls: None,
3542 volatile_suffix_len: 0,
3543 };
3544
3545 let messages = vec![LlmMessage::text(LlmMessageRole::User, "hello")];
3546 let _ = driver.chat_completion_stream(messages, &config).await;
3547
3548 let requests = server
3549 .received_requests()
3550 .await
3551 .expect("mock server recorded requests");
3552 assert_eq!(requests.len(), 1, "exactly one request should be sent");
3553 let body: serde_json::Value = requests[0].body_json().expect("request body is JSON");
3554 let tools = body["tools"].as_array().expect("tools is an array");
3555
3556 assert!(
3557 tools.iter().all(|tool| tool["type"] == "function"),
3558 "OpenRouter should receive regular function tools, not hosted tool_search payloads: {tools:?}"
3559 );
3560 assert!(
3561 tools.iter().all(|tool| tool.get("defer_loading").is_none()),
3562 "OpenRouter tool schemas should not be deferred by hosted tool_search: {tools:?}"
3563 );
3564 assert_eq!(
3565 body["input"],
3566 json!([{"type": "message", "role": "user", "content": "hello"}])
3567 );
3568 }
3569
3570 #[tokio::test]
3571 async fn openai_provider_omits_openrouter_routing_controls() {
3572 use crate::driver_registry::{OpenRouterRoute, OpenRouterRoutingConfig};
3573 use wiremock::matchers::method;
3574 use wiremock::{Mock, MockServer, ResponseTemplate};
3575
3576 let server = MockServer::start().await;
3577 Mock::given(method("POST"))
3578 .respond_with(ResponseTemplate::new(200).set_body_string(""))
3579 .mount(&server)
3580 .await;
3581
3582 let api_url = format!("{}/v1/responses", server.uri());
3583 let driver = OpenResponsesProtocolChatDriver::with_base_url("test-key", api_url);
3584
3585 let mut metadata = std::collections::HashMap::new();
3586 metadata.insert("session_id".to_string(), "session_abc123".to_string());
3587 let config = LlmCallConfig {
3588 speed: None,
3589 verbosity: None,
3590 model: "gpt-5-mini".to_string(),
3591 temperature: None,
3592 max_tokens: None,
3593 tools: vec![],
3594 reasoning_effort: None,
3595 metadata,
3596 previous_response_id: None,
3597 tool_search: None,
3598 prompt_cache: None,
3599 openrouter_routing: Some(OpenRouterRoutingConfig {
3600 models: vec!["openai/gpt-5-mini".to_string()],
3601 route: Some(OpenRouterRoute::Fallback),
3602 provider: None,
3603 ..Default::default()
3604 }),
3605 parallel_tool_calls: None,
3606 volatile_suffix_len: 0,
3607 };
3608
3609 let messages = vec![LlmMessage::text(LlmMessageRole::User, "hello")];
3610 let _ = driver.chat_completion_stream(messages, &config).await;
3611
3612 let requests = server
3613 .received_requests()
3614 .await
3615 .expect("mock server recorded requests");
3616 assert_eq!(requests.len(), 1, "exactly one request should be sent");
3617 let body: serde_json::Value = requests[0].body_json().expect("request body is JSON");
3618
3619 assert!(body.get("models").is_none(), "body: {body}");
3620 assert!(body.get("route").is_none(), "body: {body}");
3621 assert!(body.get("provider").is_none(), "body: {body}");
3622 assert!(body.get("session_id").is_none(), "body: {body}");
3625 assert_eq!(body["metadata"]["session_id"], "session_abc123");
3626 }
3627
3628 #[tokio::test]
3634 async fn openresponses_stream_skips_done_sentinel() {
3635 use futures::StreamExt;
3636 use wiremock::matchers::method;
3637 use wiremock::{Mock, MockServer, ResponseTemplate};
3638
3639 let body =
3641 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\ndata: [DONE]\n\n";
3642 let server = MockServer::start().await;
3643 Mock::given(method("POST"))
3644 .respond_with(
3645 ResponseTemplate::new(200)
3646 .insert_header("content-type", "text/event-stream")
3647 .set_body_string(body),
3648 )
3649 .mount(&server)
3650 .await;
3651
3652 let api_url = format!("{}/v1/responses", server.uri());
3653 let driver = OpenResponsesProtocolChatDriver::with_base_url("test-key", api_url);
3654 let config = LlmCallConfig {
3655 speed: None,
3656 verbosity: None,
3657 model: "openai/gpt-4o-mini".to_string(),
3658 temperature: None,
3659 max_tokens: None,
3660 tools: vec![],
3661 reasoning_effort: None,
3662 metadata: std::collections::HashMap::new(),
3663 previous_response_id: None,
3664 tool_search: None,
3665 prompt_cache: None,
3666 openrouter_routing: None,
3667 parallel_tool_calls: None,
3668 volatile_suffix_len: 0,
3669 };
3670
3671 let stream = driver
3672 .chat_completion_stream(vec![LlmMessage::text(LlmMessageRole::User, "hi")], &config)
3673 .await
3674 .expect("stream should start");
3675 let events: Vec<_> = stream.collect().await;
3676
3677 let mut text = String::new();
3678 for ev in &events {
3679 match ev.as_ref().expect("no transport error") {
3680 LlmStreamEvent::TextDelta(d) => text.push_str(d),
3681 LlmStreamEvent::Error(e) => {
3682 panic!("[DONE] sentinel must not surface as an error: {e}")
3683 }
3684 _ => {}
3685 }
3686 }
3687 assert_eq!(text, "hi");
3688 }
3689
3690 #[test]
3695 fn test_compact_request_serialization() {
3696 let request = CompactRequest {
3697 model: "gpt-4o".to_string(),
3698 input: vec![
3699 CompactInputItem::Message {
3700 role: "user".to_string(),
3701 content: CompactContent::Text("Hello!".to_string()),
3702 },
3703 CompactInputItem::Message {
3704 role: "assistant".to_string(),
3705 content: CompactContent::Text("Hi there!".to_string()),
3706 },
3707 ],
3708 previous_response_id: None,
3709 instructions: Some("Be helpful".to_string()),
3710 };
3711
3712 let json = serde_json::to_value(&request).unwrap();
3713 assert_eq!(json["model"], "gpt-4o");
3714 assert_eq!(json["instructions"], "Be helpful");
3715 assert!(json["input"].is_array());
3716 assert_eq!(json["input"].as_array().unwrap().len(), 2);
3717 }
3718
3719 #[test]
3720 fn test_compact_input_item_message_serialization() {
3721 let item = CompactInputItem::Message {
3722 role: "user".to_string(),
3723 content: CompactContent::Text("Test message".to_string()),
3724 };
3725
3726 let json = serde_json::to_value(&item).unwrap();
3727 assert_eq!(json["type"], "message");
3728 assert_eq!(json["role"], "user");
3729 assert_eq!(json["content"], "Test message");
3730 }
3731
3732 #[test]
3733 fn test_compact_input_item_function_call_serialization() {
3734 let item = CompactInputItem::FunctionCall {
3735 call_id: "call_123".to_string(),
3736 name: "get_weather".to_string(),
3737 arguments: r#"{"city":"NYC"}"#.to_string(),
3738 };
3739
3740 let json = serde_json::to_value(&item).unwrap();
3741 assert_eq!(json["type"], "function_call");
3742 assert_eq!(json["call_id"], "call_123");
3743 assert_eq!(json["name"], "get_weather");
3744 assert_eq!(json["arguments"], r#"{"city":"NYC"}"#);
3745 }
3746
3747 #[test]
3748 fn test_compact_input_item_compaction_serialization() {
3749 let item = CompactInputItem::Compaction {
3750 encrypted_content: "encrypted_data_here".to_string(),
3751 };
3752
3753 let json = serde_json::to_value(&item).unwrap();
3754 assert_eq!(json["type"], "compaction");
3755 assert_eq!(json["encrypted_content"], "encrypted_data_here");
3756 }
3757
3758 #[test]
3759 fn test_compact_output_item_deserialization() {
3760 let json = r#"{
3761 "type": "message",
3762 "role": "user",
3763 "content": "Hello"
3764 }"#;
3765
3766 let item: CompactOutputItem = serde_json::from_str(json).unwrap();
3767 match item {
3768 CompactOutputItem::Message { role, content } => {
3769 assert_eq!(role, "user");
3770 match content {
3771 CompactContent::Text(text) => assert_eq!(text, "Hello"),
3772 _ => panic!("Expected text content"),
3773 }
3774 }
3775 _ => panic!("Expected Message item"),
3776 }
3777 }
3778
3779 #[test]
3780 fn test_compact_output_compaction_deserialization() {
3781 let json = r#"{
3782 "type": "compaction",
3783 "encrypted_content": "abc123encrypted"
3784 }"#;
3785
3786 let item: CompactOutputItem = serde_json::from_str(json).unwrap();
3787 match item {
3788 CompactOutputItem::Compaction { encrypted_content } => {
3789 assert_eq!(encrypted_content, "abc123encrypted");
3790 }
3791 _ => panic!("Expected Compaction item"),
3792 }
3793 }
3794
3795 #[test]
3796 fn test_compact_response_deserialization() {
3797 let json = r#"{
3798 "output": [
3799 {"type": "message", "role": "user", "content": "Hello"},
3800 {"type": "compaction", "encrypted_content": "xyz789"}
3801 ],
3802 "usage": {
3803 "input_tokens": 100,
3804 "output_tokens": 50,
3805 "total_tokens": 150
3806 }
3807 }"#;
3808
3809 let response: CompactResponse = serde_json::from_str(json).unwrap();
3810 assert_eq!(response.output.len(), 2);
3811 assert!(response.usage.is_some());
3812 let usage = response.usage.unwrap();
3813 assert_eq!(usage.input_tokens, Some(100));
3814 assert_eq!(usage.output_tokens, Some(50));
3815 assert_eq!(usage.total_tokens, Some(150));
3816 }
3817
3818 #[test]
3819 fn test_compact_content_parts_serialization() {
3820 let content = CompactContent::Parts(vec![
3821 CompactContentPart::InputText {
3822 text: "Check this image".to_string(),
3823 },
3824 CompactContentPart::InputImage {
3825 image_url: "data:image/png;base64,abc".to_string(),
3826 },
3827 ]);
3828
3829 let json = serde_json::to_value(&content).unwrap();
3830 assert!(json.is_array());
3831 assert_eq!(json[0]["type"], "input_text");
3832 assert_eq!(json[0]["text"], "Check this image");
3833 assert_eq!(json[1]["type"], "input_image");
3834 }
3835
3836 #[test]
3837 fn test_supports_compact_default_url() {
3838 let driver = OpenResponsesProtocolChatDriver::new("test-key");
3839 assert!(driver.supports_compact());
3841 }
3842
3843 #[test]
3844 fn test_supports_compact_custom_url() {
3845 let driver = OpenResponsesProtocolChatDriver::with_base_url(
3846 "test-key",
3847 "https://custom.api.com/v1/responses",
3848 );
3849 assert!(!driver.supports_compact());
3851 }
3852
3853 #[test]
3858 fn test_reasoning_input_item_serialization() {
3859 let item = ResponsesInputItem::Reasoning {
3860 r#type: "reasoning".to_string(),
3861 id: "rs_00000001".to_string(),
3862 encrypted_content: "encrypted_reasoning_context_here".to_string(),
3863 };
3864
3865 let json = serde_json::to_value(&item).unwrap();
3866 assert_eq!(json["type"], "reasoning");
3867 assert_eq!(json["id"], "rs_00000001");
3868 assert_eq!(
3869 json["encrypted_content"],
3870 "encrypted_reasoning_context_here"
3871 );
3872 }
3873
3874 #[test]
3875 fn test_build_input_with_thinking_signature() {
3876 let messages = vec![
3878 LlmMessage::text(LlmMessageRole::User, "Think about this deeply"),
3879 LlmMessage {
3880 role: LlmMessageRole::Assistant,
3881 content: LlmMessageContent::Text("I have thought about this.".to_string()),
3882 tool_calls: None,
3883 tool_call_id: None,
3884 phase: None,
3885 thinking: Some("This is my chain of thought reasoning...".to_string()),
3886 thinking_signature: Some("encrypted_reasoning_token_123".to_string()),
3887 },
3888 LlmMessage::text(LlmMessageRole::User, "What else?"),
3889 ];
3890
3891 let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
3892
3893 assert_eq!(input.len(), 4);
3895
3896 let json = serde_json::to_value(&input[0]).unwrap();
3898 assert_eq!(json["role"], "user");
3899 assert_eq!(json["content"], "Think about this deeply");
3900
3901 let json = serde_json::to_value(&input[1]).unwrap();
3903 assert_eq!(json["type"], "reasoning");
3904 assert_eq!(json["encrypted_content"], "encrypted_reasoning_token_123");
3905
3906 let json = serde_json::to_value(&input[2]).unwrap();
3908 assert_eq!(json["role"], "assistant");
3909 assert_eq!(json["content"], "I have thought about this.");
3910
3911 let json = serde_json::to_value(&input[3]).unwrap();
3913 assert_eq!(json["role"], "user");
3914 }
3915
3916 #[test]
3917 fn test_build_input_with_thinking_signature_and_tool_calls() {
3918 use crate::tool_types::ToolCall;
3919
3920 let messages = vec![
3922 LlmMessage::text(LlmMessageRole::User, "What time is it? Think carefully."),
3923 LlmMessage {
3924 role: LlmMessageRole::Assistant,
3925 content: LlmMessageContent::Text("Let me check.".to_string()),
3926 tool_calls: Some(vec![ToolCall {
3927 id: "call_123".to_string(),
3928 name: "get_time".to_string(),
3929 arguments: json!({}),
3930 }]),
3931 tool_call_id: None,
3932 phase: None,
3933 thinking: Some("I need to call the get_time tool...".to_string()),
3934 thinking_signature: Some("encrypted_token_xyz".to_string()),
3935 },
3936 LlmMessage {
3937 role: LlmMessageRole::Tool,
3938 content: LlmMessageContent::Text("10:30 AM".to_string()),
3939 tool_calls: None,
3940 tool_call_id: Some("call_123".to_string()),
3941 phase: None,
3942 thinking: None,
3943 thinking_signature: None,
3944 },
3945 ];
3946
3947 let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
3948
3949 assert_eq!(input.len(), 5);
3951
3952 let json = serde_json::to_value(&input[1]).unwrap();
3954 assert_eq!(json["type"], "reasoning");
3955 assert_eq!(json["encrypted_content"], "encrypted_token_xyz");
3956
3957 let json = serde_json::to_value(&input[2]).unwrap();
3959 assert_eq!(json["role"], "assistant");
3960
3961 let json = serde_json::to_value(&input[3]).unwrap();
3963 assert_eq!(json["type"], "function_call");
3964 assert_eq!(json["call_id"], "call_123");
3965
3966 let json = serde_json::to_value(&input[4]).unwrap();
3968 assert_eq!(json["type"], "function_call_output");
3969 }
3970
3971 #[test]
3972 fn test_build_input_without_thinking_signature() {
3973 let messages = vec![
3975 LlmMessage::text(LlmMessageRole::User, "Hello"),
3976 LlmMessage {
3977 role: LlmMessageRole::Assistant,
3978 content: LlmMessageContent::Text("Hi there!".to_string()),
3979 tool_calls: None,
3980 tool_call_id: None,
3981 phase: None,
3982 thinking: Some("Some thinking...".to_string()),
3983 thinking_signature: None, },
3985 ];
3986
3987 let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
3988
3989 assert_eq!(input.len(), 2);
3991
3992 let json = serde_json::to_value(&input[0]).unwrap();
3994 assert_eq!(json["role"], "user");
3995
3996 let json = serde_json::to_value(&input[1]).unwrap();
3997 assert_eq!(json["role"], "assistant");
3998 }
3999
4000 #[test]
4001 fn test_handle_streaming_event_reasoning_encrypted_content() {
4002 use std::sync::Mutex;
4003
4004 let input_tokens = Mutex::new(0u32);
4005 let output_tokens = Mutex::new(0u32);
4006 let cache_read_tokens = Mutex::new(None);
4007 let accumulated_tool_calls = Mutex::new(Vec::new());
4008 let finish_reason = Mutex::new(None);
4009
4010 let event = StreamingEvent::OutputItemDone {
4012 sequence_number: 5,
4013 output_index: 0,
4014 item: Some(types::OutputItem::Reasoning {
4015 id: "rs_001".to_string(),
4016 summary: vec![],
4017 content: None,
4018 encrypted_content: Some("encrypted_reasoning_data".to_string()),
4019 }),
4020 };
4021
4022 let result = handle_streaming_event(
4023 event,
4024 &input_tokens,
4025 &output_tokens,
4026 &cache_read_tokens,
4027 &accumulated_tool_calls,
4028 &finish_reason,
4029 "gpt-5".to_string(),
4030 None,
4031 );
4032
4033 match result {
4035 LlmStreamEvent::ReasonItem {
4036 provider,
4037 model,
4038 item_id,
4039 encrypted_content,
4040 summary,
4041 token_count,
4042 } => {
4043 assert_eq!(provider, "openai");
4044 assert_eq!(model.as_deref(), Some("gpt-5"));
4045 assert_eq!(item_id, "rs_001");
4046 assert_eq!(
4047 encrypted_content.as_deref(),
4048 Some("encrypted_reasoning_data")
4049 );
4050 assert!(summary.is_empty());
4051 assert!(token_count.is_none());
4052 }
4053 other => panic!("Expected ReasonItem event, got {:?}", other),
4054 }
4055 }
4056
4057 #[test]
4058 fn response_failed_preserves_provider_error_code() {
4059 use std::sync::Mutex;
4060
4061 let event: StreamingEvent = serde_json::from_value(serde_json::json!({
4062 "type": "response.failed",
4063 "sequence_number": 7,
4064 "response": {
4065 "id": "resp_failed",
4066 "object": "response",
4067 "created_at": 1,
4068 "status": "failed",
4069 "model": "gpt-5",
4070 "output": [],
4071 "tools": [],
4072 "error": {
4073 "code": "processing_error",
4074 "message": "An error occurred while processing your request."
4075 }
4076 }
4077 }))
4078 .expect("response.failed should deserialize");
4079
4080 let result = handle_streaming_event(
4081 event,
4082 &Mutex::new(0),
4083 &Mutex::new(0),
4084 &Mutex::new(None),
4085 &Mutex::new(Vec::new()),
4086 &Mutex::new(None),
4087 "gpt-5".to_string(),
4088 None,
4089 );
4090
4091 let LlmStreamEvent::Error(error) = result else {
4092 panic!("expected structured stream error");
4093 };
4094 assert_eq!(error.code.as_deref(), Some("processing_error"));
4095 assert!(crate::llm_retry::is_transient_stream_error(&error));
4096 }
4097
4098 #[test]
4099 fn test_handle_streaming_event_reasoning_without_encrypted_content() {
4100 use std::sync::Mutex;
4101
4102 let input_tokens = Mutex::new(0u32);
4103 let output_tokens = Mutex::new(0u32);
4104 let cache_read_tokens = Mutex::new(None);
4105 let accumulated_tool_calls = Mutex::new(Vec::new());
4106 let finish_reason = Mutex::new(None);
4107
4108 let event = StreamingEvent::OutputItemDone {
4110 sequence_number: 5,
4111 output_index: 0,
4112 item: Some(types::OutputItem::Reasoning {
4113 id: "rs_001".to_string(),
4114 summary: vec![types::ContentPart::SummaryText {
4115 text: "Some summary".to_string(),
4116 }],
4117 content: None,
4118 encrypted_content: None, }),
4120 };
4121
4122 let result = handle_streaming_event(
4123 event,
4124 &input_tokens,
4125 &output_tokens,
4126 &cache_read_tokens,
4127 &accumulated_tool_calls,
4128 &finish_reason,
4129 "gpt-5".to_string(),
4130 None,
4131 );
4132
4133 match result {
4136 LlmStreamEvent::ReasonItem {
4137 provider,
4138 item_id,
4139 encrypted_content,
4140 summary,
4141 ..
4142 } => {
4143 assert_eq!(provider, "openai");
4144 assert_eq!(item_id, "rs_001");
4145 assert!(encrypted_content.is_none());
4146 assert_eq!(summary, vec!["Some summary".to_string()]);
4147 }
4148 other => panic!("Expected ReasonItem event, got {:?}", other),
4149 }
4150 }
4151
4152 #[test]
4153 fn test_handle_streaming_event_reasoning_drops_plaintext_content() {
4154 use std::sync::Mutex;
4155
4156 let input_tokens = Mutex::new(0u32);
4157 let output_tokens = Mutex::new(0u32);
4158 let cache_read_tokens = Mutex::new(None);
4159 let accumulated_tool_calls = Mutex::new(Vec::new());
4160 let finish_reason = Mutex::new(None);
4161
4162 let event = StreamingEvent::OutputItemDone {
4165 sequence_number: 5,
4166 output_index: 0,
4167 item: Some(types::OutputItem::Reasoning {
4168 id: "rs_002".to_string(),
4169 summary: vec![
4170 types::ContentPart::SummaryText {
4171 text: "safe summary".to_string(),
4172 },
4173 types::ContentPart::ReasoningText {
4174 text: "SECRET hidden reasoning".to_string(),
4175 },
4176 ],
4177 content: Some(vec![types::ContentPart::ReasoningText {
4178 text: "SECRET hidden reasoning".to_string(),
4179 }]),
4180 encrypted_content: Some("opaque".to_string()),
4181 }),
4182 };
4183
4184 let result = handle_streaming_event(
4185 event,
4186 &input_tokens,
4187 &output_tokens,
4188 &cache_read_tokens,
4189 &accumulated_tool_calls,
4190 &finish_reason,
4191 "gpt-5".to_string(),
4192 None,
4193 );
4194
4195 match result {
4196 LlmStreamEvent::ReasonItem {
4197 summary,
4198 encrypted_content,
4199 ..
4200 } => {
4201 assert_eq!(summary, vec!["safe summary".to_string()]);
4202 assert_eq!(encrypted_content.as_deref(), Some("opaque"));
4203 }
4204 other => panic!("Expected ReasonItem event, got {:?}", other),
4205 }
4206 }
4207
4208 #[test]
4209 fn test_handle_streaming_event_reasoning_delta() {
4210 use std::sync::Mutex;
4211
4212 let input_tokens = Mutex::new(0u32);
4213 let output_tokens = Mutex::new(0u32);
4214 let cache_read_tokens = Mutex::new(None);
4215 let accumulated_tool_calls = Mutex::new(Vec::new());
4216 let finish_reason = Mutex::new(None);
4217
4218 let event = StreamingEvent::ReasoningDelta {
4220 sequence_number: 3,
4221 item_id: "rs_001".to_string(),
4222 output_index: 0,
4223 content_index: 0,
4224 delta: "Let me reason about this...".to_string(),
4225 obfuscation: None,
4226 };
4227
4228 let result = handle_streaming_event(
4229 event,
4230 &input_tokens,
4231 &output_tokens,
4232 &cache_read_tokens,
4233 &accumulated_tool_calls,
4234 &finish_reason,
4235 "o3".to_string(),
4236 None,
4237 );
4238
4239 match result {
4240 LlmStreamEvent::ThinkingDelta(text) => {
4241 assert_eq!(text, "Let me reason about this...");
4242 }
4243 _ => panic!("Expected ThinkingDelta, got {:?}", result),
4244 }
4245 }
4246
4247 #[test]
4248 fn test_handle_streaming_event_reasoning_summary_delta() {
4249 use std::sync::Mutex;
4250
4251 let input_tokens = Mutex::new(0u32);
4252 let output_tokens = Mutex::new(0u32);
4253 let cache_read_tokens = Mutex::new(None);
4254 let accumulated_tool_calls = Mutex::new(Vec::new());
4255 let finish_reason = Mutex::new(None);
4256
4257 let event = StreamingEvent::ReasoningSummaryDelta {
4259 sequence_number: 4,
4260 item_id: "rs_002".to_string(),
4261 output_index: 0,
4262 summary_index: 0,
4263 delta: "Breaking down the problem...".to_string(),
4264 obfuscation: None,
4265 };
4266
4267 let result = handle_streaming_event(
4268 event,
4269 &input_tokens,
4270 &output_tokens,
4271 &cache_read_tokens,
4272 &accumulated_tool_calls,
4273 &finish_reason,
4274 "gpt-5.2".to_string(),
4275 None,
4276 );
4277
4278 match result {
4279 LlmStreamEvent::TextDelta(text) => {
4280 assert_eq!(text, "Breaking down the problem...");
4281 }
4282 _ => panic!("Expected TextDelta, got {:?}", result),
4283 }
4284 }
4285
4286 #[test]
4287 fn test_request_reasoning_none_is_omitted() {
4288 let config = LlmCallConfig {
4291 speed: None,
4292 verbosity: None,
4293 model: "gpt-5.2".to_string(),
4294 temperature: None,
4295 max_tokens: None,
4296 tools: vec![],
4297 reasoning_effort: Some("none".to_string()),
4298 metadata: std::collections::HashMap::new(),
4299 previous_response_id: None,
4300 tool_search: None,
4301 prompt_cache: None,
4302 openrouter_routing: None,
4303 parallel_tool_calls: None,
4304 volatile_suffix_len: 0,
4305 };
4306
4307 let reasoning = config
4309 .reasoning_effort
4310 .as_ref()
4311 .filter(|e| !e.eq_ignore_ascii_case("none"))
4312 .map(|effort| ResponsesReasoning {
4313 effort: effort.clone(),
4314 summary: "detailed".to_string(),
4315 });
4316
4317 assert!(
4318 reasoning.is_none(),
4319 "reasoning should be None for effort=none"
4320 );
4321 }
4322
4323 #[test]
4324 fn test_request_reasoning_high_is_included() {
4325 let config = LlmCallConfig {
4327 speed: None,
4328 verbosity: None,
4329 model: "gpt-5.2".to_string(),
4330 temperature: None,
4331 max_tokens: None,
4332 tools: vec![],
4333 reasoning_effort: Some("high".to_string()),
4334 metadata: std::collections::HashMap::new(),
4335 previous_response_id: None,
4336 tool_search: None,
4337 prompt_cache: None,
4338 openrouter_routing: None,
4339 parallel_tool_calls: None,
4340 volatile_suffix_len: 0,
4341 };
4342
4343 let reasoning = config
4344 .reasoning_effort
4345 .as_ref()
4346 .filter(|e| !e.eq_ignore_ascii_case("none"))
4347 .map(|effort| ResponsesReasoning {
4348 effort: effort.clone(),
4349 summary: "detailed".to_string(),
4350 });
4351
4352 assert!(
4353 reasoning.is_some(),
4354 "reasoning should be present for effort=high"
4355 );
4356 let r = reasoning.unwrap();
4357 assert_eq!(r.effort, "high");
4358 assert_eq!(r.summary, "detailed");
4359 }
4360
4361 #[test]
4362 fn test_request_reasoning_none_case_insensitive() {
4363 for effort in &["none", "None", "NONE"] {
4365 let reasoning = Some(effort.to_string())
4366 .as_ref()
4367 .filter(|e| !e.eq_ignore_ascii_case("none"))
4368 .cloned();
4369
4370 assert!(
4371 reasoning.is_none(),
4372 "effort={effort:?} should be filtered out"
4373 );
4374 }
4375 }
4376
4377 #[test]
4378 fn test_build_input_assistant_without_thinking_or_tools() {
4379 let messages = vec![
4381 LlmMessage::text(LlmMessageRole::User, "Hello"),
4382 LlmMessage {
4383 role: LlmMessageRole::Assistant,
4384 content: LlmMessageContent::Text("Hi there!".to_string()),
4385 tool_calls: None,
4386 tool_call_id: None,
4387 phase: None,
4388 thinking: None,
4389 thinking_signature: None,
4390 },
4391 ];
4392
4393 let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
4394
4395 assert_eq!(input.len(), 2);
4396 let json = serde_json::to_value(&input[1]).unwrap();
4397 assert_eq!(json["role"], "assistant");
4398 assert!(json.get("type").is_none() || json["type"] == "message");
4399 }
4400
4401 #[test]
4402 fn test_build_input_multiple_reasoning_items_get_unique_ids() {
4403 let messages = vec![
4405 LlmMessage::text(LlmMessageRole::User, "First question"),
4406 LlmMessage {
4407 role: LlmMessageRole::Assistant,
4408 content: LlmMessageContent::Text("First answer.".to_string()),
4409 tool_calls: None,
4410 tool_call_id: None,
4411 phase: None,
4412 thinking: Some("thinking 1".to_string()),
4413 thinking_signature: Some("encrypted_1".to_string()),
4414 },
4415 LlmMessage::text(LlmMessageRole::User, "Second question"),
4416 LlmMessage {
4417 role: LlmMessageRole::Assistant,
4418 content: LlmMessageContent::Text("Second answer.".to_string()),
4419 tool_calls: None,
4420 tool_call_id: None,
4421 phase: None,
4422 thinking: Some("thinking 2".to_string()),
4423 thinking_signature: Some("encrypted_2".to_string()),
4424 },
4425 ];
4426
4427 let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
4428
4429 assert_eq!(input.len(), 6);
4431
4432 let r1 = serde_json::to_value(&input[1]).unwrap();
4433 let r2 = serde_json::to_value(&input[4]).unwrap();
4434
4435 assert_eq!(r1["type"], "reasoning");
4436 assert_eq!(r2["type"], "reasoning");
4437 assert_ne!(r1["id"], r2["id"], "Reasoning items should have unique IDs");
4438 assert_eq!(r1["encrypted_content"], "encrypted_1");
4439 assert_eq!(r2["encrypted_content"], "encrypted_2");
4440 }
4441
4442 #[test]
4443 fn test_build_input_with_phases_enabled() {
4444 use crate::message::ExecutionPhase;
4445
4446 let messages = vec![
4447 LlmMessage::text(LlmMessageRole::System, "You are helpful"),
4448 LlmMessage::text(LlmMessageRole::User, "Hello"),
4449 LlmMessage {
4450 role: LlmMessageRole::Assistant,
4451 content: LlmMessageContent::Text("Working on it...".to_string()),
4452 tool_calls: Some(vec![crate::tool_types::ToolCall {
4453 id: "call_1".to_string(),
4454 name: "search".to_string(),
4455 arguments: json!({}),
4456 }]),
4457 tool_call_id: None,
4458 phase: Some(ExecutionPhase::Commentary),
4459 thinking: None,
4460 thinking_signature: None,
4461 },
4462 LlmMessage {
4463 role: LlmMessageRole::Tool,
4464 content: LlmMessageContent::Text("result".to_string()),
4465 tool_calls: None,
4466 tool_call_id: Some("call_1".to_string()),
4467 phase: None,
4468 thinking: None,
4469 thinking_signature: None,
4470 },
4471 ];
4472
4473 let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, true);
4475 let assistant_json = serde_json::to_value(&input[1]).unwrap();
4476 assert_eq!(assistant_json["phase"], "commentary");
4477
4478 let (_, input_no_phases) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
4480 let assistant_json_no = serde_json::to_value(&input_no_phases[1]).unwrap();
4481 assert!(assistant_json_no.get("phase").is_none() || assistant_json_no["phase"].is_null());
4482 }
4483
4484 fn make_tool(
4490 name: &str,
4491 category: Option<&str>,
4492 deferrable: crate::tool_types::DeferrablePolicy,
4493 ) -> ToolDefinition {
4494 ToolDefinition::Builtin(crate::tool_types::BuiltinTool {
4495 name: name.to_string(),
4496 display_name: None,
4497 description: format!("{} description", name),
4498 parameters: json!({"type": "object", "properties": {}}),
4499 policy: crate::tool_types::ToolPolicy::Auto,
4500 category: category.map(|s| s.to_string()),
4501 deferrable,
4502 hints: crate::tool_types::ToolHints::default(),
4503 full_parameters: None,
4504 })
4505 }
4506
4507 #[test]
4508 fn test_convert_tools_with_search_below_threshold_falls_back() {
4509 use crate::tool_types::DeferrablePolicy;
4510
4511 let tools: Vec<ToolDefinition> = (0..5)
4512 .map(|i| {
4513 make_tool(
4514 &format!("tool_{i}"),
4515 Some("cat"),
4516 DeferrablePolicy::Automatic,
4517 )
4518 })
4519 .collect();
4520
4521 let result = OpenResponsesProtocolChatDriver::convert_tools_with_search(&tools, 15);
4523 assert_eq!(result.len(), 5);
4524 let json = serde_json::to_value(&result).unwrap();
4526 for item in json.as_array().unwrap() {
4527 assert_eq!(item["type"], "function");
4528 assert!(item.get("defer_loading").is_none() || item["defer_loading"].is_null());
4529 }
4530 }
4531
4532 #[test]
4533 fn test_convert_tools_with_search_groups_by_category() {
4534 use crate::tool_types::DeferrablePolicy;
4535
4536 let mut tools = vec![];
4537 for i in 0..10 {
4539 tools.push(make_tool(
4540 &format!("fs_tool_{i}"),
4541 Some("FileSystem"),
4542 DeferrablePolicy::Automatic,
4543 ));
4544 }
4545 for i in 0..6 {
4546 tools.push(make_tool(
4547 &format!("weather_tool_{i}"),
4548 Some("Weather"),
4549 DeferrablePolicy::Automatic,
4550 ));
4551 }
4552
4553 let result = OpenResponsesProtocolChatDriver::convert_tools_with_search(&tools, 15);
4554 let json = serde_json::to_value(&result).unwrap();
4555 let arr = json.as_array().unwrap();
4556
4557 assert_eq!(arr.len(), 3);
4559
4560 assert_eq!(arr.last().unwrap()["type"], "tool_search");
4562
4563 let ns: Vec<&Value> = arr.iter().filter(|v| v["type"] == "namespace").collect();
4565 assert_eq!(ns.len(), 2);
4566
4567 let ns_names: Vec<&str> = ns.iter().map(|v| v["name"].as_str().unwrap()).collect();
4568 assert!(ns_names.contains(&"FileSystem"));
4569 assert!(ns_names.contains(&"Weather"));
4570
4571 for n in &ns {
4573 let inner_tools = n["tools"].as_array().unwrap();
4574 match n["name"].as_str().unwrap() {
4575 "FileSystem" => assert_eq!(inner_tools.len(), 10),
4576 "Weather" => assert_eq!(inner_tools.len(), 6),
4577 other => panic!("Unexpected namespace: {other}"),
4578 }
4579 for t in inner_tools {
4581 assert_eq!(t["defer_loading"], true);
4582 }
4583 }
4584 }
4585
4586 #[test]
4587 fn test_convert_tools_with_search_never_defer_stays_top_level() {
4588 use crate::tool_types::DeferrablePolicy;
4589
4590 let mut tools = vec![];
4591 tools.push(make_tool(
4593 "write_todos",
4594 Some("Productivity"),
4595 DeferrablePolicy::Never,
4596 ));
4597 tools.push(make_tool(
4598 "get_session_info",
4599 Some("Session"),
4600 DeferrablePolicy::Never,
4601 ));
4602 for i in 0..14 {
4604 tools.push(make_tool(
4605 &format!("fs_tool_{i}"),
4606 Some("FileSystem"),
4607 DeferrablePolicy::Automatic,
4608 ));
4609 }
4610
4611 let result = OpenResponsesProtocolChatDriver::convert_tools_with_search(&tools, 15);
4612 let json = serde_json::to_value(&result).unwrap();
4613 let arr = json.as_array().unwrap();
4614
4615 assert_eq!(arr.len(), 4);
4617
4618 let funcs: Vec<&Value> = arr.iter().filter(|v| v["type"] == "function").collect();
4620 assert_eq!(funcs.len(), 2);
4621 for f in &funcs {
4622 assert!(f.get("defer_loading").is_none() || f["defer_loading"].is_null());
4624 }
4625
4626 let ns: Vec<&Value> = arr.iter().filter(|v| v["type"] == "namespace").collect();
4628 assert_eq!(ns.len(), 1);
4629 assert_eq!(ns[0]["name"], "FileSystem");
4630 assert_eq!(ns[0]["tools"].as_array().unwrap().len(), 14);
4631 }
4632
4633 #[test]
4634 fn test_convert_tools_with_search_ungrouped_tools() {
4635 use crate::tool_types::DeferrablePolicy;
4636
4637 let mut tools = vec![];
4638 for i in 0..10 {
4640 tools.push(make_tool(
4641 &format!("cat_tool_{i}"),
4642 Some("Cat"),
4643 DeferrablePolicy::Automatic,
4644 ));
4645 }
4646 for i in 0..6 {
4648 tools.push(make_tool(
4649 &format!("misc_tool_{i}"),
4650 None,
4651 DeferrablePolicy::Automatic,
4652 ));
4653 }
4654
4655 let result = OpenResponsesProtocolChatDriver::convert_tools_with_search(&tools, 15);
4656 let json = serde_json::to_value(&result).unwrap();
4657 let arr = json.as_array().unwrap();
4658
4659 assert_eq!(arr.len(), 8);
4661
4662 let ns: Vec<&Value> = arr.iter().filter(|v| v["type"] == "namespace").collect();
4663 assert_eq!(ns.len(), 1);
4664 assert_eq!(ns[0]["tools"].as_array().unwrap().len(), 10);
4665
4666 let funcs: Vec<&Value> = arr.iter().filter(|v| v["type"] == "function").collect();
4667 assert_eq!(funcs.len(), 6);
4668 for f in &funcs {
4670 assert_eq!(f["defer_loading"], true);
4671 }
4672
4673 assert_eq!(arr.last().unwrap()["type"], "tool_search");
4674 }
4675
4676 #[test]
4677 fn test_convert_tools_with_search_always_policy() {
4678 use crate::tool_types::DeferrablePolicy;
4679
4680 let mut tools = vec![];
4681 for i in 0..14 {
4683 tools.push(make_tool(
4684 &format!("tool_{i}"),
4685 Some("General"),
4686 DeferrablePolicy::Automatic,
4687 ));
4688 }
4689 tools.push(make_tool(
4691 "always_tool",
4692 Some("General"),
4693 DeferrablePolicy::Always,
4694 ));
4695
4696 let result = OpenResponsesProtocolChatDriver::convert_tools_with_search(&tools, 15);
4698 let json = serde_json::to_value(&result).unwrap();
4699 let arr = json.as_array().unwrap();
4700
4701 assert_eq!(arr.len(), 2);
4703
4704 let ns = &arr[0];
4705 assert_eq!(ns["type"], "namespace");
4706 let inner = ns["tools"].as_array().unwrap();
4707 assert_eq!(inner.len(), 15);
4708 for t in inner {
4710 assert_eq!(t["defer_loading"], true);
4711 }
4712 }
4713
4714 #[test]
4715 fn test_tool_search_serialization_format() {
4716 let ts = ResponsesTool::ToolSearch {
4718 r#type: "tool_search".to_string(),
4719 };
4720 let json = serde_json::to_value(&ts).unwrap();
4721 assert_eq!(json, json!({"type": "tool_search"}));
4722 }
4723
4724 #[test]
4725 fn test_namespace_serialization_format() {
4726 let ns = ResponsesTool::Namespace {
4727 r#type: "namespace".to_string(),
4728 name: "FileSystem".to_string(),
4729 description: "Tools for FileSystem".to_string(),
4730 tools: vec![ResponsesTool::Function {
4731 r#type: "function".to_string(),
4732 name: "read_file".to_string(),
4733 description: "Read a file".to_string(),
4734 parameters: json!({}),
4735 defer_loading: Some(true),
4736 }],
4737 };
4738 let json = serde_json::to_value(&ns).unwrap();
4739 assert_eq!(json["type"], "namespace");
4740 assert_eq!(json["name"], "FileSystem");
4741 assert_eq!(json["tools"][0]["name"], "read_file");
4742 assert_eq!(json["tools"][0]["defer_loading"], true);
4743 }
4744
4745 #[test]
4746 fn test_hosted_tool_search_completed_event_preserves_response_id() {
4747 let event_json = r#"{
4748 "type": "response.completed",
4749 "sequence_number": 8,
4750 "response": {
4751 "id": "resp_tool_search",
4752 "object": "response",
4753 "created_at": 1780000000,
4754 "status": "completed",
4755 "model": "gpt-5.5",
4756 "output": [
4757 {
4758 "type": "tool_search_call",
4759 "execution": "server",
4760 "call_id": null,
4761 "status": "completed",
4762 "arguments": { "paths": ["Math"] }
4763 },
4764 {
4765 "type": "tool_search_output",
4766 "execution": "server",
4767 "call_id": null,
4768 "status": "completed",
4769 "tools": [
4770 {
4771 "type": "namespace",
4772 "name": "Math",
4773 "description": "Tools for Math",
4774 "tools": [
4775 {
4776 "type": "function",
4777 "name": "add",
4778 "description": "Add numbers.",
4779 "defer_loading": true,
4780 "parameters": {
4781 "type": "object",
4782 "properties": {
4783 "a": { "type": "number" },
4784 "b": { "type": "number" }
4785 },
4786 "required": ["a", "b"],
4787 "additionalProperties": false
4788 }
4789 }
4790 ]
4791 }
4792 ]
4793 },
4794 {
4795 "type": "function_call",
4796 "id": "fc_123",
4797 "call_id": "call_123",
4798 "name": "add",
4799 "namespace": "Math",
4800 "arguments": "{\"a\":7,\"b\":3}",
4801 "status": "completed"
4802 }
4803 ],
4804 "usage": {
4805 "input_tokens": 10,
4806 "output_tokens": 5,
4807 "total_tokens": 15
4808 }
4809 }
4810 }"#;
4811
4812 let event: StreamingEvent = serde_json::from_str(event_json).unwrap();
4813 let stream_event = handle_streaming_event(
4814 event,
4815 &Mutex::new(0),
4816 &Mutex::new(0),
4817 &Mutex::new(None),
4818 &Mutex::new(Vec::new()),
4819 &Mutex::new(Some("tool_calls".to_string())),
4820 "gpt-5.5".to_string(),
4821 None,
4822 );
4823
4824 match stream_event {
4825 LlmStreamEvent::Done(metadata) => {
4826 assert_eq!(metadata.response_id.as_deref(), Some("resp_tool_search"));
4827 assert_eq!(metadata.finish_reason.as_deref(), Some("tool_calls"));
4828 }
4829 other => panic!("expected Done event, got {other:?}"),
4830 }
4831 }
4832
4833 #[test]
4834 fn test_completed_event_normalizes_cache_inclusive_prompt_tokens() {
4835 let event_json = r#"{
4839 "type": "response.completed",
4840 "sequence_number": 9,
4841 "response": {
4842 "id": "resp_cache",
4843 "object": "response",
4844 "created_at": 1780000000,
4845 "status": "completed",
4846 "model": "gpt-5.5",
4847 "output": [],
4848 "usage": {
4849 "input_tokens": 1000,
4850 "output_tokens": 20,
4851 "total_tokens": 1020,
4852 "input_tokens_details": { "cached_tokens": 800 }
4853 }
4854 }
4855 }"#;
4856
4857 let event: StreamingEvent = serde_json::from_str(event_json).unwrap();
4858 let stream_event = handle_streaming_event(
4859 event,
4860 &Mutex::new(0),
4861 &Mutex::new(0),
4862 &Mutex::new(None),
4863 &Mutex::new(Vec::new()),
4864 &Mutex::new(None),
4865 "gpt-5.5".to_string(),
4866 None,
4867 );
4868
4869 match stream_event {
4870 LlmStreamEvent::Done(metadata) => {
4871 assert_eq!(metadata.prompt_tokens, Some(200));
4873 assert_eq!(metadata.cache_read_tokens, Some(800));
4874 assert_eq!(metadata.total_tokens, Some(1020));
4876 }
4877 other => panic!("expected Done event, got {other:?}"),
4878 }
4879 }
4880
4881 #[test]
4882 fn test_sanitize_parameters_adds_missing_properties() {
4883 let params = json!({"type": "object", "additionalProperties": false});
4884 let sanitized = OpenResponsesProtocolChatDriver::sanitize_parameters(¶ms);
4885 assert_eq!(
4886 sanitized,
4887 json!({"type": "object", "properties": {}, "additionalProperties": false})
4888 );
4889 }
4890
4891 #[test]
4892 fn test_sanitize_parameters_preserves_existing_properties() {
4893 let params = json!({"type": "object", "properties": {"x": {"type": "string"}}, "additionalProperties": false});
4894 let sanitized = OpenResponsesProtocolChatDriver::sanitize_parameters(¶ms);
4895 assert_eq!(sanitized, params);
4896 }
4897
4898 #[test]
4899 fn test_sanitize_parameters_ignores_non_object_types() {
4900 let params = json!({"type": "string"});
4901 let sanitized = OpenResponsesProtocolChatDriver::sanitize_parameters(¶ms);
4902 assert_eq!(sanitized, params);
4903 }
4904
4905 fn auth_test_config() -> LlmCallConfig {
4911 LlmCallConfig {
4912 speed: None,
4913 verbosity: None,
4914 model: "gpt-5.4".to_string(),
4915 temperature: None,
4916 max_tokens: None,
4917 tools: vec![],
4918 reasoning_effort: None,
4919 metadata: std::collections::HashMap::new(),
4920 previous_response_id: None,
4921 tool_search: None,
4922 prompt_cache: None,
4923 openrouter_routing: None,
4924 parallel_tool_calls: None,
4925 volatile_suffix_len: 0,
4926 }
4927 }
4928
4929 struct CountingAuth {
4932 header: (String, String),
4933 calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
4934 }
4935
4936 #[async_trait::async_trait]
4937 impl AuthHeaderProvider for CountingAuth {
4938 async fn auth_header(&self) -> Result<(String, String)> {
4939 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4940 Ok(self.header.clone())
4941 }
4942 }
4943
4944 struct HeaderInjectingExtension;
4947
4948 impl OpenResponsesRequestExtension for HeaderInjectingExtension {
4949 fn decorate(&self, _body: &mut Value, _config: &LlmCallConfig) -> Result<()> {
4950 Ok(())
4951 }
4952
4953 fn decorate_headers(&self, headers: &mut HeaderMap, _config: &LlmCallConfig) -> Result<()> {
4954 headers.insert("x-openrouter-route", HeaderValue::from_static("fallback"));
4955 headers.insert(
4957 "authorization",
4958 HeaderValue::from_static("Bearer decoration"),
4959 );
4960 Ok(())
4961 }
4962 }
4963
4964 #[tokio::test]
4965 async fn resolve_auth_header_defaults_to_bearer_on_non_azure() {
4966 let driver = OpenResponsesProtocolChatDriver::new("secret-key");
4967 let (name, value) = driver
4968 .resolve_auth_header("https://api.openai.com/v1/responses")
4969 .await
4970 .expect("auth resolves");
4971 assert_eq!(name.as_str(), "authorization");
4972 assert_eq!(value.to_str().unwrap(), "Bearer secret-key");
4973 }
4974
4975 #[tokio::test]
4976 async fn resolve_auth_header_uses_api_key_header_on_azure() {
4977 let driver = OpenResponsesProtocolChatDriver::new("secret-key");
4978 let (name, value) = driver
4979 .resolve_auth_header("https://my-resource.openai.azure.com/openai/v1/responses")
4980 .await
4981 .expect("auth resolves");
4982 assert_eq!(name.as_str(), "api-key");
4983 assert_eq!(value.to_str().unwrap(), "secret-key");
4984 }
4985
4986 #[tokio::test]
4987 async fn resolve_auth_header_prefers_provider_over_static_key() {
4988 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
4989 let driver = OpenResponsesProtocolChatDriver::new("ignored-key").with_auth_provider(
4990 std::sync::Arc::new(CountingAuth {
4991 header: (
4992 "Authorization".to_string(),
4993 "Bearer minted-token".to_string(),
4994 ),
4995 calls: calls.clone(),
4996 }),
4997 );
4998 let (name, value) = driver
5000 .resolve_auth_header("https://my-resource.openai.azure.com/openai/v1/responses")
5001 .await
5002 .expect("auth resolves");
5003 assert_eq!(name.as_str(), "authorization");
5004 assert_eq!(value.to_str().unwrap(), "Bearer minted-token");
5005 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
5006 }
5007
5008 #[tokio::test]
5009 async fn default_static_auth_applied_on_the_wire() {
5010 use wiremock::matchers::{header, method};
5011 use wiremock::{Mock, MockServer, ResponseTemplate};
5012
5013 let server = MockServer::start().await;
5014 Mock::given(method("POST"))
5015 .and(header("authorization", "Bearer wire-key"))
5016 .respond_with(ResponseTemplate::new(200).set_body_string(""))
5017 .mount(&server)
5018 .await;
5019
5020 let api_url = format!("{}/v1/responses", server.uri());
5021 let driver = OpenResponsesProtocolChatDriver::with_base_url("wire-key", api_url);
5022 let messages = vec![LlmMessage::text(LlmMessageRole::User, "hi")];
5023 let _ = driver
5024 .chat_completion_stream(messages, &auth_test_config())
5025 .await;
5026
5027 let requests = server.received_requests().await.unwrap();
5028 assert_eq!(
5029 requests.len(),
5030 1,
5031 "default static key must authenticate the request"
5032 );
5033 }
5034
5035 #[tokio::test]
5036 async fn auth_provider_header_wins_over_extension_header() {
5037 use wiremock::matchers::{header, method};
5038 use wiremock::{Mock, MockServer, ResponseTemplate};
5039
5040 let server = MockServer::start().await;
5041 Mock::given(method("POST"))
5044 .and(header("authorization", "Bearer minted-token"))
5045 .and(header("x-openrouter-route", "fallback"))
5046 .respond_with(ResponseTemplate::new(200).set_body_string(""))
5047 .mount(&server)
5048 .await;
5049
5050 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
5051 let api_url = format!("{}/v1/responses", server.uri());
5052 let driver = OpenResponsesProtocolChatDriver::with_base_url("ignored", api_url)
5053 .with_request_extension(std::sync::Arc::new(HeaderInjectingExtension))
5054 .with_auth_provider(std::sync::Arc::new(CountingAuth {
5055 header: (
5056 "Authorization".to_string(),
5057 "Bearer minted-token".to_string(),
5058 ),
5059 calls: calls.clone(),
5060 }));
5061
5062 let messages = vec![LlmMessage::text(LlmMessageRole::User, "hi")];
5063 let _ = driver
5064 .chat_completion_stream(messages, &auth_test_config())
5065 .await;
5066
5067 let requests = server.received_requests().await.unwrap();
5068 assert_eq!(
5069 requests.len(),
5070 1,
5071 "auth header must win over a conflicting decoration header"
5072 );
5073 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
5074 }
5075
5076 #[tokio::test]
5077 async fn auth_provider_awaited_on_each_retry_attempt() {
5078 use wiremock::matchers::method;
5079 use wiremock::{Mock, MockServer, ResponseTemplate};
5080
5081 let server = MockServer::start().await;
5082 Mock::given(method("POST"))
5085 .respond_with(ResponseTemplate::new(503).set_body_string("overloaded"))
5086 .mount(&server)
5087 .await;
5088
5089 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
5090 let api_url = format!("{}/v1/responses", server.uri());
5091 let fast_retry = LlmRetryConfig {
5092 max_retries: 1,
5093 initial_backoff: std::time::Duration::from_millis(1),
5094 max_backoff: std::time::Duration::from_millis(1),
5095 backoff_multiplier: 1.0,
5096 jitter_factor: 0.0,
5097 };
5098 let driver = OpenResponsesProtocolChatDriver::with_base_url("ignored", api_url)
5099 .with_retry_config(fast_retry)
5100 .with_auth_provider(std::sync::Arc::new(CountingAuth {
5101 header: (
5102 "Authorization".to_string(),
5103 "Bearer minted-token".to_string(),
5104 ),
5105 calls: calls.clone(),
5106 }));
5107
5108 let messages = vec![LlmMessage::text(LlmMessageRole::User, "hi")];
5109 let _ = driver
5110 .chat_completion_stream(messages, &auth_test_config())
5111 .await;
5112
5113 assert_eq!(
5115 calls.load(std::sync::atomic::Ordering::SeqCst),
5116 2,
5117 "refreshable auth must be resolved per HTTP attempt, including retries"
5118 );
5119 }
5120}