1use crate::config::ProviderConfig;
2use crate::layers::transform::{ProviderRequest, TransformLayer};
3use crate::types::{
4 ChatMessage, ChatRequest, ChatResponse, Choice, Delta, StreamChoice, StreamChunk, Usage,
5};
6use anyhow::{Context, Result};
7use async_trait::async_trait;
8use bytes::Bytes;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12#[derive(Debug, Serialize)]
15struct AnthropicRequest {
16 model: String,
17 max_tokens: u64,
18 #[serde(skip_serializing_if = "Option::is_none")]
24 system: Option<Value>,
25 messages: Vec<AnthropicMessage>,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 temperature: Option<f64>,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 top_p: Option<f64>,
30 stream: bool,
31}
32
33#[derive(Debug, Serialize, Deserialize)]
34struct AnthropicMessage {
35 role: String,
36 content: Value,
39}
40
41#[derive(Debug, Deserialize)]
44struct AnthropicResponse {
45 id: String,
46 #[serde(rename = "type")]
47 _type: String,
48 model: String,
49 content: Vec<AnthropicContent>,
50 usage: AnthropicUsage,
51}
52
53#[derive(Debug, Deserialize)]
54struct AnthropicContent {
55 #[serde(rename = "type")]
56 _type: String,
57 text: String,
58}
59
60#[derive(Debug, Deserialize)]
61struct AnthropicUsage {
62 input_tokens: u64,
63 output_tokens: u64,
64 #[serde(default)]
65 cache_read_input_tokens: Option<u64>,
66 #[serde(default)]
67 cache_creation_input_tokens: Option<u64>,
68}
69
70#[derive(Debug, Deserialize)]
73struct AnthropicStreamEvent {
74 #[serde(rename = "type")]
75 event_type: String,
76 #[serde(default)]
77 index: Option<u32>,
78 #[serde(default)]
79 delta: Option<AnthropicDelta>,
80 #[serde(default)]
81 message: Option<AnthropicStreamMessage>,
82}
83
84#[derive(Debug, Deserialize)]
85struct AnthropicDelta {
86 #[serde(rename = "type")]
87 _type: String,
88 #[serde(default)]
89 text: Option<String>,
90}
91
92#[derive(Debug, Deserialize)]
93struct AnthropicStreamMessage {
94 id: String,
95 model: String,
96}
97
98const DEFAULT_MAX_TOKENS: u64 = 4096;
99const ANTHROPIC_VERSION: &str = "2023-06-01";
100
101pub struct AnthropicTransform;
103
104#[async_trait]
105impl TransformLayer for AnthropicTransform {
106 async fn transform_request(
107 &self,
108 request: &ChatRequest,
109 api_key: &str,
110 provider: &ProviderConfig,
111 ) -> Result<ProviderRequest> {
112 let url = format!("{}/messages", provider.base_url.trim_end_matches('/'));
113
114 let mut system_blocks: Vec<Value> = Vec::new();
124 let mut messages: Vec<AnthropicMessage> = Vec::new();
125
126 for msg in &request.messages {
127 if msg.role == "system" {
128 match &msg.content {
129 Value::Array(items) => {
130 for item in items {
131 system_blocks.push(item.clone());
132 }
133 }
134 Value::String(text) if !text.is_empty() => {
135 system_blocks.push(serde_json::json!({
136 "type": "text",
137 "text": text,
138 }));
139 }
140 Value::Null => {}
141 other if !other.is_string() => {
142 system_blocks.push(serde_json::json!({
143 "type": "text",
144 "text": other.to_string(),
145 }));
146 }
147 _ => {}
148 }
149 } else {
150 messages.push(AnthropicMessage {
151 role: msg.role.clone(),
152 content: msg.content.clone(),
153 });
154 }
155 }
156
157 let system: Option<Value> = if system_blocks.is_empty() {
161 None
162 } else if system_blocks.len() == 1
163 && system_blocks[0].get("type").and_then(Value::as_str) == Some("text")
164 && system_blocks[0].get("cache_control").is_none()
165 {
166 system_blocks
167 .into_iter()
168 .next()
169 .and_then(|block| {
170 block
171 .get("text")
172 .and_then(Value::as_str)
173 .map(|s| Value::String(s.to_string()))
174 })
175 } else {
176 Some(Value::Array(system_blocks))
177 };
178
179 let anthropic_req = AnthropicRequest {
180 model: request.model.clone(),
181 max_tokens: request.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS),
182 system,
183 messages,
184 temperature: request.temperature,
185 top_p: request.top_p,
186 stream: request.stream,
187 };
188
189 let body =
190 serde_json::to_vec(&anthropic_req).context("Failed to serialize Anthropic request")?;
191
192 Ok(ProviderRequest {
193 url,
194 method: "POST".into(),
195 headers: vec![
196 ("Content-Type".into(), "application/json".into()),
197 ("x-api-key".into(), api_key.to_string()),
198 ("anthropic-version".into(), ANTHROPIC_VERSION.into()),
199 ],
200 body: Bytes::from(body),
201 })
202 }
203
204 async fn transform_response(
205 &self,
206 status: u16,
207 body: Bytes,
208 _provider: &ProviderConfig,
209 ) -> Result<ChatResponse> {
210 if status != 200 {
211 let text = String::from_utf8_lossy(&body);
212 anyhow::bail!("Anthropic returned status {}: {}", status, text);
213 }
214
215 let resp: AnthropicResponse =
216 serde_json::from_slice(&body).context("Failed to parse Anthropic response")?;
217
218 let text = resp
219 .content
220 .first()
221 .map(|c| c.text.clone())
222 .unwrap_or_default();
223
224 let total = resp.usage.input_tokens + resp.usage.output_tokens;
225
226 Ok(ChatResponse {
227 id: resp.id,
228 object: "chat.completion".into(),
229 created: 0, model: resp.model,
231 choices: vec![Choice {
232 index: 0,
233 message: ChatMessage {
234 role: "assistant".into(),
235 content: Value::String(text),
236 tool_calls: None,
237 tool_call_id: None,
238 },
239 finish_reason: Some("stop".into()),
240 }],
241 usage: Some(Usage {
242 prompt_tokens: resp.usage.input_tokens,
243 completion_tokens: resp.usage.output_tokens,
244 total_tokens: total,
245 prompt_cache_hit_tokens: None,
246 prompt_cache_miss_tokens: None,
247 cache_read_input_tokens: resp.usage.cache_read_input_tokens,
248 cache_creation_input_tokens: resp.usage.cache_creation_input_tokens,
249 }),
250 })
251 }
252
253 async fn transform_stream_chunk(
254 &self,
255 chunk: &str,
256 _provider: &ProviderConfig,
257 ) -> Result<Option<StreamChunk>> {
258 let line = chunk.trim();
259
260 if line.is_empty() || line.starts_with(':') || line.starts_with("event:") {
262 return Ok(None);
263 }
264
265 let data = line.strip_prefix("data: ").unwrap_or(line);
266
267 let event: AnthropicStreamEvent =
268 serde_json::from_str(data).context("Failed to parse Anthropic stream event")?;
269
270 match event.event_type.as_str() {
271 "content_block_delta" => {
272 let text = event.delta.and_then(|d| d.text).unwrap_or_default();
273
274 Ok(Some(StreamChunk {
275 id: String::new(),
276 object: "chat.completion.chunk".into(),
277 created: 0,
278 model: String::new(),
279 choices: vec![StreamChoice {
280 index: event.index.unwrap_or(0),
281 delta: Delta {
282 content: Some(text),
283 ..Default::default()
284 },
285 finish_reason: None,
286 }],
287 }))
288 }
289 "message_start" => {
290 let (id, model) = match &event.message {
292 Some(m) => (m.id.clone(), m.model.clone()),
293 None => (String::new(), String::new()),
294 };
295 Ok(Some(StreamChunk {
296 id,
297 object: "chat.completion.chunk".into(),
298 created: 0,
299 model,
300 choices: vec![StreamChoice {
301 index: 0,
302 delta: Delta {
303 role: Some("assistant".into()),
304 ..Default::default()
305 },
306 finish_reason: None,
307 }],
308 }))
309 }
310 "message_stop" => Ok(Some(StreamChunk {
311 id: String::new(),
312 object: "chat.completion.chunk".into(),
313 created: 0,
314 model: String::new(),
315 choices: vec![StreamChoice {
316 index: 0,
317 delta: Delta::default(),
318 finish_reason: Some("stop".into()),
319 }],
320 })),
321 _ => Ok(None),
323 }
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330 use crate::config::{ApiKeyConfig, ProviderApi, ProviderConfig};
331 use crate::types::{ChatMessage, ChatRequest};
332
333 fn provider() -> ProviderConfig {
334 ProviderConfig {
335 name: "anthropic".into(),
336 base_url: "https://api.anthropic.com/v1".into(),
337 format: "anthropic".into(),
338 api: ProviderApi::ChatCompletions,
339 keys: vec![ApiKeyConfig {
340 value: "sk-ant-test".into(),
341 label: None,
342 enabled: true,
343 }],
344 models: vec!["claude-sonnet-4-20250514".into()],
345 }
346 }
347
348 fn request_with_system() -> ChatRequest {
349 ChatRequest {
350 model: "claude-sonnet-4-20250514".into(),
351 messages: vec![
352 ChatMessage {
353 role: "system".into(),
354 content: "You are helpful.".into(),
355 tool_calls: None,
356 tool_call_id: None,
357 },
358 ChatMessage {
359 role: "user".into(),
360 content: "hello".into(),
361 tool_calls: None,
362 tool_call_id: None,
363 },
364 ],
365 stream: false,
366 temperature: None,
367 max_tokens: Some(1024),
368 top_p: None,
369 tools: None,
370 tool_choice: None,
371 response_format: None,
372 x_provider: None,
373 }
374 }
375
376 fn request_no_system() -> ChatRequest {
377 ChatRequest {
378 model: "claude-sonnet-4-20250514".into(),
379 messages: vec![ChatMessage {
380 role: "user".into(),
381 content: "hello".into(),
382 tool_calls: None,
383 tool_call_id: None,
384 }],
385 stream: false,
386 temperature: Some(0.7),
387 max_tokens: None,
388 top_p: None,
389 tools: None,
390 tool_choice: None,
391 response_format: None,
392 x_provider: None,
393 }
394 }
395
396 #[tokio::test]
397 async fn test_request_url_and_headers() {
398 let t = AnthropicTransform;
399 let req = t
400 .transform_request(&request_with_system(), "sk-ant-test", &provider())
401 .await
402 .unwrap();
403
404 assert_eq!(req.url, "https://api.anthropic.com/v1/messages");
405 assert!(req
406 .headers
407 .iter()
408 .any(|(k, v)| k == "x-api-key" && v == "sk-ant-test"));
409 assert!(req
410 .headers
411 .iter()
412 .any(|(k, v)| k == "anthropic-version" && v == ANTHROPIC_VERSION));
413 assert!(!req.headers.iter().any(|(k, _)| k == "Authorization"));
415 }
416
417 #[tokio::test]
418 async fn test_request_system_extracted() {
419 let t = AnthropicTransform;
420 let req = t
421 .transform_request(&request_with_system(), "sk-ant-test", &provider())
422 .await
423 .unwrap();
424
425 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
426 assert_eq!(body["system"], "You are helpful.");
427 let messages = body["messages"].as_array().unwrap();
429 assert_eq!(messages.len(), 1);
430 assert_eq!(messages[0]["role"], "user");
431 }
432
433 #[tokio::test]
434 async fn test_request_no_system() {
435 let t = AnthropicTransform;
436 let req = t
437 .transform_request(&request_no_system(), "sk-ant-test", &provider())
438 .await
439 .unwrap();
440
441 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
442 assert!(body.get("system").is_none());
443 }
444
445 #[tokio::test]
446 async fn test_request_default_max_tokens() {
447 let t = AnthropicTransform;
448 let req = t
449 .transform_request(&request_no_system(), "sk-ant-test", &provider())
450 .await
451 .unwrap();
452
453 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
454 assert_eq!(body["max_tokens"], DEFAULT_MAX_TOKENS);
455 }
456
457 #[tokio::test]
458 async fn test_request_explicit_max_tokens() {
459 let t = AnthropicTransform;
460 let req = t
461 .transform_request(&request_with_system(), "sk-ant-test", &provider())
462 .await
463 .unwrap();
464
465 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
466 assert_eq!(body["max_tokens"], 1024);
467 }
468
469 #[tokio::test]
470 async fn test_transform_response() {
471 let t = AnthropicTransform;
472 let anthropic_body = serde_json::json!({
473 "id": "msg_123",
474 "type": "message",
475 "role": "assistant",
476 "model": "claude-sonnet-4-20250514",
477 "content": [{"type": "text", "text": "Hello!"}],
478 "usage": {"input_tokens": 10, "output_tokens": 5}
479 });
480 let body = Bytes::from(serde_json::to_vec(&anthropic_body).unwrap());
481
482 let resp = t.transform_response(200, body, &provider()).await.unwrap();
483
484 assert_eq!(resp.id, "msg_123");
485 assert_eq!(resp.object, "chat.completion");
486 assert_eq!(resp.model, "claude-sonnet-4-20250514");
487 assert_eq!(resp.choices.len(), 1);
488 assert_eq!(resp.choices[0].message.role, "assistant");
489 assert_eq!(resp.choices[0].message.content, "Hello!");
490 let usage = resp.usage.unwrap();
491 assert_eq!(usage.prompt_tokens, 10);
492 assert_eq!(usage.completion_tokens, 5);
493 assert_eq!(usage.total_tokens, 15);
494 }
495
496 #[tokio::test]
497 async fn test_transform_response_error_status() {
498 let t = AnthropicTransform;
499 let body = Bytes::from(r#"{"error":{"message":"invalid key"}}"#);
500 let result = t.transform_response(401, body, &provider()).await;
501 assert!(result.is_err());
502 assert!(result.unwrap_err().to_string().contains("401"));
503 }
504
505 #[tokio::test]
506 async fn cache_control_survives_when_system_is_structured_array() {
507 let t = AnthropicTransform;
513 let req = ChatRequest {
514 model: "claude-sonnet-4-20250514".into(),
515 messages: vec![
516 ChatMessage {
517 role: "system".into(),
518 content: serde_json::json!([{
519 "type": "text",
520 "text": "You are helpful.",
521 "cache_control": {"type": "ephemeral"}
522 }]),
523 tool_calls: None,
524 tool_call_id: None,
525 },
526 ChatMessage {
527 role: "user".into(),
528 content: serde_json::Value::String("hello".into()),
529 tool_calls: None,
530 tool_call_id: None,
531 },
532 ],
533 stream: false,
534 temperature: None,
535 max_tokens: Some(1024),
536 top_p: None,
537 tools: None,
538 tool_choice: None,
539 response_format: None,
540 x_provider: None,
541 };
542 let outbound = t
543 .transform_request(&req, "sk-ant-test", &provider())
544 .await
545 .unwrap();
546 let body: serde_json::Value = serde_json::from_slice(&outbound.body).unwrap();
547 let system_field = &body["system"];
548 assert!(
549 system_field.is_array(),
550 "structured system must stay an array, got {}",
551 system_field
552 );
553 let first = &system_field[0];
554 assert_eq!(first["type"], "text");
555 assert_eq!(first["text"], "You are helpful.");
556 assert_eq!(first["cache_control"]["type"], "ephemeral");
557 }
558
559 #[tokio::test]
560 async fn anthropic_response_usage_carries_cache_read_and_creation_tokens() {
561 let t = AnthropicTransform;
562 let body = Bytes::from(serde_json::to_vec(&serde_json::json!({
563 "id": "msg_456",
564 "type": "message",
565 "role": "assistant",
566 "model": "claude-sonnet-4-20250514",
567 "content": [{"type": "text", "text": "ok"}],
568 "usage": {
569 "input_tokens": 12,
570 "output_tokens": 3,
571 "cache_read_input_tokens": 9000,
572 "cache_creation_input_tokens": 1200
573 }
574 })).unwrap());
575 let resp = t.transform_response(200, body, &provider()).await.unwrap();
576 let usage = resp.usage.expect("usage missing");
577 assert_eq!(usage.cache_read_input_tokens, Some(9000));
578 assert_eq!(usage.cache_creation_input_tokens, Some(1200));
579 }
580
581 #[tokio::test]
582 async fn test_stream_content_block_delta() {
583 let t = AnthropicTransform;
584 let data = r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}"#;
585 let result = t.transform_stream_chunk(data, &provider()).await.unwrap();
586 let chunk = result.unwrap();
587 assert_eq!(chunk.choices[0].delta.content.as_deref(), Some("Hi"));
588 assert!(chunk.choices[0].finish_reason.is_none());
589 }
590
591 #[tokio::test]
592 async fn test_stream_message_start() {
593 let t = AnthropicTransform;
594 let data = r#"data: {"type":"message_start","message":{"id":"msg_1","model":"claude-sonnet-4-20250514","role":"assistant","content":[],"usage":{"input_tokens":10,"output_tokens":0}}}"#;
595 let result = t.transform_stream_chunk(data, &provider()).await.unwrap();
596 let chunk = result.unwrap();
597 assert_eq!(chunk.id, "msg_1");
598 assert_eq!(chunk.choices[0].delta.role.as_deref(), Some("assistant"));
599 }
600
601 #[tokio::test]
602 async fn test_stream_message_stop() {
603 let t = AnthropicTransform;
604 let data = r#"data: {"type":"message_stop"}"#;
605 let result = t.transform_stream_chunk(data, &provider()).await.unwrap();
606 let chunk = result.unwrap();
607 assert_eq!(chunk.choices[0].finish_reason.as_deref(), Some("stop"));
608 }
609
610 #[tokio::test]
611 async fn test_stream_skip_event_line() {
612 let t = AnthropicTransform;
613 let result = t
614 .transform_stream_chunk("event: content_block_delta", &provider())
615 .await
616 .unwrap();
617 assert!(result.is_none());
618 }
619
620 #[tokio::test]
621 async fn test_stream_skip_empty() {
622 let t = AnthropicTransform;
623 let result = t.transform_stream_chunk("", &provider()).await.unwrap();
624 assert!(result.is_none());
625 }
626
627 #[tokio::test]
628 async fn test_stream_skip_ping() {
629 let t = AnthropicTransform;
630 let data = r#"data: {"type":"ping"}"#;
631 let result = t.transform_stream_chunk(data, &provider()).await.unwrap();
632 assert!(result.is_none());
633 }
634}