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