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 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
28fn 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
41fn 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
55fn 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
67fn 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
76fn 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#[async_trait]
92pub trait LLMClient: Send + Sync {
93 async fn complete(&self, request: LLMRequest) -> Result<LLMResponse>;
95 fn model_name(&self) -> &str;
97
98 async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream>;
100}
101
102pub struct OpenAIClient {
104 api_key: String,
105 model: String,
106 base_url: String,
107 client: reqwest::Client,
108}
109
110impl OpenAIClient {
111 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 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 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#[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 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
409fn parse_sse_line(line: &str) -> Option<&str> {
412 line.strip_prefix("data: ").filter(|d| *d != "[DONE]")
413}
414
415fn 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
434fn parse_openai_sse(data: &str) -> Option<StreamEvent> {
436 let v: serde_json::Value = serde_json::from_str(data).ok()?;
437
438 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 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 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
476fn 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
508pub struct AnthropicClient {
510 api_key: String,
511 model: String,
512 base_url: String,
513 client: reqwest::Client,
514}
515
516impl AnthropicClient {
517 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 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 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#[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 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(¤t_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 let full = "data: 안녕\n".as_bytes().to_vec();
842 let (first, rest) = full.split_at(7);
844
845 let mut buffer = Vec::new();
846 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 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 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 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 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}