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