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