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/// Parse an Anthropic streaming JSON chunk into zero or more StreamEvents.
766/// A `message_delta` can carry both a stop reason and final usage, so one
767/// chunk may yield several events.
768fn parse_anthropic_sse(event_type: &str, data: &str) -> Vec<StreamEvent> {
769    let Ok(v) = serde_json::from_str::<serde_json::Value>(data) else {
770        return Vec::new();
771    };
772
773    match event_type {
774        "content_block_delta" => {
775            let Some(delta) = v.get("delta") else {
776                return Vec::new();
777            };
778            // Extended thinking deltas arrive as {"type":"thinking_delta","thinking":"..."}.
779            match delta.get("type").and_then(|t| t.as_str()) {
780                Some("thinking_delta") => match delta.get("thinking").and_then(|t| t.as_str()) {
781                    Some(text) if !text.is_empty() => {
782                        vec![StreamEvent::ReasoningDelta {
783                            content: text.to_string(),
784                        }]
785                    }
786                    _ => Vec::new(),
787                },
788                _ => {
789                    // text_delta (default) carries {"text":"..."}.
790                    match delta.get("text").and_then(|t| t.as_str()) {
791                        Some(text) if !text.is_empty() => {
792                            vec![StreamEvent::Delta {
793                                content: text.to_string(),
794                            }]
795                        }
796                        _ => Vec::new(),
797                    }
798                }
799            }
800        }
801        "message_delta" => {
802            let mut events = Vec::new();
803            // `delta.stop_reason` (`"max_tokens"`, `"refusal"`, …) is the only
804            // signal that the generation ended early — without it a truncated
805            // stream looks identical to a complete one.
806            if let Some(reason) = v
807                .get("delta")
808                .and_then(|d| d.get("stop_reason"))
809                .and_then(|r| r.as_str())
810            {
811                events.push(StreamEvent::Stopped {
812                    reason: reason.to_string(),
813                });
814            }
815            let usage = v.get("usage").and_then(|u| {
816                Some(TokenUsage {
817                    prompt_tokens: 0,
818                    completion_tokens: u.get("output_tokens")?.as_u64()? as u32,
819                    total_tokens: 0,
820                    reasoning_tokens: None,
821                })
822            });
823            match usage {
824                Some(u) => events.push(StreamEvent::Usage(u)),
825                None => events.push(StreamEvent::Done),
826            }
827            events
828        }
829        "message_stop" => vec![StreamEvent::Done],
830        _ => Vec::new(),
831    }
832}
833
834/// Async LLM client for the Anthropic Messages API.
835pub struct AnthropicClient {
836    api_key: String,
837    model: String,
838    base_url: String,
839    client: reqwest::Client,
840}
841
842impl AnthropicClient {
843    /// Create a new client using credentials from the environment variable in `config`.
844    pub fn new(config: &ModelConfig) -> Result<Self> {
845        let api_key = std::env::var(&config.api_key_env).map_err(|_| {
846            KernelError::Config(format!(
847                "Environment variable {} not set",
848                config.api_key_env
849            ))
850        })?;
851        Ok(Self {
852            api_key,
853            model: config.model.clone(),
854            base_url: config
855                .base_url
856                .clone()
857                .unwrap_or_else(|| "https://api.anthropic.com/v1".into()),
858            client: http_client()?,
859        })
860    }
861
862    /// Create a new client with an explicit API key, using the default Anthropic base URL.
863    ///
864    /// Returns a [`KernelError::Config`] if the HTTP client (with its connect /
865    /// total timeouts) cannot be built, rather than silently falling back to a
866    /// timeout-less `reqwest::Client::default()`.
867    pub fn from_key(model: impl Into<String>, api_key: impl Into<String>) -> Result<Self> {
868        Ok(Self {
869            api_key: api_key.into(),
870            model: model.into(),
871            base_url: "https://api.anthropic.com/v1".into(),
872            client: http_client()?,
873        })
874    }
875
876    /// Create from an explicit key and a shared `reqwest::Client`.
877    pub fn from_key_with_client(
878        model: impl Into<String>,
879        api_key: impl Into<String>,
880        client: reqwest::Client,
881    ) -> Self {
882        Self {
883            api_key: api_key.into(),
884            model: model.into(),
885            base_url: "https://api.anthropic.com/v1".into(),
886            client,
887        }
888    }
889
890    /// Create from an explicit key, a custom base URL, and a shared `reqwest::Client`.
891    ///
892    /// Use this for Anthropic-compatible endpoints that are not the default
893    /// `api.anthropic.com` (self-hosted proxies, regional gateways, …).
894    pub fn from_key_with_base_url(
895        model: impl Into<String>,
896        api_key: impl Into<String>,
897        base_url: impl Into<String>,
898        client: reqwest::Client,
899    ) -> Self {
900        Self {
901            api_key: api_key.into(),
902            model: model.into(),
903            base_url: base_url.into(),
904            client,
905        }
906    }
907}
908
909#[derive(serde::Serialize)]
910struct AnthropicRequest {
911    model: String,
912    max_tokens: u32,
913    /// Omitted entirely when `None` — Claude Opus 5.5 / Sonnet 5.5 reject any
914    /// non-default sampling parameter with 400, so callers opt out by leaving
915    /// it unset instead of sending the historical `0.7`.
916    #[serde(skip_serializing_if = "Option::is_none")]
917    temperature: Option<f32>,
918    #[serde(skip_serializing_if = "Option::is_none")]
919    system: Option<String>,
920    messages: Vec<AnthropicMessage>,
921    #[serde(skip_serializing_if = "std::ops::Not::not")]
922    stream: bool,
923    #[serde(skip_serializing_if = "Option::is_none")]
924    tools: Option<Vec<serde_json::Value>>,
925    #[serde(skip_serializing_if = "Option::is_none")]
926    output_config: Option<serde_json::Value>,
927}
928
929#[derive(serde::Serialize)]
930struct AnthropicMessage {
931    role: String,
932    content: String,
933}
934
935#[derive(serde::Deserialize)]
936struct AnthropicResponse {
937    #[serde(default)]
938    id: Option<String>,
939    content: Vec<AnthropicContentBlock>,
940    model: String,
941    #[serde(default)]
942    stop_reason: Option<String>,
943    usage: AnthropicUsage,
944}
945
946/// A response content block. `text` blocks carry `text`; `tool_use` blocks
947/// carry `id`/`name`/`input`; `thinking` blocks (extended thinking) carry `thinking`.
948#[derive(serde::Deserialize)]
949struct AnthropicContentBlock {
950    #[serde(rename = "type")]
951    block_type: String,
952    #[serde(default)]
953    text: Option<String>,
954    /// Extended thinking content (`{"type":"thinking","thinking":"..."}`).
955    #[serde(default)]
956    thinking: Option<String>,
957    #[serde(default)]
958    id: Option<String>,
959    #[serde(default)]
960    name: Option<String>,
961    #[serde(default)]
962    input: Option<serde_json::Value>,
963}
964
965#[derive(serde::Deserialize)]
966struct AnthropicUsage {
967    input_tokens: u32,
968    output_tokens: u32,
969}
970
971#[async_trait]
972impl LLMClient for AnthropicClient {
973    async fn complete(&self, request: LLMRequest) -> Result<LLMResponse> {
974        let model = request.model.clone().unwrap_or_else(|| self.model.clone());
975        let max_tokens = request.max_tokens.unwrap_or(4096);
976        let temperature = request.temperature;
977        let system = request.system.clone();
978        let tools = request
979            .tools
980            .as_deref()
981            .map(anthropic_tools)
982            .filter(|t| !t.is_empty());
983        let output_config = anthropic_output_config(
984            request.response_format.as_ref(),
985            request.reasoning.as_ref().and_then(|r| r.effort),
986        );
987        let extra_body = request.extra_body.clone();
988        let messages: Vec<AnthropicMessage> = request
989            .into_anthropic_messages()
990            .into_iter()
991            .map(|(role, content)| AnthropicMessage { role, content })
992            .collect();
993
994        let body = AnthropicRequest {
995            model,
996            max_tokens,
997            temperature,
998            system,
999            messages,
1000            stream: false,
1001            tools,
1002            output_config,
1003        };
1004
1005        let resp = self
1006            .client
1007            .post(format!("{}/messages", self.base_url))
1008            .header("x-api-key", &self.api_key)
1009            .header("anthropic-version", "2023-06-01")
1010            .header("content-type", "application/json")
1011            .json(&body_with_extra(&body, extra_body.as_ref()))
1012            .send()
1013            .await
1014            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
1015
1016        check_rate_limit(&resp)?;
1017
1018        let status = resp.status();
1019
1020        if !status.is_success() {
1021            let text = resp.text().await.unwrap_or_default();
1022            return Err(KernelError::Http {
1023                status: status.as_u16(),
1024                message: redact_http_body(&text),
1025            });
1026        }
1027
1028        let chat_resp: AnthropicResponse = resp
1029            .json()
1030            .await
1031            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
1032
1033        let mut content = String::new();
1034        let mut reasoning = String::new();
1035        let mut tool_calls = Vec::new();
1036        for block in chat_resp.content {
1037            match block.block_type.as_str() {
1038                "text" => {
1039                    if let Some(t) = block.text {
1040                        content.push_str(&t);
1041                    }
1042                }
1043                "thinking" => {
1044                    if let Some(t) = block.thinking {
1045                        reasoning.push_str(&t);
1046                    }
1047                }
1048                "tool_use" => {
1049                    if let (Some(id), Some(name)) = (block.id, block.name) {
1050                        let arguments = block
1051                            .input
1052                            .map(|v| v.to_string())
1053                            .unwrap_or_else(|| "{}".to_string());
1054                        tool_calls.push(ToolCall {
1055                            id,
1056                            name,
1057                            arguments,
1058                        });
1059                    }
1060                }
1061                _ => {}
1062            }
1063        }
1064
1065        Ok(LLMResponse {
1066            content,
1067            reasoning: if reasoning.is_empty() {
1068                None
1069            } else {
1070                Some(reasoning)
1071            },
1072            model: chat_resp.model,
1073            usage: TokenUsage {
1074                prompt_tokens: chat_resp.usage.input_tokens,
1075                completion_tokens: chat_resp.usage.output_tokens,
1076                total_tokens: chat_resp.usage.input_tokens + chat_resp.usage.output_tokens,
1077                reasoning_tokens: None,
1078            },
1079            tool_calls,
1080            finish_reason: chat_resp.stop_reason,
1081            id: chat_resp.id,
1082            created: None,
1083        })
1084    }
1085
1086    fn model_name(&self) -> &str {
1087        &self.model
1088    }
1089
1090    async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream> {
1091        let model = request.model.clone().unwrap_or_else(|| self.model.clone());
1092        let max_tokens = request.max_tokens.unwrap_or(4096);
1093        let temperature = request.temperature;
1094        let system = request.system.clone();
1095        let extra_body = request.extra_body.clone();
1096        let output_config =
1097            anthropic_output_config(None, request.reasoning.as_ref().and_then(|r| r.effort));
1098        let messages: Vec<AnthropicMessage> = request
1099            .into_anthropic_messages()
1100            .into_iter()
1101            .map(|(role, content)| AnthropicMessage { role, content })
1102            .collect();
1103
1104        let body = AnthropicRequest {
1105            model,
1106            max_tokens,
1107            temperature,
1108            system,
1109            messages,
1110            stream: true,
1111            // Streaming is text-only here: the SSE parser emits text deltas and
1112            // does not reassemble streamed tool-use blocks. `effort` still
1113            // applies to streaming, so it rides in `output_config` (no
1114            // `format` — streaming has no schema enforcement).
1115            tools: None,
1116            output_config,
1117        };
1118
1119        let resp = self
1120            .client
1121            .post(format!("{}/messages", self.base_url))
1122            .header("x-api-key", &self.api_key)
1123            .header("anthropic-version", "2023-06-01")
1124            .header("content-type", "application/json")
1125            .json(&body_with_extra(&body, extra_body.as_ref()))
1126            .send()
1127            .await
1128            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
1129
1130        check_rate_limit(&resp)?;
1131
1132        let status = resp.status();
1133        if !status.is_success() {
1134            let text = resp.text().await.unwrap_or_default();
1135            return Err(KernelError::Http {
1136                status: status.as_u16(),
1137                message: redact_http_body(&text),
1138            });
1139        }
1140
1141        let (tx, rx) = tokio::sync::mpsc::channel::<Result<StreamEvent>>(16);
1142
1143        tokio::spawn(async move {
1144            let mut stream = std::pin::pin!(resp.bytes_stream());
1145            let mut buffer: Vec<u8> = Vec::new();
1146            let mut current_event = String::new();
1147
1148            use tokio_stream::StreamExt;
1149
1150            while let Some(chunk) = stream.next().await {
1151                let chunk = match chunk {
1152                    Ok(c) => c,
1153                    Err(e) => {
1154                        let _ = tx.send(Err(KernelError::LlmApi(e.to_string()))).await;
1155                        return;
1156                    }
1157                };
1158
1159                for line in drain_sse_lines(&mut buffer, &chunk) {
1160                    if let Some(evt) = line.strip_prefix("event: ") {
1161                        current_event = evt.to_string();
1162                    } else if let Some(data) = line.strip_prefix("data: ") {
1163                        if data == "[DONE]" {
1164                            let _ = tx.send(Ok(StreamEvent::Done)).await;
1165                            return;
1166                        }
1167                        for event in parse_anthropic_sse(&current_event, data) {
1168                            let is_done = matches!(event, StreamEvent::Done);
1169                            if tx.send(Ok(event)).await.is_err() || is_done {
1170                                return;
1171                            }
1172                        }
1173                        current_event.clear();
1174                    }
1175                }
1176            }
1177            let _ = tx.send(Ok(StreamEvent::Done)).await;
1178        });
1179
1180        Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
1181    }
1182}
1183
1184#[cfg(test)]
1185mod tests {
1186    use super::*;
1187    use crate::llm::types::ReasoningSummary;
1188
1189    #[test]
1190    fn parse_sse_line_extracts_data() {
1191        assert_eq!(
1192            parse_sse_line("data: {\"id\":\"1\"}"),
1193            Some("{\"id\":\"1\"}")
1194        );
1195    }
1196
1197    #[test]
1198    fn parse_sse_line_skips_done() {
1199        assert_eq!(parse_sse_line("data: [DONE]"), None);
1200    }
1201
1202    #[test]
1203    fn parse_sse_line_skips_non_data() {
1204        assert_eq!(parse_sse_line("event: ping"), None);
1205        assert_eq!(parse_sse_line(""), None);
1206    }
1207
1208    #[test]
1209    fn drain_sse_lines_reassembles_multibyte_split_across_chunks() {
1210        // "data: 안녕\n" — "data: " is 6 bytes, 안/녕 are 3 bytes each.
1211        let full = "data: 안녕\n".as_bytes().to_vec();
1212        // Split at byte 7, mid-way through "안"'s 3-byte sequence.
1213        let (first, rest) = full.split_at(7);
1214
1215        let mut buffer = Vec::new();
1216        // No newline yet, and the trailing bytes are a partial codepoint:
1217        // nothing should be emitted, and nothing should be corrupted.
1218        assert!(drain_sse_lines(&mut buffer, first).is_empty());
1219
1220        let lines = drain_sse_lines(&mut buffer, rest);
1221        assert_eq!(lines, vec!["data: 안녕".to_string()]);
1222        // A per-chunk from_utf8_lossy would instead have produced U+FFFD here.
1223        assert!(!lines[0].contains('\u{FFFD}'));
1224    }
1225
1226    #[test]
1227    fn drain_sse_lines_handles_multiple_lines_and_keeps_partial_tail() {
1228        let mut buffer = Vec::new();
1229        let lines = drain_sse_lines(&mut buffer, b"event: ping\r\ndata: {}\npartial");
1230        assert_eq!(
1231            lines,
1232            vec!["event: ping".to_string(), "data: {}".to_string()]
1233        );
1234        // The unterminated "partial" tail stays buffered for the next chunk.
1235        let lines = drain_sse_lines(&mut buffer, b" tail\n");
1236        assert_eq!(lines, vec!["partial tail".to_string()]);
1237    }
1238
1239    #[test]
1240    fn openai_delta_extraction() {
1241        let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#;
1242        let event = parse_openai_sse(data).unwrap();
1243        match event {
1244            StreamEvent::Delta { content } => assert_eq!(content, "Hello"),
1245            _ => panic!("expected Delta, got {:?}", event),
1246        }
1247    }
1248
1249    #[test]
1250    fn openai_usage_extraction() {
1251        let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}"#;
1252        let event = parse_openai_sse(data).unwrap();
1253        match event {
1254            StreamEvent::Usage(usage) => {
1255                assert_eq!(usage.prompt_tokens, 10);
1256                assert_eq!(usage.completion_tokens, 5);
1257                assert_eq!(usage.total_tokens, 15);
1258            }
1259            _ => panic!("expected Usage, got {:?}", event),
1260        }
1261    }
1262
1263    #[test]
1264    fn openai_finish_reason_is_done() {
1265        let data =
1266            r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#;
1267        let event = parse_openai_sse(data).unwrap();
1268        assert!(matches!(event, StreamEvent::Done));
1269    }
1270
1271    #[test]
1272    fn openai_empty_delta_skipped() {
1273        let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}]}"#;
1274        assert!(parse_openai_sse(data).is_none());
1275    }
1276
1277    #[test]
1278    fn anthropic_content_block_delta() {
1279        let data = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#;
1280        let events = parse_anthropic_sse("content_block_delta", data);
1281        match events.as_slice() {
1282            [StreamEvent::Delta { content }] => assert_eq!(content, "Hello"),
1283            _ => panic!("expected one Delta, got {:?}", events),
1284        }
1285    }
1286
1287    #[test]
1288    fn anthropic_message_delta_usage() {
1289        let data = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}"#;
1290        let events = parse_anthropic_sse("message_delta", data);
1291        match events.as_slice() {
1292            [StreamEvent::Stopped { reason }, StreamEvent::Usage(usage)] => {
1293                assert_eq!(reason, "end_turn");
1294                assert_eq!(usage.completion_tokens, 5);
1295            }
1296            _ => panic!("expected Stopped + Usage, got {:?}", events),
1297        }
1298    }
1299
1300    #[test]
1301    fn anthropic_message_delta_max_tokens_surfaces_stop_reason() {
1302        let data = r#"{"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":4096}}"#;
1303        let events = parse_anthropic_sse("message_delta", data);
1304        match events.as_slice() {
1305            [StreamEvent::Stopped { reason }, StreamEvent::Usage(_)] => {
1306                assert_eq!(reason, "max_tokens")
1307            }
1308            _ => panic!("expected Stopped + Usage, got {:?}", events),
1309        }
1310    }
1311
1312    #[test]
1313    fn anthropic_message_delta_refusal_surfaces_stop_reason() {
1314        let data = r#"{"type":"message_delta","delta":{"stop_reason":"refusal"},"usage":{"output_tokens":12}}"#;
1315        let events = parse_anthropic_sse("message_delta", data);
1316        match events.as_slice() {
1317            [StreamEvent::Stopped { reason }, StreamEvent::Usage(_)] => {
1318                assert_eq!(reason, "refusal")
1319            }
1320            _ => panic!("expected Stopped + Usage, got {:?}", events),
1321        }
1322    }
1323
1324    #[test]
1325    fn anthropic_message_delta_without_stop_reason_stays_usage_only() {
1326        let data = r#"{"type":"message_delta","delta":{},"usage":{"output_tokens":5}}"#;
1327        let events = parse_anthropic_sse("message_delta", data);
1328        match events.as_slice() {
1329            [StreamEvent::Usage(_)] => {}
1330            _ => panic!("expected Usage only, got {:?}", events),
1331        }
1332    }
1333
1334    #[test]
1335    fn anthropic_message_stop() {
1336        let events = parse_anthropic_sse("message_stop", r#"{"type":"message_stop"}"#);
1337        assert!(matches!(events.as_slice(), [StreamEvent::Done]));
1338    }
1339
1340    #[test]
1341    fn anthropic_unknown_event_ignored() {
1342        assert!(parse_anthropic_sse("ping", "{}").is_empty());
1343    }
1344
1345    fn sample_tool() -> ToolDefinition {
1346        ToolDefinition {
1347            name: "get_weather".into(),
1348            description: "Get weather".into(),
1349            input_schema: serde_json::json!({
1350                "type": "object",
1351                "properties": { "location": { "type": "string" } },
1352                "required": ["location"]
1353            }),
1354        }
1355    }
1356
1357    #[test]
1358    fn openai_tools_use_function_wrapper() {
1359        let out = openai_tools(&[sample_tool()]);
1360        assert_eq!(out.len(), 1);
1361        assert_eq!(out[0]["type"], "function");
1362        assert_eq!(out[0]["function"]["name"], "get_weather");
1363        // input_schema is forwarded verbatim as `parameters`.
1364        assert_eq!(out[0]["function"]["parameters"]["required"][0], "location");
1365    }
1366
1367    #[test]
1368    fn openai_response_format_maps_each_variant() {
1369        assert!(openai_response_format(&ResponseFormat::Text).is_none());
1370        assert_eq!(
1371            openai_response_format(&ResponseFormat::Json).unwrap()["type"],
1372            "json_object"
1373        );
1374        let schema = serde_json::json!({"type": "object"});
1375        let js = openai_response_format(&ResponseFormat::JsonSchema { schema }).unwrap();
1376        assert_eq!(js["type"], "json_schema");
1377        assert_eq!(js["json_schema"]["strict"], true);
1378    }
1379
1380    #[test]
1381    fn anthropic_tools_use_input_schema_key() {
1382        let out = anthropic_tools(&[sample_tool()]);
1383        assert_eq!(out[0]["name"], "get_weather");
1384        assert_eq!(out[0]["input_schema"]["type"], "object");
1385        assert!(out[0].get("function").is_none());
1386    }
1387
1388    #[test]
1389    fn anthropic_output_config_only_for_json_schema() {
1390        assert!(anthropic_output_config(Some(&ResponseFormat::Text), None).is_none());
1391        assert!(anthropic_output_config(Some(&ResponseFormat::Json), None).is_none());
1392        assert!(anthropic_output_config(None, None).is_none());
1393        let schema = serde_json::json!({"type": "object"});
1394        let cfg = anthropic_output_config(Some(&ResponseFormat::JsonSchema { schema }), None);
1395        let cfg = cfg.unwrap();
1396        assert_eq!(cfg["format"]["type"], "json_schema");
1397    }
1398
1399    #[test]
1400    fn anthropic_output_config_carries_effort() {
1401        let cfg = anthropic_output_config(None, Some(ReasoningEffort::High)).unwrap();
1402        assert_eq!(cfg["effort"], "high");
1403        assert!(cfg.get("format").is_none());
1404        // Schema and effort merge into one object (two keys, not two configs).
1405        let schema = serde_json::json!({"type": "object"});
1406        let cfg = anthropic_output_config(
1407            Some(&ResponseFormat::JsonSchema { schema }),
1408            Some(ReasoningEffort::XHigh),
1409        )
1410        .unwrap();
1411        assert_eq!(cfg["effort"], "xhigh");
1412        assert_eq!(cfg["format"]["type"], "json_schema");
1413        // Wire values must match the Anthropic effort vocabulary exactly.
1414        for (variant, wire) in [
1415            (ReasoningEffort::Low, "low"),
1416            (ReasoningEffort::Medium, "medium"),
1417            (ReasoningEffort::High, "high"),
1418            (ReasoningEffort::XHigh, "xhigh"),
1419            (ReasoningEffort::Max, "max"),
1420        ] {
1421            let cfg = anthropic_output_config(None, Some(variant)).unwrap();
1422            assert_eq!(cfg["effort"], wire);
1423        }
1424    }
1425
1426    #[test]
1427    fn anthropic_request_omits_temperature_when_none() {
1428        let body = AnthropicRequest {
1429            model: "claude-opus-5-5".into(),
1430            max_tokens: 64,
1431            temperature: None,
1432            system: None,
1433            messages: vec![],
1434            stream: false,
1435            tools: None,
1436            output_config: None,
1437        };
1438        let json = serde_json::to_value(&body).unwrap();
1439        assert!(json.get("temperature").is_none());
1440        assert!(json.get("output_config").is_none());
1441    }
1442
1443    #[test]
1444    fn anthropic_request_serializes_temperature_and_effort_when_set() {
1445        let body = AnthropicRequest {
1446            model: "claude-sonnet-4-6".into(),
1447            max_tokens: 64,
1448            temperature: Some(0.5),
1449            system: None,
1450            messages: vec![],
1451            stream: false,
1452            tools: None,
1453            output_config: anthropic_output_config(None, Some(ReasoningEffort::Max)),
1454        };
1455        let json = serde_json::to_value(&body).unwrap();
1456        assert_eq!(json["temperature"], serde_json::json!(0.5f32));
1457        assert_eq!(json["output_config"]["effort"], "max");
1458    }
1459
1460    #[test]
1461    fn openai_request_serializes_tools_and_format() {
1462        let body = OpenAIChatRequest {
1463            model: "gpt-4o".into(),
1464            messages: vec![OpenAIChatMessage {
1465                role: "user".into(),
1466                content: "hi".into(),
1467            }],
1468            temperature: 0.7,
1469            max_tokens: None,
1470            stream: false,
1471            tools: Some(openai_tools(&[sample_tool()])),
1472            response_format: Some(serde_json::json!({ "type": "json_object" })),
1473            reasoning_effort: None,
1474            reasoning: None,
1475            verbosity: None,
1476        };
1477        let json = serde_json::to_value(&body).unwrap();
1478        assert_eq!(json["tools"][0]["function"]["name"], "get_weather");
1479        assert_eq!(json["response_format"]["type"], "json_object");
1480        // Omitted when None (backward-compatible request shape).
1481        assert!(json.get("max_tokens").is_none());
1482        assert!(json.get("reasoning_effort").is_none());
1483        assert!(json.get("reasoning").is_none());
1484    }
1485
1486    #[test]
1487    fn openai_reasoning_effort_maps_to_official_param() {
1488        let (effort, reasoning) =
1489            openai_reasoning(Some(&ReasoningConfig::effort(ReasoningEffort::High)));
1490        assert_eq!(effort, Some(ReasoningEffort::High));
1491        assert!(reasoning.is_none());
1492        let body = OpenAIChatRequest {
1493            model: "gpt-5.5".into(),
1494            messages: vec![],
1495            temperature: 0.7,
1496            max_tokens: None,
1497            stream: false,
1498            tools: None,
1499            response_format: None,
1500            reasoning_effort: effort,
1501            reasoning,
1502            verbosity: None,
1503        };
1504        let json = serde_json::to_value(&body).unwrap();
1505        assert_eq!(json["reasoning_effort"], "high");
1506        assert!(json.get("reasoning").is_none());
1507    }
1508
1509    #[test]
1510    fn openai_reasoning_disabled_maps_to_openrouter_object() {
1511        // immune's exact need: reasoning: {"enabled": false} on OpenRouter,
1512        // with no reasoning_effort key and no config -> no keys at all.
1513        let (effort, reasoning) = openai_reasoning(Some(&ReasoningConfig::disabled()));
1514        assert_eq!(effort, None);
1515        assert_eq!(reasoning.unwrap()["enabled"], false);
1516
1517        let (effort, reasoning) = openai_reasoning(None);
1518        assert!(effort.is_none() && reasoning.is_none());
1519    }
1520
1521    #[test]
1522    fn openai_reasoning_both_knobs_serialize_independently() {
1523        let (effort, reasoning) = openai_reasoning(Some(&ReasoningConfig {
1524            enabled: Some(true),
1525            effort: Some(ReasoningEffort::Low),
1526            summary: Some(ReasoningSummary::Concise),
1527        }));
1528        let body = OpenAIChatRequest {
1529            model: "m".into(),
1530            messages: vec![],
1531            temperature: 0.7,
1532            max_tokens: None,
1533            stream: false,
1534            tools: None,
1535            response_format: None,
1536            reasoning_effort: effort,
1537            reasoning,
1538            verbosity: Some(Verbosity::High),
1539        };
1540        let json = serde_json::to_value(&body).unwrap();
1541        assert_eq!(json["reasoning_effort"], "low");
1542        assert_eq!(json["reasoning"]["enabled"], true);
1543        assert_eq!(json["reasoning"]["summary"], "concise");
1544        assert_eq!(json["verbosity"], "high");
1545    }
1546
1547    #[test]
1548    fn openai_reasoning_summary_only_serializes_object() {
1549        let (effort, reasoning) = openai_reasoning(Some(&ReasoningConfig {
1550            enabled: None,
1551            effort: None,
1552            summary: Some(ReasoningSummary::Detailed),
1553        }));
1554        assert!(effort.is_none());
1555        assert_eq!(reasoning.unwrap()["summary"], "detailed");
1556    }
1557
1558    #[test]
1559    fn openai_body_merges_extra_body_last_write_wins() {
1560        let body = OpenAIChatRequest {
1561            model: "m".into(),
1562            messages: vec![],
1563            temperature: 0.7,
1564            max_tokens: None,
1565            stream: false,
1566            tools: None,
1567            response_format: None,
1568            reasoning_effort: None,
1569            reasoning: None,
1570            verbosity: None,
1571        };
1572        let mut extra = serde_json::Map::new();
1573        extra.insert("seed".into(), 7.into());
1574        extra.insert("temperature".into(), 0.1.into());
1575        let merged = body_with_extra(&body, Some(&extra));
1576        assert_eq!(merged["seed"], 7);
1577        // Extra key overrides the natively forwarded one (last-write-wins).
1578        assert_eq!(merged["temperature"], 0.1);
1579        // Without extras the body is untouched. (Compare via json! so the f32
1580        // → f64 widening in the Number matches on both sides.)
1581        let plain = body_with_extra(&body, None);
1582        assert_eq!(plain["temperature"], serde_json::json!(0.7f32));
1583        assert!(plain.get("seed").is_none());
1584    }
1585
1586    #[test]
1587    fn anthropic_body_merges_extra_body_last_write_wins() {
1588        let body = AnthropicRequest {
1589            model: "m".into(),
1590            max_tokens: 8,
1591            temperature: Some(0.7),
1592            system: Some("sys".into()),
1593            messages: vec![],
1594            stream: false,
1595            tools: None,
1596            output_config: None,
1597        };
1598        let mut extra = serde_json::Map::new();
1599        extra.insert("thinking".into(), serde_json::json!({"type": "disabled"}));
1600        let merged = body_with_extra(&body, Some(&extra));
1601        assert_eq!(merged["thinking"]["type"], "disabled");
1602        assert_eq!(merged["system"], "sys");
1603    }
1604
1605    #[test]
1606    fn verbosity_wire_values_match_openai_spec() {
1607        // Official enum: low, medium, high.
1608        for (variant, wire) in [
1609            (Verbosity::Low, "low"),
1610            (Verbosity::Medium, "medium"),
1611            (Verbosity::High, "high"),
1612        ] {
1613            assert_eq!(serde_json::to_value(variant).unwrap(), wire);
1614        }
1615    }
1616
1617    #[test]
1618    fn reasoning_summary_wire_values_match_openai_spec() {
1619        // Official enum: auto, concise, detailed.
1620        for (variant, wire) in [
1621            (ReasoningSummary::Auto, "auto"),
1622            (ReasoningSummary::Concise, "concise"),
1623            (ReasoningSummary::Detailed, "detailed"),
1624        ] {
1625            assert_eq!(serde_json::to_value(variant).unwrap(), wire);
1626        }
1627    }
1628
1629    #[test]
1630    fn reasoning_effort_wire_values_match_openai_spec() {
1631        // Official enum: none, minimal, low, medium, high, xhigh, max.
1632        for (variant, wire) in [
1633            (ReasoningEffort::None, "none"),
1634            (ReasoningEffort::Minimal, "minimal"),
1635            (ReasoningEffort::Low, "low"),
1636            (ReasoningEffort::Medium, "medium"),
1637            (ReasoningEffort::High, "high"),
1638            (ReasoningEffort::XHigh, "xhigh"),
1639            (ReasoningEffort::Max, "max"),
1640        ] {
1641            assert_eq!(serde_json::to_value(variant).unwrap(), wire);
1642            let back: ReasoningEffort = serde_json::from_value(wire.into()).unwrap();
1643            assert_eq!(back, variant);
1644        }
1645    }
1646
1647    #[test]
1648    fn lenient_decode_rescues_openrouter_control_chars() {
1649        // OpenRouter was observed returning 200 with raw \n / \t inside the
1650        // reasoning string; strict serde_json fails the whole body.
1651        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}}";
1652        assert!(serde_json::from_str::<OpenAIChatResponse>(raw).is_err());
1653        let resp: OpenAIChatResponse = from_json_lenient(raw).unwrap();
1654        let first = resp.choices.into_iter().next().unwrap();
1655        assert_eq!(first.message.content.as_deref(), Some("답변"));
1656        assert_eq!(
1657            first.message.reasoning_content.as_deref(),
1658            Some("step 1\nstep 2\ttab")
1659        );
1660    }
1661
1662    #[test]
1663    fn lenient_decode_keeps_original_error_for_structural_garbage() {
1664        // Broken structure (not just control chars) still errors, surfacing
1665        // the *original* strict-parse error rather than a sanitize artifact.
1666        let raw = "{\"choices\": tr";
1667        let strict = serde_json::from_str::<OpenAIChatResponse>(raw)
1668            .map(|_: OpenAIChatResponse| ())
1669            .unwrap_err();
1670        let lenient = from_json_lenient::<OpenAIChatResponse>(raw)
1671            .map(|_: OpenAIChatResponse| ())
1672            .unwrap_err();
1673        assert_eq!(lenient.to_string(), strict.to_string());
1674    }
1675
1676    #[test]
1677    fn escape_unescaped_control_chars_only_touches_string_literals() {
1678        // Raw newline outside strings (pretty-printed JSON) stays as-is;
1679        // inside strings it becomes \uXXXX; existing escapes are untouched.
1680        let raw = "{\n  \"a\": \"x\ny\\n\\tz\",\n  \"b\": 1\n}";
1681        let out = escape_unescaped_control_chars(raw);
1682        assert_eq!(out, "{\n  \"a\": \"x\\u000ay\\n\\tz\",\n  \"b\": 1\n}");
1683        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
1684        assert_eq!(v["a"], "x\ny\n\tz");
1685    }
1686
1687    #[test]
1688    fn escape_unescaped_control_chars_handles_escaped_quotes() {
1689        // \" inside a string must not toggle the in-string state.
1690        let raw = "{\"a\":\"say \\\"hi\\\" ok\"}";
1691        assert_eq!(escape_unescaped_control_chars(raw), raw);
1692    }
1693
1694    #[test]
1695    fn openai_sse_lenient_reasoning_delta_with_control_chars() {
1696        // A streamed reasoning delta carrying a raw tab must not be dropped.
1697        let data = "{\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"think\ttab\"},\"finish_reason\":null}]}";
1698        let event = parse_openai_sse(data).unwrap();
1699        match event {
1700            StreamEvent::ReasoningDelta { content } => assert_eq!(content, "think\ttab"),
1701            other => panic!("expected ReasoningDelta, got {other:?}"),
1702        }
1703    }
1704
1705    #[test]
1706    fn openai_response_parses_tool_calls() {
1707        let raw = r#"{
1708            "id": "chatcmpl-1",
1709            "created": 1700000000,
1710            "model": "gpt-4o",
1711            "choices": [{
1712                "index": 0,
1713                "message": {
1714                    "role": "assistant",
1715                    "content": null,
1716                    "tool_calls": [{
1717                        "id": "call_abc",
1718                        "type": "function",
1719                        "function": { "name": "get_weather", "arguments": "{\"location\":\"Paris\"}" }
1720                    }]
1721                },
1722                "finish_reason": "tool_calls"
1723            }],
1724            "usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15 }
1725        }"#;
1726        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1727        assert_eq!(resp.id.as_deref(), Some("chatcmpl-1"));
1728        let choice = resp.choices.into_iter().next().unwrap();
1729        assert_eq!(choice.finish_reason.as_deref(), Some("tool_calls"));
1730        assert!(choice.message.content.is_none());
1731        assert_eq!(choice.message.tool_calls.len(), 1);
1732        assert_eq!(choice.message.tool_calls[0].function.name, "get_weather");
1733    }
1734
1735    #[test]
1736    fn openai_response_parses_glm47_reasoning_content() {
1737        // GLM-4.7: content=null, reasoning_content carries the chain-of-thought + final JSON.
1738        let raw = r#"{
1739            "id":"chatcmpl-1","created":1700000000,"model":"glm-4.7",
1740            "choices":[{"index":0,"message":{"role":"assistant","content":null,
1741                "reasoning_content":"thinking... {\"rating\":\"Buy\"}"},"finish_reason":"stop"}],
1742            "usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15,
1743                "completion_tokens_details":{"reasoning_tokens":3}}
1744        }"#;
1745        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1746        let choice = resp.choices.into_iter().next().unwrap();
1747        assert!(choice.message.content.is_none());
1748        assert!(choice.message.reasoning_content.is_some());
1749        assert_eq!(
1750            resp.usage
1751                .unwrap()
1752                .completion_tokens_details
1753                .unwrap()
1754                .reasoning_tokens,
1755            Some(3)
1756        );
1757    }
1758
1759    #[test]
1760    fn openai_complete_promotes_reasoning_when_content_empty() {
1761        // complete() must promote reasoning_content into content when content is empty,
1762        // so downstream json_extract finds the JSON. Drives the *production* promotion
1763        // helper (not a re-implementation) so the regression guard tracks real code.
1764        let raw = r#"{
1765            "id":"chatcmpl-1","created":1700000000,"model":"glm-4.7",
1766            "choices":[{"index":0,"message":{"role":"assistant","content":"",
1767                "reasoning_content":"사고 {\"rating\":\"Sell\",\"key_thesis\":\"약세\"}"},"finish_reason":"stop"}]
1768        }"#;
1769        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1770        let first = resp.choices.into_iter().next().unwrap();
1771        let raw_content = first.message.content.unwrap_or_default();
1772        let reasoning = first.message.reasoning_content.clone();
1773        let content = promote_reasoning_into_content(raw_content, reasoning.as_deref());
1774        assert!(
1775            content.contains("\"rating\":\"Sell\""),
1776            "promoted content must contain JSON: {content}"
1777        );
1778        assert_eq!(
1779            reasoning.as_deref(),
1780            Some("사고 {\"rating\":\"Sell\",\"key_thesis\":\"약세\"}")
1781        );
1782    }
1783
1784    #[test]
1785    fn openai_complete_keeps_content_when_present() {
1786        // Non-empty content must NOT be overwritten by reasoning.
1787        let raw = r#"{
1788            "id":"chatcmpl-1","created":1700000000,"model":"gpt-4o",
1789            "choices":[{"index":0,"message":{"role":"assistant","content":"answer",
1790                "reasoning_content":"thought"},"finish_reason":"stop"}]
1791        }"#;
1792        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1793        let first = resp.choices.into_iter().next().unwrap();
1794        let raw_content = first.message.content.clone().unwrap_or_default();
1795        let reasoning = first.message.reasoning_content.as_deref();
1796        let content = promote_reasoning_into_content(raw_content.clone(), reasoning);
1797        assert_eq!(content, "answer");
1798        assert_eq!(reasoning, Some("thought"));
1799    }
1800
1801    #[test]
1802    fn promote_reasoning_into_content_edge_cases() {
1803        // #3 caveat: empty content + reasoning surfaces the reasoning as the answer
1804        // (documented trade-off; original reasoning is the source of truth).
1805        assert_eq!(
1806            promote_reasoning_into_content(String::new(), Some("chain-of-thought")),
1807            "chain-of-thought"
1808        );
1809        // content present wins regardless of reasoning.
1810        assert_eq!(
1811            promote_reasoning_into_content("ans".into(), Some("cot")),
1812            "ans"
1813        );
1814        // both empty -> empty (no panic, no spurious content).
1815        assert_eq!(promote_reasoning_into_content(String::new(), None), "");
1816        assert_eq!(promote_reasoning_into_content("x".into(), None), "x");
1817    }
1818
1819    #[test]
1820    fn openai_response_parses_deepseek_reasoning_alias() {
1821        // DeepSeek-R1 uses field name `reasoning` instead of `reasoning_content`.
1822        let raw = r#"{"model":"deepseek-r1","choices":[{"message":{"content":"ans","reasoning":"thought"}}]}"#;
1823        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1824        assert_eq!(
1825            resp.choices[0].message.reasoning_content.as_deref(),
1826            Some("thought")
1827        );
1828    }
1829
1830    #[test]
1831    fn openai_sse_reasoning_delta_extracted() {
1832        let data = r#"{"choices":[{"index":0,"delta":{"reasoning_content":"thinking"},"finish_reason":null}]}"#;
1833        let event = parse_openai_sse(data).unwrap();
1834        match event {
1835            StreamEvent::ReasoningDelta { content } => assert_eq!(content, "thinking"),
1836            other => panic!("expected ReasoningDelta, got {other:?}"),
1837        }
1838    }
1839
1840    #[test]
1841    fn openai_sse_content_delta_still_works_alongside_reasoning() {
1842        // Separate content chunk must still produce Delta, not be swallowed.
1843        let data = r#"{"choices":[{"index":0,"delta":{"content":"answer"},"finish_reason":null}]}"#;
1844        let event = parse_openai_sse(data).unwrap();
1845        assert!(matches!(event, StreamEvent::Delta { .. }));
1846    }
1847
1848    #[test]
1849    fn openai_sse_usage_carries_reasoning_tokens() {
1850        // Final streaming chunk carries choices (empty delta) + usage with reasoning_tokens.
1851        let data = r#"{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],
1852            "usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3,
1853            "completion_tokens_details":{"reasoning_tokens":7}}}"#;
1854        let event = parse_openai_sse(data).unwrap();
1855        match event {
1856            StreamEvent::Usage(u) => assert_eq!(u.reasoning_tokens, Some(7)),
1857            other => panic!("expected Usage, got {other:?}"),
1858        }
1859    }
1860
1861    #[test]
1862    fn anthropic_response_parses_tool_use_block() {
1863        let raw = r#"{
1864            "id": "msg_1",
1865            "model": "claude-sonnet-4-6",
1866            "stop_reason": "tool_use",
1867            "content": [
1868                { "type": "text", "text": "Let me check." },
1869                { "type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": { "location": "Paris" } }
1870            ],
1871            "usage": { "input_tokens": 12, "output_tokens": 8 }
1872        }"#;
1873        let resp: AnthropicResponse = serde_json::from_str(raw).unwrap();
1874        assert_eq!(resp.stop_reason.as_deref(), Some("tool_use"));
1875        assert_eq!(resp.content.len(), 2);
1876        assert_eq!(resp.content[0].block_type, "text");
1877        assert_eq!(resp.content[1].block_type, "tool_use");
1878        assert_eq!(resp.content[1].name.as_deref(), Some("get_weather"));
1879        assert_eq!(resp.content[1].input.as_ref().unwrap()["location"], "Paris");
1880    }
1881
1882    #[test]
1883    fn anthropic_response_parses_thinking_block() {
1884        // Extended thinking block must surface in LLMResponse.reasoning via the parse loop.
1885        let raw = r#"{
1886            "id": "msg_2",
1887            "model": "claude-sonnet-4-6",
1888            "stop_reason": "end_turn",
1889            "content": [
1890                { "type": "thinking", "thinking": "step by step..." },
1891                { "type": "text", "text": "Final answer." }
1892            ],
1893            "usage": { "input_tokens": 5, "output_tokens": 9 }
1894        }"#;
1895        let resp: AnthropicResponse = serde_json::from_str(raw).unwrap();
1896        let mut reasoning = String::new();
1897        let mut content = String::new();
1898        for block in resp.content {
1899            match block.block_type.as_str() {
1900                "text" => {
1901                    if let Some(t) = block.text {
1902                        content.push_str(&t);
1903                    }
1904                }
1905                "thinking" => {
1906                    if let Some(t) = block.thinking {
1907                        reasoning.push_str(&t);
1908                    }
1909                }
1910                _ => {}
1911            }
1912        }
1913        assert_eq!(content, "Final answer.");
1914        assert_eq!(reasoning, "step by step...");
1915    }
1916
1917    #[test]
1918    fn anthropic_sse_thinking_delta_extracted() {
1919        let data = r#"{"delta":{"type":"thinking_delta","thinking":"a thought"}}"#;
1920        let events = parse_anthropic_sse("content_block_delta", data);
1921        match events.as_slice() {
1922            [StreamEvent::ReasoningDelta { content }] => assert_eq!(content, "a thought"),
1923            other => panic!("expected ReasoningDelta, got {other:?}"),
1924        }
1925    }
1926}