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
11fn 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
28fn 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
45fn 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
58fn 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
72fn 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
84fn 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
93fn 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#[async_trait]
109pub trait LLMClient: Send + Sync {
110 async fn complete(&self, request: LLMRequest) -> Result<LLMResponse>;
112 fn model_name(&self) -> &str;
114
115 async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream>;
117}
118
119pub struct OpenAIClient {
121 api_key: String,
122 model: String,
123 base_url: String,
124 client: reqwest::Client,
125}
126
127impl OpenAIClient {
128 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 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 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 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#[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 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
453fn parse_sse_line(line: &str) -> Option<&str> {
456 line.strip_prefix("data: ").filter(|d| *d != "[DONE]")
457}
458
459fn 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
478fn parse_openai_sse(data: &str) -> Option<StreamEvent> {
480 let v: serde_json::Value = serde_json::from_str(data).ok()?;
481
482 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 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 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
520fn 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
552pub struct AnthropicClient {
554 api_key: String,
555 model: String,
556 base_url: String,
557 client: reqwest::Client,
558}
559
560impl AnthropicClient {
561 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 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 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 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#[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 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(¤t_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 let full = "data: 안녕\n".as_bytes().to_vec();
904 let (first, rest) = full.split_at(7);
906
907 let mut buffer = Vec::new();
908 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 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 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 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 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}