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 crate::tls::ensure_tls_provider();
87 reqwest::Client::builder()
88 .connect_timeout(Duration::from_secs(10))
89 .timeout(Duration::from_secs(120))
90 .build()
91 .map_err(|e| KernelError::Config(format!("Failed to build HTTP client: {}", e)))
92}
93
94fn check_rate_limit(resp: &reqwest::Response) -> Result<()> {
96 if resp.status().as_u16() == 429 {
97 let retry = resp
98 .headers()
99 .get("retry-after")
100 .and_then(|v| v.to_str().ok())
101 .and_then(|v| v.parse().ok())
102 .unwrap_or(60);
103 return Err(KernelError::RateLimited(retry));
104 }
105 Ok(())
106}
107
108#[async_trait]
110pub trait LLMClient: Send + Sync {
111 async fn complete(&self, request: LLMRequest) -> Result<LLMResponse>;
123 fn model_name(&self) -> &str;
125
126 async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream>;
133}
134
135pub struct OpenAIClient {
137 api_key: String,
138 model: String,
139 base_url: String,
140 client: reqwest::Client,
141}
142
143impl OpenAIClient {
144 pub fn new(config: &ModelConfig) -> Result<Self> {
146 let api_key = std::env::var(&config.api_key_env).map_err(|_| {
147 KernelError::Config(format!(
148 "Environment variable {} not set",
149 config.api_key_env
150 ))
151 })?;
152 Ok(Self {
153 api_key,
154 model: config.model.clone(),
155 base_url: config
156 .base_url
157 .clone()
158 .unwrap_or_else(|| "https://api.openai.com/v1".into()),
159 client: http_client()?,
160 })
161 }
162
163 pub fn from_key(model: impl Into<String>, api_key: impl Into<String>) -> Result<Self> {
177 Ok(Self {
178 api_key: api_key.into(),
179 model: model.into(),
180 base_url: "https://api.openai.com/v1".into(),
181 client: http_client()?,
182 })
183 }
184
185 pub fn from_key_with_client(
191 model: impl Into<String>,
192 api_key: impl Into<String>,
193 client: reqwest::Client,
194 ) -> Self {
195 Self {
196 api_key: api_key.into(),
197 model: model.into(),
198 base_url: "https://api.openai.com/v1".into(),
199 client,
200 }
201 }
202
203 pub fn from_key_with_base_url(
209 model: impl Into<String>,
210 api_key: impl Into<String>,
211 base_url: impl Into<String>,
212 client: reqwest::Client,
213 ) -> Self {
214 Self {
215 api_key: api_key.into(),
216 model: model.into(),
217 base_url: base_url.into(),
218 client,
219 }
220 }
221}
222
223#[derive(serde::Serialize)]
224struct OpenAIChatRequest {
225 model: String,
226 messages: Vec<OpenAIChatMessage>,
227 temperature: f32,
228 #[serde(skip_serializing_if = "Option::is_none")]
229 max_tokens: Option<u32>,
230 #[serde(skip_serializing_if = "std::ops::Not::not")]
231 stream: bool,
232 #[serde(skip_serializing_if = "Option::is_none")]
233 tools: Option<Vec<serde_json::Value>>,
234 #[serde(skip_serializing_if = "Option::is_none")]
235 response_format: Option<serde_json::Value>,
236}
237
238#[derive(serde::Serialize)]
239struct OpenAIChatMessage {
240 role: String,
241 content: String,
242}
243
244#[derive(serde::Deserialize)]
245struct OpenAIChatResponse {
246 #[serde(default)]
247 id: Option<String>,
248 #[serde(default)]
249 created: Option<u64>,
250 choices: Vec<OpenAIChoice>,
251 model: String,
252 usage: Option<OpenAIUsage>,
253}
254
255#[derive(serde::Deserialize)]
256struct OpenAIChoice {
257 message: OpenAIRespMessage,
258 #[serde(default)]
259 finish_reason: Option<String>,
260}
261
262#[derive(serde::Deserialize)]
269struct OpenAIRespMessage {
270 #[serde(default)]
271 content: Option<String>,
272 #[serde(default, alias = "reasoning")]
274 reasoning_content: Option<String>,
275 #[serde(default)]
276 tool_calls: Vec<OpenAIToolCall>,
277}
278
279#[derive(serde::Deserialize)]
280struct OpenAIToolCall {
281 id: String,
282 function: OpenAIFunctionCall,
283}
284
285#[derive(serde::Deserialize)]
286struct OpenAIFunctionCall {
287 name: String,
288 #[serde(default)]
289 arguments: String,
290}
291
292#[derive(serde::Deserialize)]
298struct OpenAIUsage {
299 #[serde(default)]
300 prompt_tokens: u32,
301 #[serde(default)]
302 completion_tokens: u32,
303 #[serde(default)]
304 total_tokens: u32,
305 #[serde(default)]
307 completion_tokens_details: Option<OpenAICompletionTokensDetails>,
308}
309
310#[derive(serde::Deserialize)]
312struct OpenAICompletionTokensDetails {
313 #[serde(default)]
314 reasoning_tokens: Option<u32>,
315}
316
317#[async_trait]
318impl LLMClient for OpenAIClient {
319 async fn complete(&self, request: LLMRequest) -> Result<LLMResponse> {
320 let model = request.model.clone().unwrap_or_else(|| self.model.clone());
321 let temperature = request.temperature;
322 let max_tokens = request.max_tokens;
323 let tools = request
324 .tools
325 .as_deref()
326 .map(openai_tools)
327 .filter(|t| !t.is_empty());
328 let response_format = request
329 .response_format
330 .as_ref()
331 .and_then(openai_response_format);
332 let messages: Vec<_> = request
333 .into_openai_messages()
334 .into_iter()
335 .map(|(role, content)| OpenAIChatMessage { role, content })
336 .collect();
337
338 let body = OpenAIChatRequest {
339 model,
340 messages,
341 temperature,
342 max_tokens,
343 stream: false,
344 tools,
345 response_format,
346 };
347
348 let resp = self
349 .client
350 .post(format!("{}/chat/completions", self.base_url))
351 .header("Authorization", format!("Bearer {}", self.api_key))
352 .json(&body)
353 .send()
354 .await
355 .map_err(|e| KernelError::LlmApi(e.to_string()))?;
356
357 check_rate_limit(&resp)?;
358
359 let status = resp.status();
360
361 if !status.is_success() {
362 let text = resp.text().await.unwrap_or_default();
363 return Err(KernelError::Http {
364 status: status.as_u16(),
365 message: redact_http_body(&text),
366 });
367 }
368
369 let chat_resp: OpenAIChatResponse = resp
370 .json()
371 .await
372 .map_err(|e| KernelError::LlmApi(e.to_string()))?;
373
374 let id = chat_resp.id;
375 let created = chat_resp.created;
376 let first = chat_resp.choices.into_iter().next();
377 let finish_reason = first.as_ref().and_then(|c| c.finish_reason.clone());
378 let (content, reasoning, tool_calls) = match first {
379 Some(c) => {
380 let raw_content = c.message.content.unwrap_or_default();
381 let reasoning = c.message.reasoning_content;
382 let content = promote_reasoning_into_content(raw_content, reasoning.as_deref());
383 let calls = c
384 .message
385 .tool_calls
386 .into_iter()
387 .map(|tc| ToolCall {
388 id: tc.id,
389 name: tc.function.name,
390 arguments: tc.function.arguments,
391 })
392 .collect();
393 (content, reasoning, calls)
394 }
395 None => (String::new(), None, Vec::new()),
396 };
397
398 let usage = chat_resp.usage.map(|u| TokenUsage {
399 prompt_tokens: u.prompt_tokens,
400 completion_tokens: u.completion_tokens,
401 total_tokens: u.total_tokens,
402 reasoning_tokens: u.completion_tokens_details.and_then(|d| d.reasoning_tokens),
403 });
404
405 Ok(LLMResponse {
406 content,
407 reasoning,
408 model: chat_resp.model,
409 usage: usage.unwrap_or_default(),
410 tool_calls,
411 finish_reason,
412 id,
413 created,
414 })
415 }
416
417 fn model_name(&self) -> &str {
418 &self.model
419 }
420
421 async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream> {
422 let model = request.model.clone().unwrap_or_else(|| self.model.clone());
423 let temperature = request.temperature;
424 let max_tokens = request.max_tokens;
425 let messages: Vec<_> = request
426 .into_openai_messages()
427 .into_iter()
428 .map(|(role, content)| OpenAIChatMessage { role, content })
429 .collect();
430
431 let body = OpenAIChatRequest {
432 model,
433 messages,
434 temperature,
435 max_tokens,
436 stream: true,
437 tools: None,
440 response_format: None,
441 };
442
443 let resp = self
444 .client
445 .post(format!("{}/chat/completions", self.base_url))
446 .header("Authorization", format!("Bearer {}", self.api_key))
447 .json(&body)
448 .send()
449 .await
450 .map_err(|e| KernelError::LlmApi(e.to_string()))?;
451
452 check_rate_limit(&resp)?;
453
454 let status = resp.status();
455 if !status.is_success() {
456 let text = resp.text().await.unwrap_or_default();
457 return Err(KernelError::Http {
458 status: status.as_u16(),
459 message: redact_http_body(&text),
460 });
461 }
462
463 let (tx, rx) = tokio::sync::mpsc::channel::<Result<StreamEvent>>(16);
464
465 tokio::spawn(async move {
466 let mut stream = std::pin::pin!(resp.bytes_stream());
467 let mut buffer: Vec<u8> = Vec::new();
468
469 use tokio_stream::StreamExt;
470
471 while let Some(chunk) = stream.next().await {
472 let chunk = match chunk {
473 Ok(c) => c,
474 Err(e) => {
475 let _ = tx.send(Err(KernelError::LlmApi(e.to_string()))).await;
476 return;
477 }
478 };
479
480 for line in drain_sse_lines(&mut buffer, &chunk) {
481 if let Some(data) = parse_sse_line(&line)
482 && let Some(event) = parse_openai_sse(data)
483 {
484 let is_done = matches!(event, StreamEvent::Done);
485 if tx.send(Ok(event)).await.is_err() || is_done {
486 return;
487 }
488 }
489 }
490 }
491 let _ = tx.send(Ok(StreamEvent::Done)).await;
492 });
493
494 Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
495 }
496}
497
498fn parse_sse_line(line: &str) -> Option<&str> {
501 line.strip_prefix("data: ").filter(|d| *d != "[DONE]")
502}
503
504fn drain_sse_lines(buffer: &mut Vec<u8>, chunk: &[u8]) -> Vec<String> {
514 buffer.extend_from_slice(chunk);
515 let mut lines = Vec::new();
516 while let Some(pos) = buffer.iter().position(|&b| b == b'\n') {
517 let line: Vec<u8> = buffer.drain(..=pos).collect();
518 lines.push(String::from_utf8_lossy(&line).trim_end().to_string());
519 }
520 lines
521}
522
523fn promote_reasoning_into_content(raw_content: String, reasoning: Option<&str>) -> String {
544 if raw_content.is_empty() {
545 reasoning.unwrap_or_default().to_string()
546 } else {
547 raw_content
548 }
549}
550
551fn parse_openai_sse(data: &str) -> Option<StreamEvent> {
553 let v: serde_json::Value = serde_json::from_str(data).ok()?;
554
555 if let Some(rc) = v
558 .get("choices")?
559 .get(0)?
560 .get("delta")?
561 .get("reasoning_content")
562 .and_then(|c| c.as_str())
563 && !rc.is_empty()
564 {
565 return Some(StreamEvent::ReasoningDelta {
566 content: rc.to_string(),
567 });
568 }
569
570 if let Some(content) = v
572 .get("choices")?
573 .get(0)?
574 .get("delta")?
575 .get("content")
576 .and_then(|c| c.as_str())
577 && !content.is_empty()
578 {
579 return Some(StreamEvent::Delta {
580 content: content.to_string(),
581 });
582 }
583
584 if let Some(usage) = v.get("usage").and_then(|u| {
586 Some(TokenUsage {
587 prompt_tokens: u.get("prompt_tokens")?.as_u64()? as u32,
588 completion_tokens: u.get("completion_tokens")?.as_u64()? as u32,
589 total_tokens: u.get("total_tokens")?.as_u64()? as u32,
590 reasoning_tokens: u
591 .get("completion_tokens_details")
592 .and_then(|d| d.get("reasoning_tokens"))
593 .and_then(|r| r.as_u64())
594 .map(|n| n as u32),
595 })
596 }) {
597 return Some(StreamEvent::Usage(usage));
598 }
599
600 if v.get("choices")?
602 .get(0)?
603 .get("finish_reason")
604 .and_then(|r| r.as_str())
605 .is_some()
606 {
607 return Some(StreamEvent::Done);
608 }
609
610 None
611}
612
613fn parse_anthropic_sse(event_type: &str, data: &str) -> Option<StreamEvent> {
615 let v: serde_json::Value = serde_json::from_str(data).ok()?;
616
617 match event_type {
618 "content_block_delta" => {
619 let delta = v.get("delta")?;
620 match delta.get("type").and_then(|t| t.as_str()) {
622 Some("thinking_delta") => {
623 let text = delta.get("thinking")?.as_str()?;
624 if !text.is_empty() {
625 return Some(StreamEvent::ReasoningDelta {
626 content: text.to_string(),
627 });
628 }
629 None
630 }
631 _ => {
632 let text = delta.get("text")?.as_str()?;
634 if !text.is_empty() {
635 return Some(StreamEvent::Delta {
636 content: text.to_string(),
637 });
638 }
639 None
640 }
641 }
642 }
643 "message_delta" => {
644 let usage = v.get("usage").and_then(|u| {
645 Some(TokenUsage {
646 prompt_tokens: 0,
647 completion_tokens: u.get("output_tokens")?.as_u64()? as u32,
648 total_tokens: 0,
649 reasoning_tokens: None,
650 })
651 });
652 if let Some(usage) = usage {
653 return Some(StreamEvent::Usage(usage));
654 }
655 Some(StreamEvent::Done)
656 }
657 "message_stop" => Some(StreamEvent::Done),
658 _ => None,
659 }
660}
661
662pub struct AnthropicClient {
664 api_key: String,
665 model: String,
666 base_url: String,
667 client: reqwest::Client,
668}
669
670impl AnthropicClient {
671 pub fn new(config: &ModelConfig) -> Result<Self> {
673 let api_key = std::env::var(&config.api_key_env).map_err(|_| {
674 KernelError::Config(format!(
675 "Environment variable {} not set",
676 config.api_key_env
677 ))
678 })?;
679 Ok(Self {
680 api_key,
681 model: config.model.clone(),
682 base_url: config
683 .base_url
684 .clone()
685 .unwrap_or_else(|| "https://api.anthropic.com/v1".into()),
686 client: http_client()?,
687 })
688 }
689
690 pub fn from_key(model: impl Into<String>, api_key: impl Into<String>) -> Result<Self> {
696 Ok(Self {
697 api_key: api_key.into(),
698 model: model.into(),
699 base_url: "https://api.anthropic.com/v1".into(),
700 client: http_client()?,
701 })
702 }
703
704 pub fn from_key_with_client(
706 model: impl Into<String>,
707 api_key: impl Into<String>,
708 client: reqwest::Client,
709 ) -> Self {
710 Self {
711 api_key: api_key.into(),
712 model: model.into(),
713 base_url: "https://api.anthropic.com/v1".into(),
714 client,
715 }
716 }
717
718 pub fn from_key_with_base_url(
723 model: impl Into<String>,
724 api_key: impl Into<String>,
725 base_url: impl Into<String>,
726 client: reqwest::Client,
727 ) -> Self {
728 Self {
729 api_key: api_key.into(),
730 model: model.into(),
731 base_url: base_url.into(),
732 client,
733 }
734 }
735}
736
737#[derive(serde::Serialize)]
738struct AnthropicRequest {
739 model: String,
740 max_tokens: u32,
741 temperature: f32,
742 #[serde(skip_serializing_if = "Option::is_none")]
743 system: Option<String>,
744 messages: Vec<AnthropicMessage>,
745 #[serde(skip_serializing_if = "std::ops::Not::not")]
746 stream: bool,
747 #[serde(skip_serializing_if = "Option::is_none")]
748 tools: Option<Vec<serde_json::Value>>,
749 #[serde(skip_serializing_if = "Option::is_none")]
750 output_config: Option<serde_json::Value>,
751}
752
753#[derive(serde::Serialize)]
754struct AnthropicMessage {
755 role: String,
756 content: String,
757}
758
759#[derive(serde::Deserialize)]
760struct AnthropicResponse {
761 #[serde(default)]
762 id: Option<String>,
763 content: Vec<AnthropicContentBlock>,
764 model: String,
765 #[serde(default)]
766 stop_reason: Option<String>,
767 usage: AnthropicUsage,
768}
769
770#[derive(serde::Deserialize)]
773struct AnthropicContentBlock {
774 #[serde(rename = "type")]
775 block_type: String,
776 #[serde(default)]
777 text: Option<String>,
778 #[serde(default)]
780 thinking: Option<String>,
781 #[serde(default)]
782 id: Option<String>,
783 #[serde(default)]
784 name: Option<String>,
785 #[serde(default)]
786 input: Option<serde_json::Value>,
787}
788
789#[derive(serde::Deserialize)]
790struct AnthropicUsage {
791 input_tokens: u32,
792 output_tokens: u32,
793}
794
795#[async_trait]
796impl LLMClient for AnthropicClient {
797 async fn complete(&self, request: LLMRequest) -> Result<LLMResponse> {
798 let model = request.model.clone().unwrap_or_else(|| self.model.clone());
799 let max_tokens = request.max_tokens.unwrap_or(4096);
800 let temperature = request.temperature;
801 let system = request.system.clone();
802 let tools = request
803 .tools
804 .as_deref()
805 .map(anthropic_tools)
806 .filter(|t| !t.is_empty());
807 let output_config = request
808 .response_format
809 .as_ref()
810 .and_then(anthropic_output_config);
811 let messages: Vec<AnthropicMessage> = request
812 .into_anthropic_messages()
813 .into_iter()
814 .map(|(role, content)| AnthropicMessage { role, content })
815 .collect();
816
817 let body = AnthropicRequest {
818 model,
819 max_tokens,
820 temperature,
821 system,
822 messages,
823 stream: false,
824 tools,
825 output_config,
826 };
827
828 let resp = self
829 .client
830 .post(format!("{}/messages", self.base_url))
831 .header("x-api-key", &self.api_key)
832 .header("anthropic-version", "2023-06-01")
833 .header("content-type", "application/json")
834 .json(&body)
835 .send()
836 .await
837 .map_err(|e| KernelError::LlmApi(e.to_string()))?;
838
839 check_rate_limit(&resp)?;
840
841 let status = resp.status();
842
843 if !status.is_success() {
844 let text = resp.text().await.unwrap_or_default();
845 return Err(KernelError::Http {
846 status: status.as_u16(),
847 message: redact_http_body(&text),
848 });
849 }
850
851 let chat_resp: AnthropicResponse = resp
852 .json()
853 .await
854 .map_err(|e| KernelError::LlmApi(e.to_string()))?;
855
856 let mut content = String::new();
857 let mut reasoning = String::new();
858 let mut tool_calls = Vec::new();
859 for block in chat_resp.content {
860 match block.block_type.as_str() {
861 "text" => {
862 if let Some(t) = block.text {
863 content.push_str(&t);
864 }
865 }
866 "thinking" => {
867 if let Some(t) = block.thinking {
868 reasoning.push_str(&t);
869 }
870 }
871 "tool_use" => {
872 if let (Some(id), Some(name)) = (block.id, block.name) {
873 let arguments = block
874 .input
875 .map(|v| v.to_string())
876 .unwrap_or_else(|| "{}".to_string());
877 tool_calls.push(ToolCall {
878 id,
879 name,
880 arguments,
881 });
882 }
883 }
884 _ => {}
885 }
886 }
887
888 Ok(LLMResponse {
889 content,
890 reasoning: if reasoning.is_empty() {
891 None
892 } else {
893 Some(reasoning)
894 },
895 model: chat_resp.model,
896 usage: TokenUsage {
897 prompt_tokens: chat_resp.usage.input_tokens,
898 completion_tokens: chat_resp.usage.output_tokens,
899 total_tokens: chat_resp.usage.input_tokens + chat_resp.usage.output_tokens,
900 reasoning_tokens: None,
901 },
902 tool_calls,
903 finish_reason: chat_resp.stop_reason,
904 id: chat_resp.id,
905 created: None,
906 })
907 }
908
909 fn model_name(&self) -> &str {
910 &self.model
911 }
912
913 async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream> {
914 let model = request.model.clone().unwrap_or_else(|| self.model.clone());
915 let max_tokens = request.max_tokens.unwrap_or(4096);
916 let temperature = request.temperature;
917 let system = request.system.clone();
918 let messages: Vec<AnthropicMessage> = request
919 .into_anthropic_messages()
920 .into_iter()
921 .map(|(role, content)| AnthropicMessage { role, content })
922 .collect();
923
924 let body = AnthropicRequest {
925 model,
926 max_tokens,
927 temperature,
928 system,
929 messages,
930 stream: true,
931 tools: None,
934 output_config: None,
935 };
936
937 let resp = self
938 .client
939 .post(format!("{}/messages", self.base_url))
940 .header("x-api-key", &self.api_key)
941 .header("anthropic-version", "2023-06-01")
942 .header("content-type", "application/json")
943 .json(&body)
944 .send()
945 .await
946 .map_err(|e| KernelError::LlmApi(e.to_string()))?;
947
948 check_rate_limit(&resp)?;
949
950 let status = resp.status();
951 if !status.is_success() {
952 let text = resp.text().await.unwrap_or_default();
953 return Err(KernelError::Http {
954 status: status.as_u16(),
955 message: redact_http_body(&text),
956 });
957 }
958
959 let (tx, rx) = tokio::sync::mpsc::channel::<Result<StreamEvent>>(16);
960
961 tokio::spawn(async move {
962 let mut stream = std::pin::pin!(resp.bytes_stream());
963 let mut buffer: Vec<u8> = Vec::new();
964 let mut current_event = String::new();
965
966 use tokio_stream::StreamExt;
967
968 while let Some(chunk) = stream.next().await {
969 let chunk = match chunk {
970 Ok(c) => c,
971 Err(e) => {
972 let _ = tx.send(Err(KernelError::LlmApi(e.to_string()))).await;
973 return;
974 }
975 };
976
977 for line in drain_sse_lines(&mut buffer, &chunk) {
978 if let Some(evt) = line.strip_prefix("event: ") {
979 current_event = evt.to_string();
980 } else if let Some(data) = line.strip_prefix("data: ") {
981 if data == "[DONE]" {
982 let _ = tx.send(Ok(StreamEvent::Done)).await;
983 return;
984 }
985 if let Some(event) = parse_anthropic_sse(¤t_event, data) {
986 let is_done = matches!(event, StreamEvent::Done);
987 if tx.send(Ok(event)).await.is_err() || is_done {
988 return;
989 }
990 }
991 current_event.clear();
992 }
993 }
994 }
995 let _ = tx.send(Ok(StreamEvent::Done)).await;
996 });
997
998 Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
999 }
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004 use super::*;
1005
1006 #[test]
1007 fn parse_sse_line_extracts_data() {
1008 assert_eq!(
1009 parse_sse_line("data: {\"id\":\"1\"}"),
1010 Some("{\"id\":\"1\"}")
1011 );
1012 }
1013
1014 #[test]
1015 fn parse_sse_line_skips_done() {
1016 assert_eq!(parse_sse_line("data: [DONE]"), None);
1017 }
1018
1019 #[test]
1020 fn parse_sse_line_skips_non_data() {
1021 assert_eq!(parse_sse_line("event: ping"), None);
1022 assert_eq!(parse_sse_line(""), None);
1023 }
1024
1025 #[test]
1026 fn drain_sse_lines_reassembles_multibyte_split_across_chunks() {
1027 let full = "data: 안녕\n".as_bytes().to_vec();
1029 let (first, rest) = full.split_at(7);
1031
1032 let mut buffer = Vec::new();
1033 assert!(drain_sse_lines(&mut buffer, first).is_empty());
1036
1037 let lines = drain_sse_lines(&mut buffer, rest);
1038 assert_eq!(lines, vec!["data: 안녕".to_string()]);
1039 assert!(!lines[0].contains('\u{FFFD}'));
1041 }
1042
1043 #[test]
1044 fn drain_sse_lines_handles_multiple_lines_and_keeps_partial_tail() {
1045 let mut buffer = Vec::new();
1046 let lines = drain_sse_lines(&mut buffer, b"event: ping\r\ndata: {}\npartial");
1047 assert_eq!(
1048 lines,
1049 vec!["event: ping".to_string(), "data: {}".to_string()]
1050 );
1051 let lines = drain_sse_lines(&mut buffer, b" tail\n");
1053 assert_eq!(lines, vec!["partial tail".to_string()]);
1054 }
1055
1056 #[test]
1057 fn openai_delta_extraction() {
1058 let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#;
1059 let event = parse_openai_sse(data).unwrap();
1060 match event {
1061 StreamEvent::Delta { content } => assert_eq!(content, "Hello"),
1062 _ => panic!("expected Delta, got {:?}", event),
1063 }
1064 }
1065
1066 #[test]
1067 fn openai_usage_extraction() {
1068 let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}"#;
1069 let event = parse_openai_sse(data).unwrap();
1070 match event {
1071 StreamEvent::Usage(usage) => {
1072 assert_eq!(usage.prompt_tokens, 10);
1073 assert_eq!(usage.completion_tokens, 5);
1074 assert_eq!(usage.total_tokens, 15);
1075 }
1076 _ => panic!("expected Usage, got {:?}", event),
1077 }
1078 }
1079
1080 #[test]
1081 fn openai_finish_reason_is_done() {
1082 let data =
1083 r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#;
1084 let event = parse_openai_sse(data).unwrap();
1085 assert!(matches!(event, StreamEvent::Done));
1086 }
1087
1088 #[test]
1089 fn openai_empty_delta_skipped() {
1090 let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}]}"#;
1091 assert!(parse_openai_sse(data).is_none());
1092 }
1093
1094 #[test]
1095 fn anthropic_content_block_delta() {
1096 let data = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#;
1097 let event = parse_anthropic_sse("content_block_delta", data).unwrap();
1098 match event {
1099 StreamEvent::Delta { content } => assert_eq!(content, "Hello"),
1100 _ => panic!("expected Delta, got {:?}", event),
1101 }
1102 }
1103
1104 #[test]
1105 fn anthropic_message_delta_usage() {
1106 let data = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}"#;
1107 let event = parse_anthropic_sse("message_delta", data).unwrap();
1108 match event {
1109 StreamEvent::Usage(usage) => assert_eq!(usage.completion_tokens, 5),
1110 _ => panic!("expected Usage, got {:?}", event),
1111 }
1112 }
1113
1114 #[test]
1115 fn anthropic_message_stop() {
1116 let event = parse_anthropic_sse("message_stop", r#"{"type":"message_stop"}"#).unwrap();
1117 assert!(matches!(event, StreamEvent::Done));
1118 }
1119
1120 #[test]
1121 fn anthropic_unknown_event_ignored() {
1122 assert!(parse_anthropic_sse("ping", "{}").is_none());
1123 }
1124
1125 fn sample_tool() -> ToolDefinition {
1126 ToolDefinition {
1127 name: "get_weather".into(),
1128 description: "Get weather".into(),
1129 input_schema: serde_json::json!({
1130 "type": "object",
1131 "properties": { "location": { "type": "string" } },
1132 "required": ["location"]
1133 }),
1134 }
1135 }
1136
1137 #[test]
1138 fn openai_tools_use_function_wrapper() {
1139 let out = openai_tools(&[sample_tool()]);
1140 assert_eq!(out.len(), 1);
1141 assert_eq!(out[0]["type"], "function");
1142 assert_eq!(out[0]["function"]["name"], "get_weather");
1143 assert_eq!(out[0]["function"]["parameters"]["required"][0], "location");
1145 }
1146
1147 #[test]
1148 fn openai_response_format_maps_each_variant() {
1149 assert!(openai_response_format(&ResponseFormat::Text).is_none());
1150 assert_eq!(
1151 openai_response_format(&ResponseFormat::Json).unwrap()["type"],
1152 "json_object"
1153 );
1154 let schema = serde_json::json!({"type": "object"});
1155 let js = openai_response_format(&ResponseFormat::JsonSchema { schema }).unwrap();
1156 assert_eq!(js["type"], "json_schema");
1157 assert_eq!(js["json_schema"]["strict"], true);
1158 }
1159
1160 #[test]
1161 fn anthropic_tools_use_input_schema_key() {
1162 let out = anthropic_tools(&[sample_tool()]);
1163 assert_eq!(out[0]["name"], "get_weather");
1164 assert_eq!(out[0]["input_schema"]["type"], "object");
1165 assert!(out[0].get("function").is_none());
1166 }
1167
1168 #[test]
1169 fn anthropic_output_config_only_for_json_schema() {
1170 assert!(anthropic_output_config(&ResponseFormat::Text).is_none());
1171 assert!(anthropic_output_config(&ResponseFormat::Json).is_none());
1172 let schema = serde_json::json!({"type": "object"});
1173 let cfg = anthropic_output_config(&ResponseFormat::JsonSchema { schema }).unwrap();
1174 assert_eq!(cfg["format"]["type"], "json_schema");
1175 }
1176
1177 #[test]
1178 fn openai_request_serializes_tools_and_format() {
1179 let body = OpenAIChatRequest {
1180 model: "gpt-4o".into(),
1181 messages: vec![OpenAIChatMessage {
1182 role: "user".into(),
1183 content: "hi".into(),
1184 }],
1185 temperature: 0.7,
1186 max_tokens: None,
1187 stream: false,
1188 tools: Some(openai_tools(&[sample_tool()])),
1189 response_format: Some(serde_json::json!({ "type": "json_object" })),
1190 };
1191 let json = serde_json::to_value(&body).unwrap();
1192 assert_eq!(json["tools"][0]["function"]["name"], "get_weather");
1193 assert_eq!(json["response_format"]["type"], "json_object");
1194 assert!(json.get("max_tokens").is_none());
1196 }
1197
1198 #[test]
1199 fn openai_response_parses_tool_calls() {
1200 let raw = r#"{
1201 "id": "chatcmpl-1",
1202 "created": 1700000000,
1203 "model": "gpt-4o",
1204 "choices": [{
1205 "index": 0,
1206 "message": {
1207 "role": "assistant",
1208 "content": null,
1209 "tool_calls": [{
1210 "id": "call_abc",
1211 "type": "function",
1212 "function": { "name": "get_weather", "arguments": "{\"location\":\"Paris\"}" }
1213 }]
1214 },
1215 "finish_reason": "tool_calls"
1216 }],
1217 "usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15 }
1218 }"#;
1219 let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1220 assert_eq!(resp.id.as_deref(), Some("chatcmpl-1"));
1221 let choice = resp.choices.into_iter().next().unwrap();
1222 assert_eq!(choice.finish_reason.as_deref(), Some("tool_calls"));
1223 assert!(choice.message.content.is_none());
1224 assert_eq!(choice.message.tool_calls.len(), 1);
1225 assert_eq!(choice.message.tool_calls[0].function.name, "get_weather");
1226 }
1227
1228 #[test]
1229 fn openai_response_parses_glm47_reasoning_content() {
1230 let raw = r#"{
1232 "id":"chatcmpl-1","created":1700000000,"model":"glm-4.7",
1233 "choices":[{"index":0,"message":{"role":"assistant","content":null,
1234 "reasoning_content":"thinking... {\"rating\":\"Buy\"}"},"finish_reason":"stop"}],
1235 "usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15,
1236 "completion_tokens_details":{"reasoning_tokens":3}}
1237 }"#;
1238 let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1239 let choice = resp.choices.into_iter().next().unwrap();
1240 assert!(choice.message.content.is_none());
1241 assert!(choice.message.reasoning_content.is_some());
1242 assert_eq!(
1243 resp.usage
1244 .unwrap()
1245 .completion_tokens_details
1246 .unwrap()
1247 .reasoning_tokens,
1248 Some(3)
1249 );
1250 }
1251
1252 #[test]
1253 fn openai_complete_promotes_reasoning_when_content_empty() {
1254 let raw = r#"{
1258 "id":"chatcmpl-1","created":1700000000,"model":"glm-4.7",
1259 "choices":[{"index":0,"message":{"role":"assistant","content":"",
1260 "reasoning_content":"사고 {\"rating\":\"Sell\",\"key_thesis\":\"약세\"}"},"finish_reason":"stop"}]
1261 }"#;
1262 let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1263 let first = resp.choices.into_iter().next().unwrap();
1264 let raw_content = first.message.content.unwrap_or_default();
1265 let reasoning = first.message.reasoning_content.clone();
1266 let content = promote_reasoning_into_content(raw_content, reasoning.as_deref());
1267 assert!(
1268 content.contains("\"rating\":\"Sell\""),
1269 "promoted content must contain JSON: {content}"
1270 );
1271 assert_eq!(
1272 reasoning.as_deref(),
1273 Some("사고 {\"rating\":\"Sell\",\"key_thesis\":\"약세\"}")
1274 );
1275 }
1276
1277 #[test]
1278 fn openai_complete_keeps_content_when_present() {
1279 let raw = r#"{
1281 "id":"chatcmpl-1","created":1700000000,"model":"gpt-4o",
1282 "choices":[{"index":0,"message":{"role":"assistant","content":"answer",
1283 "reasoning_content":"thought"},"finish_reason":"stop"}]
1284 }"#;
1285 let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1286 let first = resp.choices.into_iter().next().unwrap();
1287 let raw_content = first.message.content.clone().unwrap_or_default();
1288 let reasoning = first.message.reasoning_content.as_deref();
1289 let content = promote_reasoning_into_content(raw_content.clone(), reasoning);
1290 assert_eq!(content, "answer");
1291 assert_eq!(reasoning, Some("thought"));
1292 }
1293
1294 #[test]
1295 fn promote_reasoning_into_content_edge_cases() {
1296 assert_eq!(
1299 promote_reasoning_into_content(String::new(), Some("chain-of-thought")),
1300 "chain-of-thought"
1301 );
1302 assert_eq!(
1304 promote_reasoning_into_content("ans".into(), Some("cot")),
1305 "ans"
1306 );
1307 assert_eq!(promote_reasoning_into_content(String::new(), None), "");
1309 assert_eq!(promote_reasoning_into_content("x".into(), None), "x");
1310 }
1311
1312 #[test]
1313 fn openai_response_parses_deepseek_reasoning_alias() {
1314 let raw = r#"{"model":"deepseek-r1","choices":[{"message":{"content":"ans","reasoning":"thought"}}]}"#;
1316 let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1317 assert_eq!(
1318 resp.choices[0].message.reasoning_content.as_deref(),
1319 Some("thought")
1320 );
1321 }
1322
1323 #[test]
1324 fn openai_sse_reasoning_delta_extracted() {
1325 let data = r#"{"choices":[{"index":0,"delta":{"reasoning_content":"thinking"},"finish_reason":null}]}"#;
1326 let event = parse_openai_sse(data).unwrap();
1327 match event {
1328 StreamEvent::ReasoningDelta { content } => assert_eq!(content, "thinking"),
1329 other => panic!("expected ReasoningDelta, got {other:?}"),
1330 }
1331 }
1332
1333 #[test]
1334 fn openai_sse_content_delta_still_works_alongside_reasoning() {
1335 let data = r#"{"choices":[{"index":0,"delta":{"content":"answer"},"finish_reason":null}]}"#;
1337 let event = parse_openai_sse(data).unwrap();
1338 assert!(matches!(event, StreamEvent::Delta { .. }));
1339 }
1340
1341 #[test]
1342 fn openai_sse_usage_carries_reasoning_tokens() {
1343 let data = r#"{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],
1345 "usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3,
1346 "completion_tokens_details":{"reasoning_tokens":7}}}"#;
1347 let event = parse_openai_sse(data).unwrap();
1348 match event {
1349 StreamEvent::Usage(u) => assert_eq!(u.reasoning_tokens, Some(7)),
1350 other => panic!("expected Usage, got {other:?}"),
1351 }
1352 }
1353
1354 #[test]
1355 fn anthropic_response_parses_tool_use_block() {
1356 let raw = r#"{
1357 "id": "msg_1",
1358 "model": "claude-sonnet-4-6",
1359 "stop_reason": "tool_use",
1360 "content": [
1361 { "type": "text", "text": "Let me check." },
1362 { "type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": { "location": "Paris" } }
1363 ],
1364 "usage": { "input_tokens": 12, "output_tokens": 8 }
1365 }"#;
1366 let resp: AnthropicResponse = serde_json::from_str(raw).unwrap();
1367 assert_eq!(resp.stop_reason.as_deref(), Some("tool_use"));
1368 assert_eq!(resp.content.len(), 2);
1369 assert_eq!(resp.content[0].block_type, "text");
1370 assert_eq!(resp.content[1].block_type, "tool_use");
1371 assert_eq!(resp.content[1].name.as_deref(), Some("get_weather"));
1372 assert_eq!(resp.content[1].input.as_ref().unwrap()["location"], "Paris");
1373 }
1374
1375 #[test]
1376 fn anthropic_response_parses_thinking_block() {
1377 let raw = r#"{
1379 "id": "msg_2",
1380 "model": "claude-sonnet-4-6",
1381 "stop_reason": "end_turn",
1382 "content": [
1383 { "type": "thinking", "thinking": "step by step..." },
1384 { "type": "text", "text": "Final answer." }
1385 ],
1386 "usage": { "input_tokens": 5, "output_tokens": 9 }
1387 }"#;
1388 let resp: AnthropicResponse = serde_json::from_str(raw).unwrap();
1389 let mut reasoning = String::new();
1390 let mut content = String::new();
1391 for block in resp.content {
1392 match block.block_type.as_str() {
1393 "text" => {
1394 if let Some(t) = block.text {
1395 content.push_str(&t);
1396 }
1397 }
1398 "thinking" => {
1399 if let Some(t) = block.thinking {
1400 reasoning.push_str(&t);
1401 }
1402 }
1403 _ => {}
1404 }
1405 }
1406 assert_eq!(content, "Final answer.");
1407 assert_eq!(reasoning, "step by step...");
1408 }
1409
1410 #[test]
1411 fn anthropic_sse_thinking_delta_extracted() {
1412 let data = r#"{"delta":{"type":"thinking_delta","thinking":"a thought"}}"#;
1413 let event = parse_anthropic_sse("content_block_delta", data).unwrap();
1414 match event {
1415 StreamEvent::ReasoningDelta { content } => assert_eq!(content, "a thought"),
1416 other => panic!("expected ReasoningDelta, got {other:?}"),
1417 }
1418 }
1419}