Skip to main content

everruns_provider/
openresponses_protocol.rs

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