Skip to main content

llm_kernel/llm/
client.rs

1use std::time::Duration;
2
3use async_trait::async_trait;
4
5use crate::error::{KernelError, Result};
6use crate::llm::tool::{ToolCall, ToolDefinition};
7use crate::llm::types::{
8    LLMRequest, LLMResponse, LLMStream, ModelConfig, ReasoningConfig, ReasoningEffort,
9    ResponseFormat, StreamEvent, TokenUsage, Verbosity,
10};
11
12/// Best-effort redaction of an HTTP error response body before it lands in a
13/// [`KernelError::Http`]. Some API gateways/proxies echo the request
14/// `Authorization` header inside error bodies; without this, a caller that logs
15/// the error leaks the API key. Full pattern masking when the `safety` feature
16/// is enabled; otherwise the body is passed through unchanged (the masking
17/// regex is an opt-in dependency).
18fn redact_http_body(body: &str) -> String {
19    #[cfg(feature = "safety")]
20    {
21        crate::safety::sanitize::mask_secrets(body)
22    }
23    #[cfg(not(feature = "safety"))]
24    {
25        body.to_string()
26    }
27}
28
29/// Convert kernel [`ToolDefinition`]s into OpenAI `tools` (`type: "function"`).
30fn openai_tools(tools: &[ToolDefinition]) -> Vec<serde_json::Value> {
31    tools
32        .iter()
33        .map(|t| {
34            serde_json::json!({
35                "type": "function",
36                "function": {
37                    "name": t.name,
38                    "description": t.description,
39                    "parameters": t.input_schema,
40                }
41            })
42        })
43        .collect()
44}
45
46/// Map a [`ResponseFormat`] to OpenAI's `response_format` object, or `None` for
47/// the provider default (plain text).
48fn openai_response_format(rf: &ResponseFormat) -> Option<serde_json::Value> {
49    match rf {
50        ResponseFormat::Text => None,
51        ResponseFormat::Json => Some(serde_json::json!({ "type": "json_object" })),
52        ResponseFormat::JsonSchema { schema } => Some(serde_json::json!({
53            "type": "json_schema",
54            "json_schema": { "name": "response", "schema": schema, "strict": true }
55        })),
56    }
57}
58
59/// Convert kernel [`ToolDefinition`]s into Anthropic `tools` (with `input_schema`).
60fn anthropic_tools(tools: &[ToolDefinition]) -> Vec<serde_json::Value> {
61    tools
62        .iter()
63        .map(|t| {
64            serde_json::json!({
65                "name": t.name,
66                "description": t.description,
67                "input_schema": t.input_schema,
68            })
69        })
70        .collect()
71}
72
73/// Map a [`ResponseFormat`] and an optional [`ReasoningEffort`] to Anthropic's
74/// `output_config`. [`ResponseFormat::JsonSchema`] maps to
75/// `output_config.format`; `effort` maps to `output_config.effort`, which is
76/// how Claude 5.5-class models set thinking depth (`low`/`medium`/`high`/
77/// `xhigh`/`max` — adaptive thinking is always on there and the legacy
78/// `thinking.budget_tokens` form is rejected). `none`/`minimal` are
79/// OpenAI-only values and forwarded verbatim (the API rejects them — a silent
80/// drop would hide caller bugs). `Json` (schemaless) and `Text` contribute
81/// nothing. Returns `None` when nothing maps (provider default).
82fn anthropic_output_config(
83    rf: Option<&ResponseFormat>,
84    effort: Option<ReasoningEffort>,
85) -> Option<serde_json::Value> {
86    let mut obj = serde_json::Map::new();
87    if let Some(ResponseFormat::JsonSchema { schema }) = rf {
88        obj.insert(
89            "format".into(),
90            serde_json::json!({ "type": "json_schema", "schema": schema }),
91        );
92    }
93    if let Some(effort) = effort {
94        obj.insert(
95            "effort".into(),
96            serde_json::to_value(effort).expect("ReasoningEffort is a plain enum"),
97        );
98    }
99    if obj.is_empty() {
100        None
101    } else {
102        Some(serde_json::Value::Object(obj))
103    }
104}
105
106/// Map kernel [`ReasoningConfig`] to the wire keys the OpenAI-compatible path
107/// forwards: `effort` becomes the official top-level `reasoning_effort`
108/// parameter; `enabled` (OpenRouter extension) and `summary` (official
109/// Responses-API `reasoning.summary`) share one `reasoning` object. Each key
110/// is emitted only when set, so a `None` config (or an unset knob) adds
111/// nothing to the request body.
112fn openai_reasoning(
113    cfg: Option<&ReasoningConfig>,
114) -> (Option<ReasoningEffort>, Option<serde_json::Value>) {
115    match cfg {
116        None => (None, None),
117        Some(c) => {
118            let mut obj = serde_json::Map::new();
119            if let Some(enabled) = c.enabled {
120                obj.insert("enabled".into(), enabled.into());
121            }
122            if let Some(summary) = c.summary {
123                obj.insert(
124                    "summary".into(),
125                    serde_json::to_value(summary).expect("ReasoningSummary is a plain enum"),
126                );
127            }
128            (
129                c.effort,
130                if obj.is_empty() {
131                    None
132                } else {
133                    Some(serde_json::Value::Object(obj))
134                },
135            )
136        }
137    }
138}
139
140/// Serialize the outgoing body and merge [`LLMRequest::extra_body`] keys into
141/// it (last-write-wins), for official spec parameters or provider extensions
142/// the kernel does not model natively.
143fn body_with_extra<T: serde::Serialize>(
144    body: &T,
145    extra: Option<&serde_json::Map<String, serde_json::Value>>,
146) -> serde_json::Value {
147    let mut v = serde_json::to_value(body).expect("request body serializes");
148    if let Some(extra) = extra
149        && let serde_json::Value::Object(map) = &mut v
150    {
151        for (k, val) in extra {
152            map.insert(k.clone(), val.clone());
153        }
154    }
155    v
156}
157
158/// Build a `reqwest::Client` with connect and total timeouts.
159fn http_client() -> Result<reqwest::Client> {
160    crate::tls::ensure_tls_provider();
161    reqwest::Client::builder()
162        .connect_timeout(Duration::from_secs(10))
163        .timeout(Duration::from_secs(120))
164        .build()
165        .map_err(|e| KernelError::Config(format!("Failed to build HTTP client: {}", e)))
166}
167
168/// Check for HTTP 429 rate-limit response and extract `retry-after` header.
169fn check_rate_limit(resp: &reqwest::Response) -> Result<()> {
170    if resp.status().as_u16() == 429 {
171        let retry = resp
172            .headers()
173            .get("retry-after")
174            .and_then(|v| v.to_str().ok())
175            .and_then(|v| v.parse().ok())
176            .unwrap_or(60);
177        return Err(KernelError::RateLimited(retry));
178    }
179    Ok(())
180}
181
182/// Unified async interface for LLM chat completion and streaming.
183#[async_trait]
184pub trait LLMClient: Send + Sync {
185    /// Send a chat completion request and return the full response.
186    ///
187    /// **Reasoning-model answer promotion (non-streaming only):** when a provider
188    /// returns the final answer in `reasoning_content` and leaves `content` empty
189    /// (notably GLM-4.7), `complete` promotes the reasoning into `content` so
190    /// downstream consumers transparently receive the answer. The original
191    /// reasoning is preserved in [`LLMResponse::reasoning`].
192    ///
193    /// This promotion is **not** applied by [`stream_complete`](Self::stream_complete),
194    /// which surfaces reasoning as separate [`StreamEvent::ReasoningDelta`] events —
195    /// see that variant's docs for the streaming accumulation contract.
196    async fn complete(&self, request: LLMRequest) -> Result<LLMResponse>;
197    /// Return the model name this client is configured to use.
198    fn model_name(&self) -> &str;
199
200    /// Stream a chat completion, yielding events as they arrive.
201    ///
202    /// Does **not** promote reasoning into a final `content` — see
203    /// [`StreamEvent::ReasoningDelta`] for why streaming consumers of
204    /// reasoning-only models (GLM-4.7) must accumulate both `ReasoningDelta` and
205    /// `Delta` to reconstruct the answer.
206    async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream>;
207}
208
209/// Async LLM client for the OpenAI chat completions API.
210pub struct OpenAIClient {
211    api_key: String,
212    model: String,
213    base_url: String,
214    client: reqwest::Client,
215}
216
217impl OpenAIClient {
218    /// Create a new client using credentials from the environment variable in `config`.
219    pub fn new(config: &ModelConfig) -> Result<Self> {
220        let api_key = std::env::var(&config.api_key_env).map_err(|_| {
221            KernelError::Config(format!(
222                "Environment variable {} not set",
223                config.api_key_env
224            ))
225        })?;
226        Ok(Self {
227            api_key,
228            model: config.model.clone(),
229            base_url: config
230                .base_url
231                .clone()
232                .unwrap_or_else(|| "https://api.openai.com/v1".into()),
233            client: http_client()?,
234        })
235    }
236
237    /// Create a new client with an explicit API key, using the default OpenAI base URL.
238    ///
239    /// Returns a [`KernelError::Config`] if the HTTP client (with its connect /
240    /// total timeouts) cannot be built, rather than silently falling back to a
241    /// timeout-less `reqwest::Client::default()`.
242    ///
243    /// # Example
244    ///
245    /// ```no_run
246    /// use llm_kernel::llm::OpenAIClient;
247    /// let client = OpenAIClient::from_key("gpt-4o-mini", "sk-...")?;
248    /// # Ok::<(), llm_kernel::error::KernelError>(())
249    /// ```
250    pub fn from_key(model: impl Into<String>, api_key: impl Into<String>) -> Result<Self> {
251        Ok(Self {
252            api_key: api_key.into(),
253            model: model.into(),
254            base_url: "https://api.openai.com/v1".into(),
255            client: http_client()?,
256        })
257    }
258
259    /// Create from an explicit key and a shared `reqwest::Client`.
260    ///
261    /// Prefer this over [`from_key`](Self::from_key) when constructing multiple
262    /// clients in a hot path — the shared client reuses the underlying TCP
263    /// connection pool.
264    pub fn from_key_with_client(
265        model: impl Into<String>,
266        api_key: impl Into<String>,
267        client: reqwest::Client,
268    ) -> Self {
269        Self {
270            api_key: api_key.into(),
271            model: model.into(),
272            base_url: "https://api.openai.com/v1".into(),
273            client,
274        }
275    }
276
277    /// Create from an explicit key, a custom base URL, and a shared `reqwest::Client`.
278    ///
279    /// Use this for OpenAI-compatible providers that are not the default OpenAI
280    /// endpoint (DeepSeek, Groq, Ollama, LM Studio, custom gateways, …) when you
281    /// already hold the key in memory and want to reuse a shared connection pool.
282    pub fn from_key_with_base_url(
283        model: impl Into<String>,
284        api_key: impl Into<String>,
285        base_url: impl Into<String>,
286        client: reqwest::Client,
287    ) -> Self {
288        Self {
289            api_key: api_key.into(),
290            model: model.into(),
291            base_url: base_url.into(),
292            client,
293        }
294    }
295}
296
297#[derive(serde::Serialize)]
298struct OpenAIChatRequest {
299    model: String,
300    messages: Vec<OpenAIChatMessage>,
301    temperature: f32,
302    #[serde(skip_serializing_if = "Option::is_none")]
303    max_tokens: Option<u32>,
304    #[serde(skip_serializing_if = "std::ops::Not::not")]
305    stream: bool,
306    #[serde(skip_serializing_if = "Option::is_none")]
307    tools: Option<Vec<serde_json::Value>>,
308    #[serde(skip_serializing_if = "Option::is_none")]
309    response_format: Option<serde_json::Value>,
310    /// Official OpenAI `reasoning_effort` parameter.
311    #[serde(skip_serializing_if = "Option::is_none")]
312    reasoning_effort: Option<ReasoningEffort>,
313    /// OpenRouter extension / Responses-style `reasoning` object.
314    #[serde(skip_serializing_if = "Option::is_none")]
315    reasoning: Option<serde_json::Value>,
316    /// Official OpenAI `verbosity` parameter.
317    #[serde(skip_serializing_if = "Option::is_none")]
318    verbosity: Option<Verbosity>,
319}
320
321#[derive(serde::Serialize)]
322struct OpenAIChatMessage {
323    role: String,
324    content: String,
325}
326
327#[derive(serde::Deserialize)]
328struct OpenAIChatResponse {
329    #[serde(default)]
330    id: Option<String>,
331    #[serde(default)]
332    created: Option<u64>,
333    choices: Vec<OpenAIChoice>,
334    model: String,
335    usage: Option<OpenAIUsage>,
336}
337
338#[derive(serde::Deserialize)]
339struct OpenAIChoice {
340    message: OpenAIRespMessage,
341    #[serde(default)]
342    finish_reason: Option<String>,
343}
344
345/// Response-side assistant message. `content` is `null` on tool-call turns, so
346/// it is optional and defaults to empty.
347///
348/// Reasoning models (GLM-4.5+/z.ai, OpenAI o1) emit their chain-of-thought in
349/// `reasoning_content` and leave `content` null — see ADR below. DeepSeek-R1 uses
350/// the field name `reasoning`, covered via serde alias.
351#[derive(serde::Deserialize)]
352struct OpenAIRespMessage {
353    #[serde(default)]
354    content: Option<String>,
355    /// Reasoning model's chain-of-thought. Aliased from `reasoning` (DeepSeek-R1).
356    #[serde(default, alias = "reasoning")]
357    reasoning_content: Option<String>,
358    #[serde(default)]
359    tool_calls: Vec<OpenAIToolCall>,
360}
361
362#[derive(serde::Deserialize)]
363struct OpenAIToolCall {
364    id: String,
365    function: OpenAIFunctionCall,
366}
367
368#[derive(serde::Deserialize)]
369struct OpenAIFunctionCall {
370    name: String,
371    #[serde(default)]
372    arguments: String,
373}
374
375/// Token usage reported by OpenAI-compatible providers.
376///
377/// All fields are `#[serde(default)]`: some providers (and partial/error
378/// responses) omit `usage` fields, and a missing field should not cause the
379/// whole response to fail parsing — `total_tokens` can be recomputed downstream.
380#[derive(serde::Deserialize)]
381struct OpenAIUsage {
382    #[serde(default)]
383    prompt_tokens: u32,
384    #[serde(default)]
385    completion_tokens: u32,
386    #[serde(default)]
387    total_tokens: u32,
388    /// OpenAI o1 / GLM-4.7 expose reasoning token counts here.
389    #[serde(default)]
390    completion_tokens_details: Option<OpenAICompletionTokensDetails>,
391}
392
393/// Nested under `usage.completion_tokens_details` for reasoning models.
394#[derive(serde::Deserialize)]
395struct OpenAICompletionTokensDetails {
396    #[serde(default)]
397    reasoning_tokens: Option<u32>,
398}
399
400#[async_trait]
401impl LLMClient for OpenAIClient {
402    async fn complete(&self, request: LLMRequest) -> Result<LLMResponse> {
403        let model = request.model.clone().unwrap_or_else(|| self.model.clone());
404        // `None` is a caller opt-out of the sampling parameter; OpenAI keeps its
405        // historical default instead of omitting (behavior preserved from when
406        // `LLMRequest.temperature` was a plain `f32`).
407        let temperature = request.temperature.unwrap_or(0.7);
408        let max_tokens = request.max_tokens;
409        let tools = request
410            .tools
411            .as_deref()
412            .map(openai_tools)
413            .filter(|t| !t.is_empty());
414        let response_format = request
415            .response_format
416            .as_ref()
417            .and_then(openai_response_format);
418        let (reasoning_effort, reasoning) = openai_reasoning(request.reasoning.as_ref());
419        let verbosity = request.verbosity;
420        let extra_body = request.extra_body.clone();
421        let messages: Vec<_> = request
422            .into_openai_messages()
423            .into_iter()
424            .map(|(role, content)| OpenAIChatMessage { role, content })
425            .collect();
426
427        let body = OpenAIChatRequest {
428            model,
429            messages,
430            temperature,
431            max_tokens,
432            stream: false,
433            tools,
434            response_format,
435            reasoning_effort,
436            reasoning,
437            verbosity,
438        };
439
440        let resp = self
441            .client
442            .post(format!("{}/chat/completions", self.base_url))
443            .header("Authorization", format!("Bearer {}", self.api_key))
444            .json(&body_with_extra(&body, extra_body.as_ref()))
445            .send()
446            .await
447            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
448
449        check_rate_limit(&resp)?;
450
451        let status = resp.status();
452
453        if !status.is_success() {
454            let text = resp.text().await.unwrap_or_default();
455            return Err(KernelError::Http {
456                status: status.as_u16(),
457                message: redact_http_body(&text),
458            });
459        }
460
461        let text = resp
462            .text()
463            .await
464            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
465        let chat_resp: OpenAIChatResponse = from_json_lenient(&text)
466            .map_err(|e| KernelError::LlmApi(format!("error decoding response body: {e}")))?;
467
468        let id = chat_resp.id;
469        let created = chat_resp.created;
470        let first = chat_resp.choices.into_iter().next();
471        let finish_reason = first.as_ref().and_then(|c| c.finish_reason.clone());
472        let (content, reasoning, tool_calls) = match first {
473            Some(c) => {
474                let raw_content = c.message.content.unwrap_or_default();
475                let reasoning = c.message.reasoning_content;
476                let content = promote_reasoning_into_content(raw_content, reasoning.as_deref());
477                let calls = c
478                    .message
479                    .tool_calls
480                    .into_iter()
481                    .map(|tc| ToolCall {
482                        id: tc.id,
483                        name: tc.function.name,
484                        arguments: tc.function.arguments,
485                    })
486                    .collect();
487                (content, reasoning, calls)
488            }
489            None => (String::new(), None, Vec::new()),
490        };
491
492        let usage = chat_resp.usage.map(|u| TokenUsage {
493            prompt_tokens: u.prompt_tokens,
494            completion_tokens: u.completion_tokens,
495            total_tokens: u.total_tokens,
496            reasoning_tokens: u.completion_tokens_details.and_then(|d| d.reasoning_tokens),
497        });
498
499        Ok(LLMResponse {
500            content,
501            reasoning,
502            model: chat_resp.model,
503            usage: usage.unwrap_or_default(),
504            tool_calls,
505            finish_reason,
506            id,
507            created,
508        })
509    }
510
511    fn model_name(&self) -> &str {
512        &self.model
513    }
514
515    async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream> {
516        let model = request.model.clone().unwrap_or_else(|| self.model.clone());
517        // Same historical-default preservation as `complete`.
518        let temperature = request.temperature.unwrap_or(0.7);
519        let max_tokens = request.max_tokens;
520        let (reasoning_effort, reasoning) = openai_reasoning(request.reasoning.as_ref());
521        let verbosity = request.verbosity;
522        let extra_body = request.extra_body.clone();
523        let messages: Vec<_> = request
524            .into_openai_messages()
525            .into_iter()
526            .map(|(role, content)| OpenAIChatMessage { role, content })
527            .collect();
528
529        let body = OpenAIChatRequest {
530            model,
531            messages,
532            temperature,
533            max_tokens,
534            stream: true,
535            // Streaming is text-only here: the SSE parser emits text deltas and
536            // does not reassemble streamed tool-call fragments.
537            tools: None,
538            response_format: None,
539            reasoning_effort,
540            reasoning,
541            verbosity,
542        };
543
544        let resp = self
545            .client
546            .post(format!("{}/chat/completions", self.base_url))
547            .header("Authorization", format!("Bearer {}", self.api_key))
548            .json(&body_with_extra(&body, extra_body.as_ref()))
549            .send()
550            .await
551            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
552
553        check_rate_limit(&resp)?;
554
555        let status = resp.status();
556        if !status.is_success() {
557            let text = resp.text().await.unwrap_or_default();
558            return Err(KernelError::Http {
559                status: status.as_u16(),
560                message: redact_http_body(&text),
561            });
562        }
563
564        let (tx, rx) = tokio::sync::mpsc::channel::<Result<StreamEvent>>(16);
565
566        tokio::spawn(async move {
567            let mut stream = std::pin::pin!(resp.bytes_stream());
568            let mut buffer: Vec<u8> = Vec::new();
569
570            use tokio_stream::StreamExt;
571
572            while let Some(chunk) = stream.next().await {
573                let chunk = match chunk {
574                    Ok(c) => c,
575                    Err(e) => {
576                        let _ = tx.send(Err(KernelError::LlmApi(e.to_string()))).await;
577                        return;
578                    }
579                };
580
581                for line in drain_sse_lines(&mut buffer, &chunk) {
582                    if let Some(data) = parse_sse_line(&line)
583                        && let Some(event) = parse_openai_sse(data)
584                    {
585                        let is_done = matches!(event, StreamEvent::Done);
586                        if tx.send(Ok(event)).await.is_err() || is_done {
587                            return;
588                        }
589                    }
590                }
591            }
592            let _ = tx.send(Ok(StreamEvent::Done)).await;
593        });
594
595        Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
596    }
597}
598
599/// Extract the data payload from an SSE `data: ...` line.
600/// Returns `None` for non-data lines and for `data: [DONE]`.
601fn parse_sse_line(line: &str) -> Option<&str> {
602    line.strip_prefix("data: ").filter(|d| *d != "[DONE]")
603}
604
605/// Append a raw network chunk to `buffer` and drain every complete,
606/// newline-terminated line, decoded as UTF-8.
607///
608/// Decoding is deferred until a line's bytes are fully buffered. A single
609/// codepoint can straddle two network chunks, and decoding each chunk eagerly
610/// with [`String::from_utf8_lossy`] would replace the split bytes with `U+FFFD`
611/// — corrupting e.g. CJK or emoji deltas. Because `\n` (`0x0A`) is never a UTF-8
612/// lead or continuation byte, splitting on it can't cut a codepoint, so every
613/// drained line is a whole number of codepoints and decodes losslessly.
614fn drain_sse_lines(buffer: &mut Vec<u8>, chunk: &[u8]) -> Vec<String> {
615    buffer.extend_from_slice(chunk);
616    let mut lines = Vec::new();
617    while let Some(pos) = buffer.iter().position(|&b| b == b'\n') {
618        let line: Vec<u8> = buffer.drain(..=pos).collect();
619        lines.push(String::from_utf8_lossy(&line).trim_end().to_string());
620    }
621    lines
622}
623
624/// Decide the `content` exposed to consumers for a reasoning-model response.
625///
626/// # Two provider behaviors, one rule
627///
628/// - **GLM-4.7 (non-standard):** leaves `content` empty/null and returns the
629///   final answer inside `reasoning_content`. Promoting reasoning into `content`
630///   is required so downstream `json_extract` finds the JSON.
631/// - **Standard reasoning models (OpenAI o1, DeepSeek-R1):** put the final
632///   answer in `content` and the chain-of-thought in `reasoning_content`. When
633///   `content` is genuinely present it is preserved unchanged.
634///
635/// # Caveat — empty `content` on a standard model
636///
637/// The promotion triggers on *any* empty `content`. If a standard model ever
638/// returns an empty `content` together with reasoning (e.g. the model produced
639/// only chain-of-thought and no final answer, or a transport/parse glitch), this
640/// rule would surface the chain-of-thought as the answer. That is an acceptable
641/// trade-off: the alternative is an empty answer, which fails every downstream
642/// consumer the same way. The original reasoning is always preserved verbatim in
643/// [`LLMResponse::reasoning`], so callers can detect this case and reject it.
644fn promote_reasoning_into_content(raw_content: String, reasoning: Option<&str>) -> String {
645    if raw_content.is_empty() {
646        reasoning.unwrap_or_default().to_string()
647    } else {
648        raw_content
649    }
650}
651
652/// Escape raw C0 control characters (`0x00`–`0x1F`) inside JSON string
653/// literals as `\u00XX`.
654///
655/// Some OpenAI-compatible gateways (observed on OpenRouter) return HTTP 200
656/// bodies whose `reasoning` strings contain unescaped control characters
657/// (raw `\n`, `\t`, …), which strict JSON parsers reject wholesale. This
658/// walk-only pass rewrites each such character to its escape and leaves
659/// everything outside string literals untouched. Best effort: structurally
660/// broken JSON stays broken and the original parse error is surfaced.
661fn escape_unescaped_control_chars(raw: &str) -> String {
662    let mut out = String::with_capacity(raw.len());
663    let mut in_string = false;
664    let mut escaped = false;
665    for ch in raw.chars() {
666        if escaped {
667            escaped = false;
668            out.push(ch);
669            continue;
670        }
671        match ch {
672            '"' => {
673                in_string = !in_string;
674                out.push(ch);
675            }
676            '\\' if in_string => {
677                escaped = true;
678                out.push(ch);
679            }
680            c if in_string && (c as u32) < 0x20 => {
681                out.push_str(&format!("\\u{:04x}", c as u32));
682            }
683            c => out.push(c),
684        }
685    }
686    out
687}
688
689/// Parse `raw` as JSON, retrying once with control-character escaping when
690/// strict parsing fails.
691///
692/// Valid bodies take the strict path unchanged (zero overhead beyond the
693/// string read); only bodies that strict-JSON rejects get the sanitize pass.
694/// If the sanitized retry also fails, the *original* error is returned so
695/// callers see the real reason and not a secondary artifact.
696fn from_json_lenient<T: serde::de::DeserializeOwned>(raw: &str) -> serde_json::Result<T> {
697    match serde_json::from_str(raw) {
698        Ok(v) => Ok(v),
699        Err(err) => serde_json::from_str(&escape_unescaped_control_chars(raw)).map_err(|_| err),
700    }
701}
702
703/// Parse an OpenAI streaming JSON chunk into a StreamEvent.
704fn parse_openai_sse(data: &str) -> Option<StreamEvent> {
705    let v: serde_json::Value = from_json_lenient(data).ok()?;
706
707    // GLM-4.5+/o1 send reasoning and answer as separate delta chunks; check
708    // reasoning_content first so it is surfaced as ReasoningDelta, not dropped.
709    if let Some(rc) = v
710        .get("choices")?
711        .get(0)?
712        .get("delta")?
713        .get("reasoning_content")
714        .and_then(|c| c.as_str())
715        && !rc.is_empty()
716    {
717        return Some(StreamEvent::ReasoningDelta {
718            content: rc.to_string(),
719        });
720    }
721
722    // Extract delta content
723    if let Some(content) = v
724        .get("choices")?
725        .get(0)?
726        .get("delta")?
727        .get("content")
728        .and_then(|c| c.as_str())
729        && !content.is_empty()
730    {
731        return Some(StreamEvent::Delta {
732            content: content.to_string(),
733        });
734    }
735
736    // Extract usage from the final chunk
737    if let Some(usage) = v.get("usage").and_then(|u| {
738        Some(TokenUsage {
739            prompt_tokens: u.get("prompt_tokens")?.as_u64()? as u32,
740            completion_tokens: u.get("completion_tokens")?.as_u64()? as u32,
741            total_tokens: u.get("total_tokens")?.as_u64()? as u32,
742            reasoning_tokens: u
743                .get("completion_tokens_details")
744                .and_then(|d| d.get("reasoning_tokens"))
745                .and_then(|r| r.as_u64())
746                .map(|n| n as u32),
747        })
748    }) {
749        return Some(StreamEvent::Usage(usage));
750    }
751
752    // finish_reason = "stop" means done (no more content in this chunk)
753    if v.get("choices")?
754        .get(0)?
755        .get("finish_reason")
756        .and_then(|r| r.as_str())
757        .is_some()
758    {
759        return Some(StreamEvent::Done);
760    }
761
762    None
763}
764
765/// Sum of the input-side token fields of an Anthropic `usage` object
766/// (`input_tokens` + both cache fields). `None` when none are present.
767fn anthropic_input_tokens(u: &serde_json::Value) -> Option<u32> {
768    let fields = [
769        "input_tokens",
770        "cache_creation_input_tokens",
771        "cache_read_input_tokens",
772    ];
773    let values: Vec<u64> = fields
774        .iter()
775        .filter_map(|f| u.get(*f).and_then(|n| n.as_u64()))
776        .collect();
777    (!values.is_empty()).then(|| values.iter().sum::<u64>() as u32)
778}
779
780/// Parse an Anthropic streaming JSON chunk into zero or more StreamEvents.
781/// A `message_delta` can carry both a stop reason and final usage, so one
782/// chunk may yield several events.
783fn parse_anthropic_sse(event_type: &str, data: &str) -> Vec<StreamEvent> {
784    let Ok(v) = serde_json::from_str::<serde_json::Value>(data) else {
785        return Vec::new();
786    };
787
788    match event_type {
789        // Input-side usage (incl. prompt-cache tokens) only arrives here.
790        "message_start" => {
791            let Some(u) = v.get("message").and_then(|m| m.get("usage")) else {
792                return Vec::new();
793            };
794            let prompt = anthropic_input_tokens(u).unwrap_or(0);
795            let completion = u.get("output_tokens").and_then(|n| n.as_u64()).unwrap_or(0) as u32;
796            vec![StreamEvent::Usage(TokenUsage {
797                prompt_tokens: prompt,
798                completion_tokens: completion,
799                total_tokens: prompt + completion,
800                reasoning_tokens: None,
801            })]
802        }
803        "content_block_delta" => {
804            let Some(delta) = v.get("delta") else {
805                return Vec::new();
806            };
807            // Extended thinking deltas arrive as {"type":"thinking_delta","thinking":"..."}.
808            match delta.get("type").and_then(|t| t.as_str()) {
809                Some("thinking_delta") => match delta.get("thinking").and_then(|t| t.as_str()) {
810                    Some(text) if !text.is_empty() => {
811                        vec![StreamEvent::ReasoningDelta {
812                            content: text.to_string(),
813                        }]
814                    }
815                    _ => Vec::new(),
816                },
817                _ => {
818                    // text_delta (default) carries {"text":"..."}.
819                    match delta.get("text").and_then(|t| t.as_str()) {
820                        Some(text) if !text.is_empty() => {
821                            vec![StreamEvent::Delta {
822                                content: text.to_string(),
823                            }]
824                        }
825                        _ => Vec::new(),
826                    }
827                }
828            }
829        }
830        "message_delta" => {
831            let mut events = Vec::new();
832            // `delta.stop_reason` (`"max_tokens"`, `"refusal"`, …) is the only
833            // signal that the generation ended early — without it a truncated
834            // stream looks identical to a complete one.
835            if let Some(reason) = v
836                .get("delta")
837                .and_then(|d| d.get("stop_reason"))
838                .and_then(|r| r.as_str())
839            {
840                events.push(StreamEvent::Stopped {
841                    reason: reason.to_string(),
842                });
843            }
844            let usage = v.get("usage").and_then(|u| {
845                // Input fields are cumulative when present; absent means 0 so
846                // consumers merging Usage events keep the message_start value.
847                let prompt = anthropic_input_tokens(u).unwrap_or(0);
848                let completion = u.get("output_tokens")?.as_u64()? as u32;
849                Some(TokenUsage {
850                    prompt_tokens: prompt,
851                    completion_tokens: completion,
852                    total_tokens: prompt + completion,
853                    reasoning_tokens: None,
854                })
855            });
856            match usage {
857                Some(u) => events.push(StreamEvent::Usage(u)),
858                None => events.push(StreamEvent::Done),
859            }
860            events
861        }
862        "message_stop" => vec![StreamEvent::Done],
863        _ => Vec::new(),
864    }
865}
866
867/// Async LLM client for the Anthropic Messages API.
868pub struct AnthropicClient {
869    api_key: String,
870    model: String,
871    base_url: String,
872    client: reqwest::Client,
873}
874
875impl AnthropicClient {
876    /// Create a new client using credentials from the environment variable in `config`.
877    pub fn new(config: &ModelConfig) -> Result<Self> {
878        let api_key = std::env::var(&config.api_key_env).map_err(|_| {
879            KernelError::Config(format!(
880                "Environment variable {} not set",
881                config.api_key_env
882            ))
883        })?;
884        Ok(Self {
885            api_key,
886            model: config.model.clone(),
887            base_url: config
888                .base_url
889                .clone()
890                .unwrap_or_else(|| "https://api.anthropic.com/v1".into()),
891            client: http_client()?,
892        })
893    }
894
895    /// Create a new client with an explicit API key, using the default Anthropic base URL.
896    ///
897    /// Returns a [`KernelError::Config`] if the HTTP client (with its connect /
898    /// total timeouts) cannot be built, rather than silently falling back to a
899    /// timeout-less `reqwest::Client::default()`.
900    pub fn from_key(model: impl Into<String>, api_key: impl Into<String>) -> Result<Self> {
901        Ok(Self {
902            api_key: api_key.into(),
903            model: model.into(),
904            base_url: "https://api.anthropic.com/v1".into(),
905            client: http_client()?,
906        })
907    }
908
909    /// Create from an explicit key and a shared `reqwest::Client`.
910    pub fn from_key_with_client(
911        model: impl Into<String>,
912        api_key: impl Into<String>,
913        client: reqwest::Client,
914    ) -> Self {
915        Self {
916            api_key: api_key.into(),
917            model: model.into(),
918            base_url: "https://api.anthropic.com/v1".into(),
919            client,
920        }
921    }
922
923    /// Create from an explicit key, a custom base URL, and a shared `reqwest::Client`.
924    ///
925    /// Use this for Anthropic-compatible endpoints that are not the default
926    /// `api.anthropic.com` (self-hosted proxies, regional gateways, …).
927    pub fn from_key_with_base_url(
928        model: impl Into<String>,
929        api_key: impl Into<String>,
930        base_url: impl Into<String>,
931        client: reqwest::Client,
932    ) -> Self {
933        Self {
934            api_key: api_key.into(),
935            model: model.into(),
936            base_url: base_url.into(),
937            client,
938        }
939    }
940}
941
942#[derive(serde::Serialize)]
943struct AnthropicRequest {
944    model: String,
945    max_tokens: u32,
946    /// Omitted entirely when `None` — Claude Opus 5.5 / Sonnet 5.5 reject any
947    /// non-default sampling parameter with 400, so callers opt out by leaving
948    /// it unset instead of sending the historical `0.7`.
949    #[serde(skip_serializing_if = "Option::is_none")]
950    temperature: Option<f32>,
951    #[serde(skip_serializing_if = "Option::is_none")]
952    system: Option<String>,
953    messages: Vec<AnthropicMessage>,
954    #[serde(skip_serializing_if = "std::ops::Not::not")]
955    stream: bool,
956    #[serde(skip_serializing_if = "Option::is_none")]
957    tools: Option<Vec<serde_json::Value>>,
958    #[serde(skip_serializing_if = "Option::is_none")]
959    output_config: Option<serde_json::Value>,
960}
961
962#[derive(serde::Serialize)]
963struct AnthropicMessage {
964    role: String,
965    content: String,
966}
967
968#[derive(serde::Deserialize)]
969struct AnthropicResponse {
970    #[serde(default)]
971    id: Option<String>,
972    content: Vec<AnthropicContentBlock>,
973    model: String,
974    #[serde(default)]
975    stop_reason: Option<String>,
976    usage: AnthropicUsage,
977}
978
979/// A response content block. `text` blocks carry `text`; `tool_use` blocks
980/// carry `id`/`name`/`input`; `thinking` blocks (extended thinking) carry `thinking`.
981#[derive(serde::Deserialize)]
982struct AnthropicContentBlock {
983    #[serde(rename = "type")]
984    block_type: String,
985    #[serde(default)]
986    text: Option<String>,
987    /// Extended thinking content (`{"type":"thinking","thinking":"..."}`).
988    #[serde(default)]
989    thinking: Option<String>,
990    #[serde(default)]
991    id: Option<String>,
992    #[serde(default)]
993    name: Option<String>,
994    #[serde(default)]
995    input: Option<serde_json::Value>,
996}
997
998#[derive(serde::Deserialize)]
999struct AnthropicUsage {
1000    input_tokens: u32,
1001    output_tokens: u32,
1002}
1003
1004#[async_trait]
1005impl LLMClient for AnthropicClient {
1006    async fn complete(&self, request: LLMRequest) -> Result<LLMResponse> {
1007        let model = request.model.clone().unwrap_or_else(|| self.model.clone());
1008        let max_tokens = request.max_tokens.unwrap_or(4096);
1009        let temperature = request.temperature;
1010        let system = request.system.clone();
1011        let tools = request
1012            .tools
1013            .as_deref()
1014            .map(anthropic_tools)
1015            .filter(|t| !t.is_empty());
1016        let output_config = anthropic_output_config(
1017            request.response_format.as_ref(),
1018            request.reasoning.as_ref().and_then(|r| r.effort),
1019        );
1020        let extra_body = request.extra_body.clone();
1021        let messages: Vec<AnthropicMessage> = request
1022            .into_anthropic_messages()
1023            .into_iter()
1024            .map(|(role, content)| AnthropicMessage { role, content })
1025            .collect();
1026
1027        let body = AnthropicRequest {
1028            model,
1029            max_tokens,
1030            temperature,
1031            system,
1032            messages,
1033            stream: false,
1034            tools,
1035            output_config,
1036        };
1037
1038        let resp = self
1039            .client
1040            .post(format!("{}/messages", self.base_url))
1041            .header("x-api-key", &self.api_key)
1042            .header("anthropic-version", "2023-06-01")
1043            .header("content-type", "application/json")
1044            .json(&body_with_extra(&body, extra_body.as_ref()))
1045            .send()
1046            .await
1047            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
1048
1049        check_rate_limit(&resp)?;
1050
1051        let status = resp.status();
1052
1053        if !status.is_success() {
1054            let text = resp.text().await.unwrap_or_default();
1055            return Err(KernelError::Http {
1056                status: status.as_u16(),
1057                message: redact_http_body(&text),
1058            });
1059        }
1060
1061        let chat_resp: AnthropicResponse = resp
1062            .json()
1063            .await
1064            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
1065
1066        let mut content = String::new();
1067        let mut reasoning = String::new();
1068        let mut tool_calls = Vec::new();
1069        for block in chat_resp.content {
1070            match block.block_type.as_str() {
1071                "text" => {
1072                    if let Some(t) = block.text {
1073                        content.push_str(&t);
1074                    }
1075                }
1076                "thinking" => {
1077                    if let Some(t) = block.thinking {
1078                        reasoning.push_str(&t);
1079                    }
1080                }
1081                "tool_use" => {
1082                    if let (Some(id), Some(name)) = (block.id, block.name) {
1083                        let arguments = block
1084                            .input
1085                            .map(|v| v.to_string())
1086                            .unwrap_or_else(|| "{}".to_string());
1087                        tool_calls.push(ToolCall {
1088                            id,
1089                            name,
1090                            arguments,
1091                        });
1092                    }
1093                }
1094                _ => {}
1095            }
1096        }
1097
1098        Ok(LLMResponse {
1099            content,
1100            reasoning: if reasoning.is_empty() {
1101                None
1102            } else {
1103                Some(reasoning)
1104            },
1105            model: chat_resp.model,
1106            usage: TokenUsage {
1107                prompt_tokens: chat_resp.usage.input_tokens,
1108                completion_tokens: chat_resp.usage.output_tokens,
1109                total_tokens: chat_resp.usage.input_tokens + chat_resp.usage.output_tokens,
1110                reasoning_tokens: None,
1111            },
1112            tool_calls,
1113            finish_reason: chat_resp.stop_reason,
1114            id: chat_resp.id,
1115            created: None,
1116        })
1117    }
1118
1119    fn model_name(&self) -> &str {
1120        &self.model
1121    }
1122
1123    async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream> {
1124        let model = request.model.clone().unwrap_or_else(|| self.model.clone());
1125        let max_tokens = request.max_tokens.unwrap_or(4096);
1126        let temperature = request.temperature;
1127        let system = request.system.clone();
1128        let extra_body = request.extra_body.clone();
1129        let output_config =
1130            anthropic_output_config(None, request.reasoning.as_ref().and_then(|r| r.effort));
1131        let messages: Vec<AnthropicMessage> = request
1132            .into_anthropic_messages()
1133            .into_iter()
1134            .map(|(role, content)| AnthropicMessage { role, content })
1135            .collect();
1136
1137        let body = AnthropicRequest {
1138            model,
1139            max_tokens,
1140            temperature,
1141            system,
1142            messages,
1143            stream: true,
1144            // Streaming is text-only here: the SSE parser emits text deltas and
1145            // does not reassemble streamed tool-use blocks. `effort` still
1146            // applies to streaming, so it rides in `output_config` (no
1147            // `format` — streaming has no schema enforcement).
1148            tools: None,
1149            output_config,
1150        };
1151
1152        let resp = self
1153            .client
1154            .post(format!("{}/messages", self.base_url))
1155            .header("x-api-key", &self.api_key)
1156            .header("anthropic-version", "2023-06-01")
1157            .header("content-type", "application/json")
1158            .json(&body_with_extra(&body, extra_body.as_ref()))
1159            .send()
1160            .await
1161            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
1162
1163        check_rate_limit(&resp)?;
1164
1165        let status = resp.status();
1166        if !status.is_success() {
1167            let text = resp.text().await.unwrap_or_default();
1168            return Err(KernelError::Http {
1169                status: status.as_u16(),
1170                message: redact_http_body(&text),
1171            });
1172        }
1173
1174        let (tx, rx) = tokio::sync::mpsc::channel::<Result<StreamEvent>>(16);
1175
1176        tokio::spawn(async move {
1177            let mut stream = std::pin::pin!(resp.bytes_stream());
1178            let mut buffer: Vec<u8> = Vec::new();
1179            let mut current_event = String::new();
1180
1181            use tokio_stream::StreamExt;
1182
1183            while let Some(chunk) = stream.next().await {
1184                let chunk = match chunk {
1185                    Ok(c) => c,
1186                    Err(e) => {
1187                        let _ = tx.send(Err(KernelError::LlmApi(e.to_string()))).await;
1188                        return;
1189                    }
1190                };
1191
1192                for line in drain_sse_lines(&mut buffer, &chunk) {
1193                    if let Some(evt) = line.strip_prefix("event: ") {
1194                        current_event = evt.to_string();
1195                    } else if let Some(data) = line.strip_prefix("data: ") {
1196                        if data == "[DONE]" {
1197                            let _ = tx.send(Ok(StreamEvent::Done)).await;
1198                            return;
1199                        }
1200                        for event in parse_anthropic_sse(&current_event, data) {
1201                            let is_done = matches!(event, StreamEvent::Done);
1202                            if tx.send(Ok(event)).await.is_err() || is_done {
1203                                return;
1204                            }
1205                        }
1206                        current_event.clear();
1207                    }
1208                }
1209            }
1210            let _ = tx.send(Ok(StreamEvent::Done)).await;
1211        });
1212
1213        Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
1214    }
1215}
1216
1217#[cfg(test)]
1218mod tests {
1219    use super::*;
1220    use crate::llm::types::ReasoningSummary;
1221
1222    #[test]
1223    fn parse_sse_line_extracts_data() {
1224        assert_eq!(
1225            parse_sse_line("data: {\"id\":\"1\"}"),
1226            Some("{\"id\":\"1\"}")
1227        );
1228    }
1229
1230    #[test]
1231    fn parse_sse_line_skips_done() {
1232        assert_eq!(parse_sse_line("data: [DONE]"), None);
1233    }
1234
1235    #[test]
1236    fn parse_sse_line_skips_non_data() {
1237        assert_eq!(parse_sse_line("event: ping"), None);
1238        assert_eq!(parse_sse_line(""), None);
1239    }
1240
1241    #[test]
1242    fn drain_sse_lines_reassembles_multibyte_split_across_chunks() {
1243        // "data: 안녕\n" — "data: " is 6 bytes, 안/녕 are 3 bytes each.
1244        let full = "data: 안녕\n".as_bytes().to_vec();
1245        // Split at byte 7, mid-way through "안"'s 3-byte sequence.
1246        let (first, rest) = full.split_at(7);
1247
1248        let mut buffer = Vec::new();
1249        // No newline yet, and the trailing bytes are a partial codepoint:
1250        // nothing should be emitted, and nothing should be corrupted.
1251        assert!(drain_sse_lines(&mut buffer, first).is_empty());
1252
1253        let lines = drain_sse_lines(&mut buffer, rest);
1254        assert_eq!(lines, vec!["data: 안녕".to_string()]);
1255        // A per-chunk from_utf8_lossy would instead have produced U+FFFD here.
1256        assert!(!lines[0].contains('\u{FFFD}'));
1257    }
1258
1259    #[test]
1260    fn drain_sse_lines_handles_multiple_lines_and_keeps_partial_tail() {
1261        let mut buffer = Vec::new();
1262        let lines = drain_sse_lines(&mut buffer, b"event: ping\r\ndata: {}\npartial");
1263        assert_eq!(
1264            lines,
1265            vec!["event: ping".to_string(), "data: {}".to_string()]
1266        );
1267        // The unterminated "partial" tail stays buffered for the next chunk.
1268        let lines = drain_sse_lines(&mut buffer, b" tail\n");
1269        assert_eq!(lines, vec!["partial tail".to_string()]);
1270    }
1271
1272    #[test]
1273    fn openai_delta_extraction() {
1274        let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#;
1275        let event = parse_openai_sse(data).unwrap();
1276        match event {
1277            StreamEvent::Delta { content } => assert_eq!(content, "Hello"),
1278            _ => panic!("expected Delta, got {:?}", event),
1279        }
1280    }
1281
1282    #[test]
1283    fn openai_usage_extraction() {
1284        let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}"#;
1285        let event = parse_openai_sse(data).unwrap();
1286        match event {
1287            StreamEvent::Usage(usage) => {
1288                assert_eq!(usage.prompt_tokens, 10);
1289                assert_eq!(usage.completion_tokens, 5);
1290                assert_eq!(usage.total_tokens, 15);
1291            }
1292            _ => panic!("expected Usage, got {:?}", event),
1293        }
1294    }
1295
1296    #[test]
1297    fn openai_finish_reason_is_done() {
1298        let data =
1299            r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#;
1300        let event = parse_openai_sse(data).unwrap();
1301        assert!(matches!(event, StreamEvent::Done));
1302    }
1303
1304    #[test]
1305    fn openai_empty_delta_skipped() {
1306        let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}]}"#;
1307        assert!(parse_openai_sse(data).is_none());
1308    }
1309
1310    #[test]
1311    fn anthropic_content_block_delta() {
1312        let data = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#;
1313        let events = parse_anthropic_sse("content_block_delta", data);
1314        match events.as_slice() {
1315            [StreamEvent::Delta { content }] => assert_eq!(content, "Hello"),
1316            _ => panic!("expected one Delta, got {:?}", events),
1317        }
1318    }
1319
1320    #[test]
1321    fn anthropic_message_delta_usage() {
1322        let data = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}"#;
1323        let events = parse_anthropic_sse("message_delta", data);
1324        match events.as_slice() {
1325            [StreamEvent::Stopped { reason }, StreamEvent::Usage(usage)] => {
1326                assert_eq!(reason, "end_turn");
1327                assert_eq!(usage.completion_tokens, 5);
1328            }
1329            _ => panic!("expected Stopped + Usage, got {:?}", events),
1330        }
1331    }
1332
1333    #[test]
1334    fn anthropic_message_delta_max_tokens_surfaces_stop_reason() {
1335        let data = r#"{"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":4096}}"#;
1336        let events = parse_anthropic_sse("message_delta", data);
1337        match events.as_slice() {
1338            [StreamEvent::Stopped { reason }, StreamEvent::Usage(_)] => {
1339                assert_eq!(reason, "max_tokens")
1340            }
1341            _ => panic!("expected Stopped + Usage, got {:?}", events),
1342        }
1343    }
1344
1345    #[test]
1346    fn anthropic_message_delta_refusal_surfaces_stop_reason() {
1347        let data = r#"{"type":"message_delta","delta":{"stop_reason":"refusal"},"usage":{"output_tokens":12}}"#;
1348        let events = parse_anthropic_sse("message_delta", data);
1349        match events.as_slice() {
1350            [StreamEvent::Stopped { reason }, StreamEvent::Usage(_)] => {
1351                assert_eq!(reason, "refusal")
1352            }
1353            _ => panic!("expected Stopped + Usage, got {:?}", events),
1354        }
1355    }
1356
1357    #[test]
1358    fn anthropic_message_delta_without_stop_reason_stays_usage_only() {
1359        let data = r#"{"type":"message_delta","delta":{},"usage":{"output_tokens":5}}"#;
1360        let events = parse_anthropic_sse("message_delta", data);
1361        match events.as_slice() {
1362            [StreamEvent::Usage(_)] => {}
1363            _ => panic!("expected Usage only, got {:?}", events),
1364        }
1365    }
1366
1367    #[test]
1368    fn anthropic_message_start_usage_includes_cache_tokens() {
1369        let data = r#"{"type":"message_start","message":{"id":"msg_1","usage":{"input_tokens":10,"cache_creation_input_tokens":200,"cache_read_input_tokens":3000,"output_tokens":1}}}"#;
1370        let events = parse_anthropic_sse("message_start", data);
1371        match events.as_slice() {
1372            [StreamEvent::Usage(u)] => {
1373                assert_eq!(u.prompt_tokens, 3210);
1374                assert_eq!(u.completion_tokens, 1);
1375                assert_eq!(u.total_tokens, 3211);
1376            }
1377            _ => panic!("expected one Usage, got {:?}", events),
1378        }
1379    }
1380
1381    #[test]
1382    fn anthropic_message_start_usage_missing_fields_default_to_zero() {
1383        let data = r#"{"type":"message_start","message":{"usage":{"input_tokens":42}}}"#;
1384        let events = parse_anthropic_sse("message_start", data);
1385        match events.as_slice() {
1386            [StreamEvent::Usage(u)] => {
1387                assert_eq!(u.prompt_tokens, 42);
1388                assert_eq!(u.completion_tokens, 0);
1389                assert_eq!(u.total_tokens, 42);
1390            }
1391            _ => panic!("expected one Usage, got {:?}", events),
1392        }
1393    }
1394
1395    #[test]
1396    fn anthropic_message_start_without_usage_ignored() {
1397        let data = r#"{"type":"message_start","message":{"id":"msg_1"}}"#;
1398        assert!(parse_anthropic_sse("message_start", data).is_empty());
1399    }
1400
1401    #[test]
1402    fn anthropic_message_delta_with_cumulative_input_usage() {
1403        let data = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":10,"cache_read_input_tokens":90,"output_tokens":25}}"#;
1404        let events = parse_anthropic_sse("message_delta", data);
1405        match events.as_slice() {
1406            [StreamEvent::Stopped { .. }, StreamEvent::Usage(u)] => {
1407                assert_eq!(u.prompt_tokens, 100);
1408                assert_eq!(u.completion_tokens, 25);
1409                assert_eq!(u.total_tokens, 125);
1410            }
1411            _ => panic!("expected Stopped + Usage, got {:?}", events),
1412        }
1413    }
1414
1415    #[test]
1416    fn anthropic_message_delta_output_only_usage() {
1417        let data = r#"{"type":"message_delta","delta":{},"usage":{"output_tokens":7}}"#;
1418        let events = parse_anthropic_sse("message_delta", data);
1419        match events.as_slice() {
1420            [StreamEvent::Usage(u)] => {
1421                assert_eq!(u.prompt_tokens, 0);
1422                assert_eq!(u.completion_tokens, 7);
1423                assert_eq!(u.total_tokens, 7);
1424            }
1425            _ => panic!("expected Usage only, got {:?}", events),
1426        }
1427    }
1428
1429    #[test]
1430    fn anthropic_message_stop() {
1431        let events = parse_anthropic_sse("message_stop", r#"{"type":"message_stop"}"#);
1432        assert!(matches!(events.as_slice(), [StreamEvent::Done]));
1433    }
1434
1435    #[test]
1436    fn anthropic_unknown_event_ignored() {
1437        assert!(parse_anthropic_sse("ping", "{}").is_empty());
1438    }
1439
1440    fn sample_tool() -> ToolDefinition {
1441        ToolDefinition {
1442            name: "get_weather".into(),
1443            description: "Get weather".into(),
1444            input_schema: serde_json::json!({
1445                "type": "object",
1446                "properties": { "location": { "type": "string" } },
1447                "required": ["location"]
1448            }),
1449        }
1450    }
1451
1452    #[test]
1453    fn openai_tools_use_function_wrapper() {
1454        let out = openai_tools(&[sample_tool()]);
1455        assert_eq!(out.len(), 1);
1456        assert_eq!(out[0]["type"], "function");
1457        assert_eq!(out[0]["function"]["name"], "get_weather");
1458        // input_schema is forwarded verbatim as `parameters`.
1459        assert_eq!(out[0]["function"]["parameters"]["required"][0], "location");
1460    }
1461
1462    #[test]
1463    fn openai_response_format_maps_each_variant() {
1464        assert!(openai_response_format(&ResponseFormat::Text).is_none());
1465        assert_eq!(
1466            openai_response_format(&ResponseFormat::Json).unwrap()["type"],
1467            "json_object"
1468        );
1469        let schema = serde_json::json!({"type": "object"});
1470        let js = openai_response_format(&ResponseFormat::JsonSchema { schema }).unwrap();
1471        assert_eq!(js["type"], "json_schema");
1472        assert_eq!(js["json_schema"]["strict"], true);
1473    }
1474
1475    #[test]
1476    fn anthropic_tools_use_input_schema_key() {
1477        let out = anthropic_tools(&[sample_tool()]);
1478        assert_eq!(out[0]["name"], "get_weather");
1479        assert_eq!(out[0]["input_schema"]["type"], "object");
1480        assert!(out[0].get("function").is_none());
1481    }
1482
1483    #[test]
1484    fn anthropic_output_config_only_for_json_schema() {
1485        assert!(anthropic_output_config(Some(&ResponseFormat::Text), None).is_none());
1486        assert!(anthropic_output_config(Some(&ResponseFormat::Json), None).is_none());
1487        assert!(anthropic_output_config(None, None).is_none());
1488        let schema = serde_json::json!({"type": "object"});
1489        let cfg = anthropic_output_config(Some(&ResponseFormat::JsonSchema { schema }), None);
1490        let cfg = cfg.unwrap();
1491        assert_eq!(cfg["format"]["type"], "json_schema");
1492    }
1493
1494    #[test]
1495    fn anthropic_output_config_carries_effort() {
1496        let cfg = anthropic_output_config(None, Some(ReasoningEffort::High)).unwrap();
1497        assert_eq!(cfg["effort"], "high");
1498        assert!(cfg.get("format").is_none());
1499        // Schema and effort merge into one object (two keys, not two configs).
1500        let schema = serde_json::json!({"type": "object"});
1501        let cfg = anthropic_output_config(
1502            Some(&ResponseFormat::JsonSchema { schema }),
1503            Some(ReasoningEffort::XHigh),
1504        )
1505        .unwrap();
1506        assert_eq!(cfg["effort"], "xhigh");
1507        assert_eq!(cfg["format"]["type"], "json_schema");
1508        // Wire values must match the Anthropic effort vocabulary exactly.
1509        for (variant, wire) in [
1510            (ReasoningEffort::Low, "low"),
1511            (ReasoningEffort::Medium, "medium"),
1512            (ReasoningEffort::High, "high"),
1513            (ReasoningEffort::XHigh, "xhigh"),
1514            (ReasoningEffort::Max, "max"),
1515        ] {
1516            let cfg = anthropic_output_config(None, Some(variant)).unwrap();
1517            assert_eq!(cfg["effort"], wire);
1518        }
1519    }
1520
1521    #[test]
1522    fn anthropic_request_omits_temperature_when_none() {
1523        let body = AnthropicRequest {
1524            model: "claude-opus-5-5".into(),
1525            max_tokens: 64,
1526            temperature: None,
1527            system: None,
1528            messages: vec![],
1529            stream: false,
1530            tools: None,
1531            output_config: None,
1532        };
1533        let json = serde_json::to_value(&body).unwrap();
1534        assert!(json.get("temperature").is_none());
1535        assert!(json.get("output_config").is_none());
1536    }
1537
1538    #[test]
1539    fn anthropic_request_serializes_temperature_and_effort_when_set() {
1540        let body = AnthropicRequest {
1541            model: "claude-sonnet-4-6".into(),
1542            max_tokens: 64,
1543            temperature: Some(0.5),
1544            system: None,
1545            messages: vec![],
1546            stream: false,
1547            tools: None,
1548            output_config: anthropic_output_config(None, Some(ReasoningEffort::Max)),
1549        };
1550        let json = serde_json::to_value(&body).unwrap();
1551        assert_eq!(json["temperature"], serde_json::json!(0.5f32));
1552        assert_eq!(json["output_config"]["effort"], "max");
1553    }
1554
1555    #[test]
1556    fn openai_request_serializes_tools_and_format() {
1557        let body = OpenAIChatRequest {
1558            model: "gpt-4o".into(),
1559            messages: vec![OpenAIChatMessage {
1560                role: "user".into(),
1561                content: "hi".into(),
1562            }],
1563            temperature: 0.7,
1564            max_tokens: None,
1565            stream: false,
1566            tools: Some(openai_tools(&[sample_tool()])),
1567            response_format: Some(serde_json::json!({ "type": "json_object" })),
1568            reasoning_effort: None,
1569            reasoning: None,
1570            verbosity: None,
1571        };
1572        let json = serde_json::to_value(&body).unwrap();
1573        assert_eq!(json["tools"][0]["function"]["name"], "get_weather");
1574        assert_eq!(json["response_format"]["type"], "json_object");
1575        // Omitted when None (backward-compatible request shape).
1576        assert!(json.get("max_tokens").is_none());
1577        assert!(json.get("reasoning_effort").is_none());
1578        assert!(json.get("reasoning").is_none());
1579    }
1580
1581    #[test]
1582    fn openai_reasoning_effort_maps_to_official_param() {
1583        let (effort, reasoning) =
1584            openai_reasoning(Some(&ReasoningConfig::effort(ReasoningEffort::High)));
1585        assert_eq!(effort, Some(ReasoningEffort::High));
1586        assert!(reasoning.is_none());
1587        let body = OpenAIChatRequest {
1588            model: "gpt-5.5".into(),
1589            messages: vec![],
1590            temperature: 0.7,
1591            max_tokens: None,
1592            stream: false,
1593            tools: None,
1594            response_format: None,
1595            reasoning_effort: effort,
1596            reasoning,
1597            verbosity: None,
1598        };
1599        let json = serde_json::to_value(&body).unwrap();
1600        assert_eq!(json["reasoning_effort"], "high");
1601        assert!(json.get("reasoning").is_none());
1602    }
1603
1604    #[test]
1605    fn openai_reasoning_disabled_maps_to_openrouter_object() {
1606        // immune's exact need: reasoning: {"enabled": false} on OpenRouter,
1607        // with no reasoning_effort key and no config -> no keys at all.
1608        let (effort, reasoning) = openai_reasoning(Some(&ReasoningConfig::disabled()));
1609        assert_eq!(effort, None);
1610        assert_eq!(reasoning.unwrap()["enabled"], false);
1611
1612        let (effort, reasoning) = openai_reasoning(None);
1613        assert!(effort.is_none() && reasoning.is_none());
1614    }
1615
1616    #[test]
1617    fn openai_reasoning_both_knobs_serialize_independently() {
1618        let (effort, reasoning) = openai_reasoning(Some(&ReasoningConfig {
1619            enabled: Some(true),
1620            effort: Some(ReasoningEffort::Low),
1621            summary: Some(ReasoningSummary::Concise),
1622        }));
1623        let body = OpenAIChatRequest {
1624            model: "m".into(),
1625            messages: vec![],
1626            temperature: 0.7,
1627            max_tokens: None,
1628            stream: false,
1629            tools: None,
1630            response_format: None,
1631            reasoning_effort: effort,
1632            reasoning,
1633            verbosity: Some(Verbosity::High),
1634        };
1635        let json = serde_json::to_value(&body).unwrap();
1636        assert_eq!(json["reasoning_effort"], "low");
1637        assert_eq!(json["reasoning"]["enabled"], true);
1638        assert_eq!(json["reasoning"]["summary"], "concise");
1639        assert_eq!(json["verbosity"], "high");
1640    }
1641
1642    #[test]
1643    fn openai_reasoning_summary_only_serializes_object() {
1644        let (effort, reasoning) = openai_reasoning(Some(&ReasoningConfig {
1645            enabled: None,
1646            effort: None,
1647            summary: Some(ReasoningSummary::Detailed),
1648        }));
1649        assert!(effort.is_none());
1650        assert_eq!(reasoning.unwrap()["summary"], "detailed");
1651    }
1652
1653    #[test]
1654    fn openai_body_merges_extra_body_last_write_wins() {
1655        let body = OpenAIChatRequest {
1656            model: "m".into(),
1657            messages: vec![],
1658            temperature: 0.7,
1659            max_tokens: None,
1660            stream: false,
1661            tools: None,
1662            response_format: None,
1663            reasoning_effort: None,
1664            reasoning: None,
1665            verbosity: None,
1666        };
1667        let mut extra = serde_json::Map::new();
1668        extra.insert("seed".into(), 7.into());
1669        extra.insert("temperature".into(), 0.1.into());
1670        let merged = body_with_extra(&body, Some(&extra));
1671        assert_eq!(merged["seed"], 7);
1672        // Extra key overrides the natively forwarded one (last-write-wins).
1673        assert_eq!(merged["temperature"], 0.1);
1674        // Without extras the body is untouched. (Compare via json! so the f32
1675        // → f64 widening in the Number matches on both sides.)
1676        let plain = body_with_extra(&body, None);
1677        assert_eq!(plain["temperature"], serde_json::json!(0.7f32));
1678        assert!(plain.get("seed").is_none());
1679    }
1680
1681    #[test]
1682    fn anthropic_body_merges_extra_body_last_write_wins() {
1683        let body = AnthropicRequest {
1684            model: "m".into(),
1685            max_tokens: 8,
1686            temperature: Some(0.7),
1687            system: Some("sys".into()),
1688            messages: vec![],
1689            stream: false,
1690            tools: None,
1691            output_config: None,
1692        };
1693        let mut extra = serde_json::Map::new();
1694        extra.insert("thinking".into(), serde_json::json!({"type": "disabled"}));
1695        let merged = body_with_extra(&body, Some(&extra));
1696        assert_eq!(merged["thinking"]["type"], "disabled");
1697        assert_eq!(merged["system"], "sys");
1698    }
1699
1700    #[test]
1701    fn verbosity_wire_values_match_openai_spec() {
1702        // Official enum: low, medium, high.
1703        for (variant, wire) in [
1704            (Verbosity::Low, "low"),
1705            (Verbosity::Medium, "medium"),
1706            (Verbosity::High, "high"),
1707        ] {
1708            assert_eq!(serde_json::to_value(variant).unwrap(), wire);
1709        }
1710    }
1711
1712    #[test]
1713    fn reasoning_summary_wire_values_match_openai_spec() {
1714        // Official enum: auto, concise, detailed.
1715        for (variant, wire) in [
1716            (ReasoningSummary::Auto, "auto"),
1717            (ReasoningSummary::Concise, "concise"),
1718            (ReasoningSummary::Detailed, "detailed"),
1719        ] {
1720            assert_eq!(serde_json::to_value(variant).unwrap(), wire);
1721        }
1722    }
1723
1724    #[test]
1725    fn reasoning_effort_wire_values_match_openai_spec() {
1726        // Official enum: none, minimal, low, medium, high, xhigh, max.
1727        for (variant, wire) in [
1728            (ReasoningEffort::None, "none"),
1729            (ReasoningEffort::Minimal, "minimal"),
1730            (ReasoningEffort::Low, "low"),
1731            (ReasoningEffort::Medium, "medium"),
1732            (ReasoningEffort::High, "high"),
1733            (ReasoningEffort::XHigh, "xhigh"),
1734            (ReasoningEffort::Max, "max"),
1735        ] {
1736            assert_eq!(serde_json::to_value(variant).unwrap(), wire);
1737            let back: ReasoningEffort = serde_json::from_value(wire.into()).unwrap();
1738            assert_eq!(back, variant);
1739        }
1740    }
1741
1742    #[test]
1743    fn lenient_decode_rescues_openrouter_control_chars() {
1744        // OpenRouter was observed returning 200 with raw \n / \t inside the
1745        // reasoning string; strict serde_json fails the whole body.
1746        let raw = "{\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"답변\",\"reasoning_content\":\"step 1\nstep 2\ttab\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}";
1747        assert!(serde_json::from_str::<OpenAIChatResponse>(raw).is_err());
1748        let resp: OpenAIChatResponse = from_json_lenient(raw).unwrap();
1749        let first = resp.choices.into_iter().next().unwrap();
1750        assert_eq!(first.message.content.as_deref(), Some("답변"));
1751        assert_eq!(
1752            first.message.reasoning_content.as_deref(),
1753            Some("step 1\nstep 2\ttab")
1754        );
1755    }
1756
1757    #[test]
1758    fn lenient_decode_keeps_original_error_for_structural_garbage() {
1759        // Broken structure (not just control chars) still errors, surfacing
1760        // the *original* strict-parse error rather than a sanitize artifact.
1761        let raw = "{\"choices\": tr";
1762        let strict = serde_json::from_str::<OpenAIChatResponse>(raw)
1763            .map(|_: OpenAIChatResponse| ())
1764            .unwrap_err();
1765        let lenient = from_json_lenient::<OpenAIChatResponse>(raw)
1766            .map(|_: OpenAIChatResponse| ())
1767            .unwrap_err();
1768        assert_eq!(lenient.to_string(), strict.to_string());
1769    }
1770
1771    #[test]
1772    fn escape_unescaped_control_chars_only_touches_string_literals() {
1773        // Raw newline outside strings (pretty-printed JSON) stays as-is;
1774        // inside strings it becomes \uXXXX; existing escapes are untouched.
1775        let raw = "{\n  \"a\": \"x\ny\\n\\tz\",\n  \"b\": 1\n}";
1776        let out = escape_unescaped_control_chars(raw);
1777        assert_eq!(out, "{\n  \"a\": \"x\\u000ay\\n\\tz\",\n  \"b\": 1\n}");
1778        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
1779        assert_eq!(v["a"], "x\ny\n\tz");
1780    }
1781
1782    #[test]
1783    fn escape_unescaped_control_chars_handles_escaped_quotes() {
1784        // \" inside a string must not toggle the in-string state.
1785        let raw = "{\"a\":\"say \\\"hi\\\" ok\"}";
1786        assert_eq!(escape_unescaped_control_chars(raw), raw);
1787    }
1788
1789    #[test]
1790    fn openai_sse_lenient_reasoning_delta_with_control_chars() {
1791        // A streamed reasoning delta carrying a raw tab must not be dropped.
1792        let data = "{\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"think\ttab\"},\"finish_reason\":null}]}";
1793        let event = parse_openai_sse(data).unwrap();
1794        match event {
1795            StreamEvent::ReasoningDelta { content } => assert_eq!(content, "think\ttab"),
1796            other => panic!("expected ReasoningDelta, got {other:?}"),
1797        }
1798    }
1799
1800    #[test]
1801    fn openai_response_parses_tool_calls() {
1802        let raw = r#"{
1803            "id": "chatcmpl-1",
1804            "created": 1700000000,
1805            "model": "gpt-4o",
1806            "choices": [{
1807                "index": 0,
1808                "message": {
1809                    "role": "assistant",
1810                    "content": null,
1811                    "tool_calls": [{
1812                        "id": "call_abc",
1813                        "type": "function",
1814                        "function": { "name": "get_weather", "arguments": "{\"location\":\"Paris\"}" }
1815                    }]
1816                },
1817                "finish_reason": "tool_calls"
1818            }],
1819            "usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15 }
1820        }"#;
1821        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1822        assert_eq!(resp.id.as_deref(), Some("chatcmpl-1"));
1823        let choice = resp.choices.into_iter().next().unwrap();
1824        assert_eq!(choice.finish_reason.as_deref(), Some("tool_calls"));
1825        assert!(choice.message.content.is_none());
1826        assert_eq!(choice.message.tool_calls.len(), 1);
1827        assert_eq!(choice.message.tool_calls[0].function.name, "get_weather");
1828    }
1829
1830    #[test]
1831    fn openai_response_parses_glm47_reasoning_content() {
1832        // GLM-4.7: content=null, reasoning_content carries the chain-of-thought + final JSON.
1833        let raw = r#"{
1834            "id":"chatcmpl-1","created":1700000000,"model":"glm-4.7",
1835            "choices":[{"index":0,"message":{"role":"assistant","content":null,
1836                "reasoning_content":"thinking... {\"rating\":\"Buy\"}"},"finish_reason":"stop"}],
1837            "usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15,
1838                "completion_tokens_details":{"reasoning_tokens":3}}
1839        }"#;
1840        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1841        let choice = resp.choices.into_iter().next().unwrap();
1842        assert!(choice.message.content.is_none());
1843        assert!(choice.message.reasoning_content.is_some());
1844        assert_eq!(
1845            resp.usage
1846                .unwrap()
1847                .completion_tokens_details
1848                .unwrap()
1849                .reasoning_tokens,
1850            Some(3)
1851        );
1852    }
1853
1854    #[test]
1855    fn openai_complete_promotes_reasoning_when_content_empty() {
1856        // complete() must promote reasoning_content into content when content is empty,
1857        // so downstream json_extract finds the JSON. Drives the *production* promotion
1858        // helper (not a re-implementation) so the regression guard tracks real code.
1859        let raw = r#"{
1860            "id":"chatcmpl-1","created":1700000000,"model":"glm-4.7",
1861            "choices":[{"index":0,"message":{"role":"assistant","content":"",
1862                "reasoning_content":"사고 {\"rating\":\"Sell\",\"key_thesis\":\"약세\"}"},"finish_reason":"stop"}]
1863        }"#;
1864        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1865        let first = resp.choices.into_iter().next().unwrap();
1866        let raw_content = first.message.content.unwrap_or_default();
1867        let reasoning = first.message.reasoning_content.clone();
1868        let content = promote_reasoning_into_content(raw_content, reasoning.as_deref());
1869        assert!(
1870            content.contains("\"rating\":\"Sell\""),
1871            "promoted content must contain JSON: {content}"
1872        );
1873        assert_eq!(
1874            reasoning.as_deref(),
1875            Some("사고 {\"rating\":\"Sell\",\"key_thesis\":\"약세\"}")
1876        );
1877    }
1878
1879    #[test]
1880    fn openai_complete_keeps_content_when_present() {
1881        // Non-empty content must NOT be overwritten by reasoning.
1882        let raw = r#"{
1883            "id":"chatcmpl-1","created":1700000000,"model":"gpt-4o",
1884            "choices":[{"index":0,"message":{"role":"assistant","content":"answer",
1885                "reasoning_content":"thought"},"finish_reason":"stop"}]
1886        }"#;
1887        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1888        let first = resp.choices.into_iter().next().unwrap();
1889        let raw_content = first.message.content.clone().unwrap_or_default();
1890        let reasoning = first.message.reasoning_content.as_deref();
1891        let content = promote_reasoning_into_content(raw_content.clone(), reasoning);
1892        assert_eq!(content, "answer");
1893        assert_eq!(reasoning, Some("thought"));
1894    }
1895
1896    #[test]
1897    fn promote_reasoning_into_content_edge_cases() {
1898        // #3 caveat: empty content + reasoning surfaces the reasoning as the answer
1899        // (documented trade-off; original reasoning is the source of truth).
1900        assert_eq!(
1901            promote_reasoning_into_content(String::new(), Some("chain-of-thought")),
1902            "chain-of-thought"
1903        );
1904        // content present wins regardless of reasoning.
1905        assert_eq!(
1906            promote_reasoning_into_content("ans".into(), Some("cot")),
1907            "ans"
1908        );
1909        // both empty -> empty (no panic, no spurious content).
1910        assert_eq!(promote_reasoning_into_content(String::new(), None), "");
1911        assert_eq!(promote_reasoning_into_content("x".into(), None), "x");
1912    }
1913
1914    #[test]
1915    fn openai_response_parses_deepseek_reasoning_alias() {
1916        // DeepSeek-R1 uses field name `reasoning` instead of `reasoning_content`.
1917        let raw = r#"{"model":"deepseek-r1","choices":[{"message":{"content":"ans","reasoning":"thought"}}]}"#;
1918        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1919        assert_eq!(
1920            resp.choices[0].message.reasoning_content.as_deref(),
1921            Some("thought")
1922        );
1923    }
1924
1925    #[test]
1926    fn openai_sse_reasoning_delta_extracted() {
1927        let data = r#"{"choices":[{"index":0,"delta":{"reasoning_content":"thinking"},"finish_reason":null}]}"#;
1928        let event = parse_openai_sse(data).unwrap();
1929        match event {
1930            StreamEvent::ReasoningDelta { content } => assert_eq!(content, "thinking"),
1931            other => panic!("expected ReasoningDelta, got {other:?}"),
1932        }
1933    }
1934
1935    #[test]
1936    fn openai_sse_content_delta_still_works_alongside_reasoning() {
1937        // Separate content chunk must still produce Delta, not be swallowed.
1938        let data = r#"{"choices":[{"index":0,"delta":{"content":"answer"},"finish_reason":null}]}"#;
1939        let event = parse_openai_sse(data).unwrap();
1940        assert!(matches!(event, StreamEvent::Delta { .. }));
1941    }
1942
1943    #[test]
1944    fn openai_sse_usage_carries_reasoning_tokens() {
1945        // Final streaming chunk carries choices (empty delta) + usage with reasoning_tokens.
1946        let data = r#"{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],
1947            "usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3,
1948            "completion_tokens_details":{"reasoning_tokens":7}}}"#;
1949        let event = parse_openai_sse(data).unwrap();
1950        match event {
1951            StreamEvent::Usage(u) => assert_eq!(u.reasoning_tokens, Some(7)),
1952            other => panic!("expected Usage, got {other:?}"),
1953        }
1954    }
1955
1956    #[test]
1957    fn anthropic_response_parses_tool_use_block() {
1958        let raw = r#"{
1959            "id": "msg_1",
1960            "model": "claude-sonnet-4-6",
1961            "stop_reason": "tool_use",
1962            "content": [
1963                { "type": "text", "text": "Let me check." },
1964                { "type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": { "location": "Paris" } }
1965            ],
1966            "usage": { "input_tokens": 12, "output_tokens": 8 }
1967        }"#;
1968        let resp: AnthropicResponse = serde_json::from_str(raw).unwrap();
1969        assert_eq!(resp.stop_reason.as_deref(), Some("tool_use"));
1970        assert_eq!(resp.content.len(), 2);
1971        assert_eq!(resp.content[0].block_type, "text");
1972        assert_eq!(resp.content[1].block_type, "tool_use");
1973        assert_eq!(resp.content[1].name.as_deref(), Some("get_weather"));
1974        assert_eq!(resp.content[1].input.as_ref().unwrap()["location"], "Paris");
1975    }
1976
1977    #[test]
1978    fn anthropic_response_parses_thinking_block() {
1979        // Extended thinking block must surface in LLMResponse.reasoning via the parse loop.
1980        let raw = r#"{
1981            "id": "msg_2",
1982            "model": "claude-sonnet-4-6",
1983            "stop_reason": "end_turn",
1984            "content": [
1985                { "type": "thinking", "thinking": "step by step..." },
1986                { "type": "text", "text": "Final answer." }
1987            ],
1988            "usage": { "input_tokens": 5, "output_tokens": 9 }
1989        }"#;
1990        let resp: AnthropicResponse = serde_json::from_str(raw).unwrap();
1991        let mut reasoning = String::new();
1992        let mut content = String::new();
1993        for block in resp.content {
1994            match block.block_type.as_str() {
1995                "text" => {
1996                    if let Some(t) = block.text {
1997                        content.push_str(&t);
1998                    }
1999                }
2000                "thinking" => {
2001                    if let Some(t) = block.thinking {
2002                        reasoning.push_str(&t);
2003                    }
2004                }
2005                _ => {}
2006            }
2007        }
2008        assert_eq!(content, "Final answer.");
2009        assert_eq!(reasoning, "step by step...");
2010    }
2011
2012    #[test]
2013    fn anthropic_sse_thinking_delta_extracted() {
2014        let data = r#"{"delta":{"type":"thinking_delta","thinking":"a thought"}}"#;
2015        let events = parse_anthropic_sse("content_block_delta", data);
2016        match events.as_slice() {
2017            [StreamEvent::ReasoningDelta { content }] => assert_eq!(content, "a thought"),
2018            other => panic!("expected ReasoningDelta, got {other:?}"),
2019        }
2020    }
2021}