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