Skip to main content

oxicode_ai/providers/
openai.rs

1//! OpenAI-compatible provider implementation
2
3use bytes::Bytes;
4use futures::{Stream, StreamExt};
5use reqwest::Client;
6use serde::Deserialize;
7use serde_json::Value as JsonValue;
8use serde_json::json;
9use std::future::Future;
10use std::pin::Pin;
11use std::sync::Arc;
12
13use super::openai_responses_shared::parse_streaming_json;
14use super::shared_client;
15use super::sse::split_complete_lines;
16use crate::{
17    Api, AssistantMessage, ContentBlock, Context, Message, MessageContent, Model, Provider,
18    ProviderEvent, StopReason, StreamOptions, StreamResult, TextContent, ThinkingContent, ToolCall,
19    ToolResultMessage, Usage, error::ProviderError,
20};
21
22/// Detect whether a model targets the ZAI provider.
23fn is_zai(model: &Model) -> bool {
24    model.provider.eq_ignore_ascii_case("zai") || model.base_url.contains("api.z.ai")
25}
26
27/// OpenAI-compatible provider
28#[derive(Clone)]
29pub struct OpenAiProvider {
30    client: &'static Client,
31    api_key: Option<String>,
32    base_url: Option<String>,
33    /// Extra HTTP headers to include in every request (e.g. OpenRouter Referer).
34    extra_headers: Vec<(String, String)>,
35}
36
37impl OpenAiProvider {
38    /// Create a new OpenAI provider without an API key.
39    ///
40    /// API keys are resolved at request time via auth.json or StreamOptions.
41    /// Use `with_api_key()` for explicit key injection.
42    pub fn new() -> Self {
43        Self {
44            client: shared_client(),
45            api_key: None,
46            base_url: None,
47            extra_headers: Vec::new(),
48        }
49    }
50
51    /// Create with explicit API key (public API for external consumers)
52    pub fn with_api_key(api_key: impl Into<String>) -> Self {
53        Self {
54            client: shared_client(),
55            api_key: Some(api_key.into()),
56            base_url: None,
57            extra_headers: Vec::new(),
58        }
59    }
60
61    /// Create with a custom base URL (API key resolved from auth storage).
62    ///
63    /// Used for built-in OpenAI-compatible providers like Groq, Cerebras, etc.
64    pub fn with_base_url(base_url: &str) -> Self {
65        Self {
66            client: shared_client(),
67            api_key: None,
68            base_url: Some(base_url.to_string()),
69            extra_headers: Vec::new(),
70        }
71    }
72
73    /// Create with a custom base URL and optional API key.
74    ///
75    /// Used for registering custom OpenAI-compatible providers (Minimax, ZAI, etc.).
76    pub fn with_base_url_and_key(base_url: &str, api_key: Option<String>) -> Self {
77        Self {
78            client: shared_client(),
79            api_key,
80            base_url: Some(base_url.to_string()),
81            extra_headers: Vec::new(),
82        }
83    }
84
85    /// Create with a custom base URL, optional API key, and extra headers.
86    ///
87    /// Used for providers that require specific HTTP headers (OpenRouter, etc.).
88    pub fn with_config(
89        base_url: &str,
90        api_key: Option<String>,
91        extra_headers: Vec<(String, String)>,
92    ) -> Self {
93        Self {
94            client: shared_client(),
95            api_key,
96            base_url: Some(base_url.to_string()),
97            extra_headers,
98        }
99    }
100}
101
102impl Default for OpenAiProvider {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108impl Provider for OpenAiProvider {
109    fn stream<'a>(
110        &'a self,
111        model: &'a Model,
112        context: &'a Context,
113        options: Option<StreamOptions>,
114    ) -> Pin<Box<dyn Future<Output = StreamResult> + Send + 'a>> {
115        Box::pin(async move {
116            let options = options.unwrap_or_default();
117
118            // Build the request
119            let effective_base_url = self.base_url.as_deref().unwrap_or(&model.base_url);
120            let url = format!("{}/chat/completions", effective_base_url);
121
122            // Get API key
123            let api_key = options
124                .api_key
125                .as_ref()
126                .or(self.api_key.as_ref())
127                .ok_or_else(|| ProviderError::MissingApiKey)?;
128
129            // Build messages (apply provider-specific normalization)
130            let normalized = normalize_messages(&context.messages, &model.provider, &model.id);
131            let messages = build_messages_from_normalized(&context.system_prompt, &normalized)?;
132
133            // Build request body
134            let mut body = serde_json::json!({
135                "model": model.id,
136                "messages": messages,
137                "stream": true,
138                "stream_options": { "include_usage": true },
139            });
140
141            // Add optional parameters
142            if let Some(temp) = options.temperature {
143                body["temperature"] = serde_json::json!(temp);
144            }
145
146            if let Some(max) = options.max_tokens {
147                // Use max_completion_tokens for OpenAI (newer API field).
148                // Some providers still use max_tokens; we keep both for compat.
149                body["max_completion_tokens"] = serde_json::json!(max);
150                body["max_tokens"] = serde_json::json!(max);
151            }
152
153            // Add tools if present
154            if !context.tools.is_empty() {
155                body["tools"] = build_tools(&context.tools)?;
156            }
157
158            // Force the tool choice when a Named choice is set (and tools exist).
159            if let Some(choice) = build_tool_choice(options.tool_choice.as_ref()) {
160                body["tool_choice"] = choice;
161            }
162
163            // ── Reasoning effort (o1/o3/o4 models) ──────────────────────────
164            // When thinking_level is set and the model supports reasoning,
165            // include `reasoning_effort` in the request body.
166            // Also checks provider_options.openai for fine-grained control.
167            if model.reasoning {
168                let openai_opts = options
169                    .provider_options
170                    .as_ref()
171                    .and_then(|po| po.openai.as_ref());
172
173                let effort = openai_opts
174                    .and_then(|o| o.reasoning_effort.clone())
175                    .or_else(|| {
176                        options
177                            .thinking_level
178                            .as_ref()
179                            .and_then(|l| l.as_str().map(String::from))
180                    });
181
182                if let Some(effort_str) = effort {
183                    body["reasoning_effort"] = serde_json::json!(effort_str);
184                }
185            }
186
187            // ── ZAI-specific parameters ──────────────────────────────────
188            // Mirror pi's detectCompat: when provider is ZAI (or base_url contains
189            // api.z.ai), send enable_thinking and tool_stream.
190            if is_zai(model) {
191                if model.reasoning {
192                    body["enable_thinking"] = serde_json::json!(true);
193                }
194                if !context.tools.is_empty() {
195                    body["tool_stream"] = serde_json::json!(true);
196                }
197            }
198
199            tracing::info!(
200                "Sending request to {} model={} body_len={} enable_thinking={} tool_stream={}",
201                url,
202                model.id,
203                body.to_string().len(),
204                body.get("enable_thinking").is_some(),
205                body.get("tool_stream").is_some()
206            );
207            tracing::debug!("Request body: {}", body.to_string());
208
209            // Build headers
210            let mut headers = reqwest::header::HeaderMap::new();
211            headers.insert(
212                reqwest::header::AUTHORIZATION,
213                format!("Bearer {}", api_key).parse().map_err(|e| {
214                    ProviderError::InvalidResponse(format!("invalid bearer header: {e}"))
215                })?,
216            );
217            headers.insert(
218                reqwest::header::CONTENT_TYPE,
219                "application/json".parse().map_err(|e| {
220                    ProviderError::InvalidResponse(format!("invalid header value: {e}"))
221                })?,
222            );
223
224            // Provider-level default headers (e.g. OpenRouter HTTP-Referer)
225            for (k, v) in &self.extra_headers {
226                if let (Ok(name), Ok(value)) = (
227                    k.parse::<reqwest::header::HeaderName>(),
228                    v.parse::<reqwest::header::HeaderValue>(),
229                ) {
230                    headers.insert(name, value);
231                }
232            }
233
234            // Per-request headers (from StreamOptions)
235            for (k, v) in &options.headers {
236                if let (Ok(name), Ok(value)) = (
237                    k.parse::<reqwest::header::HeaderName>(),
238                    v.parse::<reqwest::header::HeaderValue>(),
239                ) {
240                    headers.insert(name, value);
241                }
242            }
243
244            // Make request
245            let response = self
246                .client
247                .post(&url)
248                .headers(headers)
249                .json(&body)
250                .send()
251                .await
252                .map_err(ProviderError::RequestFailed)?;
253
254            if !response.status().is_success() {
255                let status = response.status();
256                let body: String = response.text().await.unwrap_or_default();
257                return Err(ProviderError::HttpError(
258                    crate::error::HttpErrorDetail::new(status.as_u16(), body),
259                ));
260            }
261
262            // Create event stream
263            let provider_name = model.provider.clone();
264            let model_id = model.id.clone();
265
266            // Emit Start event once at the beginning of the stream (matches pi's behavior)
267            let start_event = ProviderEvent::Start {
268                partial: Arc::new(AssistantMessage::new(
269                    Api::OpenAiCompletions,
270                    &provider_name,
271                    &model_id,
272                )),
273            };
274
275            // Stateful stream parser that accumulates tool calls across chunks.
276            // OpenAI sends tool calls as multiple deltas (id, name, arguments fragments)
277            // that must be reassembled before emitting ToolCallEnd.
278            //
279            // State:
280            //   pending_bytes     – incomplete UTF-8 bytes from the previous HTTP chunk
281            //   pending_tc_index  – accumulated tool calls keyed by streaming index
282            //   pending_tc_id     – secondary lookup by tool-call ID (ZAI et al. may
283            //                       omit the index on continuation deltas)
284            //   thinking_started  – whether ThinkingStart has been emitted
285            let stream = response
286                .bytes_stream()
287                .scan(
288                    (
289                        Vec::new(),
290                        std::collections::HashMap::<usize, (String, String, String)>::new(),
291                        std::collections::HashMap::<String, usize>::new(), // id → index
292                        false,
293                        AssistantMessage::new(Api::OpenAiCompletions, &provider_name, &model_id),
294                    ),
295                    move |(
296                        pending_bytes,
297                        pending_tc,
298                        tc_id_to_index,
299                        thinking_started,
300                        accumulated_output,
301                    ),
302                          chunk: Result<Bytes, reqwest::Error>| {
303                        let events = match chunk {
304                            Ok(bytes) => {
305                                // Prepend any incomplete bytes from previous chunk
306                                let mut combined =
307                                    Vec::with_capacity(pending_bytes.len() + bytes.len());
308                                combined.extend_from_slice(pending_bytes);
309                                combined.extend_from_slice(&bytes);
310
311                                // Split into complete lines (ending with \n) and trailing incomplete data.
312                                // This prevents JSON parse failures from partial SSE lines
313                                // that were split across HTTP chunks.
314                                let (text, trailing) = split_complete_lines(&combined);
315                                *pending_bytes = trailing;
316
317                                tracing::debug!(
318                                    "parse_sse_events input: {} bytes, {} lines",
319                                    text.len(),
320                                    text.lines().count()
321                                );
322                                let raw_events = parse_sse_events(
323                                    &text,
324                                    &provider_name,
325                                    &model_id,
326                                    accumulated_output,
327                                );
328                                tracing::debug!("parse_sse_events output: {} events", raw_events.len());
329
330                                // Post-process: accumulate tool call deltas, inject ThinkingStart once
331                                let mut processed = Vec::new();
332                                for event in raw_events {
333                                    match &event {
334                                        ProviderEvent::ThinkingDelta { content_index, .. } => {
335                                            // Inject ThinkingStart before the first ThinkingDelta
336                                            if !*thinking_started {
337                                                *thinking_started = true;
338                                                processed.push(ProviderEvent::ThinkingStart {
339                                                    content_index: *content_index,
340                                                    partial: Arc::new(AssistantMessage::new(
341                                                        Api::OpenAiCompletions,
342                                                        &provider_name,
343                                                        &model_id,
344                                                    )),
345                                                });
346                                            }
347                                            processed.push(event);
348                                        }
349                                        ProviderEvent::ToolCallStart {
350                                            content_index,
351                                            tool_call_id,
352                                            tool_name,
353                                            ..
354                                        } => {
355                                            let entry =
356                                                pending_tc.entry(*content_index).or_insert_with(|| {
357                                                    (String::new(), String::new(), String::new())
358                                                });
359                                            if let Some(id) = tool_call_id
360                                                && !id.is_empty()
361                                            {
362                                                entry.0 = id.clone();
363                                                tc_id_to_index.insert(id.clone(), *content_index);
364                                            }
365                                            if let Some(name) = tool_name
366                                                && !name.is_empty()
367                                            {
368                                                entry.1 = name.clone();
369                                            }
370                                            processed.push(event);
371                                        }
372                                        ProviderEvent::ToolCallDelta {
373                                            content_index,
374                                            delta,
375                                            ..
376                                        } => {
377                                            // Dual-map lookup: prefer index, fall back to ID
378                                            let idx = if pending_tc.contains_key(content_index) {
379                                                *content_index
380                                            } else {
381                                                // Scan id→index map for a match
382                                                tc_id_to_index
383                                                    .values()
384                                                    .copied()
385                                                    .find(|i| *i == *content_index)
386                                                    .unwrap_or(*content_index)
387                                            };
388                                            let entry = pending_tc.entry(idx).or_insert_with(|| {
389                                                (String::new(), String::new(), String::new())
390                                            });
391                                            tracing::debug!(
392                                                "[TC-DELTA] idx={}, delta_len={}, accumulated_len={}",
393                                                idx,
394                                                delta.len(),
395                                                entry.2.len() + delta.len()
396                                            );
397                                            entry.2.push_str(delta);
398                                            processed.push(event);
399                                        }
400                                        ProviderEvent::ToolCallEnd { .. } => {
401                                            // Already a ToolCallEnd from parse_sse_events
402                                            processed.push(event);
403                                        }
404                                        ProviderEvent::Done { reason, .. } => {
405                                            // Before Done, emit ToolCallEnd for all accumulated tool calls
406                                            if matches!(reason, StopReason::ToolUse) {
407                                                let mut indices: Vec<usize> =
408                                                    pending_tc.keys().copied().collect();
409                                                indices.sort();
410                                                for idx in indices {
411                                                    let (id, name, arguments) = &pending_tc[&idx];
412                                                    tracing::debug!(
413                                                        "[TC-END] idx={}, id={}, name={}, args_len={}",
414                                                        idx,
415                                                        id.len(),
416                                                        name.len(),
417                                                        arguments.len()
418                                                    );
419                                                    let args_value = parse_streaming_json(arguments);
420                                                    processed.push(ProviderEvent::ToolCallEnd {
421                                                        content_index: idx,
422                                                        tool_call: crate::ToolCall {
423                                                            content_type:
424                                                                crate::messages::ToolCallType::ToolCall,
425                                                            id: id.clone(),
426                                                            name: name.clone(),
427                                                            arguments: args_value,
428                                                            thought_signature: None,
429                                                        },
430                                                        partial: Arc::new(AssistantMessage::new(
431                                                            Api::OpenAiCompletions,
432                                                            &provider_name,
433                                                            &model_id,
434                                                        )),
435                                                    });
436                                                }
437                                            }
438                                            // Clear pending_tc for the next stream/turn.
439                                            // Without this, tool call arguments from the previous
440                                            // turn leak into the next turn's accumulation.
441                                            pending_tc.clear();
442                                            tc_id_to_index.clear();
443                                            processed.push(event);
444                                        }
445                                        _ => {
446                                            processed.push(event);
447                                        }
448                                    }
449                                }
450                                processed
451                            }
452                            Err(e) => {
453                                vec![ProviderEvent::Error {
454                                    reason: StopReason::Error,
455                                    error: create_error_message(
456                                        &e.to_string(),
457                                        &provider_name,
458                                        &model_id,
459                                    ),
460                                }]
461                            }
462                        };
463                        // Return Some to continue, wrap events in an iterator
464                        async move { Some(futures::stream::iter(events)) }
465                    },
466                )
467                .flatten();
468
469            // Prepend Start event to the stream
470            let stream_with_start = futures::stream::once(async move { start_event }).chain(stream);
471            Ok(Box::pin(stream_with_start) as Pin<Box<dyn Stream<Item = ProviderEvent> + Send>>)
472        })
473    }
474}
475
476/// Build messages array from normalized Message structs (without Context dependency).
477///
478/// This function is called after `normalize_messages` has already applied
479/// provider-specific transforms. It converts oxicode Message types to the
480/// JSON format required by the OpenAI Chat API.
481fn build_messages_from_normalized(
482    system_prompt: &Option<String>,
483    messages: &[Message],
484) -> Result<Vec<JsonValue>, ProviderError> {
485    let mut result = Vec::new();
486
487    // System prompt
488    if let Some(prompt) = system_prompt {
489        result.push(serde_json::json!({
490            "role": "system",
491            "content": prompt,
492        }));
493    }
494
495    // Conversation messages
496    for msg in messages {
497        match msg {
498            Message::User(u) => {
499                let content: String = match &u.content {
500                    MessageContent::Text(s) => s.clone(),
501                    MessageContent::Blocks(blocks) => blocks_to_content(blocks)?.to_string(),
502                };
503                result.push(serde_json::json!({
504                    "role": "user",
505                    "content": content,
506                }));
507            }
508            Message::Assistant(a) => {
509                // OpenAI format: separate content (text) and tool_calls
510                let mut text_parts = Vec::new();
511                let mut tool_calls = Vec::new();
512                for block in &a.content {
513                    match block {
514                        ContentBlock::Text(t) => {
515                            text_parts.push(t.text.clone());
516                        }
517                        ContentBlock::Thinking(_) => {
518                            // Skip thinking blocks in message history
519                        }
520                        ContentBlock::ToolCall(tc) => {
521                            tool_calls.push(serde_json::json!({
522                                "id": tc.id,
523                                "type": "function",
524                                "function": {
525                                    "name": tc.name,
526                                    "arguments": tc.arguments.to_string(),
527                                },
528                            }));
529                        }
530                        ContentBlock::Image(_) | ContentBlock::Unknown(_) => {}
531                    }
532                }
533
534                let mut msg_obj = serde_json::json!({
535                    "role": "assistant",
536                    "content": text_parts.join(""),
537                });
538                if !tool_calls.is_empty() {
539                    msg_obj["tool_calls"] = serde_json::json!(tool_calls);
540                }
541                result.push(msg_obj);
542            }
543            Message::ToolResult(t) => {
544                let result_text: String = t
545                    .content
546                    .iter()
547                    .filter_map(|b| b.as_text())
548                    .collect::<Vec<_>>()
549                    .join("");
550                result.push(serde_json::json!({
551                    "role": "tool",
552                    "tool_call_id": t.tool_call_id,
553                    "content": result_text,
554                }));
555            }
556        }
557    }
558
559    Ok(result)
560}
561
562/// Convert content blocks to a string representation
563fn blocks_to_content(blocks: &[ContentBlock]) -> Result<JsonValue, ProviderError> {
564    if blocks.len() == 1
565        && let Some(text) = blocks[0].as_text()
566    {
567        return Ok(JsonValue::String(text.to_string()));
568    }
569
570    let items: Result<Vec<_>, _> = blocks
571        .iter()
572        .map(|block| match block {
573            ContentBlock::Text(t) => Ok(serde_json::json!({
574                "type": "text",
575                "text": t.text,
576            })),
577            ContentBlock::ToolCall(tc) => Ok(serde_json::json!({
578                "type": "function",
579                "id": tc.id,
580                "function": {
581                    "name": tc.name,
582                    "arguments": tc.arguments.to_string(),
583                },
584            })),
585            ContentBlock::Thinking(th) => Ok(serde_json::json!({
586                "type": "thinking",
587                "thinking": th.thinking,
588            })),
589            ContentBlock::Image(img) => Ok(serde_json::json!({
590                "type": "image_url",
591                "image_url": {
592                    "url": format!("data:{};base64,{}", img.mime_type, img.data),
593                },
594            })),
595            ContentBlock::Unknown(_) => Err(ProviderError::InvalidResponse(
596                "Unknown content block type".into(),
597            )),
598        })
599        .collect();
600
601    Ok(serde_json::json!(items?))
602}
603
604/// Map a `ToolChoice` to OpenAI's forced-tool-choice shape (Chat Completions).
605fn build_tool_choice(tool_choice: Option<&crate::tools::ToolChoice>) -> Option<JsonValue> {
606    match tool_choice {
607        None | Some(crate::tools::ToolChoice::Auto) => None,
608        Some(crate::tools::ToolChoice::Named(name)) => {
609            Some(json!({"type": "function", "function": {"name": name}}))
610        }
611    }
612}
613
614/// Build tools array
615fn build_tools(tools: &[crate::Tool]) -> Result<JsonValue, ProviderError> {
616    let items: Vec<_> = tools
617        .iter()
618        .map(|tool| {
619            serde_json::json!({
620                "type": "function",
621                "function": {
622                    "name": tool.name,
623                    "description": tool.description,
624                    "parameters": tool.parameters,
625                },
626            })
627        })
628        .collect();
629
630    Ok(serde_json::json!(items))
631}
632
633/// Parse SSE event stream from a byte buffer.
634///
635/// Optimizations over a naïve implementation:
636/// - **Fast-line splitting** – iterates over `\n` boundaries via `split`
637///   instead of allocating an intermediate `String` per line.
638/// - **Early `DONE` exit** – breaks immediately when `data: [DONE]` is
639///   encountered.
640/// - **Pre-allocated events** – reserves capacity based on data-line count.
641/// - **Accumulated usage** – tracks usage separately, only cloning into
642///   the Done message at stream end, not on every chunk.
643fn parse_sse_events(
644    text: &str,
645    _provider: &str,
646    _model_id: &str,
647    output: &mut AssistantMessage,
648) -> Vec<ProviderEvent> {
649    // F-6 (audit 2026-06-21): replace the 2-pass scan (count + parse) with a
650    // single length-based estimate. Most SSE `data:` lines are 60–120 bytes,
651    // so `text.len() / 80` is a tighter upper bound than the old
652    // `count(filter("data: "))` pass and avoids scanning the chunk twice.
653    // For very small chunks the estimate is 0 and `Vec::with_capacity(0)`
654    // is a no-op. For 64KB chunks this saves one full scan (≈64µs per
655    // chunk on a 2024-era CPU at ~1GB/s scan).
656    let mut events = Vec::with_capacity(text.len() / 80);
657
658    let mut accumulated_usage = Usage::default();
659
660    for line in text.split('\n') {
661        let line = line.trim_end_matches('\r');
662        if line.is_empty() {
663            continue;
664        }
665
666        // Fast rejection for non-data lines (comments, event tags, etc.)
667        if !line.starts_with("data: ") {
668            continue;
669        }
670
671        let data = &line[6..]; // skip "data: "
672
673        // Early exit on stream end
674        if data == "[DONE]" {
675            break;
676        }
677
678        if data.is_empty() {
679            continue;
680        }
681
682        let chunk = match serde_json::from_str::<SSEChunk>(data) {
683            Ok(c) => c,
684            Err(_) => continue,
685        };
686
687        // ── Accumulate usage BEFORE processing choices ────────────────
688        // OpenAI with include_usage sends usage in a final chunk with
689        // empty choices. By accumulating before the choice loop, the
690        // Done event (triggered by finish_reason in an earlier chunk)
691        // and any subsequent rendering sees the latest usage.
692        if let Some(chunk_usage) = &chunk.usage {
693            accumulated_usage.input = chunk_usage.prompt_tokens.max(accumulated_usage.input);
694            accumulated_usage.output = chunk_usage.completion_tokens.max(accumulated_usage.output);
695            accumulated_usage.cache_read = chunk_usage
696                .prompt_tokens_details
697                .as_ref()
698                .map(|d| d.cached_tokens)
699                .unwrap_or(0)
700                .max(accumulated_usage.cache_read);
701            accumulated_usage.total_tokens =
702                chunk_usage.total_tokens.max(accumulated_usage.total_tokens);
703        }
704
705        for choice in &chunk.choices {
706            if let Some(delta) = &choice.delta {
707                if let Some(content) = &delta.content {
708                    // pi-mono: append to the output's text block
709                    let last_text_idx = output
710                        .content
711                        .iter()
712                        .rposition(|b| matches!(b, ContentBlock::Text(_)));
713                    if let Some(idx) = last_text_idx
714                        && let ContentBlock::Text(t) = &mut output.content[idx]
715                    {
716                        t.text.push_str(content);
717                    } else {
718                        output
719                            .content
720                            .push(ContentBlock::Text(TextContent::new(content.clone())));
721                    }
722                    events.push(ProviderEvent::TextDelta {
723                        content_index: choice.index,
724                        delta: content.clone(),
725                        partial: Arc::new(output.clone()),
726                    });
727                }
728
729                // Handle GLM's reasoning_content field (thinking/thought chain)
730                if let Some(ref reasoning) = delta.reasoning_content
731                    && !reasoning.is_empty()
732                {
733                    // pi-mono: append to the output's thinking block
734                    let last_think_idx = output
735                        .content
736                        .iter()
737                        .rposition(|b| matches!(b, ContentBlock::Thinking(_)));
738                    if let Some(idx) = last_think_idx
739                        && let ContentBlock::Thinking(t) = &mut output.content[idx]
740                    {
741                        t.thinking.push_str(reasoning);
742                    } else {
743                        output
744                            .content
745                            .push(ContentBlock::Thinking(ThinkingContent::new(
746                                reasoning.clone(),
747                            )));
748                    }
749                    events.push(ProviderEvent::ThinkingDelta {
750                        content_index: choice.index,
751                        delta: reasoning.clone(),
752                        partial: Arc::new(output.clone()),
753                    });
754                }
755
756                if let Some(tool_calls) = &delta.tool_calls {
757                    for tc in tool_calls {
758                        let tc_index = tc.index.unwrap_or(choice.index);
759
760                        // Emit ToolCallStart when id or name is present (first delta)
761                        if tc.id.is_some()
762                            || tc.function.as_ref().and_then(|f| f.name.as_ref()).is_some()
763                        {
764                            events.push(ProviderEvent::ToolCallStart {
765                                content_index: tc_index,
766                                tool_call_id: tc.id.clone(),
767                                tool_name: tc.function.as_ref().and_then(|f| f.name.clone()),
768                                partial: Arc::new(output.clone()),
769                            });
770                        }
771
772                        // Emit ToolCallDelta for arguments
773                        if let Some(func) = &tc.function {
774                            events.push(ProviderEvent::ToolCallDelta {
775                                content_index: tc_index,
776                                delta: func.arguments.clone().unwrap_or_default(),
777                                partial: Arc::new(output.clone()),
778                            });
779                        }
780                    }
781                }
782            }
783
784            if choice.finish_reason.is_some() {
785                let reason = match choice.finish_reason.as_deref() {
786                    Some("stop") | Some("end") => StopReason::Stop,
787                    Some("length") => StopReason::Length,
788                    Some("tool_calls") | Some("function_call") => StopReason::ToolUse,
789                    Some("content_filter") => StopReason::Error,
790                    Some(unknown) => {
791                        tracing::warn!("Unknown finish_reason: '{}', treating as Error", unknown);
792                        StopReason::Error
793                    }
794                    None => StopReason::Stop,
795                };
796                tracing::info!("finish_reason={:?} → {:?}", choice.finish_reason, reason);
797
798                let mut done_msg = output.clone();
799                done_msg.stop_reason = reason;
800                done_msg.usage = accumulated_usage.clone();
801                events.push(ProviderEvent::Done {
802                    reason,
803                    message: done_msg,
804                });
805            }
806        }
807    }
808
809    events
810}
811
812/// Create error assistant message
813fn create_error_message(msg: &str, provider: &str, model_id: &str) -> AssistantMessage {
814    let mut message = AssistantMessage::new(Api::OpenAiCompletions, provider, model_id);
815    message.stop_reason = StopReason::Error;
816    message.error_message = Some(msg.to_string());
817    message
818}
819
820// SSE chunk structure
821#[derive(Debug, Deserialize)]
822// serde deserialization structs
823struct SSEChunk {
824    _id: Option<String>,
825    #[serde(rename = "model")]
826    _model: Option<String>,
827    choices: Vec<Choice>,
828    usage: Option<UsageInfo>,
829}
830
831#[derive(Debug, Deserialize)]
832// serde deserialization structs
833struct Choice {
834    index: usize,
835    delta: Option<Delta>,
836    finish_reason: Option<String>,
837}
838
839#[derive(Debug, Deserialize)]
840struct Delta {
841    content: Option<String>,
842    reasoning_content: Option<String>,
843    tool_calls: Option<Vec<ToolCallDelta>>,
844}
845
846#[derive(Debug, Deserialize)]
847// serde deserialization structs
848struct ToolCallDelta {
849    index: Option<usize>,
850    id: Option<String>,
851    #[serde(rename = "type")]
852    _type_: Option<String>,
853    function: Option<FunctionDelta>,
854}
855
856#[derive(Debug, Deserialize)]
857// serde deserialization structs
858struct FunctionDelta {
859    name: Option<String>,
860    arguments: Option<String>,
861}
862
863#[derive(Debug, Deserialize, Clone)]
864struct UsageInfo {
865    prompt_tokens: usize,
866    completion_tokens: usize,
867    total_tokens: usize,
868    #[serde(rename = "prompt_tokens_details")]
869    prompt_tokens_details: Option<PromptTokensDetails>,
870}
871
872#[derive(Debug, Deserialize, Clone)]
873struct PromptTokensDetails {
874    #[serde(rename = "cached_tokens")]
875    cached_tokens: usize,
876}
877
878// ============================================================================
879// Provider-agnostic Message Normalization
880//
881// Mirrors opencode's transform.ts normalizeMessages() — these transforms
882// sanitize message content before sending to the API:
883//   - Empty content filtering (Anthropic rejects empty messages/parts)
884//   - Tool ID scrubbing (Mistral requires 9-char, Claude needs alphanum)
885//   - DeepSeek reasoning injection (empty reasoning parts required)
886//   - Anthropic tool-use ordering (tool_use must not precede non-tool content)
887// ============================================================================
888
889/// Normalize messages for a specific provider.
890///
891/// This applies provider-specific transforms that the API requires:
892///   - Anthropic: filter empty text/reasoning parts, add cache_control
893///   - Claude (via Anthropic): scrub tool IDs to alphanumeric + underscore
894///   - Mistral: truncate tool IDs to 9 alphanumeric chars
895///   - DeepSeek: inject empty reasoning parts into assistant messages
896///   - Anthropic/Vertex: reorder tool_use before text parts
897pub fn normalize_messages(messages: &[Message], provider: &str, model_id: &str) -> Vec<Message> {
898    let provider_lower = provider.to_lowercase();
899    let model_lower = model_id.to_lowercase();
900
901    let is_anthropic = provider_lower == "anthropic"
902        || provider_lower.contains("vertex-anthropic")
903        || provider_lower.contains("google-vertex") && model_lower.contains("claude");
904
905    let is_mistral = provider_lower == "mistral"
906        || model_lower.contains("mistral")
907        || model_lower.contains("devstral");
908
909    let is_deepseek = model_lower.contains("deepseek");
910
911    let is_claude = model_lower.contains("claude");
912
913    let needs_tool_id_scrub = is_mistral || is_claude;
914
915    let messages: Vec<Message> = messages.to_vec();
916
917    // 1. DeepSeek: inject empty reasoning parts into assistant messages
918    let messages = if is_deepseek {
919        messages
920            .iter()
921            .map(|msg| match msg {
922                Message::Assistant(a) => {
923                    let has_reasoning = a
924                        .content
925                        .iter()
926                        .any(|b| matches!(b, ContentBlock::Thinking(_)));
927                    if has_reasoning {
928                        Message::Assistant(a.clone())
929                    } else {
930                        let mut new_content = a.content.clone();
931                        new_content
932                            .push(ContentBlock::Thinking(ThinkingContent::new(String::new())));
933                        let mut new_msg = AssistantMessage::new(a.api, &a.provider, &a.model);
934                        new_msg.content = new_content;
935                        new_msg.usage = a.usage.clone();
936                        new_msg.stop_reason = a.stop_reason;
937                        new_msg.response_id = a.response_id.clone();
938                        new_msg.timestamp = a.timestamp;
939                        Message::Assistant(new_msg)
940                    }
941                }
942                _ => msg.clone(),
943            })
944            .collect()
945    } else {
946        messages
947    };
948
949    // 2. Anthropic: filter empty text/reasoning parts, remove empty messages
950    let messages = if is_anthropic {
951        messages
952            .iter()
953            .filter_map(|msg| match msg {
954                Message::User(u) => {
955                    let filtered = filter_empty_content(&u.content);
956                    filtered.as_ref()?;
957                    // SAFETY: the `as_ref()?` above already returned `None` for
958                    // empty content, so `filtered` is `Some` here. Infallible by
959                    // construction — the early return is the proof.
960                    #[allow(clippy::expect_used)]
961                    Some(Message::User(crate::UserMessage {
962                        role: u.role,
963                        content: filtered.expect("checked above"),
964                        timestamp: u.timestamp,
965                        visible: true,
966                    }))
967                }
968                Message::Assistant(a) => {
969                    let filtered: Vec<ContentBlock> = a
970                        .content
971                        .iter()
972                        .filter(|b| !is_empty_content_block(b))
973                        .cloned()
974                        .collect();
975                    if filtered.is_empty() {
976                        return None; // Entire message is empty
977                    }
978                    let mut new_msg = AssistantMessage::new(a.api, &a.provider, &a.model);
979                    new_msg.content = filtered;
980                    new_msg.usage = a.usage.clone();
981                    new_msg.stop_reason = a.stop_reason;
982                    new_msg.response_id = a.response_id.clone();
983                    new_msg.timestamp = a.timestamp;
984                    Some(Message::Assistant(new_msg))
985                }
986                Message::ToolResult(t) => Some(Message::ToolResult(t.clone())),
987            })
988            .collect()
989    } else {
990        messages
991    };
992
993    // 3. Anthropic/Vertex: reorder tool_use before non-tool content
994    let messages = if is_anthropic {
995        messages
996            .iter()
997            .flat_map(|msg| match msg {
998                Message::Assistant(a) => {
999                    let parts = &a.content;
1000                    let has_tool = parts.iter().any(|b| matches!(b, ContentBlock::ToolCall(_)));
1001                    let has_non_tool = parts
1002                        .iter()
1003                        .any(|b| !matches!(b, ContentBlock::ToolCall(_)));
1004
1005                    if has_tool && has_non_tool {
1006                        // Split into [non-tool parts] + [tool parts]
1007                        let non_tools: Vec<ContentBlock> = parts
1008                            .iter()
1009                            .filter(|b| !matches!(b, ContentBlock::ToolCall(_)))
1010                            .cloned()
1011                            .collect();
1012                        let tools: Vec<ContentBlock> = parts
1013                            .iter()
1014                            .filter(|b| matches!(b, ContentBlock::ToolCall(_)))
1015                            .cloned()
1016                            .collect();
1017
1018                        let mut msg1 = AssistantMessage::new(a.api, &a.provider, &a.model);
1019                        msg1.content = non_tools;
1020                        msg1.usage = a.usage.clone();
1021                        msg1.stop_reason = a.stop_reason;
1022                        msg1.response_id = a.response_id.clone();
1023                        msg1.timestamp = a.timestamp;
1024
1025                        let mut msg2 = AssistantMessage::new(a.api, &a.provider, &a.model);
1026                        msg2.content = tools;
1027                        msg2.usage = a.usage.clone();
1028                        msg2.stop_reason = a.stop_reason;
1029                        msg2.response_id = a.response_id.clone();
1030                        msg2.timestamp = a.timestamp;
1031
1032                        vec![Message::Assistant(msg1), Message::Assistant(msg2)]
1033                    } else {
1034                        vec![Message::Assistant(a.clone())]
1035                    }
1036                }
1037                _ => vec![msg.clone()],
1038            })
1039            .collect()
1040    } else {
1041        messages
1042    };
1043
1044    // 4. Tool ID scrubbing (Mistral: 9 chars, Claude: alphanumeric + underscore)
1045
1046    if needs_tool_id_scrub {
1047        messages
1048            .iter()
1049            .map(|msg| match msg {
1050                Message::Assistant(a) => {
1051                    let new_content: Vec<ContentBlock> = a
1052                        .content
1053                        .iter()
1054                        .map(|block| match block {
1055                            ContentBlock::ToolCall(tc) => ContentBlock::ToolCall(ToolCall::new(
1056                                scrub_tool_id(&tc.id, is_mistral, is_anthropic),
1057                                tc.name.clone(),
1058                                tc.arguments.clone(),
1059                            )),
1060                            _ => block.clone(),
1061                        })
1062                        .collect();
1063                    let mut new_msg = AssistantMessage::new(a.api, &a.provider, &a.model);
1064                    new_msg.content = new_content;
1065                    new_msg.usage = a.usage.clone();
1066                    new_msg.stop_reason = a.stop_reason;
1067                    new_msg.response_id = a.response_id.clone();
1068                    new_msg.timestamp = a.timestamp;
1069                    Message::Assistant(new_msg)
1070                }
1071                Message::ToolResult(t) => Message::ToolResult(ToolResultMessage::new(
1072                    scrub_tool_id(&t.tool_call_id, is_mistral, is_anthropic),
1073                    &t.tool_name,
1074                    t.content.clone(),
1075                )),
1076                _ => msg.clone(),
1077            })
1078            .collect()
1079    } else {
1080        messages
1081    }
1082}
1083
1084/// Check if a content block is effectively empty.
1085fn is_empty_content_block(block: &ContentBlock) -> bool {
1086    match block {
1087        ContentBlock::Text(t) => t.text.trim().is_empty(),
1088        ContentBlock::Thinking(t) => t.thinking.trim().is_empty(),
1089        _ => false,
1090    }
1091}
1092
1093/// Filter empty parts from MessageContent.
1094/// Returns None if the entire content becomes empty.
1095fn filter_empty_content(content: &MessageContent) -> Option<MessageContent> {
1096    match content {
1097        MessageContent::Text(s) => {
1098            if s.trim().is_empty() {
1099                None
1100            } else {
1101                Some(MessageContent::Text(s.clone()))
1102            }
1103        }
1104        MessageContent::Blocks(blocks) => {
1105            let filtered: Vec<ContentBlock> = blocks
1106                .iter()
1107                .filter(|b| !is_empty_content_block(b))
1108                .cloned()
1109                .collect();
1110            if filtered.is_empty() {
1111                None
1112            } else {
1113                Some(MessageContent::Blocks(filtered))
1114            }
1115        }
1116    }
1117}
1118
1119/// Scrub a tool call ID for provider compatibility.
1120/// Mistral: keep first 9 alphanumeric chars, pad with zeros
1121/// Claude: keep only alphanumeric + underscore
1122fn scrub_tool_id(id: &str, is_mistral: bool, is_anthropic: bool) -> String {
1123    if is_mistral {
1124        let alphanumeric: String = id.chars().filter(|c| c.is_alphanumeric()).take(9).collect();
1125        if alphanumeric.len() < 9 {
1126            format!("{}{}", alphanumeric, "0".repeat(9 - alphanumeric.len()))
1127        } else {
1128            alphanumeric
1129        }
1130    } else if is_anthropic {
1131        // Anthropic requires tool call IDs matching [a-zA-Z0-9_-]{1,64}.
1132        // pi: `id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64)`
1133        id.chars()
1134            .map(|c| {
1135                if c.is_alphanumeric() || c == '_' || c == '-' {
1136                    c
1137                } else {
1138                    '_'
1139                }
1140            })
1141            .take(64)
1142            .collect()
1143    } else {
1144        id.chars()
1145            .filter(|c| c.is_alphanumeric() || *c == '_')
1146            .collect()
1147    }
1148}
1149
1150#[cfg(test)]
1151mod tests {
1152    use super::*;
1153
1154    #[test]
1155    fn build_tool_choice_maps_named_to_function_shape() {
1156        assert!(build_tool_choice(None).is_none());
1157        assert!(build_tool_choice(Some(&crate::tools::ToolChoice::Auto)).is_none());
1158        assert_eq!(
1159            build_tool_choice(Some(&crate::tools::ToolChoice::Named("todo".into()))),
1160            Some(serde_json::json!({"type": "function", "function": {"name": "todo"}}))
1161        );
1162    }
1163
1164    const PROVIDER: &str = "openai";
1165    const MODEL: &str = "gpt-4o";
1166
1167    fn parse_sse(sse: &str) -> Vec<ProviderEvent> {
1168        let mut output = AssistantMessage::new(Api::OpenAiCompletions, PROVIDER, MODEL);
1169        parse_sse_events(sse, PROVIDER, MODEL, &mut output)
1170    }
1171
1172    // ── SSE event parsing ──────────────────────────────────────────────
1173
1174    #[test]
1175    fn parse_single_text_event() {
1176        let sse = "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"}}]}\n\n";
1177        let events = parse_sse(sse);
1178        assert_eq!(events.len(), 1);
1179        match &events[0] {
1180            ProviderEvent::TextDelta {
1181                delta,
1182                content_index,
1183                ..
1184            } => {
1185                assert_eq!(delta, "Hello");
1186                assert_eq!(*content_index, 0);
1187            }
1188            other => panic!("expected TextDelta, got {other:?}"),
1189        }
1190    }
1191
1192    #[test]
1193    fn parse_multiple_text_events() {
1194        let sse = concat!(
1195            "data: {\"id\":\"c\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hel\"}}]}\n",
1196            "\n",
1197            "data: {\"id\":\"c\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"lo!\"}}]}\n",
1198            "\n"
1199        );
1200        let events = parse_sse(sse);
1201        assert_eq!(events.len(), 2);
1202        let texts: Vec<&str> = events
1203            .iter()
1204            .filter_map(|e| match e {
1205                ProviderEvent::TextDelta { delta, .. } => Some(delta.as_str()),
1206                _ => None,
1207            })
1208            .collect();
1209        assert_eq!(texts, vec!["Hel", "lo!"]);
1210    }
1211
1212    #[test]
1213    fn parse_done_terminator() {
1214        let sse = concat!(
1215            "data: {\"id\":\"c\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"X\"}}]}\n",
1216            "\n",
1217            "data: [DONE]\n",
1218            "\n",
1219            "data: {\"id\":\"c\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"NEVER\"}}]}\n"
1220        );
1221        let events = parse_sse(sse);
1222        // Should stop at [DONE]; the final data line is never parsed
1223        assert_eq!(events.len(), 1);
1224        match &events[0] {
1225            ProviderEvent::TextDelta { delta, .. } => assert_eq!(delta, "X"),
1226            other => panic!("expected TextDelta, got {other:?}"),
1227        }
1228    }
1229
1230    // ── Content extraction ─────────────────────────────────────────────
1231
1232    #[test]
1233    fn parse_finish_reason_stop() {
1234        let sse = "data: {\"id\":\"c\",\"choices\":[{\"index\":0,\"delta\":null,\"finish_reason\":\"stop\"}]}\n\n";
1235        let events = parse_sse(sse);
1236        assert_eq!(events.len(), 1);
1237        match &events[0] {
1238            ProviderEvent::Done { reason, .. } => assert!(matches!(reason, StopReason::Stop)),
1239            other => panic!("expected Done, got {other:?}"),
1240        }
1241    }
1242
1243    #[test]
1244    fn parse_finish_reason_length() {
1245        let sse = "data: {\"id\":\"c\",\"choices\":[{\"index\":0,\"delta\":null,\"finish_reason\":\"length\"}]}\n\n";
1246        let events = parse_sse(sse);
1247        match &events[0] {
1248            ProviderEvent::Done { reason, .. } => assert!(matches!(reason, StopReason::Length)),
1249            other => panic!("expected Done with Length, got {other:?}"),
1250        }
1251    }
1252
1253    #[test]
1254    fn parse_finish_reason_tool_calls() {
1255        let sse = "data: {\"id\":\"c\",\"choices\":[{\"index\":0,\"delta\":null,\"finish_reason\":\"tool_calls\"}]}\n\n";
1256        let events = parse_sse(sse);
1257        match &events[0] {
1258            ProviderEvent::Done { reason, .. } => assert!(matches!(reason, StopReason::ToolUse)),
1259            other => panic!("expected Done with ToolUse, got {other:?}"),
1260        }
1261    }
1262
1263    // ── Tool call delta accumulation ───────────────────────────────────
1264
1265    #[test]
1266    fn parse_tool_call_deltas() {
1267        let sse = concat!(
1268            "data: {\"id\":\"c\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]}}]}\n",
1269            "\n",
1270            "data: {\"id\":\"c\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"city\\\":\\\"SF\\\"}\"}}]}}]}\n",
1271            "\n"
1272        );
1273        let events = parse_sse(sse);
1274        // First chunk: ToolCallStart (id+name present) + ToolCallDelta (function present)
1275        // Second chunk: ToolCallDelta only
1276        assert_eq!(events.len(), 3);
1277        let starts: Vec<&str> = events
1278            .iter()
1279            .filter_map(|e| match e {
1280                ProviderEvent::ToolCallStart { tool_name, .. } => tool_name.as_deref(),
1281                _ => None,
1282            })
1283            .collect();
1284        assert_eq!(starts, vec!["get_weather"]);
1285        let deltas: Vec<&str> = events
1286            .iter()
1287            .filter_map(|e| match e {
1288                ProviderEvent::ToolCallDelta { delta, .. } => Some(delta.as_str()),
1289                _ => None,
1290            })
1291            .collect();
1292        assert_eq!(deltas, vec!["", "{\"city\":\"SF\"}"]);
1293    }
1294
1295    #[test]
1296    fn parse_tool_call_with_no_arguments_field() {
1297        // function field present but arguments is null → emits ToolCallStart + ToolCallDelta
1298        let sse = "data: {\"id\":\"c\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"run\"}}]}}]}\n\n";
1299        let events = parse_sse(sse);
1300        assert_eq!(events.len(), 2);
1301        match &events[0] {
1302            ProviderEvent::ToolCallStart { tool_name, .. } => {
1303                assert_eq!(tool_name.as_deref(), Some("run"));
1304            }
1305            other => panic!("expected ToolCallStart, got {other:?}"),
1306        }
1307        match &events[1] {
1308            ProviderEvent::ToolCallDelta { delta, .. } => assert_eq!(delta, ""),
1309            other => panic!("expected ToolCallDelta, got {other:?}"),
1310        }
1311    }
1312
1313    // ── Usage accumulation ─────────────────────────────────────────────
1314
1315    #[test]
1316    fn parse_usage_in_chunk() {
1317        // Usage is accumulated from earlier chunks; the Done event captures
1318        // usage that was accumulated *before* the finish_reason chunk.
1319        let sse = concat!(
1320            "data: {\"id\":\"c\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":8,\"total_tokens\":18,\"prompt_tokens_details\":{\"cached_tokens\":3}}}\n",
1321            "\n",
1322            "data: {\"id\":\"c\",\"choices\":[{\"index\":0,\"delta\":null,\"finish_reason\":\"stop\"}]}\n"
1323        );
1324        let events = parse_sse(sse);
1325        // TextDelta + Done
1326        assert_eq!(events.len(), 2);
1327        match &events[1] {
1328            ProviderEvent::Done { message, .. } => {
1329                assert_eq!(message.usage.input, 10);
1330                assert_eq!(message.usage.output, 8);
1331                assert_eq!(message.usage.total_tokens, 18);
1332                assert_eq!(message.usage.cache_read, 3);
1333            }
1334            other => panic!("expected Done, got {other:?}"),
1335        }
1336    }
1337
1338    #[test]
1339    fn parse_usage_without_cache_details() {
1340        // Usage from an earlier chunk; Done event on a separate chunk without usage.
1341        let sse = concat!(
1342            "data: {\"id\":\"c\",\"choices\":[],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}\n",
1343            "\n",
1344            "data: {\"id\":\"c\",\"choices\":[{\"index\":0,\"delta\":null,\"finish_reason\":\"stop\"}]}\n"
1345        );
1346        let events = parse_sse(sse);
1347        match &events[0] {
1348            ProviderEvent::Done { message, .. } => {
1349                assert_eq!(message.usage.input, 5);
1350                assert_eq!(message.usage.output, 2);
1351                assert_eq!(message.usage.cache_read, 0);
1352            }
1353            other => panic!("expected Done, got {other:?}"),
1354        }
1355    }
1356
1357    // ── Empty / malformed handling ─────────────────────────────────────
1358
1359    #[test]
1360    fn parse_empty_input() {
1361        let events = parse_sse("");
1362        assert!(events.is_empty());
1363    }
1364
1365    #[test]
1366    fn parse_only_empty_lines() {
1367        let events = parse_sse("\n\n\n");
1368        assert!(events.is_empty());
1369    }
1370
1371    #[test]
1372    fn parse_malformed_json_after_data() {
1373        let sse = "data: {not json at all}\ndata: also bad\ndata: {\"id\":\"c\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"}}]}\n";
1374        let events = parse_sse(sse);
1375        // Malformed lines are skipped, only the valid one emits
1376        assert_eq!(events.len(), 1);
1377        match &events[0] {
1378            ProviderEvent::TextDelta { delta, .. } => assert_eq!(delta, "ok"),
1379            other => panic!("expected TextDelta, got {other:?}"),
1380        }
1381    }
1382
1383    #[test]
1384    fn parse_empty_data_line() {
1385        let sse = "data: \ndata: {\"id\":\"c\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"X\"}}]}\n";
1386        let events = parse_sse(sse);
1387        assert_eq!(events.len(), 1);
1388    }
1389
1390    #[test]
1391    fn parse_non_data_lines_ignored() {
1392        let sse = "event: ping\nid: 42\nretry: 5000\ndata: {\"id\":\"c\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Y\"}}]}\n";
1393        let events = parse_sse(sse);
1394        assert_eq!(events.len(), 1);
1395    }
1396
1397    #[test]
1398    fn parse_carriage_return_line_endings() {
1399        let sse = "data: {\"id\":\"c\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"CR\"}}]}\r\n\r\n";
1400        let events = parse_sse(sse);
1401        assert_eq!(events.len(), 1);
1402        match &events[0] {
1403            ProviderEvent::TextDelta { delta, .. } => assert_eq!(delta, "CR"),
1404            other => panic!("expected TextDelta, got {other:?}"),
1405        }
1406    }
1407
1408    // ── Mixed content + tool + done ────────────────────────────────────
1409
1410    #[test]
1411    fn parse_full_stream_with_text_tool_and_done() {
1412        let sse = concat!(
1413            "data: {\"id\":\"c\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Let me\"}}]}\n",
1414            "\n",
1415            "data: {\"id\":\"c\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" check\"}}]}\n",
1416            "\n",
1417            "data: {\"id\":\"c\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"search\",\"arguments\":\"{\\\"q\\\":\\\"rust\\\"}\"}}]}}]}\n",
1418            "\n",
1419            "data: {\"id\":\"c\",\"choices\":[{\"index\":0,\"delta\":null,\"finish_reason\":\"tool_calls\"}]}\n",
1420            "\n",
1421            "data: [DONE]\n"
1422        );
1423        let events = parse_sse(sse);
1424        assert_eq!(events.len(), 5); // 2 TextDelta + ToolCallStart + ToolCallDelta + Done
1425
1426        let mut text_count = 0;
1427        let mut tc_start_count = 0;
1428        let mut tc_delta_count = 0;
1429        let mut done_count = 0;
1430        for e in &events {
1431            match e {
1432                ProviderEvent::TextDelta { .. } => text_count += 1,
1433                ProviderEvent::ToolCallStart { .. } => tc_start_count += 1,
1434                ProviderEvent::ToolCallDelta { .. } => tc_delta_count += 1,
1435                ProviderEvent::Done { reason, .. } => {
1436                    done_count += 1;
1437                    assert!(matches!(reason, StopReason::ToolUse));
1438                }
1439                other => panic!("unexpected event: {other:?}"),
1440            }
1441        }
1442        assert_eq!(text_count, 2);
1443        assert_eq!(tc_start_count, 1);
1444        assert_eq!(tc_delta_count, 1);
1445        assert_eq!(done_count, 1);
1446    }
1447}