Skip to main content

everruns_core/
openresponses_protocol.rs

1// Open Responses Protocol Driver
2//
3// Implementation of the Open Responses specification (https://www.openresponses.org/)
4// an open-source, vendor-neutral API standard for multi-provider LLM interfaces.
5//
6// Rate limit handling: On 429 errors, the driver automatically retries with
7// exponential backoff, respecting x-ratelimit-reset-* and retry-after headers.
8// Retry metadata is included in the response for observability.
9//
10// The spec is inspired by and interoperable with the OpenAI Responses API, offering:
11// - One spec, many providers (OpenAI, Anthropic, Gemini, local models)
12// - Agentic loop support with tool calls and state machines
13// - Semantic streaming events (not raw text deltas)
14// - 40-80% better cache utilization vs Chat Completions API
15// - Native stateful conversation support
16//
17// Specification: https://www.openresponses.org/specification
18// GitHub: https://github.com/openresponses/openresponses
19//
20// The Chat Completions API remains supported for backward compatibility.
21
22use 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
58/// Open Responses Protocol Driver (OpenAI implementation)
59///
60/// Implements `ChatDriver` using the Open Responses specification
61/// (<https://www.openresponses.org/>). This driver targets OpenAI's API
62/// but follows the vendor-neutral Open Responses standard.
63///
64/// Rate limit handling: On 429 errors, automatically retries with exponential
65/// backoff, respecting `x-ratelimit-reset-*` and `retry-after` headers.
66///
67/// The Open Responses spec is recommended for new projects, offering:
68/// - Better performance with reasoning models (o1, o3, GPT-5)
69/// - Provider-agnostic streaming events
70/// - Native agentic loop support
71///
72/// # Example
73///
74/// ```ignore
75/// use everruns_core::OpenResponsesProtocolChatDriver;
76///
77/// let driver = OpenResponsesProtocolChatDriver::new("your-api-key");
78/// // or with custom endpoint
79/// let driver = OpenResponsesProtocolChatDriver::with_base_url("your-api-key", "https://api.example.com/v1/responses");
80/// // or with custom retry config
81/// let driver = OpenResponsesProtocolChatDriver::new("your-api-key")
82///     .with_retry_config(LlmRetryConfig::aggressive());
83/// ```
84/// Hook for provider-specific augmentation of an Open Responses request.
85///
86/// The Open Responses request shape this driver builds is vendor-neutral.
87/// Providers reached through it (e.g. OpenRouter) layer extra top-level fields
88/// onto the outgoing JSON or HTTP headers via this seam, so the core driver
89/// stays free of provider branching. `decorate` and `decorate_headers` run once
90/// per request, after the base body is serialized and before it is sent; either
91/// may return an error to abort the request (e.g. failed routing validation).
92pub trait OpenResponsesRequestExtension: Send + Sync {
93    fn decorate(&self, body: &mut Value, config: &LlmCallConfig) -> Result<()>;
94
95    /// Add provider-specific **non-auth** request headers (routing, attribution,
96    /// `session_id`, `OpenAI-Beta`, `originator`, account ids, …).
97    ///
98    /// Authentication is a separate seam ([`AuthHeaderProvider`], set via
99    /// [`OpenResponsesProtocolChatDriver::with_auth_provider`]). The driver
100    /// applies these decoration headers first, then applies the resolved auth
101    /// header, so **the auth header always wins on a name conflict**. Do not set
102    /// `Authorization` / `api-key` here; use an auth provider instead.
103    fn decorate_headers(&self, _headers: &mut HeaderMap, _config: &LlmCallConfig) -> Result<()> {
104        Ok(())
105    }
106
107    /// Refine retry metadata from provider-specific rate limit response fields.
108    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 configuration for rate limit errors
124    retry_config: LlmRetryConfig,
125    /// Optional provider-specific request-body decorator (see
126    /// [`OpenResponsesRequestExtension`]). `None` for vanilla OpenAI/Azure.
127    request_extension: Option<Arc<dyn OpenResponsesRequestExtension>>,
128    /// Optional pluggable auth-header provider (see [`AuthHeaderProvider`]). When
129    /// set it overrides the default host-keyed `api-key` / bearer auth and is
130    /// awaited once per HTTP attempt so refreshable (OAuth) providers can mint or
131    /// refresh tokens per retry. `None` falls back to the static `api_key`.
132    auth_provider: Option<Arc<dyn AuthHeaderProvider>>,
133}
134
135impl OpenResponsesProtocolChatDriver {
136    /// Create a new driver with the given API key
137    pub fn new(api_key: impl Into<String>) -> Self {
138        Self {
139            // SSRF-hardened shared client (redirects disabled + DNS-pinned
140            // resolver). The api_url is org-configurable, so a bare
141            // `Client::new()` would leave this provider open to DNS-rebind /
142            // redirect SSRF (TM-API-013, EVE-623).
143            client: crate::driver_helpers::shared_streaming_http_client(),
144            api_key: api_key.into(),
145            api_url: DEFAULT_API_URL.to_string(),
146            provider_type: DriverId::OpenAI,
147            retry_config: LlmRetryConfig::default(),
148            request_extension: None,
149            auth_provider: None,
150        }
151    }
152
153    /// Create a new driver with a custom API URL
154    pub fn with_base_url(api_key: impl Into<String>, api_url: impl Into<String>) -> Self {
155        Self {
156            client: crate::driver_helpers::shared_streaming_http_client(),
157            api_key: api_key.into(),
158            api_url: api_url.into(),
159            provider_type: DriverId::OpenAI,
160            retry_config: LlmRetryConfig::default(),
161            request_extension: None,
162            auth_provider: None,
163        }
164    }
165
166    /// Set the model provider used for provider-specific request features.
167    pub fn with_provider_type(mut self, provider_type: DriverId) -> Self {
168        self.provider_type = provider_type;
169        self
170    }
171
172    /// Attach a provider-specific request-body decorator. The decorator runs on
173    /// every chat request just before it is sent (see
174    /// [`OpenResponsesRequestExtension`]).
175    pub fn with_request_extension(
176        mut self,
177        extension: Arc<dyn OpenResponsesRequestExtension>,
178    ) -> Self {
179        self.request_extension = Some(extension);
180        self
181    }
182
183    /// Set a pluggable [`AuthHeaderProvider`] that overrides the default
184    /// host-keyed `api-key` / bearer auth. The provider is awaited once per HTTP
185    /// attempt (including retries), so refreshable OAuth providers (ChatGPT/Codex,
186    /// Entra ID, workload identity, …) can mint or refresh tokens per request
187    /// without the driver knowing the scheme. The resolved auth header takes
188    /// precedence over any header set by an
189    /// [`OpenResponsesRequestExtension::decorate_headers`].
190    pub fn with_auth_provider(mut self, provider: Arc<dyn AuthHeaderProvider>) -> Self {
191        self.auth_provider = Some(provider);
192        self
193    }
194
195    /// Resolve the auth header `(name, value)` for one HTTP attempt against
196    /// `url`. Uses the pluggable [`AuthHeaderProvider`] when set (awaited each
197    /// attempt so tokens can refresh), otherwise the default static-key behavior
198    /// shared with the Chat Completions driver.
199    async fn resolve_auth_header(&self, url: &str) -> Result<(HeaderName, HeaderValue)> {
200        let (name, value) = match &self.auth_provider {
201            Some(provider) => provider.auth_header().await?,
202            None => {
203                let (name, value) = openai_auth_header_pair(url, &self.api_key);
204                (name.to_string(), value.into_owned())
205            }
206        };
207        let name = HeaderName::from_bytes(name.as_bytes())
208            .map_err(|e| AgentLoopError::llm(format!("invalid auth header name {name:?}: {e}")))?;
209        let mut value = HeaderValue::from_str(&value)
210            .map_err(|e| AgentLoopError::llm(format!("invalid auth header value: {e}")))?;
211        // Auth material must never leak into debug logs of the request builder.
212        value.set_sensitive(true);
213        Ok((name, value))
214    }
215
216    /// Configure retry behavior for rate limit errors
217    pub fn with_retry_config(mut self, config: LlmRetryConfig) -> Self {
218        self.retry_config = config;
219        self
220    }
221
222    /// Send one streaming Responses request, applying the shared header-phase
223    /// retry loop (transient send failures, 429, and 5xx), and return the raw
224    /// response plus its retry metadata.
225    ///
226    /// Invoked once per reconnect attempt by [`connect_sse_with_reconnect`]; it
227    /// re-sends the identical request and consumes no body bytes, so retrying is
228    /// idempotent. The classifier preserves the Responses API terminal
229    /// classification and error messages exactly.
230    async fn send_responses_request(
231        &self,
232        request_body: &Value,
233        extension_headers: &HeaderMap,
234        model: &str,
235    ) -> Result<(reqwest::Response, RetryMetadata)> {
236        let last_error: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
237
238        retry_request(
239            &self.retry_config,
240            "OpenResponsesProtocolDriver",
241            || async {
242                // Compose headers: provider decoration first, then the resolved
243                // auth header (awaited each attempt so refreshable providers can
244                // rotate tokens per retry). `insert` overrides any same-named
245                // decoration header, so auth always wins on conflict. An auth
246                // failure is fatal (no retry).
247                let mut headers = extension_headers.clone();
248                let (auth_name, auth_value) = self
249                    .resolve_auth_header(&self.api_url)
250                    .await
251                    .map_err(SendOutcome::Fatal)?;
252                headers.insert(auth_name, auth_value);
253
254                self.client
255                    .post(&self.api_url)
256                    .headers(headers)
257                    .header("Content-Type", "application/json")
258                    .json(request_body)
259                    .send()
260                    .await
261                    .map_err(SendOutcome::Send)
262            },
263            |response, attempts, can_retry| {
264                let last_error = Arc::clone(&last_error);
265                let model = model.to_string();
266                async move {
267                    let status = response.status();
268
269                    if can_retry {
270                        // Parse rate limit info from headers before consuming body.
271                        let response_headers = response.headers().clone();
272                        let mut rate_limit_info = if is_rate_limit_status(status) {
273                            Some(RateLimitInfo::from_openai_headers(&response_headers))
274                        } else {
275                            None
276                        };
277
278                        let error_text = response.text().await.unwrap_or_default();
279                        if let (Some(extension), Some(info)) =
280                            (self.request_extension.as_ref(), rate_limit_info.as_mut())
281                        {
282                            extension.update_rate_limit_info(info, &response_headers, &error_text);
283                        }
284
285                        // Exhausted billing quota is surfaced as a 429 but is not
286                        // transient — fail fast instead of burning retries.
287                        if is_provider_quota_message(&error_text) {
288                            return RetryDecision::Terminal(AgentLoopError::llm_kind(
289                                LlmErrorKind::QuotaExhausted,
290                                format!("OpenAI Responses API error ({}): {}", status, error_text),
291                            ));
292                        }
293
294                        let wait = rate_limit_info
295                            .as_ref()
296                            .map(|info| info.recommended_wait(&self.retry_config, attempts))
297                            .unwrap_or_else(|| self.retry_config.calculate_backoff(attempts));
298
299                        *last_error.lock().unwrap() = Some(error_text);
300                        return RetryDecision::Retry {
301                            wait,
302                            rate_limit_info,
303                        };
304                    }
305
306                    // Non-retryable error or max retries exceeded
307                    let error_text = response.text().await.unwrap_or_default();
308
309                    // Check if this is a model-not-found error
310                    if is_openai_model_not_found(status, &error_text) {
311                        return RetryDecision::Terminal(AgentLoopError::model_not_available(model));
312                    }
313
314                    // Check if this is a request-too-large error (context length).
315                    if is_openai_request_too_large(status, &error_text) {
316                        return RetryDecision::Terminal(AgentLoopError::request_too_large(
317                            format!("OpenAI Responses API ({}): {}", status, error_text),
318                        ));
319                    }
320
321                    let error_msg =
322                        format!("OpenAI Responses API error ({}): {}", status, error_text);
323
324                    // Attach the semantic error kind while the HTTP status and
325                    // body are still available (see LlmErrorKind).
326                    let kind = LlmErrorKind::from_provider_status(status.as_u16(), &error_text);
327
328                    if attempts > 0 {
329                        return RetryDecision::Terminal(AgentLoopError::llm_kind(
330                            kind,
331                            format!(
332                                "{} (after {} retries, last error: {})",
333                                error_msg,
334                                attempts,
335                                last_error.lock().unwrap().take().unwrap_or_default()
336                            ),
337                        ));
338                    }
339
340                    RetryDecision::Terminal(AgentLoopError::llm_kind(kind, error_msg))
341                }
342            },
343            |e, attempts| AgentLoopError::llm(send_error_message(e, attempts)),
344        )
345        .await
346    }
347
348    /// Get the API URL
349    pub fn api_url(&self) -> &str {
350        &self.api_url
351    }
352
353    /// Get the API key (for subclass access)
354    pub fn api_key(&self) -> &str {
355        &self.api_key
356    }
357
358    /// Get the HTTP client (for subclass access)
359    pub fn client(&self) -> &Client {
360        &self.client
361    }
362
363    /// Get the provider type used for model profile lookup.
364    pub fn provider_type(&self) -> &DriverId {
365        &self.provider_type
366    }
367
368    fn convert_role(role: &LlmMessageRole) -> &'static str {
369        match role {
370            LlmMessageRole::System => "developer", // Responses API uses "developer" for system
371            LlmMessageRole::User => "user",
372            LlmMessageRole::Assistant => "assistant",
373            LlmMessageRole::Tool => "tool",
374        }
375    }
376
377    fn convert_message(msg: &LlmMessage, supports_phases: bool) -> ResponsesInputItem {
378        // Handle tool result messages differently
379        // Note: OpenAI Responses API function_call_output only supports text output.
380        // Images in tool results are dropped with a warning.
381        if msg.role == LlmMessageRole::Tool
382            && let Some(tool_call_id) = &msg.tool_call_id
383        {
384            let mut has_images = false;
385            let output = match &msg.content {
386                LlmMessageContent::Text(text) => text.clone(),
387                LlmMessageContent::Parts(parts) => {
388                    has_images = parts
389                        .iter()
390                        .any(|p| matches!(p, LlmContentPart::Image { .. }));
391                    parts
392                        .iter()
393                        .filter_map(|p| match p {
394                            LlmContentPart::Text { text } => Some(text.clone()),
395                            _ => None,
396                        })
397                        .collect::<Vec<_>>()
398                        .join("")
399                }
400            };
401            if has_images {
402                tracing::warn!(
403                    tool_call_id = %tool_call_id,
404                    "OpenResponses API does not support images in tool results; images dropped"
405                );
406            }
407            return ResponsesInputItem::FunctionCallOutput {
408                r#type: "function_call_output".to_string(),
409                call_id: tool_call_id.clone(),
410                output,
411            };
412        }
413
414        let content = match &msg.content {
415            LlmMessageContent::Text(text) => ResponsesContent::Text(text.clone()),
416            LlmMessageContent::Parts(parts) => {
417                let responses_parts: Vec<ResponsesContentPart> = parts
418                    .iter()
419                    .map(|part| match part {
420                        LlmContentPart::Text { text } => ResponsesContentPart::InputText {
421                            r#type: "input_text".to_string(),
422                            text: text.clone(),
423                        },
424                        LlmContentPart::Image { url } => ResponsesContentPart::InputImage {
425                            r#type: "input_image".to_string(),
426                            image_url: url.clone(),
427                        },
428                        LlmContentPart::Audio { url } => ResponsesContentPart::InputAudio {
429                            r#type: "input_audio".to_string(),
430                            input_audio: ResponsesInputAudio {
431                                data: url.clone(),
432                                format: "wav".to_string(),
433                            },
434                        },
435                    })
436                    .collect();
437                ResponsesContent::Parts(responses_parts)
438            }
439        };
440
441        // Only include phase on assistant messages when the model supports it.
442        // Map ExecutionPhase enum to the provider's wire format string.
443        let phase = if supports_phases && msg.role == LlmMessageRole::Assistant {
444            msg.phase.map(|p| p.as_provider_str().to_string())
445        } else {
446            None
447        };
448
449        ResponsesInputItem::Message {
450            r#type: "message".to_string(),
451            role: Self::convert_role(&msg.role).to_string(),
452            content,
453            phase,
454        }
455    }
456
457    /// Ensure an object-typed JSON Schema has a `properties` key.
458    /// OpenAI rejects function schemas where `type: "object"` lacks `properties`.
459    fn sanitize_parameters(params: &Value) -> Value {
460        let mut p = params.clone();
461        if let Some(obj) = p.as_object_mut()
462            && obj.get("type").and_then(|v| v.as_str()) == Some("object")
463            && !obj.contains_key("properties")
464        {
465            obj.insert(
466                "properties".to_string(),
467                serde_json::Value::Object(serde_json::Map::new()),
468            );
469        }
470        p
471    }
472
473    fn convert_tools(tools: &[ToolDefinition]) -> Vec<ResponsesTool> {
474        tools
475            .iter()
476            .map(|tool| ResponsesTool::Function {
477                r#type: "function".to_string(),
478                name: tool.name().to_string(),
479                description: tool.description().to_string(),
480                parameters: Self::sanitize_parameters(tool.parameters()),
481                defer_loading: None,
482            })
483            .collect()
484    }
485
486    /// Convert tools with tool_search support: groups tools into namespaces,
487    /// marks them as deferred, and appends a `tool_search` entry.
488    fn convert_tools_with_search(tools: &[ToolDefinition], threshold: usize) -> Vec<ResponsesTool> {
489        use crate::tool_types::DeferrablePolicy;
490        use std::collections::HashMap;
491
492        // Below threshold: fall back to standard conversion
493        if tools.len() < threshold {
494            return Self::convert_tools(tools);
495        }
496
497        let mut namespaces: HashMap<String, Vec<ResponsesTool>> = HashMap::new();
498        let mut ungrouped = vec![];
499        let mut never_defer = vec![];
500
501        for tool in tools {
502            let should_defer = match tool.deferrable() {
503                DeferrablePolicy::Never => false,
504                DeferrablePolicy::Automatic | DeferrablePolicy::Always => true,
505            };
506
507            let func = ResponsesTool::Function {
508                r#type: "function".to_string(),
509                name: tool.name().to_string(),
510                description: tool.description().to_string(),
511                parameters: Self::sanitize_parameters(tool.parameters()),
512                defer_loading: if should_defer { Some(true) } else { None },
513            };
514
515            if !should_defer {
516                never_defer.push(func);
517            } else {
518                match tool.category() {
519                    Some(cat) => {
520                        namespaces.entry(cat.to_string()).or_default().push(func);
521                    }
522                    None => ungrouped.push(func),
523                }
524            }
525        }
526
527        let mut result: Vec<ResponsesTool> = Vec::new();
528
529        // Non-deferred tools first (always visible to model)
530        result.extend(never_defer);
531
532        // Namespaced tools
533        for (name, tools) in namespaces {
534            let description = format!("Tools for {name}");
535            result.push(ResponsesTool::Namespace {
536                r#type: "namespace".to_string(),
537                name,
538                description,
539                tools,
540            });
541        }
542
543        // Ungrouped deferred tools
544        result.extend(ungrouped);
545
546        // Add tool_search activator
547        result.push(ResponsesTool::ToolSearch {
548            r#type: "tool_search".to_string(),
549        });
550
551        result
552    }
553
554    fn build_prompt_cache_key(
555        config: &LlmCallConfig,
556        _input_items: &[ResponsesInputItem],
557        instructions: &Option<String>,
558        tools: &Option<Vec<ResponsesTool>>,
559    ) -> Option<String> {
560        let prompt_cache = config.prompt_cache.as_ref().filter(|cfg| cfg.enabled)?;
561        let cache_family = config
562            .metadata
563            .get("session_id")
564            .or_else(|| config.metadata.get("agent_id"))
565            .or_else(|| config.metadata.get("harness_id"))
566            .or_else(|| config.metadata.get("org_id"));
567        let fingerprint = json!({
568            "strategy": prompt_cache.strategy,
569            "model": config.model,
570            "cache_family": cache_family,
571            "instructions": instructions,
572            "tools": tools,
573        });
574        let payload = serde_json::to_vec(&fingerprint).ok()?;
575        let digest = hex::encode(Sha256::digest(payload));
576        let digest_len = OPENAI_PROMPT_CACHE_KEY_MAX_LEN - PROMPT_CACHE_KEY_PREFIX.len();
577        Some(format!(
578            "{PROMPT_CACHE_KEY_PREFIX}{}",
579            &digest[..digest_len]
580        ))
581    }
582
583    /// Compact a conversation to reduce context size
584    ///
585    /// This method calls the /v1/responses/compact endpoint to compress the conversation
586    /// history. User messages are kept verbatim, while assistant messages, tool calls,
587    /// and tool results are replaced by an encrypted compaction item.
588    ///
589    /// # Arguments
590    ///
591    /// * `request` - The compact request containing the model and input items
592    ///
593    /// # Returns
594    ///
595    /// Returns a `CompactResponse` containing the compacted output items.
596    /// The output can be used directly as input for the next /v1/responses call.
597    ///
598    /// # Example
599    ///
600    /// ```ignore
601    /// use everruns_core::{OpenResponsesProtocolChatDriver, CompactRequest, CompactInputItem, CompactContent};
602    ///
603    /// let driver = OpenResponsesProtocolChatDriver::new("your-api-key");
604    ///
605    /// let request = CompactRequest {
606    ///     model: "gpt-4o".to_string(),
607    ///     input: vec![
608    ///         CompactInputItem::Message {
609    ///             role: "user".to_string(),
610    ///             content: CompactContent::Text("Hello!".to_string()),
611    ///         },
612    ///     ],
613    ///     previous_response_id: None,
614    ///     instructions: None,
615    /// };
616    ///
617    /// let response = driver.compact(request).await?;
618    /// // Use response.output as input for the next /v1/responses call
619    /// ```
620    pub async fn compact(&self, request: CompactRequest) -> Result<CompactResponse> {
621        // Build the compact endpoint URL
622        // Replace /v1/responses with /v1/responses/compact
623        let compact_url = if self.api_url.ends_with("/responses") {
624            format!("{}/compact", self.api_url)
625        } else if self.api_url.ends_with("/responses/") {
626            format!("{}compact", self.api_url)
627        } else {
628            // Custom URL - just append /compact
629            format!("{}/compact", self.api_url.trim_end_matches('/'))
630        };
631
632        // Retry loop for rate limit (429) and transient errors. Shared executor
633        // owns the loop/backoff/send-error retry/exhaustion logging; the
634        // classifier preserves the compact endpoint's terminal classification
635        // and (compact-specific) error messages exactly.
636        let last_error: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
637
638        let (response, _retry_metadata) = retry_request(
639            &self.retry_config,
640            "OpenResponsesProtocolDriver(compact)",
641            || async {
642                // Auth is resolved per attempt so refreshable providers can
643                // rotate tokens across retries (same seam as the streaming path).
644                let (auth_name, auth_value) = self
645                    .resolve_auth_header(&compact_url)
646                    .await
647                    .map_err(SendOutcome::Fatal)?;
648                self.client
649                    .post(&compact_url)
650                    .header(auth_name, auth_value)
651                    .header("Content-Type", "application/json")
652                    .json(&request)
653                    .send()
654                    .await
655                    .map_err(SendOutcome::Send)
656            },
657            |response, attempts, can_retry| {
658                let last_error = Arc::clone(&last_error);
659                let request_model = request.model.clone();
660                async move {
661                    let status = response.status();
662
663                    if can_retry {
664                        let response_headers = response.headers().clone();
665                        let mut rate_limit_info = if is_rate_limit_status(status) {
666                            Some(RateLimitInfo::from_openai_headers(&response_headers))
667                        } else {
668                            None
669                        };
670
671                        let error_text = response.text().await.unwrap_or_default();
672                        if let (Some(extension), Some(info)) =
673                            (self.request_extension.as_ref(), rate_limit_info.as_mut())
674                        {
675                            extension.update_rate_limit_info(info, &response_headers, &error_text);
676                        }
677
678                        let wait = rate_limit_info
679                            .as_ref()
680                            .map(|info| info.recommended_wait(&self.retry_config, attempts))
681                            .unwrap_or_else(|| self.retry_config.calculate_backoff(attempts));
682
683                        *last_error.lock().unwrap() = Some(error_text);
684                        return RetryDecision::Retry {
685                            wait,
686                            rate_limit_info,
687                        };
688                    }
689
690                    // Non-retryable error or max retries exceeded
691                    let error_text = response.text().await.unwrap_or_default();
692
693                    // Check if this is a model-not-found error
694                    if is_openai_model_not_found(status, &error_text) {
695                        return RetryDecision::Terminal(AgentLoopError::model_not_available(
696                            request_model,
697                        ));
698                    }
699
700                    // Check if this is a request-too-large error (context length).
701                    if is_openai_request_too_large(status, &error_text) {
702                        return RetryDecision::Terminal(AgentLoopError::request_too_large(
703                            format!("OpenAI Responses compact API ({}): {}", status, error_text),
704                        ));
705                    }
706
707                    let error_msg = format!(
708                        "OpenAI Responses compact API error ({}): {}",
709                        status, error_text
710                    );
711
712                    if attempts > 0 {
713                        return RetryDecision::Terminal(AgentLoopError::llm(format!(
714                            "{} (after {} retries, last error: {})",
715                            error_msg,
716                            attempts,
717                            last_error.lock().unwrap().take().unwrap_or_default()
718                        )));
719                    }
720
721                    RetryDecision::Terminal(AgentLoopError::llm(error_msg))
722                }
723            },
724            |e, attempts| {
725                let suffix = if attempts > 0 {
726                    format!(" (after {attempts} retries)")
727                } else {
728                    String::new()
729                };
730                AgentLoopError::llm(format!("Failed to send compact request: {e}{suffix}"))
731            },
732        )
733        .await?;
734
735        // Parse the response
736        let compact_response: CompactResponse = response
737            .json()
738            .await
739            .map_err(|e| AgentLoopError::llm(format!("Failed to parse compact response: {}", e)))?;
740
741        Ok(compact_response)
742    }
743
744    /// Check if this driver supports the compact endpoint
745    ///
746    /// Returns true for OpenAI's Responses API. Custom endpoints may or may not
747    /// support compaction.
748    pub fn supports_compact(&self) -> bool {
749        // We assume compact is supported for the default OpenAI endpoint
750        // For custom endpoints, callers should try and handle errors gracefully
751        self.api_url.starts_with("https://api.openai.com/")
752    }
753
754    /// Build input items from messages, extracting system/developer instructions
755    ///
756    /// Handles the conversion of:
757    /// - Assistant messages with tool_calls into separate FunctionCall items
758    /// - Assistant messages with thinking into Reasoning items (for o-series/GPT-5 models)
759    ///
760    /// Note: this function always reconstructs the FULL transcript from the supplied
761    /// messages. The caller is responsible for trimming to a delta window when a
762    /// `previous_response_id` is in play — see [`compute_delta_input_items`]. The
763    /// stateful Responses invariant is: a request must not mix `previous_response_id`
764    /// with prior transcript input the provider already holds server-side.
765    fn build_input(
766        messages: &[LlmMessage],
767        supports_phases: bool,
768    ) -> (Option<String>, Vec<ResponsesInputItem>) {
769        // Accumulate all system messages into `instructions`. Multiple system
770        // messages legitimately occur in one request — the agent system prompt
771        // plus, e.g., infinity context's hidden-history notice or compaction's
772        // conversation summary. Overwriting would drop the real system prompt and
773        // keep only the last notice. See `fold_system_messages`.
774        let instructions: Option<String> = fold_system_messages(messages);
775        let mut input_items = Vec::new();
776        // Counter for generating reasoning item IDs
777        let mut reasoning_counter = 0u32;
778
779        for msg in messages {
780            if msg.role == LlmMessageRole::System {
781                // Folded above into `instructions`; never emit the System message
782                // as a separate input item.
783            } else if msg.role == LlmMessageRole::Assistant {
784                // For assistant messages, emit Reasoning item BEFORE message content if present
785                // This is required for o-series and GPT-5 models with extended thinking
786                if let Some(encrypted_content) = &msg.thinking_signature {
787                    reasoning_counter += 1;
788                    input_items.push(ResponsesInputItem::Reasoning {
789                        r#type: "reasoning".to_string(),
790                        id: format!("rs_{:08x}", reasoning_counter),
791                        encrypted_content: encrypted_content.clone(),
792                    });
793                    tracing::debug!(
794                        encrypted_len = encrypted_content.len(),
795                        "OpenResponses: including reasoning item in request"
796                    );
797                }
798
799                // Handle tool calls
800                if msg.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty()) {
801                    // First emit the message content if non-empty
802                    let has_content = match &msg.content {
803                        LlmMessageContent::Text(text) => !text.is_empty(),
804                        LlmMessageContent::Parts(parts) => !parts.is_empty(),
805                    };
806                    if has_content {
807                        input_items.push(Self::convert_message(msg, supports_phases));
808                    }
809
810                    // Then emit FunctionCall items for each tool call
811                    if let Some(tool_calls) = &msg.tool_calls {
812                        for tc in tool_calls {
813                            input_items.push(ResponsesInputItem::FunctionCall {
814                                r#type: "function_call".to_string(),
815                                call_id: tc.id.clone(),
816                                name: tc.name.clone(),
817                                arguments: tc.arguments.to_string(),
818                            });
819                        }
820                    }
821                } else {
822                    input_items.push(Self::convert_message(msg, supports_phases));
823                }
824            } else {
825                input_items.push(Self::convert_message(msg, supports_phases));
826            }
827        }
828
829        (instructions, input_items)
830    }
831}
832
833/// Trim input items to the "delta" window for a stateful Responses continuation.
834///
835/// When a request carries `previous_response_id`, OpenAI already holds the prior
836/// transcript server-side. Re-sending it in `input` double-counts context (charges
837/// the user twice and inflates prompt-cache keys). The invariant is:
838///
839///   **A request must not mix `previous_response_id` with prior transcript input.**
840///
841/// "Delta" is everything strictly after the last item that belonged to a prior
842/// assistant turn. Items that belong to a prior assistant turn are: assistant
843/// `Message`, `Reasoning`, and `FunctionCall` (the assistant's own tool calls).
844/// What remains as delta is typically `FunctionCallOutput` items (tool results
845/// the client produced) plus any fresh user `Message`s.
846///
847/// Defensive behavior: if no prior-assistant item is found (e.g., the caller
848/// passed only fresh user input), all items are treated as delta and kept. An
849/// empty input is also valid — the provider can resume purely from
850/// `previous_response_id`.
851fn compute_delta_input_items(items: Vec<ResponsesInputItem>) -> Vec<ResponsesInputItem> {
852    // Find the index of the last item that is part of a prior assistant turn.
853    let last_assistant_turn_idx = items
854        .iter()
855        .enumerate()
856        .rev()
857        .find_map(|(i, item)| match item {
858            ResponsesInputItem::Message { role, .. } if role == "assistant" => Some(i),
859            ResponsesInputItem::Reasoning { .. } => Some(i),
860            ResponsesInputItem::FunctionCall { .. } => Some(i),
861            _ => None,
862        });
863
864    match last_assistant_turn_idx {
865        Some(idx) => items.into_iter().skip(idx + 1).collect(),
866        // No prior-assistant items in input — defensive: keep all items as delta.
867        None => items,
868    }
869}
870
871/// The single decision point for whether a Responses request `input` should be
872/// trimmed to the delta window. Extracted so the call path can be regression-tested
873/// without spinning up an HTTP mock — protects against accidentally removing the
874/// `previous_response_id.is_some()` guard that enforces the stateful invariant.
875fn finalize_input_for_request(
876    input_items: Vec<ResponsesInputItem>,
877    previous_response_id: &Option<String>,
878) -> Vec<ResponsesInputItem> {
879    if previous_response_id.is_some() {
880        compute_delta_input_items(input_items)
881    } else {
882        repair_unpaired_function_call_items(input_items)
883    }
884}
885
886/// Find `call_id`s that break the OpenAI/Codex Responses tool-pairing invariant
887/// for a stateless full-replay `input`: a serialized `function_call` with no
888/// matching `function_call_output` (EVE-597) or a `function_call_output` with
889/// no matching `function_call` (EVE-519). An empty result means the input is
890/// protocol-valid in both directions.
891fn unpaired_function_call_ids(items: &[ResponsesInputItem]) -> Vec<String> {
892    let call_ids: HashSet<&str> = items
893        .iter()
894        .filter_map(|item| match item {
895            ResponsesInputItem::FunctionCall { call_id, .. } => Some(call_id.as_str()),
896            _ => None,
897        })
898        .collect();
899    let output_ids: HashSet<&str> = items
900        .iter()
901        .filter_map(|item| match item {
902            ResponsesInputItem::FunctionCallOutput { call_id, .. } => Some(call_id.as_str()),
903            _ => None,
904        })
905        .collect();
906
907    items
908        .iter()
909        .filter_map(|item| match item {
910            ResponsesInputItem::FunctionCall { call_id, .. }
911                if !output_ids.contains(call_id.as_str()) =>
912            {
913                Some(call_id.clone())
914            }
915            ResponsesInputItem::FunctionCallOutput { call_id, .. }
916                if !call_ids.contains(call_id.as_str()) =>
917            {
918                Some(call_id.clone())
919            }
920            _ => None,
921        })
922        .collect()
923}
924
925/// Repair a stateless full-replay Responses `input` so every `function_call` is
926/// paired with its `function_call_output` and vice versa.
927///
928/// OpenAI/Codex Responses reject requests that contain a `function_call`
929/// without a matching `function_call_output` ("No tool output found for
930/// function call …", EVE-597) or a `function_call_output` without a matching
931/// `function_call` ("No tool call found for function call output", EVE-519).
932/// Long-session compaction / model-view masking can evict one side of a pair —
933/// e.g. `keep_recent_tool_outputs = 3` drops an old tool result while its
934/// assistant `function_call` survives — leaving the serialized request
935/// protocol-invalid and producing a permanent 400 on every continuation.
936///
937/// Tool-call pairs are atomic here: when only one side survives we drop both so
938/// the request stays valid rather than 400ing at the provider. Dropped dangling
939/// items are logged with their `call_id` to point at the responsible
940/// compaction/serialization stage.
941fn repair_unpaired_function_call_items(
942    input_items: Vec<ResponsesInputItem>,
943) -> Vec<ResponsesInputItem> {
944    let unpaired: HashSet<String> = unpaired_function_call_ids(&input_items)
945        .into_iter()
946        .collect();
947
948    if unpaired.is_empty() {
949        return input_items;
950    }
951
952    tracing::warn!(
953        unpaired_call_ids = ?unpaired,
954        "dropping unpaired function_call / function_call_output items before \
955         stateless Responses replay; one side of the pair was likely evicted by \
956         compaction or model-view masking (EVE-597/EVE-519)"
957    );
958
959    input_items
960        .into_iter()
961        .filter(|item| match item {
962            ResponsesInputItem::FunctionCall { call_id, .. }
963            | ResponsesInputItem::FunctionCallOutput { call_id, .. } => {
964                !unpaired.contains(call_id.as_str())
965            }
966            _ => true,
967        })
968        .collect()
969}
970
971/// Whether the endpoint at `api_url` persists responses server-side and honors
972/// `previous_response_id` for stateful continuation.
973///
974/// Only OpenAI's hosted API and Azure OpenAI are known to store responses.
975/// OpenAI-compatible gateways that expose a stateless `/responses` shim — e.g.
976/// OpenRouter and Google Gemini's compat endpoint — *accept* `previous_response_id`
977/// but silently ignore it (`store: false`). Chaining against those drops the
978/// conversation from turn 2 onward, so they must get the full transcript replayed
979/// in `input` each turn instead (EVE-523).
980fn endpoint_persists_responses(api_url: &str) -> bool {
981    crate::openai_protocol::is_openai_api_url(api_url)
982        || crate::openai_protocol::is_azure_openai_api_url(api_url)
983}
984
985#[async_trait]
986impl ChatDriver for OpenResponsesProtocolChatDriver {
987    async fn chat_completion_stream(
988        &self,
989        messages: Vec<LlmMessage>,
990        config: &LlmCallConfig,
991    ) -> Result<LlmResponseStream> {
992        // Check the provider-specific model profile before sending native
993        // Responses features. OpenAI-compatible gateways may share base model
994        // metadata without supporting OpenAI-only extensions such as phases or
995        // hosted tool_search.
996        let model_profile =
997            crate::model_profiles::get_model_profile(&self.provider_type, &config.model);
998        let supports_phases = model_profile
999            .as_ref()
1000            .is_some_and(|profile| profile.supports_phases);
1001        let supports_tool_search = model_profile
1002            .as_ref()
1003            .is_some_and(|profile| profile.tool_search);
1004
1005        let (instructions, input_items) = Self::build_input(&messages, supports_phases);
1006
1007        // Only chain via `previous_response_id` when the endpoint actually persists
1008        // responses server-side. Stateless OpenAI-compatible gateways (OpenRouter,
1009        // Gemini compat, …) accept the field but ignore it, so chaining there drops
1010        // the conversation from turn 2 onward (EVE-523). For those we send no
1011        // continuation handle and replay the full transcript in `input` below.
1012        let previous_response_id = if endpoint_persists_responses(&self.api_url) {
1013            config.previous_response_id.clone()
1014        } else {
1015            None
1016        };
1017
1018        // Stateful Responses continuations must not mix `previous_response_id` with
1019        // the prior transcript input the provider already holds server-side. When a
1020        // continuation handle is present, trim `input_items` to the delta window so
1021        // the request only carries new tool results / user messages. With no handle
1022        // (incl. stateless gateways), the full transcript is kept.
1023        let input_items = finalize_input_for_request(input_items, &previous_response_id);
1024
1025        let tools = if config.tools.is_empty() {
1026            None
1027        } else if let Some(ref ts_config) = config.tool_search {
1028            if ts_config.enabled && supports_tool_search {
1029                Some(Self::convert_tools_with_search(
1030                    &config.tools,
1031                    ts_config.threshold,
1032                ))
1033            } else {
1034                Some(Self::convert_tools(&config.tools))
1035            }
1036        } else {
1037            Some(Self::convert_tools(&config.tools))
1038        };
1039
1040        // Build reasoning config if specified.
1041        // Skip when effort is "none" — sending reasoning params to models that
1042        // don't support them (or with effort=none) causes OpenAI API errors.
1043        let reasoning = config
1044            .reasoning_effort
1045            .as_ref()
1046            .filter(|e| !e.eq_ignore_ascii_case("none"))
1047            .map(|effort| ResponsesReasoning {
1048                effort: effort.clone(),
1049                summary: "detailed".to_string(),
1050            });
1051
1052        // Build metadata for request tracking
1053        let metadata = if config.metadata.is_empty() {
1054            None
1055        } else {
1056            Some(config.metadata.clone())
1057        };
1058        let prompt_cache_key =
1059            Self::build_prompt_cache_key(config, &input_items, &instructions, &tools);
1060        let request = ResponsesRequest {
1061            model: config.model.clone(),
1062            input: input_items,
1063            instructions,
1064            previous_response_id,
1065            temperature: config.temperature,
1066            max_output_tokens: config.max_tokens,
1067            stream: true,
1068            tools,
1069            reasoning,
1070            metadata,
1071            prompt_cache_key,
1072            parallel_tool_calls: config
1073                .resolved_parallel_tool_calls(self.supports_parallel_tool_calls(&config.model)),
1074            service_tier: config.speed.clone(),
1075        };
1076
1077        // Log request details for debugging LLM errors.
1078        // Only log request shape to avoid leaking prompt or metadata contents.
1079        {
1080            let tool_count = request.tools.as_ref().map_or(0, |t| t.len());
1081            let input_count = request.input.len();
1082            let has_instructions = request.instructions.is_some();
1083            let has_reasoning = request.reasoning.is_some();
1084            let has_previous_response = request.previous_response_id.is_some();
1085            tracing::debug!(
1086                model = %request.model,
1087                input_items = input_count,
1088                tool_count = tool_count,
1089                has_instructions = has_instructions,
1090                has_reasoning = has_reasoning,
1091                has_previous_response = has_previous_response,
1092                api_url = %self.api_url,
1093                "OpenResponsesDriver: sending request"
1094            );
1095        }
1096
1097        // Serialize the vendor-neutral request, then let any provider-specific
1098        // extension (e.g. OpenRouter) layer extra fields and headers onto it.
1099        let mut request_body = serde_json::to_value(&request)
1100            .map_err(|e| AgentLoopError::llm(format!("Failed to serialize request: {}", e)))?;
1101        if let Some(extension) = &self.request_extension {
1102            extension.decorate(&mut request_body, config)?;
1103        }
1104        let mut extension_headers = HeaderMap::new();
1105        if let Some(extension) = &self.request_extension {
1106            extension.decorate_headers(&mut extension_headers, config)?;
1107        }
1108
1109        // Establish the SSE stream, transparently reconnecting on a transport
1110        // failure that lands before the first event is decoded (the "error
1111        // decoding response body" flake). Header-phase retries (429/5xx and
1112        // transient send failures) are handled inside the per-attempt send.
1113        let (event_stream, retry_metadata) = connect_sse_with_reconnect(
1114            &self.retry_config,
1115            "OpenResponsesProtocolDriver",
1116            |_attempt| {
1117                self.send_responses_request(&request_body, &extension_headers, &config.model)
1118            },
1119        )
1120        .await?;
1121
1122        let model = config.model.clone();
1123        let input_tokens = Arc::new(Mutex::new(0u32));
1124        let output_tokens = Arc::new(Mutex::new(0u32));
1125        let cache_read_tokens = Arc::new(Mutex::new(Option::<u32>::None));
1126        let accumulated_tool_calls = Arc::new(Mutex::new(Vec::<ToolCallAccumulator>::new()));
1127        let finish_reason = Arc::new(Mutex::new(Option::<String>::None));
1128        // Share retry metadata with stream closure (only set if retries occurred)
1129        let shared_retry_metadata = if retry_metadata.had_retries() {
1130            Some(Arc::new(retry_metadata))
1131        } else {
1132            None
1133        };
1134
1135        let converted_stream: LlmResponseStream = Box::pin(event_stream.then(move |result| {
1136            let model = model.clone();
1137            let input_tokens = Arc::clone(&input_tokens);
1138            let output_tokens = Arc::clone(&output_tokens);
1139            let cache_read_tokens = Arc::clone(&cache_read_tokens);
1140            let accumulated_tool_calls = Arc::clone(&accumulated_tool_calls);
1141            let finish_reason = Arc::clone(&finish_reason);
1142            let retry_metadata_for_done = shared_retry_metadata.clone();
1143
1144            async move {
1145                match result {
1146                    Ok(event) => {
1147                        let event_data = &event.data;
1148
1149                        // OpenAI-compatible gateways (e.g. OpenRouter) terminate the
1150                        // Responses SSE stream with a chat-completions-style `[DONE]`
1151                        // sentinel, which OpenAI's native Responses API does not send.
1152                        // It is not JSON, so skip it instead of surfacing a spurious
1153                        // "Failed to parse event" error after the real completion.
1154                        if event_data == "[DONE]" {
1155                            return Ok(LlmStreamEvent::TextDelta(String::new()));
1156                        }
1157
1158                        // Try to parse as typed StreamingEvent first for type safety
1159                        if let Ok(streaming_event) =
1160                            serde_json::from_str::<StreamingEvent>(event_data)
1161                        {
1162                            return Ok(handle_streaming_event(
1163                                streaming_event,
1164                                &input_tokens,
1165                                &output_tokens,
1166                                &cache_read_tokens,
1167                                &accumulated_tool_calls,
1168                                &finish_reason,
1169                                model,
1170                                retry_metadata_for_done,
1171                            ));
1172                        }
1173
1174                        // Fallback: parse as generic JSON for backwards compatibility
1175                        let parsed: std::result::Result<Value, _> =
1176                            serde_json::from_str(event_data);
1177
1178                        match parsed {
1179                            Ok(json) => {
1180                                let event_type = json.get("type").and_then(|t| t.as_str());
1181
1182                                match event_type {
1183                                    Some("response.output_text.delta") => {
1184                                        // Text delta
1185                                        if let Some(delta) =
1186                                            json.get("delta").and_then(|d| d.as_str())
1187                                        {
1188                                            Ok(LlmStreamEvent::TextDelta(delta.to_string()))
1189                                        } else {
1190                                            Ok(LlmStreamEvent::TextDelta(String::new()))
1191                                        }
1192                                    }
1193
1194                                    Some("response.function_call_arguments.delta") => {
1195                                        // Function call arguments delta
1196                                        if let (Some(item_id), Some(delta)) = (
1197                                            json.get("item_id").and_then(|c| c.as_str()),
1198                                            json.get("delta").and_then(|d| d.as_str()),
1199                                        ) {
1200                                            let mut acc = accumulated_tool_calls.lock().unwrap();
1201                                            // Find or create accumulator for this item_id
1202                                            if let Some(tc) =
1203                                                acc.iter_mut().find(|t| t.id == item_id)
1204                                            {
1205                                                tc.arguments.push_str(delta);
1206                                            } else {
1207                                                acc.push(ToolCallAccumulator {
1208                                                    id: item_id.to_string(),
1209                                                    call_id: String::new(),
1210                                                    name: String::new(),
1211                                                    arguments: delta.to_string(),
1212                                                });
1213                                            }
1214                                        }
1215                                        Ok(LlmStreamEvent::TextDelta(String::new()))
1216                                    }
1217
1218                                    Some("response.output_item.added") => {
1219                                        // New output item added - may be function call
1220                                        if let Some(item) = json.get("item")
1221                                            && item.get("type").and_then(|t| t.as_str())
1222                                                == Some("function_call")
1223                                        {
1224                                            let id = item
1225                                                .get("id")
1226                                                .and_then(|c| c.as_str())
1227                                                .unwrap_or("")
1228                                                .to_string();
1229                                            let call_id = item
1230                                                .get("call_id")
1231                                                .and_then(|c| c.as_str())
1232                                                .unwrap_or("")
1233                                                .to_string();
1234                                            let name = item
1235                                                .get("name")
1236                                                .and_then(|n| n.as_str())
1237                                                .unwrap_or("")
1238                                                .to_string();
1239
1240                                            let mut acc = accumulated_tool_calls.lock().unwrap();
1241                                            if let Some(tc) = acc.iter_mut().find(|t| t.id == id) {
1242                                                tc.name = name;
1243                                                tc.call_id = call_id;
1244                                            } else {
1245                                                acc.push(ToolCallAccumulator {
1246                                                    id,
1247                                                    call_id,
1248                                                    name,
1249                                                    arguments: String::new(),
1250                                                });
1251                                            }
1252                                        }
1253                                        Ok(LlmStreamEvent::TextDelta(String::new()))
1254                                    }
1255
1256                                    Some("response.output_item.done") => {
1257                                        // Output item completed - check if it's a function call
1258                                        if let Some(item) = json.get("item")
1259                                            && item.get("type").and_then(|t| t.as_str())
1260                                                == Some("function_call")
1261                                        {
1262                                            // Function call completed, emit ToolCalls event
1263                                            let acc = accumulated_tool_calls.lock().unwrap();
1264                                            if !acc.is_empty() {
1265                                                let tool_calls: Vec<ToolCall> = acc
1266                                                    .iter()
1267                                                    .filter(|tc| !tc.name.is_empty())
1268                                                    .map(|tc| {
1269                                                        let arguments: Value =
1270                                                            serde_json::from_str(&tc.arguments)
1271                                                                .unwrap_or(json!({}));
1272                                                        ToolCall {
1273                                                            id: tc.call_id.clone(),
1274                                                            name: tc.name.clone(),
1275                                                            arguments,
1276                                                        }
1277                                                    })
1278                                                    .collect();
1279
1280                                                if !tool_calls.is_empty() {
1281                                                    *finish_reason.lock().unwrap() =
1282                                                        Some("tool_calls".to_string());
1283                                                    return Ok(LlmStreamEvent::ToolCalls(
1284                                                        tool_calls,
1285                                                    ));
1286                                                }
1287                                            }
1288                                        }
1289                                        Ok(LlmStreamEvent::TextDelta(String::new()))
1290                                    }
1291
1292                                    Some("response.completed") | Some("response.done") => {
1293                                        // Response completed - extract usage
1294                                        let response_obj = json.get("response").unwrap_or(&json);
1295
1296                                        // Authoritative per-request cost from OpenAI-compatible
1297                                        // gateways (e.g. OpenRouter `usage.cost`, in USD credits).
1298                                        let mut provider_cost_usd: Option<f64> = None;
1299                                        if let Some(usage) = response_obj.get("usage") {
1300                                            if let Some(input) =
1301                                                usage.get("input_tokens").and_then(|t| t.as_u64())
1302                                            {
1303                                                *input_tokens.lock().unwrap() = input as u32;
1304                                            }
1305                                            if let Some(output) =
1306                                                usage.get("output_tokens").and_then(|t| t.as_u64())
1307                                            {
1308                                                *output_tokens.lock().unwrap() = output as u32;
1309                                            }
1310                                            // Check for cached tokens
1311                                            if let Some(details) = usage.get("input_tokens_details")
1312                                                && let Some(cached) = details
1313                                                    .get("cached_tokens")
1314                                                    .and_then(|t| t.as_u64())
1315                                            {
1316                                                *cache_read_tokens.lock().unwrap() =
1317                                                    Some(cached as u32);
1318                                            }
1319                                            provider_cost_usd =
1320                                                usage.get("cost").and_then(|c| c.as_f64());
1321                                        }
1322
1323                                        // Determine finish reason from status
1324                                        let status = response_obj
1325                                            .get("status")
1326                                            .and_then(|s| s.as_str())
1327                                            .unwrap_or("completed");
1328
1329                                        let reason = match status {
1330                                            "completed" => {
1331                                                // Check if there were tool calls
1332                                                let existing_reason =
1333                                                    finish_reason.lock().unwrap().clone();
1334                                                existing_reason
1335                                                    .unwrap_or_else(|| "stop".to_string())
1336                                            }
1337                                            "failed" => {
1338                                                let error_detail = response_obj
1339                                                    .get("error")
1340                                                    .map(|e| e.to_string())
1341                                                    .unwrap_or_else(|| "no error detail".into());
1342                                                tracing::warn!(
1343                                                    response_error = %error_detail,
1344                                                    "OpenResponsesDriver: response completed with 'failed' status (fallback parser)"
1345                                                );
1346                                                "error".to_string()
1347                                            }
1348                                            "cancelled" => "stop".to_string(),
1349                                            _ => "stop".to_string(),
1350                                        };
1351
1352                                        // Extract phase from the last assistant message in output items
1353                                        let phase = response_obj
1354                                            .get("output")
1355                                            .and_then(|o| o.as_array())
1356                                            .and_then(|items| {
1357                                                items.iter().rev().find_map(|item| {
1358                                                    if item.get("type")?.as_str()? == "message"
1359                                                        && item.get("role")?.as_str()?
1360                                                            == "assistant"
1361                                                    {
1362                                                        item.get("phase")?
1363                                                            .as_str()
1364                                                            .map(String::from)
1365                                                    } else {
1366                                                        None
1367                                                    }
1368                                                })
1369                                            });
1370
1371                                        let input = *input_tokens.lock().unwrap();
1372                                        let output = *output_tokens.lock().unwrap();
1373                                        let cached = *cache_read_tokens.lock().unwrap();
1374
1375                                        Ok(LlmStreamEvent::Done(Box::new(LlmCompletionMetadata {
1376                                            // `input` is OpenAI's cache-inclusive prompt count;
1377                                            // normalize to non-cached input (disjoint convention).
1378                                            total_tokens: Some(input + output),
1379                                            prompt_tokens: Some(disjoint_prompt_tokens(input, cached)),
1380                                            completion_tokens: Some(output),
1381                                            cache_read_tokens: cached,
1382                                            cache_creation_tokens: None,
1383                                            provider_cost_usd,
1384                                            model: Some(model),
1385                                            finish_reason: Some(reason),
1386                                            retry_metadata: retry_metadata_for_done
1387                                                .map(|arc| (*arc).clone()),
1388                                            response_id: None,
1389                                            phase,
1390                                        })))
1391                                    }
1392
1393                                    Some("error") => {
1394                                        // Error event (fallback JSON path)
1395                                        let error_code = json
1396                                            .get("error")
1397                                            .and_then(|e| e.get("code"))
1398                                            .and_then(|c| c.as_str())
1399                                            .unwrap_or("unknown");
1400                                        let error_msg = json
1401                                            .get("error")
1402                                            .and_then(|e| e.get("message"))
1403                                            .and_then(|m| m.as_str())
1404                                            .unwrap_or("Unknown error");
1405                                        tracing::warn!(
1406                                            error_code = error_code,
1407                                            error_message = error_msg,
1408                                            raw_error = %json.get("error").unwrap_or(&json),
1409                                            "OpenResponsesDriver: received streaming error event (fallback parser)"
1410                                        );
1411                                        Ok(LlmStreamEvent::Error(
1412                                            crate::driver_registry::LlmStreamError::provider(
1413                                                (error_code != "unknown")
1414                                                    .then_some(error_code.to_string()),
1415                                                None,
1416                                                error_msg,
1417                                            ),
1418                                        ))
1419                                    }
1420
1421                                    _ => {
1422                                        // Other event types - ignore
1423                                        Ok(LlmStreamEvent::TextDelta(String::new()))
1424                                    }
1425                                }
1426                            }
1427                            Err(e) => Ok(LlmStreamEvent::Error(
1428                                format!("Failed to parse event: {}", e).into(),
1429                            )),
1430                        }
1431                    }
1432                    Err(e) => Ok(LlmStreamEvent::Error(
1433                        format!("Stream error: {}", e).into(),
1434                    )),
1435                }
1436            }
1437        }));
1438
1439        Ok(converted_stream)
1440    }
1441
1442    fn supports_compact(&self) -> bool {
1443        // Delegate to the inherent method
1444        OpenResponsesProtocolChatDriver::supports_compact(self)
1445    }
1446
1447    /// The Responses API accepts the top-level `parallel_tool_calls` boolean.
1448    fn supports_parallel_tool_calls(&self, _model: &str) -> bool {
1449        true
1450    }
1451
1452    async fn compact(
1453        &self,
1454        request: crate::openresponses_protocol::CompactRequest,
1455    ) -> Result<Option<crate::openresponses_protocol::CompactResponse>> {
1456        // Delegate to the inherent method and wrap in Some
1457        Ok(Some(
1458            OpenResponsesProtocolChatDriver::compact(self, request).await?,
1459        ))
1460    }
1461}
1462
1463impl std::fmt::Debug for OpenResponsesProtocolChatDriver {
1464    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1465        f.debug_struct("OpenResponsesProtocolChatDriver")
1466            .field("api_url", &self.api_url)
1467            .field("provider_type", &self.provider_type)
1468            .field("api_key", &"[REDACTED]")
1469            .finish()
1470    }
1471}
1472
1473// ============================================================================
1474// Helper Types
1475// ============================================================================
1476
1477/// Accumulator for tool call arguments during streaming
1478#[derive(Clone, Default)]
1479struct ToolCallAccumulator {
1480    /// Item ID in the stream
1481    id: String,
1482    /// Unique call ID for the function call
1483    call_id: String,
1484    /// Function name
1485    name: String,
1486    /// Accumulated JSON arguments
1487    arguments: String,
1488}
1489
1490/// Handle typed streaming events from the OpenResponses API
1491#[allow(clippy::too_many_arguments)]
1492fn handle_streaming_event(
1493    event: StreamingEvent,
1494    input_tokens: &Mutex<u32>,
1495    output_tokens: &Mutex<u32>,
1496    cache_read_tokens: &Mutex<Option<u32>>,
1497    accumulated_tool_calls: &Mutex<Vec<ToolCallAccumulator>>,
1498    finish_reason: &Mutex<Option<String>>,
1499    model: String,
1500    retry_metadata: Option<Arc<RetryMetadata>>,
1501) -> LlmStreamEvent {
1502    match event {
1503        StreamingEvent::OutputTextDelta { delta, .. } => LlmStreamEvent::TextDelta(delta),
1504
1505        StreamingEvent::ReasoningDelta { delta, .. } => LlmStreamEvent::ThinkingDelta(delta),
1506
1507        StreamingEvent::ReasoningTextDelta { delta, .. } => LlmStreamEvent::ThinkingDelta(delta),
1508
1509        StreamingEvent::ReasoningSummaryDelta { delta, .. } => {
1510            // OpenAI's reasoning summary stream is a model-supplied, readable
1511            // summary, not raw chain-of-thought. Surface it as public text so
1512            // clients can display progress without exposing hidden reasoning.
1513            LlmStreamEvent::TextDelta(delta)
1514        }
1515
1516        StreamingEvent::FunctionCallArgumentsDelta { item_id, delta, .. } => {
1517            let mut acc = accumulated_tool_calls.lock().unwrap();
1518            if let Some(tc) = acc.iter_mut().find(|t| t.id == item_id) {
1519                tc.arguments.push_str(&delta);
1520            } else {
1521                acc.push(ToolCallAccumulator {
1522                    id: item_id,
1523                    call_id: String::new(),
1524                    name: String::new(),
1525                    arguments: delta,
1526                });
1527            }
1528            LlmStreamEvent::TextDelta(String::new())
1529        }
1530
1531        StreamingEvent::OutputItemAdded { item, .. } => {
1532            if let Some(types::OutputItem::FunctionCall {
1533                id, call_id, name, ..
1534            }) = item
1535            {
1536                let mut acc = accumulated_tool_calls.lock().unwrap();
1537                if let Some(tc) = acc.iter_mut().find(|t| t.id == id) {
1538                    tc.name = name;
1539                    tc.call_id = call_id;
1540                } else {
1541                    acc.push(ToolCallAccumulator {
1542                        id,
1543                        call_id,
1544                        name,
1545                        arguments: String::new(),
1546                    });
1547                }
1548            }
1549            LlmStreamEvent::TextDelta(String::new())
1550        }
1551
1552        StreamingEvent::OutputItemDone { item, .. } => {
1553            match item {
1554                Some(types::OutputItem::FunctionCall { .. }) => {
1555                    let acc = accumulated_tool_calls.lock().unwrap();
1556                    if !acc.is_empty() {
1557                        let tool_calls: Vec<ToolCall> = acc
1558                            .iter()
1559                            .filter(|tc| !tc.name.is_empty())
1560                            .map(|tc| {
1561                                let arguments: Value =
1562                                    serde_json::from_str(&tc.arguments).unwrap_or(json!({}));
1563                                ToolCall {
1564                                    id: tc.call_id.clone(),
1565                                    name: tc.name.clone(),
1566                                    arguments,
1567                                }
1568                            })
1569                            .collect();
1570
1571                        if !tool_calls.is_empty() {
1572                            *finish_reason.lock().unwrap() = Some("tool_calls".to_string());
1573                            return LlmStreamEvent::ToolCalls(tool_calls);
1574                        }
1575                    }
1576                    LlmStreamEvent::TextDelta(String::new())
1577                }
1578                Some(types::OutputItem::Reasoning {
1579                    id,
1580                    summary,
1581                    content: _, // plaintext reasoning content is intentionally not propagated
1582                    encrypted_content,
1583                }) => {
1584                    // Plaintext reasoning content from the provider is intentionally
1585                    // dropped here so it never reaches persisted events. Only the
1586                    // provider's opaque encrypted artifact and curated summary text
1587                    // travel forward.
1588                    let safe_summary: Vec<String> = summary
1589                        .into_iter()
1590                        .filter_map(|part| match part {
1591                            types::ContentPart::SummaryText { text } => Some(text),
1592                            _ => None,
1593                        })
1594                        .collect();
1595                    tracing::debug!(
1596                        encrypted_len = encrypted_content.as_ref().map(|s| s.len()).unwrap_or(0),
1597                        summary_segments = safe_summary.len(),
1598                        "OpenResponses: received reasoning item"
1599                    );
1600                    LlmStreamEvent::ReasonItem {
1601                        provider: "openai".to_string(),
1602                        model: Some(model.clone()),
1603                        item_id: id,
1604                        encrypted_content,
1605                        summary: safe_summary,
1606                        token_count: None,
1607                    }
1608                }
1609                _ => LlmStreamEvent::TextDelta(String::new()),
1610            }
1611        }
1612
1613        StreamingEvent::ResponseCompleted { response, .. } => {
1614            // Extract usage
1615            if let Some(usage) = &response.usage {
1616                *input_tokens.lock().unwrap() = usage.input_tokens;
1617                *output_tokens.lock().unwrap() = usage.output_tokens;
1618                if let Some(details) = &usage.input_tokens_details {
1619                    *cache_read_tokens.lock().unwrap() = Some(details.cached_tokens);
1620                }
1621            }
1622
1623            let reason = match response.status {
1624                types::ResponseStatus::Completed => {
1625                    let existing = finish_reason.lock().unwrap().clone();
1626                    existing.unwrap_or_else(|| "stop".to_string())
1627                }
1628                types::ResponseStatus::Failed => {
1629                    tracing::warn!(
1630                        response_id = %response.id,
1631                        error = ?response.error,
1632                        "OpenResponsesDriver: response completed with 'failed' status"
1633                    );
1634                    "error".to_string()
1635                }
1636                types::ResponseStatus::Cancelled => "stop".to_string(),
1637                _ => "stop".to_string(),
1638            };
1639
1640            // Extract phase from the last assistant message in output items.
1641            // The API assigns the phase; we preserve it as-is for subsequent requests.
1642            let phase = response.output.iter().rev().find_map(|item| {
1643                if let types::OutputItem::Message { phase, .. } = item {
1644                    phase.clone()
1645                } else {
1646                    None
1647                }
1648            });
1649
1650            let input = *input_tokens.lock().unwrap();
1651            let output = *output_tokens.lock().unwrap();
1652            let cached = *cache_read_tokens.lock().unwrap();
1653            let provider_cost_usd = response.usage.as_ref().and_then(|u| u.cost);
1654
1655            LlmStreamEvent::Done(Box::new(LlmCompletionMetadata {
1656                // `input` is OpenAI's cache-inclusive prompt count; normalize to
1657                // non-cached input (disjoint convention).
1658                total_tokens: Some(input + output),
1659                prompt_tokens: Some(disjoint_prompt_tokens(input, cached)),
1660                completion_tokens: Some(output),
1661                cache_read_tokens: cached,
1662                cache_creation_tokens: None,
1663                provider_cost_usd,
1664                model: Some(model),
1665                finish_reason: Some(reason),
1666                retry_metadata: retry_metadata.map(|arc| (*arc).clone()),
1667                response_id: Some(response.id),
1668                phase,
1669            }))
1670        }
1671
1672        StreamingEvent::Error { error, .. } => {
1673            tracing::warn!(
1674                error_code = error.code.as_deref().unwrap_or("none"),
1675                error_message = %error.message,
1676                "OpenResponsesDriver: received streaming error event from provider"
1677            );
1678            LlmStreamEvent::Error(crate::driver_registry::LlmStreamError::provider(
1679                error.code,
1680                None,
1681                error.message,
1682            ))
1683        }
1684
1685        StreamingEvent::ResponseFailed { response, .. } => {
1686            let error = response.error.unwrap_or(types::Error {
1687                code: "processing_error".to_string(),
1688                message: "The provider failed while processing the response".to_string(),
1689            });
1690            tracing::warn!(
1691                response_id = %response.id,
1692                error_code = %error.code,
1693                error_message = %error.message,
1694                "OpenResponsesDriver: response failed in stream"
1695            );
1696            LlmStreamEvent::Error(crate::driver_registry::LlmStreamError::provider(
1697                Some(error.code),
1698                None,
1699                error.message,
1700            ))
1701        }
1702
1703        StreamingEvent::RefusalDelta { delta, .. } => {
1704            // Treat refusal as an error message
1705            LlmStreamEvent::Error(format!("Model refused: {}", delta).into())
1706        }
1707
1708        // All other events: emit empty delta to maintain stream continuity
1709        _ => LlmStreamEvent::TextDelta(String::new()),
1710    }
1711}
1712
1713// ============================================================================
1714// Compact Endpoint Types (Public API)
1715// ============================================================================
1716
1717/// Request for the /v1/responses/compact endpoint
1718///
1719/// This endpoint compacts a conversation by replacing prior assistant messages,
1720/// tool calls, and tool results with an encrypted compaction item that preserves
1721/// latent context but is opaque. User messages are kept verbatim.
1722#[derive(Debug, Clone, Serialize)]
1723pub struct CompactRequest {
1724    /// Model to use for compaction (required)
1725    pub model: String,
1726    /// Input items to compact (the current conversation window)
1727    #[serde(skip_serializing_if = "Vec::is_empty")]
1728    pub input: Vec<CompactInputItem>,
1729    /// Previous response ID (optional, alternative to input)
1730    #[serde(skip_serializing_if = "Option::is_none")]
1731    pub previous_response_id: Option<String>,
1732    /// System instructions (optional, applies only to the compaction request)
1733    #[serde(skip_serializing_if = "Option::is_none")]
1734    pub instructions: Option<String>,
1735}
1736
1737/// Input item for compact request
1738///
1739/// These are the same types as ResponsesInputItem but exposed publicly
1740/// for callers to construct compact requests.
1741#[derive(Debug, Clone, Serialize, Deserialize)]
1742#[serde(tag = "type")]
1743pub enum CompactInputItem {
1744    /// A message (user, assistant, or developer)
1745    #[serde(rename = "message")]
1746    Message {
1747        role: String,
1748        content: CompactContent,
1749    },
1750    /// A function call from the assistant
1751    #[serde(rename = "function_call")]
1752    FunctionCall {
1753        call_id: String,
1754        name: String,
1755        arguments: String,
1756    },
1757    /// Output from a function call
1758    #[serde(rename = "function_call_output")]
1759    FunctionCallOutput { call_id: String, output: String },
1760    /// A compaction item (from a previous compact call)
1761    #[serde(rename = "compaction")]
1762    Compaction { encrypted_content: String },
1763}
1764
1765/// Content for compact input items
1766#[derive(Debug, Clone, Serialize, Deserialize)]
1767#[serde(untagged)]
1768pub enum CompactContent {
1769    /// Simple text content
1770    Text(String),
1771    /// Array of content parts
1772    Parts(Vec<CompactContentPart>),
1773}
1774
1775/// Content part for compact input
1776#[derive(Debug, Clone, Serialize, Deserialize)]
1777#[serde(tag = "type")]
1778pub enum CompactContentPart {
1779    /// Text content
1780    #[serde(rename = "input_text")]
1781    InputText { text: String },
1782    /// Image content
1783    #[serde(rename = "input_image")]
1784    InputImage { image_url: String },
1785}
1786
1787/// Response from the /v1/responses/compact endpoint
1788#[derive(Debug, Clone, Deserialize)]
1789pub struct CompactResponse {
1790    /// The compacted output items
1791    pub output: Vec<CompactOutputItem>,
1792    /// Token usage information
1793    pub usage: Option<CompactUsage>,
1794}
1795
1796/// Output item from compact response
1797#[derive(Debug, Clone, Serialize, Deserialize)]
1798#[serde(tag = "type")]
1799pub enum CompactOutputItem {
1800    /// A user message (kept verbatim)
1801    #[serde(rename = "message")]
1802    Message {
1803        role: String,
1804        content: CompactContent,
1805    },
1806    /// An encrypted compaction item
1807    #[serde(rename = "compaction")]
1808    Compaction {
1809        /// Encrypted content that preserves latent context
1810        encrypted_content: String,
1811    },
1812}
1813
1814/// Token usage from compact response
1815#[derive(Debug, Clone, Deserialize)]
1816pub struct CompactUsage {
1817    /// Input tokens processed
1818    pub input_tokens: Option<u32>,
1819    /// Output tokens generated
1820    pub output_tokens: Option<u32>,
1821    /// Total tokens used
1822    pub total_tokens: Option<u32>,
1823}
1824
1825// ============================================================================
1826// Compaction Conversion Utilities
1827// ============================================================================
1828
1829impl CompactInputItem {
1830    /// Convert an LlmMessage to CompactInputItem(s)
1831    ///
1832    /// An assistant message with tool_calls is expanded into multiple items:
1833    /// one Message for the text content and one FunctionCall for each tool call.
1834    pub fn from_llm_message(msg: &LlmMessage) -> Vec<Self> {
1835        let mut items = Vec::new();
1836
1837        let role = match msg.role {
1838            LlmMessageRole::System => "developer",
1839            LlmMessageRole::User => "user",
1840            LlmMessageRole::Assistant => "assistant",
1841            LlmMessageRole::Tool => "tool",
1842        };
1843
1844        // Handle tool result messages differently
1845        if msg.role == LlmMessageRole::Tool
1846            && let Some(tool_call_id) = &msg.tool_call_id
1847        {
1848            let output = match &msg.content {
1849                LlmMessageContent::Text(text) => text.clone(),
1850                LlmMessageContent::Parts(parts) => parts
1851                    .iter()
1852                    .filter_map(|p| match p {
1853                        LlmContentPart::Text { text } => Some(text.clone()),
1854                        _ => None,
1855                    })
1856                    .collect::<Vec<_>>()
1857                    .join(""),
1858            };
1859            items.push(CompactInputItem::FunctionCallOutput {
1860                call_id: tool_call_id.clone(),
1861                output,
1862            });
1863            return items;
1864        }
1865
1866        // Add message content (if non-empty)
1867        let content = Self::content_from_llm_message(msg);
1868        let has_content = match &content {
1869            CompactContent::Text(t) => !t.is_empty(),
1870            CompactContent::Parts(p) => !p.is_empty(),
1871        };
1872
1873        if has_content || msg.tool_calls.is_none() {
1874            items.push(CompactInputItem::Message {
1875                role: role.to_string(),
1876                content,
1877            });
1878        }
1879
1880        // Add function calls for assistant messages
1881        if msg.role == LlmMessageRole::Assistant
1882            && let Some(tool_calls) = &msg.tool_calls
1883        {
1884            for tc in tool_calls {
1885                items.push(CompactInputItem::FunctionCall {
1886                    call_id: tc.id.clone(),
1887                    name: tc.name.clone(),
1888                    arguments: tc.arguments.to_string(),
1889                });
1890            }
1891        }
1892
1893        items
1894    }
1895
1896    /// Convert LlmMessageContent to CompactContent
1897    fn content_from_llm_message(msg: &LlmMessage) -> CompactContent {
1898        match &msg.content {
1899            LlmMessageContent::Text(text) => CompactContent::Text(text.clone()),
1900            LlmMessageContent::Parts(parts) => {
1901                let compact_parts: Vec<CompactContentPart> = parts
1902                    .iter()
1903                    .filter_map(|part| match part {
1904                        LlmContentPart::Text { text } => {
1905                            Some(CompactContentPart::InputText { text: text.clone() })
1906                        }
1907                        LlmContentPart::Image { url } => {
1908                            // URL is already in data URL format (data:image/png;base64,...)
1909                            Some(CompactContentPart::InputImage {
1910                                image_url: url.clone(),
1911                            })
1912                        }
1913                        LlmContentPart::Audio { .. } => None, // Audio not supported in compact
1914                    })
1915                    .collect();
1916                if compact_parts.len() == 1
1917                    && let CompactContentPart::InputText { text } = &compact_parts[0]
1918                {
1919                    return CompactContent::Text(text.clone());
1920                }
1921                CompactContent::Parts(compact_parts)
1922            }
1923        }
1924    }
1925}
1926
1927impl CompactOutputItem {
1928    /// Convert a CompactOutputItem to LlmMessage
1929    ///
1930    /// Compaction items are converted to a special system message containing
1931    /// the encrypted context that will be included in subsequent requests.
1932    pub fn to_llm_message(&self) -> Option<LlmMessage> {
1933        match self {
1934            CompactOutputItem::Message { role, content } => {
1935                let llm_role = match role.as_str() {
1936                    "user" => LlmMessageRole::User,
1937                    "assistant" => LlmMessageRole::Assistant,
1938                    "developer" | "system" => LlmMessageRole::System,
1939                    "tool" => LlmMessageRole::Tool,
1940                    _ => LlmMessageRole::User, // Default to user
1941                };
1942
1943                let llm_content = match content {
1944                    CompactContent::Text(text) => LlmMessageContent::Text(text.clone()),
1945                    CompactContent::Parts(parts) => {
1946                        let llm_parts: Vec<LlmContentPart> = parts
1947                            .iter()
1948                            .map(|p| match p {
1949                                CompactContentPart::InputText { text } => {
1950                                    LlmContentPart::Text { text: text.clone() }
1951                                }
1952                                CompactContentPart::InputImage { image_url } => {
1953                                    // Pass the URL directly - it's already in data URL format
1954                                    LlmContentPart::Image {
1955                                        url: image_url.clone(),
1956                                    }
1957                                }
1958                            })
1959                            .collect();
1960                        LlmMessageContent::Parts(llm_parts)
1961                    }
1962                };
1963
1964                Some(LlmMessage {
1965                    role: llm_role,
1966                    content: llm_content,
1967                    tool_calls: None,
1968                    tool_call_id: None,
1969                    phase: None,
1970                    thinking: None,
1971                    thinking_signature: None,
1972                })
1973            }
1974            CompactOutputItem::Compaction { .. } => {
1975                // Compaction items are handled separately - they're passed as-is
1976                // to the next request, not converted to messages
1977                None
1978            }
1979        }
1980    }
1981}
1982
1983/// Convert a slice of LlmMessages to CompactInputItems
1984pub fn messages_to_compact_input(messages: &[LlmMessage]) -> Vec<CompactInputItem> {
1985    messages
1986        .iter()
1987        .flat_map(CompactInputItem::from_llm_message)
1988        .collect()
1989}
1990
1991/// Convert CompactResponse output to LlmMessages plus any compaction items
1992///
1993/// Returns a tuple of (regular messages, compaction items).
1994/// The compaction items should be preserved and included in subsequent compact requests.
1995pub fn compact_output_to_messages(
1996    output: &[CompactOutputItem],
1997) -> (Vec<LlmMessage>, Vec<CompactInputItem>) {
1998    let mut messages = Vec::new();
1999    let mut compaction_items = Vec::new();
2000
2001    for item in output {
2002        match item {
2003            CompactOutputItem::Message { role, content } => {
2004                if let Some(msg) = item.to_llm_message() {
2005                    messages.push(msg);
2006                } else {
2007                    // Re-add as compact input for next request
2008                    compaction_items.push(CompactInputItem::Message {
2009                        role: role.clone(),
2010                        content: content.clone(),
2011                    });
2012                }
2013            }
2014            CompactOutputItem::Compaction { encrypted_content } => {
2015                compaction_items.push(CompactInputItem::Compaction {
2016                    encrypted_content: encrypted_content.clone(),
2017                });
2018            }
2019        }
2020    }
2021
2022    (messages, compaction_items)
2023}
2024
2025// ============================================================================
2026// OpenAI Responses API Types
2027// ============================================================================
2028
2029#[derive(Debug, Serialize)]
2030struct ResponsesRequest {
2031    model: String,
2032    input: Vec<ResponsesInputItem>,
2033    #[serde(skip_serializing_if = "Option::is_none")]
2034    instructions: Option<String>,
2035    #[serde(skip_serializing_if = "Option::is_none")]
2036    previous_response_id: Option<String>,
2037    #[serde(skip_serializing_if = "Option::is_none")]
2038    temperature: Option<f32>,
2039    #[serde(skip_serializing_if = "Option::is_none")]
2040    max_output_tokens: Option<u32>,
2041    stream: bool,
2042    #[serde(skip_serializing_if = "Option::is_none")]
2043    tools: Option<Vec<ResponsesTool>>,
2044    #[serde(skip_serializing_if = "Option::is_none")]
2045    reasoning: Option<ResponsesReasoning>,
2046    /// Metadata for tracking API usage (up to 16 key-value pairs).
2047    /// Useful for correlating requests with session_id, agent_id, org_id, etc.
2048    #[serde(skip_serializing_if = "Option::is_none")]
2049    metadata: Option<std::collections::HashMap<String, String>>,
2050    #[serde(skip_serializing_if = "Option::is_none")]
2051    prompt_cache_key: Option<String>,
2052    /// Request-level parallel tool calling preference (EVE-598). Omitted when
2053    /// `None` to preserve the provider default.
2054    #[serde(skip_serializing_if = "Option::is_none")]
2055    parallel_tool_calls: Option<bool>,
2056    /// Speed selector: OpenAI service tier ("flex", "default", "priority").
2057    /// Omitted when `None` so the provider keeps its default ("auto") routing.
2058    #[serde(skip_serializing_if = "Option::is_none")]
2059    service_tier: Option<String>,
2060}
2061
2062#[derive(Debug, Serialize)]
2063struct ResponsesReasoning {
2064    effort: String,
2065    /// Request reasoning summary to get thinking tokens streamed back.
2066    /// Without this, reasoning happens internally but tokens are not exposed.
2067    summary: String,
2068}
2069
2070#[derive(Debug, Serialize)]
2071#[serde(untagged)]
2072enum ResponsesInputItem {
2073    Message {
2074        r#type: String,
2075        role: String,
2076        content: ResponsesContent,
2077        /// Execution phase for assistant messages (e.g., "in_progress", "completed").
2078        /// Helps GPT-5.x distinguish intermediate working commentary from final answers.
2079        /// Only set on assistant messages; must be preserved when replaying history.
2080        #[serde(skip_serializing_if = "Option::is_none")]
2081        phase: Option<String>,
2082    },
2083    FunctionCall {
2084        r#type: String,
2085        call_id: String,
2086        name: String,
2087        arguments: String,
2088    },
2089    FunctionCallOutput {
2090        r#type: String,
2091        call_id: String,
2092        output: String,
2093    },
2094    /// Reasoning item for o-series and GPT-5 models
2095    /// Contains encrypted reasoning content that preserves reasoning context across turns
2096    /// (similar to Anthropic's thinking signature).
2097    ///
2098    /// Stateless requests must re-send prior `Reasoning` items in `input` so the model can
2099    /// continue from them. Stateful continuations (those carrying `previous_response_id`)
2100    /// rely on OpenAI to hold the prior reasoning chain server-side, so [`compute_delta_input_items`]
2101    /// intentionally drops `Reasoning` items that belong to a prior assistant turn — re-sending
2102    /// them alongside `previous_response_id` would violate the no-mixing invariant.
2103    Reasoning {
2104        r#type: String,
2105        /// Unique ID for this reasoning item
2106        id: String,
2107        /// Encrypted reasoning content (required for multi-turn conversations)
2108        encrypted_content: String,
2109    },
2110}
2111
2112#[derive(Debug, Serialize, Deserialize)]
2113#[serde(untagged)]
2114enum ResponsesContent {
2115    Text(String),
2116    Parts(Vec<ResponsesContentPart>),
2117}
2118
2119// The "Input" prefix matches OpenAI's Responses API naming convention
2120#[derive(Debug, Serialize, Deserialize)]
2121#[serde(untagged)]
2122#[allow(clippy::enum_variant_names)]
2123enum ResponsesContentPart {
2124    InputText {
2125        r#type: String,
2126        text: String,
2127    },
2128    InputImage {
2129        r#type: String,
2130        image_url: String,
2131    },
2132    InputAudio {
2133        r#type: String,
2134        input_audio: ResponsesInputAudio,
2135    },
2136}
2137
2138#[derive(Debug, Serialize, Deserialize)]
2139struct ResponsesInputAudio {
2140    data: String,
2141    format: String,
2142}
2143
2144#[derive(Debug, Serialize)]
2145#[serde(untagged)]
2146enum ResponsesTool {
2147    /// Standard function tool (or deferred function with defer_loading)
2148    Function {
2149        r#type: String,
2150        name: String,
2151        description: String,
2152        parameters: Value,
2153        #[serde(skip_serializing_if = "Option::is_none")]
2154        defer_loading: Option<bool>,
2155    },
2156    /// Namespace grouping for tool_search (groups related deferred tools)
2157    Namespace {
2158        r#type: String,
2159        name: String,
2160        description: String,
2161        tools: Vec<ResponsesTool>,
2162    },
2163    /// Activates tool_search on the request
2164    ToolSearch { r#type: String },
2165}
2166
2167// ============================================================================
2168// Tests
2169// ============================================================================
2170
2171#[cfg(test)]
2172mod tests {
2173    use super::*;
2174
2175    #[test]
2176    fn test_driver_with_api_key() {
2177        let driver = OpenResponsesProtocolChatDriver::new("test-key");
2178        assert!(format!("{:?}", driver).contains("OpenResponsesProtocolChatDriver"));
2179    }
2180
2181    #[test]
2182    fn test_driver_with_base_url() {
2183        let driver = OpenResponsesProtocolChatDriver::with_base_url(
2184            "test-key",
2185            "https://custom.api.com/v1/responses",
2186        );
2187        assert!(format!("{:?}", driver).contains("OpenResponsesProtocolChatDriver"));
2188        assert_eq!(driver.api_url(), "https://custom.api.com/v1/responses");
2189    }
2190
2191    #[test]
2192    fn test_request_serialization() {
2193        let request = ResponsesRequest {
2194            service_tier: None,
2195            model: "gpt-4o".to_string(),
2196            input: vec![ResponsesInputItem::Message {
2197                r#type: "message".to_string(),
2198                role: "user".to_string(),
2199                content: ResponsesContent::Text("Hello".to_string()),
2200                phase: None,
2201            }],
2202            instructions: Some("You are helpful".to_string()),
2203            previous_response_id: None,
2204            temperature: None,
2205            max_output_tokens: None,
2206            stream: true,
2207            tools: None,
2208            reasoning: None,
2209            metadata: None,
2210            prompt_cache_key: None,
2211            parallel_tool_calls: None,
2212        };
2213
2214        let json = serde_json::to_value(&request).unwrap();
2215        assert_eq!(json["model"], "gpt-4o");
2216        assert_eq!(json["stream"], true);
2217        assert_eq!(json["instructions"], "You are helpful");
2218        assert!(json["input"].is_array());
2219    }
2220
2221    #[test]
2222    fn test_request_with_reasoning() {
2223        let request = ResponsesRequest {
2224            service_tier: None,
2225            model: "o3".to_string(),
2226            input: vec![ResponsesInputItem::Message {
2227                r#type: "message".to_string(),
2228                role: "user".to_string(),
2229                content: ResponsesContent::Text("Think about this".to_string()),
2230                phase: None,
2231            }],
2232            instructions: None,
2233            previous_response_id: None,
2234            temperature: None,
2235            max_output_tokens: None,
2236            stream: true,
2237            tools: None,
2238            reasoning: Some(ResponsesReasoning {
2239                effort: "high".to_string(),
2240                summary: "detailed".to_string(),
2241            }),
2242            metadata: None,
2243            prompt_cache_key: None,
2244            parallel_tool_calls: None,
2245        };
2246
2247        let json = serde_json::to_value(&request).unwrap();
2248        assert_eq!(json["reasoning"]["effort"], "high");
2249        assert_eq!(json["reasoning"]["summary"], "detailed");
2250    }
2251
2252    #[test]
2253    fn test_request_with_metadata() {
2254        let mut metadata = std::collections::HashMap::new();
2255        metadata.insert("session_id".to_string(), "session_abc123".to_string());
2256        metadata.insert("agent_id".to_string(), "agent_xyz789".to_string());
2257
2258        let request = ResponsesRequest {
2259            service_tier: None,
2260            model: "gpt-4o".to_string(),
2261            input: vec![ResponsesInputItem::Message {
2262                r#type: "message".to_string(),
2263                role: "user".to_string(),
2264                content: ResponsesContent::Text("Hello".to_string()),
2265                phase: None,
2266            }],
2267            instructions: None,
2268            previous_response_id: None,
2269            temperature: None,
2270            max_output_tokens: None,
2271            stream: true,
2272            tools: None,
2273            reasoning: None,
2274            metadata: Some(metadata),
2275            prompt_cache_key: None,
2276            parallel_tool_calls: None,
2277        };
2278
2279        let json = serde_json::to_value(&request).unwrap();
2280        assert_eq!(json["metadata"]["session_id"], "session_abc123");
2281        assert_eq!(json["metadata"]["agent_id"], "agent_xyz789");
2282    }
2283
2284    /// EVE-598: the Responses request serializes `parallel_tool_calls` only when
2285    /// the config sets it, preserving provider defaults when `None`.
2286    #[test]
2287    fn test_request_serializes_parallel_tool_calls() {
2288        let make = |flag: Option<bool>| ResponsesRequest {
2289            service_tier: None,
2290            model: "gpt-5.4".to_string(),
2291            input: vec![ResponsesInputItem::Message {
2292                r#type: "message".to_string(),
2293                role: "user".to_string(),
2294                content: ResponsesContent::Text("Hello".to_string()),
2295                phase: None,
2296            }],
2297            instructions: None,
2298            previous_response_id: None,
2299            temperature: None,
2300            max_output_tokens: None,
2301            stream: true,
2302            tools: None,
2303            reasoning: None,
2304            metadata: None,
2305            prompt_cache_key: None,
2306            parallel_tool_calls: flag,
2307        };
2308
2309        // None → field omitted entirely (provider default preserved).
2310        let json = serde_json::to_value(make(None)).unwrap();
2311        assert!(json.get("parallel_tool_calls").is_none());
2312
2313        // Some(true) → field present and true.
2314        let json = serde_json::to_value(make(Some(true))).unwrap();
2315        assert_eq!(json["parallel_tool_calls"], true);
2316
2317        // Some(false) → field present and false.
2318        let json = serde_json::to_value(make(Some(false))).unwrap();
2319        assert_eq!(json["parallel_tool_calls"], false);
2320    }
2321
2322    /// The speed selector serializes as `service_tier` only when set,
2323    /// preserving the provider's default ("auto") routing when `None`.
2324    #[test]
2325    fn test_request_serializes_service_tier() {
2326        let make = |tier: Option<&str>| ResponsesRequest {
2327            service_tier: tier.map(str::to_string),
2328            model: "gpt-5.4".to_string(),
2329            input: vec![ResponsesInputItem::Message {
2330                r#type: "message".to_string(),
2331                role: "user".to_string(),
2332                content: ResponsesContent::Text("Hello".to_string()),
2333                phase: None,
2334            }],
2335            instructions: None,
2336            previous_response_id: None,
2337            temperature: None,
2338            max_output_tokens: None,
2339            stream: true,
2340            tools: None,
2341            reasoning: None,
2342            metadata: None,
2343            prompt_cache_key: None,
2344            parallel_tool_calls: None,
2345        };
2346
2347        let json = serde_json::to_value(make(None)).unwrap();
2348        assert!(json.get("service_tier").is_none());
2349
2350        let json = serde_json::to_value(make(Some("priority"))).unwrap();
2351        assert_eq!(json["service_tier"], "priority");
2352
2353        let json = serde_json::to_value(make(Some("flex"))).unwrap();
2354        assert_eq!(json["service_tier"], "flex");
2355    }
2356
2357    #[test]
2358    fn test_build_prompt_cache_key_when_enabled() {
2359        let mut metadata = std::collections::HashMap::new();
2360        metadata.insert("session_id".to_string(), "session_abc123".to_string());
2361        let config = LlmCallConfig {
2362            speed: None,
2363            model: "gpt-5.4".to_string(),
2364            temperature: None,
2365            max_tokens: None,
2366            tools: vec![],
2367            reasoning_effort: None,
2368            metadata,
2369            previous_response_id: None,
2370            tool_search: None,
2371            prompt_cache: Some(crate::driver_registry::PromptCacheConfig {
2372                enabled: true,
2373                strategy: crate::driver_registry::PromptCacheStrategy::Auto,
2374                gemini_cached_content: None,
2375            }),
2376            openrouter_routing: None,
2377            parallel_tool_calls: None,
2378            volatile_suffix_len: 0,
2379        };
2380        let input = vec![ResponsesInputItem::Message {
2381            r#type: "message".to_string(),
2382            role: "user".to_string(),
2383            content: ResponsesContent::Text("Hello".to_string()),
2384            phase: None,
2385        }];
2386
2387        let key = OpenResponsesProtocolChatDriver::build_prompt_cache_key(
2388            &config,
2389            &input,
2390            &Some("You are helpful".to_string()),
2391            &None,
2392        );
2393
2394        assert!(key.is_some());
2395        assert!(key.unwrap().starts_with("everruns:"));
2396    }
2397
2398    #[test]
2399    fn test_build_prompt_cache_key_ignores_changing_input() {
2400        let mut metadata = std::collections::HashMap::new();
2401        metadata.insert("session_id".to_string(), "session_abc123".to_string());
2402        let config = LlmCallConfig {
2403            speed: None,
2404            model: "gpt-5.4".to_string(),
2405            temperature: None,
2406            max_tokens: None,
2407            tools: vec![],
2408            reasoning_effort: None,
2409            metadata,
2410            previous_response_id: None,
2411            tool_search: None,
2412            prompt_cache: Some(crate::driver_registry::PromptCacheConfig {
2413                enabled: true,
2414                strategy: crate::driver_registry::PromptCacheStrategy::Auto,
2415                gemini_cached_content: None,
2416            }),
2417            openrouter_routing: None,
2418            parallel_tool_calls: None,
2419            volatile_suffix_len: 0,
2420        };
2421        let first_input = vec![ResponsesInputItem::Message {
2422            r#type: "message".to_string(),
2423            role: "user".to_string(),
2424            content: ResponsesContent::Text("first turn".to_string()),
2425            phase: None,
2426        }];
2427        let second_input = vec![ResponsesInputItem::Message {
2428            r#type: "message".to_string(),
2429            role: "user".to_string(),
2430            content: ResponsesContent::Text("second turn with different text".to_string()),
2431            phase: None,
2432        }];
2433
2434        let first = OpenResponsesProtocolChatDriver::build_prompt_cache_key(
2435            &config,
2436            &first_input,
2437            &Some("You are helpful".to_string()),
2438            &None,
2439        );
2440        let second = OpenResponsesProtocolChatDriver::build_prompt_cache_key(
2441            &config,
2442            &second_input,
2443            &Some("You are helpful".to_string()),
2444            &None,
2445        );
2446
2447        assert_eq!(first, second);
2448    }
2449
2450    #[test]
2451    fn test_build_prompt_cache_key_changes_with_cache_family() {
2452        let mut first_metadata = std::collections::HashMap::new();
2453        first_metadata.insert("session_id".to_string(), "session_abc123".to_string());
2454        let mut second_metadata = std::collections::HashMap::new();
2455        second_metadata.insert("session_id".to_string(), "session_xyz789".to_string());
2456        let make_config = |metadata| LlmCallConfig {
2457            speed: None,
2458            model: "gpt-5.4".to_string(),
2459            temperature: None,
2460            max_tokens: None,
2461            tools: vec![],
2462            reasoning_effort: None,
2463            metadata,
2464            previous_response_id: None,
2465            tool_search: None,
2466            prompt_cache: Some(crate::driver_registry::PromptCacheConfig {
2467                enabled: true,
2468                strategy: crate::driver_registry::PromptCacheStrategy::Auto,
2469                gemini_cached_content: None,
2470            }),
2471            openrouter_routing: None,
2472            parallel_tool_calls: None,
2473            volatile_suffix_len: 0,
2474        };
2475        let input = vec![ResponsesInputItem::Message {
2476            r#type: "message".to_string(),
2477            role: "user".to_string(),
2478            content: ResponsesContent::Text("same turn".to_string()),
2479            phase: None,
2480        }];
2481
2482        let first = OpenResponsesProtocolChatDriver::build_prompt_cache_key(
2483            &make_config(first_metadata),
2484            &input,
2485            &Some("You are helpful".to_string()),
2486            &None,
2487        );
2488        let second = OpenResponsesProtocolChatDriver::build_prompt_cache_key(
2489            &make_config(second_metadata),
2490            &input,
2491            &Some("You are helpful".to_string()),
2492            &None,
2493        );
2494
2495        assert_ne!(first, second);
2496    }
2497
2498    #[test]
2499    fn test_build_prompt_cache_key_stays_within_openai_limit() {
2500        let config = LlmCallConfig {
2501            speed: None,
2502            model: "gpt-5.5".to_string(),
2503            temperature: None,
2504            max_tokens: None,
2505            tools: vec![],
2506            reasoning_effort: None,
2507            metadata: std::collections::HashMap::new(),
2508            previous_response_id: None,
2509            tool_search: None,
2510            prompt_cache: Some(crate::driver_registry::PromptCacheConfig {
2511                enabled: true,
2512                strategy: crate::driver_registry::PromptCacheStrategy::Auto,
2513                gemini_cached_content: None,
2514            }),
2515            openrouter_routing: None,
2516            parallel_tool_calls: None,
2517            volatile_suffix_len: 0,
2518        };
2519        let input = vec![ResponsesInputItem::Message {
2520            r#type: "message".to_string(),
2521            role: "user".to_string(),
2522            content: ResponsesContent::Text("fetch chalyi.name for me".to_string()),
2523            phase: None,
2524        }];
2525
2526        let key = OpenResponsesProtocolChatDriver::build_prompt_cache_key(
2527            &config,
2528            &input,
2529            &Some("You are helpful".to_string()),
2530            &None,
2531        )
2532        .unwrap();
2533
2534        assert!(
2535            key.len() <= 64,
2536            "OpenAI prompt_cache_key limit is 64 characters, got {}",
2537            key.len()
2538        );
2539    }
2540
2541    #[test]
2542    fn test_function_call_output_serialization() {
2543        let item = ResponsesInputItem::FunctionCallOutput {
2544            r#type: "function_call_output".to_string(),
2545            call_id: "call_123".to_string(),
2546            output: r#"{"result": 42}"#.to_string(),
2547        };
2548
2549        let json = serde_json::to_value(&item).unwrap();
2550        assert_eq!(json["type"], "function_call_output");
2551        assert_eq!(json["call_id"], "call_123");
2552        assert_eq!(json["output"], r#"{"result": 42}"#);
2553    }
2554
2555    #[test]
2556    fn test_multipart_content_serialization() {
2557        let content = ResponsesContent::Parts(vec![
2558            ResponsesContentPart::InputText {
2559                r#type: "input_text".to_string(),
2560                text: "Look at this image".to_string(),
2561            },
2562            ResponsesContentPart::InputImage {
2563                r#type: "input_image".to_string(),
2564                image_url: "data:image/png;base64,abc123".to_string(),
2565            },
2566        ]);
2567
2568        let json = serde_json::to_value(&content).unwrap();
2569        assert!(json.is_array());
2570        assert_eq!(json[0]["type"], "input_text");
2571        assert_eq!(json[1]["type"], "input_image");
2572    }
2573
2574    #[test]
2575    fn test_tool_serialization() {
2576        let tool = ResponsesTool::Function {
2577            r#type: "function".to_string(),
2578            name: "get_weather".to_string(),
2579            description: "Get weather for a location".to_string(),
2580            parameters: json!({
2581                "type": "object",
2582                "properties": {
2583                    "location": {"type": "string"}
2584                },
2585                "required": ["location"]
2586            }),
2587            defer_loading: None,
2588        };
2589
2590        let json = serde_json::to_value(&tool).unwrap();
2591        assert_eq!(json["type"], "function");
2592        assert_eq!(json["name"], "get_weather");
2593        assert!(json["parameters"]["properties"]["location"].is_object());
2594    }
2595
2596    #[test]
2597    fn test_build_input_extracts_system_as_instructions() {
2598        let messages = vec![
2599            LlmMessage::text(LlmMessageRole::System, "You are a helpful assistant"),
2600            LlmMessage::text(LlmMessageRole::User, "Hello"),
2601        ];
2602
2603        let (instructions, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
2604
2605        assert_eq!(
2606            instructions,
2607            Some("You are a helpful assistant".to_string())
2608        );
2609        assert_eq!(input.len(), 1); // Only user message, system converted to instructions
2610    }
2611
2612    #[test]
2613    fn test_build_input_concatenates_multiple_system_messages() {
2614        // The agent system prompt plus a later system message (e.g. infinity
2615        // context's hidden-history notice or compaction's summary) must both
2616        // survive — the later one must not overwrite the real system prompt.
2617        let messages = vec![
2618            LlmMessage::text(LlmMessageRole::System, "You are a helpful assistant"),
2619            LlmMessage::text(LlmMessageRole::User, "Hello"),
2620            LlmMessage::text(
2621                LlmMessageRole::System,
2622                "[IMPORTANT: 3 earlier messages are NOT visible in this context.]",
2623            ),
2624        ];
2625
2626        let (instructions, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
2627
2628        assert_eq!(
2629            instructions,
2630            Some(
2631                "You are a helpful assistant\n\n[IMPORTANT: 3 earlier messages are NOT visible in this context.]"
2632                    .to_string()
2633            )
2634        );
2635        assert_eq!(input.len(), 1); // Only the user message remains as input
2636    }
2637
2638    #[test]
2639    fn test_convert_role() {
2640        assert_eq!(
2641            OpenResponsesProtocolChatDriver::convert_role(&LlmMessageRole::System),
2642            "developer"
2643        );
2644        assert_eq!(
2645            OpenResponsesProtocolChatDriver::convert_role(&LlmMessageRole::User),
2646            "user"
2647        );
2648        assert_eq!(
2649            OpenResponsesProtocolChatDriver::convert_role(&LlmMessageRole::Assistant),
2650            "assistant"
2651        );
2652        assert_eq!(
2653            OpenResponsesProtocolChatDriver::convert_role(&LlmMessageRole::Tool),
2654            "tool"
2655        );
2656    }
2657
2658    #[test]
2659    fn test_function_call_serialization() {
2660        let item = ResponsesInputItem::FunctionCall {
2661            r#type: "function_call".to_string(),
2662            call_id: "call_abc123".to_string(),
2663            name: "get_current_time".to_string(),
2664            arguments: r#"{"timezone":"UTC"}"#.to_string(),
2665        };
2666
2667        let json = serde_json::to_value(&item).unwrap();
2668        assert_eq!(json["type"], "function_call");
2669        assert_eq!(json["call_id"], "call_abc123");
2670        assert_eq!(json["name"], "get_current_time");
2671        assert_eq!(json["arguments"], r#"{"timezone":"UTC"}"#);
2672    }
2673
2674    #[test]
2675    fn test_build_input_with_tool_calls() {
2676        use crate::tool_types::ToolCall;
2677
2678        // Simulate a conversation with tool calls:
2679        // 1. User asks a question
2680        // 2. Assistant calls a tool
2681        // 3. Tool returns result
2682        let messages = vec![
2683            LlmMessage::text(LlmMessageRole::System, "You are helpful"),
2684            LlmMessage::text(LlmMessageRole::User, "What time is it?"),
2685            LlmMessage {
2686                role: LlmMessageRole::Assistant,
2687                content: LlmMessageContent::Text(String::new()),
2688                tool_calls: Some(vec![ToolCall {
2689                    id: "call_xyz789".to_string(),
2690                    name: "get_current_time".to_string(),
2691                    arguments: json!({"timezone": "UTC"}),
2692                }]),
2693                tool_call_id: None,
2694                phase: None,
2695                thinking: None,
2696                thinking_signature: None,
2697            },
2698            LlmMessage {
2699                role: LlmMessageRole::Tool,
2700                content: LlmMessageContent::Text("2025-01-19T10:30:00Z".to_string()),
2701                tool_calls: None,
2702                tool_call_id: Some("call_xyz789".to_string()),
2703                phase: None,
2704                thinking: None,
2705                thinking_signature: None,
2706            },
2707        ];
2708
2709        let (instructions, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
2710
2711        // System message becomes instructions
2712        assert_eq!(instructions, Some("You are helpful".to_string()));
2713
2714        // Should have: user message, function_call, function_call_output
2715        assert_eq!(input.len(), 3);
2716
2717        // Verify the function_call is present (second item, since assistant had empty content)
2718        let json = serde_json::to_value(&input[1]).unwrap();
2719        assert_eq!(json["type"], "function_call");
2720        assert_eq!(json["call_id"], "call_xyz789");
2721        assert_eq!(json["name"], "get_current_time");
2722
2723        // Verify the function_call_output is present
2724        let json = serde_json::to_value(&input[2]).unwrap();
2725        assert_eq!(json["type"], "function_call_output");
2726        assert_eq!(json["call_id"], "call_xyz789");
2727        assert_eq!(json["output"], "2025-01-19T10:30:00Z");
2728    }
2729
2730    #[test]
2731    fn test_build_input_with_tool_calls_and_text() {
2732        use crate::tool_types::ToolCall;
2733
2734        // Assistant message with both text content and tool calls
2735        let messages = vec![
2736            LlmMessage::text(LlmMessageRole::User, "What time is it?"),
2737            LlmMessage {
2738                role: LlmMessageRole::Assistant,
2739                content: LlmMessageContent::Text("Let me check the time for you.".to_string()),
2740                tool_calls: Some(vec![ToolCall {
2741                    id: "call_abc".to_string(),
2742                    name: "get_time".to_string(),
2743                    arguments: json!({}),
2744                }]),
2745                tool_call_id: None,
2746                phase: None,
2747                thinking: None,
2748                thinking_signature: None,
2749            },
2750        ];
2751
2752        let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
2753
2754        // Should have: user message, assistant message, function_call
2755        assert_eq!(input.len(), 3);
2756
2757        // First is user message
2758        let json = serde_json::to_value(&input[0]).unwrap();
2759        assert_eq!(json["role"], "user");
2760
2761        // Second is assistant message with text
2762        let json = serde_json::to_value(&input[1]).unwrap();
2763        assert_eq!(json["role"], "assistant");
2764
2765        // Third is function_call
2766        let json = serde_json::to_value(&input[2]).unwrap();
2767        assert_eq!(json["type"], "function_call");
2768        assert_eq!(json["call_id"], "call_abc");
2769    }
2770
2771    // ========================================================================
2772    // EVE-488: Stateful Responses continuations must not double-send context.
2773    //
2774    // When `previous_response_id` is set, the OpenAI Responses provider already
2775    // holds the prior transcript server-side. Re-sending it in `input` causes
2776    // double-counting. These tests pin the invariant that the delta-trim helper
2777    // only keeps items strictly after the most recent assistant turn, and
2778    // that the request-building path applies the trim when (and only when) a
2779    // continuation handle is present.
2780    // ========================================================================
2781
2782    /// Issue reproducer: a stateful continuation must not carry the full prior
2783    /// transcript in `input` alongside `previous_response_id`. After trimming,
2784    /// only the new tool result and any fresh user input should remain.
2785    #[test]
2786    fn openresponses_requests_should_not_mix_previous_response_id_with_full_transcript() {
2787        use crate::tool_types::ToolCall;
2788
2789        // Simulate a multi-turn transcript: system + user + assistant(tool_call) + tool result.
2790        // This is the exact shape that gets reconstructed on a follow-up turn when
2791        // the runtime has a `previous_response_id` from the prior assistant turn.
2792        let messages = vec![
2793            LlmMessage::text(LlmMessageRole::System, "You are helpful"),
2794            LlmMessage::text(LlmMessageRole::User, "What time is it?"),
2795            LlmMessage {
2796                role: LlmMessageRole::Assistant,
2797                content: LlmMessageContent::Text("Let me check.".to_string()),
2798                tool_calls: Some(vec![ToolCall {
2799                    id: "call_xyz789".to_string(),
2800                    name: "get_current_time".to_string(),
2801                    arguments: json!({"timezone": "UTC"}),
2802                }]),
2803                tool_call_id: None,
2804                phase: None,
2805                thinking: None,
2806                thinking_signature: None,
2807            },
2808            LlmMessage {
2809                role: LlmMessageRole::Tool,
2810                content: LlmMessageContent::Text("2025-01-19T10:30:00Z".to_string()),
2811                tool_calls: None,
2812                tool_call_id: Some("call_xyz789".to_string()),
2813                phase: None,
2814                thinking: None,
2815                thinking_signature: None,
2816            },
2817        ];
2818
2819        // Build the full transcript the same way the driver does.
2820        let (instructions, full_input) =
2821            OpenResponsesProtocolChatDriver::build_input(&messages, false);
2822
2823        // Without trimming the full transcript leaks user + assistant + function_call
2824        // + function_call_output — exactly the bug.
2825        assert!(
2826            full_input.len() > 1,
2827            "sanity: full transcript has multi items"
2828        );
2829
2830        // The trim performed when `previous_response_id` is present in the request
2831        // path must drop everything up to and including the last prior-assistant item.
2832        let delta = compute_delta_input_items(full_input);
2833
2834        // Only the tool result (function_call_output) should remain.
2835        assert_eq!(
2836            delta.len(),
2837            1,
2838            "stateful continuation must only send delta items; got {} items",
2839            delta.len()
2840        );
2841        let json = serde_json::to_value(&delta[0]).unwrap();
2842        assert_eq!(json["type"], "function_call_output");
2843        assert_eq!(json["call_id"], "call_xyz789");
2844        assert_eq!(json["output"], "2025-01-19T10:30:00Z");
2845
2846        // Instructions (system message) are NOT part of `input`; they're still sent
2847        // separately and that is correct — they don't count toward the invariant.
2848        assert_eq!(instructions, Some("You are helpful".to_string()));
2849    }
2850
2851    /// Stateless mode (no previous_response_id): all input items are kept.
2852    /// The trim helper is only invoked by the call path when previous_response_id
2853    /// is set; this test pins that the helper produces correct delta output
2854    /// regardless, leaving the fresh user message that follows the assistant turn.
2855    #[test]
2856    fn compute_delta_keeps_tail_after_assistant_message() {
2857        let items = vec![
2858            ResponsesInputItem::Message {
2859                r#type: "message".to_string(),
2860                role: "user".to_string(),
2861                content: ResponsesContent::Text("hi".to_string()),
2862                phase: None,
2863            },
2864            ResponsesInputItem::Message {
2865                r#type: "message".to_string(),
2866                role: "assistant".to_string(),
2867                content: ResponsesContent::Text("hello".to_string()),
2868                phase: None,
2869            },
2870            ResponsesInputItem::Message {
2871                r#type: "message".to_string(),
2872                role: "user".to_string(),
2873                content: ResponsesContent::Text("follow up".to_string()),
2874                phase: None,
2875            },
2876        ];
2877        let trimmed = compute_delta_input_items(items);
2878        assert_eq!(trimmed.len(), 1);
2879        let json = serde_json::to_value(&trimmed[0]).unwrap();
2880        assert_eq!(json["role"], "user");
2881        assert_eq!(
2882            json["content"], "follow up",
2883            "trim keeps the fresh user message that arrived after the assistant turn"
2884        );
2885    }
2886
2887    /// Stateful continuation with parallel tool calls: every tool output that
2888    /// follows the prior assistant's function_call items is kept. The function_call
2889    /// items themselves belong to server-side state and are dropped.
2890    #[test]
2891    fn compute_delta_keeps_tool_results_after_last_assistant_turn() {
2892        let items = vec![
2893            ResponsesInputItem::Message {
2894                r#type: "message".to_string(),
2895                role: "user".to_string(),
2896                content: ResponsesContent::Text("do two things".to_string()),
2897                phase: None,
2898            },
2899            ResponsesInputItem::Message {
2900                r#type: "message".to_string(),
2901                role: "assistant".to_string(),
2902                content: ResponsesContent::Text("ok".to_string()),
2903                phase: None,
2904            },
2905            ResponsesInputItem::FunctionCall {
2906                r#type: "function_call".to_string(),
2907                call_id: "call_a".to_string(),
2908                name: "tool_a".to_string(),
2909                arguments: "{}".to_string(),
2910            },
2911            ResponsesInputItem::FunctionCall {
2912                r#type: "function_call".to_string(),
2913                call_id: "call_b".to_string(),
2914                name: "tool_b".to_string(),
2915                arguments: "{}".to_string(),
2916            },
2917            ResponsesInputItem::FunctionCallOutput {
2918                r#type: "function_call_output".to_string(),
2919                call_id: "call_a".to_string(),
2920                output: "a result".to_string(),
2921            },
2922            ResponsesInputItem::FunctionCallOutput {
2923                r#type: "function_call_output".to_string(),
2924                call_id: "call_b".to_string(),
2925                output: "b result".to_string(),
2926            },
2927        ];
2928
2929        let trimmed = compute_delta_input_items(items);
2930
2931        // The function_call items live in server-side state. The delta carries
2932        // only the tool outputs the client produced for them.
2933        assert_eq!(trimmed.len(), 2);
2934        for item in &trimmed {
2935            let json = serde_json::to_value(item).unwrap();
2936            assert_eq!(json["type"], "function_call_output");
2937        }
2938    }
2939
2940    /// Empty input with previous_response_id is valid: the provider can resume
2941    /// purely from the continuation handle, no input needed.
2942    #[test]
2943    fn compute_delta_allows_empty_input_for_stateful_continuation() {
2944        let trimmed = compute_delta_input_items(vec![]);
2945        assert!(trimmed.is_empty());
2946    }
2947
2948    /// Defensive: if no prior-assistant item is present (caller passed only fresh
2949    /// user input), all items are kept as delta.
2950    #[test]
2951    fn compute_delta_keeps_all_items_when_no_assistant_turn_present() {
2952        let items = vec![
2953            ResponsesInputItem::Message {
2954                r#type: "message".to_string(),
2955                role: "user".to_string(),
2956                content: ResponsesContent::Text("one".to_string()),
2957                phase: None,
2958            },
2959            ResponsesInputItem::Message {
2960                r#type: "message".to_string(),
2961                role: "user".to_string(),
2962                content: ResponsesContent::Text("two".to_string()),
2963                phase: None,
2964            },
2965        ];
2966        let trimmed = compute_delta_input_items(items);
2967        assert_eq!(trimmed.len(), 2);
2968    }
2969
2970    /// Reasoning items from a prior assistant turn are also dropped by the trim.
2971    #[test]
2972    fn compute_delta_drops_prior_reasoning_items() {
2973        let items = vec![
2974            ResponsesInputItem::Reasoning {
2975                r#type: "reasoning".to_string(),
2976                id: "rs_00000001".to_string(),
2977                encrypted_content: "encrypted-blob".to_string(),
2978            },
2979            ResponsesInputItem::Message {
2980                r#type: "message".to_string(),
2981                role: "assistant".to_string(),
2982                content: ResponsesContent::Text("prior".to_string()),
2983                phase: None,
2984            },
2985            ResponsesInputItem::FunctionCallOutput {
2986                r#type: "function_call_output".to_string(),
2987                call_id: "call_z".to_string(),
2988                output: "result".to_string(),
2989            },
2990        ];
2991        let trimmed = compute_delta_input_items(items);
2992        assert_eq!(trimmed.len(), 1);
2993        let json = serde_json::to_value(&trimmed[0]).unwrap();
2994        assert_eq!(json["type"], "function_call_output");
2995    }
2996
2997    // ------------------------------------------------------------------------
2998    // Request-builder integration: `finalize_input_for_request` is the single
2999    // gate that chooses whether the request `input` is trimmed. These tests
3000    // pin the exact decision the call path makes — they catch regressions
3001    // where the `previous_response_id`-presence check is accidentally dropped
3002    // or inverted, which is what would re-introduce the bug even if the trim
3003    // helper itself stays correct.
3004    // ------------------------------------------------------------------------
3005
3006    fn sample_full_transcript_items() -> Vec<ResponsesInputItem> {
3007        vec![
3008            ResponsesInputItem::Message {
3009                r#type: "message".to_string(),
3010                role: "user".to_string(),
3011                content: ResponsesContent::Text("first request".to_string()),
3012                phase: None,
3013            },
3014            ResponsesInputItem::Message {
3015                r#type: "message".to_string(),
3016                role: "assistant".to_string(),
3017                content: ResponsesContent::Text("first reply".to_string()),
3018                phase: None,
3019            },
3020            ResponsesInputItem::Message {
3021                r#type: "message".to_string(),
3022                role: "user".to_string(),
3023                content: ResponsesContent::Text("follow-up".to_string()),
3024                phase: None,
3025            },
3026        ]
3027    }
3028
3029    #[test]
3030    fn finalize_input_skips_trim_when_previous_response_id_is_none() {
3031        let items = sample_full_transcript_items();
3032        let original_len = items.len();
3033        let out = finalize_input_for_request(items, &None);
3034        assert_eq!(
3035            out.len(),
3036            original_len,
3037            "stateless mode keeps the full transcript so the model has context"
3038        );
3039    }
3040
3041    #[test]
3042    fn finalize_input_drops_locally_orphaned_tool_output_without_previous_response_id() {
3043        let items = vec![
3044            ResponsesInputItem::Message {
3045                r#type: "message".to_string(),
3046                role: "user".to_string(),
3047                content: ResponsesContent::Text("fresh".to_string()),
3048                phase: None,
3049            },
3050            ResponsesInputItem::FunctionCallOutput {
3051                r#type: "function_call_output".to_string(),
3052                call_id: "call_trimmed".to_string(),
3053                output: "result".to_string(),
3054            },
3055        ];
3056
3057        let out = finalize_input_for_request(items, &None);
3058
3059        assert_eq!(out.len(), 1);
3060        let json = serde_json::to_value(&out[0]).unwrap();
3061        assert_eq!(json["type"], "message");
3062    }
3063
3064    #[test]
3065    fn finalize_input_keeps_tool_output_with_previous_response_id_even_without_local_call() {
3066        let items = vec![
3067            ResponsesInputItem::FunctionCallOutput {
3068                r#type: "function_call_output".to_string(),
3069                call_id: "call_server_side".to_string(),
3070                output: "stateful result".to_string(),
3071            },
3072            ResponsesInputItem::Message {
3073                r#type: "message".to_string(),
3074                role: "user".to_string(),
3075                content: ResponsesContent::Text("follow-up".to_string()),
3076                phase: None,
3077            },
3078        ];
3079
3080        let out = finalize_input_for_request(items, &Some("resp_prev_42".to_string()));
3081
3082        assert_eq!(out.len(), 2);
3083        let json = serde_json::to_value(&out[0]).unwrap();
3084        assert_eq!(json["type"], "function_call_output");
3085        assert_eq!(json["call_id"], "call_server_side");
3086    }
3087
3088    #[test]
3089    fn finalize_input_trims_when_previous_response_id_is_set() {
3090        let items = sample_full_transcript_items();
3091        let out = finalize_input_for_request(items, &Some("resp_prev_42".to_string()));
3092        assert_eq!(
3093            out.len(),
3094            1,
3095            "stateful continuation must drop everything up to and including the prior assistant message"
3096        );
3097        let json = serde_json::to_value(&out[0]).unwrap();
3098        assert_eq!(json["type"], "message");
3099        assert_eq!(json["role"], "user");
3100        // Only the post-assistant follow-up survives.
3101        let txt = json["content"].as_str().unwrap_or("");
3102        assert_eq!(txt, "follow-up");
3103    }
3104
3105    #[test]
3106    fn finalize_input_allows_empty_input_with_previous_response_id() {
3107        let out = finalize_input_for_request(vec![], &Some("resp_anything".to_string()));
3108        assert!(
3109            out.is_empty(),
3110            "empty delta is valid — the provider can resume purely from the response id"
3111        );
3112    }
3113
3114    // ------------------------------------------------------------------------
3115    // EVE-597: stateless full-replay must not serialize a `function_call` whose
3116    // `function_call_output` was evicted by compaction / model-view masking.
3117    // OpenAI/Codex Responses 400 with "No tool output found for function call …"
3118    // and the session wedges permanently. This is the sibling of EVE-519 (orphan
3119    // output, covered above); the repair drops both sides of a broken pair.
3120    // ------------------------------------------------------------------------
3121
3122    fn function_call(call_id: &str, name: &str) -> ResponsesInputItem {
3123        ResponsesInputItem::FunctionCall {
3124            r#type: "function_call".to_string(),
3125            call_id: call_id.to_string(),
3126            name: name.to_string(),
3127            arguments: "{}".to_string(),
3128        }
3129    }
3130
3131    fn function_call_output(call_id: &str) -> ResponsesInputItem {
3132        ResponsesInputItem::FunctionCallOutput {
3133            r#type: "function_call_output".to_string(),
3134            call_id: call_id.to_string(),
3135            output: "result".to_string(),
3136        }
3137    }
3138
3139    fn user_message(text: &str) -> ResponsesInputItem {
3140        ResponsesInputItem::Message {
3141            r#type: "message".to_string(),
3142            role: "user".to_string(),
3143            content: ResponsesContent::Text(text.to_string()),
3144            phase: None,
3145        }
3146    }
3147
3148    #[test]
3149    fn finalize_input_drops_dangling_function_call_without_previous_response_id() {
3150        // The exact incident: an early `read_file` call survived compaction but
3151        // its tool output was evicted (keep_recent_tool_outputs), leaving a
3152        // dangling `function_call`.
3153        let items = vec![
3154            user_message("fresh"),
3155            function_call("call_pHJNxIuwzLppFsQK5nJrDOpZ", "read_file"),
3156        ];
3157
3158        let out = finalize_input_for_request(items, &None);
3159
3160        assert_eq!(out.len(), 1);
3161        assert!(
3162            unpaired_function_call_ids(&out).is_empty(),
3163            "the dangling function_call must be dropped"
3164        );
3165        let json = serde_json::to_value(&out[0]).unwrap();
3166        assert_eq!(json["type"], "message");
3167    }
3168
3169    #[test]
3170    fn finalize_input_preserves_paired_function_call_and_output() {
3171        let items = vec![
3172            user_message("what time is it?"),
3173            function_call("call_ok", "get_current_time"),
3174            function_call_output("call_ok"),
3175        ];
3176
3177        let out = finalize_input_for_request(items, &None);
3178
3179        assert_eq!(out.len(), 3, "an intact call/output pair must survive");
3180        assert!(unpaired_function_call_ids(&out).is_empty());
3181    }
3182
3183    #[test]
3184    fn finalize_input_compaction_drops_only_the_dangling_old_call() {
3185        // Post-compaction model view equivalent to keep_recent_tool_outputs = 3:
3186        // one old call whose output was masked away, followed by three intact
3187        // recent pairs. Only the dangling old call is dropped; the recent pairs
3188        // and the surrounding messages are preserved.
3189        let mut items = vec![
3190            user_message("long session"),
3191            function_call("call_old", "read_file"),
3192        ];
3193        for i in 0..3 {
3194            let id = format!("call_recent_{i}");
3195            items.push(function_call(&id, "tool"));
3196            items.push(function_call_output(&id));
3197        }
3198
3199        let out = finalize_input_for_request(items, &None);
3200
3201        assert!(
3202            unpaired_function_call_ids(&out).is_empty(),
3203            "no dangling function_call may remain after repair"
3204        );
3205        assert!(
3206            !out.iter().any(|item| matches!(
3207                item,
3208                ResponsesInputItem::FunctionCall { call_id, .. } if call_id == "call_old"
3209            )),
3210            "the old dangling call must be removed"
3211        );
3212        // 1 user message + 3 intact recent pairs (6 items) = 7.
3213        assert_eq!(out.len(), 7);
3214    }
3215
3216    #[test]
3217    fn unpaired_function_call_ids_reports_both_directions() {
3218        let items = vec![
3219            function_call("call_no_output", "read_file"), // EVE-597: dangling call
3220            function_call_output("out_no_call"),          // EVE-519: orphan output
3221            function_call("paired", "tool"),
3222            function_call_output("paired"),
3223        ];
3224
3225        let mut ids = unpaired_function_call_ids(&items);
3226        ids.sort();
3227        assert_eq!(
3228            ids,
3229            vec!["call_no_output".to_string(), "out_no_call".to_string()]
3230        );
3231    }
3232
3233    // ========================================================================
3234    // Stateless-gateway detection (EVE-523)
3235    // ========================================================================
3236
3237    #[test]
3238    fn endpoint_persists_responses_for_openai_and_azure() {
3239        // OpenAI hosted API — stateful.
3240        assert!(endpoint_persists_responses(
3241            "https://api.openai.com/v1/responses"
3242        ));
3243        assert!(endpoint_persists_responses(
3244            "https://api.openai.com:443/v1/responses"
3245        ));
3246        // Azure OpenAI — stateful.
3247        assert!(endpoint_persists_responses(
3248            "https://my-resource.openai.azure.com/openai/v1/responses"
3249        ));
3250        assert!(endpoint_persists_responses(
3251            "https://my-resource.services.ai.azure.com/openai/v1/responses"
3252        ));
3253    }
3254
3255    #[test]
3256    fn endpoint_does_not_persist_for_stateless_gateways() {
3257        // OpenRouter and Gemini's compat shim accept `previous_response_id` but
3258        // ignore it — they must be treated as stateless so we replay the full
3259        // transcript each turn (EVE-523).
3260        assert!(!endpoint_persists_responses(
3261            "https://openrouter.ai/api/v1/responses"
3262        ));
3263        assert!(!endpoint_persists_responses(
3264            "https://generativelanguage.googleapis.com/v1beta/openai/responses"
3265        ));
3266        // A host that merely contains "openai" in its name must not be trusted.
3267        assert!(!endpoint_persists_responses(
3268            "https://api.openai.example.com/v1/responses"
3269        ));
3270    }
3271
3272    /// End-to-end shape of the call path: against a stateless gateway, a request
3273    /// that carries a `previous_response_id` in config must still send the FULL
3274    /// transcript in `input` (no trim) because the gateway will not have stored
3275    /// the prior response. This is the core EVE-523 regression guard.
3276    #[test]
3277    fn stateless_gateway_replays_full_transcript_despite_previous_response_id() {
3278        let api_url = "https://openrouter.ai/api/v1/responses";
3279        let prev_id: Option<String> = Some("gen-turn-1".to_string());
3280
3281        // Mirror the gating the call path performs.
3282        let effective_prev_id = if endpoint_persists_responses(api_url) {
3283            prev_id.clone()
3284        } else {
3285            None
3286        };
3287        assert!(
3288            effective_prev_id.is_none(),
3289            "stateless gateway must not chain via previous_response_id"
3290        );
3291
3292        let items = sample_full_transcript_items();
3293        let original_len = items.len();
3294        let out = finalize_input_for_request(items, &effective_prev_id);
3295        assert_eq!(
3296            out.len(),
3297            original_len,
3298            "stateless gateway must replay the full transcript so the model keeps context"
3299        );
3300    }
3301
3302    /// The same transcript against OpenAI's hosted API trims to the delta window
3303    /// and keeps the continuation handle — confirming the optimization is intact
3304    /// for genuinely stateful endpoints.
3305    #[test]
3306    fn stateful_endpoint_still_trims_and_chains() {
3307        let api_url = "https://api.openai.com/v1/responses";
3308        let prev_id: Option<String> = Some("resp_turn_1".to_string());
3309
3310        let effective_prev_id = if endpoint_persists_responses(api_url) {
3311            prev_id.clone()
3312        } else {
3313            None
3314        };
3315        assert_eq!(
3316            effective_prev_id, prev_id,
3317            "stateful endpoint keeps the continuation handle"
3318        );
3319
3320        let out = finalize_input_for_request(sample_full_transcript_items(), &effective_prev_id);
3321        assert_eq!(out.len(), 1, "stateful endpoint trims to the delta window");
3322    }
3323
3324    /// Wire-level EVE-523 reproducer: drive the real `chat_completion_stream`
3325    /// against a mock endpoint on a non-OpenAI host. Even with a
3326    /// `previous_response_id` in config, the request on the wire must omit it and
3327    /// carry the FULL transcript (user task + assistant turn + tool result), so a
3328    /// stateless gateway that ignores `previous_response_id` still sees the task.
3329    #[tokio::test]
3330    async fn stateless_gateway_request_replays_full_transcript_on_the_wire() {
3331        use crate::tool_types::ToolCall;
3332        use serde_json::json;
3333        use wiremock::matchers::method;
3334        use wiremock::{Mock, MockServer, ResponseTemplate};
3335
3336        let server = MockServer::start().await;
3337        // Any 200 lets the request through; we inspect the captured request, not
3338        // the (empty) streamed body.
3339        Mock::given(method("POST"))
3340            .respond_with(ResponseTemplate::new(200).set_body_string(""))
3341            .mount(&server)
3342            .await;
3343
3344        // server.uri() is a 127.0.0.1 host — not OpenAI/Azure — so it is treated
3345        // as a stateless gateway.
3346        let api_url = format!("{}/v1/responses", server.uri());
3347        let driver = OpenResponsesProtocolChatDriver::with_base_url("test-key", api_url);
3348
3349        let messages = vec![
3350            LlmMessage::text(LlmMessageRole::System, "You are helpful"),
3351            LlmMessage::text(LlmMessageRole::User, "upgrade dependencies"),
3352            LlmMessage {
3353                role: LlmMessageRole::Assistant,
3354                content: LlmMessageContent::Text("Let me look.".to_string()),
3355                tool_calls: Some(vec![ToolCall {
3356                    id: "call_1".to_string(),
3357                    name: "read_file".to_string(),
3358                    arguments: json!({"path": "Cargo.toml"}),
3359                }]),
3360                tool_call_id: None,
3361                phase: None,
3362                thinking: None,
3363                thinking_signature: None,
3364            },
3365            LlmMessage {
3366                role: LlmMessageRole::Tool,
3367                content: LlmMessageContent::Text("[package]…".to_string()),
3368                tool_calls: None,
3369                tool_call_id: Some("call_1".to_string()),
3370                phase: None,
3371                thinking: None,
3372                thinking_signature: None,
3373            },
3374        ];
3375
3376        let config = LlmCallConfig {
3377            speed: None,
3378            model: "some/model".to_string(),
3379            temperature: None,
3380            max_tokens: None,
3381            tools: vec![],
3382            reasoning_effort: None,
3383            metadata: std::collections::HashMap::new(),
3384            // Continuation handle from a prior turn — must be ignored on a
3385            // stateless gateway.
3386            previous_response_id: Some("gen-turn-1".to_string()),
3387            tool_search: None,
3388            prompt_cache: None,
3389            openrouter_routing: None,
3390            parallel_tool_calls: None,
3391            volatile_suffix_len: 0,
3392        };
3393
3394        // Fire the request. The stream body is irrelevant for this assertion.
3395        let _ = driver.chat_completion_stream(messages, &config).await;
3396
3397        let requests = server
3398            .received_requests()
3399            .await
3400            .expect("mock server recorded requests");
3401        assert_eq!(requests.len(), 1, "exactly one request should be sent");
3402        let body: serde_json::Value = requests[0].body_json().expect("request body is JSON");
3403
3404        // previous_response_id must be absent (skipped) — the gateway would ignore it.
3405        assert!(
3406            body.get("previous_response_id").is_none(),
3407            "stateless gateway request must omit previous_response_id; body: {body}"
3408        );
3409
3410        // The full transcript must be replayed: user message, assistant message,
3411        // function_call, and function_call_output (instructions carry the system msg).
3412        let input = body["input"].as_array().expect("input is an array");
3413        assert_eq!(
3414            input.len(),
3415            4,
3416            "full transcript must be replayed on a stateless gateway; got {input:?}"
3417        );
3418        assert_eq!(body["instructions"], "You are helpful");
3419        let has_user_task = input
3420            .iter()
3421            .any(|item| item["type"] == "message" && item["role"] == "user");
3422        assert!(
3423            has_user_task,
3424            "the original user task must be replayed; got {input:?}"
3425        );
3426        let has_tool_output = input
3427            .iter()
3428            .any(|item| item["type"] == "function_call_output");
3429        assert!(
3430            has_tool_output,
3431            "the latest tool result must still be present; got {input:?}"
3432        );
3433    }
3434
3435    #[tokio::test]
3436    async fn openrouter_provider_does_not_send_hosted_tool_search() {
3437        use crate::tool_types::DeferrablePolicy;
3438        use serde_json::json;
3439        use wiremock::matchers::method;
3440        use wiremock::{Mock, MockServer, ResponseTemplate};
3441
3442        let server = MockServer::start().await;
3443        Mock::given(method("POST"))
3444            .respond_with(ResponseTemplate::new(200).set_body_string(""))
3445            .mount(&server)
3446            .await;
3447
3448        let api_url = format!("{}/v1/responses", server.uri());
3449        let driver = OpenResponsesProtocolChatDriver::with_base_url("test-key", api_url)
3450            .with_provider_type(DriverId::OpenRouter);
3451
3452        let tools: Vec<ToolDefinition> = (0..16)
3453            .map(|i| {
3454                make_tool(
3455                    &format!("tool_{i}"),
3456                    Some("General"),
3457                    DeferrablePolicy::Automatic,
3458                )
3459            })
3460            .collect();
3461
3462        let config = LlmCallConfig {
3463            speed: None,
3464            model: "gpt-5.4".to_string(),
3465            temperature: None,
3466            max_tokens: None,
3467            tools,
3468            reasoning_effort: None,
3469            metadata: std::collections::HashMap::new(),
3470            previous_response_id: None,
3471            tool_search: Some(crate::driver_registry::ToolSearchConfig {
3472                enabled: true,
3473                threshold: 15,
3474            }),
3475            prompt_cache: None,
3476            openrouter_routing: None,
3477            parallel_tool_calls: None,
3478            volatile_suffix_len: 0,
3479        };
3480
3481        let messages = vec![LlmMessage::text(LlmMessageRole::User, "hello")];
3482        let _ = driver.chat_completion_stream(messages, &config).await;
3483
3484        let requests = server
3485            .received_requests()
3486            .await
3487            .expect("mock server recorded requests");
3488        assert_eq!(requests.len(), 1, "exactly one request should be sent");
3489        let body: serde_json::Value = requests[0].body_json().expect("request body is JSON");
3490        let tools = body["tools"].as_array().expect("tools is an array");
3491
3492        assert!(
3493            tools.iter().all(|tool| tool["type"] == "function"),
3494            "OpenRouter should receive regular function tools, not hosted tool_search payloads: {tools:?}"
3495        );
3496        assert!(
3497            tools.iter().all(|tool| tool.get("defer_loading").is_none()),
3498            "OpenRouter tool schemas should not be deferred by hosted tool_search: {tools:?}"
3499        );
3500        assert_eq!(
3501            body["input"],
3502            json!([{"type": "message", "role": "user", "content": "hello"}])
3503        );
3504    }
3505
3506    #[tokio::test]
3507    async fn openai_provider_omits_openrouter_routing_controls() {
3508        use crate::driver_registry::{OpenRouterRoute, OpenRouterRoutingConfig};
3509        use wiremock::matchers::method;
3510        use wiremock::{Mock, MockServer, ResponseTemplate};
3511
3512        let server = MockServer::start().await;
3513        Mock::given(method("POST"))
3514            .respond_with(ResponseTemplate::new(200).set_body_string(""))
3515            .mount(&server)
3516            .await;
3517
3518        let api_url = format!("{}/v1/responses", server.uri());
3519        let driver = OpenResponsesProtocolChatDriver::with_base_url("test-key", api_url);
3520
3521        let mut metadata = std::collections::HashMap::new();
3522        metadata.insert("session_id".to_string(), "session_abc123".to_string());
3523        let config = LlmCallConfig {
3524            speed: None,
3525            model: "gpt-5-mini".to_string(),
3526            temperature: None,
3527            max_tokens: None,
3528            tools: vec![],
3529            reasoning_effort: None,
3530            metadata,
3531            previous_response_id: None,
3532            tool_search: None,
3533            prompt_cache: None,
3534            openrouter_routing: Some(OpenRouterRoutingConfig {
3535                models: vec!["openai/gpt-5-mini".to_string()],
3536                route: Some(OpenRouterRoute::Fallback),
3537                provider: None,
3538                ..Default::default()
3539            }),
3540            parallel_tool_calls: None,
3541            volatile_suffix_len: 0,
3542        };
3543
3544        let messages = vec![LlmMessage::text(LlmMessageRole::User, "hello")];
3545        let _ = driver.chat_completion_stream(messages, &config).await;
3546
3547        let requests = server
3548            .received_requests()
3549            .await
3550            .expect("mock server recorded requests");
3551        assert_eq!(requests.len(), 1, "exactly one request should be sent");
3552        let body: serde_json::Value = requests[0].body_json().expect("request body is JSON");
3553
3554        assert!(body.get("models").is_none(), "body: {body}");
3555        assert!(body.get("route").is_none(), "body: {body}");
3556        assert!(body.get("provider").is_none(), "body: {body}");
3557        // The top-level session_id is OpenRouter-only; OpenAI must not receive it
3558        // even though the session id rides along in `metadata`.
3559        assert!(body.get("session_id").is_none(), "body: {body}");
3560        assert_eq!(body["metadata"]["session_id"], "session_abc123");
3561    }
3562
3563    /// OpenAI-compatible gateways (e.g. OpenRouter) terminate the Responses SSE
3564    /// stream with a chat-completions-style `[DONE]` sentinel that OpenAI's
3565    /// native API does not send. It must be skipped, not surfaced as a spurious
3566    /// `Error` event after the real completion. (EVE: caught by the OpenRouter
3567    /// live chat smoke test.)
3568    #[tokio::test]
3569    async fn openresponses_stream_skips_done_sentinel() {
3570        use futures::StreamExt;
3571        use wiremock::matchers::method;
3572        use wiremock::{Mock, MockServer, ResponseTemplate};
3573
3574        // A normal text delta followed by the trailing `[DONE]` sentinel.
3575        let body =
3576            "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\ndata: [DONE]\n\n";
3577        let server = MockServer::start().await;
3578        Mock::given(method("POST"))
3579            .respond_with(
3580                ResponseTemplate::new(200)
3581                    .insert_header("content-type", "text/event-stream")
3582                    .set_body_string(body),
3583            )
3584            .mount(&server)
3585            .await;
3586
3587        let api_url = format!("{}/v1/responses", server.uri());
3588        let driver = OpenResponsesProtocolChatDriver::with_base_url("test-key", api_url);
3589        let config = LlmCallConfig {
3590            speed: None,
3591            model: "openai/gpt-4o-mini".to_string(),
3592            temperature: None,
3593            max_tokens: None,
3594            tools: vec![],
3595            reasoning_effort: None,
3596            metadata: std::collections::HashMap::new(),
3597            previous_response_id: None,
3598            tool_search: None,
3599            prompt_cache: None,
3600            openrouter_routing: None,
3601            parallel_tool_calls: None,
3602            volatile_suffix_len: 0,
3603        };
3604
3605        let stream = driver
3606            .chat_completion_stream(vec![LlmMessage::text(LlmMessageRole::User, "hi")], &config)
3607            .await
3608            .expect("stream should start");
3609        let events: Vec<_> = stream.collect().await;
3610
3611        let mut text = String::new();
3612        for ev in &events {
3613            match ev.as_ref().expect("no transport error") {
3614                LlmStreamEvent::TextDelta(d) => text.push_str(d),
3615                LlmStreamEvent::Error(e) => {
3616                    panic!("[DONE] sentinel must not surface as an error: {e}")
3617                }
3618                _ => {}
3619            }
3620        }
3621        assert_eq!(text, "hi");
3622    }
3623
3624    // ========================================================================
3625    // Compact endpoint tests
3626    // ========================================================================
3627
3628    #[test]
3629    fn test_compact_request_serialization() {
3630        let request = CompactRequest {
3631            model: "gpt-4o".to_string(),
3632            input: vec![
3633                CompactInputItem::Message {
3634                    role: "user".to_string(),
3635                    content: CompactContent::Text("Hello!".to_string()),
3636                },
3637                CompactInputItem::Message {
3638                    role: "assistant".to_string(),
3639                    content: CompactContent::Text("Hi there!".to_string()),
3640                },
3641            ],
3642            previous_response_id: None,
3643            instructions: Some("Be helpful".to_string()),
3644        };
3645
3646        let json = serde_json::to_value(&request).unwrap();
3647        assert_eq!(json["model"], "gpt-4o");
3648        assert_eq!(json["instructions"], "Be helpful");
3649        assert!(json["input"].is_array());
3650        assert_eq!(json["input"].as_array().unwrap().len(), 2);
3651    }
3652
3653    #[test]
3654    fn test_compact_input_item_message_serialization() {
3655        let item = CompactInputItem::Message {
3656            role: "user".to_string(),
3657            content: CompactContent::Text("Test message".to_string()),
3658        };
3659
3660        let json = serde_json::to_value(&item).unwrap();
3661        assert_eq!(json["type"], "message");
3662        assert_eq!(json["role"], "user");
3663        assert_eq!(json["content"], "Test message");
3664    }
3665
3666    #[test]
3667    fn test_compact_input_item_function_call_serialization() {
3668        let item = CompactInputItem::FunctionCall {
3669            call_id: "call_123".to_string(),
3670            name: "get_weather".to_string(),
3671            arguments: r#"{"city":"NYC"}"#.to_string(),
3672        };
3673
3674        let json = serde_json::to_value(&item).unwrap();
3675        assert_eq!(json["type"], "function_call");
3676        assert_eq!(json["call_id"], "call_123");
3677        assert_eq!(json["name"], "get_weather");
3678        assert_eq!(json["arguments"], r#"{"city":"NYC"}"#);
3679    }
3680
3681    #[test]
3682    fn test_compact_input_item_compaction_serialization() {
3683        let item = CompactInputItem::Compaction {
3684            encrypted_content: "encrypted_data_here".to_string(),
3685        };
3686
3687        let json = serde_json::to_value(&item).unwrap();
3688        assert_eq!(json["type"], "compaction");
3689        assert_eq!(json["encrypted_content"], "encrypted_data_here");
3690    }
3691
3692    #[test]
3693    fn test_compact_output_item_deserialization() {
3694        let json = r#"{
3695            "type": "message",
3696            "role": "user",
3697            "content": "Hello"
3698        }"#;
3699
3700        let item: CompactOutputItem = serde_json::from_str(json).unwrap();
3701        match item {
3702            CompactOutputItem::Message { role, content } => {
3703                assert_eq!(role, "user");
3704                match content {
3705                    CompactContent::Text(text) => assert_eq!(text, "Hello"),
3706                    _ => panic!("Expected text content"),
3707                }
3708            }
3709            _ => panic!("Expected Message item"),
3710        }
3711    }
3712
3713    #[test]
3714    fn test_compact_output_compaction_deserialization() {
3715        let json = r#"{
3716            "type": "compaction",
3717            "encrypted_content": "abc123encrypted"
3718        }"#;
3719
3720        let item: CompactOutputItem = serde_json::from_str(json).unwrap();
3721        match item {
3722            CompactOutputItem::Compaction { encrypted_content } => {
3723                assert_eq!(encrypted_content, "abc123encrypted");
3724            }
3725            _ => panic!("Expected Compaction item"),
3726        }
3727    }
3728
3729    #[test]
3730    fn test_compact_response_deserialization() {
3731        let json = r#"{
3732            "output": [
3733                {"type": "message", "role": "user", "content": "Hello"},
3734                {"type": "compaction", "encrypted_content": "xyz789"}
3735            ],
3736            "usage": {
3737                "input_tokens": 100,
3738                "output_tokens": 50,
3739                "total_tokens": 150
3740            }
3741        }"#;
3742
3743        let response: CompactResponse = serde_json::from_str(json).unwrap();
3744        assert_eq!(response.output.len(), 2);
3745        assert!(response.usage.is_some());
3746        let usage = response.usage.unwrap();
3747        assert_eq!(usage.input_tokens, Some(100));
3748        assert_eq!(usage.output_tokens, Some(50));
3749        assert_eq!(usage.total_tokens, Some(150));
3750    }
3751
3752    #[test]
3753    fn test_compact_content_parts_serialization() {
3754        let content = CompactContent::Parts(vec![
3755            CompactContentPart::InputText {
3756                text: "Check this image".to_string(),
3757            },
3758            CompactContentPart::InputImage {
3759                image_url: "data:image/png;base64,abc".to_string(),
3760            },
3761        ]);
3762
3763        let json = serde_json::to_value(&content).unwrap();
3764        assert!(json.is_array());
3765        assert_eq!(json[0]["type"], "input_text");
3766        assert_eq!(json[0]["text"], "Check this image");
3767        assert_eq!(json[1]["type"], "input_image");
3768    }
3769
3770    #[test]
3771    fn test_supports_compact_default_url() {
3772        let driver = OpenResponsesProtocolChatDriver::new("test-key");
3773        // Default URL is OpenAI, should support compact
3774        assert!(driver.supports_compact());
3775    }
3776
3777    #[test]
3778    fn test_supports_compact_custom_url() {
3779        let driver = OpenResponsesProtocolChatDriver::with_base_url(
3780            "test-key",
3781            "https://custom.api.com/v1/responses",
3782        );
3783        // Custom URL, compact support unknown
3784        assert!(!driver.supports_compact());
3785    }
3786
3787    // ========================================================================
3788    // OpenAI Thinking/Reasoning Support Tests
3789    // ========================================================================
3790
3791    #[test]
3792    fn test_reasoning_input_item_serialization() {
3793        let item = ResponsesInputItem::Reasoning {
3794            r#type: "reasoning".to_string(),
3795            id: "rs_00000001".to_string(),
3796            encrypted_content: "encrypted_reasoning_context_here".to_string(),
3797        };
3798
3799        let json = serde_json::to_value(&item).unwrap();
3800        assert_eq!(json["type"], "reasoning");
3801        assert_eq!(json["id"], "rs_00000001");
3802        assert_eq!(
3803            json["encrypted_content"],
3804            "encrypted_reasoning_context_here"
3805        );
3806    }
3807
3808    #[test]
3809    fn test_build_input_with_thinking_signature() {
3810        // Assistant message with thinking and thinking_signature (encrypted_content)
3811        let messages = vec![
3812            LlmMessage::text(LlmMessageRole::User, "Think about this deeply"),
3813            LlmMessage {
3814                role: LlmMessageRole::Assistant,
3815                content: LlmMessageContent::Text("I have thought about this.".to_string()),
3816                tool_calls: None,
3817                tool_call_id: None,
3818                phase: None,
3819                thinking: Some("This is my chain of thought reasoning...".to_string()),
3820                thinking_signature: Some("encrypted_reasoning_token_123".to_string()),
3821            },
3822            LlmMessage::text(LlmMessageRole::User, "What else?"),
3823        ];
3824
3825        let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
3826
3827        // Should have: user message, reasoning item, assistant message, user message
3828        assert_eq!(input.len(), 4);
3829
3830        // First is user message
3831        let json = serde_json::to_value(&input[0]).unwrap();
3832        assert_eq!(json["role"], "user");
3833        assert_eq!(json["content"], "Think about this deeply");
3834
3835        // Second is reasoning item (before assistant message)
3836        let json = serde_json::to_value(&input[1]).unwrap();
3837        assert_eq!(json["type"], "reasoning");
3838        assert_eq!(json["encrypted_content"], "encrypted_reasoning_token_123");
3839
3840        // Third is assistant message
3841        let json = serde_json::to_value(&input[2]).unwrap();
3842        assert_eq!(json["role"], "assistant");
3843        assert_eq!(json["content"], "I have thought about this.");
3844
3845        // Fourth is second user message
3846        let json = serde_json::to_value(&input[3]).unwrap();
3847        assert_eq!(json["role"], "user");
3848    }
3849
3850    #[test]
3851    fn test_build_input_with_thinking_signature_and_tool_calls() {
3852        use crate::tool_types::ToolCall;
3853
3854        // Assistant message with thinking, tool calls, and thinking_signature
3855        let messages = vec![
3856            LlmMessage::text(LlmMessageRole::User, "What time is it? Think carefully."),
3857            LlmMessage {
3858                role: LlmMessageRole::Assistant,
3859                content: LlmMessageContent::Text("Let me check.".to_string()),
3860                tool_calls: Some(vec![ToolCall {
3861                    id: "call_123".to_string(),
3862                    name: "get_time".to_string(),
3863                    arguments: json!({}),
3864                }]),
3865                tool_call_id: None,
3866                phase: None,
3867                thinking: Some("I need to call the get_time tool...".to_string()),
3868                thinking_signature: Some("encrypted_token_xyz".to_string()),
3869            },
3870            LlmMessage {
3871                role: LlmMessageRole::Tool,
3872                content: LlmMessageContent::Text("10:30 AM".to_string()),
3873                tool_calls: None,
3874                tool_call_id: Some("call_123".to_string()),
3875                phase: None,
3876                thinking: None,
3877                thinking_signature: None,
3878            },
3879        ];
3880
3881        let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
3882
3883        // Should have: user, reasoning, assistant, function_call, function_call_output
3884        assert_eq!(input.len(), 5);
3885
3886        // Reasoning item comes before assistant message
3887        let json = serde_json::to_value(&input[1]).unwrap();
3888        assert_eq!(json["type"], "reasoning");
3889        assert_eq!(json["encrypted_content"], "encrypted_token_xyz");
3890
3891        // Assistant message
3892        let json = serde_json::to_value(&input[2]).unwrap();
3893        assert_eq!(json["role"], "assistant");
3894
3895        // Function call
3896        let json = serde_json::to_value(&input[3]).unwrap();
3897        assert_eq!(json["type"], "function_call");
3898        assert_eq!(json["call_id"], "call_123");
3899
3900        // Function call output
3901        let json = serde_json::to_value(&input[4]).unwrap();
3902        assert_eq!(json["type"], "function_call_output");
3903    }
3904
3905    #[test]
3906    fn test_build_input_without_thinking_signature() {
3907        // Assistant message with thinking but NO thinking_signature should not emit reasoning item
3908        let messages = vec![
3909            LlmMessage::text(LlmMessageRole::User, "Hello"),
3910            LlmMessage {
3911                role: LlmMessageRole::Assistant,
3912                content: LlmMessageContent::Text("Hi there!".to_string()),
3913                tool_calls: None,
3914                tool_call_id: None,
3915                phase: None,
3916                thinking: Some("Some thinking...".to_string()),
3917                thinking_signature: None, // No signature!
3918            },
3919        ];
3920
3921        let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
3922
3923        // Should have: user message, assistant message (no reasoning item)
3924        assert_eq!(input.len(), 2);
3925
3926        // Verify no reasoning item
3927        let json = serde_json::to_value(&input[0]).unwrap();
3928        assert_eq!(json["role"], "user");
3929
3930        let json = serde_json::to_value(&input[1]).unwrap();
3931        assert_eq!(json["role"], "assistant");
3932    }
3933
3934    #[test]
3935    fn test_handle_streaming_event_reasoning_encrypted_content() {
3936        use std::sync::Mutex;
3937
3938        let input_tokens = Mutex::new(0u32);
3939        let output_tokens = Mutex::new(0u32);
3940        let cache_read_tokens = Mutex::new(None);
3941        let accumulated_tool_calls = Mutex::new(Vec::new());
3942        let finish_reason = Mutex::new(None);
3943
3944        // Create an OutputItemDone event with Reasoning item containing encrypted_content
3945        let event = StreamingEvent::OutputItemDone {
3946            sequence_number: 5,
3947            output_index: 0,
3948            item: Some(types::OutputItem::Reasoning {
3949                id: "rs_001".to_string(),
3950                summary: vec![],
3951                content: None,
3952                encrypted_content: Some("encrypted_reasoning_data".to_string()),
3953            }),
3954        };
3955
3956        let result = handle_streaming_event(
3957            event,
3958            &input_tokens,
3959            &output_tokens,
3960            &cache_read_tokens,
3961            &accumulated_tool_calls,
3962            &finish_reason,
3963            "gpt-5".to_string(),
3964            None,
3965        );
3966
3967        // Should emit ReasonItem with the encrypted content and metadata
3968        match result {
3969            LlmStreamEvent::ReasonItem {
3970                provider,
3971                model,
3972                item_id,
3973                encrypted_content,
3974                summary,
3975                token_count,
3976            } => {
3977                assert_eq!(provider, "openai");
3978                assert_eq!(model.as_deref(), Some("gpt-5"));
3979                assert_eq!(item_id, "rs_001");
3980                assert_eq!(
3981                    encrypted_content.as_deref(),
3982                    Some("encrypted_reasoning_data")
3983                );
3984                assert!(summary.is_empty());
3985                assert!(token_count.is_none());
3986            }
3987            other => panic!("Expected ReasonItem event, got {:?}", other),
3988        }
3989    }
3990
3991    #[test]
3992    fn response_failed_preserves_provider_error_code() {
3993        use std::sync::Mutex;
3994
3995        let event: StreamingEvent = serde_json::from_value(serde_json::json!({
3996            "type": "response.failed",
3997            "sequence_number": 7,
3998            "response": {
3999                "id": "resp_failed",
4000                "object": "response",
4001                "created_at": 1,
4002                "status": "failed",
4003                "model": "gpt-5",
4004                "output": [],
4005                "tools": [],
4006                "error": {
4007                    "code": "processing_error",
4008                    "message": "An error occurred while processing your request."
4009                }
4010            }
4011        }))
4012        .expect("response.failed should deserialize");
4013
4014        let result = handle_streaming_event(
4015            event,
4016            &Mutex::new(0),
4017            &Mutex::new(0),
4018            &Mutex::new(None),
4019            &Mutex::new(Vec::new()),
4020            &Mutex::new(None),
4021            "gpt-5".to_string(),
4022            None,
4023        );
4024
4025        let LlmStreamEvent::Error(error) = result else {
4026            panic!("expected structured stream error");
4027        };
4028        assert_eq!(error.code.as_deref(), Some("processing_error"));
4029        assert!(crate::llm_retry::is_transient_stream_error(&error));
4030    }
4031
4032    #[test]
4033    fn test_handle_streaming_event_reasoning_without_encrypted_content() {
4034        use std::sync::Mutex;
4035
4036        let input_tokens = Mutex::new(0u32);
4037        let output_tokens = Mutex::new(0u32);
4038        let cache_read_tokens = Mutex::new(None);
4039        let accumulated_tool_calls = Mutex::new(Vec::new());
4040        let finish_reason = Mutex::new(None);
4041
4042        // Create an OutputItemDone event with Reasoning item but NO encrypted_content
4043        let event = StreamingEvent::OutputItemDone {
4044            sequence_number: 5,
4045            output_index: 0,
4046            item: Some(types::OutputItem::Reasoning {
4047                id: "rs_001".to_string(),
4048                summary: vec![types::ContentPart::SummaryText {
4049                    text: "Some summary".to_string(),
4050                }],
4051                content: None,
4052                encrypted_content: None, // No encrypted content
4053            }),
4054        };
4055
4056        let result = handle_streaming_event(
4057            event,
4058            &input_tokens,
4059            &output_tokens,
4060            &cache_read_tokens,
4061            &accumulated_tool_calls,
4062            &finish_reason,
4063            "gpt-5".to_string(),
4064            None,
4065        );
4066
4067        // Should still emit ReasonItem carrying the safe summary even when no
4068        // encrypted content is present so the durable reasoning record survives.
4069        match result {
4070            LlmStreamEvent::ReasonItem {
4071                provider,
4072                item_id,
4073                encrypted_content,
4074                summary,
4075                ..
4076            } => {
4077                assert_eq!(provider, "openai");
4078                assert_eq!(item_id, "rs_001");
4079                assert!(encrypted_content.is_none());
4080                assert_eq!(summary, vec!["Some summary".to_string()]);
4081            }
4082            other => panic!("Expected ReasonItem event, got {:?}", other),
4083        }
4084    }
4085
4086    #[test]
4087    fn test_handle_streaming_event_reasoning_drops_plaintext_content() {
4088        use std::sync::Mutex;
4089
4090        let input_tokens = Mutex::new(0u32);
4091        let output_tokens = Mutex::new(0u32);
4092        let cache_read_tokens = Mutex::new(None);
4093        let accumulated_tool_calls = Mutex::new(Vec::new());
4094        let finish_reason = Mutex::new(None);
4095
4096        // Reasoning item with plaintext content and a non-summary content part in `summary`.
4097        // Both must be excluded from the emitted ReasonItem.
4098        let event = StreamingEvent::OutputItemDone {
4099            sequence_number: 5,
4100            output_index: 0,
4101            item: Some(types::OutputItem::Reasoning {
4102                id: "rs_002".to_string(),
4103                summary: vec![
4104                    types::ContentPart::SummaryText {
4105                        text: "safe summary".to_string(),
4106                    },
4107                    types::ContentPart::ReasoningText {
4108                        text: "SECRET hidden reasoning".to_string(),
4109                    },
4110                ],
4111                content: Some(vec![types::ContentPart::ReasoningText {
4112                    text: "SECRET hidden reasoning".to_string(),
4113                }]),
4114                encrypted_content: Some("opaque".to_string()),
4115            }),
4116        };
4117
4118        let result = handle_streaming_event(
4119            event,
4120            &input_tokens,
4121            &output_tokens,
4122            &cache_read_tokens,
4123            &accumulated_tool_calls,
4124            &finish_reason,
4125            "gpt-5".to_string(),
4126            None,
4127        );
4128
4129        match result {
4130            LlmStreamEvent::ReasonItem {
4131                summary,
4132                encrypted_content,
4133                ..
4134            } => {
4135                assert_eq!(summary, vec!["safe summary".to_string()]);
4136                assert_eq!(encrypted_content.as_deref(), Some("opaque"));
4137            }
4138            other => panic!("Expected ReasonItem event, got {:?}", other),
4139        }
4140    }
4141
4142    #[test]
4143    fn test_handle_streaming_event_reasoning_delta() {
4144        use std::sync::Mutex;
4145
4146        let input_tokens = Mutex::new(0u32);
4147        let output_tokens = Mutex::new(0u32);
4148        let cache_read_tokens = Mutex::new(None);
4149        let accumulated_tool_calls = Mutex::new(Vec::new());
4150        let finish_reason = Mutex::new(None);
4151
4152        // ReasoningDelta (opaque reasoning from o-series) maps to ThinkingDelta
4153        let event = StreamingEvent::ReasoningDelta {
4154            sequence_number: 3,
4155            item_id: "rs_001".to_string(),
4156            output_index: 0,
4157            content_index: 0,
4158            delta: "Let me reason about this...".to_string(),
4159            obfuscation: None,
4160        };
4161
4162        let result = handle_streaming_event(
4163            event,
4164            &input_tokens,
4165            &output_tokens,
4166            &cache_read_tokens,
4167            &accumulated_tool_calls,
4168            &finish_reason,
4169            "o3".to_string(),
4170            None,
4171        );
4172
4173        match result {
4174            LlmStreamEvent::ThinkingDelta(text) => {
4175                assert_eq!(text, "Let me reason about this...");
4176            }
4177            _ => panic!("Expected ThinkingDelta, got {:?}", result),
4178        }
4179    }
4180
4181    #[test]
4182    fn test_handle_streaming_event_reasoning_summary_delta() {
4183        use std::sync::Mutex;
4184
4185        let input_tokens = Mutex::new(0u32);
4186        let output_tokens = Mutex::new(0u32);
4187        let cache_read_tokens = Mutex::new(None);
4188        let accumulated_tool_calls = Mutex::new(Vec::new());
4189        let finish_reason = Mutex::new(None);
4190
4191        // ReasoningSummaryDelta (readable summary from GPT-5.x) maps to public TextDelta
4192        let event = StreamingEvent::ReasoningSummaryDelta {
4193            sequence_number: 4,
4194            item_id: "rs_002".to_string(),
4195            output_index: 0,
4196            summary_index: 0,
4197            delta: "Breaking down the problem...".to_string(),
4198            obfuscation: None,
4199        };
4200
4201        let result = handle_streaming_event(
4202            event,
4203            &input_tokens,
4204            &output_tokens,
4205            &cache_read_tokens,
4206            &accumulated_tool_calls,
4207            &finish_reason,
4208            "gpt-5.2".to_string(),
4209            None,
4210        );
4211
4212        match result {
4213            LlmStreamEvent::TextDelta(text) => {
4214                assert_eq!(text, "Breaking down the problem...");
4215            }
4216            _ => panic!("Expected TextDelta, got {:?}", result),
4217        }
4218    }
4219
4220    #[test]
4221    fn test_request_reasoning_none_is_omitted() {
4222        // When reasoning effort is "none", the reasoning field should be omitted
4223        // to avoid API errors on models that don't support reasoning params
4224        let config = LlmCallConfig {
4225            speed: None,
4226            model: "gpt-5.2".to_string(),
4227            temperature: None,
4228            max_tokens: None,
4229            tools: vec![],
4230            reasoning_effort: Some("none".to_string()),
4231            metadata: std::collections::HashMap::new(),
4232            previous_response_id: None,
4233            tool_search: None,
4234            prompt_cache: None,
4235            openrouter_routing: None,
4236            parallel_tool_calls: None,
4237            volatile_suffix_len: 0,
4238        };
4239
4240        // Simulate the driver's filter logic
4241        let reasoning = config
4242            .reasoning_effort
4243            .as_ref()
4244            .filter(|e| !e.eq_ignore_ascii_case("none"))
4245            .map(|effort| ResponsesReasoning {
4246                effort: effort.clone(),
4247                summary: "detailed".to_string(),
4248            });
4249
4250        assert!(
4251            reasoning.is_none(),
4252            "reasoning should be None for effort=none"
4253        );
4254    }
4255
4256    #[test]
4257    fn test_request_reasoning_high_is_included() {
4258        // When reasoning effort is "high", the reasoning field should be present
4259        let config = LlmCallConfig {
4260            speed: None,
4261            model: "gpt-5.2".to_string(),
4262            temperature: None,
4263            max_tokens: None,
4264            tools: vec![],
4265            reasoning_effort: Some("high".to_string()),
4266            metadata: std::collections::HashMap::new(),
4267            previous_response_id: None,
4268            tool_search: None,
4269            prompt_cache: None,
4270            openrouter_routing: None,
4271            parallel_tool_calls: None,
4272            volatile_suffix_len: 0,
4273        };
4274
4275        let reasoning = config
4276            .reasoning_effort
4277            .as_ref()
4278            .filter(|e| !e.eq_ignore_ascii_case("none"))
4279            .map(|effort| ResponsesReasoning {
4280                effort: effort.clone(),
4281                summary: "detailed".to_string(),
4282            });
4283
4284        assert!(
4285            reasoning.is_some(),
4286            "reasoning should be present for effort=high"
4287        );
4288        let r = reasoning.unwrap();
4289        assert_eq!(r.effort, "high");
4290        assert_eq!(r.summary, "detailed");
4291    }
4292
4293    #[test]
4294    fn test_request_reasoning_none_case_insensitive() {
4295        // "None", "NONE", "none" should all be filtered out
4296        for effort in &["none", "None", "NONE"] {
4297            let reasoning = Some(effort.to_string())
4298                .as_ref()
4299                .filter(|e| !e.eq_ignore_ascii_case("none"))
4300                .cloned();
4301
4302            assert!(
4303                reasoning.is_none(),
4304                "effort={effort:?} should be filtered out"
4305            );
4306        }
4307    }
4308
4309    #[test]
4310    fn test_build_input_assistant_without_thinking_or_tools() {
4311        // Plain assistant message (no thinking, no tool calls) should just be a message
4312        let messages = vec![
4313            LlmMessage::text(LlmMessageRole::User, "Hello"),
4314            LlmMessage {
4315                role: LlmMessageRole::Assistant,
4316                content: LlmMessageContent::Text("Hi there!".to_string()),
4317                tool_calls: None,
4318                tool_call_id: None,
4319                phase: None,
4320                thinking: None,
4321                thinking_signature: None,
4322            },
4323        ];
4324
4325        let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
4326
4327        assert_eq!(input.len(), 2);
4328        let json = serde_json::to_value(&input[1]).unwrap();
4329        assert_eq!(json["role"], "assistant");
4330        assert!(json.get("type").is_none() || json["type"] == "message");
4331    }
4332
4333    #[test]
4334    fn test_build_input_multiple_reasoning_items_get_unique_ids() {
4335        // Multiple assistant messages with thinking_signature should get unique reasoning IDs
4336        let messages = vec![
4337            LlmMessage::text(LlmMessageRole::User, "First question"),
4338            LlmMessage {
4339                role: LlmMessageRole::Assistant,
4340                content: LlmMessageContent::Text("First answer.".to_string()),
4341                tool_calls: None,
4342                tool_call_id: None,
4343                phase: None,
4344                thinking: Some("thinking 1".to_string()),
4345                thinking_signature: Some("encrypted_1".to_string()),
4346            },
4347            LlmMessage::text(LlmMessageRole::User, "Second question"),
4348            LlmMessage {
4349                role: LlmMessageRole::Assistant,
4350                content: LlmMessageContent::Text("Second answer.".to_string()),
4351                tool_calls: None,
4352                tool_call_id: None,
4353                phase: None,
4354                thinking: Some("thinking 2".to_string()),
4355                thinking_signature: Some("encrypted_2".to_string()),
4356            },
4357        ];
4358
4359        let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
4360
4361        // Should have: user, reasoning_1, assistant, user, reasoning_2, assistant
4362        assert_eq!(input.len(), 6);
4363
4364        let r1 = serde_json::to_value(&input[1]).unwrap();
4365        let r2 = serde_json::to_value(&input[4]).unwrap();
4366
4367        assert_eq!(r1["type"], "reasoning");
4368        assert_eq!(r2["type"], "reasoning");
4369        assert_ne!(r1["id"], r2["id"], "Reasoning items should have unique IDs");
4370        assert_eq!(r1["encrypted_content"], "encrypted_1");
4371        assert_eq!(r2["encrypted_content"], "encrypted_2");
4372    }
4373
4374    #[test]
4375    fn test_build_input_with_phases_enabled() {
4376        use crate::message::ExecutionPhase;
4377
4378        let messages = vec![
4379            LlmMessage::text(LlmMessageRole::System, "You are helpful"),
4380            LlmMessage::text(LlmMessageRole::User, "Hello"),
4381            LlmMessage {
4382                role: LlmMessageRole::Assistant,
4383                content: LlmMessageContent::Text("Working on it...".to_string()),
4384                tool_calls: Some(vec![crate::tool_types::ToolCall {
4385                    id: "call_1".to_string(),
4386                    name: "search".to_string(),
4387                    arguments: json!({}),
4388                }]),
4389                tool_call_id: None,
4390                phase: Some(ExecutionPhase::Commentary),
4391                thinking: None,
4392                thinking_signature: None,
4393            },
4394            LlmMessage {
4395                role: LlmMessageRole::Tool,
4396                content: LlmMessageContent::Text("result".to_string()),
4397                tool_calls: None,
4398                tool_call_id: Some("call_1".to_string()),
4399                phase: None,
4400                thinking: None,
4401                thinking_signature: None,
4402            },
4403        ];
4404
4405        // With supports_phases=true, assistant message should include phase
4406        let (_, input) = OpenResponsesProtocolChatDriver::build_input(&messages, true);
4407        let assistant_json = serde_json::to_value(&input[1]).unwrap();
4408        assert_eq!(assistant_json["phase"], "commentary");
4409
4410        // With supports_phases=false, phase should be absent
4411        let (_, input_no_phases) = OpenResponsesProtocolChatDriver::build_input(&messages, false);
4412        let assistant_json_no = serde_json::to_value(&input_no_phases[1]).unwrap();
4413        assert!(assistant_json_no.get("phase").is_none() || assistant_json_no["phase"].is_null());
4414    }
4415
4416    // ========================================================================
4417    // tool_search / convert_tools_with_search tests
4418    // ========================================================================
4419
4420    /// Helper: create a ToolDefinition with optional category and deferrable policy
4421    fn make_tool(
4422        name: &str,
4423        category: Option<&str>,
4424        deferrable: crate::tool_types::DeferrablePolicy,
4425    ) -> ToolDefinition {
4426        ToolDefinition::Builtin(crate::tool_types::BuiltinTool {
4427            name: name.to_string(),
4428            display_name: None,
4429            description: format!("{} description", name),
4430            parameters: json!({"type": "object", "properties": {}}),
4431            policy: crate::tool_types::ToolPolicy::Auto,
4432            category: category.map(|s| s.to_string()),
4433            deferrable,
4434            hints: crate::tool_types::ToolHints::default(),
4435            full_parameters: None,
4436        })
4437    }
4438
4439    #[test]
4440    fn test_convert_tools_with_search_below_threshold_falls_back() {
4441        use crate::tool_types::DeferrablePolicy;
4442
4443        let tools: Vec<ToolDefinition> = (0..5)
4444            .map(|i| {
4445                make_tool(
4446                    &format!("tool_{i}"),
4447                    Some("cat"),
4448                    DeferrablePolicy::Automatic,
4449                )
4450            })
4451            .collect();
4452
4453        // threshold=15, only 5 tools → should fall back to standard convert_tools
4454        let result = OpenResponsesProtocolChatDriver::convert_tools_with_search(&tools, 15);
4455        assert_eq!(result.len(), 5);
4456        // No ToolSearch entry, no namespaces
4457        let json = serde_json::to_value(&result).unwrap();
4458        for item in json.as_array().unwrap() {
4459            assert_eq!(item["type"], "function");
4460            assert!(item.get("defer_loading").is_none() || item["defer_loading"].is_null());
4461        }
4462    }
4463
4464    #[test]
4465    fn test_convert_tools_with_search_groups_by_category() {
4466        use crate::tool_types::DeferrablePolicy;
4467
4468        let mut tools = vec![];
4469        // 10 "FileSystem" tools + 6 "Weather" tools = 16, threshold=15
4470        for i in 0..10 {
4471            tools.push(make_tool(
4472                &format!("fs_tool_{i}"),
4473                Some("FileSystem"),
4474                DeferrablePolicy::Automatic,
4475            ));
4476        }
4477        for i in 0..6 {
4478            tools.push(make_tool(
4479                &format!("weather_tool_{i}"),
4480                Some("Weather"),
4481                DeferrablePolicy::Automatic,
4482            ));
4483        }
4484
4485        let result = OpenResponsesProtocolChatDriver::convert_tools_with_search(&tools, 15);
4486        let json = serde_json::to_value(&result).unwrap();
4487        let arr = json.as_array().unwrap();
4488
4489        // Should have: 2 namespace entries + 1 tool_search entry = 3
4490        assert_eq!(arr.len(), 3);
4491
4492        // Last entry should be tool_search
4493        assert_eq!(arr.last().unwrap()["type"], "tool_search");
4494
4495        // The two namespace entries
4496        let ns: Vec<&Value> = arr.iter().filter(|v| v["type"] == "namespace").collect();
4497        assert_eq!(ns.len(), 2);
4498
4499        let ns_names: Vec<&str> = ns.iter().map(|v| v["name"].as_str().unwrap()).collect();
4500        assert!(ns_names.contains(&"FileSystem"));
4501        assert!(ns_names.contains(&"Weather"));
4502
4503        // Check tool counts inside namespaces
4504        for n in &ns {
4505            let inner_tools = n["tools"].as_array().unwrap();
4506            match n["name"].as_str().unwrap() {
4507                "FileSystem" => assert_eq!(inner_tools.len(), 10),
4508                "Weather" => assert_eq!(inner_tools.len(), 6),
4509                other => panic!("Unexpected namespace: {other}"),
4510            }
4511            // All inner tools should have defer_loading: true
4512            for t in inner_tools {
4513                assert_eq!(t["defer_loading"], true);
4514            }
4515        }
4516    }
4517
4518    #[test]
4519    fn test_convert_tools_with_search_never_defer_stays_top_level() {
4520        use crate::tool_types::DeferrablePolicy;
4521
4522        let mut tools = vec![];
4523        // 2 Never-defer tools
4524        tools.push(make_tool(
4525            "write_todos",
4526            Some("Productivity"),
4527            DeferrablePolicy::Never,
4528        ));
4529        tools.push(make_tool(
4530            "get_session_info",
4531            Some("Session"),
4532            DeferrablePolicy::Never,
4533        ));
4534        // 14 Automatic tools in "FileSystem" category
4535        for i in 0..14 {
4536            tools.push(make_tool(
4537                &format!("fs_tool_{i}"),
4538                Some("FileSystem"),
4539                DeferrablePolicy::Automatic,
4540            ));
4541        }
4542
4543        let result = OpenResponsesProtocolChatDriver::convert_tools_with_search(&tools, 15);
4544        let json = serde_json::to_value(&result).unwrap();
4545        let arr = json.as_array().unwrap();
4546
4547        // 2 never-defer functions + 1 FileSystem namespace + 1 tool_search = 4
4548        assert_eq!(arr.len(), 4);
4549
4550        // First two should be non-deferred functions
4551        let funcs: Vec<&Value> = arr.iter().filter(|v| v["type"] == "function").collect();
4552        assert_eq!(funcs.len(), 2);
4553        for f in &funcs {
4554            // No defer_loading on never-defer tools
4555            assert!(f.get("defer_loading").is_none() || f["defer_loading"].is_null());
4556        }
4557
4558        // Namespace
4559        let ns: Vec<&Value> = arr.iter().filter(|v| v["type"] == "namespace").collect();
4560        assert_eq!(ns.len(), 1);
4561        assert_eq!(ns[0]["name"], "FileSystem");
4562        assert_eq!(ns[0]["tools"].as_array().unwrap().len(), 14);
4563    }
4564
4565    #[test]
4566    fn test_convert_tools_with_search_ungrouped_tools() {
4567        use crate::tool_types::DeferrablePolicy;
4568
4569        let mut tools = vec![];
4570        // 10 categorized tools
4571        for i in 0..10 {
4572            tools.push(make_tool(
4573                &format!("cat_tool_{i}"),
4574                Some("Cat"),
4575                DeferrablePolicy::Automatic,
4576            ));
4577        }
4578        // 6 uncategorized tools (no category → ungrouped)
4579        for i in 0..6 {
4580            tools.push(make_tool(
4581                &format!("misc_tool_{i}"),
4582                None,
4583                DeferrablePolicy::Automatic,
4584            ));
4585        }
4586
4587        let result = OpenResponsesProtocolChatDriver::convert_tools_with_search(&tools, 15);
4588        let json = serde_json::to_value(&result).unwrap();
4589        let arr = json.as_array().unwrap();
4590
4591        // 1 namespace + 6 ungrouped functions + 1 tool_search = 8
4592        assert_eq!(arr.len(), 8);
4593
4594        let ns: Vec<&Value> = arr.iter().filter(|v| v["type"] == "namespace").collect();
4595        assert_eq!(ns.len(), 1);
4596        assert_eq!(ns[0]["tools"].as_array().unwrap().len(), 10);
4597
4598        let funcs: Vec<&Value> = arr.iter().filter(|v| v["type"] == "function").collect();
4599        assert_eq!(funcs.len(), 6);
4600        // These ungrouped tools should still have defer_loading: true
4601        for f in &funcs {
4602            assert_eq!(f["defer_loading"], true);
4603        }
4604
4605        assert_eq!(arr.last().unwrap()["type"], "tool_search");
4606    }
4607
4608    #[test]
4609    fn test_convert_tools_with_search_always_policy() {
4610        use crate::tool_types::DeferrablePolicy;
4611
4612        let mut tools = vec![];
4613        // 14 Automatic tools
4614        for i in 0..14 {
4615            tools.push(make_tool(
4616                &format!("tool_{i}"),
4617                Some("General"),
4618                DeferrablePolicy::Automatic,
4619            ));
4620        }
4621        // 1 Always tool (should be deferred even if only at threshold)
4622        tools.push(make_tool(
4623            "always_tool",
4624            Some("General"),
4625            DeferrablePolicy::Always,
4626        ));
4627
4628        // Exactly at threshold (15 tools, threshold=15)
4629        let result = OpenResponsesProtocolChatDriver::convert_tools_with_search(&tools, 15);
4630        let json = serde_json::to_value(&result).unwrap();
4631        let arr = json.as_array().unwrap();
4632
4633        // 1 namespace (General) + 1 tool_search = 2
4634        assert_eq!(arr.len(), 2);
4635
4636        let ns = &arr[0];
4637        assert_eq!(ns["type"], "namespace");
4638        let inner = ns["tools"].as_array().unwrap();
4639        assert_eq!(inner.len(), 15);
4640        // All should have defer_loading: true
4641        for t in inner {
4642            assert_eq!(t["defer_loading"], true);
4643        }
4644    }
4645
4646    #[test]
4647    fn test_tool_search_serialization_format() {
4648        // Verify the ToolSearch entry serializes correctly
4649        let ts = ResponsesTool::ToolSearch {
4650            r#type: "tool_search".to_string(),
4651        };
4652        let json = serde_json::to_value(&ts).unwrap();
4653        assert_eq!(json, json!({"type": "tool_search"}));
4654    }
4655
4656    #[test]
4657    fn test_namespace_serialization_format() {
4658        let ns = ResponsesTool::Namespace {
4659            r#type: "namespace".to_string(),
4660            name: "FileSystem".to_string(),
4661            description: "Tools for FileSystem".to_string(),
4662            tools: vec![ResponsesTool::Function {
4663                r#type: "function".to_string(),
4664                name: "read_file".to_string(),
4665                description: "Read a file".to_string(),
4666                parameters: json!({}),
4667                defer_loading: Some(true),
4668            }],
4669        };
4670        let json = serde_json::to_value(&ns).unwrap();
4671        assert_eq!(json["type"], "namespace");
4672        assert_eq!(json["name"], "FileSystem");
4673        assert_eq!(json["tools"][0]["name"], "read_file");
4674        assert_eq!(json["tools"][0]["defer_loading"], true);
4675    }
4676
4677    #[test]
4678    fn test_hosted_tool_search_completed_event_preserves_response_id() {
4679        let event_json = r#"{
4680            "type": "response.completed",
4681            "sequence_number": 8,
4682            "response": {
4683                "id": "resp_tool_search",
4684                "object": "response",
4685                "created_at": 1780000000,
4686                "status": "completed",
4687                "model": "gpt-5.5",
4688                "output": [
4689                    {
4690                        "type": "tool_search_call",
4691                        "execution": "server",
4692                        "call_id": null,
4693                        "status": "completed",
4694                        "arguments": { "paths": ["Math"] }
4695                    },
4696                    {
4697                        "type": "tool_search_output",
4698                        "execution": "server",
4699                        "call_id": null,
4700                        "status": "completed",
4701                        "tools": [
4702                            {
4703                                "type": "namespace",
4704                                "name": "Math",
4705                                "description": "Tools for Math",
4706                                "tools": [
4707                                    {
4708                                        "type": "function",
4709                                        "name": "add",
4710                                        "description": "Add numbers.",
4711                                        "defer_loading": true,
4712                                        "parameters": {
4713                                            "type": "object",
4714                                            "properties": {
4715                                                "a": { "type": "number" },
4716                                                "b": { "type": "number" }
4717                                            },
4718                                            "required": ["a", "b"],
4719                                            "additionalProperties": false
4720                                        }
4721                                    }
4722                                ]
4723                            }
4724                        ]
4725                    },
4726                    {
4727                        "type": "function_call",
4728                        "id": "fc_123",
4729                        "call_id": "call_123",
4730                        "name": "add",
4731                        "namespace": "Math",
4732                        "arguments": "{\"a\":7,\"b\":3}",
4733                        "status": "completed"
4734                    }
4735                ],
4736                "usage": {
4737                    "input_tokens": 10,
4738                    "output_tokens": 5,
4739                    "total_tokens": 15
4740                }
4741            }
4742        }"#;
4743
4744        let event: StreamingEvent = serde_json::from_str(event_json).unwrap();
4745        let stream_event = handle_streaming_event(
4746            event,
4747            &Mutex::new(0),
4748            &Mutex::new(0),
4749            &Mutex::new(None),
4750            &Mutex::new(Vec::new()),
4751            &Mutex::new(Some("tool_calls".to_string())),
4752            "gpt-5.5".to_string(),
4753            None,
4754        );
4755
4756        match stream_event {
4757            LlmStreamEvent::Done(metadata) => {
4758                assert_eq!(metadata.response_id.as_deref(), Some("resp_tool_search"));
4759                assert_eq!(metadata.finish_reason.as_deref(), Some("tool_calls"));
4760            }
4761            other => panic!("expected Done event, got {other:?}"),
4762        }
4763    }
4764
4765    #[test]
4766    fn test_completed_event_normalizes_cache_inclusive_prompt_tokens() {
4767        // OpenAI reports `input_tokens` inclusive of cached reads. The driver
4768        // must normalize to the disjoint convention: prompt_tokens carries only
4769        // the non-cached remainder (input − cached), with cache reported on top.
4770        let event_json = r#"{
4771            "type": "response.completed",
4772            "sequence_number": 9,
4773            "response": {
4774                "id": "resp_cache",
4775                "object": "response",
4776                "created_at": 1780000000,
4777                "status": "completed",
4778                "model": "gpt-5.5",
4779                "output": [],
4780                "usage": {
4781                    "input_tokens": 1000,
4782                    "output_tokens": 20,
4783                    "total_tokens": 1020,
4784                    "input_tokens_details": { "cached_tokens": 800 }
4785                }
4786            }
4787        }"#;
4788
4789        let event: StreamingEvent = serde_json::from_str(event_json).unwrap();
4790        let stream_event = handle_streaming_event(
4791            event,
4792            &Mutex::new(0),
4793            &Mutex::new(0),
4794            &Mutex::new(None),
4795            &Mutex::new(Vec::new()),
4796            &Mutex::new(None),
4797            "gpt-5.5".to_string(),
4798            None,
4799        );
4800
4801        match stream_event {
4802            LlmStreamEvent::Done(metadata) => {
4803                // 1000 reported − 800 cached = 200 non-cached input.
4804                assert_eq!(metadata.prompt_tokens, Some(200));
4805                assert_eq!(metadata.cache_read_tokens, Some(800));
4806                // total_tokens stays the true prompt+output total (1000 + 20).
4807                assert_eq!(metadata.total_tokens, Some(1020));
4808            }
4809            other => panic!("expected Done event, got {other:?}"),
4810        }
4811    }
4812
4813    #[test]
4814    fn test_sanitize_parameters_adds_missing_properties() {
4815        let params = json!({"type": "object", "additionalProperties": false});
4816        let sanitized = OpenResponsesProtocolChatDriver::sanitize_parameters(&params);
4817        assert_eq!(
4818            sanitized,
4819            json!({"type": "object", "properties": {}, "additionalProperties": false})
4820        );
4821    }
4822
4823    #[test]
4824    fn test_sanitize_parameters_preserves_existing_properties() {
4825        let params = json!({"type": "object", "properties": {"x": {"type": "string"}}, "additionalProperties": false});
4826        let sanitized = OpenResponsesProtocolChatDriver::sanitize_parameters(&params);
4827        assert_eq!(sanitized, params);
4828    }
4829
4830    #[test]
4831    fn test_sanitize_parameters_ignores_non_object_types() {
4832        let params = json!({"type": "string"});
4833        let sanitized = OpenResponsesProtocolChatDriver::sanitize_parameters(&params);
4834        assert_eq!(sanitized, params);
4835    }
4836
4837    // ========================================================================
4838    // Pluggable request auth (EVE-618)
4839    // ========================================================================
4840
4841    /// Minimal `LlmCallConfig` for wire tests.
4842    fn auth_test_config() -> LlmCallConfig {
4843        LlmCallConfig {
4844            speed: None,
4845            model: "gpt-5.4".to_string(),
4846            temperature: None,
4847            max_tokens: None,
4848            tools: vec![],
4849            reasoning_effort: None,
4850            metadata: std::collections::HashMap::new(),
4851            previous_response_id: None,
4852            tool_search: None,
4853            prompt_cache: None,
4854            openrouter_routing: None,
4855            parallel_tool_calls: None,
4856            volatile_suffix_len: 0,
4857        }
4858    }
4859
4860    /// Static auth provider that records how many times it was awaited, so tests
4861    /// can assert per-attempt resolution (refreshable providers).
4862    struct CountingAuth {
4863        header: (String, String),
4864        calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
4865    }
4866
4867    #[async_trait::async_trait]
4868    impl AuthHeaderProvider for CountingAuth {
4869        async fn auth_header(&self) -> Result<(String, String)> {
4870            self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4871            Ok(self.header.clone())
4872        }
4873    }
4874
4875    /// Extension that injects a non-auth header and (deliberately) a conflicting
4876    /// `Authorization` header, to prove the auth seam wins on conflict.
4877    struct HeaderInjectingExtension;
4878
4879    impl OpenResponsesRequestExtension for HeaderInjectingExtension {
4880        fn decorate(&self, _body: &mut Value, _config: &LlmCallConfig) -> Result<()> {
4881            Ok(())
4882        }
4883
4884        fn decorate_headers(&self, headers: &mut HeaderMap, _config: &LlmCallConfig) -> Result<()> {
4885            headers.insert("x-openrouter-route", HeaderValue::from_static("fallback"));
4886            // Decoration must never override auth — the driver applies auth last.
4887            headers.insert(
4888                "authorization",
4889                HeaderValue::from_static("Bearer decoration"),
4890            );
4891            Ok(())
4892        }
4893    }
4894
4895    #[tokio::test]
4896    async fn resolve_auth_header_defaults_to_bearer_on_non_azure() {
4897        let driver = OpenResponsesProtocolChatDriver::new("secret-key");
4898        let (name, value) = driver
4899            .resolve_auth_header("https://api.openai.com/v1/responses")
4900            .await
4901            .expect("auth resolves");
4902        assert_eq!(name.as_str(), "authorization");
4903        assert_eq!(value.to_str().unwrap(), "Bearer secret-key");
4904    }
4905
4906    #[tokio::test]
4907    async fn resolve_auth_header_uses_api_key_header_on_azure() {
4908        let driver = OpenResponsesProtocolChatDriver::new("secret-key");
4909        let (name, value) = driver
4910            .resolve_auth_header("https://my-resource.openai.azure.com/openai/v1/responses")
4911            .await
4912            .expect("auth resolves");
4913        assert_eq!(name.as_str(), "api-key");
4914        assert_eq!(value.to_str().unwrap(), "secret-key");
4915    }
4916
4917    #[tokio::test]
4918    async fn resolve_auth_header_prefers_provider_over_static_key() {
4919        let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
4920        let driver = OpenResponsesProtocolChatDriver::new("ignored-key").with_auth_provider(
4921            std::sync::Arc::new(CountingAuth {
4922                header: (
4923                    "Authorization".to_string(),
4924                    "Bearer minted-token".to_string(),
4925                ),
4926                calls: calls.clone(),
4927            }),
4928        );
4929        // Azure URL: the static path would emit `api-key`, but the provider wins.
4930        let (name, value) = driver
4931            .resolve_auth_header("https://my-resource.openai.azure.com/openai/v1/responses")
4932            .await
4933            .expect("auth resolves");
4934        assert_eq!(name.as_str(), "authorization");
4935        assert_eq!(value.to_str().unwrap(), "Bearer minted-token");
4936        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
4937    }
4938
4939    #[tokio::test]
4940    async fn default_static_auth_applied_on_the_wire() {
4941        use wiremock::matchers::{header, method};
4942        use wiremock::{Mock, MockServer, ResponseTemplate};
4943
4944        let server = MockServer::start().await;
4945        Mock::given(method("POST"))
4946            .and(header("authorization", "Bearer wire-key"))
4947            .respond_with(ResponseTemplate::new(200).set_body_string(""))
4948            .mount(&server)
4949            .await;
4950
4951        let api_url = format!("{}/v1/responses", server.uri());
4952        let driver = OpenResponsesProtocolChatDriver::with_base_url("wire-key", api_url);
4953        let messages = vec![LlmMessage::text(LlmMessageRole::User, "hi")];
4954        let _ = driver
4955            .chat_completion_stream(messages, &auth_test_config())
4956            .await;
4957
4958        let requests = server.received_requests().await.unwrap();
4959        assert_eq!(
4960            requests.len(),
4961            1,
4962            "default static key must authenticate the request"
4963        );
4964    }
4965
4966    #[tokio::test]
4967    async fn auth_provider_header_wins_over_extension_header() {
4968        use wiremock::matchers::{header, method};
4969        use wiremock::{Mock, MockServer, ResponseTemplate};
4970
4971        let server = MockServer::start().await;
4972        // The request only matches if the auth header is the minted token (not the
4973        // extension's decoration value) AND the non-auth decoration is present.
4974        Mock::given(method("POST"))
4975            .and(header("authorization", "Bearer minted-token"))
4976            .and(header("x-openrouter-route", "fallback"))
4977            .respond_with(ResponseTemplate::new(200).set_body_string(""))
4978            .mount(&server)
4979            .await;
4980
4981        let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
4982        let api_url = format!("{}/v1/responses", server.uri());
4983        let driver = OpenResponsesProtocolChatDriver::with_base_url("ignored", api_url)
4984            .with_request_extension(std::sync::Arc::new(HeaderInjectingExtension))
4985            .with_auth_provider(std::sync::Arc::new(CountingAuth {
4986                header: (
4987                    "Authorization".to_string(),
4988                    "Bearer minted-token".to_string(),
4989                ),
4990                calls: calls.clone(),
4991            }));
4992
4993        let messages = vec![LlmMessage::text(LlmMessageRole::User, "hi")];
4994        let _ = driver
4995            .chat_completion_stream(messages, &auth_test_config())
4996            .await;
4997
4998        let requests = server.received_requests().await.unwrap();
4999        assert_eq!(
5000            requests.len(),
5001            1,
5002            "auth header must win over a conflicting decoration header"
5003        );
5004        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
5005    }
5006
5007    #[tokio::test]
5008    async fn auth_provider_awaited_on_each_retry_attempt() {
5009        use wiremock::matchers::method;
5010        use wiremock::{Mock, MockServer, ResponseTemplate};
5011
5012        let server = MockServer::start().await;
5013        // Always 503 (transient): the driver exhausts its retries, awaiting auth
5014        // before every attempt.
5015        Mock::given(method("POST"))
5016            .respond_with(ResponseTemplate::new(503).set_body_string("overloaded"))
5017            .mount(&server)
5018            .await;
5019
5020        let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
5021        let api_url = format!("{}/v1/responses", server.uri());
5022        let fast_retry = LlmRetryConfig {
5023            max_retries: 1,
5024            initial_backoff: std::time::Duration::from_millis(1),
5025            max_backoff: std::time::Duration::from_millis(1),
5026            backoff_multiplier: 1.0,
5027            jitter_factor: 0.0,
5028        };
5029        let driver = OpenResponsesProtocolChatDriver::with_base_url("ignored", api_url)
5030            .with_retry_config(fast_retry)
5031            .with_auth_provider(std::sync::Arc::new(CountingAuth {
5032                header: (
5033                    "Authorization".to_string(),
5034                    "Bearer minted-token".to_string(),
5035                ),
5036                calls: calls.clone(),
5037            }));
5038
5039        let messages = vec![LlmMessage::text(LlmMessageRole::User, "hi")];
5040        let _ = driver
5041            .chat_completion_stream(messages, &auth_test_config())
5042            .await;
5043
5044        // Initial attempt + one retry = two auth resolutions.
5045        assert_eq!(
5046            calls.load(std::sync::atomic::Ordering::SeqCst),
5047            2,
5048            "refreshable auth must be resolved per HTTP attempt, including retries"
5049        );
5050    }
5051}