Skip to main content

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