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(
83 rf: Option<&ResponseFormat>,
84 effort: Option<ReasoningEffort>,
85) -> Option<serde_json::Value> {
86 let mut obj = serde_json::Map::new();
87 if let Some(ResponseFormat::JsonSchema { schema }) = rf {
88 obj.insert(
89 "format".into(),
90 serde_json::json!({ "type": "json_schema", "schema": schema }),
91 );
92 }
93 if let Some(effort) = effort {
94 obj.insert(
95 "effort".into(),
96 serde_json::to_value(effort).expect("ReasoningEffort is a plain enum"),
97 );
98 }
99 if obj.is_empty() {
100 None
101 } else {
102 Some(serde_json::Value::Object(obj))
103 }
104}
105
106fn openai_reasoning(
113 cfg: Option<&ReasoningConfig>,
114) -> (Option<ReasoningEffort>, Option<serde_json::Value>) {
115 match cfg {
116 None => (None, None),
117 Some(c) => {
118 let mut obj = serde_json::Map::new();
119 if let Some(enabled) = c.enabled {
120 obj.insert("enabled".into(), enabled.into());
121 }
122 if let Some(summary) = c.summary {
123 obj.insert(
124 "summary".into(),
125 serde_json::to_value(summary).expect("ReasoningSummary is a plain enum"),
126 );
127 }
128 (
129 c.effort,
130 if obj.is_empty() {
131 None
132 } else {
133 Some(serde_json::Value::Object(obj))
134 },
135 )
136 }
137 }
138}
139
140fn body_with_extra<T: serde::Serialize>(
144 body: &T,
145 extra: Option<&serde_json::Map<String, serde_json::Value>>,
146) -> serde_json::Value {
147 let mut v = serde_json::to_value(body).expect("request body serializes");
148 if let Some(extra) = extra
149 && let serde_json::Value::Object(map) = &mut v
150 {
151 for (k, val) in extra {
152 map.insert(k.clone(), val.clone());
153 }
154 }
155 v
156}
157
158fn http_client() -> Result<reqwest::Client> {
160 crate::tls::ensure_tls_provider();
161 reqwest::Client::builder()
162 .connect_timeout(Duration::from_secs(10))
163 .timeout(Duration::from_secs(120))
164 .build()
165 .map_err(|e| KernelError::Config(format!("Failed to build HTTP client: {}", e)))
166}
167
168fn check_rate_limit(resp: &reqwest::Response) -> Result<()> {
170 if resp.status().as_u16() == 429 {
171 let retry = resp
172 .headers()
173 .get("retry-after")
174 .and_then(|v| v.to_str().ok())
175 .and_then(|v| v.parse().ok())
176 .unwrap_or(60);
177 return Err(KernelError::RateLimited(retry));
178 }
179 Ok(())
180}
181
182#[async_trait]
184pub trait LLMClient: Send + Sync {
185 async fn complete(&self, request: LLMRequest) -> Result<LLMResponse>;
197 fn model_name(&self) -> &str;
199
200 async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream>;
207}
208
209pub struct OpenAIClient {
211 api_key: String,
212 model: String,
213 base_url: String,
214 client: reqwest::Client,
215}
216
217impl OpenAIClient {
218 pub fn new(config: &ModelConfig) -> Result<Self> {
220 let api_key = std::env::var(&config.api_key_env).map_err(|_| {
221 KernelError::Config(format!(
222 "Environment variable {} not set",
223 config.api_key_env
224 ))
225 })?;
226 Ok(Self {
227 api_key,
228 model: config.model.clone(),
229 base_url: config
230 .base_url
231 .clone()
232 .unwrap_or_else(|| "https://api.openai.com/v1".into()),
233 client: http_client()?,
234 })
235 }
236
237 pub fn from_key(model: impl Into<String>, api_key: impl Into<String>) -> Result<Self> {
251 Ok(Self {
252 api_key: api_key.into(),
253 model: model.into(),
254 base_url: "https://api.openai.com/v1".into(),
255 client: http_client()?,
256 })
257 }
258
259 pub fn from_key_with_client(
265 model: impl Into<String>,
266 api_key: impl Into<String>,
267 client: reqwest::Client,
268 ) -> Self {
269 Self {
270 api_key: api_key.into(),
271 model: model.into(),
272 base_url: "https://api.openai.com/v1".into(),
273 client,
274 }
275 }
276
277 pub fn from_key_with_base_url(
283 model: impl Into<String>,
284 api_key: impl Into<String>,
285 base_url: impl Into<String>,
286 client: reqwest::Client,
287 ) -> Self {
288 Self {
289 api_key: api_key.into(),
290 model: model.into(),
291 base_url: base_url.into(),
292 client,
293 }
294 }
295}
296
297#[derive(serde::Serialize)]
298struct OpenAIChatRequest {
299 model: String,
300 messages: Vec<OpenAIChatMessage>,
301 temperature: f32,
302 #[serde(skip_serializing_if = "Option::is_none")]
303 max_tokens: Option<u32>,
304 #[serde(skip_serializing_if = "std::ops::Not::not")]
305 stream: bool,
306 #[serde(skip_serializing_if = "Option::is_none")]
307 tools: Option<Vec<serde_json::Value>>,
308 #[serde(skip_serializing_if = "Option::is_none")]
309 response_format: Option<serde_json::Value>,
310 #[serde(skip_serializing_if = "Option::is_none")]
312 reasoning_effort: Option<ReasoningEffort>,
313 #[serde(skip_serializing_if = "Option::is_none")]
315 reasoning: Option<serde_json::Value>,
316 #[serde(skip_serializing_if = "Option::is_none")]
318 verbosity: Option<Verbosity>,
319}
320
321#[derive(serde::Serialize)]
322struct OpenAIChatMessage {
323 role: String,
324 content: String,
325}
326
327#[derive(serde::Deserialize)]
328struct OpenAIChatResponse {
329 #[serde(default)]
330 id: Option<String>,
331 #[serde(default)]
332 created: Option<u64>,
333 choices: Vec<OpenAIChoice>,
334 model: String,
335 usage: Option<OpenAIUsage>,
336}
337
338#[derive(serde::Deserialize)]
339struct OpenAIChoice {
340 message: OpenAIRespMessage,
341 #[serde(default)]
342 finish_reason: Option<String>,
343}
344
345#[derive(serde::Deserialize)]
352struct OpenAIRespMessage {
353 #[serde(default)]
354 content: Option<String>,
355 #[serde(default, alias = "reasoning")]
357 reasoning_content: Option<String>,
358 #[serde(default)]
359 tool_calls: Vec<OpenAIToolCall>,
360}
361
362#[derive(serde::Deserialize)]
363struct OpenAIToolCall {
364 id: String,
365 function: OpenAIFunctionCall,
366}
367
368#[derive(serde::Deserialize)]
369struct OpenAIFunctionCall {
370 name: String,
371 #[serde(default)]
372 arguments: String,
373}
374
375#[derive(serde::Deserialize)]
381struct OpenAIUsage {
382 #[serde(default)]
383 prompt_tokens: u32,
384 #[serde(default)]
385 completion_tokens: u32,
386 #[serde(default)]
387 total_tokens: u32,
388 #[serde(default)]
390 completion_tokens_details: Option<OpenAICompletionTokensDetails>,
391}
392
393#[derive(serde::Deserialize)]
395struct OpenAICompletionTokensDetails {
396 #[serde(default)]
397 reasoning_tokens: Option<u32>,
398}
399
400#[async_trait]
401impl LLMClient for OpenAIClient {
402 async fn complete(&self, request: LLMRequest) -> Result<LLMResponse> {
403 let model = request.model.clone().unwrap_or_else(|| self.model.clone());
404 let temperature = request.temperature.unwrap_or(0.7);
408 let max_tokens = request.max_tokens;
409 let tools = request
410 .tools
411 .as_deref()
412 .map(openai_tools)
413 .filter(|t| !t.is_empty());
414 let response_format = request
415 .response_format
416 .as_ref()
417 .and_then(openai_response_format);
418 let (reasoning_effort, reasoning) = openai_reasoning(request.reasoning.as_ref());
419 let verbosity = request.verbosity;
420 let extra_body = request.extra_body.clone();
421 let messages: Vec<_> = request
422 .into_openai_messages()
423 .into_iter()
424 .map(|(role, content)| OpenAIChatMessage { role, content })
425 .collect();
426
427 let body = OpenAIChatRequest {
428 model,
429 messages,
430 temperature,
431 max_tokens,
432 stream: false,
433 tools,
434 response_format,
435 reasoning_effort,
436 reasoning,
437 verbosity,
438 };
439
440 let resp = self
441 .client
442 .post(format!("{}/chat/completions", self.base_url))
443 .header("Authorization", format!("Bearer {}", self.api_key))
444 .json(&body_with_extra(&body, extra_body.as_ref()))
445 .send()
446 .await
447 .map_err(|e| KernelError::LlmApi(e.to_string()))?;
448
449 check_rate_limit(&resp)?;
450
451 let status = resp.status();
452
453 if !status.is_success() {
454 let text = resp.text().await.unwrap_or_default();
455 return Err(KernelError::Http {
456 status: status.as_u16(),
457 message: redact_http_body(&text),
458 });
459 }
460
461 let text = resp
462 .text()
463 .await
464 .map_err(|e| KernelError::LlmApi(e.to_string()))?;
465 let chat_resp: OpenAIChatResponse = from_json_lenient(&text)
466 .map_err(|e| KernelError::LlmApi(format!("error decoding response body: {e}")))?;
467
468 let id = chat_resp.id;
469 let created = chat_resp.created;
470 let first = chat_resp.choices.into_iter().next();
471 let finish_reason = first.as_ref().and_then(|c| c.finish_reason.clone());
472 let (content, reasoning, tool_calls) = match first {
473 Some(c) => {
474 let raw_content = c.message.content.unwrap_or_default();
475 let reasoning = c.message.reasoning_content;
476 let content = promote_reasoning_into_content(raw_content, reasoning.as_deref());
477 let calls = c
478 .message
479 .tool_calls
480 .into_iter()
481 .map(|tc| ToolCall {
482 id: tc.id,
483 name: tc.function.name,
484 arguments: tc.function.arguments,
485 })
486 .collect();
487 (content, reasoning, calls)
488 }
489 None => (String::new(), None, Vec::new()),
490 };
491
492 let usage = chat_resp.usage.map(|u| TokenUsage {
493 prompt_tokens: u.prompt_tokens,
494 completion_tokens: u.completion_tokens,
495 total_tokens: u.total_tokens,
496 reasoning_tokens: u.completion_tokens_details.and_then(|d| d.reasoning_tokens),
497 });
498
499 Ok(LLMResponse {
500 content,
501 reasoning,
502 model: chat_resp.model,
503 usage: usage.unwrap_or_default(),
504 tool_calls,
505 finish_reason,
506 id,
507 created,
508 })
509 }
510
511 fn model_name(&self) -> &str {
512 &self.model
513 }
514
515 async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream> {
516 let model = request.model.clone().unwrap_or_else(|| self.model.clone());
517 let temperature = request.temperature.unwrap_or(0.7);
519 let max_tokens = request.max_tokens;
520 let (reasoning_effort, reasoning) = openai_reasoning(request.reasoning.as_ref());
521 let verbosity = request.verbosity;
522 let extra_body = request.extra_body.clone();
523 let messages: Vec<_> = request
524 .into_openai_messages()
525 .into_iter()
526 .map(|(role, content)| OpenAIChatMessage { role, content })
527 .collect();
528
529 let body = OpenAIChatRequest {
530 model,
531 messages,
532 temperature,
533 max_tokens,
534 stream: true,
535 tools: None,
538 response_format: None,
539 reasoning_effort,
540 reasoning,
541 verbosity,
542 };
543
544 let resp = self
545 .client
546 .post(format!("{}/chat/completions", self.base_url))
547 .header("Authorization", format!("Bearer {}", self.api_key))
548 .json(&body_with_extra(&body, extra_body.as_ref()))
549 .send()
550 .await
551 .map_err(|e| KernelError::LlmApi(e.to_string()))?;
552
553 check_rate_limit(&resp)?;
554
555 let status = resp.status();
556 if !status.is_success() {
557 let text = resp.text().await.unwrap_or_default();
558 return Err(KernelError::Http {
559 status: status.as_u16(),
560 message: redact_http_body(&text),
561 });
562 }
563
564 let (tx, rx) = tokio::sync::mpsc::channel::<Result<StreamEvent>>(16);
565
566 tokio::spawn(async move {
567 let mut stream = std::pin::pin!(resp.bytes_stream());
568 let mut buffer: Vec<u8> = Vec::new();
569
570 use tokio_stream::StreamExt;
571
572 while let Some(chunk) = stream.next().await {
573 let chunk = match chunk {
574 Ok(c) => c,
575 Err(e) => {
576 let _ = tx.send(Err(KernelError::LlmApi(e.to_string()))).await;
577 return;
578 }
579 };
580
581 for line in drain_sse_lines(&mut buffer, &chunk) {
582 if let Some(data) = parse_sse_line(&line)
583 && let Some(event) = parse_openai_sse(data)
584 {
585 let is_done = matches!(event, StreamEvent::Done);
586 if tx.send(Ok(event)).await.is_err() || is_done {
587 return;
588 }
589 }
590 }
591 }
592 let _ = tx.send(Ok(StreamEvent::Done)).await;
593 });
594
595 Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
596 }
597}
598
599fn parse_sse_line(line: &str) -> Option<&str> {
602 line.strip_prefix("data: ").filter(|d| *d != "[DONE]")
603}
604
605fn drain_sse_lines(buffer: &mut Vec<u8>, chunk: &[u8]) -> Vec<String> {
615 buffer.extend_from_slice(chunk);
616 let mut lines = Vec::new();
617 while let Some(pos) = buffer.iter().position(|&b| b == b'\n') {
618 let line: Vec<u8> = buffer.drain(..=pos).collect();
619 lines.push(String::from_utf8_lossy(&line).trim_end().to_string());
620 }
621 lines
622}
623
624fn promote_reasoning_into_content(raw_content: String, reasoning: Option<&str>) -> String {
645 if raw_content.is_empty() {
646 reasoning.unwrap_or_default().to_string()
647 } else {
648 raw_content
649 }
650}
651
652fn escape_unescaped_control_chars(raw: &str) -> String {
662 let mut out = String::with_capacity(raw.len());
663 let mut in_string = false;
664 let mut escaped = false;
665 for ch in raw.chars() {
666 if escaped {
667 escaped = false;
668 out.push(ch);
669 continue;
670 }
671 match ch {
672 '"' => {
673 in_string = !in_string;
674 out.push(ch);
675 }
676 '\\' if in_string => {
677 escaped = true;
678 out.push(ch);
679 }
680 c if in_string && (c as u32) < 0x20 => {
681 out.push_str(&format!("\\u{:04x}", c as u32));
682 }
683 c => out.push(c),
684 }
685 }
686 out
687}
688
689fn from_json_lenient<T: serde::de::DeserializeOwned>(raw: &str) -> serde_json::Result<T> {
697 match serde_json::from_str(raw) {
698 Ok(v) => Ok(v),
699 Err(err) => serde_json::from_str(&escape_unescaped_control_chars(raw)).map_err(|_| err),
700 }
701}
702
703fn parse_openai_sse(data: &str) -> Option<StreamEvent> {
705 let v: serde_json::Value = from_json_lenient(data).ok()?;
706
707 if let Some(rc) = v
710 .get("choices")?
711 .get(0)?
712 .get("delta")?
713 .get("reasoning_content")
714 .and_then(|c| c.as_str())
715 && !rc.is_empty()
716 {
717 return Some(StreamEvent::ReasoningDelta {
718 content: rc.to_string(),
719 });
720 }
721
722 if let Some(content) = v
724 .get("choices")?
725 .get(0)?
726 .get("delta")?
727 .get("content")
728 .and_then(|c| c.as_str())
729 && !content.is_empty()
730 {
731 return Some(StreamEvent::Delta {
732 content: content.to_string(),
733 });
734 }
735
736 if let Some(usage) = v.get("usage").and_then(|u| {
738 Some(TokenUsage {
739 prompt_tokens: u.get("prompt_tokens")?.as_u64()? as u32,
740 completion_tokens: u.get("completion_tokens")?.as_u64()? as u32,
741 total_tokens: u.get("total_tokens")?.as_u64()? as u32,
742 reasoning_tokens: u
743 .get("completion_tokens_details")
744 .and_then(|d| d.get("reasoning_tokens"))
745 .and_then(|r| r.as_u64())
746 .map(|n| n as u32),
747 })
748 }) {
749 return Some(StreamEvent::Usage(usage));
750 }
751
752 if v.get("choices")?
754 .get(0)?
755 .get("finish_reason")
756 .and_then(|r| r.as_str())
757 .is_some()
758 {
759 return Some(StreamEvent::Done);
760 }
761
762 None
763}
764
765fn anthropic_input_tokens(u: &serde_json::Value) -> Option<u32> {
768 let fields = [
769 "input_tokens",
770 "cache_creation_input_tokens",
771 "cache_read_input_tokens",
772 ];
773 let values: Vec<u64> = fields
774 .iter()
775 .filter_map(|f| u.get(*f).and_then(|n| n.as_u64()))
776 .collect();
777 (!values.is_empty()).then(|| values.iter().sum::<u64>() as u32)
778}
779
780fn parse_anthropic_sse(event_type: &str, data: &str) -> Vec<StreamEvent> {
784 let Ok(v) = serde_json::from_str::<serde_json::Value>(data) else {
785 return Vec::new();
786 };
787
788 match event_type {
789 "message_start" => {
791 let Some(u) = v.get("message").and_then(|m| m.get("usage")) else {
792 return Vec::new();
793 };
794 let prompt = anthropic_input_tokens(u).unwrap_or(0);
795 let completion = u.get("output_tokens").and_then(|n| n.as_u64()).unwrap_or(0) as u32;
796 vec![StreamEvent::Usage(TokenUsage {
797 prompt_tokens: prompt,
798 completion_tokens: completion,
799 total_tokens: prompt + completion,
800 reasoning_tokens: None,
801 })]
802 }
803 "content_block_delta" => {
804 let Some(delta) = v.get("delta") else {
805 return Vec::new();
806 };
807 match delta.get("type").and_then(|t| t.as_str()) {
809 Some("thinking_delta") => match delta.get("thinking").and_then(|t| t.as_str()) {
810 Some(text) if !text.is_empty() => {
811 vec![StreamEvent::ReasoningDelta {
812 content: text.to_string(),
813 }]
814 }
815 _ => Vec::new(),
816 },
817 _ => {
818 match delta.get("text").and_then(|t| t.as_str()) {
820 Some(text) if !text.is_empty() => {
821 vec![StreamEvent::Delta {
822 content: text.to_string(),
823 }]
824 }
825 _ => Vec::new(),
826 }
827 }
828 }
829 }
830 "message_delta" => {
831 let mut events = Vec::new();
832 if let Some(reason) = v
836 .get("delta")
837 .and_then(|d| d.get("stop_reason"))
838 .and_then(|r| r.as_str())
839 {
840 events.push(StreamEvent::Stopped {
841 reason: reason.to_string(),
842 });
843 }
844 let usage = v.get("usage").and_then(|u| {
845 let prompt = anthropic_input_tokens(u).unwrap_or(0);
848 let completion = u.get("output_tokens")?.as_u64()? as u32;
849 Some(TokenUsage {
850 prompt_tokens: prompt,
851 completion_tokens: completion,
852 total_tokens: prompt + completion,
853 reasoning_tokens: None,
854 })
855 });
856 match usage {
857 Some(u) => events.push(StreamEvent::Usage(u)),
858 None => events.push(StreamEvent::Done),
859 }
860 events
861 }
862 "message_stop" => vec![StreamEvent::Done],
863 _ => Vec::new(),
864 }
865}
866
867pub struct AnthropicClient {
869 api_key: String,
870 model: String,
871 base_url: String,
872 client: reqwest::Client,
873}
874
875impl AnthropicClient {
876 pub fn new(config: &ModelConfig) -> Result<Self> {
878 let api_key = std::env::var(&config.api_key_env).map_err(|_| {
879 KernelError::Config(format!(
880 "Environment variable {} not set",
881 config.api_key_env
882 ))
883 })?;
884 Ok(Self {
885 api_key,
886 model: config.model.clone(),
887 base_url: config
888 .base_url
889 .clone()
890 .unwrap_or_else(|| "https://api.anthropic.com/v1".into()),
891 client: http_client()?,
892 })
893 }
894
895 pub fn from_key(model: impl Into<String>, api_key: impl Into<String>) -> Result<Self> {
901 Ok(Self {
902 api_key: api_key.into(),
903 model: model.into(),
904 base_url: "https://api.anthropic.com/v1".into(),
905 client: http_client()?,
906 })
907 }
908
909 pub fn from_key_with_client(
911 model: impl Into<String>,
912 api_key: impl Into<String>,
913 client: reqwest::Client,
914 ) -> Self {
915 Self {
916 api_key: api_key.into(),
917 model: model.into(),
918 base_url: "https://api.anthropic.com/v1".into(),
919 client,
920 }
921 }
922
923 pub fn from_key_with_base_url(
928 model: impl Into<String>,
929 api_key: impl Into<String>,
930 base_url: impl Into<String>,
931 client: reqwest::Client,
932 ) -> Self {
933 Self {
934 api_key: api_key.into(),
935 model: model.into(),
936 base_url: base_url.into(),
937 client,
938 }
939 }
940}
941
942#[derive(serde::Serialize)]
943struct AnthropicRequest {
944 model: String,
945 max_tokens: u32,
946 #[serde(skip_serializing_if = "Option::is_none")]
950 temperature: Option<f32>,
951 #[serde(skip_serializing_if = "Option::is_none")]
952 system: Option<String>,
953 messages: Vec<AnthropicMessage>,
954 #[serde(skip_serializing_if = "std::ops::Not::not")]
955 stream: bool,
956 #[serde(skip_serializing_if = "Option::is_none")]
957 tools: Option<Vec<serde_json::Value>>,
958 #[serde(skip_serializing_if = "Option::is_none")]
959 output_config: Option<serde_json::Value>,
960}
961
962#[derive(serde::Serialize)]
963struct AnthropicMessage {
964 role: String,
965 content: String,
966}
967
968#[derive(serde::Deserialize)]
969struct AnthropicResponse {
970 #[serde(default)]
971 id: Option<String>,
972 content: Vec<AnthropicContentBlock>,
973 model: String,
974 #[serde(default)]
975 stop_reason: Option<String>,
976 usage: AnthropicUsage,
977}
978
979#[derive(serde::Deserialize)]
982struct AnthropicContentBlock {
983 #[serde(rename = "type")]
984 block_type: String,
985 #[serde(default)]
986 text: Option<String>,
987 #[serde(default)]
989 thinking: Option<String>,
990 #[serde(default)]
991 id: Option<String>,
992 #[serde(default)]
993 name: Option<String>,
994 #[serde(default)]
995 input: Option<serde_json::Value>,
996}
997
998#[derive(serde::Deserialize)]
999struct AnthropicUsage {
1000 input_tokens: u32,
1001 output_tokens: u32,
1002 #[serde(default)]
1003 cache_creation_input_tokens: u32,
1004 #[serde(default)]
1005 cache_read_input_tokens: u32,
1006}
1007
1008impl AnthropicUsage {
1009 fn prompt_tokens(&self) -> u32 {
1011 self.input_tokens + self.cache_creation_input_tokens + self.cache_read_input_tokens
1012 }
1013}
1014
1015#[async_trait]
1016impl LLMClient for AnthropicClient {
1017 async fn complete(&self, request: LLMRequest) -> Result<LLMResponse> {
1018 let model = request.model.clone().unwrap_or_else(|| self.model.clone());
1019 let max_tokens = request.max_tokens.unwrap_or(4096);
1020 let temperature = request.temperature;
1021 let system = request.system.clone();
1022 let tools = request
1023 .tools
1024 .as_deref()
1025 .map(anthropic_tools)
1026 .filter(|t| !t.is_empty());
1027 let output_config = anthropic_output_config(
1028 request.response_format.as_ref(),
1029 request.reasoning.as_ref().and_then(|r| r.effort),
1030 );
1031 let extra_body = request.extra_body.clone();
1032 let messages: Vec<AnthropicMessage> = request
1033 .into_anthropic_messages()
1034 .into_iter()
1035 .map(|(role, content)| AnthropicMessage { role, content })
1036 .collect();
1037
1038 let body = AnthropicRequest {
1039 model,
1040 max_tokens,
1041 temperature,
1042 system,
1043 messages,
1044 stream: false,
1045 tools,
1046 output_config,
1047 };
1048
1049 let resp = self
1050 .client
1051 .post(format!("{}/messages", self.base_url))
1052 .header("x-api-key", &self.api_key)
1053 .header("anthropic-version", "2023-06-01")
1054 .header("content-type", "application/json")
1055 .json(&body_with_extra(&body, extra_body.as_ref()))
1056 .send()
1057 .await
1058 .map_err(|e| KernelError::LlmApi(e.to_string()))?;
1059
1060 check_rate_limit(&resp)?;
1061
1062 let status = resp.status();
1063
1064 if !status.is_success() {
1065 let text = resp.text().await.unwrap_or_default();
1066 return Err(KernelError::Http {
1067 status: status.as_u16(),
1068 message: redact_http_body(&text),
1069 });
1070 }
1071
1072 let chat_resp: AnthropicResponse = resp
1073 .json()
1074 .await
1075 .map_err(|e| KernelError::LlmApi(e.to_string()))?;
1076
1077 let mut content = String::new();
1078 let mut reasoning = String::new();
1079 let mut tool_calls = Vec::new();
1080 for block in chat_resp.content {
1081 match block.block_type.as_str() {
1082 "text" => {
1083 if let Some(t) = block.text {
1084 content.push_str(&t);
1085 }
1086 }
1087 "thinking" => {
1088 if let Some(t) = block.thinking {
1089 reasoning.push_str(&t);
1090 }
1091 }
1092 "tool_use" => {
1093 if let (Some(id), Some(name)) = (block.id, block.name) {
1094 let arguments = block
1095 .input
1096 .map(|v| v.to_string())
1097 .unwrap_or_else(|| "{}".to_string());
1098 tool_calls.push(ToolCall {
1099 id,
1100 name,
1101 arguments,
1102 });
1103 }
1104 }
1105 _ => {}
1106 }
1107 }
1108
1109 Ok(LLMResponse {
1110 content,
1111 reasoning: if reasoning.is_empty() {
1112 None
1113 } else {
1114 Some(reasoning)
1115 },
1116 model: chat_resp.model,
1117 usage: TokenUsage {
1118 prompt_tokens: chat_resp.usage.prompt_tokens(),
1119 completion_tokens: chat_resp.usage.output_tokens,
1120 total_tokens: chat_resp.usage.prompt_tokens() + chat_resp.usage.output_tokens,
1121 reasoning_tokens: None,
1122 },
1123 tool_calls,
1124 finish_reason: chat_resp.stop_reason,
1125 id: chat_resp.id,
1126 created: None,
1127 })
1128 }
1129
1130 fn model_name(&self) -> &str {
1131 &self.model
1132 }
1133
1134 async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream> {
1135 let model = request.model.clone().unwrap_or_else(|| self.model.clone());
1136 let max_tokens = request.max_tokens.unwrap_or(4096);
1137 let temperature = request.temperature;
1138 let system = request.system.clone();
1139 let extra_body = request.extra_body.clone();
1140 let output_config =
1141 anthropic_output_config(None, request.reasoning.as_ref().and_then(|r| r.effort));
1142 let messages: Vec<AnthropicMessage> = request
1143 .into_anthropic_messages()
1144 .into_iter()
1145 .map(|(role, content)| AnthropicMessage { role, content })
1146 .collect();
1147
1148 let body = AnthropicRequest {
1149 model,
1150 max_tokens,
1151 temperature,
1152 system,
1153 messages,
1154 stream: true,
1155 tools: None,
1160 output_config,
1161 };
1162
1163 let resp = self
1164 .client
1165 .post(format!("{}/messages", self.base_url))
1166 .header("x-api-key", &self.api_key)
1167 .header("anthropic-version", "2023-06-01")
1168 .header("content-type", "application/json")
1169 .json(&body_with_extra(&body, extra_body.as_ref()))
1170 .send()
1171 .await
1172 .map_err(|e| KernelError::LlmApi(e.to_string()))?;
1173
1174 check_rate_limit(&resp)?;
1175
1176 let status = resp.status();
1177 if !status.is_success() {
1178 let text = resp.text().await.unwrap_or_default();
1179 return Err(KernelError::Http {
1180 status: status.as_u16(),
1181 message: redact_http_body(&text),
1182 });
1183 }
1184
1185 let (tx, rx) = tokio::sync::mpsc::channel::<Result<StreamEvent>>(16);
1186
1187 tokio::spawn(async move {
1188 let mut stream = std::pin::pin!(resp.bytes_stream());
1189 let mut buffer: Vec<u8> = Vec::new();
1190 let mut current_event = String::new();
1191
1192 use tokio_stream::StreamExt;
1193
1194 while let Some(chunk) = stream.next().await {
1195 let chunk = match chunk {
1196 Ok(c) => c,
1197 Err(e) => {
1198 let _ = tx.send(Err(KernelError::LlmApi(e.to_string()))).await;
1199 return;
1200 }
1201 };
1202
1203 for line in drain_sse_lines(&mut buffer, &chunk) {
1204 if let Some(evt) = line.strip_prefix("event: ") {
1205 current_event = evt.to_string();
1206 } else if let Some(data) = line.strip_prefix("data: ") {
1207 if data == "[DONE]" {
1208 let _ = tx.send(Ok(StreamEvent::Done)).await;
1209 return;
1210 }
1211 for event in parse_anthropic_sse(¤t_event, data) {
1212 let is_done = matches!(event, StreamEvent::Done);
1213 if tx.send(Ok(event)).await.is_err() || is_done {
1214 return;
1215 }
1216 }
1217 current_event.clear();
1218 }
1219 }
1220 }
1221 let _ = tx.send(Ok(StreamEvent::Done)).await;
1222 });
1223
1224 Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
1225 }
1226}
1227
1228#[cfg(test)]
1229mod tests {
1230 use super::*;
1231 use crate::llm::types::ReasoningSummary;
1232
1233 #[test]
1234 fn parse_sse_line_extracts_data() {
1235 assert_eq!(
1236 parse_sse_line("data: {\"id\":\"1\"}"),
1237 Some("{\"id\":\"1\"}")
1238 );
1239 }
1240
1241 #[test]
1242 fn parse_sse_line_skips_done() {
1243 assert_eq!(parse_sse_line("data: [DONE]"), None);
1244 }
1245
1246 #[test]
1247 fn parse_sse_line_skips_non_data() {
1248 assert_eq!(parse_sse_line("event: ping"), None);
1249 assert_eq!(parse_sse_line(""), None);
1250 }
1251
1252 #[test]
1253 fn drain_sse_lines_reassembles_multibyte_split_across_chunks() {
1254 let full = "data: 안녕\n".as_bytes().to_vec();
1256 let (first, rest) = full.split_at(7);
1258
1259 let mut buffer = Vec::new();
1260 assert!(drain_sse_lines(&mut buffer, first).is_empty());
1263
1264 let lines = drain_sse_lines(&mut buffer, rest);
1265 assert_eq!(lines, vec!["data: 안녕".to_string()]);
1266 assert!(!lines[0].contains('\u{FFFD}'));
1268 }
1269
1270 #[test]
1271 fn drain_sse_lines_handles_multiple_lines_and_keeps_partial_tail() {
1272 let mut buffer = Vec::new();
1273 let lines = drain_sse_lines(&mut buffer, b"event: ping\r\ndata: {}\npartial");
1274 assert_eq!(
1275 lines,
1276 vec!["event: ping".to_string(), "data: {}".to_string()]
1277 );
1278 let lines = drain_sse_lines(&mut buffer, b" tail\n");
1280 assert_eq!(lines, vec!["partial tail".to_string()]);
1281 }
1282
1283 #[test]
1284 fn openai_delta_extraction() {
1285 let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#;
1286 let event = parse_openai_sse(data).unwrap();
1287 match event {
1288 StreamEvent::Delta { content } => assert_eq!(content, "Hello"),
1289 _ => panic!("expected Delta, got {:?}", event),
1290 }
1291 }
1292
1293 #[test]
1294 fn openai_usage_extraction() {
1295 let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}"#;
1296 let event = parse_openai_sse(data).unwrap();
1297 match event {
1298 StreamEvent::Usage(usage) => {
1299 assert_eq!(usage.prompt_tokens, 10);
1300 assert_eq!(usage.completion_tokens, 5);
1301 assert_eq!(usage.total_tokens, 15);
1302 }
1303 _ => panic!("expected Usage, got {:?}", event),
1304 }
1305 }
1306
1307 #[test]
1308 fn openai_finish_reason_is_done() {
1309 let data =
1310 r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#;
1311 let event = parse_openai_sse(data).unwrap();
1312 assert!(matches!(event, StreamEvent::Done));
1313 }
1314
1315 #[test]
1316 fn openai_empty_delta_skipped() {
1317 let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}]}"#;
1318 assert!(parse_openai_sse(data).is_none());
1319 }
1320
1321 #[test]
1322 fn anthropic_content_block_delta() {
1323 let data = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#;
1324 let events = parse_anthropic_sse("content_block_delta", data);
1325 match events.as_slice() {
1326 [StreamEvent::Delta { content }] => assert_eq!(content, "Hello"),
1327 _ => panic!("expected one Delta, got {:?}", events),
1328 }
1329 }
1330
1331 #[test]
1332 fn anthropic_message_delta_usage() {
1333 let data = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}"#;
1334 let events = parse_anthropic_sse("message_delta", data);
1335 match events.as_slice() {
1336 [StreamEvent::Stopped { reason }, StreamEvent::Usage(usage)] => {
1337 assert_eq!(reason, "end_turn");
1338 assert_eq!(usage.completion_tokens, 5);
1339 }
1340 _ => panic!("expected Stopped + Usage, got {:?}", events),
1341 }
1342 }
1343
1344 #[test]
1345 fn anthropic_message_delta_max_tokens_surfaces_stop_reason() {
1346 let data = r#"{"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":4096}}"#;
1347 let events = parse_anthropic_sse("message_delta", data);
1348 match events.as_slice() {
1349 [StreamEvent::Stopped { reason }, StreamEvent::Usage(_)] => {
1350 assert_eq!(reason, "max_tokens")
1351 }
1352 _ => panic!("expected Stopped + Usage, got {:?}", events),
1353 }
1354 }
1355
1356 #[test]
1357 fn anthropic_message_delta_refusal_surfaces_stop_reason() {
1358 let data = r#"{"type":"message_delta","delta":{"stop_reason":"refusal"},"usage":{"output_tokens":12}}"#;
1359 let events = parse_anthropic_sse("message_delta", data);
1360 match events.as_slice() {
1361 [StreamEvent::Stopped { reason }, StreamEvent::Usage(_)] => {
1362 assert_eq!(reason, "refusal")
1363 }
1364 _ => panic!("expected Stopped + Usage, got {:?}", events),
1365 }
1366 }
1367
1368 #[test]
1369 fn anthropic_message_delta_without_stop_reason_stays_usage_only() {
1370 let data = r#"{"type":"message_delta","delta":{},"usage":{"output_tokens":5}}"#;
1371 let events = parse_anthropic_sse("message_delta", data);
1372 match events.as_slice() {
1373 [StreamEvent::Usage(_)] => {}
1374 _ => panic!("expected Usage only, got {:?}", events),
1375 }
1376 }
1377
1378 #[test]
1379 fn anthropic_message_start_usage_includes_cache_tokens() {
1380 let data = r#"{"type":"message_start","message":{"id":"msg_1","usage":{"input_tokens":10,"cache_creation_input_tokens":200,"cache_read_input_tokens":3000,"output_tokens":1}}}"#;
1381 let events = parse_anthropic_sse("message_start", data);
1382 match events.as_slice() {
1383 [StreamEvent::Usage(u)] => {
1384 assert_eq!(u.prompt_tokens, 3210);
1385 assert_eq!(u.completion_tokens, 1);
1386 assert_eq!(u.total_tokens, 3211);
1387 }
1388 _ => panic!("expected one Usage, got {:?}", events),
1389 }
1390 }
1391
1392 #[test]
1393 fn anthropic_message_start_usage_missing_fields_default_to_zero() {
1394 let data = r#"{"type":"message_start","message":{"usage":{"input_tokens":42}}}"#;
1395 let events = parse_anthropic_sse("message_start", data);
1396 match events.as_slice() {
1397 [StreamEvent::Usage(u)] => {
1398 assert_eq!(u.prompt_tokens, 42);
1399 assert_eq!(u.completion_tokens, 0);
1400 assert_eq!(u.total_tokens, 42);
1401 }
1402 _ => panic!("expected one Usage, got {:?}", events),
1403 }
1404 }
1405
1406 #[test]
1407 fn anthropic_message_start_without_usage_ignored() {
1408 let data = r#"{"type":"message_start","message":{"id":"msg_1"}}"#;
1409 assert!(parse_anthropic_sse("message_start", data).is_empty());
1410 }
1411
1412 #[test]
1413 fn anthropic_message_delta_with_cumulative_input_usage() {
1414 let data = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":10,"cache_read_input_tokens":90,"output_tokens":25}}"#;
1415 let events = parse_anthropic_sse("message_delta", data);
1416 match events.as_slice() {
1417 [StreamEvent::Stopped { .. }, StreamEvent::Usage(u)] => {
1418 assert_eq!(u.prompt_tokens, 100);
1419 assert_eq!(u.completion_tokens, 25);
1420 assert_eq!(u.total_tokens, 125);
1421 }
1422 _ => panic!("expected Stopped + Usage, got {:?}", events),
1423 }
1424 }
1425
1426 #[test]
1427 fn anthropic_message_delta_output_only_usage() {
1428 let data = r#"{"type":"message_delta","delta":{},"usage":{"output_tokens":7}}"#;
1429 let events = parse_anthropic_sse("message_delta", data);
1430 match events.as_slice() {
1431 [StreamEvent::Usage(u)] => {
1432 assert_eq!(u.prompt_tokens, 0);
1433 assert_eq!(u.completion_tokens, 7);
1434 assert_eq!(u.total_tokens, 7);
1435 }
1436 _ => panic!("expected Usage only, got {:?}", events),
1437 }
1438 }
1439
1440 #[test]
1441 fn anthropic_message_stop() {
1442 let events = parse_anthropic_sse("message_stop", r#"{"type":"message_stop"}"#);
1443 assert!(matches!(events.as_slice(), [StreamEvent::Done]));
1444 }
1445
1446 #[test]
1447 fn anthropic_unknown_event_ignored() {
1448 assert!(parse_anthropic_sse("ping", "{}").is_empty());
1449 }
1450
1451 fn sample_tool() -> ToolDefinition {
1452 ToolDefinition {
1453 name: "get_weather".into(),
1454 description: "Get weather".into(),
1455 input_schema: serde_json::json!({
1456 "type": "object",
1457 "properties": { "location": { "type": "string" } },
1458 "required": ["location"]
1459 }),
1460 }
1461 }
1462
1463 #[test]
1464 fn openai_tools_use_function_wrapper() {
1465 let out = openai_tools(&[sample_tool()]);
1466 assert_eq!(out.len(), 1);
1467 assert_eq!(out[0]["type"], "function");
1468 assert_eq!(out[0]["function"]["name"], "get_weather");
1469 assert_eq!(out[0]["function"]["parameters"]["required"][0], "location");
1471 }
1472
1473 #[test]
1474 fn openai_response_format_maps_each_variant() {
1475 assert!(openai_response_format(&ResponseFormat::Text).is_none());
1476 assert_eq!(
1477 openai_response_format(&ResponseFormat::Json).unwrap()["type"],
1478 "json_object"
1479 );
1480 let schema = serde_json::json!({"type": "object"});
1481 let js = openai_response_format(&ResponseFormat::JsonSchema { schema }).unwrap();
1482 assert_eq!(js["type"], "json_schema");
1483 assert_eq!(js["json_schema"]["strict"], true);
1484 }
1485
1486 #[test]
1487 fn anthropic_tools_use_input_schema_key() {
1488 let out = anthropic_tools(&[sample_tool()]);
1489 assert_eq!(out[0]["name"], "get_weather");
1490 assert_eq!(out[0]["input_schema"]["type"], "object");
1491 assert!(out[0].get("function").is_none());
1492 }
1493
1494 #[test]
1495 fn anthropic_output_config_only_for_json_schema() {
1496 assert!(anthropic_output_config(Some(&ResponseFormat::Text), None).is_none());
1497 assert!(anthropic_output_config(Some(&ResponseFormat::Json), None).is_none());
1498 assert!(anthropic_output_config(None, None).is_none());
1499 let schema = serde_json::json!({"type": "object"});
1500 let cfg = anthropic_output_config(Some(&ResponseFormat::JsonSchema { schema }), None);
1501 let cfg = cfg.unwrap();
1502 assert_eq!(cfg["format"]["type"], "json_schema");
1503 }
1504
1505 #[test]
1506 fn anthropic_output_config_carries_effort() {
1507 let cfg = anthropic_output_config(None, Some(ReasoningEffort::High)).unwrap();
1508 assert_eq!(cfg["effort"], "high");
1509 assert!(cfg.get("format").is_none());
1510 let schema = serde_json::json!({"type": "object"});
1512 let cfg = anthropic_output_config(
1513 Some(&ResponseFormat::JsonSchema { schema }),
1514 Some(ReasoningEffort::XHigh),
1515 )
1516 .unwrap();
1517 assert_eq!(cfg["effort"], "xhigh");
1518 assert_eq!(cfg["format"]["type"], "json_schema");
1519 for (variant, wire) in [
1521 (ReasoningEffort::Low, "low"),
1522 (ReasoningEffort::Medium, "medium"),
1523 (ReasoningEffort::High, "high"),
1524 (ReasoningEffort::XHigh, "xhigh"),
1525 (ReasoningEffort::Max, "max"),
1526 ] {
1527 let cfg = anthropic_output_config(None, Some(variant)).unwrap();
1528 assert_eq!(cfg["effort"], wire);
1529 }
1530 }
1531
1532 #[test]
1533 fn anthropic_request_omits_temperature_when_none() {
1534 let body = AnthropicRequest {
1535 model: "claude-opus-5-5".into(),
1536 max_tokens: 64,
1537 temperature: None,
1538 system: None,
1539 messages: vec![],
1540 stream: false,
1541 tools: None,
1542 output_config: None,
1543 };
1544 let json = serde_json::to_value(&body).unwrap();
1545 assert!(json.get("temperature").is_none());
1546 assert!(json.get("output_config").is_none());
1547 }
1548
1549 #[test]
1550 fn anthropic_request_serializes_temperature_and_effort_when_set() {
1551 let body = AnthropicRequest {
1552 model: "claude-sonnet-4-6".into(),
1553 max_tokens: 64,
1554 temperature: Some(0.5),
1555 system: None,
1556 messages: vec![],
1557 stream: false,
1558 tools: None,
1559 output_config: anthropic_output_config(None, Some(ReasoningEffort::Max)),
1560 };
1561 let json = serde_json::to_value(&body).unwrap();
1562 assert_eq!(json["temperature"], serde_json::json!(0.5f32));
1563 assert_eq!(json["output_config"]["effort"], "max");
1564 }
1565
1566 #[test]
1567 fn openai_request_serializes_tools_and_format() {
1568 let body = OpenAIChatRequest {
1569 model: "gpt-4o".into(),
1570 messages: vec![OpenAIChatMessage {
1571 role: "user".into(),
1572 content: "hi".into(),
1573 }],
1574 temperature: 0.7,
1575 max_tokens: None,
1576 stream: false,
1577 tools: Some(openai_tools(&[sample_tool()])),
1578 response_format: Some(serde_json::json!({ "type": "json_object" })),
1579 reasoning_effort: None,
1580 reasoning: None,
1581 verbosity: None,
1582 };
1583 let json = serde_json::to_value(&body).unwrap();
1584 assert_eq!(json["tools"][0]["function"]["name"], "get_weather");
1585 assert_eq!(json["response_format"]["type"], "json_object");
1586 assert!(json.get("max_tokens").is_none());
1588 assert!(json.get("reasoning_effort").is_none());
1589 assert!(json.get("reasoning").is_none());
1590 }
1591
1592 #[test]
1593 fn openai_reasoning_effort_maps_to_official_param() {
1594 let (effort, reasoning) =
1595 openai_reasoning(Some(&ReasoningConfig::effort(ReasoningEffort::High)));
1596 assert_eq!(effort, Some(ReasoningEffort::High));
1597 assert!(reasoning.is_none());
1598 let body = OpenAIChatRequest {
1599 model: "gpt-5.5".into(),
1600 messages: vec![],
1601 temperature: 0.7,
1602 max_tokens: None,
1603 stream: false,
1604 tools: None,
1605 response_format: None,
1606 reasoning_effort: effort,
1607 reasoning,
1608 verbosity: None,
1609 };
1610 let json = serde_json::to_value(&body).unwrap();
1611 assert_eq!(json["reasoning_effort"], "high");
1612 assert!(json.get("reasoning").is_none());
1613 }
1614
1615 #[test]
1616 fn openai_reasoning_disabled_maps_to_openrouter_object() {
1617 let (effort, reasoning) = openai_reasoning(Some(&ReasoningConfig::disabled()));
1620 assert_eq!(effort, None);
1621 assert_eq!(reasoning.unwrap()["enabled"], false);
1622
1623 let (effort, reasoning) = openai_reasoning(None);
1624 assert!(effort.is_none() && reasoning.is_none());
1625 }
1626
1627 #[test]
1628 fn openai_reasoning_both_knobs_serialize_independently() {
1629 let (effort, reasoning) = openai_reasoning(Some(&ReasoningConfig {
1630 enabled: Some(true),
1631 effort: Some(ReasoningEffort::Low),
1632 summary: Some(ReasoningSummary::Concise),
1633 }));
1634 let body = OpenAIChatRequest {
1635 model: "m".into(),
1636 messages: vec![],
1637 temperature: 0.7,
1638 max_tokens: None,
1639 stream: false,
1640 tools: None,
1641 response_format: None,
1642 reasoning_effort: effort,
1643 reasoning,
1644 verbosity: Some(Verbosity::High),
1645 };
1646 let json = serde_json::to_value(&body).unwrap();
1647 assert_eq!(json["reasoning_effort"], "low");
1648 assert_eq!(json["reasoning"]["enabled"], true);
1649 assert_eq!(json["reasoning"]["summary"], "concise");
1650 assert_eq!(json["verbosity"], "high");
1651 }
1652
1653 #[test]
1654 fn openai_reasoning_summary_only_serializes_object() {
1655 let (effort, reasoning) = openai_reasoning(Some(&ReasoningConfig {
1656 enabled: None,
1657 effort: None,
1658 summary: Some(ReasoningSummary::Detailed),
1659 }));
1660 assert!(effort.is_none());
1661 assert_eq!(reasoning.unwrap()["summary"], "detailed");
1662 }
1663
1664 #[test]
1665 fn openai_body_merges_extra_body_last_write_wins() {
1666 let body = OpenAIChatRequest {
1667 model: "m".into(),
1668 messages: vec![],
1669 temperature: 0.7,
1670 max_tokens: None,
1671 stream: false,
1672 tools: None,
1673 response_format: None,
1674 reasoning_effort: None,
1675 reasoning: None,
1676 verbosity: None,
1677 };
1678 let mut extra = serde_json::Map::new();
1679 extra.insert("seed".into(), 7.into());
1680 extra.insert("temperature".into(), 0.1.into());
1681 let merged = body_with_extra(&body, Some(&extra));
1682 assert_eq!(merged["seed"], 7);
1683 assert_eq!(merged["temperature"], 0.1);
1685 let plain = body_with_extra(&body, None);
1688 assert_eq!(plain["temperature"], serde_json::json!(0.7f32));
1689 assert!(plain.get("seed").is_none());
1690 }
1691
1692 #[test]
1693 fn anthropic_body_merges_extra_body_last_write_wins() {
1694 let body = AnthropicRequest {
1695 model: "m".into(),
1696 max_tokens: 8,
1697 temperature: Some(0.7),
1698 system: Some("sys".into()),
1699 messages: vec![],
1700 stream: false,
1701 tools: None,
1702 output_config: None,
1703 };
1704 let mut extra = serde_json::Map::new();
1705 extra.insert("thinking".into(), serde_json::json!({"type": "disabled"}));
1706 let merged = body_with_extra(&body, Some(&extra));
1707 assert_eq!(merged["thinking"]["type"], "disabled");
1708 assert_eq!(merged["system"], "sys");
1709 }
1710
1711 #[test]
1712 fn verbosity_wire_values_match_openai_spec() {
1713 for (variant, wire) in [
1715 (Verbosity::Low, "low"),
1716 (Verbosity::Medium, "medium"),
1717 (Verbosity::High, "high"),
1718 ] {
1719 assert_eq!(serde_json::to_value(variant).unwrap(), wire);
1720 }
1721 }
1722
1723 #[test]
1724 fn reasoning_summary_wire_values_match_openai_spec() {
1725 for (variant, wire) in [
1727 (ReasoningSummary::Auto, "auto"),
1728 (ReasoningSummary::Concise, "concise"),
1729 (ReasoningSummary::Detailed, "detailed"),
1730 ] {
1731 assert_eq!(serde_json::to_value(variant).unwrap(), wire);
1732 }
1733 }
1734
1735 #[test]
1736 fn reasoning_effort_wire_values_match_openai_spec() {
1737 for (variant, wire) in [
1739 (ReasoningEffort::None, "none"),
1740 (ReasoningEffort::Minimal, "minimal"),
1741 (ReasoningEffort::Low, "low"),
1742 (ReasoningEffort::Medium, "medium"),
1743 (ReasoningEffort::High, "high"),
1744 (ReasoningEffort::XHigh, "xhigh"),
1745 (ReasoningEffort::Max, "max"),
1746 ] {
1747 assert_eq!(serde_json::to_value(variant).unwrap(), wire);
1748 let back: ReasoningEffort = serde_json::from_value(wire.into()).unwrap();
1749 assert_eq!(back, variant);
1750 }
1751 }
1752
1753 #[test]
1754 fn lenient_decode_rescues_openrouter_control_chars() {
1755 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}}";
1758 assert!(serde_json::from_str::<OpenAIChatResponse>(raw).is_err());
1759 let resp: OpenAIChatResponse = from_json_lenient(raw).unwrap();
1760 let first = resp.choices.into_iter().next().unwrap();
1761 assert_eq!(first.message.content.as_deref(), Some("답변"));
1762 assert_eq!(
1763 first.message.reasoning_content.as_deref(),
1764 Some("step 1\nstep 2\ttab")
1765 );
1766 }
1767
1768 #[test]
1769 fn lenient_decode_keeps_original_error_for_structural_garbage() {
1770 let raw = "{\"choices\": tr";
1773 let strict = serde_json::from_str::<OpenAIChatResponse>(raw)
1774 .map(|_: OpenAIChatResponse| ())
1775 .unwrap_err();
1776 let lenient = from_json_lenient::<OpenAIChatResponse>(raw)
1777 .map(|_: OpenAIChatResponse| ())
1778 .unwrap_err();
1779 assert_eq!(lenient.to_string(), strict.to_string());
1780 }
1781
1782 #[test]
1783 fn escape_unescaped_control_chars_only_touches_string_literals() {
1784 let raw = "{\n \"a\": \"x\ny\\n\\tz\",\n \"b\": 1\n}";
1787 let out = escape_unescaped_control_chars(raw);
1788 assert_eq!(out, "{\n \"a\": \"x\\u000ay\\n\\tz\",\n \"b\": 1\n}");
1789 let v: serde_json::Value = serde_json::from_str(&out).unwrap();
1790 assert_eq!(v["a"], "x\ny\n\tz");
1791 }
1792
1793 #[test]
1794 fn escape_unescaped_control_chars_handles_escaped_quotes() {
1795 let raw = "{\"a\":\"say \\\"hi\\\" ok\"}";
1797 assert_eq!(escape_unescaped_control_chars(raw), raw);
1798 }
1799
1800 #[test]
1801 fn openai_sse_lenient_reasoning_delta_with_control_chars() {
1802 let data = "{\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"think\ttab\"},\"finish_reason\":null}]}";
1804 let event = parse_openai_sse(data).unwrap();
1805 match event {
1806 StreamEvent::ReasoningDelta { content } => assert_eq!(content, "think\ttab"),
1807 other => panic!("expected ReasoningDelta, got {other:?}"),
1808 }
1809 }
1810
1811 #[test]
1812 fn openai_response_parses_tool_calls() {
1813 let raw = r#"{
1814 "id": "chatcmpl-1",
1815 "created": 1700000000,
1816 "model": "gpt-4o",
1817 "choices": [{
1818 "index": 0,
1819 "message": {
1820 "role": "assistant",
1821 "content": null,
1822 "tool_calls": [{
1823 "id": "call_abc",
1824 "type": "function",
1825 "function": { "name": "get_weather", "arguments": "{\"location\":\"Paris\"}" }
1826 }]
1827 },
1828 "finish_reason": "tool_calls"
1829 }],
1830 "usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15 }
1831 }"#;
1832 let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1833 assert_eq!(resp.id.as_deref(), Some("chatcmpl-1"));
1834 let choice = resp.choices.into_iter().next().unwrap();
1835 assert_eq!(choice.finish_reason.as_deref(), Some("tool_calls"));
1836 assert!(choice.message.content.is_none());
1837 assert_eq!(choice.message.tool_calls.len(), 1);
1838 assert_eq!(choice.message.tool_calls[0].function.name, "get_weather");
1839 }
1840
1841 #[test]
1842 fn openai_response_parses_glm47_reasoning_content() {
1843 let raw = r#"{
1845 "id":"chatcmpl-1","created":1700000000,"model":"glm-4.7",
1846 "choices":[{"index":0,"message":{"role":"assistant","content":null,
1847 "reasoning_content":"thinking... {\"rating\":\"Buy\"}"},"finish_reason":"stop"}],
1848 "usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15,
1849 "completion_tokens_details":{"reasoning_tokens":3}}
1850 }"#;
1851 let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1852 let choice = resp.choices.into_iter().next().unwrap();
1853 assert!(choice.message.content.is_none());
1854 assert!(choice.message.reasoning_content.is_some());
1855 assert_eq!(
1856 resp.usage
1857 .unwrap()
1858 .completion_tokens_details
1859 .unwrap()
1860 .reasoning_tokens,
1861 Some(3)
1862 );
1863 }
1864
1865 #[test]
1866 fn openai_complete_promotes_reasoning_when_content_empty() {
1867 let raw = r#"{
1871 "id":"chatcmpl-1","created":1700000000,"model":"glm-4.7",
1872 "choices":[{"index":0,"message":{"role":"assistant","content":"",
1873 "reasoning_content":"사고 {\"rating\":\"Sell\",\"key_thesis\":\"약세\"}"},"finish_reason":"stop"}]
1874 }"#;
1875 let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1876 let first = resp.choices.into_iter().next().unwrap();
1877 let raw_content = first.message.content.unwrap_or_default();
1878 let reasoning = first.message.reasoning_content.clone();
1879 let content = promote_reasoning_into_content(raw_content, reasoning.as_deref());
1880 assert!(
1881 content.contains("\"rating\":\"Sell\""),
1882 "promoted content must contain JSON: {content}"
1883 );
1884 assert_eq!(
1885 reasoning.as_deref(),
1886 Some("사고 {\"rating\":\"Sell\",\"key_thesis\":\"약세\"}")
1887 );
1888 }
1889
1890 #[test]
1891 fn openai_complete_keeps_content_when_present() {
1892 let raw = r#"{
1894 "id":"chatcmpl-1","created":1700000000,"model":"gpt-4o",
1895 "choices":[{"index":0,"message":{"role":"assistant","content":"answer",
1896 "reasoning_content":"thought"},"finish_reason":"stop"}]
1897 }"#;
1898 let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1899 let first = resp.choices.into_iter().next().unwrap();
1900 let raw_content = first.message.content.clone().unwrap_or_default();
1901 let reasoning = first.message.reasoning_content.as_deref();
1902 let content = promote_reasoning_into_content(raw_content.clone(), reasoning);
1903 assert_eq!(content, "answer");
1904 assert_eq!(reasoning, Some("thought"));
1905 }
1906
1907 #[test]
1908 fn promote_reasoning_into_content_edge_cases() {
1909 assert_eq!(
1912 promote_reasoning_into_content(String::new(), Some("chain-of-thought")),
1913 "chain-of-thought"
1914 );
1915 assert_eq!(
1917 promote_reasoning_into_content("ans".into(), Some("cot")),
1918 "ans"
1919 );
1920 assert_eq!(promote_reasoning_into_content(String::new(), None), "");
1922 assert_eq!(promote_reasoning_into_content("x".into(), None), "x");
1923 }
1924
1925 #[test]
1926 fn openai_response_parses_deepseek_reasoning_alias() {
1927 let raw = r#"{"model":"deepseek-r1","choices":[{"message":{"content":"ans","reasoning":"thought"}}]}"#;
1929 let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1930 assert_eq!(
1931 resp.choices[0].message.reasoning_content.as_deref(),
1932 Some("thought")
1933 );
1934 }
1935
1936 #[test]
1937 fn openai_sse_reasoning_delta_extracted() {
1938 let data = r#"{"choices":[{"index":0,"delta":{"reasoning_content":"thinking"},"finish_reason":null}]}"#;
1939 let event = parse_openai_sse(data).unwrap();
1940 match event {
1941 StreamEvent::ReasoningDelta { content } => assert_eq!(content, "thinking"),
1942 other => panic!("expected ReasoningDelta, got {other:?}"),
1943 }
1944 }
1945
1946 #[test]
1947 fn openai_sse_content_delta_still_works_alongside_reasoning() {
1948 let data = r#"{"choices":[{"index":0,"delta":{"content":"answer"},"finish_reason":null}]}"#;
1950 let event = parse_openai_sse(data).unwrap();
1951 assert!(matches!(event, StreamEvent::Delta { .. }));
1952 }
1953
1954 #[test]
1955 fn openai_sse_usage_carries_reasoning_tokens() {
1956 let data = r#"{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],
1958 "usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3,
1959 "completion_tokens_details":{"reasoning_tokens":7}}}"#;
1960 let event = parse_openai_sse(data).unwrap();
1961 match event {
1962 StreamEvent::Usage(u) => assert_eq!(u.reasoning_tokens, Some(7)),
1963 other => panic!("expected Usage, got {other:?}"),
1964 }
1965 }
1966
1967 #[test]
1968 fn anthropic_response_parses_tool_use_block() {
1969 let raw = r#"{
1970 "id": "msg_1",
1971 "model": "claude-sonnet-4-6",
1972 "stop_reason": "tool_use",
1973 "content": [
1974 { "type": "text", "text": "Let me check." },
1975 { "type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": { "location": "Paris" } }
1976 ],
1977 "usage": { "input_tokens": 12, "output_tokens": 8 }
1978 }"#;
1979 let resp: AnthropicResponse = serde_json::from_str(raw).unwrap();
1980 assert_eq!(resp.stop_reason.as_deref(), Some("tool_use"));
1981 assert_eq!(resp.content.len(), 2);
1982 assert_eq!(resp.content[0].block_type, "text");
1983 assert_eq!(resp.content[1].block_type, "tool_use");
1984 assert_eq!(resp.content[1].name.as_deref(), Some("get_weather"));
1985 assert_eq!(resp.content[1].input.as_ref().unwrap()["location"], "Paris");
1986 }
1987
1988 #[test]
1989 fn anthropic_response_usage_includes_cache_tokens() {
1990 let raw = r#"{"type":"message","model":"claude-opus-5-5","content":[],"stop_reason":"end_turn","usage":{"input_tokens":10,"cache_creation_input_tokens":200,"cache_read_input_tokens":3000,"output_tokens":5}}"#;
1991 let resp: AnthropicResponse = serde_json::from_str(raw).unwrap();
1992 assert_eq!(resp.usage.prompt_tokens(), 3210);
1993
1994 let raw = r#"{"type":"message","model":"claude-opus-5-5","content":[],"stop_reason":"end_turn","usage":{"input_tokens":12,"output_tokens":8}}"#;
1995 let resp: AnthropicResponse = serde_json::from_str(raw).unwrap();
1996 assert_eq!(resp.usage.prompt_tokens(), 12);
1997 }
1998
1999 #[test]
2000 fn anthropic_response_parses_thinking_block() {
2001 let raw = r#"{
2003 "id": "msg_2",
2004 "model": "claude-sonnet-4-6",
2005 "stop_reason": "end_turn",
2006 "content": [
2007 { "type": "thinking", "thinking": "step by step..." },
2008 { "type": "text", "text": "Final answer." }
2009 ],
2010 "usage": { "input_tokens": 5, "output_tokens": 9 }
2011 }"#;
2012 let resp: AnthropicResponse = serde_json::from_str(raw).unwrap();
2013 let mut reasoning = String::new();
2014 let mut content = String::new();
2015 for block in resp.content {
2016 match block.block_type.as_str() {
2017 "text" => {
2018 if let Some(t) = block.text {
2019 content.push_str(&t);
2020 }
2021 }
2022 "thinking" => {
2023 if let Some(t) = block.thinking {
2024 reasoning.push_str(&t);
2025 }
2026 }
2027 _ => {}
2028 }
2029 }
2030 assert_eq!(content, "Final answer.");
2031 assert_eq!(reasoning, "step by step...");
2032 }
2033
2034 #[test]
2035 fn anthropic_sse_thinking_delta_extracted() {
2036 let data = r#"{"delta":{"type":"thinking_delta","thinking":"a thought"}}"#;
2037 let events = parse_anthropic_sse("content_block_delta", data);
2038 match events.as_slice() {
2039 [StreamEvent::ReasoningDelta { content }] => assert_eq!(content, "a thought"),
2040 other => panic!("expected ReasoningDelta, got {other:?}"),
2041 }
2042 }
2043}