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    #[serde(default)]
1003    cache_creation_input_tokens: u32,
1004    #[serde(default)]
1005    cache_read_input_tokens: u32,
1006}
1007
1008impl AnthropicUsage {
1009    /// Total input tokens, counting prompt-cache writes and reads.
1010    fn prompt_tokens(&self) -> u32 {
1011        self.input_tokens + self.cache_creation_input_tokens + self.cache_read_input_tokens
1012    }
1013}
1014
1015#[async_trait]
1016impl LLMClient for AnthropicClient {
1017    async fn complete(&self, request: LLMRequest) -> Result<LLMResponse> {
1018        let model = request.model.clone().unwrap_or_else(|| self.model.clone());
1019        let max_tokens = request.max_tokens.unwrap_or(4096);
1020        let temperature = request.temperature;
1021        let system = request.system.clone();
1022        let tools = request
1023            .tools
1024            .as_deref()
1025            .map(anthropic_tools)
1026            .filter(|t| !t.is_empty());
1027        let output_config = anthropic_output_config(
1028            request.response_format.as_ref(),
1029            request.reasoning.as_ref().and_then(|r| r.effort),
1030        );
1031        let extra_body = request.extra_body.clone();
1032        let messages: Vec<AnthropicMessage> = request
1033            .into_anthropic_messages()
1034            .into_iter()
1035            .map(|(role, content)| AnthropicMessage { role, content })
1036            .collect();
1037
1038        let body = AnthropicRequest {
1039            model,
1040            max_tokens,
1041            temperature,
1042            system,
1043            messages,
1044            stream: false,
1045            tools,
1046            output_config,
1047        };
1048
1049        let resp = self
1050            .client
1051            .post(format!("{}/messages", self.base_url))
1052            .header("x-api-key", &self.api_key)
1053            .header("anthropic-version", "2023-06-01")
1054            .header("content-type", "application/json")
1055            .json(&body_with_extra(&body, extra_body.as_ref()))
1056            .send()
1057            .await
1058            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
1059
1060        check_rate_limit(&resp)?;
1061
1062        let status = resp.status();
1063
1064        if !status.is_success() {
1065            let text = resp.text().await.unwrap_or_default();
1066            return Err(KernelError::Http {
1067                status: status.as_u16(),
1068                message: redact_http_body(&text),
1069            });
1070        }
1071
1072        let chat_resp: AnthropicResponse = resp
1073            .json()
1074            .await
1075            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
1076
1077        let mut content = String::new();
1078        let mut reasoning = String::new();
1079        let mut tool_calls = Vec::new();
1080        for block in chat_resp.content {
1081            match block.block_type.as_str() {
1082                "text" => {
1083                    if let Some(t) = block.text {
1084                        content.push_str(&t);
1085                    }
1086                }
1087                "thinking" => {
1088                    if let Some(t) = block.thinking {
1089                        reasoning.push_str(&t);
1090                    }
1091                }
1092                "tool_use" => {
1093                    if let (Some(id), Some(name)) = (block.id, block.name) {
1094                        let arguments = block
1095                            .input
1096                            .map(|v| v.to_string())
1097                            .unwrap_or_else(|| "{}".to_string());
1098                        tool_calls.push(ToolCall {
1099                            id,
1100                            name,
1101                            arguments,
1102                        });
1103                    }
1104                }
1105                _ => {}
1106            }
1107        }
1108
1109        Ok(LLMResponse {
1110            content,
1111            reasoning: if reasoning.is_empty() {
1112                None
1113            } else {
1114                Some(reasoning)
1115            },
1116            model: chat_resp.model,
1117            usage: TokenUsage {
1118                prompt_tokens: chat_resp.usage.prompt_tokens(),
1119                completion_tokens: chat_resp.usage.output_tokens,
1120                total_tokens: chat_resp.usage.prompt_tokens() + chat_resp.usage.output_tokens,
1121                reasoning_tokens: None,
1122            },
1123            tool_calls,
1124            finish_reason: chat_resp.stop_reason,
1125            id: chat_resp.id,
1126            created: None,
1127        })
1128    }
1129
1130    fn model_name(&self) -> &str {
1131        &self.model
1132    }
1133
1134    async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream> {
1135        let model = request.model.clone().unwrap_or_else(|| self.model.clone());
1136        let max_tokens = request.max_tokens.unwrap_or(4096);
1137        let temperature = request.temperature;
1138        let system = request.system.clone();
1139        let extra_body = request.extra_body.clone();
1140        let output_config =
1141            anthropic_output_config(None, request.reasoning.as_ref().and_then(|r| r.effort));
1142        let messages: Vec<AnthropicMessage> = request
1143            .into_anthropic_messages()
1144            .into_iter()
1145            .map(|(role, content)| AnthropicMessage { role, content })
1146            .collect();
1147
1148        let body = AnthropicRequest {
1149            model,
1150            max_tokens,
1151            temperature,
1152            system,
1153            messages,
1154            stream: true,
1155            // Streaming is text-only here: the SSE parser emits text deltas and
1156            // does not reassemble streamed tool-use blocks. `effort` still
1157            // applies to streaming, so it rides in `output_config` (no
1158            // `format` — streaming has no schema enforcement).
1159            tools: None,
1160            output_config,
1161        };
1162
1163        let resp = self
1164            .client
1165            .post(format!("{}/messages", self.base_url))
1166            .header("x-api-key", &self.api_key)
1167            .header("anthropic-version", "2023-06-01")
1168            .header("content-type", "application/json")
1169            .json(&body_with_extra(&body, extra_body.as_ref()))
1170            .send()
1171            .await
1172            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
1173
1174        check_rate_limit(&resp)?;
1175
1176        let status = resp.status();
1177        if !status.is_success() {
1178            let text = resp.text().await.unwrap_or_default();
1179            return Err(KernelError::Http {
1180                status: status.as_u16(),
1181                message: redact_http_body(&text),
1182            });
1183        }
1184
1185        let (tx, rx) = tokio::sync::mpsc::channel::<Result<StreamEvent>>(16);
1186
1187        tokio::spawn(async move {
1188            let mut stream = std::pin::pin!(resp.bytes_stream());
1189            let mut buffer: Vec<u8> = Vec::new();
1190            let mut current_event = String::new();
1191
1192            use tokio_stream::StreamExt;
1193
1194            while let Some(chunk) = stream.next().await {
1195                let chunk = match chunk {
1196                    Ok(c) => c,
1197                    Err(e) => {
1198                        let _ = tx.send(Err(KernelError::LlmApi(e.to_string()))).await;
1199                        return;
1200                    }
1201                };
1202
1203                for line in drain_sse_lines(&mut buffer, &chunk) {
1204                    if let Some(evt) = line.strip_prefix("event: ") {
1205                        current_event = evt.to_string();
1206                    } else if let Some(data) = line.strip_prefix("data: ") {
1207                        if data == "[DONE]" {
1208                            let _ = tx.send(Ok(StreamEvent::Done)).await;
1209                            return;
1210                        }
1211                        for event in parse_anthropic_sse(&current_event, data) {
1212                            let is_done = matches!(event, StreamEvent::Done);
1213                            if tx.send(Ok(event)).await.is_err() || is_done {
1214                                return;
1215                            }
1216                        }
1217                        current_event.clear();
1218                    }
1219                }
1220            }
1221            let _ = tx.send(Ok(StreamEvent::Done)).await;
1222        });
1223
1224        Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
1225    }
1226}
1227
1228#[cfg(test)]
1229mod tests {
1230    use super::*;
1231    use crate::llm::types::ReasoningSummary;
1232
1233    #[test]
1234    fn parse_sse_line_extracts_data() {
1235        assert_eq!(
1236            parse_sse_line("data: {\"id\":\"1\"}"),
1237            Some("{\"id\":\"1\"}")
1238        );
1239    }
1240
1241    #[test]
1242    fn parse_sse_line_skips_done() {
1243        assert_eq!(parse_sse_line("data: [DONE]"), None);
1244    }
1245
1246    #[test]
1247    fn parse_sse_line_skips_non_data() {
1248        assert_eq!(parse_sse_line("event: ping"), None);
1249        assert_eq!(parse_sse_line(""), None);
1250    }
1251
1252    #[test]
1253    fn drain_sse_lines_reassembles_multibyte_split_across_chunks() {
1254        // "data: 안녕\n" — "data: " is 6 bytes, 안/녕 are 3 bytes each.
1255        let full = "data: 안녕\n".as_bytes().to_vec();
1256        // Split at byte 7, mid-way through "안"'s 3-byte sequence.
1257        let (first, rest) = full.split_at(7);
1258
1259        let mut buffer = Vec::new();
1260        // No newline yet, and the trailing bytes are a partial codepoint:
1261        // nothing should be emitted, and nothing should be corrupted.
1262        assert!(drain_sse_lines(&mut buffer, first).is_empty());
1263
1264        let lines = drain_sse_lines(&mut buffer, rest);
1265        assert_eq!(lines, vec!["data: 안녕".to_string()]);
1266        // A per-chunk from_utf8_lossy would instead have produced U+FFFD here.
1267        assert!(!lines[0].contains('\u{FFFD}'));
1268    }
1269
1270    #[test]
1271    fn drain_sse_lines_handles_multiple_lines_and_keeps_partial_tail() {
1272        let mut buffer = Vec::new();
1273        let lines = drain_sse_lines(&mut buffer, b"event: ping\r\ndata: {}\npartial");
1274        assert_eq!(
1275            lines,
1276            vec!["event: ping".to_string(), "data: {}".to_string()]
1277        );
1278        // The unterminated "partial" tail stays buffered for the next chunk.
1279        let lines = drain_sse_lines(&mut buffer, b" tail\n");
1280        assert_eq!(lines, vec!["partial tail".to_string()]);
1281    }
1282
1283    #[test]
1284    fn openai_delta_extraction() {
1285        let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#;
1286        let event = parse_openai_sse(data).unwrap();
1287        match event {
1288            StreamEvent::Delta { content } => assert_eq!(content, "Hello"),
1289            _ => panic!("expected Delta, got {:?}", event),
1290        }
1291    }
1292
1293    #[test]
1294    fn openai_usage_extraction() {
1295        let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}"#;
1296        let event = parse_openai_sse(data).unwrap();
1297        match event {
1298            StreamEvent::Usage(usage) => {
1299                assert_eq!(usage.prompt_tokens, 10);
1300                assert_eq!(usage.completion_tokens, 5);
1301                assert_eq!(usage.total_tokens, 15);
1302            }
1303            _ => panic!("expected Usage, got {:?}", event),
1304        }
1305    }
1306
1307    #[test]
1308    fn openai_finish_reason_is_done() {
1309        let data =
1310            r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#;
1311        let event = parse_openai_sse(data).unwrap();
1312        assert!(matches!(event, StreamEvent::Done));
1313    }
1314
1315    #[test]
1316    fn openai_empty_delta_skipped() {
1317        let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}]}"#;
1318        assert!(parse_openai_sse(data).is_none());
1319    }
1320
1321    #[test]
1322    fn anthropic_content_block_delta() {
1323        let data = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#;
1324        let events = parse_anthropic_sse("content_block_delta", data);
1325        match events.as_slice() {
1326            [StreamEvent::Delta { content }] => assert_eq!(content, "Hello"),
1327            _ => panic!("expected one Delta, got {:?}", events),
1328        }
1329    }
1330
1331    #[test]
1332    fn anthropic_message_delta_usage() {
1333        let data = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}"#;
1334        let events = parse_anthropic_sse("message_delta", data);
1335        match events.as_slice() {
1336            [StreamEvent::Stopped { reason }, StreamEvent::Usage(usage)] => {
1337                assert_eq!(reason, "end_turn");
1338                assert_eq!(usage.completion_tokens, 5);
1339            }
1340            _ => panic!("expected Stopped + Usage, got {:?}", events),
1341        }
1342    }
1343
1344    #[test]
1345    fn anthropic_message_delta_max_tokens_surfaces_stop_reason() {
1346        let data = r#"{"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":4096}}"#;
1347        let events = parse_anthropic_sse("message_delta", data);
1348        match events.as_slice() {
1349            [StreamEvent::Stopped { reason }, StreamEvent::Usage(_)] => {
1350                assert_eq!(reason, "max_tokens")
1351            }
1352            _ => panic!("expected Stopped + Usage, got {:?}", events),
1353        }
1354    }
1355
1356    #[test]
1357    fn anthropic_message_delta_refusal_surfaces_stop_reason() {
1358        let data = r#"{"type":"message_delta","delta":{"stop_reason":"refusal"},"usage":{"output_tokens":12}}"#;
1359        let events = parse_anthropic_sse("message_delta", data);
1360        match events.as_slice() {
1361            [StreamEvent::Stopped { reason }, StreamEvent::Usage(_)] => {
1362                assert_eq!(reason, "refusal")
1363            }
1364            _ => panic!("expected Stopped + Usage, got {:?}", events),
1365        }
1366    }
1367
1368    #[test]
1369    fn anthropic_message_delta_without_stop_reason_stays_usage_only() {
1370        let data = r#"{"type":"message_delta","delta":{},"usage":{"output_tokens":5}}"#;
1371        let events = parse_anthropic_sse("message_delta", data);
1372        match events.as_slice() {
1373            [StreamEvent::Usage(_)] => {}
1374            _ => panic!("expected Usage only, got {:?}", events),
1375        }
1376    }
1377
1378    #[test]
1379    fn anthropic_message_start_usage_includes_cache_tokens() {
1380        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}}}"#;
1381        let events = parse_anthropic_sse("message_start", data);
1382        match events.as_slice() {
1383            [StreamEvent::Usage(u)] => {
1384                assert_eq!(u.prompt_tokens, 3210);
1385                assert_eq!(u.completion_tokens, 1);
1386                assert_eq!(u.total_tokens, 3211);
1387            }
1388            _ => panic!("expected one Usage, got {:?}", events),
1389        }
1390    }
1391
1392    #[test]
1393    fn anthropic_message_start_usage_missing_fields_default_to_zero() {
1394        let data = r#"{"type":"message_start","message":{"usage":{"input_tokens":42}}}"#;
1395        let events = parse_anthropic_sse("message_start", data);
1396        match events.as_slice() {
1397            [StreamEvent::Usage(u)] => {
1398                assert_eq!(u.prompt_tokens, 42);
1399                assert_eq!(u.completion_tokens, 0);
1400                assert_eq!(u.total_tokens, 42);
1401            }
1402            _ => panic!("expected one Usage, got {:?}", events),
1403        }
1404    }
1405
1406    #[test]
1407    fn anthropic_message_start_without_usage_ignored() {
1408        let data = r#"{"type":"message_start","message":{"id":"msg_1"}}"#;
1409        assert!(parse_anthropic_sse("message_start", data).is_empty());
1410    }
1411
1412    #[test]
1413    fn anthropic_message_delta_with_cumulative_input_usage() {
1414        let data = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":10,"cache_read_input_tokens":90,"output_tokens":25}}"#;
1415        let events = parse_anthropic_sse("message_delta", data);
1416        match events.as_slice() {
1417            [StreamEvent::Stopped { .. }, StreamEvent::Usage(u)] => {
1418                assert_eq!(u.prompt_tokens, 100);
1419                assert_eq!(u.completion_tokens, 25);
1420                assert_eq!(u.total_tokens, 125);
1421            }
1422            _ => panic!("expected Stopped + Usage, got {:?}", events),
1423        }
1424    }
1425
1426    #[test]
1427    fn anthropic_message_delta_output_only_usage() {
1428        let data = r#"{"type":"message_delta","delta":{},"usage":{"output_tokens":7}}"#;
1429        let events = parse_anthropic_sse("message_delta", data);
1430        match events.as_slice() {
1431            [StreamEvent::Usage(u)] => {
1432                assert_eq!(u.prompt_tokens, 0);
1433                assert_eq!(u.completion_tokens, 7);
1434                assert_eq!(u.total_tokens, 7);
1435            }
1436            _ => panic!("expected Usage only, got {:?}", events),
1437        }
1438    }
1439
1440    #[test]
1441    fn anthropic_message_stop() {
1442        let events = parse_anthropic_sse("message_stop", r#"{"type":"message_stop"}"#);
1443        assert!(matches!(events.as_slice(), [StreamEvent::Done]));
1444    }
1445
1446    #[test]
1447    fn anthropic_unknown_event_ignored() {
1448        assert!(parse_anthropic_sse("ping", "{}").is_empty());
1449    }
1450
1451    fn sample_tool() -> ToolDefinition {
1452        ToolDefinition {
1453            name: "get_weather".into(),
1454            description: "Get weather".into(),
1455            input_schema: serde_json::json!({
1456                "type": "object",
1457                "properties": { "location": { "type": "string" } },
1458                "required": ["location"]
1459            }),
1460        }
1461    }
1462
1463    #[test]
1464    fn openai_tools_use_function_wrapper() {
1465        let out = openai_tools(&[sample_tool()]);
1466        assert_eq!(out.len(), 1);
1467        assert_eq!(out[0]["type"], "function");
1468        assert_eq!(out[0]["function"]["name"], "get_weather");
1469        // input_schema is forwarded verbatim as `parameters`.
1470        assert_eq!(out[0]["function"]["parameters"]["required"][0], "location");
1471    }
1472
1473    #[test]
1474    fn openai_response_format_maps_each_variant() {
1475        assert!(openai_response_format(&ResponseFormat::Text).is_none());
1476        assert_eq!(
1477            openai_response_format(&ResponseFormat::Json).unwrap()["type"],
1478            "json_object"
1479        );
1480        let schema = serde_json::json!({"type": "object"});
1481        let js = openai_response_format(&ResponseFormat::JsonSchema { schema }).unwrap();
1482        assert_eq!(js["type"], "json_schema");
1483        assert_eq!(js["json_schema"]["strict"], true);
1484    }
1485
1486    #[test]
1487    fn anthropic_tools_use_input_schema_key() {
1488        let out = anthropic_tools(&[sample_tool()]);
1489        assert_eq!(out[0]["name"], "get_weather");
1490        assert_eq!(out[0]["input_schema"]["type"], "object");
1491        assert!(out[0].get("function").is_none());
1492    }
1493
1494    #[test]
1495    fn anthropic_output_config_only_for_json_schema() {
1496        assert!(anthropic_output_config(Some(&ResponseFormat::Text), None).is_none());
1497        assert!(anthropic_output_config(Some(&ResponseFormat::Json), None).is_none());
1498        assert!(anthropic_output_config(None, None).is_none());
1499        let schema = serde_json::json!({"type": "object"});
1500        let cfg = anthropic_output_config(Some(&ResponseFormat::JsonSchema { schema }), None);
1501        let cfg = cfg.unwrap();
1502        assert_eq!(cfg["format"]["type"], "json_schema");
1503    }
1504
1505    #[test]
1506    fn anthropic_output_config_carries_effort() {
1507        let cfg = anthropic_output_config(None, Some(ReasoningEffort::High)).unwrap();
1508        assert_eq!(cfg["effort"], "high");
1509        assert!(cfg.get("format").is_none());
1510        // Schema and effort merge into one object (two keys, not two configs).
1511        let schema = serde_json::json!({"type": "object"});
1512        let cfg = anthropic_output_config(
1513            Some(&ResponseFormat::JsonSchema { schema }),
1514            Some(ReasoningEffort::XHigh),
1515        )
1516        .unwrap();
1517        assert_eq!(cfg["effort"], "xhigh");
1518        assert_eq!(cfg["format"]["type"], "json_schema");
1519        // Wire values must match the Anthropic effort vocabulary exactly.
1520        for (variant, wire) in [
1521            (ReasoningEffort::Low, "low"),
1522            (ReasoningEffort::Medium, "medium"),
1523            (ReasoningEffort::High, "high"),
1524            (ReasoningEffort::XHigh, "xhigh"),
1525            (ReasoningEffort::Max, "max"),
1526        ] {
1527            let cfg = anthropic_output_config(None, Some(variant)).unwrap();
1528            assert_eq!(cfg["effort"], wire);
1529        }
1530    }
1531
1532    #[test]
1533    fn anthropic_request_omits_temperature_when_none() {
1534        let body = AnthropicRequest {
1535            model: "claude-opus-5-5".into(),
1536            max_tokens: 64,
1537            temperature: None,
1538            system: None,
1539            messages: vec![],
1540            stream: false,
1541            tools: None,
1542            output_config: None,
1543        };
1544        let json = serde_json::to_value(&body).unwrap();
1545        assert!(json.get("temperature").is_none());
1546        assert!(json.get("output_config").is_none());
1547    }
1548
1549    #[test]
1550    fn anthropic_request_serializes_temperature_and_effort_when_set() {
1551        let body = AnthropicRequest {
1552            model: "claude-sonnet-4-6".into(),
1553            max_tokens: 64,
1554            temperature: Some(0.5),
1555            system: None,
1556            messages: vec![],
1557            stream: false,
1558            tools: None,
1559            output_config: anthropic_output_config(None, Some(ReasoningEffort::Max)),
1560        };
1561        let json = serde_json::to_value(&body).unwrap();
1562        assert_eq!(json["temperature"], serde_json::json!(0.5f32));
1563        assert_eq!(json["output_config"]["effort"], "max");
1564    }
1565
1566    #[test]
1567    fn openai_request_serializes_tools_and_format() {
1568        let body = OpenAIChatRequest {
1569            model: "gpt-4o".into(),
1570            messages: vec![OpenAIChatMessage {
1571                role: "user".into(),
1572                content: "hi".into(),
1573            }],
1574            temperature: 0.7,
1575            max_tokens: None,
1576            stream: false,
1577            tools: Some(openai_tools(&[sample_tool()])),
1578            response_format: Some(serde_json::json!({ "type": "json_object" })),
1579            reasoning_effort: None,
1580            reasoning: None,
1581            verbosity: None,
1582        };
1583        let json = serde_json::to_value(&body).unwrap();
1584        assert_eq!(json["tools"][0]["function"]["name"], "get_weather");
1585        assert_eq!(json["response_format"]["type"], "json_object");
1586        // Omitted when None (backward-compatible request shape).
1587        assert!(json.get("max_tokens").is_none());
1588        assert!(json.get("reasoning_effort").is_none());
1589        assert!(json.get("reasoning").is_none());
1590    }
1591
1592    #[test]
1593    fn openai_reasoning_effort_maps_to_official_param() {
1594        let (effort, reasoning) =
1595            openai_reasoning(Some(&ReasoningConfig::effort(ReasoningEffort::High)));
1596        assert_eq!(effort, Some(ReasoningEffort::High));
1597        assert!(reasoning.is_none());
1598        let body = OpenAIChatRequest {
1599            model: "gpt-5.5".into(),
1600            messages: vec![],
1601            temperature: 0.7,
1602            max_tokens: None,
1603            stream: false,
1604            tools: None,
1605            response_format: None,
1606            reasoning_effort: effort,
1607            reasoning,
1608            verbosity: None,
1609        };
1610        let json = serde_json::to_value(&body).unwrap();
1611        assert_eq!(json["reasoning_effort"], "high");
1612        assert!(json.get("reasoning").is_none());
1613    }
1614
1615    #[test]
1616    fn openai_reasoning_disabled_maps_to_openrouter_object() {
1617        // immune's exact need: reasoning: {"enabled": false} on OpenRouter,
1618        // with no reasoning_effort key and no config -> no keys at all.
1619        let (effort, reasoning) = openai_reasoning(Some(&ReasoningConfig::disabled()));
1620        assert_eq!(effort, None);
1621        assert_eq!(reasoning.unwrap()["enabled"], false);
1622
1623        let (effort, reasoning) = openai_reasoning(None);
1624        assert!(effort.is_none() && reasoning.is_none());
1625    }
1626
1627    #[test]
1628    fn openai_reasoning_both_knobs_serialize_independently() {
1629        let (effort, reasoning) = openai_reasoning(Some(&ReasoningConfig {
1630            enabled: Some(true),
1631            effort: Some(ReasoningEffort::Low),
1632            summary: Some(ReasoningSummary::Concise),
1633        }));
1634        let body = OpenAIChatRequest {
1635            model: "m".into(),
1636            messages: vec![],
1637            temperature: 0.7,
1638            max_tokens: None,
1639            stream: false,
1640            tools: None,
1641            response_format: None,
1642            reasoning_effort: effort,
1643            reasoning,
1644            verbosity: Some(Verbosity::High),
1645        };
1646        let json = serde_json::to_value(&body).unwrap();
1647        assert_eq!(json["reasoning_effort"], "low");
1648        assert_eq!(json["reasoning"]["enabled"], true);
1649        assert_eq!(json["reasoning"]["summary"], "concise");
1650        assert_eq!(json["verbosity"], "high");
1651    }
1652
1653    #[test]
1654    fn openai_reasoning_summary_only_serializes_object() {
1655        let (effort, reasoning) = openai_reasoning(Some(&ReasoningConfig {
1656            enabled: None,
1657            effort: None,
1658            summary: Some(ReasoningSummary::Detailed),
1659        }));
1660        assert!(effort.is_none());
1661        assert_eq!(reasoning.unwrap()["summary"], "detailed");
1662    }
1663
1664    #[test]
1665    fn openai_body_merges_extra_body_last_write_wins() {
1666        let body = OpenAIChatRequest {
1667            model: "m".into(),
1668            messages: vec![],
1669            temperature: 0.7,
1670            max_tokens: None,
1671            stream: false,
1672            tools: None,
1673            response_format: None,
1674            reasoning_effort: None,
1675            reasoning: None,
1676            verbosity: None,
1677        };
1678        let mut extra = serde_json::Map::new();
1679        extra.insert("seed".into(), 7.into());
1680        extra.insert("temperature".into(), 0.1.into());
1681        let merged = body_with_extra(&body, Some(&extra));
1682        assert_eq!(merged["seed"], 7);
1683        // Extra key overrides the natively forwarded one (last-write-wins).
1684        assert_eq!(merged["temperature"], 0.1);
1685        // Without extras the body is untouched. (Compare via json! so the f32
1686        // → f64 widening in the Number matches on both sides.)
1687        let plain = body_with_extra(&body, None);
1688        assert_eq!(plain["temperature"], serde_json::json!(0.7f32));
1689        assert!(plain.get("seed").is_none());
1690    }
1691
1692    #[test]
1693    fn anthropic_body_merges_extra_body_last_write_wins() {
1694        let body = AnthropicRequest {
1695            model: "m".into(),
1696            max_tokens: 8,
1697            temperature: Some(0.7),
1698            system: Some("sys".into()),
1699            messages: vec![],
1700            stream: false,
1701            tools: None,
1702            output_config: None,
1703        };
1704        let mut extra = serde_json::Map::new();
1705        extra.insert("thinking".into(), serde_json::json!({"type": "disabled"}));
1706        let merged = body_with_extra(&body, Some(&extra));
1707        assert_eq!(merged["thinking"]["type"], "disabled");
1708        assert_eq!(merged["system"], "sys");
1709    }
1710
1711    #[test]
1712    fn verbosity_wire_values_match_openai_spec() {
1713        // Official enum: low, medium, high.
1714        for (variant, wire) in [
1715            (Verbosity::Low, "low"),
1716            (Verbosity::Medium, "medium"),
1717            (Verbosity::High, "high"),
1718        ] {
1719            assert_eq!(serde_json::to_value(variant).unwrap(), wire);
1720        }
1721    }
1722
1723    #[test]
1724    fn reasoning_summary_wire_values_match_openai_spec() {
1725        // Official enum: auto, concise, detailed.
1726        for (variant, wire) in [
1727            (ReasoningSummary::Auto, "auto"),
1728            (ReasoningSummary::Concise, "concise"),
1729            (ReasoningSummary::Detailed, "detailed"),
1730        ] {
1731            assert_eq!(serde_json::to_value(variant).unwrap(), wire);
1732        }
1733    }
1734
1735    #[test]
1736    fn reasoning_effort_wire_values_match_openai_spec() {
1737        // Official enum: none, minimal, low, medium, high, xhigh, max.
1738        for (variant, wire) in [
1739            (ReasoningEffort::None, "none"),
1740            (ReasoningEffort::Minimal, "minimal"),
1741            (ReasoningEffort::Low, "low"),
1742            (ReasoningEffort::Medium, "medium"),
1743            (ReasoningEffort::High, "high"),
1744            (ReasoningEffort::XHigh, "xhigh"),
1745            (ReasoningEffort::Max, "max"),
1746        ] {
1747            assert_eq!(serde_json::to_value(variant).unwrap(), wire);
1748            let back: ReasoningEffort = serde_json::from_value(wire.into()).unwrap();
1749            assert_eq!(back, variant);
1750        }
1751    }
1752
1753    #[test]
1754    fn lenient_decode_rescues_openrouter_control_chars() {
1755        // OpenRouter was observed returning 200 with raw \n / \t inside the
1756        // reasoning string; strict serde_json fails the whole body.
1757        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}}";
1758        assert!(serde_json::from_str::<OpenAIChatResponse>(raw).is_err());
1759        let resp: OpenAIChatResponse = from_json_lenient(raw).unwrap();
1760        let first = resp.choices.into_iter().next().unwrap();
1761        assert_eq!(first.message.content.as_deref(), Some("답변"));
1762        assert_eq!(
1763            first.message.reasoning_content.as_deref(),
1764            Some("step 1\nstep 2\ttab")
1765        );
1766    }
1767
1768    #[test]
1769    fn lenient_decode_keeps_original_error_for_structural_garbage() {
1770        // Broken structure (not just control chars) still errors, surfacing
1771        // the *original* strict-parse error rather than a sanitize artifact.
1772        let raw = "{\"choices\": tr";
1773        let strict = serde_json::from_str::<OpenAIChatResponse>(raw)
1774            .map(|_: OpenAIChatResponse| ())
1775            .unwrap_err();
1776        let lenient = from_json_lenient::<OpenAIChatResponse>(raw)
1777            .map(|_: OpenAIChatResponse| ())
1778            .unwrap_err();
1779        assert_eq!(lenient.to_string(), strict.to_string());
1780    }
1781
1782    #[test]
1783    fn escape_unescaped_control_chars_only_touches_string_literals() {
1784        // Raw newline outside strings (pretty-printed JSON) stays as-is;
1785        // inside strings it becomes \uXXXX; existing escapes are untouched.
1786        let raw = "{\n  \"a\": \"x\ny\\n\\tz\",\n  \"b\": 1\n}";
1787        let out = escape_unescaped_control_chars(raw);
1788        assert_eq!(out, "{\n  \"a\": \"x\\u000ay\\n\\tz\",\n  \"b\": 1\n}");
1789        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
1790        assert_eq!(v["a"], "x\ny\n\tz");
1791    }
1792
1793    #[test]
1794    fn escape_unescaped_control_chars_handles_escaped_quotes() {
1795        // \" inside a string must not toggle the in-string state.
1796        let raw = "{\"a\":\"say \\\"hi\\\" ok\"}";
1797        assert_eq!(escape_unescaped_control_chars(raw), raw);
1798    }
1799
1800    #[test]
1801    fn openai_sse_lenient_reasoning_delta_with_control_chars() {
1802        // A streamed reasoning delta carrying a raw tab must not be dropped.
1803        let data = "{\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"think\ttab\"},\"finish_reason\":null}]}";
1804        let event = parse_openai_sse(data).unwrap();
1805        match event {
1806            StreamEvent::ReasoningDelta { content } => assert_eq!(content, "think\ttab"),
1807            other => panic!("expected ReasoningDelta, got {other:?}"),
1808        }
1809    }
1810
1811    #[test]
1812    fn openai_response_parses_tool_calls() {
1813        let raw = r#"{
1814            "id": "chatcmpl-1",
1815            "created": 1700000000,
1816            "model": "gpt-4o",
1817            "choices": [{
1818                "index": 0,
1819                "message": {
1820                    "role": "assistant",
1821                    "content": null,
1822                    "tool_calls": [{
1823                        "id": "call_abc",
1824                        "type": "function",
1825                        "function": { "name": "get_weather", "arguments": "{\"location\":\"Paris\"}" }
1826                    }]
1827                },
1828                "finish_reason": "tool_calls"
1829            }],
1830            "usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15 }
1831        }"#;
1832        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1833        assert_eq!(resp.id.as_deref(), Some("chatcmpl-1"));
1834        let choice = resp.choices.into_iter().next().unwrap();
1835        assert_eq!(choice.finish_reason.as_deref(), Some("tool_calls"));
1836        assert!(choice.message.content.is_none());
1837        assert_eq!(choice.message.tool_calls.len(), 1);
1838        assert_eq!(choice.message.tool_calls[0].function.name, "get_weather");
1839    }
1840
1841    #[test]
1842    fn openai_response_parses_glm47_reasoning_content() {
1843        // GLM-4.7: content=null, reasoning_content carries the chain-of-thought + final JSON.
1844        let raw = r#"{
1845            "id":"chatcmpl-1","created":1700000000,"model":"glm-4.7",
1846            "choices":[{"index":0,"message":{"role":"assistant","content":null,
1847                "reasoning_content":"thinking... {\"rating\":\"Buy\"}"},"finish_reason":"stop"}],
1848            "usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15,
1849                "completion_tokens_details":{"reasoning_tokens":3}}
1850        }"#;
1851        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1852        let choice = resp.choices.into_iter().next().unwrap();
1853        assert!(choice.message.content.is_none());
1854        assert!(choice.message.reasoning_content.is_some());
1855        assert_eq!(
1856            resp.usage
1857                .unwrap()
1858                .completion_tokens_details
1859                .unwrap()
1860                .reasoning_tokens,
1861            Some(3)
1862        );
1863    }
1864
1865    #[test]
1866    fn openai_complete_promotes_reasoning_when_content_empty() {
1867        // complete() must promote reasoning_content into content when content is empty,
1868        // so downstream json_extract finds the JSON. Drives the *production* promotion
1869        // helper (not a re-implementation) so the regression guard tracks real code.
1870        let raw = r#"{
1871            "id":"chatcmpl-1","created":1700000000,"model":"glm-4.7",
1872            "choices":[{"index":0,"message":{"role":"assistant","content":"",
1873                "reasoning_content":"사고 {\"rating\":\"Sell\",\"key_thesis\":\"약세\"}"},"finish_reason":"stop"}]
1874        }"#;
1875        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1876        let first = resp.choices.into_iter().next().unwrap();
1877        let raw_content = first.message.content.unwrap_or_default();
1878        let reasoning = first.message.reasoning_content.clone();
1879        let content = promote_reasoning_into_content(raw_content, reasoning.as_deref());
1880        assert!(
1881            content.contains("\"rating\":\"Sell\""),
1882            "promoted content must contain JSON: {content}"
1883        );
1884        assert_eq!(
1885            reasoning.as_deref(),
1886            Some("사고 {\"rating\":\"Sell\",\"key_thesis\":\"약세\"}")
1887        );
1888    }
1889
1890    #[test]
1891    fn openai_complete_keeps_content_when_present() {
1892        // Non-empty content must NOT be overwritten by reasoning.
1893        let raw = r#"{
1894            "id":"chatcmpl-1","created":1700000000,"model":"gpt-4o",
1895            "choices":[{"index":0,"message":{"role":"assistant","content":"answer",
1896                "reasoning_content":"thought"},"finish_reason":"stop"}]
1897        }"#;
1898        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1899        let first = resp.choices.into_iter().next().unwrap();
1900        let raw_content = first.message.content.clone().unwrap_or_default();
1901        let reasoning = first.message.reasoning_content.as_deref();
1902        let content = promote_reasoning_into_content(raw_content.clone(), reasoning);
1903        assert_eq!(content, "answer");
1904        assert_eq!(reasoning, Some("thought"));
1905    }
1906
1907    #[test]
1908    fn promote_reasoning_into_content_edge_cases() {
1909        // #3 caveat: empty content + reasoning surfaces the reasoning as the answer
1910        // (documented trade-off; original reasoning is the source of truth).
1911        assert_eq!(
1912            promote_reasoning_into_content(String::new(), Some("chain-of-thought")),
1913            "chain-of-thought"
1914        );
1915        // content present wins regardless of reasoning.
1916        assert_eq!(
1917            promote_reasoning_into_content("ans".into(), Some("cot")),
1918            "ans"
1919        );
1920        // both empty -> empty (no panic, no spurious content).
1921        assert_eq!(promote_reasoning_into_content(String::new(), None), "");
1922        assert_eq!(promote_reasoning_into_content("x".into(), None), "x");
1923    }
1924
1925    #[test]
1926    fn openai_response_parses_deepseek_reasoning_alias() {
1927        // DeepSeek-R1 uses field name `reasoning` instead of `reasoning_content`.
1928        let raw = r#"{"model":"deepseek-r1","choices":[{"message":{"content":"ans","reasoning":"thought"}}]}"#;
1929        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1930        assert_eq!(
1931            resp.choices[0].message.reasoning_content.as_deref(),
1932            Some("thought")
1933        );
1934    }
1935
1936    #[test]
1937    fn openai_sse_reasoning_delta_extracted() {
1938        let data = r#"{"choices":[{"index":0,"delta":{"reasoning_content":"thinking"},"finish_reason":null}]}"#;
1939        let event = parse_openai_sse(data).unwrap();
1940        match event {
1941            StreamEvent::ReasoningDelta { content } => assert_eq!(content, "thinking"),
1942            other => panic!("expected ReasoningDelta, got {other:?}"),
1943        }
1944    }
1945
1946    #[test]
1947    fn openai_sse_content_delta_still_works_alongside_reasoning() {
1948        // Separate content chunk must still produce Delta, not be swallowed.
1949        let data = r#"{"choices":[{"index":0,"delta":{"content":"answer"},"finish_reason":null}]}"#;
1950        let event = parse_openai_sse(data).unwrap();
1951        assert!(matches!(event, StreamEvent::Delta { .. }));
1952    }
1953
1954    #[test]
1955    fn openai_sse_usage_carries_reasoning_tokens() {
1956        // Final streaming chunk carries choices (empty delta) + usage with reasoning_tokens.
1957        let data = r#"{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],
1958            "usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3,
1959            "completion_tokens_details":{"reasoning_tokens":7}}}"#;
1960        let event = parse_openai_sse(data).unwrap();
1961        match event {
1962            StreamEvent::Usage(u) => assert_eq!(u.reasoning_tokens, Some(7)),
1963            other => panic!("expected Usage, got {other:?}"),
1964        }
1965    }
1966
1967    #[test]
1968    fn anthropic_response_parses_tool_use_block() {
1969        let raw = r#"{
1970            "id": "msg_1",
1971            "model": "claude-sonnet-4-6",
1972            "stop_reason": "tool_use",
1973            "content": [
1974                { "type": "text", "text": "Let me check." },
1975                { "type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": { "location": "Paris" } }
1976            ],
1977            "usage": { "input_tokens": 12, "output_tokens": 8 }
1978        }"#;
1979        let resp: AnthropicResponse = serde_json::from_str(raw).unwrap();
1980        assert_eq!(resp.stop_reason.as_deref(), Some("tool_use"));
1981        assert_eq!(resp.content.len(), 2);
1982        assert_eq!(resp.content[0].block_type, "text");
1983        assert_eq!(resp.content[1].block_type, "tool_use");
1984        assert_eq!(resp.content[1].name.as_deref(), Some("get_weather"));
1985        assert_eq!(resp.content[1].input.as_ref().unwrap()["location"], "Paris");
1986    }
1987
1988    #[test]
1989    fn anthropic_response_usage_includes_cache_tokens() {
1990        let raw = r#"{"type":"message","model":"claude-opus-5-5","content":[],"stop_reason":"end_turn","usage":{"input_tokens":10,"cache_creation_input_tokens":200,"cache_read_input_tokens":3000,"output_tokens":5}}"#;
1991        let resp: AnthropicResponse = serde_json::from_str(raw).unwrap();
1992        assert_eq!(resp.usage.prompt_tokens(), 3210);
1993
1994        let raw = r#"{"type":"message","model":"claude-opus-5-5","content":[],"stop_reason":"end_turn","usage":{"input_tokens":12,"output_tokens":8}}"#;
1995        let resp: AnthropicResponse = serde_json::from_str(raw).unwrap();
1996        assert_eq!(resp.usage.prompt_tokens(), 12);
1997    }
1998
1999    #[test]
2000    fn anthropic_response_parses_thinking_block() {
2001        // Extended thinking block must surface in LLMResponse.reasoning via the parse loop.
2002        let raw = r#"{
2003            "id": "msg_2",
2004            "model": "claude-sonnet-4-6",
2005            "stop_reason": "end_turn",
2006            "content": [
2007                { "type": "thinking", "thinking": "step by step..." },
2008                { "type": "text", "text": "Final answer." }
2009            ],
2010            "usage": { "input_tokens": 5, "output_tokens": 9 }
2011        }"#;
2012        let resp: AnthropicResponse = serde_json::from_str(raw).unwrap();
2013        let mut reasoning = String::new();
2014        let mut content = String::new();
2015        for block in resp.content {
2016            match block.block_type.as_str() {
2017                "text" => {
2018                    if let Some(t) = block.text {
2019                        content.push_str(&t);
2020                    }
2021                }
2022                "thinking" => {
2023                    if let Some(t) = block.thinking {
2024                        reasoning.push_str(&t);
2025                    }
2026                }
2027                _ => {}
2028            }
2029        }
2030        assert_eq!(content, "Final answer.");
2031        assert_eq!(reasoning, "step by step...");
2032    }
2033
2034    #[test]
2035    fn anthropic_sse_thinking_delta_extracted() {
2036        let data = r#"{"delta":{"type":"thinking_delta","thinking":"a thought"}}"#;
2037        let events = parse_anthropic_sse("content_block_delta", data);
2038        match events.as_slice() {
2039            [StreamEvent::ReasoningDelta { content }] => assert_eq!(content, "a thought"),
2040            other => panic!("expected ReasoningDelta, got {other:?}"),
2041        }
2042    }
2043}