Skip to main content

recursive/llm/
openai.rs

1//! OpenAI-compatible chat-completions adapter.
2//!
3//! Targets the `/chat/completions` shape that OpenAI, Azure (via gateway),
4//! GLM (Zhipu), DeepSeek, Moonshot, Together, Ollama and many others speak.
5//! The only thing that varies is the base URL + model name + API key, which
6//! is all driven by config.
7//!
8//! ## Deferred tool loading (software-layer ToolSearch)
9//!
10//! This provider implements the same deferred-tool pattern as `AnthropicProvider`
11//! but in pure software — no API-level `defer_loading` or `tool_reference` needed.
12//! On each `complete_with_search` / `stream_with_search` call:
13//!
14//! 1. Only eager tools + `ToolSearchTool` are sent in the initial request.
15//! 2. If the model calls `ToolSearchTool`, the query is resolved against the
16//!    deferred list, the matched schemas are returned as plain JSON in a
17//!    `tool_result` message, and a new request is sent with the matched tools
18//!    appended to the eager list.
19//! 3. Capped at `max_search_rounds` to prevent infinite loops.
20
21use std::collections::HashMap;
22use std::sync::Arc;
23use std::time::Duration;
24
25use async_trait::async_trait;
26use futures_util::StreamExt;
27use reqwest::Client;
28use serde::{Deserialize, Serialize};
29use serde_json::Value;
30
31use super::search::{KeywordSearchEngine, SpecWithHint, ToolSearchEngine};
32use super::StructuredRequest;
33use super::{
34    ChatProvider, Completion, RetryPolicy, StreamChunk, StreamSender, TokenUsage, ToolCall,
35    ToolSpec,
36};
37use crate::error::{Error, Result};
38use crate::message::{Message, Role};
39
40const TOOL_SEARCH_TOOL_NAME: &str = "ToolSearchTool";
41
42#[derive(Debug, Clone)]
43pub struct OpenAiProvider {
44    base_url: String,
45    api_key: String,
46    model: String,
47    client: Client,
48    temperature: f64,
49    max_tokens: u32,
50    retry: RetryPolicy,
51    stream_tx: Option<StreamSender>,
52    /// Algorithm used to resolve a `ToolSearchTool` query into a list of
53    /// deferred tool names. Defaults to `KeywordSearchEngine`.
54    search_engine: Arc<dyn ToolSearchEngine>,
55    /// Maximum number of ToolSearchTool round-trips per
56    /// `complete_with_search` / `stream_with_search` call.
57    max_search_rounds: usize,
58}
59
60impl OpenAiProvider {
61    pub fn new(
62        base_url: impl Into<String>,
63        api_key: impl Into<String>,
64        model: impl Into<String>,
65    ) -> crate::error::Result<Self> {
66        let client = Client::builder()
67            .timeout(Duration::from_secs(180))
68            .build()
69            .map_err(|e| crate::error::Error::Config {
70                message: format!("failed to build HTTP client: {e}"),
71            })?;
72        Ok(Self {
73            base_url: base_url.into().trim_end_matches('/').to_string(),
74            api_key: api_key.into(),
75            model: model.into(),
76            client,
77            temperature: 0.2,
78            // Per-response output token cap. Seed from the crate default
79            // (DEFAULT_MAX_TOKENS = 64K); callers override via
80            // `with_max_tokens`, which `Config::from_env` drives from
81            // `RECURSIVE_MAX_TOKENS` (env > provider ModelSpec.max_tokens
82            // > agent.max_tokens > this default). The historical 16384
83            // default + the comment below ("DeepSeek ceiling 8192 for v3")
84            // were stale: DeepSeek V4 supports up to 384K output tokens
85            // (flash == pro), and 16384 truncated reasoning-heavy turns
86            // mid-generation (`provider_stop:length` with empty content).
87            max_tokens: crate::llm::DEFAULT_MAX_TOKENS,
88            retry: RetryPolicy::default(),
89            stream_tx: None,
90            search_engine: Arc::new(KeywordSearchEngine::new()),
91            max_search_rounds: 3,
92        })
93    }
94
95    /// Replace the search engine used to resolve `ToolSearchTool` queries.
96    pub fn with_search_engine(mut self, engine: Arc<dyn ToolSearchEngine>) -> Self {
97        self.search_engine = engine;
98        self
99    }
100
101    /// Set the maximum number of ToolSearchTool round-trips per
102    /// `complete_with_search` / `stream_with_search` call.
103    pub fn with_max_search_rounds(mut self, n: usize) -> Self {
104        self.max_search_rounds = n;
105        self
106    }
107
108    /// Enable streaming by providing a channel sender for partial tokens.
109    pub fn with_stream_tx(mut self, tx: StreamSender) -> Self {
110        self.stream_tx = Some(tx);
111        self
112    }
113
114    /// Build an `Error::Llm` with the model name prefixed.
115    fn make_err(&self, ctx: impl Into<String>) -> Error {
116        Error::Llm {
117            provider: self.model.clone(),
118            message: ctx.into(),
119        }
120    }
121
122    pub fn with_temperature(mut self, t: f64) -> Self {
123        self.temperature = t;
124        self
125    }
126
127    pub fn with_max_tokens(mut self, n: u32) -> Self {
128        self.max_tokens = n;
129        self
130    }
131
132    pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
133        self.retry = policy;
134        self
135    }
136
137    /// POST `body` to `url` with retry, returning the raw response text on success.
138    ///
139    /// Handles: network errors, 5xx transient errors, and HTTP 200 with empty body
140    /// (MiniMax transient failure). All three are retried with exponential back-off
141    /// according to `self.retry`. Non-transient 4xx errors are returned immediately.
142    async fn post_json_with_retry(&self, url: &str, body: &Value, label: &str) -> Result<String> {
143        let mut attempt = 0;
144        loop {
145            tracing::debug!(target: "recursive::llm", request = %body, "POST {url} ({label})");
146            let result = self
147                .client
148                .post(url)
149                .bearer_auth(&self.api_key)
150                .json(body)
151                .send()
152                .await;
153
154            match result {
155                Ok(resp) => {
156                    let status = resp.status();
157                    if status.is_success() {
158                        let text = resp.text().await?;
159                        if text.trim().is_empty() {
160                            if let Some(backoff) = self.retry.backoff_for(attempt, None, true) {
161                                tracing::warn!(
162                                    target: "recursive::llm",
163                                    attempt,
164                                    backoff_ms = backoff.as_millis(),
165                                    "HTTP 200 but empty body ({label}), retrying"
166                                );
167                                tokio::time::sleep(backoff).await;
168                                attempt += 1;
169                                continue;
170                            }
171                            return Err(self.make_err("HTTP 200 but response body is empty"));
172                        }
173                        return Ok(text);
174                    }
175
176                    let text = resp.text().await?;
177                    tracing::debug!(target: "recursive::llm", body = %text, "error response ({label})");
178
179                    if let Some(backoff) =
180                        self.retry
181                            .backoff_for(attempt, Some(status.as_u16()), false)
182                    {
183                        tracing::warn!(
184                            target: "recursive::llm",
185                            attempt,
186                            backoff_ms = backoff.as_millis(),
187                            status = status.as_u16(),
188                            "transient HTTP error, retrying ({label})"
189                        );
190                        tokio::time::sleep(backoff).await;
191                        attempt += 1;
192                        continue;
193                    }
194
195                    return Err(self.make_err(format!("HTTP {status}: {text}")));
196                }
197                Err(e) => {
198                    if let Some(backoff) = self.retry.backoff_for(attempt, None, true) {
199                        tracing::warn!(
200                            target: "recursive::llm",
201                            attempt,
202                            backoff_ms = backoff.as_millis(),
203                            error = %e,
204                            "network error, retrying ({label})"
205                        );
206                        tokio::time::sleep(backoff).await;
207                        attempt += 1;
208                        continue;
209                    }
210                    return Err(self.make_err(format!("request failed: {e}")));
211                }
212            }
213        }
214    }
215}
216
217#[async_trait]
218impl ChatProvider for OpenAiProvider {
219    #[tracing::instrument(skip(self, messages, tools), fields(
220        provider = %self.base_url.split('/').next_back().unwrap_or("unknown"),
221        model = %self.model
222    ))]
223    async fn complete(&self, messages: &[Message], tools: &[ToolSpec]) -> Result<Completion> {
224        let body = build_request(
225            &self.model,
226            self.temperature,
227            self.max_tokens,
228            messages,
229            tools,
230        );
231        let url = format!("{}/chat/completions", self.base_url);
232        let text = self.post_json_with_retry(&url, &body, "complete").await?;
233        let parsed: ChatResponse = serde_json::from_str(&text)
234            .map_err(|e| self.make_err(format!("failed to parse response: {e}; body: {text}")))?;
235        let choice = parsed
236            .choices
237            .into_iter()
238            .next()
239            .ok_or_else(|| self.make_err("response had no choices"))?;
240        Ok(parse_completion(choice, parsed.usage))
241    }
242
243    async fn complete_structured(&self, req: StructuredRequest) -> Result<Value> {
244        let mut body = build_request(
245            &self.model,
246            self.temperature,
247            self.max_tokens,
248            &req.messages,
249            &[],
250        );
251        body["response_format"] = serde_json::json!({
252            "type": "json_schema",
253            "json_schema": {
254                "name": req.schema_name,
255                "strict": true,
256                "schema": req.schema,
257            }
258        });
259        let url = format!("{}/chat/completions", self.base_url);
260        let text = self.post_json_with_retry(&url, &body, "structured").await?;
261        let parsed: ChatResponse = serde_json::from_str(&text)
262            .map_err(|e| self.make_err(format!("failed to parse response: {e}; body: {text}")))?;
263        let choice = parsed
264            .choices
265            .into_iter()
266            .next()
267            .ok_or_else(|| self.make_err("response had no choices"))?;
268        let completion = parse_completion(choice, parsed.usage);
269        if completion.content.trim().is_empty() {
270            return Err(self.make_err("structured response had empty content"));
271        }
272        serde_json::from_str(&completion.content).map_err(|e| {
273            self.make_err(format!(
274                "failed to parse structured response as JSON: {e}; content: {}",
275                completion.content
276            ))
277        })
278    }
279
280    async fn complete_with_search(
281        &self,
282        messages: &[Message],
283        eager_tools: &[SpecWithHint],
284        deferred_tools: &[SpecWithHint],
285    ) -> Result<Completion> {
286        if deferred_tools.is_empty() {
287            let specs: Vec<ToolSpec> = eager_tools.iter().map(|(s, _)| s.clone()).collect();
288            return self.complete(messages, &specs).await;
289        }
290        self.run_search_loop(messages, eager_tools, deferred_tools, &[], 0)
291            .await
292    }
293
294    async fn stream_with_search(
295        &self,
296        messages: &[Message],
297        eager_tools: &[SpecWithHint],
298        deferred_tools: &[SpecWithHint],
299        stream_tx: Option<StreamSender>,
300        cancel_token: Option<tokio_util::sync::CancellationToken>,
301    ) -> Result<Completion> {
302        if deferred_tools.is_empty() {
303            let specs: Vec<ToolSpec> = eager_tools.iter().map(|(s, _)| s.clone()).collect();
304            let tx = stream_tx.or_else(|| self.stream_tx.clone());
305            return self.stream_inner(messages, &specs, tx, cancel_token).await;
306        }
307        // run_stream_search_loop 内部递归调 stream(),cancel_token 自然向下传
308        self.run_stream_search_loop(
309            messages,
310            eager_tools,
311            deferred_tools,
312            &[],
313            0,
314            stream_tx,
315            cancel_token,
316        )
317        .await
318    }
319
320    async fn stream(
321        &self,
322        messages: &[Message],
323        tools: &[ToolSpec],
324        stream_tx: Option<StreamSender>,
325        cancel_token: Option<tokio_util::sync::CancellationToken>,
326    ) -> Result<Completion> {
327        // If no stream_tx provided, fall back to the instance-level one
328        let tx = stream_tx.or_else(|| self.stream_tx.clone());
329        self.stream_inner(messages, tools, tx, cancel_token).await
330    }
331}
332
333impl OpenAiProvider {
334    fn tool_search_spec() -> SpecWithHint {
335        let spec = ToolSpec {
336            name: TOOL_SEARCH_TOOL_NAME.to_string(),
337            description: "Fetches full schema definitions for deferred tools so they can be \
338                called. Until fetched, only the name is known — there is no parameter schema, so \
339                the tool cannot be invoked. Use `select:<tool_name>` for direct selection, or \
340                keywords to search."
341                .to_string(),
342            parameters: serde_json::json!({
343                "type": "object",
344                "properties": {
345                    "query": {
346                        "type": "string",
347                        "description": "Query to find deferred tools."
348                    },
349                    "max_results": {
350                        "type": "integer",
351                        "description": "Maximum number of results to return (default: 5)",
352                        "default": 5
353                    }
354                },
355                "required": ["query"]
356            }),
357        };
358        (spec, Some("find search discover tools lookup".to_string()))
359    }
360
361    /// Software-layer ToolSearch loop for `complete_with_search`.
362    ///
363    /// Sends only `eager_tools` + `ToolSearchTool` in round 0. If the model
364    /// calls `ToolSearchTool`, resolves the query, appends the matched schemas
365    /// as a plain JSON `tool_result`, and recurses with the matched tools
366    /// added to `loaded_tools` (which are appended to eager for the next call).
367    async fn run_search_loop(
368        &self,
369        messages: &[Message],
370        eager_tools: &[SpecWithHint],
371        deferred_tools: &[SpecWithHint],
372        loaded_tools: &[ToolSpec],
373        round: usize,
374    ) -> Result<Completion> {
375        // Build tool list: ToolSearchTool + caller eager + already-loaded deferred
376        let mut call_tools: Vec<ToolSpec> = Vec::new();
377        call_tools.push(Self::tool_search_spec().0);
378        call_tools.extend(eager_tools.iter().map(|(s, _)| s.clone()));
379        call_tools.extend_from_slice(loaded_tools);
380
381        let completion = self.complete(messages, &call_tools).await?;
382
383        let search_call = completion
384            .tool_calls
385            .iter()
386            .find(|c| c.name == TOOL_SEARCH_TOOL_NAME);
387
388        let search_call = match search_call {
389            None => return Ok(completion),
390            Some(c) => c.clone(),
391        };
392
393        if round >= self.max_search_rounds {
394            tracing::warn!(
395                target: "recursive::llm",
396                round,
397                max = self.max_search_rounds,
398                "ToolSearchTool (openai): hit max_search_rounds, returning current completion"
399            );
400            return Ok(completion);
401        }
402
403        let query = search_call
404            .arguments
405            .get("query")
406            .and_then(|v| v.as_str())
407            .unwrap_or("");
408        let names = self.search_engine.resolve(query, deferred_tools);
409        let matched: Vec<ToolSpec> = deferred_tools
410            .iter()
411            .filter(|(s, _)| names.contains(&s.name))
412            .map(|(s, _)| s.clone())
413            .collect();
414        let result_json = serde_json::to_string(&matched).unwrap_or_else(|_| "[]".into());
415
416        // Append the ToolSearch exchange to the transcript
417        let mut next_messages: Vec<Message> = messages.to_vec();
418        next_messages.push(Message {
419            role: Role::Assistant,
420            content: completion.content.clone(),
421            tool_calls: completion.tool_calls.clone(),
422            tool_call_id: None,
423            reasoning_content: completion.reasoning_content.clone(),
424            is_compaction_summary: false,
425        });
426        next_messages.push(Message {
427            role: Role::Tool,
428            content: result_json,
429            tool_calls: vec![],
430            tool_call_id: Some(search_call.id.clone()),
431            reasoning_content: None,
432            is_compaction_summary: false,
433        });
434
435        let mut next_loaded = loaded_tools.to_vec();
436        next_loaded.extend_from_slice(&matched);
437
438        Box::pin(self.run_search_loop(
439            &next_messages,
440            eager_tools,
441            deferred_tools,
442            &next_loaded,
443            round + 1,
444        ))
445        .await
446    }
447
448    /// Software-layer ToolSearch loop for `stream_with_search`.
449    #[allow(clippy::too_many_arguments)]
450    async fn run_stream_search_loop(
451        &self,
452        messages: &[Message],
453        eager_tools: &[SpecWithHint],
454        deferred_tools: &[SpecWithHint],
455        loaded_tools: &[ToolSpec],
456        round: usize,
457        stream_tx: Option<StreamSender>,
458        cancel_token: Option<tokio_util::sync::CancellationToken>,
459    ) -> Result<Completion> {
460        let mut call_tools: Vec<ToolSpec> = Vec::new();
461        call_tools.push(Self::tool_search_spec().0);
462        call_tools.extend(eager_tools.iter().map(|(s, _)| s.clone()));
463        call_tools.extend_from_slice(loaded_tools);
464
465        let tx = stream_tx.clone().or_else(|| self.stream_tx.clone());
466        let completion = self
467            .stream_inner(messages, &call_tools, tx, cancel_token.clone())
468            .await?;
469
470        let search_call = completion
471            .tool_calls
472            .iter()
473            .find(|c| c.name == TOOL_SEARCH_TOOL_NAME);
474
475        let search_call = match search_call {
476            None => return Ok(completion),
477            Some(c) => c.clone(),
478        };
479
480        if round >= self.max_search_rounds {
481            tracing::warn!(
482                target: "recursive::llm",
483                round,
484                max = self.max_search_rounds,
485                "ToolSearchTool (openai/stream): hit max_search_rounds"
486            );
487            return Ok(completion);
488        }
489
490        let query = search_call
491            .arguments
492            .get("query")
493            .and_then(|v| v.as_str())
494            .unwrap_or("");
495        let names = self.search_engine.resolve(query, deferred_tools);
496        let matched: Vec<ToolSpec> = deferred_tools
497            .iter()
498            .filter(|(s, _)| names.contains(&s.name))
499            .map(|(s, _)| s.clone())
500            .collect();
501        let result_json = serde_json::to_string(&matched).unwrap_or_else(|_| "[]".into());
502
503        let mut next_messages: Vec<Message> = messages.to_vec();
504        next_messages.push(Message {
505            role: Role::Assistant,
506            content: completion.content.clone(),
507            tool_calls: completion.tool_calls.clone(),
508            tool_call_id: None,
509            reasoning_content: completion.reasoning_content.clone(),
510            is_compaction_summary: false,
511        });
512        next_messages.push(Message {
513            role: Role::Tool,
514            content: result_json,
515            tool_calls: vec![],
516            tool_call_id: Some(search_call.id.clone()),
517            reasoning_content: None,
518            is_compaction_summary: false,
519        });
520
521        let mut next_loaded = loaded_tools.to_vec();
522        next_loaded.extend_from_slice(&matched);
523
524        Box::pin(self.run_stream_search_loop(
525            &next_messages,
526            eager_tools,
527            deferred_tools,
528            &next_loaded,
529            round + 1,
530            stream_tx,
531            cancel_token,
532        ))
533        .await
534    }
535
536    async fn stream_inner(
537        &self,
538        messages: &[Message],
539        tools: &[ToolSpec],
540        stream_tx: Option<StreamSender>,
541        cancel_token: Option<tokio_util::sync::CancellationToken>,
542    ) -> Result<Completion> {
543        let mut body = build_request(
544            &self.model,
545            self.temperature,
546            self.max_tokens,
547            messages,
548            tools,
549        );
550        body["stream"] = Value::Bool(true);
551        // Ask providers that gate usage emission behind a flag (MiniMax,
552        // OpenAI native) to include the final usage chunk. Providers that
553        // always emit usage (DeepSeek) ignore the field harmlessly. Without
554        // this the streaming path never sees `usage.prompt_tokens_details`
555        // and the TUI cache-rate badge stays invisible on MiniMax-M3.
556        body["stream_options"] = serde_json::json!({ "include_usage": true });
557        let url = format!("{}/chat/completions", self.base_url);
558
559        // Stream requests need the raw Response object for SSE parsing, so we
560        // can't use post_json_with_retry (which returns text). Retry only on
561        // non-2xx and network errors; a successful 2xx hands off to parse_sse_stream.
562        let mut attempt = 0;
563        loop {
564            tracing::debug!(target: "recursive::llm", request = %body, "POST {url} (stream)");
565            let result = self
566                .client
567                .post(&url)
568                .bearer_auth(&self.api_key)
569                .json(&body)
570                .send()
571                .await;
572
573            match result {
574                Ok(resp) => {
575                    let status = resp.status();
576                    if status.is_success() {
577                        return self
578                            .parse_sse_stream(resp, stream_tx.clone(), cancel_token.clone())
579                            .await;
580                    }
581                    let text = resp.text().await?;
582                    tracing::debug!(target: "recursive::llm", body = %text, "error response (stream)");
583                    if let Some(backoff) =
584                        self.retry
585                            .backoff_for(attempt, Some(status.as_u16()), false)
586                    {
587                        tracing::warn!(
588                            target: "recursive::llm",
589                            attempt,
590                            backoff_ms = backoff.as_millis(),
591                            status = status.as_u16(),
592                            "transient HTTP error, retrying (stream)"
593                        );
594                        tokio::time::sleep(backoff).await;
595                        attempt += 1;
596                        continue;
597                    }
598                    return Err(self.make_err(format!("HTTP {status}: {text}")));
599                }
600                Err(e) => {
601                    if let Some(backoff) = self.retry.backoff_for(attempt, None, true) {
602                        tracing::warn!(
603                            target: "recursive::llm",
604                            attempt,
605                            backoff_ms = backoff.as_millis(),
606                            error = %e,
607                            "network error, retrying (stream)"
608                        );
609                        tokio::time::sleep(backoff).await;
610                        attempt += 1;
611                        continue;
612                    }
613                    return Err(self.make_err(format!("request failed: {e}")));
614                }
615            }
616        }
617    }
618
619    /// Parse an SSE stream from a successful HTTP response.
620    ///
621    /// Reads `data: {...}\n\n` chunks line-by-line, extracts
622    /// `choices[0].delta.content` deltas, accumulates them, and emits
623    /// each delta through `stream_tx` if configured. Returns the final
624    /// `Completion` matching the non-streaming shape.
625    async fn parse_sse_stream(
626        &self,
627        resp: reqwest::Response,
628        stream_tx: Option<StreamSender>,
629        cancel_token: Option<tokio_util::sync::CancellationToken>,
630    ) -> Result<Completion> {
631        let mut content = String::new();
632        let mut reasoning_content = String::new();
633        // key: index → (id, name, accumulated_arguments)
634        let mut tool_call_builders: HashMap<usize, (String, String, String)> = HashMap::new();
635        let mut finish_reason: Option<String> = None;
636        let mut usage: Option<TokenUsage> = None;
637
638        // Process the byte stream incrementally line by line.
639        //
640        // We maintain a raw byte buffer (`incomplete`) to handle multi-byte
641        // UTF-8 sequences that may be split across HTTP chunk boundaries.
642        // String::from_utf8_lossy() would silently replace the incomplete
643        // tail bytes with U+FFFD, permanently corrupting non-ASCII content
644        // (e.g. Chinese/Japanese text in tool arguments or assistant output).
645        let mut byte_stream = resp.bytes_stream();
646        let mut incomplete: Vec<u8> = Vec::new();
647        let mut line_buf = String::new();
648
649        // 决策 4b:把 SSE 循环改成 tokio::select!,让 cancel_token 能在两个 chunk 之间立刻
650        // 触发。触发后函数返回 Err(Error::Cancelled);调用方 stream() 把 reqwest::Response
651        // drop(连接关闭);run_inner 的 dispatch_llm_step 调用点会把这个错误翻译成
652        // FinishReason::Cancelled(Invariant #7 不破)。
653        loop {
654            let chunk_result = if let Some(ct) = cancel_token.as_ref() {
655                tokio::select! {
656                    biased;
657                    _ = ct.cancelled() => {
658                        // Goal 382: a user-initiated cancel mid-stream should
659                        // still persist whatever content already arrived, so
660                        // the next "继续" turn doesn't see a gap where the
661                        // reply was. If anything accumulated, return it as a
662                        // partial Completion (finish_reason "interrupted"
663                        // sentinel); run_inner routes it to
664                        // FinishReason::Cancelled. With nothing accumulated
665                        // there is nothing to save — keep the original
666                        // Err(Error::Cancelled) so run_inner's Cancelled arm
667                        // handles it.
668                        let have_partial = !content.is_empty()
669                            || !reasoning_content.is_empty()
670                            || !tool_call_builders.is_empty();
671                        if have_partial {
672                            tracing::warn!(
673                                target: "recursive::llm",
674                                content_len = content.len(),
675                                "SSE stream cancelled; returning partial completion"
676                            );
677                            return Ok(Self::build_completion(
678                                content,
679                                reasoning_content,
680                                HashMap::new(), // drop half-received tool calls
681                                Some("interrupted".to_string()),
682                                usage.take(),
683                            ));
684                        }
685                        // reqwest::Response 在函数返回时被 drop,HTTP 连接关闭
686                        return Err(Error::Cancelled);
687                    }
688                    next = byte_stream.next() => next,
689                }
690            } else {
691                byte_stream.next().await
692            };
693            let chunk = match chunk_result {
694                Some(Ok(c)) => c,
695                Some(Err(e)) => {
696                    // Goal 382: a network drop mid-stream currently discards
697                    // everything accumulated in `content` /
698                    // `reasoning_content` / `tool_call_builders`. If any
699                    // content arrived, return it as a partial Completion so
700                    // the caller persists it (the "interrupted" sentinel
701                    // routes to FinishReason::Cancelled). A drop before the
702                    // first token has nothing to save — keep the Err.
703                    let have_partial = !content.is_empty()
704                        || !reasoning_content.is_empty()
705                        || !tool_call_builders.is_empty();
706                    if have_partial {
707                        tracing::warn!(
708                            target: "recursive::llm",
709                            error = %e,
710                            content_len = content.len(),
711                            "SSE stream interrupted; returning partial completion"
712                        );
713                        return Ok(Self::build_completion(
714                            content,
715                            reasoning_content,
716                            HashMap::new(), // drop half-received tool calls
717                            Some("interrupted".to_string()),
718                            usage.take(),
719                        ));
720                    }
721                    return Err(self.make_err(format!("SSE stream read error: {e}")));
722                }
723                None => break,
724            };
725            // Prepend any leftover bytes from the previous chunk.
726            let combined: Vec<u8> = if incomplete.is_empty() {
727                chunk.to_vec()
728            } else {
729                let mut v = std::mem::take(&mut incomplete);
730                v.extend_from_slice(&chunk);
731                v
732            };
733            // Decode as much valid UTF-8 as possible; keep the remainder.
734            let valid_up_to = match std::str::from_utf8(&combined) {
735                Ok(_) => combined.len(),
736                Err(e) => e.valid_up_to(),
737            };
738            incomplete = combined[valid_up_to..].to_vec();
739            // SAFETY: valid_up_to is guaranteed by from_utf8 to be a valid
740            // UTF-8 boundary within `combined`.
741            let text = unsafe { std::str::from_utf8_unchecked(&combined[..valid_up_to]) };
742            for ch in text.chars() {
743                if ch == '\n' {
744                    let line = std::mem::take(&mut line_buf);
745                    Self::process_sse_line(
746                        &line,
747                        &self.model,
748                        &mut content,
749                        &mut reasoning_content,
750                        &mut tool_call_builders,
751                        &mut finish_reason,
752                        &mut usage,
753                        &stream_tx,
754                    )?;
755                } else if ch != '\r' {
756                    line_buf.push(ch);
757                }
758            }
759        }
760        // Flush any remaining bytes (shouldn't happen with a well-formed stream,
761        // but handle gracefully to avoid silent truncation).
762        Self::warn_incomplete_utf8_tail(&incomplete);
763        // Flush any trailing line without a terminating newline.
764        if !line_buf.is_empty() {
765            Self::process_sse_line(
766                &line_buf,
767                &self.model,
768                &mut content,
769                &mut reasoning_content,
770                &mut tool_call_builders,
771                &mut finish_reason,
772                &mut usage,
773                &stream_tx,
774            )?;
775        }
776
777        // Normal end of stream: assemble the Completion from the accumulated
778        // state (shared with the Goal-382 partial-return paths above).
779        Ok(Self::build_completion(
780            content,
781            reasoning_content,
782            tool_call_builders,
783            finish_reason,
784            usage,
785        ))
786    }
787
788    /// Assemble a [`Completion`] from accumulated SSE state. Shared by the
789    /// normal end-of-stream path and the Goal-382 partial-return paths
790    /// (stream read error / user-initiated cancel mid-stream).
791    fn build_completion(
792        content: String,
793        reasoning_content: String,
794        mut tool_call_builders: HashMap<usize, (String, String, String)>,
795        finish_reason: Option<String>,
796        usage: Option<TokenUsage>,
797    ) -> Completion {
798        // Convert accumulated builders into ToolCall objects, sorted by index.
799        let mut sorted_indices: Vec<usize> = tool_call_builders.keys().copied().collect();
800        sorted_indices.sort_unstable();
801        let tool_calls: Vec<ToolCall> = sorted_indices
802            .into_iter()
803            .filter_map(|i| {
804                let (id, name, partial_json) = tool_call_builders.remove(&i)?;
805                if id.is_empty() && name.is_empty() {
806                    return None;
807                }
808                let arguments = if partial_json.trim().is_empty() {
809                    Value::Object(Default::default())
810                } else {
811                    serde_json::from_str(&partial_json).unwrap_or(Value::String(partial_json))
812                };
813                Some(ToolCall {
814                    id,
815                    name,
816                    arguments,
817                })
818            })
819            .collect();
820
821        Completion {
822            content,
823            tool_calls,
824            finish_reason,
825            usage,
826            reasoning_content: if reasoning_content.is_empty() {
827                None
828            } else {
829                Some(reasoning_content)
830            },
831        }
832    }
833
834    /// Log (and discard) a trailing incomplete UTF-8 sequence at end of stream.
835    ///
836    /// `#[mutants::skip]`: this helper only emits a warning, so mutating the
837    /// `!is_empty` check is behavior-equivalent and untestable without
838    /// asserting on log output.
839    #[cfg_attr(test, mutants::skip)]
840    fn warn_incomplete_utf8_tail(incomplete: &[u8]) {
841        if !incomplete.is_empty() {
842            tracing::warn!(
843                target: "recursive::llm",
844                bytes = incomplete.len(),
845                "SSE stream ended with incomplete UTF-8 sequence; discarding tail bytes"
846            );
847        }
848    }
849
850    // provider is needed to produce accurate error messages when the OpenAI adapter
851    // is used by non-OpenAI backends (DeepSeek, GLM, Moonshot, …).
852    #[allow(clippy::too_many_arguments)]
853    fn process_sse_line(
854        line: &str,
855        provider: &str,
856        content: &mut String,
857        reasoning_content: &mut String,
858        tool_call_builders: &mut HashMap<usize, (String, String, String)>,
859        finish_reason: &mut Option<String>,
860        usage: &mut Option<TokenUsage>,
861        stream_tx: &Option<StreamSender>,
862    ) -> Result<()> {
863        let data = match line.strip_prefix("data: ") {
864            Some(d) => d,
865            None => return Ok(()),
866        };
867        if data.trim() == "[DONE]" {
868            return Ok(());
869        }
870
871        let chunk: Value = serde_json::from_str(data).map_err(|e| Error::Llm {
872            provider: provider.to_string(),
873            message: format!("SSE parse error: {e}; data: {data}"),
874        })?;
875
876        if let Some(choices) = chunk.get("choices").and_then(|c| c.as_array()) {
877            if let Some(choice) = choices.first() {
878                if let Some(delta) = choice.get("delta") {
879                    if let Some(delta_content) = delta.get("content").and_then(|c| c.as_str()) {
880                        if !delta_content.is_empty() {
881                            content.push_str(delta_content);
882                            if let Some(tx) = stream_tx {
883                                let _ = tx.send(StreamChunk::Text(delta_content.to_string()));
884                            }
885                        }
886                    }
887                    if let Some(delta_reasoning) =
888                        delta.get("reasoning_content").and_then(|c| c.as_str())
889                    {
890                        if !delta_reasoning.is_empty() {
891                            reasoning_content.push_str(delta_reasoning);
892                            if let Some(tx) = stream_tx {
893                                let _ =
894                                    tx.send(StreamChunk::Reasoning(delta_reasoning.to_string()));
895                            }
896                        }
897                    }
898                    // Accumulate tool_calls deltas
899                    if let Some(tcs) = delta.get("tool_calls").and_then(|v| v.as_array()) {
900                        for tc in tcs {
901                            let idx =
902                                tc.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
903                            let entry = tool_call_builders
904                                .entry(idx)
905                                .or_insert_with(|| (String::new(), String::new(), String::new()));
906                            if let Some(id) = tc.get("id").and_then(|v| v.as_str()) {
907                                if !id.is_empty() {
908                                    entry.0 = id.to_string();
909                                }
910                            }
911                            if let Some(func) = tc.get("function") {
912                                if let Some(name) = func.get("name").and_then(|v| v.as_str()) {
913                                    if !name.is_empty() {
914                                        entry.1 = name.to_string();
915                                    }
916                                }
917                                if let Some(args) = func.get("arguments").and_then(|v| v.as_str()) {
918                                    entry.2.push_str(args);
919                                }
920                            }
921                        }
922                    }
923                }
924                if let Some(fr) = choice.get("finish_reason").and_then(|f| f.as_str()) {
925                    if !fr.is_empty() {
926                        *finish_reason = Some(fr.to_string());
927                    }
928                }
929            }
930        }
931
932        if let Some(u) = chunk.get("usage") {
933            let prompt = u.get("prompt_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
934            let completion = u
935                .get("completion_tokens")
936                .and_then(|v| v.as_u64())
937                .unwrap_or(0) as u32;
938            let total = u.get("total_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
939            // Cache hit: prefer top-level field (DeepSeek / OpenAI native),
940            // fall back to `prompt_tokens_details.cached_tokens` (MiniMax-M3).
941            let cache_hit = u
942                .get("prompt_cache_hit_tokens")
943                .and_then(|v| v.as_u64())
944                .or_else(|| {
945                    u.get("prompt_tokens_details")
946                        .and_then(|d| d.get("cached_tokens"))
947                        .and_then(|v| v.as_u64())
948                })
949                .unwrap_or(0) as u32;
950            let explicit_miss = u
951                .get("prompt_cache_miss_tokens")
952                .and_then(|v| v.as_u64())
953                .unwrap_or(0) as u32;
954            // Cache miss: prefer the explicit value when it is non-zero;
955            // an explicit 0 is ambiguous (OpenAI often omits the field or
956            // sends 0 while still reporting `cached_tokens`), so derive
957            // from `prompt_tokens - cache_hit` instead to honour the
958            // TokenUsage invariant `hit + miss == prompt_tokens` and keep
959            // the TUI percentage accurate instead of pegging at 100%.
960            let cache_miss = if explicit_miss > 0 {
961                explicit_miss
962            } else if cache_hit > 0 {
963                prompt.saturating_sub(cache_hit)
964            } else {
965                0
966            };
967            let reasoning = u
968                .get("completion_tokens_details")
969                .and_then(|d| d.get("reasoning_tokens"))
970                .and_then(|v| v.as_u64())
971                .unwrap_or(0) as u32;
972            *usage = Some(TokenUsage {
973                prompt_tokens: prompt,
974                completion_tokens: completion,
975                total_tokens: total,
976                cache_hit_tokens: cache_hit,
977                cache_miss_tokens: cache_miss,
978                reasoning_tokens: reasoning,
979            });
980        }
981        Ok(())
982    }
983}
984
985fn build_request(
986    model: &str,
987    temperature: f64,
988    max_tokens: u32,
989    messages: &[Message],
990    tools: &[ToolSpec],
991) -> Value {
992    let mut req = serde_json::json!({
993        "model": model,
994        "temperature": temperature,
995        "max_tokens": max_tokens,
996        "messages": messages.iter().map(serialize_message).collect::<Vec<_>>(),
997    });
998    if !tools.is_empty() {
999        let tools_json: Vec<Value> = tools
1000            .iter()
1001            .map(|t| {
1002                serde_json::json!({
1003                    "type": "function",
1004                    "function": {
1005                        "name": t.name,
1006                        "description": t.description,
1007                        "parameters": t.parameters,
1008                    }
1009                })
1010            })
1011            .collect();
1012        req["tools"] = Value::Array(tools_json);
1013        req["tool_choice"] = Value::String("auto".into());
1014    }
1015    // MiniMax-M3 (and M2.x) default to embedding chain-of-thought inside
1016    // `content` as `<think>…</think>`. That makes the TUI stream thinking
1017    // as a normal assistant bullet, then hoist it into a Reasoning block
1018    // only at finalise. `reasoning_split: true` asks MiniMax to emit
1019    // thinking on the dedicated `reasoning_content` SSE field instead,
1020    // which our stream parser already forwards as StreamChunk::Reasoning.
1021    // Other OpenAI-compatible providers ignore unknown fields or reject
1022    // them — keep this MiniMax-only.
1023    if model_wants_reasoning_split(model) {
1024        req["reasoning_split"] = Value::Bool(true);
1025    }
1026    req
1027}
1028
1029/// MiniMax OpenAI-compatible models that honour `reasoning_split`.
1030fn model_wants_reasoning_split(model: &str) -> bool {
1031    model
1032        .to_ascii_lowercase()
1033        .split(|c: char| !c.is_ascii_alphanumeric())
1034        .any(|part| part == "minimax")
1035}
1036
1037fn serialize_message(m: &Message) -> Value {
1038    let role = match m.role {
1039        Role::System => "system",
1040        Role::User => "user",
1041        Role::Assistant => "assistant",
1042        Role::Tool => "tool",
1043    };
1044    let mut obj = serde_json::Map::new();
1045    obj.insert("role".into(), Value::String(role.into()));
1046    obj.insert("content".into(), Value::String(m.content.clone()));
1047    if let Some(id) = &m.tool_call_id {
1048        obj.insert("tool_call_id".into(), Value::String(id.clone()));
1049    }
1050    // Echo reasoning_content back to the API (required by DeepSeek thinking mode).
1051    // Only valid on Assistant messages — other roles have no reasoning field.
1052    if matches!(m.role, Role::Assistant) {
1053        if let Some(ref reasoning) = m.reasoning_content {
1054            obj.insert("reasoning_content".into(), Value::String(reasoning.clone()));
1055        }
1056    }
1057    if !m.tool_calls.is_empty() {
1058        let calls: Vec<Value> = m
1059            .tool_calls
1060            .iter()
1061            .map(|c| {
1062                serde_json::json!({
1063                    "id": c.id,
1064                    "type": "function",
1065                    "function": {
1066                        "name": c.name,
1067                        "arguments": serde_json::to_string(&c.arguments).unwrap_or_else(|_| "{}".into()),
1068                    }
1069                })
1070            })
1071            .collect();
1072        obj.insert("tool_calls".into(), Value::Array(calls));
1073    }
1074    Value::Object(obj)
1075}
1076
1077#[derive(Debug, Deserialize)]
1078struct ChatResponse {
1079    choices: Vec<ChatChoice>,
1080    #[serde(default)]
1081    usage: Option<ResponseUsage>,
1082}
1083
1084#[derive(Debug, Deserialize)]
1085struct ResponseUsage {
1086    #[serde(default)]
1087    prompt_tokens: Option<u32>,
1088    #[serde(default)]
1089    completion_tokens: Option<u32>,
1090    #[serde(default)]
1091    total_tokens: Option<u32>,
1092    /// OpenAI standard. Some compatible providers (DeepSeek) report cache
1093    /// usage here directly. Others (MiniMax-M3) nest it under
1094    /// `prompt_tokens_details.cached_tokens` instead — see below.
1095    #[serde(default)]
1096    prompt_cache_hit_tokens: Option<u32>,
1097    #[serde(default)]
1098    prompt_cache_miss_tokens: Option<u32>,
1099    /// OpenAI-standard nested details. MiniMax-M3 reports prompt cache hits
1100    /// here as `cached_tokens` rather than at the top level. The TUI cache
1101    /// rate depends on this being read.
1102    #[serde(default)]
1103    prompt_tokens_details: Option<PromptTokensDetails>,
1104    /// OpenAI-standard nested details. o1/o3 and thinking-capable MiniMax
1105    /// models (e.g. MiniMax-M3) report reasoning tokens here. Previously
1106    /// only the streaming path captured this; non-streaming calls left
1107    /// `reasoning_tokens` at 0, undercharging the cost total. (Goal 273
1108    /// follow-up.)
1109    #[serde(default)]
1110    completion_tokens_details: Option<CompletionTokensDetails>,
1111}
1112
1113#[derive(Debug, Default, Deserialize)]
1114struct PromptTokensDetails {
1115    #[serde(default)]
1116    cached_tokens: Option<u32>,
1117}
1118
1119#[derive(Debug, Default, Deserialize)]
1120struct CompletionTokensDetails {
1121    #[serde(default)]
1122    reasoning_tokens: Option<u32>,
1123}
1124
1125impl ResponseUsage {
1126    fn to_token_usage(&self) -> TokenUsage {
1127        // Cache hit: top-level field first (DeepSeek, OpenAI native), then
1128        // fall back to `prompt_tokens_details.cached_tokens` (MiniMax-M3).
1129        let cache_hit = self
1130            .prompt_cache_hit_tokens
1131            .or_else(|| {
1132                self.prompt_tokens_details
1133                    .as_ref()
1134                    .and_then(|d| d.cached_tokens)
1135            })
1136            .unwrap_or(0);
1137
1138        // Cache miss: providers that report hit explicitly usually also
1139        // report miss, so prefer the explicit value — BUT a `0` miss is
1140        // ambiguous: OpenAI's official API frequently omits
1141        // `prompt_cache_miss_tokens` (or sends it as 0) while still
1142        // reporting `cached_tokens`, which would falsely peg the TUI at
1143        // 100%. Treat an explicit `0` the same as "not provided" and
1144        // derive miss from `prompt_tokens - hit` so the documented
1145        // invariant `cache_hit + cache_miss == prompt_tokens` holds. This
1146        // keeps the TUI cache-hit percentage accurate instead of reading
1147        // 100% on turns that actually had cache misses.
1148        let cache_miss = match self.prompt_cache_miss_tokens {
1149            Some(m) if m > 0 => m,
1150            _ if cache_hit > 0 => self.prompt_tokens.unwrap_or(0).saturating_sub(cache_hit),
1151            _ => 0,
1152        };
1153
1154        let reasoning_tokens = self
1155            .completion_tokens_details
1156            .as_ref()
1157            .and_then(|d| d.reasoning_tokens)
1158            .unwrap_or(0);
1159
1160        TokenUsage {
1161            reasoning_tokens,
1162            prompt_tokens: self.prompt_tokens.unwrap_or(0),
1163            completion_tokens: self.completion_tokens.unwrap_or(0),
1164            total_tokens: self.total_tokens.unwrap_or(0),
1165            cache_hit_tokens: cache_hit,
1166            cache_miss_tokens: cache_miss,
1167        }
1168    }
1169}
1170
1171#[derive(Debug, Deserialize)]
1172struct ChatChoice {
1173    message: ChatChoiceMessage,
1174    #[serde(default)]
1175    finish_reason: Option<String>,
1176}
1177
1178#[derive(Debug, Deserialize)]
1179struct ChatChoiceMessage {
1180    #[serde(default)]
1181    content: Option<String>,
1182    #[serde(default)]
1183    reasoning_content: Option<String>,
1184    #[serde(default)]
1185    tool_calls: Vec<RawToolCall>,
1186}
1187
1188#[derive(Debug, Deserialize, Serialize)]
1189struct RawToolCall {
1190    id: String,
1191    #[serde(default)]
1192    function: RawFunction,
1193}
1194
1195#[derive(Debug, Default, Deserialize, Serialize)]
1196struct RawFunction {
1197    #[serde(default)]
1198    name: String,
1199    #[serde(default)]
1200    arguments: String,
1201}
1202
1203fn parse_completion(choice: ChatChoice, usage: Option<ResponseUsage>) -> Completion {
1204    let content = choice.message.content.unwrap_or_default();
1205    let reasoning_content = choice.message.reasoning_content;
1206    let tool_calls = choice
1207        .message
1208        .tool_calls
1209        .into_iter()
1210        .map(|c| {
1211            let args: Value = if c.function.arguments.trim().is_empty() {
1212                Value::Object(Default::default())
1213            } else {
1214                serde_json::from_str(&c.function.arguments)
1215                    .unwrap_or_else(|_| Value::String(c.function.arguments.clone()))
1216            };
1217            ToolCall {
1218                id: c.id,
1219                name: c.function.name,
1220                arguments: args,
1221            }
1222        })
1223        .collect();
1224    Completion {
1225        content,
1226        tool_calls,
1227        finish_reason: choice.finish_reason,
1228        usage: usage.map(|u| u.to_token_usage()),
1229        reasoning_content,
1230    }
1231}
1232
1233#[cfg(test)]
1234mod tests {
1235    use super::*;
1236
1237    #[test]
1238    fn parses_plain_text_choice() {
1239        let raw = r#"{"choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}]}"#;
1240        let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
1241        let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
1242        assert_eq!(c.content, "hi");
1243        assert!(c.tool_calls.is_empty());
1244        assert_eq!(c.finish_reason.as_deref(), Some("stop"));
1245        assert!(c.usage.is_none());
1246    }
1247
1248    #[test]
1249    fn parses_tool_call_choice() {
1250        let raw = r#"{
1251            "choices":[{
1252                "message":{
1253                    "role":"assistant",
1254                    "content":null,
1255                    "tool_calls":[{
1256                        "id":"call_1",
1257                        "type":"function",
1258                        "function":{"name":"Read","arguments":"{\"path\":\"src/lib.rs\"}"}
1259                    }]
1260                },
1261                "finish_reason":"tool_calls"
1262            }]
1263        }"#;
1264        let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
1265        let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
1266        assert_eq!(c.tool_calls.len(), 1);
1267        assert_eq!(c.tool_calls[0].name, "Read");
1268        assert_eq!(c.tool_calls[0].arguments["path"], "src/lib.rs");
1269    }
1270
1271    #[test]
1272    fn parses_usage_from_response() {
1273        let raw = r#"{
1274            "choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
1275            "usage":{"prompt_tokens":41,"completion_tokens":7,"total_tokens":48}
1276        }"#;
1277        let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
1278        let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
1279        assert!(c.usage.is_some());
1280        let u = c.usage.unwrap();
1281        assert_eq!(u.prompt_tokens, 41);
1282        assert_eq!(u.completion_tokens, 7);
1283        assert_eq!(u.total_tokens, 48);
1284    }
1285
1286    #[test]
1287    fn parses_missing_usage_as_none() {
1288        let raw = r#"{"choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}]}"#;
1289        let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
1290        let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
1291        assert!(c.usage.is_none());
1292    }
1293
1294    #[test]
1295    fn parses_partial_usage_fills_zeros() {
1296        let raw = r#"{
1297            "choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
1298            "usage":{"total_tokens":50}
1299        }"#;
1300        let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
1301        let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
1302        assert!(c.usage.is_some());
1303        let u = c.usage.unwrap();
1304        assert_eq!(u.prompt_tokens, 0);
1305        assert_eq!(u.completion_tokens, 0);
1306        assert_eq!(u.total_tokens, 50);
1307        assert_eq!(u.cache_hit_tokens, 0);
1308        assert_eq!(u.cache_miss_tokens, 0);
1309    }
1310
1311    #[test]
1312    fn parses_cache_fields_from_deepseek_usage() {
1313        let raw = r#"{
1314            "choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
1315            "usage":{
1316                "prompt_tokens":100,
1317                "completion_tokens":50,
1318                "total_tokens":150,
1319                "prompt_cache_hit_tokens":60,
1320                "prompt_cache_miss_tokens":40
1321            }
1322        }"#;
1323        let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
1324        let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
1325        assert!(c.usage.is_some());
1326        let u = c.usage.unwrap();
1327        assert_eq!(u.prompt_tokens, 100);
1328        assert_eq!(u.completion_tokens, 50);
1329        assert_eq!(u.total_tokens, 150);
1330        assert_eq!(u.cache_hit_tokens, 60);
1331        assert_eq!(u.cache_miss_tokens, 40);
1332    }
1333
1334    #[test]
1335    fn parses_cache_fields_as_zero_when_absent() {
1336        let raw = r#"{
1337            "choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
1338            "usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}
1339        }"#;
1340        let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
1341        let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
1342        assert!(c.usage.is_some());
1343        let u = c.usage.unwrap();
1344        assert_eq!(u.cache_hit_tokens, 0);
1345        assert_eq!(u.cache_miss_tokens, 0);
1346    }
1347
1348    #[test]
1349    fn parses_minimax_cached_tokens_nested_under_prompt_tokens_details() {
1350        // MiniMax-M3 returns cache hits as
1351        // `usage.prompt_tokens_details.cached_tokens` rather than at the
1352        // top level. Without the nested read the TUI cache rate stays
1353        // invisible because both hit and miss collapse to 0.
1354        let raw = r#"{
1355            "choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
1356            "usage":{
1357                "prompt_tokens":780,
1358                "completion_tokens":20,
1359                "total_tokens":800,
1360                "completion_tokens_details":{"reasoning_tokens":19},
1361                "prompt_tokens_details":{"cached_tokens":768}
1362            }
1363        }"#;
1364        let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
1365        let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
1366        let u = c.usage.expect("usage should be parsed");
1367        assert_eq!(u.prompt_tokens, 780);
1368        assert_eq!(u.completion_tokens, 20);
1369        assert_eq!(u.cache_hit_tokens, 768, "cached_tokens must populate hit");
1370        // hit + miss should equal prompt_tokens (invariant).
1371        assert_eq!(
1372            u.cache_hit_tokens + u.cache_miss_tokens,
1373            u.prompt_tokens,
1374            "cache_hit + cache_miss must equal prompt_tokens"
1375        );
1376        assert_eq!(u.reasoning_tokens, 19);
1377    }
1378
1379    #[test]
1380    fn derives_cache_miss_when_only_hit_is_reported() {
1381        // MiniMax-style usage: only `cached_tokens` reported, no explicit
1382        // `prompt_cache_miss_tokens`. Derive miss = prompt - hit so the
1383        // TUI cache-hit percentage reflects reality (here ~98.5%) instead
1384        // of pegging at 100%.
1385        let raw = r#"{
1386            "choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
1387            "usage":{
1388                "prompt_tokens":780,
1389                "completion_tokens":20,
1390                "total_tokens":800,
1391                "prompt_tokens_details":{"cached_tokens":768}
1392            }
1393        }"#;
1394        let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
1395        let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
1396        let u = c.usage.expect("usage should be parsed");
1397        assert_eq!(u.cache_hit_tokens, 768);
1398        assert_eq!(u.cache_miss_tokens, 12);
1399    }
1400
1401    #[test]
1402    fn explicit_zero_cache_miss_is_derived_not_trusted() {
1403        // OpenAI's official API often sends `prompt_cache_miss_tokens: 0`
1404        // (or omits it) while still reporting a non-zero `cached_tokens`.
1405        // A literal 0 must NOT be treated as "no misses" — that would peg
1406        // the TUI at 100% on turns that actually had misses. Derive
1407        // miss = prompt - hit instead, so the rate is ~98.5% here.
1408        let raw = r#"{
1409            "choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
1410            "usage":{
1411                "prompt_tokens":780,
1412                "completion_tokens":20,
1413                "total_tokens":800,
1414                "prompt_cache_hit_tokens":768,
1415                "prompt_cache_miss_tokens":0
1416            }
1417        }"#;
1418        let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
1419        let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
1420        let u = c.usage.expect("usage should be parsed");
1421        assert_eq!(u.cache_hit_tokens, 768);
1422        assert_eq!(
1423            u.cache_miss_tokens, 12,
1424            "explicit 0 miss must derive, not peg at 100%"
1425        );
1426    }
1427
1428    #[test]
1429    fn explicit_cache_miss_takes_precedence_over_derived() {
1430        // When the provider reports both `prompt_cache_miss_tokens` and
1431        // `cached_tokens`, the explicit miss wins — even if it would
1432        // disagree with `prompt - hit`. This protects against providers
1433        // that round or batch differently (e.g. DeepSeek reports the
1434        // split directly and that's the source of truth).
1435        let raw = r#"{
1436            "choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
1437            "usage":{
1438                "prompt_tokens":100,
1439                "completion_tokens":50,
1440                "total_tokens":150,
1441                "prompt_cache_hit_tokens":60,
1442                "prompt_cache_miss_tokens":40,
1443                "prompt_tokens_details":{"cached_tokens":999}
1444            }
1445        }"#;
1446        let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
1447        let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
1448        let u = c.usage.expect("usage should be parsed");
1449        // Top-level hit field wins over the nested value.
1450        assert_eq!(u.cache_hit_tokens, 60);
1451        // Explicit miss wins over derived (100 - 60 = 40, agrees here,
1452        // but the test guards against future regressions).
1453        assert_eq!(u.cache_miss_tokens, 40);
1454    }
1455
1456    #[test]
1457    fn parses_reasoning_tokens_in_non_streaming_path() {
1458        // o1 / o3 / MiniMax thinking-mode models report reasoning tokens
1459        // under `usage.completion_tokens_details.reasoning_tokens`. The
1460        // non-streaming path used to drop this on the floor, undercharging
1461        // the cost total.
1462        let raw = r#"{
1463            "choices":[{"message":{"role":"assistant","content":"answer"},"finish_reason":"stop"}],
1464            "usage":{
1465                "prompt_tokens":50,
1466                "completion_tokens":200,
1467                "total_tokens":250,
1468                "completion_tokens_details":{"reasoning_tokens":175}
1469            }
1470        }"#;
1471        let parsed: ChatResponse = serde_json::from_str(raw).unwrap();
1472        let c = parse_completion(parsed.choices.into_iter().next().unwrap(), parsed.usage);
1473        let u = c.usage.expect("usage should be parsed");
1474        assert_eq!(u.completion_tokens, 200);
1475        assert_eq!(u.reasoning_tokens, 175);
1476    }
1477
1478    #[test]
1479    fn stream_usage_parses_minimax_nested_cache_and_reasoning() {
1480        // Drive process_sse_line directly with a single chunk whose
1481        // usage shape matches MiniMax-M3. Assert both `cached_tokens`
1482        // and `reasoning_tokens` are extracted.
1483        let mut usage: Option<TokenUsage> = None;
1484        let mut content = String::new();
1485        let mut reasoning_content = String::new();
1486        let mut tool_call_builders: std::collections::HashMap<usize, (String, String, String)> =
1487            std::collections::HashMap::new();
1488        let mut finish_reason: Option<String> = None;
1489        let data = r#"data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":780,"completion_tokens":20,"total_tokens":800,"completion_tokens_details":{"reasoning_tokens":19},"prompt_tokens_details":{"cached_tokens":768}}}"#;
1490        OpenAiProvider::process_sse_line(
1491            data,
1492            "MiniMax-M3",
1493            &mut content,
1494            &mut reasoning_content,
1495            &mut tool_call_builders,
1496            &mut finish_reason,
1497            &mut usage,
1498            &None,
1499        )
1500        .unwrap();
1501        let u = usage.expect("usage should be set");
1502        assert_eq!(u.cache_hit_tokens, 768);
1503        assert_eq!(u.cache_miss_tokens, 12);
1504        assert_eq!(u.reasoning_tokens, 19);
1505        assert_eq!(content, "ok");
1506    }
1507
1508    #[test]
1509    fn serialises_assistant_with_tool_calls() {
1510        let msg = Message::assistant_with_tool_calls(
1511            "",
1512            vec![ToolCall {
1513                id: "abc".into(),
1514                name: "Write".into(),
1515                arguments: serde_json::json!({"path":"a","contents":"b"}),
1516            }],
1517        );
1518        let v = serialize_message(&msg);
1519        assert_eq!(v["tool_calls"][0]["function"]["name"], "Write");
1520        let args = v["tool_calls"][0]["function"]["arguments"]
1521            .as_str()
1522            .unwrap();
1523        let decoded: Value = serde_json::from_str(args).unwrap();
1524        assert_eq!(decoded["contents"], "b");
1525    }
1526
1527    // ---- ToolSearch loop tests ----
1528
1529    #[derive(Debug)]
1530    struct AlwaysReturnsEngine(Vec<String>);
1531    impl ToolSearchEngine for AlwaysReturnsEngine {
1532        fn resolve(&self, _query: &str, _candidates: &[SpecWithHint]) -> Vec<String> {
1533            self.0.clone()
1534        }
1535    }
1536
1537    fn make_tool_spec(name: &str) -> ToolSpec {
1538        ToolSpec {
1539            name: name.to_string(),
1540            description: format!("{name} tool"),
1541            parameters: serde_json::json!({"type":"object","properties":{},"required":[]}),
1542        }
1543    }
1544
1545    #[tokio::test]
1546    async fn openai_search_loop_skips_when_no_deferred() {
1547        // With no deferred tools, run_search_loop should make exactly one
1548        // request (no ToolSearch round-trip). We use a mock server that
1549        // returns a plain text response and assert it's called exactly once.
1550        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1551        let addr = listener.local_addr().unwrap();
1552        let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1553        let call_count_clone = call_count.clone();
1554
1555        std::thread::spawn(move || {
1556            use std::io::{Read, Write};
1557            loop {
1558                if let Ok((mut stream, _)) = listener.accept() {
1559                    call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1560                    let mut buf = [0u8; 8192];
1561                    let _ = stream.read(&mut buf);
1562                    let body = r#"{"choices":[{"message":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}"#;
1563                    write!(
1564                        stream,
1565                        "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1566                        body.len(),
1567                        body
1568                    )
1569                    .unwrap();
1570                    stream.flush().unwrap();
1571                }
1572            }
1573        });
1574        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1575
1576        let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
1577        let eager = vec![(make_tool_spec("Read"), None)];
1578        let result = provider
1579            .complete_with_search(&[Message::user("hi")], &eager, &[])
1580            .await
1581            .unwrap();
1582        assert_eq!(result.content, "done");
1583        assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 1);
1584    }
1585
1586    #[tokio::test]
1587    async fn openai_search_loop_resolves_deferred_tool() {
1588        // Two mock responses:
1589        // Round 0: model calls ToolSearchTool with query "MyTool"
1590        // Round 1: model returns normal content
1591        // After round 1 the tool list should include MyTool's schema.
1592        use std::sync::Mutex;
1593        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1594        let addr = listener.local_addr().unwrap();
1595        let captured_tools: std::sync::Arc<Mutex<Vec<String>>> =
1596            std::sync::Arc::new(Mutex::new(vec![]));
1597        let captured_clone = captured_tools.clone();
1598        let call_idx = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1599        let call_idx_clone = call_idx.clone();
1600
1601        std::thread::spawn(move || {
1602            use std::io::{Read, Write};
1603            loop {
1604                if let Ok((mut stream, _)) = listener.accept() {
1605                    let idx = call_idx_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1606                    let mut buf = [0u8; 65536];
1607                    let n = stream.read(&mut buf).unwrap_or(0);
1608                    let req = String::from_utf8_lossy(&buf[..n]);
1609                    // Extract JSON body and record tool names
1610                    if let Some(start) = req.find("\r\n\r\n") {
1611                        let body_str = req[start + 4..].trim();
1612                        if let Ok(body) = serde_json::from_str::<serde_json::Value>(body_str) {
1613                            if let Some(tools) = body["tools"].as_array() {
1614                                let names: Vec<String> = tools
1615                                    .iter()
1616                                    .filter_map(|t| {
1617                                        t["function"]["name"].as_str().map(String::from)
1618                                    })
1619                                    .collect();
1620                                *captured_clone.lock().unwrap() = names;
1621                            }
1622                        }
1623                    }
1624                    let resp = if idx == 0 {
1625                        // First call: return a ToolSearchTool call
1626                        r#"{"choices":[{"message":{"role":"assistant","content":"","tool_calls":[{"id":"ts1","type":"function","function":{"name":"ToolSearchTool","arguments":"{\"query\":\"MyTool\"}"}}]},"finish_reason":"tool_calls"}]}"#
1627                    } else {
1628                        // Second call: return normal content
1629                        r#"{"choices":[{"message":{"role":"assistant","content":"found it"},"finish_reason":"stop"}]}"#
1630                    };
1631                    write!(
1632                        stream,
1633                        "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1634                        resp.len(),
1635                        resp
1636                    )
1637                    .unwrap();
1638                    stream.flush().unwrap();
1639                }
1640            }
1641        });
1642        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1643
1644        let my_tool = make_tool_spec("MyTool");
1645        let deferred = vec![(my_tool.clone(), None)];
1646        let engine = Arc::new(AlwaysReturnsEngine(vec!["MyTool".to_string()]));
1647        let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m")
1648            .unwrap()
1649            .with_search_engine(engine);
1650
1651        let result = provider
1652            .complete_with_search(&[Message::user("hi")], &[], &deferred)
1653            .await
1654            .unwrap();
1655        assert_eq!(result.content, "found it");
1656        // The second request should include MyTool's schema
1657        let tools = captured_tools.lock().unwrap().clone();
1658        assert!(
1659            tools.contains(&"MyTool".to_string()),
1660            "second request should include MyTool, got: {tools:?}"
1661        );
1662        assert_eq!(call_idx.load(std::sync::atomic::Ordering::SeqCst), 2);
1663    }
1664
1665    #[tokio::test]
1666    async fn openai_search_loop_caps_at_max_rounds() {
1667        // Mock server always returns a ToolSearchTool call.
1668        // Assert we stop after max_search_rounds and return.
1669        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1670        let addr = listener.local_addr().unwrap();
1671        let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1672        let call_count_clone = call_count.clone();
1673
1674        std::thread::spawn(move || {
1675            use std::io::{Read, Write};
1676            loop {
1677                if let Ok((mut stream, _)) = listener.accept() {
1678                    call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1679                    let mut buf = [0u8; 65536];
1680                    let _ = stream.read(&mut buf);
1681                    // Always respond with a ToolSearchTool call
1682                    let resp = r#"{"choices":[{"message":{"role":"assistant","content":"","tool_calls":[{"id":"ts1","type":"function","function":{"name":"ToolSearchTool","arguments":"{\"query\":\"anything\"}"}}]},"finish_reason":"tool_calls"}]}"#;
1683                    write!(
1684                        stream,
1685                        "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1686                        resp.len(),
1687                        resp
1688                    )
1689                    .unwrap();
1690                    stream.flush().unwrap();
1691                }
1692            }
1693        });
1694        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1695
1696        let deferred = vec![(make_tool_spec("SomeTool"), None)];
1697        let engine = Arc::new(AlwaysReturnsEngine(vec![])); // returns nothing
1698        let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m")
1699            .unwrap()
1700            .with_search_engine(engine);
1701
1702        let result = provider
1703            .complete_with_search(&[Message::user("hi")], &[], &deferred)
1704            .await
1705            .unwrap();
1706        // Should have made max_search_rounds + 1 calls and stopped
1707        let calls = call_count.load(std::sync::atomic::Ordering::SeqCst);
1708        assert_eq!(calls, 3 + 1, "expected {} calls, got {calls}", 3 + 1);
1709        // The returned completion should be the last ToolSearchTool response
1710        assert!(!result.tool_calls.is_empty());
1711    }
1712
1713    #[test]
1714    fn builds_request_without_tools_omits_field() {
1715        let req = build_request("m", 0.2, 16384, &[Message::user("hi")], &[]);
1716        assert!(req.get("tools").is_none());
1717        assert_eq!(req["messages"][0]["role"], "user");
1718        assert_eq!(req["max_tokens"], 16384);
1719        assert!(
1720            req.get("reasoning_split").is_none(),
1721            "non-MiniMax models must not get reasoning_split"
1722        );
1723    }
1724
1725    #[test]
1726    fn builds_request_includes_max_tokens() {
1727        let req = build_request("m", 0.2, 1024, &[Message::user("hi")], &[]);
1728        assert_eq!(req["max_tokens"], 1024);
1729    }
1730
1731    #[test]
1732    fn builds_request_minimax_sets_reasoning_split() {
1733        for model in [
1734            "MiniMax-M3",
1735            "MiniMax-M2.7",
1736            "minimax-m3",
1737            "ab-MiniMax-test",
1738        ] {
1739            let req = build_request(model, 0.2, 1024, &[Message::user("hi")], &[]);
1740            assert_eq!(
1741                req.get("reasoning_split"),
1742                Some(&Value::Bool(true)),
1743                "model {model} should request reasoning_split"
1744            );
1745        }
1746    }
1747
1748    #[test]
1749    fn model_wants_reasoning_split_detects_minimax_token() {
1750        assert!(model_wants_reasoning_split("MiniMax-M3"));
1751        assert!(model_wants_reasoning_split("minimax-m2.7"));
1752        assert!(!model_wants_reasoning_split("deepseek-chat"));
1753        assert!(!model_wants_reasoning_split("gpt-4o"));
1754        // Must not false-positive on substrings that aren't a MiniMax token.
1755        assert!(!model_wants_reasoning_split("notminimax"));
1756    }
1757
1758    #[tokio::test]
1759    async fn stream_request_includes_include_usage_flag() {
1760        // Mock a streaming endpoint and capture the request body. Assert
1761        // `stream_options.include_usage == true` is set, otherwise providers
1762        // like MiniMax-M3 won't emit the final usage chunk and the TUI
1763        // cache-rate badge stays invisible (regression for the bug where
1764        // MiniMax cache hit rate never showed up after a parser fix).
1765        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1766        let addr = listener.local_addr().unwrap();
1767        let captured = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
1768        let captured_clone = captured.clone();
1769
1770        std::thread::spawn(move || {
1771            let (mut stream, _) = listener.accept().unwrap();
1772            use std::io::{Read, Write};
1773            let mut buf = [0u8; 8192];
1774            let n = stream.read(&mut buf).unwrap();
1775            let request = String::from_utf8_lossy(&buf[..n]).to_string();
1776            if let Some(body_start) = request.find("\r\n\r\n") {
1777                let body = request[body_start + 4..].trim().to_string();
1778                *captured_clone.lock().unwrap() = body.clone();
1779            }
1780            let body = "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\n\
1781data: [DONE]\n\n";
1782            write!(
1783                stream,
1784                "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1785                body.len(),
1786                body,
1787            )
1788            .unwrap();
1789            stream.flush().unwrap();
1790        });
1791
1792        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1793
1794        let provider =
1795            OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "MiniMax-M3").unwrap();
1796        let _ = provider
1797            .stream(&[Message::user("hi")], &[], None, None)
1798            .await
1799            .unwrap();
1800
1801        let body_str = captured.lock().unwrap().clone();
1802        let body: serde_json::Value =
1803            serde_json::from_str(&body_str).expect("captured body should be valid JSON");
1804        assert_eq!(body["stream"], true, "stream flag must be set");
1805        assert_eq!(
1806            body["stream_options"]["include_usage"], true,
1807            "stream_options.include_usage must be true so providers like MiniMax emit the usage chunk"
1808        );
1809    }
1810
1811    #[tokio::test]
1812    async fn error_includes_model_name_on_network_failure() {
1813        // Bind a listener on an ephemeral port, then drop it so the port is freed.
1814        // The next connect attempt gets ECONNREFUSED (a network error).
1815        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1816        let addr = listener.local_addr().unwrap();
1817        drop(listener);
1818
1819        // Small sleep so the OS releases the port.
1820        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1821
1822        let provider =
1823            OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "test-model").unwrap();
1824        let err = provider
1825            .complete(&[Message::user("hi")], &[])
1826            .await
1827            .unwrap_err();
1828        let msg = err.to_string();
1829        assert!(
1830            msg.contains("test-model"),
1831            "error should contain model name: {msg}"
1832        );
1833    }
1834
1835    #[tokio::test]
1836    async fn error_includes_model_name_and_status_on_http_error() {
1837        // Spawn a one-shot TCP listener that sends a 400 response.
1838        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1839        let addr = listener.local_addr().unwrap();
1840
1841        // Use a dedicated thread with a blocking server to avoid tokio issues.
1842        std::thread::spawn(move || {
1843            let (mut stream, _) = listener.accept().unwrap();
1844            use std::io::{Read, Write};
1845            let mut buf = [0u8; 4096];
1846            let _ = stream.read(&mut buf);
1847            write!(
1848                stream,
1849                "HTTP/1.1 400 Bad Request\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}"
1850            )
1851            .unwrap();
1852            stream.flush().unwrap();
1853        });
1854
1855        // Give the server a moment to start
1856        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1857
1858        let provider =
1859            OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "test-model-http").unwrap();
1860        let err = provider
1861            .complete(&[Message::user("hi")], &[])
1862            .await
1863            .unwrap_err();
1864        let msg = err.to_string();
1865        assert!(
1866            msg.contains("test-model-http"),
1867            "error should contain model name: {msg}"
1868        );
1869        assert!(
1870            msg.contains("400"),
1871            "error should contain HTTP status: {msg}"
1872        );
1873    }
1874
1875    #[tokio::test]
1876    async fn stream_concatenates_sse_chunks() {
1877        // Spawn a one-shot TCP server that serves a canned SSE response
1878        // with 3 chunks. Assert the returned Completion's content is the
1879        // concatenation of the deltas.
1880        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1881        let addr = listener.local_addr().unwrap();
1882
1883        std::thread::spawn(move || {
1884            let (mut stream, _) = listener.accept().unwrap();
1885            use std::io::{Read, Write};
1886            let mut buf = [0u8; 4096];
1887            let _ = stream.read(&mut buf);
1888            let body = "\
1889data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}\n\n\
1890data: {\"choices\":[{\"delta\":{\"content\":\" \"},\"finish_reason\":null}]}\n\n\
1891data: {\"choices\":[{\"delta\":{\"content\":\"World\"},\"finish_reason\":\"stop\"}]}\n\n\
1892data: [DONE]\n\n";
1893            write!(
1894                stream,
1895                "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1896                body.len(),
1897                body,
1898            )
1899            .unwrap();
1900            stream.flush().unwrap();
1901        });
1902
1903        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1904
1905        let provider =
1906            OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "test-stream-model").unwrap();
1907        let completion = provider
1908            .stream(&[Message::user("hi")], &[], None, None)
1909            .await
1910            .unwrap();
1911        assert_eq!(completion.content, "Hello World");
1912        assert_eq!(completion.finish_reason.as_deref(), Some("stop"));
1913    }
1914
1915    #[tokio::test]
1916    async fn parse_sse_stream_returns_error_on_malformed_chunk() {
1917        // A flaky proxy can truncate/garbble a chunk mid-stream. The parser
1918        // must surface that as a diagnosable `Error::Llm` whose message
1919        // contains "SSE parse error" (from `process_sse_line`'s
1920        // `serde_json::from_str(...).map_err(...)`), NOT a silent panic or
1921        // an infinite hang. The malformed `data:` line comes FIRST so the
1922        // error fires before any valid chunk is processed; a subsequent
1923        // valid `[DONE]` proves the parser stops at the bad line rather
1924        // than skipping it and completing the stream.
1925        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1926        let addr = listener.local_addr().unwrap();
1927
1928        std::thread::spawn(move || {
1929            let (mut stream, _) = listener.accept().unwrap();
1930            use std::io::{Read, Write};
1931            let mut buf = [0u8; 4096];
1932            let _ = stream.read(&mut buf);
1933            let body = "\
1934data: {not valid json
1935
1936data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}
1937
1938data: [DONE]
1939
1940";
1941            write!(
1942                stream,
1943                "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1944                body.len(),
1945                body,
1946            )
1947            .unwrap();
1948            stream.flush().unwrap();
1949        });
1950
1951        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1952
1953        let provider =
1954            OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "test-malformed-model")
1955                .unwrap();
1956        // Timeout guards against a parser bug that loops on malformed input
1957        // — the test should fail on timeout, not hang the suite.
1958        let result = tokio::time::timeout(
1959            std::time::Duration::from_secs(5),
1960            provider.stream(&[Message::user("hi")], &[], None, None),
1961        )
1962        .await
1963        .expect("SSE parser must not hang on malformed input");
1964
1965        let err = match result {
1966            Ok(_) => panic!("expected Err on malformed SSE chunk, got Ok"),
1967            Err(e) => e,
1968        };
1969        match err {
1970            Error::Llm { provider, message } => {
1971                assert!(
1972                    message.contains("SSE parse error"),
1973                    "error message should be diagnosable, got: {message}"
1974                );
1975                assert!(
1976                    provider.contains("test-malformed-model"),
1977                    "error should carry the provider/model name, got: {provider}"
1978                );
1979            }
1980            other => panic!("expected Error::Llm, got {other:?}"),
1981        }
1982    }
1983
1984    #[tokio::test]
1985    async fn parse_sse_stream_returns_partial_on_mid_stream_error() {
1986        // Goal 382: a network drop mid-stream must NOT discard the content
1987        // that already arrived — it must be returned as a partial Completion
1988        // with finish_reason "interrupted" so run_inner persists it via the
1989        // Cancelled path. Serve one valid SSE chunk, then close the
1990        // connection early with a Content-Length LARGER than the body: the
1991        // client's next bytes_stream().next() sees a truncated-body read
1992        // error (exactly the `Some(Err(e))` arm this goal touches).
1993        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1994        let addr = listener.local_addr().unwrap();
1995
1996        std::thread::spawn(move || {
1997            let (mut stream, _) = listener.accept().unwrap();
1998            use std::io::{Read, Write};
1999            let mut buf = [0u8; 4096];
2000            let _ = stream.read(&mut buf);
2001            let body = "data: {\"choices\":[{\"delta\":{\"content\":\"partial\"},\"finish_reason\":null}]}\n\n";
2002            write!(
2003                stream,
2004                "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2005                9999,
2006                body,
2007            )
2008            .unwrap();
2009            stream.flush().unwrap();
2010            // Normal FIN close: the truncated body (9999 declared vs body.len()
2011            // sent) makes the client's next read error, deterministically.
2012        });
2013
2014        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2015
2016        let provider =
2017            OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "test-stream-model").unwrap();
2018        let completion = provider
2019            .stream(&[Message::user("hi")], &[], None, None)
2020            .await
2021            .expect("partial content must be returned, not Err");
2022        assert_eq!(
2023            completion.content, "partial",
2024            "accumulated delta must survive the mid-stream read error",
2025        );
2026        assert_eq!(
2027            completion.finish_reason.as_deref(),
2028            Some("interrupted"),
2029            "partial completion must carry the interrupted sentinel",
2030        );
2031        assert!(
2032            completion.tool_calls.is_empty(),
2033            "half-received tool calls must be dropped from a partial completion",
2034        );
2035    }
2036
2037    #[tokio::test]
2038    async fn stream_fallback_delegates_to_complete() {
2039        // MockProvider doesn't override stream, so it falls back to complete.
2040        // Verify the fallback path works by using a MockProvider.
2041        use crate::llm::MockProvider;
2042        let provider = MockProvider::new(vec![super::Completion {
2043            content: "fallback works".to_string(),
2044            tool_calls: vec![],
2045            finish_reason: Some("stop".to_string()),
2046            usage: None,
2047            reasoning_content: None,
2048        }]);
2049        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2050        let completion = provider
2051            .stream(&[Message::user("hi")], &[], Some(tx), None)
2052            .await
2053            .unwrap();
2054        assert_eq!(completion.content, "fallback works");
2055        // Should have received the full content as a single delta
2056        let delta = rx.recv().await.unwrap();
2057        assert_eq!(delta, StreamChunk::Text("fallback works".to_string()));
2058    }
2059
2060    #[test]
2061    fn policy_retries_5xx_with_exponential_backoff() {
2062        let policy = RetryPolicy::default(); // max_retries = 2
2063                                             // Attempt 0: should return initial_backoff (1s)
2064        assert_eq!(
2065            policy.backoff_for(0, Some(503), false),
2066            Some(Duration::from_secs(1))
2067        );
2068        // Attempt 1: should return 2s (1s * 2^1)
2069        assert_eq!(
2070            policy.backoff_for(1, Some(500), false),
2071            Some(Duration::from_secs(2))
2072        );
2073        // Attempt 2: should return None (exceeds max_retries=2)
2074        assert_eq!(policy.backoff_for(2, Some(500), false), None);
2075    }
2076
2077    #[test]
2078    fn policy_retries_network_errors() {
2079        let policy = RetryPolicy::default();
2080        // Network error at attempt 0 should return initial_backoff
2081        assert_eq!(
2082            policy.backoff_for(0, None, true),
2083            Some(Duration::from_secs(1))
2084        );
2085    }
2086
2087    #[test]
2088    fn policy_does_not_retry_4xx() {
2089        let policy = RetryPolicy::default();
2090        // Non-rate-limit 4xx errors should not be retried
2091        assert_eq!(policy.backoff_for(0, Some(400), false), None);
2092        assert_eq!(policy.backoff_for(0, Some(401), false), None);
2093        assert_eq!(policy.backoff_for(0, Some(404), false), None);
2094        // 429 (rate limit) IS retried with backoff
2095        assert!(policy.backoff_for(0, Some(429), false).is_some());
2096    }
2097
2098    #[test]
2099    fn policy_caps_backoff_at_max() {
2100        let policy = RetryPolicy {
2101            max_retries: 10,
2102            initial_backoff: Duration::from_secs(1),
2103            max_backoff: Duration::from_secs(3),
2104        };
2105        // At attempt 5, exponential backoff would be 32s but capped to 3s
2106        assert_eq!(
2107            policy.backoff_for(5, Some(500), false),
2108            Some(Duration::from_secs(3))
2109        );
2110    }
2111
2112    #[tokio::test]
2113    async fn openai_structured_includes_schema_in_request_body() {
2114        // Spawn a mock server that captures the request body and returns
2115        // a valid JSON response. Assert the request body contains the
2116        // response_format block with the schema.
2117        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2118        let addr = listener.local_addr().unwrap();
2119        let captured = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
2120        let captured_clone = captured.clone();
2121
2122        std::thread::spawn(move || {
2123            let (mut stream, _) = listener.accept().unwrap();
2124            use std::io::{Read, Write};
2125            let mut buf = [0u8; 8192];
2126            let n = stream.read(&mut buf).unwrap();
2127            let request = String::from_utf8_lossy(&buf[..n]).to_string();
2128            // Extract the JSON body (after the blank line)
2129            if let Some(body_start) = request.find("\r\n\r\n") {
2130                let body = request[body_start + 4..].trim().to_string();
2131                *captured_clone.lock().unwrap() = body.clone();
2132            }
2133            let response_body = r#"{"choices":[{"message":{"role":"assistant","content":"{\"answer\":42}"},"finish_reason":"stop"}],"usage":null}"#;
2134            write!(
2135                stream,
2136                "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2137                response_body.len(),
2138                response_body,
2139            )
2140            .unwrap();
2141            stream.flush().unwrap();
2142        });
2143
2144        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2145
2146        let provider =
2147            OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "test-structured-model")
2148                .unwrap();
2149
2150        let schema = serde_json::json!({
2151            "type": "object",
2152            "properties": {
2153                "answer": {"type": "integer"}
2154            },
2155            "required": ["answer"]
2156        });
2157
2158        let req = StructuredRequest {
2159            messages: vec![Message::user("what is 6 times 7?".to_string())],
2160            schema: schema.clone(),
2161            schema_name: "math_answer".to_string(),
2162        };
2163
2164        let _ = provider.complete_structured(req).await;
2165
2166        // Check the captured request body
2167        let body_str = captured.lock().unwrap().clone();
2168        let body: serde_json::Value = serde_json::from_str(&body_str).unwrap();
2169
2170        // Assert response_format is present with the right shape
2171        let rf = body.get("response_format").unwrap();
2172        assert_eq!(rf["type"], "json_schema");
2173        assert_eq!(rf["json_schema"]["name"], "math_answer");
2174        assert_eq!(rf["json_schema"]["strict"], true);
2175        assert_eq!(rf["json_schema"]["schema"], schema);
2176    }
2177
2178    #[tokio::test]
2179    async fn openai_structured_parses_response_json() {
2180        // Spawn a mock server that returns a known JSON response.
2181        // Assert the parsed value matches.
2182        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2183        let addr = listener.local_addr().unwrap();
2184
2185        std::thread::spawn(move || {
2186            let (mut stream, _) = listener.accept().unwrap();
2187            use std::io::{Read, Write};
2188            let mut buf = [0u8; 8192];
2189            let _ = stream.read(&mut buf);
2190            // Return a JSON object with summary and kept_facts
2191            let response_body = r#"{"choices":[{"message":{"role":"assistant","content":"{\"summary\":\"test\",\"kept_facts\":[\"a\",\"b\"]}"},"finish_reason":"stop"}],"usage":null}"#;
2192            write!(
2193                stream,
2194                "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2195                response_body.len(),
2196                response_body,
2197            )
2198            .unwrap();
2199            stream.flush().unwrap();
2200        });
2201
2202        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2203
2204        let provider =
2205            OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "test-structured-model")
2206                .unwrap();
2207
2208        let schema = serde_json::json!({
2209            "type": "object",
2210            "properties": {
2211                "summary": {"type": "string"},
2212                "kept_facts": {"type": "array", "items": {"type": "string"}}
2213            },
2214            "required": ["summary", "kept_facts"]
2215        });
2216
2217        let req = StructuredRequest {
2218            messages: vec![Message::user("summarize".to_string())],
2219            schema,
2220            schema_name: "summary".to_string(),
2221        };
2222
2223        let result = provider.complete_structured(req).await;
2224        assert!(result.is_ok(), "got error: {:?}", result.err());
2225        let value = result.unwrap();
2226        assert_eq!(value["summary"], "test");
2227        assert_eq!(value["kept_facts"][0], "a");
2228        assert_eq!(value["kept_facts"][1], "b");
2229    }
2230
2231    // ── serialize_message targeted tests ──────────────────────────────────────
2232
2233    #[test]
2234    fn serialize_message_system_role() {
2235        // kills Role::System → "user" swap mutations
2236        let msg = Message::system("You are helpful.".to_string());
2237        let v = serialize_message(&msg);
2238        assert_eq!(v["role"].as_str(), Some("system"));
2239        assert_eq!(v["content"].as_str(), Some("You are helpful."));
2240    }
2241
2242    #[test]
2243    fn serialize_message_user_role() {
2244        // kills Role::User → "assistant" swap mutations
2245        let msg = Message::user("Hello".to_string());
2246        let v = serialize_message(&msg);
2247        assert_eq!(v["role"].as_str(), Some("user"));
2248    }
2249
2250    #[test]
2251    fn serialize_message_tool_role_includes_tool_call_id() {
2252        // kills `if let Some(id) = &m.tool_call_id` guard removal
2253        let msg = Message::tool_result("tc-abc".to_string(), "result text".to_string());
2254        let v = serialize_message(&msg);
2255        assert_eq!(v["role"].as_str(), Some("tool"));
2256        assert_eq!(v["tool_call_id"].as_str(), Some("tc-abc"));
2257    }
2258
2259    #[test]
2260    fn serialize_message_assistant_includes_reasoning_content() {
2261        // kills `if matches!(m.role, Role::Assistant)` guard removal
2262        // and the reasoning_content insertion
2263        let mut msg = Message::assistant("answer".to_string());
2264        msg.reasoning_content = Some("thinking step".to_string());
2265        let v = serialize_message(&msg);
2266        assert_eq!(v["reasoning_content"].as_str(), Some("thinking step"));
2267    }
2268
2269    #[test]
2270    fn serialize_message_non_assistant_omits_reasoning_content() {
2271        // kills removal of `if matches!(m.role, Role::Assistant)` guard —
2272        // without the guard, user messages would also get reasoning_content
2273        let mut msg = Message::user("hello".to_string());
2274        msg.reasoning_content = Some("should not appear".to_string());
2275        let v = serialize_message(&msg);
2276        assert!(
2277            v.get("reasoning_content").is_none()
2278                || v["reasoning_content"] == serde_json::Value::Null,
2279            "non-assistant messages must not include reasoning_content"
2280        );
2281    }
2282
2283    #[tokio::test]
2284    async fn post_json_with_retry_gives_up_after_max_empty_body_retries() {
2285        // Kills `attempt += 1 -> -= 1` (usize underflow panic) and
2286        // `+= 1 -> *= 1` (attempt stuck → infinite retry → test-timeout)
2287        // at the HTTP-200-empty-body retry site (line 168). An always-empty
2288        // 200 body makes real code give up after max_retries with Err.
2289        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2290        let addr = listener.local_addr().unwrap();
2291        std::thread::spawn(move || {
2292            use std::io::{Read, Write};
2293            loop {
2294                if let Ok((mut stream, _)) = listener.accept() {
2295                    let mut buf = [0u8; 4096];
2296                    let _ = stream.read(&mut buf);
2297                    write!(
2298                        stream,
2299                        "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
2300                    )
2301                    .unwrap();
2302                    stream.flush().unwrap();
2303                }
2304            }
2305        });
2306        tokio::time::sleep(Duration::from_millis(50)).await;
2307
2308        let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m")
2309            .unwrap()
2310            .with_retry_policy(RetryPolicy {
2311                max_retries: 2,
2312                initial_backoff: Duration::from_millis(5),
2313                max_backoff: Duration::from_millis(10),
2314            });
2315        let result = provider
2316            .post_json_with_retry(
2317                &format!("http://{addr}/chat"),
2318                &serde_json::json!({}),
2319                "test",
2320            )
2321            .await;
2322        assert!(
2323            result.is_err(),
2324            "must give up after max_retries empty-body responses"
2325        );
2326    }
2327
2328    #[tokio::test]
2329    async fn post_json_with_retry_gives_up_after_max_transient_http_retries() {
2330        // Kills `attempt += 1` mutants at the transient-HTTP retry site
2331        // (line 191): always-503 server → real code gives up with Err.
2332        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2333        let addr = listener.local_addr().unwrap();
2334        std::thread::spawn(move || {
2335            use std::io::{Read, Write};
2336            loop {
2337                if let Ok((mut stream, _)) = listener.accept() {
2338                    let mut buf = [0u8; 4096];
2339                    let _ = stream.read(&mut buf);
2340                    let body = r#"{"error":"boom"}"#;
2341                    write!(
2342                        stream,
2343                        "HTTP/1.1 503 Service Unavailable\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2344                        body.len(),
2345                        body
2346                    )
2347                    .unwrap();
2348                    stream.flush().unwrap();
2349                }
2350            }
2351        });
2352        tokio::time::sleep(Duration::from_millis(50)).await;
2353
2354        let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m")
2355            .unwrap()
2356            .with_retry_policy(RetryPolicy {
2357                max_retries: 2,
2358                initial_backoff: Duration::from_millis(5),
2359                max_backoff: Duration::from_millis(10),
2360            });
2361        let result = provider
2362            .post_json_with_retry(
2363                &format!("http://{addr}/chat"),
2364                &serde_json::json!({}),
2365                "test",
2366            )
2367            .await;
2368        assert!(
2369            result.is_err(),
2370            "must give up after max_retries transient HTTP failures"
2371        );
2372    }
2373
2374    #[tokio::test]
2375    async fn post_json_with_retry_gives_up_after_max_network_retries() {
2376        // Kills `attempt += 1 -> *= 1` at the network-error retry site
2377        // (line 207): server accepts and drops 3 times → real code gives up.
2378        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2379        let addr = listener.local_addr().unwrap();
2380        std::thread::spawn(move || {
2381            for _ in 0..3 {
2382                if let Ok((stream, _)) = listener.accept() {
2383                    drop(stream);
2384                }
2385            }
2386        });
2387        tokio::time::sleep(Duration::from_millis(50)).await;
2388
2389        let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m")
2390            .unwrap()
2391            .with_retry_policy(RetryPolicy {
2392                max_retries: 2,
2393                initial_backoff: Duration::from_millis(5),
2394                max_backoff: Duration::from_millis(10),
2395            });
2396        let result = provider
2397            .post_json_with_retry(
2398                &format!("http://{addr}/chat"),
2399                &serde_json::json!({}),
2400                "test",
2401            )
2402            .await;
2403        assert!(
2404            result.is_err(),
2405            "must give up after max_retries network failures"
2406        );
2407    }
2408
2409    #[tokio::test]
2410    async fn stream_inner_gives_up_after_max_transient_http_retries() {
2411        // Kills `attempt += 1` mutants at the stream_inner transient-HTTP
2412        // retry site (line 595): always-503 server → real code gives up.
2413        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2414        let addr = listener.local_addr().unwrap();
2415        std::thread::spawn(move || {
2416            use std::io::{Read, Write};
2417            loop {
2418                if let Ok((mut stream, _)) = listener.accept() {
2419                    let mut buf = [0u8; 4096];
2420                    let _ = stream.read(&mut buf);
2421                    let body = r#"{"error":"boom"}"#;
2422                    write!(
2423                        stream,
2424                        "HTTP/1.1 503 Service Unavailable\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2425                        body.len(),
2426                        body
2427                    )
2428                    .unwrap();
2429                    stream.flush().unwrap();
2430                }
2431            }
2432        });
2433        tokio::time::sleep(Duration::from_millis(50)).await;
2434
2435        let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m")
2436            .unwrap()
2437            .with_retry_policy(RetryPolicy {
2438                max_retries: 2,
2439                initial_backoff: Duration::from_millis(5),
2440                max_backoff: Duration::from_millis(10),
2441            });
2442        let result = provider
2443            .stream_inner(&[Message::user("hi")], &[], None, None)
2444            .await;
2445        assert!(
2446            result.is_err(),
2447            "stream_inner must give up after max_retries transient HTTP failures"
2448        );
2449    }
2450
2451    #[tokio::test]
2452    async fn stream_inner_gives_up_after_max_network_retries() {
2453        // Kills `attempt += 1` mutants at the stream_inner network-error
2454        // retry site (line 610): server accepts and drops 3 times.
2455        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2456        let addr = listener.local_addr().unwrap();
2457        std::thread::spawn(move || {
2458            for _ in 0..3 {
2459                if let Ok((stream, _)) = listener.accept() {
2460                    drop(stream);
2461                }
2462            }
2463        });
2464        tokio::time::sleep(Duration::from_millis(50)).await;
2465
2466        let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m")
2467            .unwrap()
2468            .with_retry_policy(RetryPolicy {
2469                max_retries: 2,
2470                initial_backoff: Duration::from_millis(5),
2471                max_backoff: Duration::from_millis(10),
2472            });
2473        let result = provider
2474            .stream_inner(&[Message::user("hi")], &[], None, None)
2475            .await;
2476        assert!(
2477            result.is_err(),
2478            "stream_inner must give up after max_retries network failures"
2479        );
2480    }
2481
2482    #[tokio::test]
2483    async fn stream_with_search_without_deferred_delegates_to_stream() {
2484        // Kills `-> Ok(Default::default())` (302:9) in stream_with_search:
2485        // with no deferred tools it must delegate to stream_inner and return
2486        // the streamed content (not an empty default).
2487        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2488        let addr = listener.local_addr().unwrap();
2489        std::thread::spawn(move || {
2490            let (mut stream, _) = listener.accept().unwrap();
2491            use std::io::{Read, Write};
2492            let mut buf = [0u8; 4096];
2493            let _ = stream.read(&mut buf);
2494            let body = "data: {\"choices\":[{\"delta\":{\"content\":\"hello search\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n";
2495            write!(
2496                stream,
2497                "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2498                body.len(),
2499                body,
2500            )
2501            .unwrap();
2502            stream.flush().unwrap();
2503        });
2504        tokio::time::sleep(Duration::from_millis(50)).await;
2505
2506        let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
2507        let completion = provider
2508            .stream_with_search(&[Message::user("hi")], &[], &[], None, None)
2509            .await
2510            .expect("stream_with_search without deferred tools should succeed");
2511        assert_eq!(completion.content, "hello search");
2512    }
2513
2514    #[tokio::test]
2515    async fn stream_search_loop_resolves_round_trip_and_stops() {
2516        // Kills `-> Ok(Default::default())` (460:9), `== -> !=` (473:30) and
2517        // `+ -> -` / `+ -> *` (529:19) in run_stream_search_loop: a round-0
2518        // ToolSearchTool call must be resolved via a tool result and the
2519        // round-1 (non-search) completion returned. The `-` mutant underflows
2520        // (panic), `*` keeps round at 0 → infinite recursion (test-timeout).
2521        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2522        let addr = listener.local_addr().unwrap();
2523        std::thread::spawn(move || {
2524            use std::io::{Read, Write};
2525            loop {
2526                if let Ok((mut stream, _)) = listener.accept() {
2527                    let mut buf = [0u8; 65536];
2528                    let _ = stream.read(&mut buf);
2529                    let req = String::from_utf8_lossy(&buf);
2530                    let body = if req.contains("\"role\":\"tool\"") {
2531                        // Round 1+: search resolved → final text
2532                        "data: {\"choices\":[{\"delta\":{\"content\":\"final answer\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"
2533                    } else {
2534                        // Round 0: ask for a ToolSearchTool call
2535                        "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"ts1\",\"function\":{\"name\":\"ToolSearchTool\",\"arguments\":\"{\\\"query\\\":\\\"x\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\ndata: [DONE]\n\n"
2536                    };
2537                    write!(
2538                        stream,
2539                        "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2540                        body.len(),
2541                        body,
2542                    )
2543                    .unwrap();
2544                    stream.flush().unwrap();
2545                }
2546            }
2547        });
2548        tokio::time::sleep(Duration::from_millis(50)).await;
2549
2550        let deferred = vec![(make_tool_spec("MyTool"), None)];
2551        let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m")
2552            .unwrap()
2553            .with_search_engine(Arc::new(AlwaysReturnsEngine(vec!["MyTool".to_string()])));
2554        let completion = provider
2555            .stream_with_search(&[Message::user("hi")], &[], &deferred, None, None)
2556            .await
2557            .expect("stream search loop should complete");
2558        assert_eq!(completion.content, "final answer");
2559        assert!(
2560            completion.tool_calls.is_empty(),
2561            "round-1 completion must not carry a search call"
2562        );
2563    }
2564
2565    #[tokio::test]
2566    async fn stream_search_loop_honors_zero_max_rounds() {
2567        // Kills `>= -> <` (480:18): with max_search_rounds=0 the FIRST search
2568        // call must be returned as-is; the `<` mutant would recurse forever
2569        // (round < 0 is never true → test-timeout).
2570        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2571        let addr = listener.local_addr().unwrap();
2572        std::thread::spawn(move || {
2573            use std::io::{Read, Write};
2574            loop {
2575                if let Ok((mut stream, _)) = listener.accept() {
2576                    let mut buf = [0u8; 65536];
2577                    let _ = stream.read(&mut buf);
2578                    let body = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"ts1\",\"function\":{\"name\":\"ToolSearchTool\",\"arguments\":\"{\\\"query\\\":\\\"x\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\ndata: [DONE]\n\n";
2579                    write!(
2580                        stream,
2581                        "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2582                        body.len(),
2583                        body,
2584                    )
2585                    .unwrap();
2586                    stream.flush().unwrap();
2587                }
2588            }
2589        });
2590        tokio::time::sleep(Duration::from_millis(50)).await;
2591
2592        let deferred = vec![(make_tool_spec("MyTool"), None)];
2593        let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m")
2594            .unwrap()
2595            .with_search_engine(Arc::new(AlwaysReturnsEngine(vec![])))
2596            .with_max_search_rounds(0);
2597        let completion = provider
2598            .stream_with_search(&[Message::user("hi")], &[], &deferred, None, None)
2599            .await
2600            .expect("round-0 search call must be returned as-is");
2601        assert!(
2602            !completion.tool_calls.is_empty(),
2603            "the round-0 search-call completion must be returned when max rounds is 0"
2604        );
2605    }
2606
2607    #[tokio::test]
2608    async fn stream_search_loop_stops_after_max_rounds_with_persistent_search_calls() {
2609        // Kills `+ -> *` (529:19): `round * 1` keeps round at 0, so a server
2610        // that ALWAYS returns a ToolSearchTool call makes the mutant recurse
2611        // forever (test-timeout), while real code stops at max_search_rounds
2612        // (default 3 → 4 stream calls) and returns the last completion.
2613        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2614        let addr = listener.local_addr().unwrap();
2615        let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
2616        let call_count_clone = call_count.clone();
2617        std::thread::spawn(move || {
2618            use std::io::{Read, Write};
2619            loop {
2620                if let Ok((mut stream, _)) = listener.accept() {
2621                    call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2622                    let mut buf = [0u8; 65536];
2623                    let _ = stream.read(&mut buf);
2624                    let body = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"ts1\",\"function\":{\"name\":\"ToolSearchTool\",\"arguments\":\"{\\\"query\\\":\\\"x\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\ndata: [DONE]\n\n";
2625                    write!(
2626                        stream,
2627                        "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2628                        body.len(),
2629                        body,
2630                    )
2631                    .unwrap();
2632                    stream.flush().unwrap();
2633                }
2634            }
2635        });
2636        tokio::time::sleep(Duration::from_millis(50)).await;
2637
2638        let deferred = vec![(make_tool_spec("MyTool"), None)];
2639        let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m")
2640            .unwrap()
2641            .with_search_engine(Arc::new(AlwaysReturnsEngine(vec![])));
2642        let completion = provider
2643            .stream_with_search(&[Message::user("hi")], &[], &deferred, None, None)
2644            .await
2645            .expect("loop must terminate at max_search_rounds");
2646        assert!(
2647            !completion.tool_calls.is_empty(),
2648            "the last search-call completion must be returned when rounds are exhausted"
2649        );
2650        assert_eq!(
2651            call_count.load(std::sync::atomic::Ordering::SeqCst),
2652            4,
2653            "rounds 0..=3 = 4 stream calls with default max_search_rounds=3"
2654        );
2655    }
2656
2657    #[tokio::test]
2658    async fn parse_sse_stream_returns_partial_when_only_reasoning_accumulated() {
2659        // Kills `have_partial` reasoning-condition mutants (delete `!` on
2660        // `!reasoning_content.is_empty()`, `|| -> &&` at 705): a stream that
2661        // carried ONLY reasoning must still return a partial Completion on a
2662        // mid-stream read error.
2663        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2664        let addr = listener.local_addr().unwrap();
2665        std::thread::spawn(move || {
2666            let (mut stream, _) = listener.accept().unwrap();
2667            use std::io::{Read, Write};
2668            let mut buf = [0u8; 4096];
2669            let _ = stream.read(&mut buf);
2670            let body = "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"Let me think\"},\"finish_reason\":null}]}\n\n";
2671            write!(
2672                stream,
2673                "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2674                9999,
2675                body,
2676            )
2677            .unwrap();
2678            stream.flush().unwrap();
2679        });
2680        tokio::time::sleep(Duration::from_millis(50)).await;
2681
2682        let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
2683        let completion = provider
2684            .stream(&[Message::user("hi")], &[], None, None)
2685            .await
2686            .expect("reasoning-only partial must be returned, not Err");
2687        assert_eq!(completion.finish_reason.as_deref(), Some("interrupted"));
2688        assert_eq!(completion.content, "");
2689        assert_eq!(
2690            completion.reasoning_content.as_deref(),
2691            Some("Let me think"),
2692            "accumulated reasoning must survive the mid-stream error"
2693        );
2694    }
2695
2696    #[tokio::test]
2697    async fn parse_sse_stream_returns_partial_when_only_tool_call_started() {
2698        // Kills `have_partial` tool-call-condition mutants (delete `!` on
2699        // `!tool_call_builders.is_empty()`, `|| -> &&` at 705): a stream that
2700        // started a tool call (but no text) must still return a partial
2701        // Completion on a mid-stream read error, with the half-received tool
2702        // call dropped (orphan risk).
2703        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2704        let addr = listener.local_addr().unwrap();
2705        std::thread::spawn(move || {
2706            let (mut stream, _) = listener.accept().unwrap();
2707            use std::io::{Read, Write};
2708            let mut buf = [0u8; 4096];
2709            let _ = stream.read(&mut buf);
2710            let body = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"Read\",\"arguments\":\"{\\\"path\\\":\\\"x\\\"}\"}}]},\"finish_reason\":null}]}\n\n";
2711            write!(
2712                stream,
2713                "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2714                9999,
2715                body,
2716            )
2717            .unwrap();
2718            stream.flush().unwrap();
2719        });
2720        tokio::time::sleep(Duration::from_millis(50)).await;
2721
2722        let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
2723        let completion = provider
2724            .stream(&[Message::user("hi")], &[], None, None)
2725            .await
2726            .expect("tool-call-only partial must be returned, not Err");
2727        assert_eq!(completion.finish_reason.as_deref(), Some("interrupted"));
2728        assert_eq!(completion.content, "");
2729        assert!(
2730            completion.tool_calls.is_empty(),
2731            "half-received tool calls must be dropped from a partial completion"
2732        );
2733    }
2734
2735    #[tokio::test]
2736    async fn parse_sse_stream_processes_trailing_line_without_newline() {
2737        // Kills `delete !` on `if !line_buf.is_empty()` (769): a final SSE
2738        // `data:` line without a terminating newline must still be processed.
2739        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2740        let addr = listener.local_addr().unwrap();
2741        std::thread::spawn(move || {
2742            let (mut stream, _) = listener.accept().unwrap();
2743            use std::io::{Read, Write};
2744            let mut buf = [0u8; 4096];
2745            let _ = stream.read(&mut buf);
2746            // NOTE: the last delta line has NO trailing newline.
2747            let body = "data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\" world\"},\"finish_reason\":null}]}";
2748            write!(
2749                stream,
2750                "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2751                body.len(),
2752                body,
2753            )
2754            .unwrap();
2755            stream.flush().unwrap();
2756        });
2757        tokio::time::sleep(Duration::from_millis(50)).await;
2758
2759        let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
2760        let completion = provider
2761            .stream(&[Message::user("hi")], &[], None, None)
2762            .await
2763            .expect("stream should complete");
2764        assert_eq!(
2765            completion.content, "Hello world",
2766            "trailing line without newline must still be processed"
2767        );
2768    }
2769
2770    #[tokio::test]
2771    async fn build_completion_keeps_tool_call_with_empty_id_but_name() {
2772        // Kills `&& -> ||` at 810 in build_completion: a tool call is only
2773        // DROPPED when BOTH id and name are empty. A call with an empty id
2774        // but a real name must be kept.
2775        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2776        let addr = listener.local_addr().unwrap();
2777        std::thread::spawn(move || {
2778            let (mut stream, _) = listener.accept().unwrap();
2779            use std::io::{Read, Write};
2780            let mut buf = [0u8; 4096];
2781            let _ = stream.read(&mut buf);
2782            // No `id` field → id stays ""; name "Read" is set.
2783            let body = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"Read\",\"arguments\":\"{\\\"path\\\":\\\"x\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\ndata: [DONE]\n\n";
2784            write!(
2785                stream,
2786                "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2787                body.len(),
2788                body,
2789            )
2790            .unwrap();
2791            stream.flush().unwrap();
2792        });
2793        tokio::time::sleep(Duration::from_millis(50)).await;
2794
2795        let provider = OpenAiProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
2796        let completion = provider
2797            .stream(&[Message::user("hi")], &[], None, None)
2798            .await
2799            .expect("stream should complete");
2800        assert_eq!(
2801            completion.tool_calls.len(),
2802            1,
2803            "tool call with empty id but real name must be kept"
2804        );
2805        assert_eq!(completion.tool_calls[0].name, "Read");
2806    }
2807
2808    #[test]
2809    fn process_sse_line_accumulates_non_empty_reasoning() {
2810        // Kills `delete !` on `!delta_reasoning.is_empty()` (879): non-empty
2811        // reasoning_content deltas must be accumulated.
2812        let mut content = String::new();
2813        let mut reasoning = String::new();
2814        let mut tcb = std::collections::HashMap::new();
2815        let mut fr = None;
2816        let mut usage = None;
2817        OpenAiProvider::process_sse_line(
2818            "data: {\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"abc\"},\"finish_reason\":null}]}",
2819            "m",
2820            &mut content,
2821            &mut reasoning,
2822            &mut tcb,
2823            &mut fr,
2824            &mut usage,
2825            &None,
2826        )
2827        .unwrap();
2828        assert_eq!(reasoning, "abc");
2829    }
2830
2831    #[test]
2832    fn process_sse_line_sets_tool_call_id_and_name() {
2833        // Kills `delete !` on `!id.is_empty()` (896) and `!name.is_empty()`
2834        // (902): tool_calls deltas must fill the builder's id and name.
2835        let mut content = String::new();
2836        let mut reasoning = String::new();
2837        let mut tcb = std::collections::HashMap::new();
2838        let mut fr = None;
2839        let mut usage = None;
2840        OpenAiProvider::process_sse_line(
2841            "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_9\",\"function\":{\"name\":\"Read\",\"arguments\":\"{}\"}}]},\"finish_reason\":null}]}",
2842            "m",
2843            &mut content,
2844            &mut reasoning,
2845            &mut tcb,
2846            &mut fr,
2847            &mut usage,
2848            &None,
2849        )
2850        .unwrap();
2851        let entry = tcb.get(&0).expect("tool call builder entry must exist");
2852        assert_eq!(entry.0, "call_9");
2853        assert_eq!(entry.1, "Read");
2854    }
2855
2856    #[test]
2857    fn process_sse_line_cache_miss_prefers_explicit() {
2858        // Kills `> -> <` on `explicit_miss > 0` (949): an explicit non-zero
2859        // miss must win over the derived prompt - hit value.
2860        let mut content = String::new();
2861        let mut reasoning = String::new();
2862        let mut tcb = std::collections::HashMap::new();
2863        let mut fr = None;
2864        let mut usage = None;
2865        OpenAiProvider::process_sse_line(
2866            "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":2,\"total_tokens\":12,\"prompt_cache_hit_tokens\":3,\"prompt_cache_miss_tokens\":5}}",
2867            "m",
2868            &mut content,
2869            &mut reasoning,
2870            &mut tcb,
2871            &mut fr,
2872            &mut usage,
2873            &None,
2874        )
2875        .unwrap();
2876        let u = usage.expect("usage must be set");
2877        assert_eq!(u.cache_miss_tokens, 5);
2878    }
2879
2880    #[test]
2881    fn process_sse_line_cache_miss_zero_when_no_hit() {
2882        // Kills `> -> >=` on `cache_hit > 0` (951): with no hit and no
2883        // explicit miss, the miss must be 0 (not prompt - 0).
2884        let mut content = String::new();
2885        let mut reasoning = String::new();
2886        let mut tcb = std::collections::HashMap::new();
2887        let mut fr = None;
2888        let mut usage = None;
2889        OpenAiProvider::process_sse_line(
2890            "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":2,\"total_tokens\":12,\"prompt_cache_hit_tokens\":0,\"prompt_cache_miss_tokens\":0}}",
2891            "m",
2892            &mut content,
2893            &mut reasoning,
2894            &mut tcb,
2895            &mut fr,
2896            &mut usage,
2897            &None,
2898        )
2899        .unwrap();
2900        let u = usage.expect("usage must be set");
2901        assert_eq!(u.cache_miss_tokens, 0);
2902    }
2903
2904    #[test]
2905    fn to_token_usage_cache_miss_prefers_explicit_positive() {
2906        // Kills `match guard m > 0 -> false` (1138:24) and `> -> <`
2907        // (1138:26) in ResponseUsage::to_token_usage: an explicit positive
2908        // miss must be honored over deriving from prompt - hit.
2909        let ru = ResponseUsage {
2910            prompt_tokens: Some(10),
2911            completion_tokens: Some(2),
2912            total_tokens: Some(12),
2913            prompt_cache_hit_tokens: Some(0),
2914            prompt_cache_miss_tokens: Some(5),
2915            prompt_tokens_details: None,
2916            completion_tokens_details: None,
2917        };
2918        let tu = ru.to_token_usage();
2919        assert_eq!(tu.cache_miss_tokens, 5);
2920    }
2921}