Skip to main content

weft_core/defaults/transforms/
anthropic.rs

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// ── Anthropic request types ──
13
14#[derive(Debug, Serialize)]
15struct AnthropicRequest {
16    model: String,
17    max_tokens: u64,
18    /// Either a single string (legacy) or a structured `[{type:text,text:...,
19    /// cache_control:...}]` array. Stored as `Value` so cache_control breakpoints
20    /// emitted by upstream agents (e.g. agent-core's `cache_control: ephemeral`)
21    /// reach Anthropic verbatim — they are silently dropped if the system field
22    /// is stringified.
23    #[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    /// Same rationale as `AnthropicRequest::system` — preserve content blocks
37    /// (including cache_control) instead of collapsing to a string.
38    content: Value,
39}
40
41// ── Anthropic response types ──
42
43#[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// ── Anthropic streaming types ──
71
72#[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
101/// Transforms for Anthropic's native Messages API.
102pub 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        // Pull out system messages (top-level Anthropic field), keep everything
115        // else as a regular message. Two reasons we don't collapse to a string:
116        //
117        //   1. agent-core emits the stable system block as
118        //      `{role:system, content:[{type:text, text, cache_control:ephemeral}]}`
119        //      and Anthropic only honors cache_control when system is sent as a
120        //      structured array. Stringifying drops the breakpoint silently.
121        //   2. Multiple system messages (e.g. stable + dynamic) must be merged
122        //      while preserving any cache_control from the first block.
123        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        // Encode `system` in the simplest form Anthropic accepts: empty -> None,
158        // single plain-text block with no cache_control -> string, anything else
159        // -> array (which lets cache_control survive).
160        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, // Anthropic doesn't return a unix timestamp
230            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        // Skip empty lines, SSE comments, and event-type lines
261        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                // Extract id/model from the message_start for downstream consumers
291                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            // message_delta, content_block_start, content_block_stop, ping — skip
322            _ => 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        // Must NOT have Bearer auth
414        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        // Messages should only contain the user message
428        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        // Reasonix Pillar 1: agent-core emits system content as a structured
508        // array with cache_control:ephemeral. Anthropic only honors that when
509        // it's sent through verbatim in the request body. This test pins the
510        // contract — if it ever regresses, prefix-cache hit rate silently drops
511        // to 0 for every Anthropic-routed turn.
512        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}