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    reqwest::Client::builder()
87        .connect_timeout(Duration::from_secs(10))
88        .timeout(Duration::from_secs(120))
89        .build()
90        .map_err(|e| KernelError::Config(format!("Failed to build HTTP client: {}", e)))
91}
92
93/// Check for HTTP 429 rate-limit response and extract `retry-after` header.
94fn check_rate_limit(resp: &reqwest::Response) -> Result<()> {
95    if resp.status().as_u16() == 429 {
96        let retry = resp
97            .headers()
98            .get("retry-after")
99            .and_then(|v| v.to_str().ok())
100            .and_then(|v| v.parse().ok())
101            .unwrap_or(60);
102        return Err(KernelError::RateLimited(retry));
103    }
104    Ok(())
105}
106
107/// Unified async interface for LLM chat completion and streaming.
108#[async_trait]
109pub trait LLMClient: Send + Sync {
110    /// Send a chat completion request and return the full response.
111    async fn complete(&self, request: LLMRequest) -> Result<LLMResponse>;
112    /// Return the model name this client is configured to use.
113    fn model_name(&self) -> &str;
114
115    /// Stream a chat completion, yielding events as they arrive.
116    async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream>;
117}
118
119/// Async LLM client for the OpenAI chat completions API.
120pub struct OpenAIClient {
121    api_key: String,
122    model: String,
123    base_url: String,
124    client: reqwest::Client,
125}
126
127impl OpenAIClient {
128    /// Create a new client using credentials from the environment variable in `config`.
129    pub fn new(config: &ModelConfig) -> Result<Self> {
130        let api_key = std::env::var(&config.api_key_env).map_err(|_| {
131            KernelError::Config(format!(
132                "Environment variable {} not set",
133                config.api_key_env
134            ))
135        })?;
136        Ok(Self {
137            api_key,
138            model: config.model.clone(),
139            base_url: config
140                .base_url
141                .clone()
142                .unwrap_or_else(|| "https://api.openai.com/v1".into()),
143            client: http_client()?,
144        })
145    }
146
147    /// Create a new client with an explicit API key, using the default OpenAI base URL.
148    ///
149    /// Returns a [`KernelError::Config`] if the HTTP client (with its connect /
150    /// total timeouts) cannot be built, rather than silently falling back to a
151    /// timeout-less `reqwest::Client::default()`.
152    ///
153    /// # Example
154    ///
155    /// ```no_run
156    /// use llm_kernel::llm::OpenAIClient;
157    /// let client = OpenAIClient::from_key("gpt-4o-mini", "sk-...")?;
158    /// # Ok::<(), llm_kernel::error::KernelError>(())
159    /// ```
160    pub fn from_key(model: impl Into<String>, api_key: impl Into<String>) -> Result<Self> {
161        Ok(Self {
162            api_key: api_key.into(),
163            model: model.into(),
164            base_url: "https://api.openai.com/v1".into(),
165            client: http_client()?,
166        })
167    }
168
169    /// Create from an explicit key and a shared `reqwest::Client`.
170    ///
171    /// Prefer this over [`from_key`](Self::from_key) when constructing multiple
172    /// clients in a hot path — the shared client reuses the underlying TCP
173    /// connection pool.
174    pub fn from_key_with_client(
175        model: impl Into<String>,
176        api_key: impl Into<String>,
177        client: reqwest::Client,
178    ) -> Self {
179        Self {
180            api_key: api_key.into(),
181            model: model.into(),
182            base_url: "https://api.openai.com/v1".into(),
183            client,
184        }
185    }
186
187    /// Create from an explicit key, a custom base URL, and a shared `reqwest::Client`.
188    ///
189    /// Use this for OpenAI-compatible providers that are not the default OpenAI
190    /// endpoint (DeepSeek, Groq, Ollama, LM Studio, custom gateways, …) when you
191    /// already hold the key in memory and want to reuse a shared connection pool.
192    pub fn from_key_with_base_url(
193        model: impl Into<String>,
194        api_key: impl Into<String>,
195        base_url: impl Into<String>,
196        client: reqwest::Client,
197    ) -> Self {
198        Self {
199            api_key: api_key.into(),
200            model: model.into(),
201            base_url: base_url.into(),
202            client,
203        }
204    }
205}
206
207#[derive(serde::Serialize)]
208struct OpenAIChatRequest {
209    model: String,
210    messages: Vec<OpenAIChatMessage>,
211    temperature: f32,
212    #[serde(skip_serializing_if = "Option::is_none")]
213    max_tokens: Option<u32>,
214    #[serde(skip_serializing_if = "std::ops::Not::not")]
215    stream: bool,
216    #[serde(skip_serializing_if = "Option::is_none")]
217    tools: Option<Vec<serde_json::Value>>,
218    #[serde(skip_serializing_if = "Option::is_none")]
219    response_format: Option<serde_json::Value>,
220}
221
222#[derive(serde::Serialize)]
223struct OpenAIChatMessage {
224    role: String,
225    content: String,
226}
227
228#[derive(serde::Deserialize)]
229struct OpenAIChatResponse {
230    #[serde(default)]
231    id: Option<String>,
232    #[serde(default)]
233    created: Option<u64>,
234    choices: Vec<OpenAIChoice>,
235    model: String,
236    usage: Option<OpenAIUsage>,
237}
238
239#[derive(serde::Deserialize)]
240struct OpenAIChoice {
241    message: OpenAIRespMessage,
242    #[serde(default)]
243    finish_reason: Option<String>,
244}
245
246/// Response-side assistant message. `content` is `null` on tool-call turns, so
247/// it is optional and defaults to empty.
248#[derive(serde::Deserialize)]
249struct OpenAIRespMessage {
250    #[serde(default)]
251    content: Option<String>,
252    #[serde(default)]
253    tool_calls: Vec<OpenAIToolCall>,
254}
255
256#[derive(serde::Deserialize)]
257struct OpenAIToolCall {
258    id: String,
259    function: OpenAIFunctionCall,
260}
261
262#[derive(serde::Deserialize)]
263struct OpenAIFunctionCall {
264    name: String,
265    #[serde(default)]
266    arguments: String,
267}
268
269#[derive(serde::Deserialize)]
270struct OpenAIUsage {
271    prompt_tokens: u32,
272    completion_tokens: u32,
273    total_tokens: u32,
274}
275
276#[async_trait]
277impl LLMClient for OpenAIClient {
278    async fn complete(&self, request: LLMRequest) -> Result<LLMResponse> {
279        let model = request.model.clone().unwrap_or_else(|| self.model.clone());
280        let temperature = request.temperature;
281        let max_tokens = request.max_tokens;
282        let tools = request
283            .tools
284            .as_deref()
285            .map(openai_tools)
286            .filter(|t| !t.is_empty());
287        let response_format = request
288            .response_format
289            .as_ref()
290            .and_then(openai_response_format);
291        let messages: Vec<_> = request
292            .into_openai_messages()
293            .into_iter()
294            .map(|(role, content)| OpenAIChatMessage { role, content })
295            .collect();
296
297        let body = OpenAIChatRequest {
298            model,
299            messages,
300            temperature,
301            max_tokens,
302            stream: false,
303            tools,
304            response_format,
305        };
306
307        let resp = self
308            .client
309            .post(format!("{}/chat/completions", self.base_url))
310            .header("Authorization", format!("Bearer {}", self.api_key))
311            .json(&body)
312            .send()
313            .await
314            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
315
316        check_rate_limit(&resp)?;
317
318        let status = resp.status();
319
320        if !status.is_success() {
321            let text = resp.text().await.unwrap_or_default();
322            return Err(KernelError::Http {
323                status: status.as_u16(),
324                message: redact_http_body(&text),
325            });
326        }
327
328        let chat_resp: OpenAIChatResponse = resp
329            .json()
330            .await
331            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
332
333        let id = chat_resp.id;
334        let created = chat_resp.created;
335        let first = chat_resp.choices.into_iter().next();
336        let finish_reason = first.as_ref().and_then(|c| c.finish_reason.clone());
337        let (content, tool_calls) = match first {
338            Some(c) => {
339                let content = c.message.content.unwrap_or_default();
340                let calls = c
341                    .message
342                    .tool_calls
343                    .into_iter()
344                    .map(|tc| ToolCall {
345                        id: tc.id,
346                        name: tc.function.name,
347                        arguments: tc.function.arguments,
348                    })
349                    .collect();
350                (content, calls)
351            }
352            None => (String::new(), Vec::new()),
353        };
354
355        let usage = chat_resp.usage.map(|u| TokenUsage {
356            prompt_tokens: u.prompt_tokens,
357            completion_tokens: u.completion_tokens,
358            total_tokens: u.total_tokens,
359        });
360
361        Ok(LLMResponse {
362            content,
363            model: chat_resp.model,
364            usage: usage.unwrap_or_default(),
365            tool_calls,
366            finish_reason,
367            id,
368            created,
369        })
370    }
371
372    fn model_name(&self) -> &str {
373        &self.model
374    }
375
376    async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream> {
377        let model = request.model.clone().unwrap_or_else(|| self.model.clone());
378        let temperature = request.temperature;
379        let max_tokens = request.max_tokens;
380        let messages: Vec<_> = request
381            .into_openai_messages()
382            .into_iter()
383            .map(|(role, content)| OpenAIChatMessage { role, content })
384            .collect();
385
386        let body = OpenAIChatRequest {
387            model,
388            messages,
389            temperature,
390            max_tokens,
391            stream: true,
392            // Streaming is text-only here: the SSE parser emits text deltas and
393            // does not reassemble streamed tool-call fragments.
394            tools: None,
395            response_format: None,
396        };
397
398        let resp = self
399            .client
400            .post(format!("{}/chat/completions", self.base_url))
401            .header("Authorization", format!("Bearer {}", self.api_key))
402            .json(&body)
403            .send()
404            .await
405            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
406
407        check_rate_limit(&resp)?;
408
409        let status = resp.status();
410        if !status.is_success() {
411            let text = resp.text().await.unwrap_or_default();
412            return Err(KernelError::Http {
413                status: status.as_u16(),
414                message: redact_http_body(&text),
415            });
416        }
417
418        let (tx, rx) = tokio::sync::mpsc::channel::<Result<StreamEvent>>(16);
419
420        tokio::spawn(async move {
421            let mut stream = std::pin::pin!(resp.bytes_stream());
422            let mut buffer: Vec<u8> = Vec::new();
423
424            use tokio_stream::StreamExt;
425
426            while let Some(chunk) = stream.next().await {
427                let chunk = match chunk {
428                    Ok(c) => c,
429                    Err(e) => {
430                        let _ = tx.send(Err(KernelError::LlmApi(e.to_string()))).await;
431                        return;
432                    }
433                };
434
435                for line in drain_sse_lines(&mut buffer, &chunk) {
436                    if let Some(data) = parse_sse_line(&line)
437                        && let Some(event) = parse_openai_sse(data)
438                    {
439                        let is_done = matches!(event, StreamEvent::Done);
440                        if tx.send(Ok(event)).await.is_err() || is_done {
441                            return;
442                        }
443                    }
444                }
445            }
446            let _ = tx.send(Ok(StreamEvent::Done)).await;
447        });
448
449        Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
450    }
451}
452
453/// Extract the data payload from an SSE `data: ...` line.
454/// Returns `None` for non-data lines and for `data: [DONE]`.
455fn parse_sse_line(line: &str) -> Option<&str> {
456    line.strip_prefix("data: ").filter(|d| *d != "[DONE]")
457}
458
459/// Append a raw network chunk to `buffer` and drain every complete,
460/// newline-terminated line, decoded as UTF-8.
461///
462/// Decoding is deferred until a line's bytes are fully buffered. A single
463/// codepoint can straddle two network chunks, and decoding each chunk eagerly
464/// with [`String::from_utf8_lossy`] would replace the split bytes with `U+FFFD`
465/// — corrupting e.g. CJK or emoji deltas. Because `\n` (`0x0A`) is never a UTF-8
466/// lead or continuation byte, splitting on it can't cut a codepoint, so every
467/// drained line is a whole number of codepoints and decodes losslessly.
468fn drain_sse_lines(buffer: &mut Vec<u8>, chunk: &[u8]) -> Vec<String> {
469    buffer.extend_from_slice(chunk);
470    let mut lines = Vec::new();
471    while let Some(pos) = buffer.iter().position(|&b| b == b'\n') {
472        let line: Vec<u8> = buffer.drain(..=pos).collect();
473        lines.push(String::from_utf8_lossy(&line).trim_end().to_string());
474    }
475    lines
476}
477
478/// Parse an OpenAI streaming JSON chunk into a StreamEvent.
479fn parse_openai_sse(data: &str) -> Option<StreamEvent> {
480    let v: serde_json::Value = serde_json::from_str(data).ok()?;
481
482    // Extract delta content
483    if let Some(content) = v
484        .get("choices")?
485        .get(0)?
486        .get("delta")?
487        .get("content")
488        .and_then(|c| c.as_str())
489        && !content.is_empty()
490    {
491        return Some(StreamEvent::Delta {
492            content: content.to_string(),
493        });
494    }
495
496    // Extract usage from the final chunk
497    if let Some(usage) = v.get("usage").and_then(|u| {
498        Some(TokenUsage {
499            prompt_tokens: u.get("prompt_tokens")?.as_u64()? as u32,
500            completion_tokens: u.get("completion_tokens")?.as_u64()? as u32,
501            total_tokens: u.get("total_tokens")?.as_u64()? as u32,
502        })
503    }) {
504        return Some(StreamEvent::Usage(usage));
505    }
506
507    // finish_reason = "stop" means done (no more content in this chunk)
508    if v.get("choices")?
509        .get(0)?
510        .get("finish_reason")
511        .and_then(|r| r.as_str())
512        .is_some()
513    {
514        return Some(StreamEvent::Done);
515    }
516
517    None
518}
519
520/// Parse an Anthropic streaming JSON chunk into a StreamEvent.
521fn parse_anthropic_sse(event_type: &str, data: &str) -> Option<StreamEvent> {
522    let v: serde_json::Value = serde_json::from_str(data).ok()?;
523
524    match event_type {
525        "content_block_delta" => {
526            let text = v.get("delta")?.get("text")?.as_str()?;
527            if !text.is_empty() {
528                return Some(StreamEvent::Delta {
529                    content: text.to_string(),
530                });
531            }
532            None
533        }
534        "message_delta" => {
535            let usage = v.get("usage").and_then(|u| {
536                Some(TokenUsage {
537                    prompt_tokens: 0,
538                    completion_tokens: u.get("output_tokens")?.as_u64()? as u32,
539                    total_tokens: 0,
540                })
541            });
542            if let Some(usage) = usage {
543                return Some(StreamEvent::Usage(usage));
544            }
545            Some(StreamEvent::Done)
546        }
547        "message_stop" => Some(StreamEvent::Done),
548        _ => None,
549    }
550}
551
552/// Async LLM client for the Anthropic Messages API.
553pub struct AnthropicClient {
554    api_key: String,
555    model: String,
556    base_url: String,
557    client: reqwest::Client,
558}
559
560impl AnthropicClient {
561    /// Create a new client using credentials from the environment variable in `config`.
562    pub fn new(config: &ModelConfig) -> Result<Self> {
563        let api_key = std::env::var(&config.api_key_env).map_err(|_| {
564            KernelError::Config(format!(
565                "Environment variable {} not set",
566                config.api_key_env
567            ))
568        })?;
569        Ok(Self {
570            api_key,
571            model: config.model.clone(),
572            base_url: config
573                .base_url
574                .clone()
575                .unwrap_or_else(|| "https://api.anthropic.com/v1".into()),
576            client: http_client()?,
577        })
578    }
579
580    /// Create a new client with an explicit API key, using the default Anthropic base URL.
581    ///
582    /// Returns a [`KernelError::Config`] if the HTTP client (with its connect /
583    /// total timeouts) cannot be built, rather than silently falling back to a
584    /// timeout-less `reqwest::Client::default()`.
585    pub fn from_key(model: impl Into<String>, api_key: impl Into<String>) -> Result<Self> {
586        Ok(Self {
587            api_key: api_key.into(),
588            model: model.into(),
589            base_url: "https://api.anthropic.com/v1".into(),
590            client: http_client()?,
591        })
592    }
593
594    /// Create from an explicit key and a shared `reqwest::Client`.
595    pub fn from_key_with_client(
596        model: impl Into<String>,
597        api_key: impl Into<String>,
598        client: reqwest::Client,
599    ) -> Self {
600        Self {
601            api_key: api_key.into(),
602            model: model.into(),
603            base_url: "https://api.anthropic.com/v1".into(),
604            client,
605        }
606    }
607
608    /// Create from an explicit key, a custom base URL, and a shared `reqwest::Client`.
609    ///
610    /// Use this for Anthropic-compatible endpoints that are not the default
611    /// `api.anthropic.com` (self-hosted proxies, regional gateways, …).
612    pub fn from_key_with_base_url(
613        model: impl Into<String>,
614        api_key: impl Into<String>,
615        base_url: impl Into<String>,
616        client: reqwest::Client,
617    ) -> Self {
618        Self {
619            api_key: api_key.into(),
620            model: model.into(),
621            base_url: base_url.into(),
622            client,
623        }
624    }
625}
626
627#[derive(serde::Serialize)]
628struct AnthropicRequest {
629    model: String,
630    max_tokens: u32,
631    temperature: f32,
632    #[serde(skip_serializing_if = "Option::is_none")]
633    system: Option<String>,
634    messages: Vec<AnthropicMessage>,
635    #[serde(skip_serializing_if = "std::ops::Not::not")]
636    stream: bool,
637    #[serde(skip_serializing_if = "Option::is_none")]
638    tools: Option<Vec<serde_json::Value>>,
639    #[serde(skip_serializing_if = "Option::is_none")]
640    output_config: Option<serde_json::Value>,
641}
642
643#[derive(serde::Serialize)]
644struct AnthropicMessage {
645    role: String,
646    content: String,
647}
648
649#[derive(serde::Deserialize)]
650struct AnthropicResponse {
651    #[serde(default)]
652    id: Option<String>,
653    content: Vec<AnthropicContentBlock>,
654    model: String,
655    #[serde(default)]
656    stop_reason: Option<String>,
657    usage: AnthropicUsage,
658}
659
660/// A response content block. `text` blocks carry `text`; `tool_use` blocks
661/// carry `id`/`name`/`input`.
662#[derive(serde::Deserialize)]
663struct AnthropicContentBlock {
664    #[serde(rename = "type")]
665    block_type: String,
666    #[serde(default)]
667    text: Option<String>,
668    #[serde(default)]
669    id: Option<String>,
670    #[serde(default)]
671    name: Option<String>,
672    #[serde(default)]
673    input: Option<serde_json::Value>,
674}
675
676#[derive(serde::Deserialize)]
677struct AnthropicUsage {
678    input_tokens: u32,
679    output_tokens: u32,
680}
681
682#[async_trait]
683impl LLMClient for AnthropicClient {
684    async fn complete(&self, request: LLMRequest) -> Result<LLMResponse> {
685        let model = request.model.clone().unwrap_or_else(|| self.model.clone());
686        let max_tokens = request.max_tokens.unwrap_or(4096);
687        let temperature = request.temperature;
688        let system = request.system.clone();
689        let tools = request
690            .tools
691            .as_deref()
692            .map(anthropic_tools)
693            .filter(|t| !t.is_empty());
694        let output_config = request
695            .response_format
696            .as_ref()
697            .and_then(anthropic_output_config);
698        let messages: Vec<AnthropicMessage> = request
699            .into_anthropic_messages()
700            .into_iter()
701            .map(|(role, content)| AnthropicMessage { role, content })
702            .collect();
703
704        let body = AnthropicRequest {
705            model,
706            max_tokens,
707            temperature,
708            system,
709            messages,
710            stream: false,
711            tools,
712            output_config,
713        };
714
715        let resp = self
716            .client
717            .post(format!("{}/messages", self.base_url))
718            .header("x-api-key", &self.api_key)
719            .header("anthropic-version", "2023-06-01")
720            .header("content-type", "application/json")
721            .json(&body)
722            .send()
723            .await
724            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
725
726        check_rate_limit(&resp)?;
727
728        let status = resp.status();
729
730        if !status.is_success() {
731            let text = resp.text().await.unwrap_or_default();
732            return Err(KernelError::Http {
733                status: status.as_u16(),
734                message: redact_http_body(&text),
735            });
736        }
737
738        let chat_resp: AnthropicResponse = resp
739            .json()
740            .await
741            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
742
743        let mut content = String::new();
744        let mut tool_calls = Vec::new();
745        for block in chat_resp.content {
746            match block.block_type.as_str() {
747                "text" => {
748                    if let Some(t) = block.text {
749                        content.push_str(&t);
750                    }
751                }
752                "tool_use" => {
753                    if let (Some(id), Some(name)) = (block.id, block.name) {
754                        let arguments = block
755                            .input
756                            .map(|v| v.to_string())
757                            .unwrap_or_else(|| "{}".to_string());
758                        tool_calls.push(ToolCall {
759                            id,
760                            name,
761                            arguments,
762                        });
763                    }
764                }
765                _ => {}
766            }
767        }
768
769        Ok(LLMResponse {
770            content,
771            model: chat_resp.model,
772            usage: TokenUsage {
773                prompt_tokens: chat_resp.usage.input_tokens,
774                completion_tokens: chat_resp.usage.output_tokens,
775                total_tokens: chat_resp.usage.input_tokens + chat_resp.usage.output_tokens,
776            },
777            tool_calls,
778            finish_reason: chat_resp.stop_reason,
779            id: chat_resp.id,
780            created: None,
781        })
782    }
783
784    fn model_name(&self) -> &str {
785        &self.model
786    }
787
788    async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream> {
789        let model = request.model.clone().unwrap_or_else(|| self.model.clone());
790        let max_tokens = request.max_tokens.unwrap_or(4096);
791        let temperature = request.temperature;
792        let system = request.system.clone();
793        let messages: Vec<AnthropicMessage> = request
794            .into_anthropic_messages()
795            .into_iter()
796            .map(|(role, content)| AnthropicMessage { role, content })
797            .collect();
798
799        let body = AnthropicRequest {
800            model,
801            max_tokens,
802            temperature,
803            system,
804            messages,
805            stream: true,
806            // Streaming is text-only here: the SSE parser emits text deltas and
807            // does not reassemble streamed tool-use blocks.
808            tools: None,
809            output_config: None,
810        };
811
812        let resp = self
813            .client
814            .post(format!("{}/messages", self.base_url))
815            .header("x-api-key", &self.api_key)
816            .header("anthropic-version", "2023-06-01")
817            .header("content-type", "application/json")
818            .json(&body)
819            .send()
820            .await
821            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
822
823        check_rate_limit(&resp)?;
824
825        let status = resp.status();
826        if !status.is_success() {
827            let text = resp.text().await.unwrap_or_default();
828            return Err(KernelError::Http {
829                status: status.as_u16(),
830                message: redact_http_body(&text),
831            });
832        }
833
834        let (tx, rx) = tokio::sync::mpsc::channel::<Result<StreamEvent>>(16);
835
836        tokio::spawn(async move {
837            let mut stream = std::pin::pin!(resp.bytes_stream());
838            let mut buffer: Vec<u8> = Vec::new();
839            let mut current_event = String::new();
840
841            use tokio_stream::StreamExt;
842
843            while let Some(chunk) = stream.next().await {
844                let chunk = match chunk {
845                    Ok(c) => c,
846                    Err(e) => {
847                        let _ = tx.send(Err(KernelError::LlmApi(e.to_string()))).await;
848                        return;
849                    }
850                };
851
852                for line in drain_sse_lines(&mut buffer, &chunk) {
853                    if let Some(evt) = line.strip_prefix("event: ") {
854                        current_event = evt.to_string();
855                    } else if let Some(data) = line.strip_prefix("data: ") {
856                        if data == "[DONE]" {
857                            let _ = tx.send(Ok(StreamEvent::Done)).await;
858                            return;
859                        }
860                        if let Some(event) = parse_anthropic_sse(&current_event, data) {
861                            let is_done = matches!(event, StreamEvent::Done);
862                            if tx.send(Ok(event)).await.is_err() || is_done {
863                                return;
864                            }
865                        }
866                        current_event.clear();
867                    }
868                }
869            }
870            let _ = tx.send(Ok(StreamEvent::Done)).await;
871        });
872
873        Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
874    }
875}
876
877#[cfg(test)]
878mod tests {
879    use super::*;
880
881    #[test]
882    fn parse_sse_line_extracts_data() {
883        assert_eq!(
884            parse_sse_line("data: {\"id\":\"1\"}"),
885            Some("{\"id\":\"1\"}")
886        );
887    }
888
889    #[test]
890    fn parse_sse_line_skips_done() {
891        assert_eq!(parse_sse_line("data: [DONE]"), None);
892    }
893
894    #[test]
895    fn parse_sse_line_skips_non_data() {
896        assert_eq!(parse_sse_line("event: ping"), None);
897        assert_eq!(parse_sse_line(""), None);
898    }
899
900    #[test]
901    fn drain_sse_lines_reassembles_multibyte_split_across_chunks() {
902        // "data: 안녕\n" — "data: " is 6 bytes, 안/녕 are 3 bytes each.
903        let full = "data: 안녕\n".as_bytes().to_vec();
904        // Split at byte 7, mid-way through "안"'s 3-byte sequence.
905        let (first, rest) = full.split_at(7);
906
907        let mut buffer = Vec::new();
908        // No newline yet, and the trailing bytes are a partial codepoint:
909        // nothing should be emitted, and nothing should be corrupted.
910        assert!(drain_sse_lines(&mut buffer, first).is_empty());
911
912        let lines = drain_sse_lines(&mut buffer, rest);
913        assert_eq!(lines, vec!["data: 안녕".to_string()]);
914        // A per-chunk from_utf8_lossy would instead have produced U+FFFD here.
915        assert!(!lines[0].contains('\u{FFFD}'));
916    }
917
918    #[test]
919    fn drain_sse_lines_handles_multiple_lines_and_keeps_partial_tail() {
920        let mut buffer = Vec::new();
921        let lines = drain_sse_lines(&mut buffer, b"event: ping\r\ndata: {}\npartial");
922        assert_eq!(
923            lines,
924            vec!["event: ping".to_string(), "data: {}".to_string()]
925        );
926        // The unterminated "partial" tail stays buffered for the next chunk.
927        let lines = drain_sse_lines(&mut buffer, b" tail\n");
928        assert_eq!(lines, vec!["partial tail".to_string()]);
929    }
930
931    #[test]
932    fn openai_delta_extraction() {
933        let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#;
934        let event = parse_openai_sse(data).unwrap();
935        match event {
936            StreamEvent::Delta { content } => assert_eq!(content, "Hello"),
937            _ => panic!("expected Delta, got {:?}", event),
938        }
939    }
940
941    #[test]
942    fn openai_usage_extraction() {
943        let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}"#;
944        let event = parse_openai_sse(data).unwrap();
945        match event {
946            StreamEvent::Usage(usage) => {
947                assert_eq!(usage.prompt_tokens, 10);
948                assert_eq!(usage.completion_tokens, 5);
949                assert_eq!(usage.total_tokens, 15);
950            }
951            _ => panic!("expected Usage, got {:?}", event),
952        }
953    }
954
955    #[test]
956    fn openai_finish_reason_is_done() {
957        let data =
958            r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#;
959        let event = parse_openai_sse(data).unwrap();
960        assert!(matches!(event, StreamEvent::Done));
961    }
962
963    #[test]
964    fn openai_empty_delta_skipped() {
965        let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}]}"#;
966        assert!(parse_openai_sse(data).is_none());
967    }
968
969    #[test]
970    fn anthropic_content_block_delta() {
971        let data = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#;
972        let event = parse_anthropic_sse("content_block_delta", data).unwrap();
973        match event {
974            StreamEvent::Delta { content } => assert_eq!(content, "Hello"),
975            _ => panic!("expected Delta, got {:?}", event),
976        }
977    }
978
979    #[test]
980    fn anthropic_message_delta_usage() {
981        let data = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}"#;
982        let event = parse_anthropic_sse("message_delta", data).unwrap();
983        match event {
984            StreamEvent::Usage(usage) => assert_eq!(usage.completion_tokens, 5),
985            _ => panic!("expected Usage, got {:?}", event),
986        }
987    }
988
989    #[test]
990    fn anthropic_message_stop() {
991        let event = parse_anthropic_sse("message_stop", r#"{"type":"message_stop"}"#).unwrap();
992        assert!(matches!(event, StreamEvent::Done));
993    }
994
995    #[test]
996    fn anthropic_unknown_event_ignored() {
997        assert!(parse_anthropic_sse("ping", "{}").is_none());
998    }
999
1000    fn sample_tool() -> ToolDefinition {
1001        ToolDefinition {
1002            name: "get_weather".into(),
1003            description: "Get weather".into(),
1004            input_schema: serde_json::json!({
1005                "type": "object",
1006                "properties": { "location": { "type": "string" } },
1007                "required": ["location"]
1008            }),
1009        }
1010    }
1011
1012    #[test]
1013    fn openai_tools_use_function_wrapper() {
1014        let out = openai_tools(&[sample_tool()]);
1015        assert_eq!(out.len(), 1);
1016        assert_eq!(out[0]["type"], "function");
1017        assert_eq!(out[0]["function"]["name"], "get_weather");
1018        // input_schema is forwarded verbatim as `parameters`.
1019        assert_eq!(out[0]["function"]["parameters"]["required"][0], "location");
1020    }
1021
1022    #[test]
1023    fn openai_response_format_maps_each_variant() {
1024        assert!(openai_response_format(&ResponseFormat::Text).is_none());
1025        assert_eq!(
1026            openai_response_format(&ResponseFormat::Json).unwrap()["type"],
1027            "json_object"
1028        );
1029        let schema = serde_json::json!({"type": "object"});
1030        let js = openai_response_format(&ResponseFormat::JsonSchema { schema }).unwrap();
1031        assert_eq!(js["type"], "json_schema");
1032        assert_eq!(js["json_schema"]["strict"], true);
1033    }
1034
1035    #[test]
1036    fn anthropic_tools_use_input_schema_key() {
1037        let out = anthropic_tools(&[sample_tool()]);
1038        assert_eq!(out[0]["name"], "get_weather");
1039        assert_eq!(out[0]["input_schema"]["type"], "object");
1040        assert!(out[0].get("function").is_none());
1041    }
1042
1043    #[test]
1044    fn anthropic_output_config_only_for_json_schema() {
1045        assert!(anthropic_output_config(&ResponseFormat::Text).is_none());
1046        assert!(anthropic_output_config(&ResponseFormat::Json).is_none());
1047        let schema = serde_json::json!({"type": "object"});
1048        let cfg = anthropic_output_config(&ResponseFormat::JsonSchema { schema }).unwrap();
1049        assert_eq!(cfg["format"]["type"], "json_schema");
1050    }
1051
1052    #[test]
1053    fn openai_request_serializes_tools_and_format() {
1054        let body = OpenAIChatRequest {
1055            model: "gpt-4o".into(),
1056            messages: vec![OpenAIChatMessage {
1057                role: "user".into(),
1058                content: "hi".into(),
1059            }],
1060            temperature: 0.7,
1061            max_tokens: None,
1062            stream: false,
1063            tools: Some(openai_tools(&[sample_tool()])),
1064            response_format: Some(serde_json::json!({ "type": "json_object" })),
1065        };
1066        let json = serde_json::to_value(&body).unwrap();
1067        assert_eq!(json["tools"][0]["function"]["name"], "get_weather");
1068        assert_eq!(json["response_format"]["type"], "json_object");
1069        // Omitted when None (backward-compatible request shape).
1070        assert!(json.get("max_tokens").is_none());
1071    }
1072
1073    #[test]
1074    fn openai_response_parses_tool_calls() {
1075        let raw = r#"{
1076            "id": "chatcmpl-1",
1077            "created": 1700000000,
1078            "model": "gpt-4o",
1079            "choices": [{
1080                "index": 0,
1081                "message": {
1082                    "role": "assistant",
1083                    "content": null,
1084                    "tool_calls": [{
1085                        "id": "call_abc",
1086                        "type": "function",
1087                        "function": { "name": "get_weather", "arguments": "{\"location\":\"Paris\"}" }
1088                    }]
1089                },
1090                "finish_reason": "tool_calls"
1091            }],
1092            "usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15 }
1093        }"#;
1094        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1095        assert_eq!(resp.id.as_deref(), Some("chatcmpl-1"));
1096        let choice = resp.choices.into_iter().next().unwrap();
1097        assert_eq!(choice.finish_reason.as_deref(), Some("tool_calls"));
1098        assert!(choice.message.content.is_none());
1099        assert_eq!(choice.message.tool_calls.len(), 1);
1100        assert_eq!(choice.message.tool_calls[0].function.name, "get_weather");
1101    }
1102
1103    #[test]
1104    fn anthropic_response_parses_tool_use_block() {
1105        let raw = r#"{
1106            "id": "msg_1",
1107            "model": "claude-sonnet-4-6",
1108            "stop_reason": "tool_use",
1109            "content": [
1110                { "type": "text", "text": "Let me check." },
1111                { "type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": { "location": "Paris" } }
1112            ],
1113            "usage": { "input_tokens": 12, "output_tokens": 8 }
1114        }"#;
1115        let resp: AnthropicResponse = serde_json::from_str(raw).unwrap();
1116        assert_eq!(resp.stop_reason.as_deref(), Some("tool_use"));
1117        assert_eq!(resp.content.len(), 2);
1118        assert_eq!(resp.content[0].block_type, "text");
1119        assert_eq!(resp.content[1].block_type, "tool_use");
1120        assert_eq!(resp.content[1].name.as_deref(), Some("get_weather"));
1121        assert_eq!(resp.content[1].input.as_ref().unwrap()["location"], "Paris");
1122    }
1123}