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, ReasoningConfig, ReasoningEffort,
9 ResponseFormat, StreamEvent, TokenUsage, Verbosity,
10};
11
12fn redact_http_body(body: &str) -> String {
19 #[cfg(feature = "safety")]
20 {
21 crate::safety::sanitize::mask_secrets(body)
22 }
23 #[cfg(not(feature = "safety"))]
24 {
25 body.to_string()
26 }
27}
28
29fn openai_tools(tools: &[ToolDefinition]) -> Vec<serde_json::Value> {
31 tools
32 .iter()
33 .map(|t| {
34 serde_json::json!({
35 "type": "function",
36 "function": {
37 "name": t.name,
38 "description": t.description,
39 "parameters": t.input_schema,
40 }
41 })
42 })
43 .collect()
44}
45
46fn openai_response_format(rf: &ResponseFormat) -> Option<serde_json::Value> {
49 match rf {
50 ResponseFormat::Text => None,
51 ResponseFormat::Json => Some(serde_json::json!({ "type": "json_object" })),
52 ResponseFormat::JsonSchema { schema } => Some(serde_json::json!({
53 "type": "json_schema",
54 "json_schema": { "name": "response", "schema": schema, "strict": true }
55 })),
56 }
57}
58
59fn anthropic_tools(tools: &[ToolDefinition]) -> Vec<serde_json::Value> {
61 tools
62 .iter()
63 .map(|t| {
64 serde_json::json!({
65 "name": t.name,
66 "description": t.description,
67 "input_schema": t.input_schema,
68 })
69 })
70 .collect()
71}
72
73fn anthropic_output_config(rf: &ResponseFormat) -> Option<serde_json::Value> {
77 match rf {
78 ResponseFormat::JsonSchema { schema } => Some(serde_json::json!({
79 "format": { "type": "json_schema", "schema": schema }
80 })),
81 ResponseFormat::Json | ResponseFormat::Text => None,
82 }
83}
84
85fn openai_reasoning(
92 cfg: Option<&ReasoningConfig>,
93) -> (Option<ReasoningEffort>, Option<serde_json::Value>) {
94 match cfg {
95 None => (None, None),
96 Some(c) => {
97 let mut obj = serde_json::Map::new();
98 if let Some(enabled) = c.enabled {
99 obj.insert("enabled".into(), enabled.into());
100 }
101 if let Some(summary) = c.summary {
102 obj.insert(
103 "summary".into(),
104 serde_json::to_value(summary).expect("ReasoningSummary is a plain enum"),
105 );
106 }
107 (
108 c.effort,
109 if obj.is_empty() {
110 None
111 } else {
112 Some(serde_json::Value::Object(obj))
113 },
114 )
115 }
116 }
117}
118
119fn body_with_extra<T: serde::Serialize>(
123 body: &T,
124 extra: Option<&serde_json::Map<String, serde_json::Value>>,
125) -> serde_json::Value {
126 let mut v = serde_json::to_value(body).expect("request body serializes");
127 if let Some(extra) = extra
128 && let serde_json::Value::Object(map) = &mut v
129 {
130 for (k, val) in extra {
131 map.insert(k.clone(), val.clone());
132 }
133 }
134 v
135}
136
137fn http_client() -> Result<reqwest::Client> {
139 crate::tls::ensure_tls_provider();
140 reqwest::Client::builder()
141 .connect_timeout(Duration::from_secs(10))
142 .timeout(Duration::from_secs(120))
143 .build()
144 .map_err(|e| KernelError::Config(format!("Failed to build HTTP client: {}", e)))
145}
146
147fn check_rate_limit(resp: &reqwest::Response) -> Result<()> {
149 if resp.status().as_u16() == 429 {
150 let retry = resp
151 .headers()
152 .get("retry-after")
153 .and_then(|v| v.to_str().ok())
154 .and_then(|v| v.parse().ok())
155 .unwrap_or(60);
156 return Err(KernelError::RateLimited(retry));
157 }
158 Ok(())
159}
160
161#[async_trait]
163pub trait LLMClient: Send + Sync {
164 async fn complete(&self, request: LLMRequest) -> Result<LLMResponse>;
176 fn model_name(&self) -> &str;
178
179 async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream>;
186}
187
188pub struct OpenAIClient {
190 api_key: String,
191 model: String,
192 base_url: String,
193 client: reqwest::Client,
194}
195
196impl OpenAIClient {
197 pub fn new(config: &ModelConfig) -> Result<Self> {
199 let api_key = std::env::var(&config.api_key_env).map_err(|_| {
200 KernelError::Config(format!(
201 "Environment variable {} not set",
202 config.api_key_env
203 ))
204 })?;
205 Ok(Self {
206 api_key,
207 model: config.model.clone(),
208 base_url: config
209 .base_url
210 .clone()
211 .unwrap_or_else(|| "https://api.openai.com/v1".into()),
212 client: http_client()?,
213 })
214 }
215
216 pub fn from_key(model: impl Into<String>, api_key: impl Into<String>) -> Result<Self> {
230 Ok(Self {
231 api_key: api_key.into(),
232 model: model.into(),
233 base_url: "https://api.openai.com/v1".into(),
234 client: http_client()?,
235 })
236 }
237
238 pub fn from_key_with_client(
244 model: impl Into<String>,
245 api_key: impl Into<String>,
246 client: reqwest::Client,
247 ) -> Self {
248 Self {
249 api_key: api_key.into(),
250 model: model.into(),
251 base_url: "https://api.openai.com/v1".into(),
252 client,
253 }
254 }
255
256 pub fn from_key_with_base_url(
262 model: impl Into<String>,
263 api_key: impl Into<String>,
264 base_url: impl Into<String>,
265 client: reqwest::Client,
266 ) -> Self {
267 Self {
268 api_key: api_key.into(),
269 model: model.into(),
270 base_url: base_url.into(),
271 client,
272 }
273 }
274}
275
276#[derive(serde::Serialize)]
277struct OpenAIChatRequest {
278 model: String,
279 messages: Vec<OpenAIChatMessage>,
280 temperature: f32,
281 #[serde(skip_serializing_if = "Option::is_none")]
282 max_tokens: Option<u32>,
283 #[serde(skip_serializing_if = "std::ops::Not::not")]
284 stream: bool,
285 #[serde(skip_serializing_if = "Option::is_none")]
286 tools: Option<Vec<serde_json::Value>>,
287 #[serde(skip_serializing_if = "Option::is_none")]
288 response_format: Option<serde_json::Value>,
289 #[serde(skip_serializing_if = "Option::is_none")]
291 reasoning_effort: Option<ReasoningEffort>,
292 #[serde(skip_serializing_if = "Option::is_none")]
294 reasoning: Option<serde_json::Value>,
295 #[serde(skip_serializing_if = "Option::is_none")]
297 verbosity: Option<Verbosity>,
298}
299
300#[derive(serde::Serialize)]
301struct OpenAIChatMessage {
302 role: String,
303 content: String,
304}
305
306#[derive(serde::Deserialize)]
307struct OpenAIChatResponse {
308 #[serde(default)]
309 id: Option<String>,
310 #[serde(default)]
311 created: Option<u64>,
312 choices: Vec<OpenAIChoice>,
313 model: String,
314 usage: Option<OpenAIUsage>,
315}
316
317#[derive(serde::Deserialize)]
318struct OpenAIChoice {
319 message: OpenAIRespMessage,
320 #[serde(default)]
321 finish_reason: Option<String>,
322}
323
324#[derive(serde::Deserialize)]
331struct OpenAIRespMessage {
332 #[serde(default)]
333 content: Option<String>,
334 #[serde(default, alias = "reasoning")]
336 reasoning_content: Option<String>,
337 #[serde(default)]
338 tool_calls: Vec<OpenAIToolCall>,
339}
340
341#[derive(serde::Deserialize)]
342struct OpenAIToolCall {
343 id: String,
344 function: OpenAIFunctionCall,
345}
346
347#[derive(serde::Deserialize)]
348struct OpenAIFunctionCall {
349 name: String,
350 #[serde(default)]
351 arguments: String,
352}
353
354#[derive(serde::Deserialize)]
360struct OpenAIUsage {
361 #[serde(default)]
362 prompt_tokens: u32,
363 #[serde(default)]
364 completion_tokens: u32,
365 #[serde(default)]
366 total_tokens: u32,
367 #[serde(default)]
369 completion_tokens_details: Option<OpenAICompletionTokensDetails>,
370}
371
372#[derive(serde::Deserialize)]
374struct OpenAICompletionTokensDetails {
375 #[serde(default)]
376 reasoning_tokens: Option<u32>,
377}
378
379#[async_trait]
380impl LLMClient for OpenAIClient {
381 async fn complete(&self, request: LLMRequest) -> Result<LLMResponse> {
382 let model = request.model.clone().unwrap_or_else(|| self.model.clone());
383 let temperature = request.temperature;
384 let max_tokens = request.max_tokens;
385 let tools = request
386 .tools
387 .as_deref()
388 .map(openai_tools)
389 .filter(|t| !t.is_empty());
390 let response_format = request
391 .response_format
392 .as_ref()
393 .and_then(openai_response_format);
394 let (reasoning_effort, reasoning) = openai_reasoning(request.reasoning.as_ref());
395 let verbosity = request.verbosity;
396 let extra_body = request.extra_body.clone();
397 let messages: Vec<_> = request
398 .into_openai_messages()
399 .into_iter()
400 .map(|(role, content)| OpenAIChatMessage { role, content })
401 .collect();
402
403 let body = OpenAIChatRequest {
404 model,
405 messages,
406 temperature,
407 max_tokens,
408 stream: false,
409 tools,
410 response_format,
411 reasoning_effort,
412 reasoning,
413 verbosity,
414 };
415
416 let resp = self
417 .client
418 .post(format!("{}/chat/completions", self.base_url))
419 .header("Authorization", format!("Bearer {}", self.api_key))
420 .json(&body_with_extra(&body, extra_body.as_ref()))
421 .send()
422 .await
423 .map_err(|e| KernelError::LlmApi(e.to_string()))?;
424
425 check_rate_limit(&resp)?;
426
427 let status = resp.status();
428
429 if !status.is_success() {
430 let text = resp.text().await.unwrap_or_default();
431 return Err(KernelError::Http {
432 status: status.as_u16(),
433 message: redact_http_body(&text),
434 });
435 }
436
437 let text = resp
438 .text()
439 .await
440 .map_err(|e| KernelError::LlmApi(e.to_string()))?;
441 let chat_resp: OpenAIChatResponse = from_json_lenient(&text)
442 .map_err(|e| KernelError::LlmApi(format!("error decoding response body: {e}")))?;
443
444 let id = chat_resp.id;
445 let created = chat_resp.created;
446 let first = chat_resp.choices.into_iter().next();
447 let finish_reason = first.as_ref().and_then(|c| c.finish_reason.clone());
448 let (content, reasoning, tool_calls) = match first {
449 Some(c) => {
450 let raw_content = c.message.content.unwrap_or_default();
451 let reasoning = c.message.reasoning_content;
452 let content = promote_reasoning_into_content(raw_content, reasoning.as_deref());
453 let calls = c
454 .message
455 .tool_calls
456 .into_iter()
457 .map(|tc| ToolCall {
458 id: tc.id,
459 name: tc.function.name,
460 arguments: tc.function.arguments,
461 })
462 .collect();
463 (content, reasoning, calls)
464 }
465 None => (String::new(), None, Vec::new()),
466 };
467
468 let usage = chat_resp.usage.map(|u| TokenUsage {
469 prompt_tokens: u.prompt_tokens,
470 completion_tokens: u.completion_tokens,
471 total_tokens: u.total_tokens,
472 reasoning_tokens: u.completion_tokens_details.and_then(|d| d.reasoning_tokens),
473 });
474
475 Ok(LLMResponse {
476 content,
477 reasoning,
478 model: chat_resp.model,
479 usage: usage.unwrap_or_default(),
480 tool_calls,
481 finish_reason,
482 id,
483 created,
484 })
485 }
486
487 fn model_name(&self) -> &str {
488 &self.model
489 }
490
491 async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream> {
492 let model = request.model.clone().unwrap_or_else(|| self.model.clone());
493 let temperature = request.temperature;
494 let max_tokens = request.max_tokens;
495 let (reasoning_effort, reasoning) = openai_reasoning(request.reasoning.as_ref());
496 let verbosity = request.verbosity;
497 let extra_body = request.extra_body.clone();
498 let messages: Vec<_> = request
499 .into_openai_messages()
500 .into_iter()
501 .map(|(role, content)| OpenAIChatMessage { role, content })
502 .collect();
503
504 let body = OpenAIChatRequest {
505 model,
506 messages,
507 temperature,
508 max_tokens,
509 stream: true,
510 tools: None,
513 response_format: None,
514 reasoning_effort,
515 reasoning,
516 verbosity,
517 };
518
519 let resp = self
520 .client
521 .post(format!("{}/chat/completions", self.base_url))
522 .header("Authorization", format!("Bearer {}", self.api_key))
523 .json(&body_with_extra(&body, extra_body.as_ref()))
524 .send()
525 .await
526 .map_err(|e| KernelError::LlmApi(e.to_string()))?;
527
528 check_rate_limit(&resp)?;
529
530 let status = resp.status();
531 if !status.is_success() {
532 let text = resp.text().await.unwrap_or_default();
533 return Err(KernelError::Http {
534 status: status.as_u16(),
535 message: redact_http_body(&text),
536 });
537 }
538
539 let (tx, rx) = tokio::sync::mpsc::channel::<Result<StreamEvent>>(16);
540
541 tokio::spawn(async move {
542 let mut stream = std::pin::pin!(resp.bytes_stream());
543 let mut buffer: Vec<u8> = Vec::new();
544
545 use tokio_stream::StreamExt;
546
547 while let Some(chunk) = stream.next().await {
548 let chunk = match chunk {
549 Ok(c) => c,
550 Err(e) => {
551 let _ = tx.send(Err(KernelError::LlmApi(e.to_string()))).await;
552 return;
553 }
554 };
555
556 for line in drain_sse_lines(&mut buffer, &chunk) {
557 if let Some(data) = parse_sse_line(&line)
558 && let Some(event) = parse_openai_sse(data)
559 {
560 let is_done = matches!(event, StreamEvent::Done);
561 if tx.send(Ok(event)).await.is_err() || is_done {
562 return;
563 }
564 }
565 }
566 }
567 let _ = tx.send(Ok(StreamEvent::Done)).await;
568 });
569
570 Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
571 }
572}
573
574fn parse_sse_line(line: &str) -> Option<&str> {
577 line.strip_prefix("data: ").filter(|d| *d != "[DONE]")
578}
579
580fn drain_sse_lines(buffer: &mut Vec<u8>, chunk: &[u8]) -> Vec<String> {
590 buffer.extend_from_slice(chunk);
591 let mut lines = Vec::new();
592 while let Some(pos) = buffer.iter().position(|&b| b == b'\n') {
593 let line: Vec<u8> = buffer.drain(..=pos).collect();
594 lines.push(String::from_utf8_lossy(&line).trim_end().to_string());
595 }
596 lines
597}
598
599fn promote_reasoning_into_content(raw_content: String, reasoning: Option<&str>) -> String {
620 if raw_content.is_empty() {
621 reasoning.unwrap_or_default().to_string()
622 } else {
623 raw_content
624 }
625}
626
627fn escape_unescaped_control_chars(raw: &str) -> String {
637 let mut out = String::with_capacity(raw.len());
638 let mut in_string = false;
639 let mut escaped = false;
640 for ch in raw.chars() {
641 if escaped {
642 escaped = false;
643 out.push(ch);
644 continue;
645 }
646 match ch {
647 '"' => {
648 in_string = !in_string;
649 out.push(ch);
650 }
651 '\\' if in_string => {
652 escaped = true;
653 out.push(ch);
654 }
655 c if in_string && (c as u32) < 0x20 => {
656 out.push_str(&format!("\\u{:04x}", c as u32));
657 }
658 c => out.push(c),
659 }
660 }
661 out
662}
663
664fn from_json_lenient<T: serde::de::DeserializeOwned>(raw: &str) -> serde_json::Result<T> {
672 match serde_json::from_str(raw) {
673 Ok(v) => Ok(v),
674 Err(err) => serde_json::from_str(&escape_unescaped_control_chars(raw)).map_err(|_| err),
675 }
676}
677
678fn parse_openai_sse(data: &str) -> Option<StreamEvent> {
680 let v: serde_json::Value = from_json_lenient(data).ok()?;
681
682 if let Some(rc) = v
685 .get("choices")?
686 .get(0)?
687 .get("delta")?
688 .get("reasoning_content")
689 .and_then(|c| c.as_str())
690 && !rc.is_empty()
691 {
692 return Some(StreamEvent::ReasoningDelta {
693 content: rc.to_string(),
694 });
695 }
696
697 if let Some(content) = v
699 .get("choices")?
700 .get(0)?
701 .get("delta")?
702 .get("content")
703 .and_then(|c| c.as_str())
704 && !content.is_empty()
705 {
706 return Some(StreamEvent::Delta {
707 content: content.to_string(),
708 });
709 }
710
711 if let Some(usage) = v.get("usage").and_then(|u| {
713 Some(TokenUsage {
714 prompt_tokens: u.get("prompt_tokens")?.as_u64()? as u32,
715 completion_tokens: u.get("completion_tokens")?.as_u64()? as u32,
716 total_tokens: u.get("total_tokens")?.as_u64()? as u32,
717 reasoning_tokens: u
718 .get("completion_tokens_details")
719 .and_then(|d| d.get("reasoning_tokens"))
720 .and_then(|r| r.as_u64())
721 .map(|n| n as u32),
722 })
723 }) {
724 return Some(StreamEvent::Usage(usage));
725 }
726
727 if v.get("choices")?
729 .get(0)?
730 .get("finish_reason")
731 .and_then(|r| r.as_str())
732 .is_some()
733 {
734 return Some(StreamEvent::Done);
735 }
736
737 None
738}
739
740fn parse_anthropic_sse(event_type: &str, data: &str) -> Option<StreamEvent> {
742 let v: serde_json::Value = serde_json::from_str(data).ok()?;
743
744 match event_type {
745 "content_block_delta" => {
746 let delta = v.get("delta")?;
747 match delta.get("type").and_then(|t| t.as_str()) {
749 Some("thinking_delta") => {
750 let text = delta.get("thinking")?.as_str()?;
751 if !text.is_empty() {
752 return Some(StreamEvent::ReasoningDelta {
753 content: text.to_string(),
754 });
755 }
756 None
757 }
758 _ => {
759 let text = delta.get("text")?.as_str()?;
761 if !text.is_empty() {
762 return Some(StreamEvent::Delta {
763 content: text.to_string(),
764 });
765 }
766 None
767 }
768 }
769 }
770 "message_delta" => {
771 let usage = v.get("usage").and_then(|u| {
772 Some(TokenUsage {
773 prompt_tokens: 0,
774 completion_tokens: u.get("output_tokens")?.as_u64()? as u32,
775 total_tokens: 0,
776 reasoning_tokens: None,
777 })
778 });
779 if let Some(usage) = usage {
780 return Some(StreamEvent::Usage(usage));
781 }
782 Some(StreamEvent::Done)
783 }
784 "message_stop" => Some(StreamEvent::Done),
785 _ => None,
786 }
787}
788
789pub struct AnthropicClient {
791 api_key: String,
792 model: String,
793 base_url: String,
794 client: reqwest::Client,
795}
796
797impl AnthropicClient {
798 pub fn new(config: &ModelConfig) -> Result<Self> {
800 let api_key = std::env::var(&config.api_key_env).map_err(|_| {
801 KernelError::Config(format!(
802 "Environment variable {} not set",
803 config.api_key_env
804 ))
805 })?;
806 Ok(Self {
807 api_key,
808 model: config.model.clone(),
809 base_url: config
810 .base_url
811 .clone()
812 .unwrap_or_else(|| "https://api.anthropic.com/v1".into()),
813 client: http_client()?,
814 })
815 }
816
817 pub fn from_key(model: impl Into<String>, api_key: impl Into<String>) -> Result<Self> {
823 Ok(Self {
824 api_key: api_key.into(),
825 model: model.into(),
826 base_url: "https://api.anthropic.com/v1".into(),
827 client: http_client()?,
828 })
829 }
830
831 pub fn from_key_with_client(
833 model: impl Into<String>,
834 api_key: impl Into<String>,
835 client: reqwest::Client,
836 ) -> Self {
837 Self {
838 api_key: api_key.into(),
839 model: model.into(),
840 base_url: "https://api.anthropic.com/v1".into(),
841 client,
842 }
843 }
844
845 pub fn from_key_with_base_url(
850 model: impl Into<String>,
851 api_key: impl Into<String>,
852 base_url: impl Into<String>,
853 client: reqwest::Client,
854 ) -> Self {
855 Self {
856 api_key: api_key.into(),
857 model: model.into(),
858 base_url: base_url.into(),
859 client,
860 }
861 }
862}
863
864#[derive(serde::Serialize)]
865struct AnthropicRequest {
866 model: String,
867 max_tokens: u32,
868 temperature: f32,
869 #[serde(skip_serializing_if = "Option::is_none")]
870 system: Option<String>,
871 messages: Vec<AnthropicMessage>,
872 #[serde(skip_serializing_if = "std::ops::Not::not")]
873 stream: bool,
874 #[serde(skip_serializing_if = "Option::is_none")]
875 tools: Option<Vec<serde_json::Value>>,
876 #[serde(skip_serializing_if = "Option::is_none")]
877 output_config: Option<serde_json::Value>,
878}
879
880#[derive(serde::Serialize)]
881struct AnthropicMessage {
882 role: String,
883 content: String,
884}
885
886#[derive(serde::Deserialize)]
887struct AnthropicResponse {
888 #[serde(default)]
889 id: Option<String>,
890 content: Vec<AnthropicContentBlock>,
891 model: String,
892 #[serde(default)]
893 stop_reason: Option<String>,
894 usage: AnthropicUsage,
895}
896
897#[derive(serde::Deserialize)]
900struct AnthropicContentBlock {
901 #[serde(rename = "type")]
902 block_type: String,
903 #[serde(default)]
904 text: Option<String>,
905 #[serde(default)]
907 thinking: Option<String>,
908 #[serde(default)]
909 id: Option<String>,
910 #[serde(default)]
911 name: Option<String>,
912 #[serde(default)]
913 input: Option<serde_json::Value>,
914}
915
916#[derive(serde::Deserialize)]
917struct AnthropicUsage {
918 input_tokens: u32,
919 output_tokens: u32,
920}
921
922#[async_trait]
923impl LLMClient for AnthropicClient {
924 async fn complete(&self, request: LLMRequest) -> Result<LLMResponse> {
925 let model = request.model.clone().unwrap_or_else(|| self.model.clone());
926 let max_tokens = request.max_tokens.unwrap_or(4096);
927 let temperature = request.temperature;
928 let system = request.system.clone();
929 let tools = request
930 .tools
931 .as_deref()
932 .map(anthropic_tools)
933 .filter(|t| !t.is_empty());
934 let output_config = request
935 .response_format
936 .as_ref()
937 .and_then(anthropic_output_config);
938 let extra_body = request.extra_body.clone();
939 let messages: Vec<AnthropicMessage> = request
940 .into_anthropic_messages()
941 .into_iter()
942 .map(|(role, content)| AnthropicMessage { role, content })
943 .collect();
944
945 let body = AnthropicRequest {
946 model,
947 max_tokens,
948 temperature,
949 system,
950 messages,
951 stream: false,
952 tools,
953 output_config,
954 };
955
956 let resp = self
957 .client
958 .post(format!("{}/messages", self.base_url))
959 .header("x-api-key", &self.api_key)
960 .header("anthropic-version", "2023-06-01")
961 .header("content-type", "application/json")
962 .json(&body_with_extra(&body, extra_body.as_ref()))
963 .send()
964 .await
965 .map_err(|e| KernelError::LlmApi(e.to_string()))?;
966
967 check_rate_limit(&resp)?;
968
969 let status = resp.status();
970
971 if !status.is_success() {
972 let text = resp.text().await.unwrap_or_default();
973 return Err(KernelError::Http {
974 status: status.as_u16(),
975 message: redact_http_body(&text),
976 });
977 }
978
979 let chat_resp: AnthropicResponse = resp
980 .json()
981 .await
982 .map_err(|e| KernelError::LlmApi(e.to_string()))?;
983
984 let mut content = String::new();
985 let mut reasoning = String::new();
986 let mut tool_calls = Vec::new();
987 for block in chat_resp.content {
988 match block.block_type.as_str() {
989 "text" => {
990 if let Some(t) = block.text {
991 content.push_str(&t);
992 }
993 }
994 "thinking" => {
995 if let Some(t) = block.thinking {
996 reasoning.push_str(&t);
997 }
998 }
999 "tool_use" => {
1000 if let (Some(id), Some(name)) = (block.id, block.name) {
1001 let arguments = block
1002 .input
1003 .map(|v| v.to_string())
1004 .unwrap_or_else(|| "{}".to_string());
1005 tool_calls.push(ToolCall {
1006 id,
1007 name,
1008 arguments,
1009 });
1010 }
1011 }
1012 _ => {}
1013 }
1014 }
1015
1016 Ok(LLMResponse {
1017 content,
1018 reasoning: if reasoning.is_empty() {
1019 None
1020 } else {
1021 Some(reasoning)
1022 },
1023 model: chat_resp.model,
1024 usage: TokenUsage {
1025 prompt_tokens: chat_resp.usage.input_tokens,
1026 completion_tokens: chat_resp.usage.output_tokens,
1027 total_tokens: chat_resp.usage.input_tokens + chat_resp.usage.output_tokens,
1028 reasoning_tokens: None,
1029 },
1030 tool_calls,
1031 finish_reason: chat_resp.stop_reason,
1032 id: chat_resp.id,
1033 created: None,
1034 })
1035 }
1036
1037 fn model_name(&self) -> &str {
1038 &self.model
1039 }
1040
1041 async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream> {
1042 let model = request.model.clone().unwrap_or_else(|| self.model.clone());
1043 let max_tokens = request.max_tokens.unwrap_or(4096);
1044 let temperature = request.temperature;
1045 let system = request.system.clone();
1046 let extra_body = request.extra_body.clone();
1047 let messages: Vec<AnthropicMessage> = request
1048 .into_anthropic_messages()
1049 .into_iter()
1050 .map(|(role, content)| AnthropicMessage { role, content })
1051 .collect();
1052
1053 let body = AnthropicRequest {
1054 model,
1055 max_tokens,
1056 temperature,
1057 system,
1058 messages,
1059 stream: true,
1060 tools: None,
1063 output_config: None,
1064 };
1065
1066 let resp = self
1067 .client
1068 .post(format!("{}/messages", self.base_url))
1069 .header("x-api-key", &self.api_key)
1070 .header("anthropic-version", "2023-06-01")
1071 .header("content-type", "application/json")
1072 .json(&body_with_extra(&body, extra_body.as_ref()))
1073 .send()
1074 .await
1075 .map_err(|e| KernelError::LlmApi(e.to_string()))?;
1076
1077 check_rate_limit(&resp)?;
1078
1079 let status = resp.status();
1080 if !status.is_success() {
1081 let text = resp.text().await.unwrap_or_default();
1082 return Err(KernelError::Http {
1083 status: status.as_u16(),
1084 message: redact_http_body(&text),
1085 });
1086 }
1087
1088 let (tx, rx) = tokio::sync::mpsc::channel::<Result<StreamEvent>>(16);
1089
1090 tokio::spawn(async move {
1091 let mut stream = std::pin::pin!(resp.bytes_stream());
1092 let mut buffer: Vec<u8> = Vec::new();
1093 let mut current_event = String::new();
1094
1095 use tokio_stream::StreamExt;
1096
1097 while let Some(chunk) = stream.next().await {
1098 let chunk = match chunk {
1099 Ok(c) => c,
1100 Err(e) => {
1101 let _ = tx.send(Err(KernelError::LlmApi(e.to_string()))).await;
1102 return;
1103 }
1104 };
1105
1106 for line in drain_sse_lines(&mut buffer, &chunk) {
1107 if let Some(evt) = line.strip_prefix("event: ") {
1108 current_event = evt.to_string();
1109 } else if let Some(data) = line.strip_prefix("data: ") {
1110 if data == "[DONE]" {
1111 let _ = tx.send(Ok(StreamEvent::Done)).await;
1112 return;
1113 }
1114 if let Some(event) = parse_anthropic_sse(¤t_event, data) {
1115 let is_done = matches!(event, StreamEvent::Done);
1116 if tx.send(Ok(event)).await.is_err() || is_done {
1117 return;
1118 }
1119 }
1120 current_event.clear();
1121 }
1122 }
1123 }
1124 let _ = tx.send(Ok(StreamEvent::Done)).await;
1125 });
1126
1127 Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
1128 }
1129}
1130
1131#[cfg(test)]
1132mod tests {
1133 use super::*;
1134 use crate::llm::types::ReasoningSummary;
1135
1136 #[test]
1137 fn parse_sse_line_extracts_data() {
1138 assert_eq!(
1139 parse_sse_line("data: {\"id\":\"1\"}"),
1140 Some("{\"id\":\"1\"}")
1141 );
1142 }
1143
1144 #[test]
1145 fn parse_sse_line_skips_done() {
1146 assert_eq!(parse_sse_line("data: [DONE]"), None);
1147 }
1148
1149 #[test]
1150 fn parse_sse_line_skips_non_data() {
1151 assert_eq!(parse_sse_line("event: ping"), None);
1152 assert_eq!(parse_sse_line(""), None);
1153 }
1154
1155 #[test]
1156 fn drain_sse_lines_reassembles_multibyte_split_across_chunks() {
1157 let full = "data: 안녕\n".as_bytes().to_vec();
1159 let (first, rest) = full.split_at(7);
1161
1162 let mut buffer = Vec::new();
1163 assert!(drain_sse_lines(&mut buffer, first).is_empty());
1166
1167 let lines = drain_sse_lines(&mut buffer, rest);
1168 assert_eq!(lines, vec!["data: 안녕".to_string()]);
1169 assert!(!lines[0].contains('\u{FFFD}'));
1171 }
1172
1173 #[test]
1174 fn drain_sse_lines_handles_multiple_lines_and_keeps_partial_tail() {
1175 let mut buffer = Vec::new();
1176 let lines = drain_sse_lines(&mut buffer, b"event: ping\r\ndata: {}\npartial");
1177 assert_eq!(
1178 lines,
1179 vec!["event: ping".to_string(), "data: {}".to_string()]
1180 );
1181 let lines = drain_sse_lines(&mut buffer, b" tail\n");
1183 assert_eq!(lines, vec!["partial tail".to_string()]);
1184 }
1185
1186 #[test]
1187 fn openai_delta_extraction() {
1188 let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#;
1189 let event = parse_openai_sse(data).unwrap();
1190 match event {
1191 StreamEvent::Delta { content } => assert_eq!(content, "Hello"),
1192 _ => panic!("expected Delta, got {:?}", event),
1193 }
1194 }
1195
1196 #[test]
1197 fn openai_usage_extraction() {
1198 let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}"#;
1199 let event = parse_openai_sse(data).unwrap();
1200 match event {
1201 StreamEvent::Usage(usage) => {
1202 assert_eq!(usage.prompt_tokens, 10);
1203 assert_eq!(usage.completion_tokens, 5);
1204 assert_eq!(usage.total_tokens, 15);
1205 }
1206 _ => panic!("expected Usage, got {:?}", event),
1207 }
1208 }
1209
1210 #[test]
1211 fn openai_finish_reason_is_done() {
1212 let data =
1213 r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#;
1214 let event = parse_openai_sse(data).unwrap();
1215 assert!(matches!(event, StreamEvent::Done));
1216 }
1217
1218 #[test]
1219 fn openai_empty_delta_skipped() {
1220 let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}]}"#;
1221 assert!(parse_openai_sse(data).is_none());
1222 }
1223
1224 #[test]
1225 fn anthropic_content_block_delta() {
1226 let data = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#;
1227 let event = parse_anthropic_sse("content_block_delta", data).unwrap();
1228 match event {
1229 StreamEvent::Delta { content } => assert_eq!(content, "Hello"),
1230 _ => panic!("expected Delta, got {:?}", event),
1231 }
1232 }
1233
1234 #[test]
1235 fn anthropic_message_delta_usage() {
1236 let data = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}"#;
1237 let event = parse_anthropic_sse("message_delta", data).unwrap();
1238 match event {
1239 StreamEvent::Usage(usage) => assert_eq!(usage.completion_tokens, 5),
1240 _ => panic!("expected Usage, got {:?}", event),
1241 }
1242 }
1243
1244 #[test]
1245 fn anthropic_message_stop() {
1246 let event = parse_anthropic_sse("message_stop", r#"{"type":"message_stop"}"#).unwrap();
1247 assert!(matches!(event, StreamEvent::Done));
1248 }
1249
1250 #[test]
1251 fn anthropic_unknown_event_ignored() {
1252 assert!(parse_anthropic_sse("ping", "{}").is_none());
1253 }
1254
1255 fn sample_tool() -> ToolDefinition {
1256 ToolDefinition {
1257 name: "get_weather".into(),
1258 description: "Get weather".into(),
1259 input_schema: serde_json::json!({
1260 "type": "object",
1261 "properties": { "location": { "type": "string" } },
1262 "required": ["location"]
1263 }),
1264 }
1265 }
1266
1267 #[test]
1268 fn openai_tools_use_function_wrapper() {
1269 let out = openai_tools(&[sample_tool()]);
1270 assert_eq!(out.len(), 1);
1271 assert_eq!(out[0]["type"], "function");
1272 assert_eq!(out[0]["function"]["name"], "get_weather");
1273 assert_eq!(out[0]["function"]["parameters"]["required"][0], "location");
1275 }
1276
1277 #[test]
1278 fn openai_response_format_maps_each_variant() {
1279 assert!(openai_response_format(&ResponseFormat::Text).is_none());
1280 assert_eq!(
1281 openai_response_format(&ResponseFormat::Json).unwrap()["type"],
1282 "json_object"
1283 );
1284 let schema = serde_json::json!({"type": "object"});
1285 let js = openai_response_format(&ResponseFormat::JsonSchema { schema }).unwrap();
1286 assert_eq!(js["type"], "json_schema");
1287 assert_eq!(js["json_schema"]["strict"], true);
1288 }
1289
1290 #[test]
1291 fn anthropic_tools_use_input_schema_key() {
1292 let out = anthropic_tools(&[sample_tool()]);
1293 assert_eq!(out[0]["name"], "get_weather");
1294 assert_eq!(out[0]["input_schema"]["type"], "object");
1295 assert!(out[0].get("function").is_none());
1296 }
1297
1298 #[test]
1299 fn anthropic_output_config_only_for_json_schema() {
1300 assert!(anthropic_output_config(&ResponseFormat::Text).is_none());
1301 assert!(anthropic_output_config(&ResponseFormat::Json).is_none());
1302 let schema = serde_json::json!({"type": "object"});
1303 let cfg = anthropic_output_config(&ResponseFormat::JsonSchema { schema }).unwrap();
1304 assert_eq!(cfg["format"]["type"], "json_schema");
1305 }
1306
1307 #[test]
1308 fn openai_request_serializes_tools_and_format() {
1309 let body = OpenAIChatRequest {
1310 model: "gpt-4o".into(),
1311 messages: vec![OpenAIChatMessage {
1312 role: "user".into(),
1313 content: "hi".into(),
1314 }],
1315 temperature: 0.7,
1316 max_tokens: None,
1317 stream: false,
1318 tools: Some(openai_tools(&[sample_tool()])),
1319 response_format: Some(serde_json::json!({ "type": "json_object" })),
1320 reasoning_effort: None,
1321 reasoning: None,
1322 verbosity: None,
1323 };
1324 let json = serde_json::to_value(&body).unwrap();
1325 assert_eq!(json["tools"][0]["function"]["name"], "get_weather");
1326 assert_eq!(json["response_format"]["type"], "json_object");
1327 assert!(json.get("max_tokens").is_none());
1329 assert!(json.get("reasoning_effort").is_none());
1330 assert!(json.get("reasoning").is_none());
1331 }
1332
1333 #[test]
1334 fn openai_reasoning_effort_maps_to_official_param() {
1335 let (effort, reasoning) =
1336 openai_reasoning(Some(&ReasoningConfig::effort(ReasoningEffort::High)));
1337 assert_eq!(effort, Some(ReasoningEffort::High));
1338 assert!(reasoning.is_none());
1339 let body = OpenAIChatRequest {
1340 model: "gpt-5.5".into(),
1341 messages: vec![],
1342 temperature: 0.7,
1343 max_tokens: None,
1344 stream: false,
1345 tools: None,
1346 response_format: None,
1347 reasoning_effort: effort,
1348 reasoning,
1349 verbosity: None,
1350 };
1351 let json = serde_json::to_value(&body).unwrap();
1352 assert_eq!(json["reasoning_effort"], "high");
1353 assert!(json.get("reasoning").is_none());
1354 }
1355
1356 #[test]
1357 fn openai_reasoning_disabled_maps_to_openrouter_object() {
1358 let (effort, reasoning) = openai_reasoning(Some(&ReasoningConfig::disabled()));
1361 assert_eq!(effort, None);
1362 assert_eq!(reasoning.unwrap()["enabled"], false);
1363
1364 let (effort, reasoning) = openai_reasoning(None);
1365 assert!(effort.is_none() && reasoning.is_none());
1366 }
1367
1368 #[test]
1369 fn openai_reasoning_both_knobs_serialize_independently() {
1370 let (effort, reasoning) = openai_reasoning(Some(&ReasoningConfig {
1371 enabled: Some(true),
1372 effort: Some(ReasoningEffort::Low),
1373 summary: Some(ReasoningSummary::Concise),
1374 }));
1375 let body = OpenAIChatRequest {
1376 model: "m".into(),
1377 messages: vec![],
1378 temperature: 0.7,
1379 max_tokens: None,
1380 stream: false,
1381 tools: None,
1382 response_format: None,
1383 reasoning_effort: effort,
1384 reasoning,
1385 verbosity: Some(Verbosity::High),
1386 };
1387 let json = serde_json::to_value(&body).unwrap();
1388 assert_eq!(json["reasoning_effort"], "low");
1389 assert_eq!(json["reasoning"]["enabled"], true);
1390 assert_eq!(json["reasoning"]["summary"], "concise");
1391 assert_eq!(json["verbosity"], "high");
1392 }
1393
1394 #[test]
1395 fn openai_reasoning_summary_only_serializes_object() {
1396 let (effort, reasoning) = openai_reasoning(Some(&ReasoningConfig {
1397 enabled: None,
1398 effort: None,
1399 summary: Some(ReasoningSummary::Detailed),
1400 }));
1401 assert!(effort.is_none());
1402 assert_eq!(reasoning.unwrap()["summary"], "detailed");
1403 }
1404
1405 #[test]
1406 fn openai_body_merges_extra_body_last_write_wins() {
1407 let body = OpenAIChatRequest {
1408 model: "m".into(),
1409 messages: vec![],
1410 temperature: 0.7,
1411 max_tokens: None,
1412 stream: false,
1413 tools: None,
1414 response_format: None,
1415 reasoning_effort: None,
1416 reasoning: None,
1417 verbosity: None,
1418 };
1419 let mut extra = serde_json::Map::new();
1420 extra.insert("seed".into(), 7.into());
1421 extra.insert("temperature".into(), 0.1.into());
1422 let merged = body_with_extra(&body, Some(&extra));
1423 assert_eq!(merged["seed"], 7);
1424 assert_eq!(merged["temperature"], 0.1);
1426 let plain = body_with_extra(&body, None);
1429 assert_eq!(plain["temperature"], serde_json::json!(0.7f32));
1430 assert!(plain.get("seed").is_none());
1431 }
1432
1433 #[test]
1434 fn anthropic_body_merges_extra_body_last_write_wins() {
1435 let body = AnthropicRequest {
1436 model: "m".into(),
1437 max_tokens: 8,
1438 temperature: 0.7,
1439 system: Some("sys".into()),
1440 messages: vec![],
1441 stream: false,
1442 tools: None,
1443 output_config: None,
1444 };
1445 let mut extra = serde_json::Map::new();
1446 extra.insert("thinking".into(), serde_json::json!({"type": "disabled"}));
1447 let merged = body_with_extra(&body, Some(&extra));
1448 assert_eq!(merged["thinking"]["type"], "disabled");
1449 assert_eq!(merged["system"], "sys");
1450 }
1451
1452 #[test]
1453 fn verbosity_wire_values_match_openai_spec() {
1454 for (variant, wire) in [
1456 (Verbosity::Low, "low"),
1457 (Verbosity::Medium, "medium"),
1458 (Verbosity::High, "high"),
1459 ] {
1460 assert_eq!(serde_json::to_value(variant).unwrap(), wire);
1461 }
1462 }
1463
1464 #[test]
1465 fn reasoning_summary_wire_values_match_openai_spec() {
1466 for (variant, wire) in [
1468 (ReasoningSummary::Auto, "auto"),
1469 (ReasoningSummary::Concise, "concise"),
1470 (ReasoningSummary::Detailed, "detailed"),
1471 ] {
1472 assert_eq!(serde_json::to_value(variant).unwrap(), wire);
1473 }
1474 }
1475
1476 #[test]
1477 fn reasoning_effort_wire_values_match_openai_spec() {
1478 for (variant, wire) in [
1480 (ReasoningEffort::None, "none"),
1481 (ReasoningEffort::Minimal, "minimal"),
1482 (ReasoningEffort::Low, "low"),
1483 (ReasoningEffort::Medium, "medium"),
1484 (ReasoningEffort::High, "high"),
1485 (ReasoningEffort::XHigh, "xhigh"),
1486 (ReasoningEffort::Max, "max"),
1487 ] {
1488 assert_eq!(serde_json::to_value(variant).unwrap(), wire);
1489 let back: ReasoningEffort = serde_json::from_value(wire.into()).unwrap();
1490 assert_eq!(back, variant);
1491 }
1492 }
1493
1494 #[test]
1495 fn lenient_decode_rescues_openrouter_control_chars() {
1496 let raw = "{\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"답변\",\"reasoning_content\":\"step 1\nstep 2\ttab\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}";
1499 assert!(serde_json::from_str::<OpenAIChatResponse>(raw).is_err());
1500 let resp: OpenAIChatResponse = from_json_lenient(raw).unwrap();
1501 let first = resp.choices.into_iter().next().unwrap();
1502 assert_eq!(first.message.content.as_deref(), Some("답변"));
1503 assert_eq!(
1504 first.message.reasoning_content.as_deref(),
1505 Some("step 1\nstep 2\ttab")
1506 );
1507 }
1508
1509 #[test]
1510 fn lenient_decode_keeps_original_error_for_structural_garbage() {
1511 let raw = "{\"choices\": tr";
1514 let strict = serde_json::from_str::<OpenAIChatResponse>(raw)
1515 .map(|_: OpenAIChatResponse| ())
1516 .unwrap_err();
1517 let lenient = from_json_lenient::<OpenAIChatResponse>(raw)
1518 .map(|_: OpenAIChatResponse| ())
1519 .unwrap_err();
1520 assert_eq!(lenient.to_string(), strict.to_string());
1521 }
1522
1523 #[test]
1524 fn escape_unescaped_control_chars_only_touches_string_literals() {
1525 let raw = "{\n \"a\": \"x\ny\\n\\tz\",\n \"b\": 1\n}";
1528 let out = escape_unescaped_control_chars(raw);
1529 assert_eq!(out, "{\n \"a\": \"x\\u000ay\\n\\tz\",\n \"b\": 1\n}");
1530 let v: serde_json::Value = serde_json::from_str(&out).unwrap();
1531 assert_eq!(v["a"], "x\ny\n\tz");
1532 }
1533
1534 #[test]
1535 fn escape_unescaped_control_chars_handles_escaped_quotes() {
1536 let raw = "{\"a\":\"say \\\"hi\\\" ok\"}";
1538 assert_eq!(escape_unescaped_control_chars(raw), raw);
1539 }
1540
1541 #[test]
1542 fn openai_sse_lenient_reasoning_delta_with_control_chars() {
1543 let data = "{\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"think\ttab\"},\"finish_reason\":null}]}";
1545 let event = parse_openai_sse(data).unwrap();
1546 match event {
1547 StreamEvent::ReasoningDelta { content } => assert_eq!(content, "think\ttab"),
1548 other => panic!("expected ReasoningDelta, got {other:?}"),
1549 }
1550 }
1551
1552 #[test]
1553 fn openai_response_parses_tool_calls() {
1554 let raw = r#"{
1555 "id": "chatcmpl-1",
1556 "created": 1700000000,
1557 "model": "gpt-4o",
1558 "choices": [{
1559 "index": 0,
1560 "message": {
1561 "role": "assistant",
1562 "content": null,
1563 "tool_calls": [{
1564 "id": "call_abc",
1565 "type": "function",
1566 "function": { "name": "get_weather", "arguments": "{\"location\":\"Paris\"}" }
1567 }]
1568 },
1569 "finish_reason": "tool_calls"
1570 }],
1571 "usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15 }
1572 }"#;
1573 let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1574 assert_eq!(resp.id.as_deref(), Some("chatcmpl-1"));
1575 let choice = resp.choices.into_iter().next().unwrap();
1576 assert_eq!(choice.finish_reason.as_deref(), Some("tool_calls"));
1577 assert!(choice.message.content.is_none());
1578 assert_eq!(choice.message.tool_calls.len(), 1);
1579 assert_eq!(choice.message.tool_calls[0].function.name, "get_weather");
1580 }
1581
1582 #[test]
1583 fn openai_response_parses_glm47_reasoning_content() {
1584 let raw = r#"{
1586 "id":"chatcmpl-1","created":1700000000,"model":"glm-4.7",
1587 "choices":[{"index":0,"message":{"role":"assistant","content":null,
1588 "reasoning_content":"thinking... {\"rating\":\"Buy\"}"},"finish_reason":"stop"}],
1589 "usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15,
1590 "completion_tokens_details":{"reasoning_tokens":3}}
1591 }"#;
1592 let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1593 let choice = resp.choices.into_iter().next().unwrap();
1594 assert!(choice.message.content.is_none());
1595 assert!(choice.message.reasoning_content.is_some());
1596 assert_eq!(
1597 resp.usage
1598 .unwrap()
1599 .completion_tokens_details
1600 .unwrap()
1601 .reasoning_tokens,
1602 Some(3)
1603 );
1604 }
1605
1606 #[test]
1607 fn openai_complete_promotes_reasoning_when_content_empty() {
1608 let raw = r#"{
1612 "id":"chatcmpl-1","created":1700000000,"model":"glm-4.7",
1613 "choices":[{"index":0,"message":{"role":"assistant","content":"",
1614 "reasoning_content":"사고 {\"rating\":\"Sell\",\"key_thesis\":\"약세\"}"},"finish_reason":"stop"}]
1615 }"#;
1616 let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1617 let first = resp.choices.into_iter().next().unwrap();
1618 let raw_content = first.message.content.unwrap_or_default();
1619 let reasoning = first.message.reasoning_content.clone();
1620 let content = promote_reasoning_into_content(raw_content, reasoning.as_deref());
1621 assert!(
1622 content.contains("\"rating\":\"Sell\""),
1623 "promoted content must contain JSON: {content}"
1624 );
1625 assert_eq!(
1626 reasoning.as_deref(),
1627 Some("사고 {\"rating\":\"Sell\",\"key_thesis\":\"약세\"}")
1628 );
1629 }
1630
1631 #[test]
1632 fn openai_complete_keeps_content_when_present() {
1633 let raw = r#"{
1635 "id":"chatcmpl-1","created":1700000000,"model":"gpt-4o",
1636 "choices":[{"index":0,"message":{"role":"assistant","content":"answer",
1637 "reasoning_content":"thought"},"finish_reason":"stop"}]
1638 }"#;
1639 let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1640 let first = resp.choices.into_iter().next().unwrap();
1641 let raw_content = first.message.content.clone().unwrap_or_default();
1642 let reasoning = first.message.reasoning_content.as_deref();
1643 let content = promote_reasoning_into_content(raw_content.clone(), reasoning);
1644 assert_eq!(content, "answer");
1645 assert_eq!(reasoning, Some("thought"));
1646 }
1647
1648 #[test]
1649 fn promote_reasoning_into_content_edge_cases() {
1650 assert_eq!(
1653 promote_reasoning_into_content(String::new(), Some("chain-of-thought")),
1654 "chain-of-thought"
1655 );
1656 assert_eq!(
1658 promote_reasoning_into_content("ans".into(), Some("cot")),
1659 "ans"
1660 );
1661 assert_eq!(promote_reasoning_into_content(String::new(), None), "");
1663 assert_eq!(promote_reasoning_into_content("x".into(), None), "x");
1664 }
1665
1666 #[test]
1667 fn openai_response_parses_deepseek_reasoning_alias() {
1668 let raw = r#"{"model":"deepseek-r1","choices":[{"message":{"content":"ans","reasoning":"thought"}}]}"#;
1670 let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1671 assert_eq!(
1672 resp.choices[0].message.reasoning_content.as_deref(),
1673 Some("thought")
1674 );
1675 }
1676
1677 #[test]
1678 fn openai_sse_reasoning_delta_extracted() {
1679 let data = r#"{"choices":[{"index":0,"delta":{"reasoning_content":"thinking"},"finish_reason":null}]}"#;
1680 let event = parse_openai_sse(data).unwrap();
1681 match event {
1682 StreamEvent::ReasoningDelta { content } => assert_eq!(content, "thinking"),
1683 other => panic!("expected ReasoningDelta, got {other:?}"),
1684 }
1685 }
1686
1687 #[test]
1688 fn openai_sse_content_delta_still_works_alongside_reasoning() {
1689 let data = r#"{"choices":[{"index":0,"delta":{"content":"answer"},"finish_reason":null}]}"#;
1691 let event = parse_openai_sse(data).unwrap();
1692 assert!(matches!(event, StreamEvent::Delta { .. }));
1693 }
1694
1695 #[test]
1696 fn openai_sse_usage_carries_reasoning_tokens() {
1697 let data = r#"{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],
1699 "usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3,
1700 "completion_tokens_details":{"reasoning_tokens":7}}}"#;
1701 let event = parse_openai_sse(data).unwrap();
1702 match event {
1703 StreamEvent::Usage(u) => assert_eq!(u.reasoning_tokens, Some(7)),
1704 other => panic!("expected Usage, got {other:?}"),
1705 }
1706 }
1707
1708 #[test]
1709 fn anthropic_response_parses_tool_use_block() {
1710 let raw = r#"{
1711 "id": "msg_1",
1712 "model": "claude-sonnet-4-6",
1713 "stop_reason": "tool_use",
1714 "content": [
1715 { "type": "text", "text": "Let me check." },
1716 { "type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": { "location": "Paris" } }
1717 ],
1718 "usage": { "input_tokens": 12, "output_tokens": 8 }
1719 }"#;
1720 let resp: AnthropicResponse = serde_json::from_str(raw).unwrap();
1721 assert_eq!(resp.stop_reason.as_deref(), Some("tool_use"));
1722 assert_eq!(resp.content.len(), 2);
1723 assert_eq!(resp.content[0].block_type, "text");
1724 assert_eq!(resp.content[1].block_type, "tool_use");
1725 assert_eq!(resp.content[1].name.as_deref(), Some("get_weather"));
1726 assert_eq!(resp.content[1].input.as_ref().unwrap()["location"], "Paris");
1727 }
1728
1729 #[test]
1730 fn anthropic_response_parses_thinking_block() {
1731 let raw = r#"{
1733 "id": "msg_2",
1734 "model": "claude-sonnet-4-6",
1735 "stop_reason": "end_turn",
1736 "content": [
1737 { "type": "thinking", "thinking": "step by step..." },
1738 { "type": "text", "text": "Final answer." }
1739 ],
1740 "usage": { "input_tokens": 5, "output_tokens": 9 }
1741 }"#;
1742 let resp: AnthropicResponse = serde_json::from_str(raw).unwrap();
1743 let mut reasoning = String::new();
1744 let mut content = String::new();
1745 for block in resp.content {
1746 match block.block_type.as_str() {
1747 "text" => {
1748 if let Some(t) = block.text {
1749 content.push_str(&t);
1750 }
1751 }
1752 "thinking" => {
1753 if let Some(t) = block.thinking {
1754 reasoning.push_str(&t);
1755 }
1756 }
1757 _ => {}
1758 }
1759 }
1760 assert_eq!(content, "Final answer.");
1761 assert_eq!(reasoning, "step by step...");
1762 }
1763
1764 #[test]
1765 fn anthropic_sse_thinking_delta_extracted() {
1766 let data = r#"{"delta":{"type":"thinking_delta","thinking":"a thought"}}"#;
1767 let event = parse_anthropic_sse("content_block_delta", data).unwrap();
1768 match event {
1769 StreamEvent::ReasoningDelta { content } => assert_eq!(content, "a thought"),
1770 other => panic!("expected ReasoningDelta, got {other:?}"),
1771 }
1772 }
1773}