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