Skip to main content

everruns_provider/
openresponses_protocol.rs

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