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, ResponseFormat, StreamEvent, TokenUsage,
9};
10
11/// Best-effort redaction of an HTTP error response body before it lands in a
12/// [`KernelError::Http`]. Some API gateways/proxies echo the request
13/// `Authorization` header inside error bodies; without this, a caller that logs
14/// the error leaks the API key. Full pattern masking when the `safety` feature
15/// is enabled; otherwise the body is passed through unchanged (the masking
16/// regex is an opt-in dependency).
17fn redact_http_body(body: &str) -> String {
18    #[cfg(feature = "safety")]
19    {
20        crate::safety::sanitize::mask_secrets(body)
21    }
22    #[cfg(not(feature = "safety"))]
23    {
24        body.to_string()
25    }
26}
27
28/// Convert kernel [`ToolDefinition`]s into OpenAI `tools` (`type: "function"`).
29fn openai_tools(tools: &[ToolDefinition]) -> Vec<serde_json::Value> {
30    tools
31        .iter()
32        .map(|t| {
33            serde_json::json!({
34                "type": "function",
35                "function": {
36                    "name": t.name,
37                    "description": t.description,
38                    "parameters": t.input_schema,
39                }
40            })
41        })
42        .collect()
43}
44
45/// Map a [`ResponseFormat`] to OpenAI's `response_format` object, or `None` for
46/// the provider default (plain text).
47fn openai_response_format(rf: &ResponseFormat) -> Option<serde_json::Value> {
48    match rf {
49        ResponseFormat::Text => None,
50        ResponseFormat::Json => Some(serde_json::json!({ "type": "json_object" })),
51        ResponseFormat::JsonSchema { schema } => Some(serde_json::json!({
52            "type": "json_schema",
53            "json_schema": { "name": "response", "schema": schema, "strict": true }
54        })),
55    }
56}
57
58/// Convert kernel [`ToolDefinition`]s into Anthropic `tools` (with `input_schema`).
59fn anthropic_tools(tools: &[ToolDefinition]) -> Vec<serde_json::Value> {
60    tools
61        .iter()
62        .map(|t| {
63            serde_json::json!({
64                "name": t.name,
65                "description": t.description,
66                "input_schema": t.input_schema,
67            })
68        })
69        .collect()
70}
71
72/// Map a [`ResponseFormat`] to Anthropic's `output_config`. Only
73/// [`ResponseFormat::JsonSchema`] has a native equivalent; `Json` (schemaless)
74/// and `Text` return `None`.
75fn anthropic_output_config(rf: &ResponseFormat) -> Option<serde_json::Value> {
76    match rf {
77        ResponseFormat::JsonSchema { schema } => Some(serde_json::json!({
78            "format": { "type": "json_schema", "schema": schema }
79        })),
80        ResponseFormat::Json | ResponseFormat::Text => None,
81    }
82}
83
84/// Build a `reqwest::Client` with connect and total timeouts.
85fn http_client() -> Result<reqwest::Client> {
86    crate::tls::ensure_tls_provider();
87    reqwest::Client::builder()
88        .connect_timeout(Duration::from_secs(10))
89        .timeout(Duration::from_secs(120))
90        .build()
91        .map_err(|e| KernelError::Config(format!("Failed to build HTTP client: {}", e)))
92}
93
94/// Check for HTTP 429 rate-limit response and extract `retry-after` header.
95fn check_rate_limit(resp: &reqwest::Response) -> Result<()> {
96    if resp.status().as_u16() == 429 {
97        let retry = resp
98            .headers()
99            .get("retry-after")
100            .and_then(|v| v.to_str().ok())
101            .and_then(|v| v.parse().ok())
102            .unwrap_or(60);
103        return Err(KernelError::RateLimited(retry));
104    }
105    Ok(())
106}
107
108/// Unified async interface for LLM chat completion and streaming.
109#[async_trait]
110pub trait LLMClient: Send + Sync {
111    /// Send a chat completion request and return the full response.
112    ///
113    /// **Reasoning-model answer promotion (non-streaming only):** when a provider
114    /// returns the final answer in `reasoning_content` and leaves `content` empty
115    /// (notably GLM-4.7), `complete` promotes the reasoning into `content` so
116    /// downstream consumers transparently receive the answer. The original
117    /// reasoning is preserved in [`LLMResponse::reasoning`].
118    ///
119    /// This promotion is **not** applied by [`stream_complete`](Self::stream_complete),
120    /// which surfaces reasoning as separate [`StreamEvent::ReasoningDelta`] events —
121    /// see that variant's docs for the streaming accumulation contract.
122    async fn complete(&self, request: LLMRequest) -> Result<LLMResponse>;
123    /// Return the model name this client is configured to use.
124    fn model_name(&self) -> &str;
125
126    /// Stream a chat completion, yielding events as they arrive.
127    ///
128    /// Does **not** promote reasoning into a final `content` — see
129    /// [`StreamEvent::ReasoningDelta`] for why streaming consumers of
130    /// reasoning-only models (GLM-4.7) must accumulate both `ReasoningDelta` and
131    /// `Delta` to reconstruct the answer.
132    async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream>;
133}
134
135/// Async LLM client for the OpenAI chat completions API.
136pub struct OpenAIClient {
137    api_key: String,
138    model: String,
139    base_url: String,
140    client: reqwest::Client,
141}
142
143impl OpenAIClient {
144    /// Create a new client using credentials from the environment variable in `config`.
145    pub fn new(config: &ModelConfig) -> Result<Self> {
146        let api_key = std::env::var(&config.api_key_env).map_err(|_| {
147            KernelError::Config(format!(
148                "Environment variable {} not set",
149                config.api_key_env
150            ))
151        })?;
152        Ok(Self {
153            api_key,
154            model: config.model.clone(),
155            base_url: config
156                .base_url
157                .clone()
158                .unwrap_or_else(|| "https://api.openai.com/v1".into()),
159            client: http_client()?,
160        })
161    }
162
163    /// Create a new client with an explicit API key, using the default OpenAI base URL.
164    ///
165    /// Returns a [`KernelError::Config`] if the HTTP client (with its connect /
166    /// total timeouts) cannot be built, rather than silently falling back to a
167    /// timeout-less `reqwest::Client::default()`.
168    ///
169    /// # Example
170    ///
171    /// ```no_run
172    /// use llm_kernel::llm::OpenAIClient;
173    /// let client = OpenAIClient::from_key("gpt-4o-mini", "sk-...")?;
174    /// # Ok::<(), llm_kernel::error::KernelError>(())
175    /// ```
176    pub fn from_key(model: impl Into<String>, api_key: impl Into<String>) -> Result<Self> {
177        Ok(Self {
178            api_key: api_key.into(),
179            model: model.into(),
180            base_url: "https://api.openai.com/v1".into(),
181            client: http_client()?,
182        })
183    }
184
185    /// Create from an explicit key and a shared `reqwest::Client`.
186    ///
187    /// Prefer this over [`from_key`](Self::from_key) when constructing multiple
188    /// clients in a hot path — the shared client reuses the underlying TCP
189    /// connection pool.
190    pub fn from_key_with_client(
191        model: impl Into<String>,
192        api_key: impl Into<String>,
193        client: reqwest::Client,
194    ) -> Self {
195        Self {
196            api_key: api_key.into(),
197            model: model.into(),
198            base_url: "https://api.openai.com/v1".into(),
199            client,
200        }
201    }
202
203    /// Create from an explicit key, a custom base URL, and a shared `reqwest::Client`.
204    ///
205    /// Use this for OpenAI-compatible providers that are not the default OpenAI
206    /// endpoint (DeepSeek, Groq, Ollama, LM Studio, custom gateways, …) when you
207    /// already hold the key in memory and want to reuse a shared connection pool.
208    pub fn from_key_with_base_url(
209        model: impl Into<String>,
210        api_key: impl Into<String>,
211        base_url: impl Into<String>,
212        client: reqwest::Client,
213    ) -> Self {
214        Self {
215            api_key: api_key.into(),
216            model: model.into(),
217            base_url: base_url.into(),
218            client,
219        }
220    }
221}
222
223#[derive(serde::Serialize)]
224struct OpenAIChatRequest {
225    model: String,
226    messages: Vec<OpenAIChatMessage>,
227    temperature: f32,
228    #[serde(skip_serializing_if = "Option::is_none")]
229    max_tokens: Option<u32>,
230    #[serde(skip_serializing_if = "std::ops::Not::not")]
231    stream: bool,
232    #[serde(skip_serializing_if = "Option::is_none")]
233    tools: Option<Vec<serde_json::Value>>,
234    #[serde(skip_serializing_if = "Option::is_none")]
235    response_format: Option<serde_json::Value>,
236}
237
238#[derive(serde::Serialize)]
239struct OpenAIChatMessage {
240    role: String,
241    content: String,
242}
243
244#[derive(serde::Deserialize)]
245struct OpenAIChatResponse {
246    #[serde(default)]
247    id: Option<String>,
248    #[serde(default)]
249    created: Option<u64>,
250    choices: Vec<OpenAIChoice>,
251    model: String,
252    usage: Option<OpenAIUsage>,
253}
254
255#[derive(serde::Deserialize)]
256struct OpenAIChoice {
257    message: OpenAIRespMessage,
258    #[serde(default)]
259    finish_reason: Option<String>,
260}
261
262/// Response-side assistant message. `content` is `null` on tool-call turns, so
263/// it is optional and defaults to empty.
264///
265/// Reasoning models (GLM-4.5+/z.ai, OpenAI o1) emit their chain-of-thought in
266/// `reasoning_content` and leave `content` null — see ADR below. DeepSeek-R1 uses
267/// the field name `reasoning`, covered via serde alias.
268#[derive(serde::Deserialize)]
269struct OpenAIRespMessage {
270    #[serde(default)]
271    content: Option<String>,
272    /// Reasoning model's chain-of-thought. Aliased from `reasoning` (DeepSeek-R1).
273    #[serde(default, alias = "reasoning")]
274    reasoning_content: Option<String>,
275    #[serde(default)]
276    tool_calls: Vec<OpenAIToolCall>,
277}
278
279#[derive(serde::Deserialize)]
280struct OpenAIToolCall {
281    id: String,
282    function: OpenAIFunctionCall,
283}
284
285#[derive(serde::Deserialize)]
286struct OpenAIFunctionCall {
287    name: String,
288    #[serde(default)]
289    arguments: String,
290}
291
292/// Token usage reported by OpenAI-compatible providers.
293///
294/// All fields are `#[serde(default)]`: some providers (and partial/error
295/// responses) omit `usage` fields, and a missing field should not cause the
296/// whole response to fail parsing — `total_tokens` can be recomputed downstream.
297#[derive(serde::Deserialize)]
298struct OpenAIUsage {
299    #[serde(default)]
300    prompt_tokens: u32,
301    #[serde(default)]
302    completion_tokens: u32,
303    #[serde(default)]
304    total_tokens: u32,
305    /// OpenAI o1 / GLM-4.7 expose reasoning token counts here.
306    #[serde(default)]
307    completion_tokens_details: Option<OpenAICompletionTokensDetails>,
308}
309
310/// Nested under `usage.completion_tokens_details` for reasoning models.
311#[derive(serde::Deserialize)]
312struct OpenAICompletionTokensDetails {
313    #[serde(default)]
314    reasoning_tokens: Option<u32>,
315}
316
317#[async_trait]
318impl LLMClient for OpenAIClient {
319    async fn complete(&self, request: LLMRequest) -> Result<LLMResponse> {
320        let model = request.model.clone().unwrap_or_else(|| self.model.clone());
321        let temperature = request.temperature;
322        let max_tokens = request.max_tokens;
323        let tools = request
324            .tools
325            .as_deref()
326            .map(openai_tools)
327            .filter(|t| !t.is_empty());
328        let response_format = request
329            .response_format
330            .as_ref()
331            .and_then(openai_response_format);
332        let messages: Vec<_> = request
333            .into_openai_messages()
334            .into_iter()
335            .map(|(role, content)| OpenAIChatMessage { role, content })
336            .collect();
337
338        let body = OpenAIChatRequest {
339            model,
340            messages,
341            temperature,
342            max_tokens,
343            stream: false,
344            tools,
345            response_format,
346        };
347
348        let resp = self
349            .client
350            .post(format!("{}/chat/completions", self.base_url))
351            .header("Authorization", format!("Bearer {}", self.api_key))
352            .json(&body)
353            .send()
354            .await
355            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
356
357        check_rate_limit(&resp)?;
358
359        let status = resp.status();
360
361        if !status.is_success() {
362            let text = resp.text().await.unwrap_or_default();
363            return Err(KernelError::Http {
364                status: status.as_u16(),
365                message: redact_http_body(&text),
366            });
367        }
368
369        let chat_resp: OpenAIChatResponse = resp
370            .json()
371            .await
372            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
373
374        let id = chat_resp.id;
375        let created = chat_resp.created;
376        let first = chat_resp.choices.into_iter().next();
377        let finish_reason = first.as_ref().and_then(|c| c.finish_reason.clone());
378        let (content, reasoning, tool_calls) = match first {
379            Some(c) => {
380                let raw_content = c.message.content.unwrap_or_default();
381                let reasoning = c.message.reasoning_content;
382                let content = promote_reasoning_into_content(raw_content, reasoning.as_deref());
383                let calls = c
384                    .message
385                    .tool_calls
386                    .into_iter()
387                    .map(|tc| ToolCall {
388                        id: tc.id,
389                        name: tc.function.name,
390                        arguments: tc.function.arguments,
391                    })
392                    .collect();
393                (content, reasoning, calls)
394            }
395            None => (String::new(), None, Vec::new()),
396        };
397
398        let usage = chat_resp.usage.map(|u| TokenUsage {
399            prompt_tokens: u.prompt_tokens,
400            completion_tokens: u.completion_tokens,
401            total_tokens: u.total_tokens,
402            reasoning_tokens: u.completion_tokens_details.and_then(|d| d.reasoning_tokens),
403        });
404
405        Ok(LLMResponse {
406            content,
407            reasoning,
408            model: chat_resp.model,
409            usage: usage.unwrap_or_default(),
410            tool_calls,
411            finish_reason,
412            id,
413            created,
414        })
415    }
416
417    fn model_name(&self) -> &str {
418        &self.model
419    }
420
421    async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream> {
422        let model = request.model.clone().unwrap_or_else(|| self.model.clone());
423        let temperature = request.temperature;
424        let max_tokens = request.max_tokens;
425        let messages: Vec<_> = request
426            .into_openai_messages()
427            .into_iter()
428            .map(|(role, content)| OpenAIChatMessage { role, content })
429            .collect();
430
431        let body = OpenAIChatRequest {
432            model,
433            messages,
434            temperature,
435            max_tokens,
436            stream: true,
437            // Streaming is text-only here: the SSE parser emits text deltas and
438            // does not reassemble streamed tool-call fragments.
439            tools: None,
440            response_format: None,
441        };
442
443        let resp = self
444            .client
445            .post(format!("{}/chat/completions", self.base_url))
446            .header("Authorization", format!("Bearer {}", self.api_key))
447            .json(&body)
448            .send()
449            .await
450            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
451
452        check_rate_limit(&resp)?;
453
454        let status = resp.status();
455        if !status.is_success() {
456            let text = resp.text().await.unwrap_or_default();
457            return Err(KernelError::Http {
458                status: status.as_u16(),
459                message: redact_http_body(&text),
460            });
461        }
462
463        let (tx, rx) = tokio::sync::mpsc::channel::<Result<StreamEvent>>(16);
464
465        tokio::spawn(async move {
466            let mut stream = std::pin::pin!(resp.bytes_stream());
467            let mut buffer: Vec<u8> = Vec::new();
468
469            use tokio_stream::StreamExt;
470
471            while let Some(chunk) = stream.next().await {
472                let chunk = match chunk {
473                    Ok(c) => c,
474                    Err(e) => {
475                        let _ = tx.send(Err(KernelError::LlmApi(e.to_string()))).await;
476                        return;
477                    }
478                };
479
480                for line in drain_sse_lines(&mut buffer, &chunk) {
481                    if let Some(data) = parse_sse_line(&line)
482                        && let Some(event) = parse_openai_sse(data)
483                    {
484                        let is_done = matches!(event, StreamEvent::Done);
485                        if tx.send(Ok(event)).await.is_err() || is_done {
486                            return;
487                        }
488                    }
489                }
490            }
491            let _ = tx.send(Ok(StreamEvent::Done)).await;
492        });
493
494        Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
495    }
496}
497
498/// Extract the data payload from an SSE `data: ...` line.
499/// Returns `None` for non-data lines and for `data: [DONE]`.
500fn parse_sse_line(line: &str) -> Option<&str> {
501    line.strip_prefix("data: ").filter(|d| *d != "[DONE]")
502}
503
504/// Append a raw network chunk to `buffer` and drain every complete,
505/// newline-terminated line, decoded as UTF-8.
506///
507/// Decoding is deferred until a line's bytes are fully buffered. A single
508/// codepoint can straddle two network chunks, and decoding each chunk eagerly
509/// with [`String::from_utf8_lossy`] would replace the split bytes with `U+FFFD`
510/// — corrupting e.g. CJK or emoji deltas. Because `\n` (`0x0A`) is never a UTF-8
511/// lead or continuation byte, splitting on it can't cut a codepoint, so every
512/// drained line is a whole number of codepoints and decodes losslessly.
513fn drain_sse_lines(buffer: &mut Vec<u8>, chunk: &[u8]) -> Vec<String> {
514    buffer.extend_from_slice(chunk);
515    let mut lines = Vec::new();
516    while let Some(pos) = buffer.iter().position(|&b| b == b'\n') {
517        let line: Vec<u8> = buffer.drain(..=pos).collect();
518        lines.push(String::from_utf8_lossy(&line).trim_end().to_string());
519    }
520    lines
521}
522
523/// Decide the `content` exposed to consumers for a reasoning-model response.
524///
525/// # Two provider behaviors, one rule
526///
527/// - **GLM-4.7 (non-standard):** leaves `content` empty/null and returns the
528///   final answer inside `reasoning_content`. Promoting reasoning into `content`
529///   is required so downstream `json_extract` finds the JSON.
530/// - **Standard reasoning models (OpenAI o1, DeepSeek-R1):** put the final
531///   answer in `content` and the chain-of-thought in `reasoning_content`. When
532///   `content` is genuinely present it is preserved unchanged.
533///
534/// # Caveat — empty `content` on a standard model
535///
536/// The promotion triggers on *any* empty `content`. If a standard model ever
537/// returns an empty `content` together with reasoning (e.g. the model produced
538/// only chain-of-thought and no final answer, or a transport/parse glitch), this
539/// rule would surface the chain-of-thought as the answer. That is an acceptable
540/// trade-off: the alternative is an empty answer, which fails every downstream
541/// consumer the same way. The original reasoning is always preserved verbatim in
542/// [`LLMResponse::reasoning`], so callers can detect this case and reject it.
543fn promote_reasoning_into_content(raw_content: String, reasoning: Option<&str>) -> String {
544    if raw_content.is_empty() {
545        reasoning.unwrap_or_default().to_string()
546    } else {
547        raw_content
548    }
549}
550
551/// Parse an OpenAI streaming JSON chunk into a StreamEvent.
552fn parse_openai_sse(data: &str) -> Option<StreamEvent> {
553    let v: serde_json::Value = serde_json::from_str(data).ok()?;
554
555    // GLM-4.5+/o1 send reasoning and answer as separate delta chunks; check
556    // reasoning_content first so it is surfaced as ReasoningDelta, not dropped.
557    if let Some(rc) = v
558        .get("choices")?
559        .get(0)?
560        .get("delta")?
561        .get("reasoning_content")
562        .and_then(|c| c.as_str())
563        && !rc.is_empty()
564    {
565        return Some(StreamEvent::ReasoningDelta {
566            content: rc.to_string(),
567        });
568    }
569
570    // Extract delta content
571    if let Some(content) = v
572        .get("choices")?
573        .get(0)?
574        .get("delta")?
575        .get("content")
576        .and_then(|c| c.as_str())
577        && !content.is_empty()
578    {
579        return Some(StreamEvent::Delta {
580            content: content.to_string(),
581        });
582    }
583
584    // Extract usage from the final chunk
585    if let Some(usage) = v.get("usage").and_then(|u| {
586        Some(TokenUsage {
587            prompt_tokens: u.get("prompt_tokens")?.as_u64()? as u32,
588            completion_tokens: u.get("completion_tokens")?.as_u64()? as u32,
589            total_tokens: u.get("total_tokens")?.as_u64()? as u32,
590            reasoning_tokens: u
591                .get("completion_tokens_details")
592                .and_then(|d| d.get("reasoning_tokens"))
593                .and_then(|r| r.as_u64())
594                .map(|n| n as u32),
595        })
596    }) {
597        return Some(StreamEvent::Usage(usage));
598    }
599
600    // finish_reason = "stop" means done (no more content in this chunk)
601    if v.get("choices")?
602        .get(0)?
603        .get("finish_reason")
604        .and_then(|r| r.as_str())
605        .is_some()
606    {
607        return Some(StreamEvent::Done);
608    }
609
610    None
611}
612
613/// Parse an Anthropic streaming JSON chunk into a StreamEvent.
614fn parse_anthropic_sse(event_type: &str, data: &str) -> Option<StreamEvent> {
615    let v: serde_json::Value = serde_json::from_str(data).ok()?;
616
617    match event_type {
618        "content_block_delta" => {
619            let delta = v.get("delta")?;
620            // Extended thinking deltas arrive as {"type":"thinking_delta","thinking":"..."}.
621            match delta.get("type").and_then(|t| t.as_str()) {
622                Some("thinking_delta") => {
623                    let text = delta.get("thinking")?.as_str()?;
624                    if !text.is_empty() {
625                        return Some(StreamEvent::ReasoningDelta {
626                            content: text.to_string(),
627                        });
628                    }
629                    None
630                }
631                _ => {
632                    // text_delta (default) carries {"text":"..."}.
633                    let text = delta.get("text")?.as_str()?;
634                    if !text.is_empty() {
635                        return Some(StreamEvent::Delta {
636                            content: text.to_string(),
637                        });
638                    }
639                    None
640                }
641            }
642        }
643        "message_delta" => {
644            let usage = v.get("usage").and_then(|u| {
645                Some(TokenUsage {
646                    prompt_tokens: 0,
647                    completion_tokens: u.get("output_tokens")?.as_u64()? as u32,
648                    total_tokens: 0,
649                    reasoning_tokens: None,
650                })
651            });
652            if let Some(usage) = usage {
653                return Some(StreamEvent::Usage(usage));
654            }
655            Some(StreamEvent::Done)
656        }
657        "message_stop" => Some(StreamEvent::Done),
658        _ => None,
659    }
660}
661
662/// Async LLM client for the Anthropic Messages API.
663pub struct AnthropicClient {
664    api_key: String,
665    model: String,
666    base_url: String,
667    client: reqwest::Client,
668}
669
670impl AnthropicClient {
671    /// Create a new client using credentials from the environment variable in `config`.
672    pub fn new(config: &ModelConfig) -> Result<Self> {
673        let api_key = std::env::var(&config.api_key_env).map_err(|_| {
674            KernelError::Config(format!(
675                "Environment variable {} not set",
676                config.api_key_env
677            ))
678        })?;
679        Ok(Self {
680            api_key,
681            model: config.model.clone(),
682            base_url: config
683                .base_url
684                .clone()
685                .unwrap_or_else(|| "https://api.anthropic.com/v1".into()),
686            client: http_client()?,
687        })
688    }
689
690    /// Create a new client with an explicit API key, using the default Anthropic base URL.
691    ///
692    /// Returns a [`KernelError::Config`] if the HTTP client (with its connect /
693    /// total timeouts) cannot be built, rather than silently falling back to a
694    /// timeout-less `reqwest::Client::default()`.
695    pub fn from_key(model: impl Into<String>, api_key: impl Into<String>) -> Result<Self> {
696        Ok(Self {
697            api_key: api_key.into(),
698            model: model.into(),
699            base_url: "https://api.anthropic.com/v1".into(),
700            client: http_client()?,
701        })
702    }
703
704    /// Create from an explicit key and a shared `reqwest::Client`.
705    pub fn from_key_with_client(
706        model: impl Into<String>,
707        api_key: impl Into<String>,
708        client: reqwest::Client,
709    ) -> Self {
710        Self {
711            api_key: api_key.into(),
712            model: model.into(),
713            base_url: "https://api.anthropic.com/v1".into(),
714            client,
715        }
716    }
717
718    /// Create from an explicit key, a custom base URL, and a shared `reqwest::Client`.
719    ///
720    /// Use this for Anthropic-compatible endpoints that are not the default
721    /// `api.anthropic.com` (self-hosted proxies, regional gateways, …).
722    pub fn from_key_with_base_url(
723        model: impl Into<String>,
724        api_key: impl Into<String>,
725        base_url: impl Into<String>,
726        client: reqwest::Client,
727    ) -> Self {
728        Self {
729            api_key: api_key.into(),
730            model: model.into(),
731            base_url: base_url.into(),
732            client,
733        }
734    }
735}
736
737#[derive(serde::Serialize)]
738struct AnthropicRequest {
739    model: String,
740    max_tokens: u32,
741    temperature: f32,
742    #[serde(skip_serializing_if = "Option::is_none")]
743    system: Option<String>,
744    messages: Vec<AnthropicMessage>,
745    #[serde(skip_serializing_if = "std::ops::Not::not")]
746    stream: bool,
747    #[serde(skip_serializing_if = "Option::is_none")]
748    tools: Option<Vec<serde_json::Value>>,
749    #[serde(skip_serializing_if = "Option::is_none")]
750    output_config: Option<serde_json::Value>,
751}
752
753#[derive(serde::Serialize)]
754struct AnthropicMessage {
755    role: String,
756    content: String,
757}
758
759#[derive(serde::Deserialize)]
760struct AnthropicResponse {
761    #[serde(default)]
762    id: Option<String>,
763    content: Vec<AnthropicContentBlock>,
764    model: String,
765    #[serde(default)]
766    stop_reason: Option<String>,
767    usage: AnthropicUsage,
768}
769
770/// A response content block. `text` blocks carry `text`; `tool_use` blocks
771/// carry `id`/`name`/`input`; `thinking` blocks (extended thinking) carry `thinking`.
772#[derive(serde::Deserialize)]
773struct AnthropicContentBlock {
774    #[serde(rename = "type")]
775    block_type: String,
776    #[serde(default)]
777    text: Option<String>,
778    /// Extended thinking content (`{"type":"thinking","thinking":"..."}`).
779    #[serde(default)]
780    thinking: Option<String>,
781    #[serde(default)]
782    id: Option<String>,
783    #[serde(default)]
784    name: Option<String>,
785    #[serde(default)]
786    input: Option<serde_json::Value>,
787}
788
789#[derive(serde::Deserialize)]
790struct AnthropicUsage {
791    input_tokens: u32,
792    output_tokens: u32,
793}
794
795#[async_trait]
796impl LLMClient for AnthropicClient {
797    async fn complete(&self, request: LLMRequest) -> Result<LLMResponse> {
798        let model = request.model.clone().unwrap_or_else(|| self.model.clone());
799        let max_tokens = request.max_tokens.unwrap_or(4096);
800        let temperature = request.temperature;
801        let system = request.system.clone();
802        let tools = request
803            .tools
804            .as_deref()
805            .map(anthropic_tools)
806            .filter(|t| !t.is_empty());
807        let output_config = request
808            .response_format
809            .as_ref()
810            .and_then(anthropic_output_config);
811        let messages: Vec<AnthropicMessage> = request
812            .into_anthropic_messages()
813            .into_iter()
814            .map(|(role, content)| AnthropicMessage { role, content })
815            .collect();
816
817        let body = AnthropicRequest {
818            model,
819            max_tokens,
820            temperature,
821            system,
822            messages,
823            stream: false,
824            tools,
825            output_config,
826        };
827
828        let resp = self
829            .client
830            .post(format!("{}/messages", self.base_url))
831            .header("x-api-key", &self.api_key)
832            .header("anthropic-version", "2023-06-01")
833            .header("content-type", "application/json")
834            .json(&body)
835            .send()
836            .await
837            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
838
839        check_rate_limit(&resp)?;
840
841        let status = resp.status();
842
843        if !status.is_success() {
844            let text = resp.text().await.unwrap_or_default();
845            return Err(KernelError::Http {
846                status: status.as_u16(),
847                message: redact_http_body(&text),
848            });
849        }
850
851        let chat_resp: AnthropicResponse = resp
852            .json()
853            .await
854            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
855
856        let mut content = String::new();
857        let mut reasoning = String::new();
858        let mut tool_calls = Vec::new();
859        for block in chat_resp.content {
860            match block.block_type.as_str() {
861                "text" => {
862                    if let Some(t) = block.text {
863                        content.push_str(&t);
864                    }
865                }
866                "thinking" => {
867                    if let Some(t) = block.thinking {
868                        reasoning.push_str(&t);
869                    }
870                }
871                "tool_use" => {
872                    if let (Some(id), Some(name)) = (block.id, block.name) {
873                        let arguments = block
874                            .input
875                            .map(|v| v.to_string())
876                            .unwrap_or_else(|| "{}".to_string());
877                        tool_calls.push(ToolCall {
878                            id,
879                            name,
880                            arguments,
881                        });
882                    }
883                }
884                _ => {}
885            }
886        }
887
888        Ok(LLMResponse {
889            content,
890            reasoning: if reasoning.is_empty() {
891                None
892            } else {
893                Some(reasoning)
894            },
895            model: chat_resp.model,
896            usage: TokenUsage {
897                prompt_tokens: chat_resp.usage.input_tokens,
898                completion_tokens: chat_resp.usage.output_tokens,
899                total_tokens: chat_resp.usage.input_tokens + chat_resp.usage.output_tokens,
900                reasoning_tokens: None,
901            },
902            tool_calls,
903            finish_reason: chat_resp.stop_reason,
904            id: chat_resp.id,
905            created: None,
906        })
907    }
908
909    fn model_name(&self) -> &str {
910        &self.model
911    }
912
913    async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream> {
914        let model = request.model.clone().unwrap_or_else(|| self.model.clone());
915        let max_tokens = request.max_tokens.unwrap_or(4096);
916        let temperature = request.temperature;
917        let system = request.system.clone();
918        let messages: Vec<AnthropicMessage> = request
919            .into_anthropic_messages()
920            .into_iter()
921            .map(|(role, content)| AnthropicMessage { role, content })
922            .collect();
923
924        let body = AnthropicRequest {
925            model,
926            max_tokens,
927            temperature,
928            system,
929            messages,
930            stream: true,
931            // Streaming is text-only here: the SSE parser emits text deltas and
932            // does not reassemble streamed tool-use blocks.
933            tools: None,
934            output_config: None,
935        };
936
937        let resp = self
938            .client
939            .post(format!("{}/messages", self.base_url))
940            .header("x-api-key", &self.api_key)
941            .header("anthropic-version", "2023-06-01")
942            .header("content-type", "application/json")
943            .json(&body)
944            .send()
945            .await
946            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
947
948        check_rate_limit(&resp)?;
949
950        let status = resp.status();
951        if !status.is_success() {
952            let text = resp.text().await.unwrap_or_default();
953            return Err(KernelError::Http {
954                status: status.as_u16(),
955                message: redact_http_body(&text),
956            });
957        }
958
959        let (tx, rx) = tokio::sync::mpsc::channel::<Result<StreamEvent>>(16);
960
961        tokio::spawn(async move {
962            let mut stream = std::pin::pin!(resp.bytes_stream());
963            let mut buffer: Vec<u8> = Vec::new();
964            let mut current_event = String::new();
965
966            use tokio_stream::StreamExt;
967
968            while let Some(chunk) = stream.next().await {
969                let chunk = match chunk {
970                    Ok(c) => c,
971                    Err(e) => {
972                        let _ = tx.send(Err(KernelError::LlmApi(e.to_string()))).await;
973                        return;
974                    }
975                };
976
977                for line in drain_sse_lines(&mut buffer, &chunk) {
978                    if let Some(evt) = line.strip_prefix("event: ") {
979                        current_event = evt.to_string();
980                    } else if let Some(data) = line.strip_prefix("data: ") {
981                        if data == "[DONE]" {
982                            let _ = tx.send(Ok(StreamEvent::Done)).await;
983                            return;
984                        }
985                        if let Some(event) = parse_anthropic_sse(&current_event, data) {
986                            let is_done = matches!(event, StreamEvent::Done);
987                            if tx.send(Ok(event)).await.is_err() || is_done {
988                                return;
989                            }
990                        }
991                        current_event.clear();
992                    }
993                }
994            }
995            let _ = tx.send(Ok(StreamEvent::Done)).await;
996        });
997
998        Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
999    }
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004    use super::*;
1005
1006    #[test]
1007    fn parse_sse_line_extracts_data() {
1008        assert_eq!(
1009            parse_sse_line("data: {\"id\":\"1\"}"),
1010            Some("{\"id\":\"1\"}")
1011        );
1012    }
1013
1014    #[test]
1015    fn parse_sse_line_skips_done() {
1016        assert_eq!(parse_sse_line("data: [DONE]"), None);
1017    }
1018
1019    #[test]
1020    fn parse_sse_line_skips_non_data() {
1021        assert_eq!(parse_sse_line("event: ping"), None);
1022        assert_eq!(parse_sse_line(""), None);
1023    }
1024
1025    #[test]
1026    fn drain_sse_lines_reassembles_multibyte_split_across_chunks() {
1027        // "data: 안녕\n" — "data: " is 6 bytes, 안/녕 are 3 bytes each.
1028        let full = "data: 안녕\n".as_bytes().to_vec();
1029        // Split at byte 7, mid-way through "안"'s 3-byte sequence.
1030        let (first, rest) = full.split_at(7);
1031
1032        let mut buffer = Vec::new();
1033        // No newline yet, and the trailing bytes are a partial codepoint:
1034        // nothing should be emitted, and nothing should be corrupted.
1035        assert!(drain_sse_lines(&mut buffer, first).is_empty());
1036
1037        let lines = drain_sse_lines(&mut buffer, rest);
1038        assert_eq!(lines, vec!["data: 안녕".to_string()]);
1039        // A per-chunk from_utf8_lossy would instead have produced U+FFFD here.
1040        assert!(!lines[0].contains('\u{FFFD}'));
1041    }
1042
1043    #[test]
1044    fn drain_sse_lines_handles_multiple_lines_and_keeps_partial_tail() {
1045        let mut buffer = Vec::new();
1046        let lines = drain_sse_lines(&mut buffer, b"event: ping\r\ndata: {}\npartial");
1047        assert_eq!(
1048            lines,
1049            vec!["event: ping".to_string(), "data: {}".to_string()]
1050        );
1051        // The unterminated "partial" tail stays buffered for the next chunk.
1052        let lines = drain_sse_lines(&mut buffer, b" tail\n");
1053        assert_eq!(lines, vec!["partial tail".to_string()]);
1054    }
1055
1056    #[test]
1057    fn openai_delta_extraction() {
1058        let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#;
1059        let event = parse_openai_sse(data).unwrap();
1060        match event {
1061            StreamEvent::Delta { content } => assert_eq!(content, "Hello"),
1062            _ => panic!("expected Delta, got {:?}", event),
1063        }
1064    }
1065
1066    #[test]
1067    fn openai_usage_extraction() {
1068        let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}"#;
1069        let event = parse_openai_sse(data).unwrap();
1070        match event {
1071            StreamEvent::Usage(usage) => {
1072                assert_eq!(usage.prompt_tokens, 10);
1073                assert_eq!(usage.completion_tokens, 5);
1074                assert_eq!(usage.total_tokens, 15);
1075            }
1076            _ => panic!("expected Usage, got {:?}", event),
1077        }
1078    }
1079
1080    #[test]
1081    fn openai_finish_reason_is_done() {
1082        let data =
1083            r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#;
1084        let event = parse_openai_sse(data).unwrap();
1085        assert!(matches!(event, StreamEvent::Done));
1086    }
1087
1088    #[test]
1089    fn openai_empty_delta_skipped() {
1090        let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}]}"#;
1091        assert!(parse_openai_sse(data).is_none());
1092    }
1093
1094    #[test]
1095    fn anthropic_content_block_delta() {
1096        let data = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#;
1097        let event = parse_anthropic_sse("content_block_delta", data).unwrap();
1098        match event {
1099            StreamEvent::Delta { content } => assert_eq!(content, "Hello"),
1100            _ => panic!("expected Delta, got {:?}", event),
1101        }
1102    }
1103
1104    #[test]
1105    fn anthropic_message_delta_usage() {
1106        let data = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}"#;
1107        let event = parse_anthropic_sse("message_delta", data).unwrap();
1108        match event {
1109            StreamEvent::Usage(usage) => assert_eq!(usage.completion_tokens, 5),
1110            _ => panic!("expected Usage, got {:?}", event),
1111        }
1112    }
1113
1114    #[test]
1115    fn anthropic_message_stop() {
1116        let event = parse_anthropic_sse("message_stop", r#"{"type":"message_stop"}"#).unwrap();
1117        assert!(matches!(event, StreamEvent::Done));
1118    }
1119
1120    #[test]
1121    fn anthropic_unknown_event_ignored() {
1122        assert!(parse_anthropic_sse("ping", "{}").is_none());
1123    }
1124
1125    fn sample_tool() -> ToolDefinition {
1126        ToolDefinition {
1127            name: "get_weather".into(),
1128            description: "Get weather".into(),
1129            input_schema: serde_json::json!({
1130                "type": "object",
1131                "properties": { "location": { "type": "string" } },
1132                "required": ["location"]
1133            }),
1134        }
1135    }
1136
1137    #[test]
1138    fn openai_tools_use_function_wrapper() {
1139        let out = openai_tools(&[sample_tool()]);
1140        assert_eq!(out.len(), 1);
1141        assert_eq!(out[0]["type"], "function");
1142        assert_eq!(out[0]["function"]["name"], "get_weather");
1143        // input_schema is forwarded verbatim as `parameters`.
1144        assert_eq!(out[0]["function"]["parameters"]["required"][0], "location");
1145    }
1146
1147    #[test]
1148    fn openai_response_format_maps_each_variant() {
1149        assert!(openai_response_format(&ResponseFormat::Text).is_none());
1150        assert_eq!(
1151            openai_response_format(&ResponseFormat::Json).unwrap()["type"],
1152            "json_object"
1153        );
1154        let schema = serde_json::json!({"type": "object"});
1155        let js = openai_response_format(&ResponseFormat::JsonSchema { schema }).unwrap();
1156        assert_eq!(js["type"], "json_schema");
1157        assert_eq!(js["json_schema"]["strict"], true);
1158    }
1159
1160    #[test]
1161    fn anthropic_tools_use_input_schema_key() {
1162        let out = anthropic_tools(&[sample_tool()]);
1163        assert_eq!(out[0]["name"], "get_weather");
1164        assert_eq!(out[0]["input_schema"]["type"], "object");
1165        assert!(out[0].get("function").is_none());
1166    }
1167
1168    #[test]
1169    fn anthropic_output_config_only_for_json_schema() {
1170        assert!(anthropic_output_config(&ResponseFormat::Text).is_none());
1171        assert!(anthropic_output_config(&ResponseFormat::Json).is_none());
1172        let schema = serde_json::json!({"type": "object"});
1173        let cfg = anthropic_output_config(&ResponseFormat::JsonSchema { schema }).unwrap();
1174        assert_eq!(cfg["format"]["type"], "json_schema");
1175    }
1176
1177    #[test]
1178    fn openai_request_serializes_tools_and_format() {
1179        let body = OpenAIChatRequest {
1180            model: "gpt-4o".into(),
1181            messages: vec![OpenAIChatMessage {
1182                role: "user".into(),
1183                content: "hi".into(),
1184            }],
1185            temperature: 0.7,
1186            max_tokens: None,
1187            stream: false,
1188            tools: Some(openai_tools(&[sample_tool()])),
1189            response_format: Some(serde_json::json!({ "type": "json_object" })),
1190        };
1191        let json = serde_json::to_value(&body).unwrap();
1192        assert_eq!(json["tools"][0]["function"]["name"], "get_weather");
1193        assert_eq!(json["response_format"]["type"], "json_object");
1194        // Omitted when None (backward-compatible request shape).
1195        assert!(json.get("max_tokens").is_none());
1196    }
1197
1198    #[test]
1199    fn openai_response_parses_tool_calls() {
1200        let raw = r#"{
1201            "id": "chatcmpl-1",
1202            "created": 1700000000,
1203            "model": "gpt-4o",
1204            "choices": [{
1205                "index": 0,
1206                "message": {
1207                    "role": "assistant",
1208                    "content": null,
1209                    "tool_calls": [{
1210                        "id": "call_abc",
1211                        "type": "function",
1212                        "function": { "name": "get_weather", "arguments": "{\"location\":\"Paris\"}" }
1213                    }]
1214                },
1215                "finish_reason": "tool_calls"
1216            }],
1217            "usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15 }
1218        }"#;
1219        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1220        assert_eq!(resp.id.as_deref(), Some("chatcmpl-1"));
1221        let choice = resp.choices.into_iter().next().unwrap();
1222        assert_eq!(choice.finish_reason.as_deref(), Some("tool_calls"));
1223        assert!(choice.message.content.is_none());
1224        assert_eq!(choice.message.tool_calls.len(), 1);
1225        assert_eq!(choice.message.tool_calls[0].function.name, "get_weather");
1226    }
1227
1228    #[test]
1229    fn openai_response_parses_glm47_reasoning_content() {
1230        // GLM-4.7: content=null, reasoning_content carries the chain-of-thought + final JSON.
1231        let raw = r#"{
1232            "id":"chatcmpl-1","created":1700000000,"model":"glm-4.7",
1233            "choices":[{"index":0,"message":{"role":"assistant","content":null,
1234                "reasoning_content":"thinking... {\"rating\":\"Buy\"}"},"finish_reason":"stop"}],
1235            "usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15,
1236                "completion_tokens_details":{"reasoning_tokens":3}}
1237        }"#;
1238        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1239        let choice = resp.choices.into_iter().next().unwrap();
1240        assert!(choice.message.content.is_none());
1241        assert!(choice.message.reasoning_content.is_some());
1242        assert_eq!(
1243            resp.usage
1244                .unwrap()
1245                .completion_tokens_details
1246                .unwrap()
1247                .reasoning_tokens,
1248            Some(3)
1249        );
1250    }
1251
1252    #[test]
1253    fn openai_complete_promotes_reasoning_when_content_empty() {
1254        // complete() must promote reasoning_content into content when content is empty,
1255        // so downstream json_extract finds the JSON. Drives the *production* promotion
1256        // helper (not a re-implementation) so the regression guard tracks real code.
1257        let raw = r#"{
1258            "id":"chatcmpl-1","created":1700000000,"model":"glm-4.7",
1259            "choices":[{"index":0,"message":{"role":"assistant","content":"",
1260                "reasoning_content":"사고 {\"rating\":\"Sell\",\"key_thesis\":\"약세\"}"},"finish_reason":"stop"}]
1261        }"#;
1262        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1263        let first = resp.choices.into_iter().next().unwrap();
1264        let raw_content = first.message.content.unwrap_or_default();
1265        let reasoning = first.message.reasoning_content.clone();
1266        let content = promote_reasoning_into_content(raw_content, reasoning.as_deref());
1267        assert!(
1268            content.contains("\"rating\":\"Sell\""),
1269            "promoted content must contain JSON: {content}"
1270        );
1271        assert_eq!(
1272            reasoning.as_deref(),
1273            Some("사고 {\"rating\":\"Sell\",\"key_thesis\":\"약세\"}")
1274        );
1275    }
1276
1277    #[test]
1278    fn openai_complete_keeps_content_when_present() {
1279        // Non-empty content must NOT be overwritten by reasoning.
1280        let raw = r#"{
1281            "id":"chatcmpl-1","created":1700000000,"model":"gpt-4o",
1282            "choices":[{"index":0,"message":{"role":"assistant","content":"answer",
1283                "reasoning_content":"thought"},"finish_reason":"stop"}]
1284        }"#;
1285        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1286        let first = resp.choices.into_iter().next().unwrap();
1287        let raw_content = first.message.content.clone().unwrap_or_default();
1288        let reasoning = first.message.reasoning_content.as_deref();
1289        let content = promote_reasoning_into_content(raw_content.clone(), reasoning);
1290        assert_eq!(content, "answer");
1291        assert_eq!(reasoning, Some("thought"));
1292    }
1293
1294    #[test]
1295    fn promote_reasoning_into_content_edge_cases() {
1296        // #3 caveat: empty content + reasoning surfaces the reasoning as the answer
1297        // (documented trade-off; original reasoning is the source of truth).
1298        assert_eq!(
1299            promote_reasoning_into_content(String::new(), Some("chain-of-thought")),
1300            "chain-of-thought"
1301        );
1302        // content present wins regardless of reasoning.
1303        assert_eq!(
1304            promote_reasoning_into_content("ans".into(), Some("cot")),
1305            "ans"
1306        );
1307        // both empty -> empty (no panic, no spurious content).
1308        assert_eq!(promote_reasoning_into_content(String::new(), None), "");
1309        assert_eq!(promote_reasoning_into_content("x".into(), None), "x");
1310    }
1311
1312    #[test]
1313    fn openai_response_parses_deepseek_reasoning_alias() {
1314        // DeepSeek-R1 uses field name `reasoning` instead of `reasoning_content`.
1315        let raw = r#"{"model":"deepseek-r1","choices":[{"message":{"content":"ans","reasoning":"thought"}}]}"#;
1316        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1317        assert_eq!(
1318            resp.choices[0].message.reasoning_content.as_deref(),
1319            Some("thought")
1320        );
1321    }
1322
1323    #[test]
1324    fn openai_sse_reasoning_delta_extracted() {
1325        let data = r#"{"choices":[{"index":0,"delta":{"reasoning_content":"thinking"},"finish_reason":null}]}"#;
1326        let event = parse_openai_sse(data).unwrap();
1327        match event {
1328            StreamEvent::ReasoningDelta { content } => assert_eq!(content, "thinking"),
1329            other => panic!("expected ReasoningDelta, got {other:?}"),
1330        }
1331    }
1332
1333    #[test]
1334    fn openai_sse_content_delta_still_works_alongside_reasoning() {
1335        // Separate content chunk must still produce Delta, not be swallowed.
1336        let data = r#"{"choices":[{"index":0,"delta":{"content":"answer"},"finish_reason":null}]}"#;
1337        let event = parse_openai_sse(data).unwrap();
1338        assert!(matches!(event, StreamEvent::Delta { .. }));
1339    }
1340
1341    #[test]
1342    fn openai_sse_usage_carries_reasoning_tokens() {
1343        // Final streaming chunk carries choices (empty delta) + usage with reasoning_tokens.
1344        let data = r#"{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],
1345            "usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3,
1346            "completion_tokens_details":{"reasoning_tokens":7}}}"#;
1347        let event = parse_openai_sse(data).unwrap();
1348        match event {
1349            StreamEvent::Usage(u) => assert_eq!(u.reasoning_tokens, Some(7)),
1350            other => panic!("expected Usage, got {other:?}"),
1351        }
1352    }
1353
1354    #[test]
1355    fn anthropic_response_parses_tool_use_block() {
1356        let raw = r#"{
1357            "id": "msg_1",
1358            "model": "claude-sonnet-4-6",
1359            "stop_reason": "tool_use",
1360            "content": [
1361                { "type": "text", "text": "Let me check." },
1362                { "type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": { "location": "Paris" } }
1363            ],
1364            "usage": { "input_tokens": 12, "output_tokens": 8 }
1365        }"#;
1366        let resp: AnthropicResponse = serde_json::from_str(raw).unwrap();
1367        assert_eq!(resp.stop_reason.as_deref(), Some("tool_use"));
1368        assert_eq!(resp.content.len(), 2);
1369        assert_eq!(resp.content[0].block_type, "text");
1370        assert_eq!(resp.content[1].block_type, "tool_use");
1371        assert_eq!(resp.content[1].name.as_deref(), Some("get_weather"));
1372        assert_eq!(resp.content[1].input.as_ref().unwrap()["location"], "Paris");
1373    }
1374
1375    #[test]
1376    fn anthropic_response_parses_thinking_block() {
1377        // Extended thinking block must surface in LLMResponse.reasoning via the parse loop.
1378        let raw = r#"{
1379            "id": "msg_2",
1380            "model": "claude-sonnet-4-6",
1381            "stop_reason": "end_turn",
1382            "content": [
1383                { "type": "thinking", "thinking": "step by step..." },
1384                { "type": "text", "text": "Final answer." }
1385            ],
1386            "usage": { "input_tokens": 5, "output_tokens": 9 }
1387        }"#;
1388        let resp: AnthropicResponse = serde_json::from_str(raw).unwrap();
1389        let mut reasoning = String::new();
1390        let mut content = String::new();
1391        for block in resp.content {
1392            match block.block_type.as_str() {
1393                "text" => {
1394                    if let Some(t) = block.text {
1395                        content.push_str(&t);
1396                    }
1397                }
1398                "thinking" => {
1399                    if let Some(t) = block.thinking {
1400                        reasoning.push_str(&t);
1401                    }
1402                }
1403                _ => {}
1404            }
1405        }
1406        assert_eq!(content, "Final answer.");
1407        assert_eq!(reasoning, "step by step...");
1408    }
1409
1410    #[test]
1411    fn anthropic_sse_thinking_delta_extracted() {
1412        let data = r#"{"delta":{"type":"thinking_delta","thinking":"a thought"}}"#;
1413        let event = parse_anthropic_sse("content_block_delta", data).unwrap();
1414        match event {
1415            StreamEvent::ReasoningDelta { content } => assert_eq!(content, "a thought"),
1416            other => panic!("expected ReasoningDelta, got {other:?}"),
1417        }
1418    }
1419}