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