Skip to main content

everruns_core/
openresponses_protocol.rs

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