Skip to main content

everruns_core/
openai_protocol.rs

1// OpenAI Protocol Chat Driver
2//
3// Base implementation of the OpenAI chat completion protocol.
4// This driver can be used with any OpenAI-compatible API endpoint.
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// This is the base protocol implementation used in examples.
11// For production use with OpenAI-specific features, use OpenAIChatDriver from everruns-openai.
12//
13// Note: OTel instrumentation is handled via the event-listener pattern.
14// llm.generation events are emitted by ReasonAtom, and OtelEventListener
15// creates the appropriate gen-ai spans. No direct tracing in drivers.
16
17use async_trait::async_trait;
18use futures::StreamExt;
19use reqwest::{Client, RequestBuilder, Url};
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22use std::borrow::Cow;
23use std::sync::{Arc, Mutex};
24
25use crate::driver_registry::{
26    ChatDriver, LlmCallConfig, LlmCompletionMetadata, LlmContentPart, LlmMessage,
27    LlmMessageContent, LlmMessageRole, LlmResponseStream, LlmStreamEvent, disjoint_prompt_tokens,
28};
29use crate::error::{AgentLoopError, LlmErrorKind, Result};
30use crate::llm_retry::{
31    LlmRetryConfig, RateLimitInfo, RetryDecision, RetryMetadata, SendOutcome, is_rate_limit_status,
32    retry_request, send_error_message,
33};
34use crate::stream_accumulator::StreamToolCallAccumulator;
35use crate::stream_reconnect::connect_sse_with_reconnect;
36use crate::tool_types::ToolDefinition;
37use crate::user_facing_error::is_provider_quota_message;
38
39const DEFAULT_API_URL: &str = "https://api.openai.com/v1/chat/completions";
40
41/// Compute the default OpenAI/Azure auth header `(name, value)` for `api_url`:
42/// Azure OpenAI uses `api-key`, everything else uses `Authorization: Bearer`.
43///
44/// Shared by the Chat Completions and Open Responses drivers so the default
45/// static-key behavior stays identical to a pluggable [`AuthHeaderProvider`]
46/// path (see [`AuthHeaderProvider`]). The Azure `api-key` branch borrows the
47/// key (no per-request allocation); only the bearer branch allocates.
48pub(crate) fn openai_auth_header_pair<'a>(
49    api_url: &str,
50    api_key: &'a str,
51) -> (&'static str, Cow<'a, str>) {
52    if is_azure_openai_api_url(api_url) {
53        ("api-key", Cow::Borrowed(api_key))
54    } else {
55        ("Authorization", Cow::Owned(format!("Bearer {}", api_key)))
56    }
57}
58
59pub(crate) fn apply_openai_api_auth(
60    request: RequestBuilder,
61    api_url: &str,
62    api_key: &str,
63) -> RequestBuilder {
64    let (name, value) = openai_auth_header_pair(api_url, api_key);
65    request.header(name, value.as_ref())
66}
67
68/// Pluggable authentication-header provider for OpenAI-compatible drivers.
69///
70/// When set on an [`OpenAIProtocolChatDriver`] via
71/// [`OpenAIProtocolChatDriver::with_auth_provider`], the driver calls
72/// [`AuthHeaderProvider::auth_header`] before each request and applies the
73/// returned `(name, value)` header instead of the default `api-key` / bearer
74/// logic keyed on the host.
75///
76/// This lets a driver authenticate with short-lived, refreshable tokens —
77/// e.g. Microsoft Entra ID (OAuth) bearer tokens for Azure AI Foundry — without
78/// the generic protocol driver having to know the auth scheme. The provider is
79/// responsible for caching and refreshing tokens; `auth_header` is awaited once
80/// per HTTP attempt, so it should be cheap on the cached path.
81#[async_trait]
82pub trait AuthHeaderProvider: Send + Sync {
83    /// Return the `(header_name, header_value)` pair to apply for
84    /// authentication, refreshing any cached credential as needed. Returning
85    /// `Err` aborts the request before it is sent.
86    async fn auth_header(&self) -> Result<(String, String)>;
87}
88
89pub fn is_azure_openai_api_url(api_url: &str) -> bool {
90    Url::parse(api_url)
91        .ok()
92        .and_then(|url| url.host_str().map(|host| host.to_ascii_lowercase()))
93        .is_some_and(|host| {
94            host.ends_with(".openai.azure.com") || host.ends_with(".services.ai.azure.com")
95        })
96}
97
98/// Whether `api_url` points at OpenAI's hosted API (`api.openai.com`).
99///
100/// Host-based (not prefix-based) so it tolerates ports and trailing paths.
101pub fn is_openai_api_url(api_url: &str) -> bool {
102    Url::parse(api_url)
103        .ok()
104        .and_then(|url| url.host_str().map(|host| host.to_ascii_lowercase()))
105        .is_some_and(|host| host == "api.openai.com")
106}
107
108// ============================================================================
109// Model-discovery helpers (shared by OpenAI-compatible provider crates)
110// ============================================================================
111//
112// These are used by both `everruns-openai` and `everruns-openrouter` to derive
113// a `/models` URL, normalize a base URL, authenticate the discovery request, and
114// map a non-success status into an error. They live in core so the provider
115// crates can reuse them without duplicating logic.
116
117const OPENAI_MODELS_URL: &str = "https://api.openai.com/v1/models";
118
119/// Whether `api_url`'s host equals `host` (case-insensitive), ignoring path/port.
120pub fn url_host_eq(api_url: &str, host: &str) -> bool {
121    Url::parse(api_url)
122        .ok()
123        .and_then(|url| url.host_str().map(str::to_owned))
124        .is_some_and(|h| h.eq_ignore_ascii_case(host))
125}
126
127/// Normalize a base URL to a canonical endpoint URL, appending `endpoint_suffix`
128/// (e.g. `/responses`) unless it is already present.
129pub fn normalize_api_url(base_url: &str, endpoint_suffix: &str) -> String {
130    let trimmed = base_url.trim_end_matches('/');
131    if trimmed.ends_with(endpoint_suffix) {
132        trimmed.to_string()
133    } else {
134        format!("{trimmed}{endpoint_suffix}")
135    }
136}
137
138/// Derive the `/models` discovery URL from a chat/responses API URL.
139pub fn models_url_for_api_url(api_url: &str) -> String {
140    let trimmed = api_url.trim_end_matches('/');
141
142    if let Some(prefix) = trimmed.strip_suffix("/responses") {
143        return format!("{prefix}/models");
144    }
145    if let Some(prefix) = trimmed.strip_suffix("/chat/completions") {
146        return format!("{prefix}/models");
147    }
148    if trimmed.ends_with("/models") {
149        return trimmed.to_string();
150    }
151    if trimmed.ends_with("/v1") || trimmed.ends_with("/openai/v1") {
152        return format!("{trimmed}/models");
153    }
154
155    OPENAI_MODELS_URL.to_string()
156}
157
158/// Apply the appropriate auth header for a `/models` discovery request: Azure
159/// OpenAI uses `api-key`, everything else uses bearer auth.
160pub fn apply_models_api_auth(
161    request: RequestBuilder,
162    api_url: &str,
163    api_key: &str,
164) -> RequestBuilder {
165    if is_azure_openai_api_url(api_url) {
166        request.header("api-key", api_key)
167    } else {
168        request.bearer_auth(api_key)
169    }
170}
171
172/// Build the error returned when the `/models` endpoint responds with a
173/// non-success status.
174pub fn models_api_status_error(status: reqwest::StatusCode) -> AgentLoopError {
175    AgentLoopError::llm(format!("Models API returned status {status}"))
176}
177
178/// OpenAI Protocol Chat Driver
179///
180/// Base implementation of `ChatDriver` for OpenAI-compatible APIs.
181/// Supports streaming responses and tool calls.
182///
183/// Rate limit handling: On 429 errors, automatically retries with exponential
184/// backoff, respecting `x-ratelimit-reset-*` and `retry-after` headers.
185///
186/// This is the base protocol driver used in examples and for OpenAI-compatible endpoints.
187/// For production use with OpenAI, consider using `OpenAIChatDriver` from the `everruns-openai` crate.
188///
189/// # Example
190///
191/// ```ignore
192/// use everruns_core::OpenAIProtocolChatDriver;
193///
194/// let driver = OpenAIProtocolChatDriver::new("your-api-key");
195/// // or with custom endpoint
196/// let driver = OpenAIProtocolChatDriver::with_base_url("your-api-key", "https://api.example.com/v1/chat/completions");
197/// // or with custom retry config
198/// let driver = OpenAIProtocolChatDriver::new("your-api-key")
199///     .with_retry_config(LlmRetryConfig::aggressive());
200/// ```
201#[derive(Clone)]
202pub struct OpenAIProtocolChatDriver {
203    client: Client,
204    api_key: String,
205    api_url: String,
206    /// Retry configuration for rate limit errors
207    retry_config: LlmRetryConfig,
208    /// Optional pluggable auth-header provider. When set, it overrides the
209    /// default `api-key` / bearer auth (used for OAuth bearer tokens).
210    auth_provider: Option<Arc<dyn AuthHeaderProvider>>,
211}
212
213impl OpenAIProtocolChatDriver {
214    /// Create a new driver with the given API key
215    pub fn new(api_key: impl Into<String>) -> Self {
216        Self {
217            client: crate::driver_helpers::shared_streaming_http_client(),
218            api_key: api_key.into(),
219            api_url: DEFAULT_API_URL.to_string(),
220            retry_config: LlmRetryConfig::default(),
221            auth_provider: None,
222        }
223    }
224
225    /// Create a new driver with a custom API URL (for OpenAI-compatible APIs)
226    pub fn with_base_url(api_key: impl Into<String>, api_url: impl Into<String>) -> Self {
227        Self {
228            client: crate::driver_helpers::shared_streaming_http_client(),
229            api_key: api_key.into(),
230            api_url: api_url.into(),
231            retry_config: LlmRetryConfig::default(),
232            auth_provider: None,
233        }
234    }
235
236    /// Configure retry behavior for rate limit errors
237    pub fn with_retry_config(mut self, config: LlmRetryConfig) -> Self {
238        self.retry_config = config;
239        self
240    }
241
242    /// Set a pluggable [`AuthHeaderProvider`] that overrides the default
243    /// `api-key` / bearer auth. Used for OAuth bearer tokens (e.g. Entra ID).
244    pub fn with_auth_provider(mut self, provider: Arc<dyn AuthHeaderProvider>) -> Self {
245        self.auth_provider = Some(provider);
246        self
247    }
248
249    /// Get the API URL
250    pub fn api_url(&self) -> &str {
251        &self.api_url
252    }
253
254    /// Get the API key (for subclass access)
255    pub fn api_key(&self) -> &str {
256        &self.api_key
257    }
258
259    /// Get the HTTP client (for subclass access)
260    pub fn client(&self) -> &Client {
261        &self.client
262    }
263
264    /// Send one streaming chat-completion request, applying the shared
265    /// header-phase retry loop (transient send failures, 429, and 5xx), and
266    /// return the raw response plus its retry metadata.
267    ///
268    /// Invoked once per reconnect attempt by [`connect_sse_with_reconnect`]. It
269    /// re-sends the identical request and consumes no body bytes, so retrying it
270    /// is idempotent. The classifier preserves OpenAI's terminal classification
271    /// and error messages exactly.
272    async fn send_chat_completion_request(
273        &self,
274        request: &OpenAiRequest,
275        model: &str,
276    ) -> Result<(reqwest::Response, RetryMetadata)> {
277        let last_error: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
278
279        retry_request(
280            &self.retry_config,
281            "OpenAIProtocolDriver",
282            || async {
283                // Apply auth: a pluggable provider (e.g. OAuth bearer token)
284                // takes precedence over the default host-keyed `api-key` /
285                // bearer logic. An auth-provider failure is fatal (no retry).
286                let request_builder = self.client.post(&self.api_url);
287                let request_builder = match &self.auth_provider {
288                    Some(provider) => {
289                        let (name, value) =
290                            provider.auth_header().await.map_err(SendOutcome::Fatal)?;
291                        request_builder.header(name, value)
292                    }
293                    None => apply_openai_api_auth(request_builder, &self.api_url, &self.api_key),
294                };
295                request_builder
296                    .header("Content-Type", "application/json")
297                    .json(request)
298                    .send()
299                    .await
300                    .map_err(SendOutcome::Send)
301            },
302            |response, attempts, can_retry| {
303                let last_error = Arc::clone(&last_error);
304                let model = model.to_string();
305                async move {
306                    let status = response.status();
307
308                    if can_retry {
309                        // Parse rate limit info from headers before consuming body.
310                        let rate_limit_info = if is_rate_limit_status(status) {
311                            Some(RateLimitInfo::from_openai_headers(response.headers()))
312                        } else {
313                            None
314                        };
315
316                        let error_text = response.text().await.unwrap_or_default();
317
318                        // Don't retry a request-too-large error (not transient).
319                        if is_openai_request_too_large(status, &error_text) {
320                            return RetryDecision::Terminal(AgentLoopError::request_too_large(
321                                format!("OpenAI API error ({}): {}", status, error_text),
322                            ));
323                        }
324
325                        // Exhausted billing quota is surfaced as a 429 but is not
326                        // transient — fail fast instead of burning retries.
327                        if is_provider_quota_message(&error_text) {
328                            return RetryDecision::Terminal(AgentLoopError::llm_kind(
329                                LlmErrorKind::QuotaExhausted,
330                                format!("OpenAI API error ({}): {}", status, error_text),
331                            ));
332                        }
333
334                        let wait = rate_limit_info
335                            .as_ref()
336                            .map(|info| info.recommended_wait(&self.retry_config, attempts))
337                            .unwrap_or_else(|| self.retry_config.calculate_backoff(attempts));
338
339                        *last_error.lock().unwrap() = Some(error_text);
340                        return RetryDecision::Retry {
341                            wait,
342                            rate_limit_info,
343                        };
344                    }
345
346                    // Non-retryable error or max retries exceeded
347                    let error_text = response.text().await.unwrap_or_default();
348                    let error_msg = format!("OpenAI API error ({}): {}", status, error_text);
349
350                    // Check if this is a model-not-found error
351                    if is_openai_model_not_found(status, &error_text) {
352                        return RetryDecision::Terminal(AgentLoopError::model_not_available(model));
353                    }
354
355                    // Check if this is a request-too-large error
356                    if is_openai_request_too_large(status, &error_text) {
357                        return RetryDecision::Terminal(AgentLoopError::request_too_large(
358                            error_msg,
359                        ));
360                    }
361
362                    // Attach the semantic error kind while the HTTP status and
363                    // body are still available (see LlmErrorKind).
364                    let kind = LlmErrorKind::from_provider_status(status.as_u16(), &error_text);
365
366                    if attempts > 0 {
367                        return RetryDecision::Terminal(AgentLoopError::llm_kind(
368                            kind,
369                            format!(
370                                "{} (after {} retries, last error: {})",
371                                error_msg,
372                                attempts,
373                                last_error.lock().unwrap().take().unwrap_or_default()
374                            ),
375                        ));
376                    }
377
378                    RetryDecision::Terminal(AgentLoopError::llm_kind(kind, error_msg))
379                }
380            },
381            |e, attempts| AgentLoopError::llm(send_error_message(e, attempts)),
382        )
383        .await
384    }
385
386    fn convert_role(role: &LlmMessageRole) -> &'static str {
387        match role {
388            LlmMessageRole::System => "system",
389            LlmMessageRole::User => "user",
390            LlmMessageRole::Assistant => "assistant",
391            LlmMessageRole::Tool => "tool",
392        }
393    }
394
395    fn convert_message(msg: &LlmMessage) -> OpenAiMessage {
396        let content = match &msg.content {
397            LlmMessageContent::Text(text) => OpenAiContent::Text(text.clone()),
398            LlmMessageContent::Parts(parts) => {
399                let openai_parts: Vec<OpenAiContentPart> = parts
400                    .iter()
401                    .map(|part| match part {
402                        LlmContentPart::Text { text } => OpenAiContentPart::Text {
403                            r#type: "text".to_string(),
404                            text: text.clone(),
405                        },
406                        LlmContentPart::Image { url } => OpenAiContentPart::ImageUrl {
407                            r#type: "image_url".to_string(),
408                            image_url: OpenAiImageUrl { url: url.clone() },
409                        },
410                        LlmContentPart::Audio { url } => OpenAiContentPart::InputAudio {
411                            r#type: "input_audio".to_string(),
412                            input_audio: OpenAiInputAudio {
413                                data: url.clone(),
414                                format: "wav".to_string(),
415                            },
416                        },
417                    })
418                    .collect();
419                OpenAiContent::Parts(openai_parts)
420            }
421        };
422
423        // OpenAI only accepts tool_calls on assistant messages
424        let tool_calls = if msg.role == LlmMessageRole::Assistant {
425            msg.tool_calls.as_ref().map(|calls| {
426                calls
427                    .iter()
428                    .map(|tc| OpenAiToolCall {
429                        id: tc.id.clone(),
430                        r#type: "function".to_string(),
431                        function: OpenAiFunctionCall {
432                            name: tc.name.clone(),
433                            arguments: serde_json::to_string(&tc.arguments).unwrap_or_default(),
434                        },
435                    })
436                    .collect()
437            })
438        } else {
439            None
440        };
441
442        OpenAiMessage {
443            role: Self::convert_role(&msg.role).to_string(),
444            content: Some(content),
445            tool_calls,
446            tool_call_id: msg.tool_call_id.clone(),
447        }
448    }
449
450    fn convert_tools(tools: &[ToolDefinition]) -> Vec<OpenAiTool> {
451        tools
452            .iter()
453            .map(|tool| OpenAiTool {
454                r#type: "function".to_string(),
455                function: OpenAiFunction {
456                    name: tool.name().to_string(),
457                    description: tool.description().to_string(),
458                    parameters: tool.parameters().clone(),
459                },
460            })
461            .collect()
462    }
463}
464
465/// Drop Tool-role messages whose tool_call_id has no matching assistant tool call in the
466/// visible window. Chat Completions rejects payloads where a `tool`-role message references
467/// a call that is absent from the conversation.
468fn drop_orphaned_tool_messages(messages: &[LlmMessage]) -> Vec<LlmMessage> {
469    use std::collections::HashSet;
470
471    let visible_call_ids: HashSet<&str> = messages
472        .iter()
473        .filter(|m| m.role == LlmMessageRole::Assistant)
474        .flat_map(|m| m.tool_calls.iter().flatten())
475        .map(|tc| tc.id.as_str())
476        .collect();
477
478    if visible_call_ids.is_empty() {
479        return messages
480            .iter()
481            .filter(|m| m.role != LlmMessageRole::Tool)
482            .cloned()
483            .collect();
484    }
485
486    messages
487        .iter()
488        .filter(|m| {
489            if m.role == LlmMessageRole::Tool {
490                return m
491                    .tool_call_id
492                    .as_deref()
493                    .is_none_or(|id| visible_call_ids.contains(id));
494            }
495            true
496        })
497        .cloned()
498        .collect()
499}
500
501#[async_trait]
502impl ChatDriver for OpenAIProtocolChatDriver {
503    async fn chat_completion_stream(
504        &self,
505        messages: Vec<LlmMessage>,
506        config: &LlmCallConfig,
507    ) -> Result<LlmResponseStream> {
508        // Note: OTel instrumentation is handled via event listeners.
509        // ReasonAtom emits llm.generation events, and OtelEventListener
510        // creates gen-ai spans from those events.
511        let messages = drop_orphaned_tool_messages(&messages);
512        let openai_messages: Vec<OpenAiMessage> =
513            messages.iter().map(Self::convert_message).collect();
514
515        let tools = if config.tools.is_empty() {
516            None
517        } else {
518            Some(Self::convert_tools(&config.tools))
519        };
520
521        // Build metadata for request tracking
522        let metadata = if config.metadata.is_empty() {
523            None
524        } else {
525            Some(config.metadata.clone())
526        };
527
528        let request = OpenAiRequest {
529            model: config.model.clone(),
530            messages: openai_messages,
531            temperature: config.temperature,
532            max_tokens: config.max_tokens,
533            stream: true,
534            stream_options: Some(OpenAiStreamOptions {
535                include_usage: true,
536            }),
537            tools,
538            parallel_tool_calls: config
539                .resolved_parallel_tool_calls(self.supports_parallel_tool_calls(&config.model)),
540            // Skip "none" — sending reasoning_effort to non-thinking models causes API errors
541            reasoning_effort: config
542                .reasoning_effort
543                .as_ref()
544                .filter(|e| !e.eq_ignore_ascii_case("none"))
545                .cloned(),
546            service_tier: config.speed.clone(),
547            metadata,
548        };
549
550        // Establish the SSE stream, transparently reconnecting on a transport
551        // failure that lands before the first event is decoded (the "error
552        // decoding response body" flake). Header-phase retries (429/5xx and
553        // transient send failures) are handled inside the per-attempt send;
554        // this adds the body-phase reconnect the official SDKs get for free.
555        let (event_stream, retry_metadata) =
556            connect_sse_with_reconnect(&self.retry_config, "OpenAIProtocolDriver", |_attempt| {
557                self.send_chat_completion_request(&request, &config.model)
558            })
559            .await?;
560
561        let model = config.model.clone();
562        let total_tokens = Arc::new(Mutex::new(0u32));
563        let prompt_tokens = Arc::new(Mutex::new(0u32));
564        let cache_read_tokens = Arc::new(Mutex::new(Option::<u32>::None));
565        // OpenAI-compatible gateways (e.g. OpenRouter) report an authoritative
566        // per-request cost in `usage.cost`; direct OpenAI leaves it absent.
567        let provider_cost_usd = Arc::new(Mutex::new(Option::<f64>::None));
568        let accumulated_tool_calls = Arc::new(Mutex::new(StreamToolCallAccumulator::new()));
569        let finish_reason = Arc::new(Mutex::new(Option::<String>::None));
570        // Captured from the first streaming chunk that carries an id field.
571        // OpenRouter sets this to a "gen-..." identifier on every completion.
572        let response_id = Arc::new(Mutex::new(Option::<String>::None));
573        // Share retry metadata with stream closure (only set if retries occurred)
574        let shared_retry_metadata = if retry_metadata.had_retries() {
575            Some(Arc::new(retry_metadata))
576        } else {
577            None
578        };
579
580        // Each SSE event maps to zero-or-more stream events (the [DONE] marker can
581        // emit a flushed ToolCalls plus Done), so the closure yields a Vec that is
582        // flattened back into the stream.
583        let converted_stream: LlmResponseStream = Box::pin(
584            event_stream
585                .then(move |result| {
586                    let model = model.clone();
587                    let total_tokens = Arc::clone(&total_tokens);
588                    let prompt_tokens = Arc::clone(&prompt_tokens);
589                    let cache_read_tokens = Arc::clone(&cache_read_tokens);
590                    let provider_cost_usd = Arc::clone(&provider_cost_usd);
591                    let accumulated_tool_calls = Arc::clone(&accumulated_tool_calls);
592                    let finish_reason = Arc::clone(&finish_reason);
593                    let response_id = Arc::clone(&response_id);
594                    let retry_metadata_for_done = shared_retry_metadata.clone();
595
596                    async move {
597                        let event = match result {
598                            Ok(event) => event,
599                            Err(e) => {
600                                return vec![Ok(LlmStreamEvent::Error(
601                                    format!("Stream error: {}", e).into(),
602                                ))];
603                            }
604                        };
605
606                        if event.data == "[DONE]" {
607                            let output_tokens = *total_tokens.lock().unwrap();
608                            let input_tokens = *prompt_tokens.lock().unwrap();
609                            let cached = *cache_read_tokens.lock().unwrap();
610                            let cost = *provider_cost_usd.lock().unwrap();
611                            let resp_id = response_id.lock().unwrap().clone();
612                            let mut reason = finish_reason.lock().unwrap().clone();
613
614                            let mut events = Vec::new();
615
616                            // Defense in depth (EVE-522): flush any tool calls that
617                            // were accumulated but never emitted before Done, so they
618                            // are never silently dropped. The normal path drains the
619                            // accumulator at the finish chunk, so this only fires as a
620                            // fallback — e.g. a provider that ends the stream with
621                            // [DONE] without a tool_calls finish chunk reaching the
622                            // handler. When it fires, reflect the tool-call completion
623                            // in the reported finish_reason.
624                            {
625                                let mut acc = accumulated_tool_calls.lock().unwrap();
626                                if let Some(event) =
627                                    take_pending_tool_calls(&mut acc, reason.as_deref())
628                                {
629                                    events.push(Ok(event));
630                                    reason.get_or_insert_with(|| "tool_calls".to_string());
631                                }
632                            }
633
634                            events.push(Ok(LlmStreamEvent::Done(Box::new(
635                                LlmCompletionMetadata {
636                                    // `input_tokens` is OpenAI's cache-inclusive prompt count;
637                                    // normalize to non-cached input for the disjoint convention.
638                                    total_tokens: Some(input_tokens + output_tokens),
639                                    prompt_tokens: Some(disjoint_prompt_tokens(
640                                        input_tokens,
641                                        cached,
642                                    )),
643                                    completion_tokens: Some(output_tokens),
644                                    cache_read_tokens: cached,
645                                    cache_creation_tokens: None,
646                                    provider_cost_usd: cost,
647                                    model: Some(model),
648                                    finish_reason: reason.or_else(|| Some("stop".to_string())),
649                                    retry_metadata: retry_metadata_for_done
650                                        .map(|arc| (*arc).clone()),
651                                    response_id: resp_id,
652                                    phase: None,
653                                },
654                            ))));
655
656                            return events;
657                        }
658
659                        match serde_json::from_str::<OpenAiStreamChunk>(&event.data) {
660                            Ok(chunk) => {
661                                // Capture the completion ID from the first chunk that
662                                // carries one. OpenRouter sets this to a "gen-..."
663                                // identifier on every chunk; direct OpenAI uses
664                                // "chatcmpl-..." style IDs.
665                                if let Some(id) = &chunk.id {
666                                    let mut rid = response_id.lock().unwrap();
667                                    if rid.is_none() {
668                                        *rid = Some(id.clone());
669                                    }
670                                }
671
672                                // Capture usage from chunk if available
673                                if let Some(usage) = &chunk.usage {
674                                    if let Some(pt) = usage.prompt_tokens {
675                                        *prompt_tokens.lock().unwrap() = pt;
676                                    }
677                                    if let Some(ct) = usage.completion_tokens {
678                                        *total_tokens.lock().unwrap() = ct;
679                                    }
680                                    // Capture cached tokens from prompt_tokens_details
681                                    if let Some(details) = &usage.prompt_tokens_details
682                                        && details.cached_tokens.is_some()
683                                    {
684                                        *cache_read_tokens.lock().unwrap() = details.cached_tokens;
685                                    }
686                                    // Authoritative cost from OpenAI-compatible gateways
687                                    // (e.g. OpenRouter `usage.cost`, in USD credits).
688                                    if usage.cost.is_some() {
689                                        *provider_cost_usd.lock().unwrap() = usage.cost;
690                                    }
691                                }
692
693                                if let Some(choice) = chunk.choices.first() {
694                                    let mut tt = total_tokens.lock().unwrap();
695                                    let mut acc = accumulated_tool_calls.lock().unwrap();
696                                    let mut fr = finish_reason.lock().unwrap();
697                                    let stream_event =
698                                        process_stream_choice(choice, &mut tt, &mut acc, &mut fr);
699                                    return vec![Ok(stream_event)];
700                                }
701                                vec![Ok(LlmStreamEvent::TextDelta(String::new()))]
702                            }
703                            Err(e) => vec![Ok(LlmStreamEvent::Error(
704                                format!("Failed to parse chunk: {}", e).into(),
705                            ))],
706                        }
707                    }
708                })
709                .flat_map(futures::stream::iter),
710        );
711
712        Ok(converted_stream)
713    }
714
715    /// OpenAI-compatible Chat Completions accept the top-level
716    /// `parallel_tool_calls` boolean, so the preference maps directly onto the
717    /// wire for every model served through this protocol.
718    fn supports_parallel_tool_calls(&self, _model: &str) -> bool {
719        true
720    }
721}
722
723impl std::fmt::Debug for OpenAIProtocolChatDriver {
724    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
725        f.debug_struct("OpenAIProtocolChatDriver")
726            .field("api_url", &self.api_url)
727            .field("api_key", &"[REDACTED]")
728            .finish()
729    }
730}
731
732// ============================================================================
733// Error Detection Helpers
734// ============================================================================
735
736/// Check if the error indicates the model was not found.
737///
738/// OpenAI returns 404 or 400 with `"model_not_found"` code or `"does not exist"` message.
739/// OpenAI can also return 403 with `"model_not_found"` for tier-gated models — these must
740/// be classified as model_unavailable rather than provider_misconfigured.
741/// Also handles Gemini/OpenAI-compatible endpoints with similar patterns.
742pub fn is_openai_model_not_found(status: reqwest::StatusCode, error_text: &str) -> bool {
743    let error_lower = error_text.to_lowercase();
744
745    // OpenAI can return 404, 400, or 403 (tier-gated access) for nonexistent/inaccessible models
746    if status == reqwest::StatusCode::NOT_FOUND
747        || status == reqwest::StatusCode::BAD_REQUEST
748        || status == reqwest::StatusCode::FORBIDDEN
749    {
750        // OpenAI: {"error":{"code":"model_not_found","message":"The model 'x' does not exist"}}
751        if error_lower.contains("model_not_found") {
752            return true;
753        }
754    }
755
756    // 404 with generic model-not-found patterns
757    if status == reqwest::StatusCode::NOT_FOUND {
758        if error_lower.contains("does not exist") {
759            return true;
760        }
761        if error_lower.contains("model") && error_lower.contains("not found") {
762            return true;
763        }
764    }
765
766    false
767}
768
769/// Check if an OpenAI API error indicates the request is too large.
770///
771/// Detects:
772/// - 429 with "Request too large" or token limit messages
773/// - 400 with "context_length_exceeded" code
774/// - Any message about maximum context length being exceeded
775pub fn is_openai_request_too_large(status: reqwest::StatusCode, error_text: &str) -> bool {
776    let error_lower = error_text.to_lowercase();
777
778    // HTTP 429 with token-related errors
779    if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
780        // "Request too large for gpt-4" pattern
781        if error_lower.contains("request too large") {
782            return true;
783        }
784        // Token limit errors: "tokens per min (TPM): Limit X, Requested Y"
785        if error_lower.contains("tokens") && error_lower.contains("limit") {
786            return true;
787        }
788    }
789
790    // HTTP 400 with context length errors
791    if status == reqwest::StatusCode::BAD_REQUEST {
792        // "context_length_exceeded" error code
793        if error_lower.contains("context_length_exceeded") {
794            return true;
795        }
796        // "maximum context length" message
797        if error_lower.contains("maximum context length") {
798            return true;
799        }
800    }
801
802    // Generic patterns that could appear with various status codes
803    if error_lower.contains("tokens must be reduced")
804        || error_lower.contains("reduce the length")
805        || error_lower.contains("input is too long")
806    {
807        return true;
808    }
809
810    false
811}
812
813// ============================================================================
814// OpenAI API Types
815// ============================================================================
816
817#[derive(Debug, Serialize)]
818struct OpenAiRequest {
819    model: String,
820    messages: Vec<OpenAiMessage>,
821    #[serde(skip_serializing_if = "Option::is_none")]
822    temperature: Option<f32>,
823    #[serde(skip_serializing_if = "Option::is_none")]
824    max_tokens: Option<u32>,
825    stream: bool,
826    /// Request usage info in streaming response (required for token counts)
827    #[serde(skip_serializing_if = "Option::is_none")]
828    stream_options: Option<OpenAiStreamOptions>,
829    #[serde(skip_serializing_if = "Option::is_none")]
830    tools: Option<Vec<OpenAiTool>>,
831    /// Request-level control over parallel tool calls. Omitted when unset so the
832    /// provider default applies.
833    #[serde(skip_serializing_if = "Option::is_none")]
834    parallel_tool_calls: Option<bool>,
835    #[serde(skip_serializing_if = "Option::is_none")]
836    reasoning_effort: Option<String>,
837    /// Speed selector: OpenAI service tier ("flex", "default", "priority").
838    /// Omitted when `None` so the provider keeps its default ("auto") routing.
839    #[serde(skip_serializing_if = "Option::is_none")]
840    service_tier: Option<String>,
841    /// Metadata for tracking API usage (up to 16 key-value pairs).
842    /// Useful for correlating requests with session_id, agent_id, org_id, etc.
843    #[serde(skip_serializing_if = "Option::is_none")]
844    metadata: Option<std::collections::HashMap<String, String>>,
845}
846
847#[derive(Debug, Serialize)]
848struct OpenAiStreamOptions {
849    include_usage: bool,
850}
851
852#[derive(Debug, Serialize, Deserialize)]
853#[serde(untagged)]
854enum OpenAiContent {
855    Text(String),
856    Parts(Vec<OpenAiContentPart>),
857}
858
859#[derive(Debug, Serialize, Deserialize)]
860#[serde(untagged)]
861enum OpenAiContentPart {
862    Text {
863        r#type: String,
864        text: String,
865    },
866    ImageUrl {
867        r#type: String,
868        image_url: OpenAiImageUrl,
869    },
870    InputAudio {
871        r#type: String,
872        input_audio: OpenAiInputAudio,
873    },
874}
875
876#[derive(Debug, Serialize, Deserialize)]
877struct OpenAiImageUrl {
878    url: String,
879}
880
881#[derive(Debug, Serialize, Deserialize)]
882struct OpenAiInputAudio {
883    data: String,
884    format: String,
885}
886
887#[derive(Debug, Serialize, Deserialize)]
888struct OpenAiMessage {
889    role: String,
890    #[serde(skip_serializing_if = "Option::is_none")]
891    content: Option<OpenAiContent>,
892    #[serde(skip_serializing_if = "Option::is_none")]
893    tool_calls: Option<Vec<OpenAiToolCall>>,
894    #[serde(skip_serializing_if = "Option::is_none")]
895    tool_call_id: Option<String>,
896}
897
898#[derive(Debug, Serialize, Deserialize)]
899struct OpenAiTool {
900    r#type: String,
901    function: OpenAiFunction,
902}
903
904#[derive(Debug, Serialize, Deserialize)]
905struct OpenAiFunction {
906    name: String,
907    description: String,
908    parameters: Value,
909}
910
911#[derive(Debug, Serialize, Deserialize)]
912struct OpenAiToolCall {
913    id: String,
914    r#type: String,
915    function: OpenAiFunctionCall,
916}
917
918#[derive(Debug, Serialize, Deserialize)]
919struct OpenAiFunctionCall {
920    name: String,
921    arguments: String,
922}
923
924#[derive(Debug, Deserialize)]
925#[allow(dead_code)] // id and model are deserialized but used by event listeners, not directly
926struct OpenAiStreamChunk {
927    /// Unique identifier for this completion
928    #[serde(default)]
929    id: Option<String>,
930    /// Model used for completion (may differ from requested)
931    #[serde(default)]
932    model: Option<String>,
933    choices: Vec<OpenAiStreamChoice>,
934    #[serde(default)]
935    usage: Option<OpenAiUsage>,
936}
937
938#[derive(Debug, Deserialize)]
939struct OpenAiUsage {
940    prompt_tokens: Option<u32>,
941    completion_tokens: Option<u32>,
942    /// Detailed breakdown of prompt tokens (includes cached tokens)
943    #[serde(default)]
944    prompt_tokens_details: Option<OpenAiPromptTokensDetails>,
945    /// Authoritative per-request cost in USD credits, returned by
946    /// OpenAI-compatible gateways such as OpenRouter. Absent for direct OpenAI.
947    #[serde(default)]
948    cost: Option<f64>,
949}
950
951#[derive(Debug, Deserialize, Default)]
952struct OpenAiPromptTokensDetails {
953    /// Number of tokens retrieved from cache
954    #[serde(default)]
955    cached_tokens: Option<u32>,
956}
957
958#[derive(Debug, Deserialize)]
959struct OpenAiStreamChoice {
960    delta: OpenAiDelta,
961    #[serde(default)]
962    finish_reason: Option<String>,
963}
964
965#[derive(Debug, Deserialize)]
966struct OpenAiDelta {
967    #[serde(default)]
968    content: Option<String>,
969    #[serde(default)]
970    tool_calls: Option<Vec<OpenAiStreamToolCall>>,
971}
972
973#[derive(Debug, Deserialize)]
974struct OpenAiStreamToolCall {
975    index: u32,
976    id: Option<String>,
977    function: Option<OpenAiStreamFunction>,
978}
979
980#[derive(Debug, Deserialize)]
981struct OpenAiStreamFunction {
982    name: Option<String>,
983    arguments: Option<String>,
984}
985
986/// Drains tool calls that were accumulated but not yet emitted, returning a
987/// final `ToolCalls` event for the `[DONE]` handler. Returns `None` when nothing
988/// is pending (the common case, since the finish chunk normally drains them).
989///
990/// The fallback may only emit calls when the provider omitted a finish reason or
991/// reported `tool_calls`. Non-tool finish reasons such as `length` and
992/// `content_filter` indicate an incomplete or rejected response, so pending
993/// calls are discarded instead of being executed. Malformed streamed argument
994/// JSON is likewise dropped (via the accumulator's strict flush) because this
995/// fallback runs without an explicit final tool-call completion chunk.
996fn take_pending_tool_calls(
997    accumulated_tool_calls: &mut StreamToolCallAccumulator,
998    finish_reason: Option<&str>,
999) -> Option<LlmStreamEvent> {
1000    if accumulated_tool_calls.is_empty() {
1001        return None;
1002    }
1003
1004    // A non-tool finish reason means the response was cut/rejected; drain the
1005    // accumulator (so a repeated flush cannot re-emit) but do not execute.
1006    if !matches!(finish_reason, None | Some("tool_calls")) {
1007        let _ = accumulated_tool_calls.take_finalized();
1008        return None;
1009    }
1010
1011    let calls = accumulated_tool_calls.take_pending_strict();
1012    if calls.is_empty() {
1013        None
1014    } else {
1015        Some(LlmStreamEvent::ToolCalls(calls))
1016    }
1017}
1018
1019/// Processes a single chat-completion stream choice, updating the running
1020/// accumulators and returning the event to emit.
1021///
1022/// EVE-522: some OpenAI-compatible providers (OpenRouter/DeepInfra) send an
1023/// empty `content: ""` delta in the *same* chunk that carries
1024/// `finish_reason: "tool_calls"`. The content branch must therefore ignore
1025/// empty content, otherwise it short-circuits before the finish handler and the
1026/// accumulated tool calls are silently dropped. Emitting drains the accumulator
1027/// so a repeated finish chunk does not re-emit the same calls.
1028fn process_stream_choice(
1029    choice: &OpenAiStreamChoice,
1030    total_tokens: &mut u32,
1031    accumulated_tool_calls: &mut StreamToolCallAccumulator,
1032    finish_reason: &mut Option<String>,
1033) -> LlmStreamEvent {
1034    // Accumulate streamed tool-call fragments, keyed by the chunk `index`. The
1035    // shared accumulator appends argument fragments in place (EVE-636: amortized
1036    // O(total)) and parses the JSON once at finalize.
1037    if let Some(tool_calls) = &choice.delta.tool_calls {
1038        for tc in tool_calls {
1039            accumulated_tool_calls.apply_indexed_delta(
1040                tc.index,
1041                tc.id.as_deref(),
1042                tc.function.as_ref().and_then(|f| f.name.as_deref()),
1043                tc.function.as_ref().and_then(|f| f.arguments.as_deref()),
1044            );
1045        }
1046        return LlmStreamEvent::TextDelta(String::new());
1047    }
1048
1049    // Content delta. Guard on non-empty: an empty-content delta that rides along
1050    // with finish_reason must not short-circuit the finish handler below.
1051    if let Some(content) = &choice.delta.content
1052        && !content.is_empty()
1053    {
1054        *total_tokens += 1;
1055        return LlmStreamEvent::TextDelta(content.clone());
1056    }
1057
1058    // Finish reason. Store it for the [DONE] handler; for tool_calls, emit the
1059    // accumulated calls immediately so the agent can start working. Draining the
1060    // accumulator prevents a second finish chunk from re-emitting the calls.
1061    if let Some(fr) = &choice.finish_reason {
1062        *finish_reason = Some(fr.clone());
1063
1064        if fr == "tool_calls" && !accumulated_tool_calls.is_empty() {
1065            return LlmStreamEvent::ToolCalls(accumulated_tool_calls.take_finalized());
1066        }
1067    }
1068
1069    LlmStreamEvent::TextDelta(String::new())
1070}
1071
1072// ============================================================================
1073// Tests
1074// ============================================================================
1075
1076#[cfg(test)]
1077mod tests {
1078    use super::*;
1079    use serde_json::json;
1080
1081    #[test]
1082    fn test_convert_message_preserves_multiple_system_messages() {
1083        // OpenAI chat-completions keeps the system role inline, so both the agent
1084        // system prompt and a later notice/summary System message (infinity_context
1085        // / compaction) pass through as separate `system` entries — neither is
1086        // dropped. Lock that in alongside the "separate system field" drivers.
1087        let messages = [
1088            LlmMessage::text(LlmMessageRole::System, "A"),
1089            LlmMessage::text(LlmMessageRole::User, "hi"),
1090            LlmMessage::text(LlmMessageRole::System, "B"),
1091        ];
1092        let converted: Vec<OpenAiMessage> = messages
1093            .iter()
1094            .map(OpenAIProtocolChatDriver::convert_message)
1095            .collect();
1096        let system_texts: Vec<String> = converted
1097            .iter()
1098            .filter(|m| m.role == "system")
1099            .filter_map(|m| match &m.content {
1100                Some(OpenAiContent::Text(t)) => Some(t.clone()),
1101                _ => None,
1102            })
1103            .collect();
1104        assert_eq!(system_texts, vec!["A".to_string(), "B".to_string()]);
1105    }
1106
1107    #[test]
1108    fn test_driver_with_api_key() {
1109        let driver = OpenAIProtocolChatDriver::new("test-key");
1110        assert!(format!("{:?}", driver).contains("OpenAIProtocolChatDriver"));
1111    }
1112
1113    #[test]
1114    fn test_driver_with_base_url() {
1115        let driver = OpenAIProtocolChatDriver::with_base_url(
1116            "test-key",
1117            "https://custom.api.com/v1/completions",
1118        );
1119        assert!(format!("{:?}", driver).contains("OpenAIProtocolChatDriver"));
1120        assert_eq!(driver.api_url(), "https://custom.api.com/v1/completions");
1121    }
1122
1123    #[test]
1124    fn test_is_azure_openai_api_url() {
1125        assert!(is_azure_openai_api_url(
1126            "https://example.openai.azure.com/openai/v1/chat/completions"
1127        ));
1128        assert!(is_azure_openai_api_url(
1129            "https://example.services.ai.azure.com/openai/v1/responses"
1130        ));
1131        assert!(!is_azure_openai_api_url(
1132            "https://api.openai.com/v1/chat/completions"
1133        ));
1134    }
1135
1136    #[test]
1137    fn test_request_includes_stream_options_for_usage() {
1138        // OpenAI streaming API requires stream_options.include_usage=true
1139        // to return token usage in the response
1140        let request = OpenAiRequest {
1141            service_tier: None,
1142            model: "gpt-4o".to_string(),
1143            messages: vec![OpenAiMessage {
1144                role: "user".to_string(),
1145                content: Some(OpenAiContent::Text("Hello".to_string())),
1146                tool_calls: None,
1147                tool_call_id: None,
1148            }],
1149            temperature: None,
1150            max_tokens: None,
1151            stream: true,
1152            stream_options: Some(OpenAiStreamOptions {
1153                include_usage: true,
1154            }),
1155            tools: None,
1156            parallel_tool_calls: None,
1157            reasoning_effort: None,
1158            metadata: None,
1159        };
1160
1161        let json = serde_json::to_value(&request).unwrap();
1162        assert_eq!(json["stream"], true);
1163        assert_eq!(json["stream_options"]["include_usage"], true);
1164    }
1165
1166    #[test]
1167    fn test_request_includes_metadata() {
1168        // Metadata should be included when provided
1169        let mut metadata = std::collections::HashMap::new();
1170        metadata.insert("session_id".to_string(), "session_abc123".to_string());
1171        metadata.insert("agent_id".to_string(), "agent_xyz789".to_string());
1172
1173        let request = OpenAiRequest {
1174            service_tier: None,
1175            model: "gpt-4o".to_string(),
1176            messages: vec![OpenAiMessage {
1177                role: "user".to_string(),
1178                content: Some(OpenAiContent::Text("Hello".to_string())),
1179                tool_calls: None,
1180                tool_call_id: None,
1181            }],
1182            temperature: None,
1183            max_tokens: None,
1184            stream: true,
1185            stream_options: None,
1186            tools: None,
1187            parallel_tool_calls: None,
1188            reasoning_effort: None,
1189            metadata: Some(metadata),
1190        };
1191
1192        let json = serde_json::to_value(&request).unwrap();
1193        assert_eq!(json["metadata"]["session_id"], "session_abc123");
1194        assert_eq!(json["metadata"]["agent_id"], "agent_xyz789");
1195    }
1196
1197    #[test]
1198    fn test_usage_chunk_parsing() {
1199        // OpenAI sends usage in a separate chunk after finish_reason
1200        // This test verifies we can parse it correctly
1201        let usage_chunk = r#"{
1202            "id": "chatcmpl-123",
1203            "object": "chat.completion.chunk",
1204            "created": 1234567890,
1205            "model": "gpt-4o",
1206            "choices": [],
1207            "usage": {
1208                "prompt_tokens": 150,
1209                "completion_tokens": 42,
1210                "total_tokens": 192
1211            }
1212        }"#;
1213
1214        let chunk: OpenAiStreamChunk = serde_json::from_str(usage_chunk).unwrap();
1215        assert!(chunk.usage.is_some());
1216        let usage = chunk.usage.unwrap();
1217        assert_eq!(usage.prompt_tokens, Some(150));
1218        assert_eq!(usage.completion_tokens, Some(42));
1219    }
1220
1221    #[test]
1222    fn test_usage_chunk_with_cached_tokens() {
1223        // OpenAI includes cached_tokens in prompt_tokens_details
1224        let usage_chunk = r#"{
1225            "id": "chatcmpl-123",
1226            "choices": [],
1227            "usage": {
1228                "prompt_tokens": 150,
1229                "completion_tokens": 42,
1230                "prompt_tokens_details": {
1231                    "cached_tokens": 100
1232                }
1233            }
1234        }"#;
1235
1236        let chunk: OpenAiStreamChunk = serde_json::from_str(usage_chunk).unwrap();
1237        let usage = chunk.usage.unwrap();
1238        assert_eq!(usage.prompt_tokens, Some(150));
1239        assert_eq!(usage.completion_tokens, Some(42));
1240        assert!(usage.prompt_tokens_details.is_some());
1241        assert_eq!(
1242            usage.prompt_tokens_details.unwrap().cached_tokens,
1243            Some(100)
1244        );
1245    }
1246
1247    #[test]
1248    fn test_usage_chunk_with_openrouter_cost() {
1249        // OpenAI-compatible gateways like OpenRouter add `usage.cost` (USD credits).
1250        let usage_chunk = r#"{
1251            "id": "gen-123",
1252            "choices": [],
1253            "usage": {
1254                "prompt_tokens": 194,
1255                "completion_tokens": 2,
1256                "total_tokens": 196,
1257                "cost": 0.00095
1258            }
1259        }"#;
1260
1261        let chunk: OpenAiStreamChunk = serde_json::from_str(usage_chunk).unwrap();
1262        let usage = chunk.usage.unwrap();
1263        assert_eq!(usage.cost, Some(0.00095));
1264    }
1265
1266    #[test]
1267    fn test_usage_chunk_without_cost_defaults_none() {
1268        // Direct OpenAI omits `cost`; it must deserialize to None, not error.
1269        let usage_chunk = r#"{
1270            "id": "chatcmpl-123",
1271            "choices": [],
1272            "usage": { "prompt_tokens": 10, "completion_tokens": 5 }
1273        }"#;
1274
1275        let chunk: OpenAiStreamChunk = serde_json::from_str(usage_chunk).unwrap();
1276        assert_eq!(chunk.usage.unwrap().cost, None);
1277    }
1278
1279    #[test]
1280    fn test_chunk_id_is_captured() {
1281        let chunk_with_id: OpenAiStreamChunk =
1282            serde_json::from_str(r#"{"id":"gen-abc123","choices":[]}"#).unwrap();
1283        assert_eq!(chunk_with_id.id.as_deref(), Some("gen-abc123"));
1284
1285        let chunk_no_id: OpenAiStreamChunk = serde_json::from_str(r#"{"choices":[]}"#).unwrap();
1286        assert!(chunk_no_id.id.is_none());
1287    }
1288
1289    #[test]
1290    fn test_finish_reason_chunk_parsing() {
1291        // Finish reason comes in a chunk BEFORE the usage chunk
1292        let finish_chunk = r#"{
1293            "id": "chatcmpl-123",
1294            "choices": [{
1295                "index": 0,
1296                "delta": {},
1297                "finish_reason": "stop"
1298            }]
1299        }"#;
1300
1301        let chunk: OpenAiStreamChunk = serde_json::from_str(finish_chunk).unwrap();
1302        assert!(chunk.usage.is_none()); // No usage in finish_reason chunk
1303        assert_eq!(chunk.choices.len(), 1);
1304        assert_eq!(chunk.choices[0].finish_reason, Some("stop".to_string()));
1305    }
1306
1307    // ========================================================================
1308    // Request-too-large detection tests
1309    // ========================================================================
1310
1311    #[test]
1312    fn test_is_openai_request_too_large_429_request_too_large() {
1313        let error = r#"{"error":{"message":"Request too large for gpt-4o in organization org-xxx on tokens per min (TPM): Limit 500000, Requested 538772."}}"#;
1314        assert!(is_openai_request_too_large(
1315            reqwest::StatusCode::TOO_MANY_REQUESTS,
1316            error
1317        ));
1318    }
1319
1320    #[test]
1321    fn test_is_openai_request_too_large_429_token_limit() {
1322        let error =
1323            r#"{"error":{"message":"tokens per min (TPM): Limit 500000, Requested 600000"}}"#;
1324        assert!(is_openai_request_too_large(
1325            reqwest::StatusCode::TOO_MANY_REQUESTS,
1326            error
1327        ));
1328    }
1329
1330    #[test]
1331    fn test_is_openai_request_too_large_400_context_length() {
1332        let error = r#"{"error":{"code":"context_length_exceeded","message":"This model's maximum context length is 128000 tokens."}}"#;
1333        assert!(is_openai_request_too_large(
1334            reqwest::StatusCode::BAD_REQUEST,
1335            error
1336        ));
1337    }
1338
1339    #[test]
1340    fn test_is_openai_request_too_large_400_max_context() {
1341        let error =
1342            r#"{"error":{"message":"This model's maximum context length is 128000 tokens"}}"#;
1343        assert!(is_openai_request_too_large(
1344            reqwest::StatusCode::BAD_REQUEST,
1345            error
1346        ));
1347    }
1348
1349    #[test]
1350    fn test_is_openai_request_too_large_tokens_must_be_reduced() {
1351        let error = r#"{"error":{"message":"The input or output tokens must be reduced"}}"#;
1352        assert!(is_openai_request_too_large(
1353            reqwest::StatusCode::BAD_REQUEST,
1354            error
1355        ));
1356    }
1357
1358    #[test]
1359    fn test_is_openai_request_too_large_false_for_other_errors() {
1360        // Regular rate limit (not token-related)
1361        let error = r#"{"error":{"message":"Rate limit exceeded: too many requests per minute"}}"#;
1362        assert!(!is_openai_request_too_large(
1363            reqwest::StatusCode::TOO_MANY_REQUESTS,
1364            error
1365        ));
1366
1367        // Internal server error
1368        let error = r#"{"error":{"message":"Internal server error"}}"#;
1369        assert!(!is_openai_request_too_large(
1370            reqwest::StatusCode::INTERNAL_SERVER_ERROR,
1371            error
1372        ));
1373
1374        // Generic 400 error
1375        let error = r#"{"error":{"message":"Invalid request"}}"#;
1376        assert!(!is_openai_request_too_large(
1377            reqwest::StatusCode::BAD_REQUEST,
1378            error
1379        ));
1380    }
1381
1382    // ========================================================================
1383    // Model-not-found detection tests
1384    // ========================================================================
1385
1386    #[test]
1387    fn test_is_openai_model_not_found_real_error() {
1388        // Real OpenAI 404 response for nonexistent model
1389        let error = r#"{"error":{"code":"model_not_found","message":"The model 'gpt-99' does not exist or you do not have access to it.","type":"invalid_request_error","param":null}}"#;
1390        assert!(is_openai_model_not_found(
1391            reqwest::StatusCode::NOT_FOUND,
1392            error
1393        ));
1394    }
1395
1396    #[test]
1397    fn test_is_openai_model_not_found_does_not_exist() {
1398        let error = r#"{"error":{"message":"The model 'fake-model' does not exist"}}"#;
1399        assert!(is_openai_model_not_found(
1400            reqwest::StatusCode::NOT_FOUND,
1401            error
1402        ));
1403    }
1404
1405    #[test]
1406    fn test_is_openai_model_not_found_generic_not_found() {
1407        let error = r#"{"error":{"message":"Model not found"}}"#;
1408        assert!(is_openai_model_not_found(
1409            reqwest::StatusCode::NOT_FOUND,
1410            error
1411        ));
1412    }
1413
1414    #[test]
1415    fn test_is_openai_model_not_found_400_with_model_not_found_code() {
1416        // OpenAI Responses API returns 400 (not 404) for nonexistent models
1417        let error = r#"{"error":{"code":"model_not_found","message":"The requested model 'gpt-99' does not exist.","type":"invalid_request_error","param":"model"}}"#;
1418        assert!(is_openai_model_not_found(
1419            reqwest::StatusCode::BAD_REQUEST,
1420            error
1421        ));
1422    }
1423
1424    #[test]
1425    fn test_is_openai_model_not_found_false_for_non_model_error() {
1426        // 400 without model_not_found code should not match
1427        let error = r#"{"error":{"code":"invalid_request","message":"Some other error"}}"#;
1428        assert!(!is_openai_model_not_found(
1429            reqwest::StatusCode::BAD_REQUEST,
1430            error
1431        ));
1432    }
1433
1434    #[test]
1435    fn test_is_openai_model_not_found_false_for_other_404() {
1436        // 404 without model-related message
1437        let error = r#"{"error":{"message":"Endpoint not found"}}"#;
1438        assert!(!is_openai_model_not_found(
1439            reqwest::StatusCode::NOT_FOUND,
1440            error
1441        ));
1442    }
1443
1444    #[test]
1445    fn test_is_openai_model_not_found_403_tier_gated_model() {
1446        // OpenAI returns 403 for models that exist but require a higher API tier;
1447        // these must classify as model_unavailable, not provider_misconfigured.
1448        let error = r#"{"error":{"code":"model_not_found","message":"The model 'gpt-5.4-mini' does not exist or you do not have access to it.","type":"invalid_request_error","param":null}}"#;
1449        assert!(is_openai_model_not_found(
1450            reqwest::StatusCode::FORBIDDEN,
1451            error
1452        ));
1453    }
1454
1455    #[test]
1456    fn test_is_openai_model_not_found_403_plain_auth_error_is_not_model_not_found() {
1457        // A plain 403 without model_not_found code is a real auth error and must
1458        // NOT be classified as model_unavailable.
1459        let error = r#"{"error":{"message":"Invalid authentication credentials","type":"authentication_error"}}"#;
1460        assert!(!is_openai_model_not_found(
1461            reqwest::StatusCode::FORBIDDEN,
1462            error
1463        ));
1464    }
1465
1466    // ========================================================================
1467    // Reasoning effort guard tests
1468    // ========================================================================
1469
1470    #[test]
1471    fn test_reasoning_effort_none_is_omitted() {
1472        // When reasoning_effort is "none", it should be filtered out
1473        // to avoid "Unrecognized request argument" errors on non-thinking models
1474        let request = OpenAiRequest {
1475            service_tier: None,
1476            model: "gpt-4o-mini".to_string(),
1477            messages: vec![OpenAiMessage {
1478                role: "user".to_string(),
1479                content: Some(OpenAiContent::Text("Hello".to_string())),
1480                tool_calls: None,
1481                tool_call_id: None,
1482            }],
1483            temperature: None,
1484            max_tokens: None,
1485            stream: true,
1486            stream_options: None,
1487            tools: None,
1488            parallel_tool_calls: None,
1489            reasoning_effort: Some("none".to_string())
1490                .as_ref()
1491                .filter(|e| !e.eq_ignore_ascii_case("none"))
1492                .cloned(),
1493            metadata: None,
1494        };
1495
1496        let json = serde_json::to_value(&request).unwrap();
1497        assert!(
1498            json.get("reasoning_effort").is_none(),
1499            "reasoning_effort should be omitted when effort is 'none'"
1500        );
1501    }
1502
1503    #[test]
1504    fn test_reasoning_effort_high_is_included() {
1505        let request = OpenAiRequest {
1506            service_tier: None,
1507            model: "o3-mini".to_string(),
1508            messages: vec![OpenAiMessage {
1509                role: "user".to_string(),
1510                content: Some(OpenAiContent::Text("Hello".to_string())),
1511                tool_calls: None,
1512                tool_call_id: None,
1513            }],
1514            temperature: None,
1515            max_tokens: None,
1516            stream: true,
1517            stream_options: None,
1518            tools: None,
1519            parallel_tool_calls: None,
1520            reasoning_effort: Some("high".to_string())
1521                .as_ref()
1522                .filter(|e| !e.eq_ignore_ascii_case("none"))
1523                .cloned(),
1524            metadata: None,
1525        };
1526
1527        let json = serde_json::to_value(&request).unwrap();
1528        assert_eq!(json["reasoning_effort"], "high");
1529    }
1530
1531    /// EVE-598: the Chat Completions request (used by the OpenAI Chat driver,
1532    /// OpenRouter, and MAI) serializes `parallel_tool_calls` only when set, so
1533    /// the provider default applies when the operator leaves it unset.
1534    #[test]
1535    fn test_request_serializes_parallel_tool_calls() {
1536        fn build(flag: Option<bool>) -> serde_json::Value {
1537            let request = OpenAiRequest {
1538                service_tier: None,
1539                model: "gpt-4o-mini".to_string(),
1540                messages: vec![OpenAiMessage {
1541                    role: "user".to_string(),
1542                    content: Some(OpenAiContent::Text("Hello".to_string())),
1543                    tool_calls: None,
1544                    tool_call_id: None,
1545                }],
1546                temperature: None,
1547                max_tokens: None,
1548                stream: true,
1549                stream_options: None,
1550                tools: None,
1551                parallel_tool_calls: flag,
1552                reasoning_effort: None,
1553                metadata: None,
1554            };
1555            serde_json::to_value(&request).unwrap()
1556        }
1557
1558        // Omitted when None.
1559        assert!(build(None).get("parallel_tool_calls").is_none());
1560        // Present and preserved for Some(_).
1561        assert_eq!(build(Some(true))["parallel_tool_calls"], true);
1562        assert_eq!(build(Some(false))["parallel_tool_calls"], false);
1563    }
1564
1565    /// The speed selector serializes as `service_tier` only when set, so the
1566    /// provider's default ("auto") routing applies when unset.
1567    #[test]
1568    fn test_request_serializes_service_tier() {
1569        fn build(tier: Option<&str>) -> serde_json::Value {
1570            let request = OpenAiRequest {
1571                service_tier: tier.map(str::to_string),
1572                model: "gpt-4o-mini".to_string(),
1573                messages: vec![OpenAiMessage {
1574                    role: "user".to_string(),
1575                    content: Some(OpenAiContent::Text("Hello".to_string())),
1576                    tool_calls: None,
1577                    tool_call_id: None,
1578                }],
1579                temperature: None,
1580                max_tokens: None,
1581                stream: true,
1582                stream_options: None,
1583                tools: None,
1584                parallel_tool_calls: None,
1585                reasoning_effort: None,
1586                metadata: None,
1587            };
1588            serde_json::to_value(&request).unwrap()
1589        }
1590
1591        assert!(build(None).get("service_tier").is_none());
1592        assert_eq!(build(Some("flex"))["service_tier"], "flex");
1593        assert_eq!(build(Some("priority"))["service_tier"], "priority");
1594    }
1595
1596    // ------------------------------------------------------------------
1597    // EVE-522: streaming chunk handling (process_stream_choice)
1598    // ------------------------------------------------------------------
1599
1600    fn choice(json_str: &str) -> OpenAiStreamChoice {
1601        serde_json::from_str(json_str).unwrap()
1602    }
1603
1604    /// EVE-522 regression: providers such as OpenRouter/DeepInfra send an empty
1605    /// `content: ""` in the same chunk that carries `finish_reason: "tool_calls"`.
1606    /// The accumulated tool calls must still be emitted exactly once.
1607    #[test]
1608    fn test_empty_content_finish_chunk_still_emits_tool_calls() {
1609        let mut total_tokens = 0u32;
1610        let mut acc = StreamToolCallAccumulator::new();
1611        let mut finish_reason: Option<String> = None;
1612
1613        // Chunk 2: tool_calls delta opens the call (id + name).
1614        let e = process_stream_choice(
1615            &choice(
1616                r#"{"delta":{"content":null,"tool_calls":[{"index":0,"id":"call_1","function":{"name":"read_file","arguments":""}}]},"finish_reason":null}"#,
1617            ),
1618            &mut total_tokens,
1619            &mut acc,
1620            &mut finish_reason,
1621        );
1622        assert!(matches!(e, LlmStreamEvent::TextDelta(s) if s.is_empty()));
1623
1624        // Chunk 3: tool_calls delta streams the arguments.
1625        let e = process_stream_choice(
1626            &choice(
1627                r#"{"delta":{"content":null,"tool_calls":[{"index":0,"function":{"arguments":"{\"path\":\"Cargo.toml\"}"}}]},"finish_reason":null}"#,
1628            ),
1629            &mut total_tokens,
1630            &mut acc,
1631            &mut finish_reason,
1632        );
1633        assert!(matches!(e, LlmStreamEvent::TextDelta(s) if s.is_empty()));
1634
1635        // Chunk 4: content:"" alongside finish_reason:"tool_calls" — must NOT
1636        // short-circuit; emits the accumulated call with parsed JSON arguments.
1637        let e = process_stream_choice(
1638            &choice(r#"{"delta":{"content":""},"finish_reason":"tool_calls"}"#),
1639            &mut total_tokens,
1640            &mut acc,
1641            &mut finish_reason,
1642        );
1643        match e {
1644            LlmStreamEvent::ToolCalls(calls) => {
1645                assert_eq!(calls.len(), 1);
1646                assert_eq!(calls[0].id, "call_1");
1647                assert_eq!(calls[0].name, "read_file");
1648                assert_eq!(calls[0].arguments, json!({"path": "Cargo.toml"}));
1649            }
1650            other => panic!("expected ToolCalls, got {:?}", other),
1651        }
1652        assert_eq!(finish_reason.as_deref(), Some("tool_calls"));
1653
1654        // Chunk 5: second finish chunk with content:"" — the accumulator was
1655        // drained, so the same call must not be emitted again.
1656        let e = process_stream_choice(
1657            &choice(r#"{"delta":{"content":""},"finish_reason":"tool_calls"}"#),
1658            &mut total_tokens,
1659            &mut acc,
1660            &mut finish_reason,
1661        );
1662        assert!(
1663            matches!(e, LlmStreamEvent::TextDelta(s) if s.is_empty()),
1664            "tool calls must only be emitted once"
1665        );
1666    }
1667
1668    /// Non-empty content deltas are still emitted and counted as output tokens.
1669    #[test]
1670    fn test_non_empty_content_is_emitted() {
1671        let mut total_tokens = 0u32;
1672        let mut acc = StreamToolCallAccumulator::new();
1673        let mut finish_reason: Option<String> = None;
1674
1675        let e = process_stream_choice(
1676            &choice(r#"{"delta":{"content":"hello"},"finish_reason":null}"#),
1677            &mut total_tokens,
1678            &mut acc,
1679            &mut finish_reason,
1680        );
1681        assert!(matches!(e, LlmStreamEvent::TextDelta(s) if s == "hello"));
1682        assert_eq!(total_tokens, 1);
1683    }
1684
1685    /// EVE-636: streamed tool-call arguments must concatenate exactly across
1686    /// many small chunks (accumulated as a raw string, parsed zero times
1687    /// mid-stream) and be parsed exactly once at the `tool_calls` finish chunk.
1688    #[test]
1689    fn test_tool_call_arguments_accumulate_across_many_chunks() {
1690        let mut total_tokens = 0u32;
1691        let mut acc = StreamToolCallAccumulator::new();
1692        let mut finish_reason: Option<String> = None;
1693
1694        // Open the call (id + name, empty initial arguments).
1695        process_stream_choice(
1696            &choice(
1697                r#"{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"write_file","arguments":""}}]},"finish_reason":null}"#,
1698            ),
1699            &mut total_tokens,
1700            &mut acc,
1701            &mut finish_reason,
1702        );
1703
1704        let payload = r#"{"path":"a.rs","contents":"a fairly long contents value streamed one character at a time to exceed one hundred chunks","n":987654321}"#;
1705        assert!(payload.chars().count() > 100);
1706
1707        // Stream the arguments one character per chunk.
1708        let mut expected = String::new();
1709        for ch in payload.chars() {
1710            let frag = ch.to_string();
1711            let chunk = json!({
1712                "delta": {"tool_calls": [{"index": 0, "function": {"arguments": frag}}]},
1713                "finish_reason": null
1714            })
1715            .to_string();
1716            process_stream_choice(
1717                &choice(&chunk),
1718                &mut total_tokens,
1719                &mut acc,
1720                &mut finish_reason,
1721            );
1722            expected.push_str(&frag);
1723        }
1724
1725        // Mid-stream the shared accumulator holds the fragments as a raw string
1726        // (parsed once at finalize); its own unit tests cover that internal, so
1727        // here we assert the observable finish-chunk result concatenates exactly.
1728
1729        // Finish chunk: parsed exactly once into the structured value.
1730        let e = process_stream_choice(
1731            &choice(r#"{"delta":{},"finish_reason":"tool_calls"}"#),
1732            &mut total_tokens,
1733            &mut acc,
1734            &mut finish_reason,
1735        );
1736        match e {
1737            LlmStreamEvent::ToolCalls(calls) => {
1738                assert_eq!(calls.len(), 1);
1739                assert_eq!(calls[0].id, "call_1");
1740                assert_eq!(
1741                    calls[0].arguments,
1742                    serde_json::from_str::<serde_json::Value>(payload).unwrap()
1743                );
1744            }
1745            other => panic!("expected ToolCalls, got {:?}", other),
1746        }
1747    }
1748
1749    /// OpenAI's native path sends `delta: {}` (no content key) in the finish
1750    /// chunk; the existing behavior of emitting tool calls there is preserved.
1751    #[test]
1752    fn test_finish_chunk_without_content_emits_tool_calls() {
1753        let mut total_tokens = 0u32;
1754        let mut acc = StreamToolCallAccumulator::new();
1755        let mut finish_reason: Option<String> = None;
1756
1757        process_stream_choice(
1758            &choice(
1759                r#"{"delta":{"tool_calls":[{"index":0,"id":"call_9","function":{"name":"list_dir","arguments":"{}"}}]},"finish_reason":null}"#,
1760            ),
1761            &mut total_tokens,
1762            &mut acc,
1763            &mut finish_reason,
1764        );
1765
1766        let e = process_stream_choice(
1767            &choice(r#"{"delta":{},"finish_reason":"tool_calls"}"#),
1768            &mut total_tokens,
1769            &mut acc,
1770            &mut finish_reason,
1771        );
1772        match e {
1773            LlmStreamEvent::ToolCalls(calls) => {
1774                assert_eq!(calls.len(), 1);
1775                assert_eq!(calls[0].name, "list_dir");
1776            }
1777            other => panic!("expected ToolCalls, got {:?}", other),
1778        }
1779    }
1780
1781    /// Seed a single tool-call slot into an accumulator the way the streamed
1782    /// chunks would (id + name + raw argument buffer), so the fallback-flush
1783    /// tests exercise the real accumulation path.
1784    fn seeded_acc(id: &str, name: &str, arguments: &str) -> StreamToolCallAccumulator {
1785        let mut acc = StreamToolCallAccumulator::new();
1786        acc.apply_indexed_delta(0, Some(id), Some(name), Some(arguments));
1787        acc
1788    }
1789
1790    /// The [DONE] fallback flushes accumulated-but-unemitted tool calls when no
1791    /// finish reason was reported and drains the accumulator; once drained it
1792    /// returns None.
1793    #[test]
1794    fn test_take_pending_tool_calls_flushes_then_drains_without_finish_reason() {
1795        let mut acc = seeded_acc("call_1", "read_file", r#"{"path":"Cargo.toml"}"#);
1796
1797        match take_pending_tool_calls(&mut acc, None) {
1798            Some(LlmStreamEvent::ToolCalls(calls)) => {
1799                assert_eq!(calls.len(), 1);
1800                assert_eq!(calls[0].name, "read_file");
1801                assert_eq!(calls[0].arguments, json!({"path": "Cargo.toml"}));
1802            }
1803            other => panic!("expected ToolCalls, got {:?}", other),
1804        }
1805        assert!(acc.is_empty(), "accumulator must be drained after flush");
1806        assert!(take_pending_tool_calls(&mut acc, None).is_none());
1807    }
1808
1809    #[test]
1810    fn test_take_pending_tool_calls_discards_non_tool_finish_reason() {
1811        let mut acc = seeded_acc("call_cut", "read_file", r#"{"path":"#);
1812
1813        assert!(take_pending_tool_calls(&mut acc, Some("length")).is_none());
1814        assert!(
1815            acc.is_empty(),
1816            "discarded unsafe fallback calls must still drain the accumulator"
1817        );
1818    }
1819
1820    #[test]
1821    fn test_take_pending_tool_calls_rejects_malformed_fallback_arguments() {
1822        let mut acc = seeded_acc("call_cut", "read_file", r#"{"path":"#);
1823
1824        assert!(take_pending_tool_calls(&mut acc, None).is_none());
1825        assert!(
1826            acc.is_empty(),
1827            "malformed fallback calls must be drained instead of re-emitted"
1828        );
1829    }
1830
1831    #[test]
1832    fn test_non_tool_finish_reason_leaves_pending_calls_for_done_discard() {
1833        let mut total_tokens = 0u32;
1834        let mut acc = StreamToolCallAccumulator::new();
1835        let mut finish_reason: Option<String> = None;
1836
1837        process_stream_choice(
1838            &choice(
1839                r#"{"delta":{"tool_calls":[{"index":0,"id":"call_cut","function":{"name":"read_file","arguments":"{\"path\":"}}]},"finish_reason":null}"#,
1840            ),
1841            &mut total_tokens,
1842            &mut acc,
1843            &mut finish_reason,
1844        );
1845
1846        let e = process_stream_choice(
1847            &choice(r#"{"delta":{},"finish_reason":"length"}"#),
1848            &mut total_tokens,
1849            &mut acc,
1850            &mut finish_reason,
1851        );
1852
1853        assert!(matches!(e, LlmStreamEvent::TextDelta(s) if s.is_empty()));
1854        assert_eq!(finish_reason.as_deref(), Some("length"));
1855        assert!(take_pending_tool_calls(&mut acc, finish_reason.as_deref()).is_none());
1856        assert!(acc.is_empty());
1857    }
1858
1859    #[test]
1860    fn drop_orphaned_tool_messages_removes_unmatched_tool_results() {
1861        use crate::driver_registry::LlmMessageContent;
1862
1863        let messages = vec![
1864            LlmMessage::text(LlmMessageRole::User, "hello"),
1865            LlmMessage {
1866                role: LlmMessageRole::Tool,
1867                content: LlmMessageContent::Text("result".to_string()),
1868                tool_calls: None,
1869                tool_call_id: Some("call_trimmed".to_string()),
1870                phase: None,
1871                thinking: None,
1872                thinking_signature: None,
1873            },
1874        ];
1875        let filtered = drop_orphaned_tool_messages(&messages);
1876        assert_eq!(filtered.len(), 1);
1877        assert_eq!(filtered[0].role, LlmMessageRole::User);
1878    }
1879
1880    #[test]
1881    fn drop_orphaned_tool_messages_keeps_matched_tool_results() {
1882        use crate::driver_registry::LlmMessageContent;
1883        use crate::tool_types::ToolCall;
1884
1885        let messages = vec![
1886            LlmMessage {
1887                role: LlmMessageRole::Assistant,
1888                content: LlmMessageContent::Text(String::new()),
1889                tool_calls: Some(vec![ToolCall {
1890                    id: "call_1".to_string(),
1891                    name: "read_file".to_string(),
1892                    arguments: json!({}),
1893                }]),
1894                tool_call_id: None,
1895                phase: None,
1896                thinking: None,
1897                thinking_signature: None,
1898            },
1899            LlmMessage {
1900                role: LlmMessageRole::Tool,
1901                content: LlmMessageContent::Text("file content".to_string()),
1902                tool_calls: None,
1903                tool_call_id: Some("call_1".to_string()),
1904                phase: None,
1905                thinking: None,
1906                thinking_signature: None,
1907            },
1908        ];
1909        let filtered = drop_orphaned_tool_messages(&messages);
1910        assert_eq!(filtered.len(), 2);
1911    }
1912}