dwara_core/ai/adapters/
anthropic.rs

1//! The Anthropic messages adapter (DW-075).
2//!
3//! Speaks the Anthropic messages API (`POST /v1/messages`). The
4//! translation is the deepest of the three dialects:
5//!
6//! - System messages lift to the top-level `system` field (joined with
7//!   a blank line when several appear).
8//! - Tool RESULTS (OpenAI `role: tool` messages) become USER turns
9//!   carrying a `tool_result` content block; assistant tool calls
10//!   become `tool_use` blocks whose `input` is the parsed arguments
11//!   object.
12//! - `max_tokens` is REQUIRED by Anthropic and absent from many OpenAI
13//!   client calls: the adapter substitutes
14//!   [`DEFAULT_MAX_TOKENS`] and says so in the module docs — the
15//!   provider refuses an unbounded generation, so a floor is the only
16//!   way to honor the request at all.
17//! - Finish reasons map `end_turn`/`stop_sequence` -> stop,
18//!   `max_tokens` -> length, `tool_use` -> tool calls,
19//!   `refusal` -> content filter.
20//! - Usage maps `input_tokens`/`output_tokens` to
21//!   prompt/completion; `total` is derived (sum) because Anthropic
22//!   does not send one.
23//!
24//! Streaming (verified in tests, wired at the gateway in DW-077): the
25//! messages-API SSE stream (`message_start` -> `content_block_*` ->
26//! `message_delta` -> `message_stop`) translates event-by-event; tool
27//! argument fragments arrive as `input_json_delta.partial_json` and
28//! are re-expressed as OpenAI-style argument-fragment deltas.
29
30use crate::ai::adapter::{AiError, ProviderAdapter, ProviderErrorBody, ProviderRequest};
31use crate::ai::types::{
32    ChatMessage, ChatRequest, ChatResponse, ChatRole, Choice, ContentPart, FinishReason,
33    StreamDelta, StreamEvent, ToolCall, ToolCallDelta, Usage,
34};
35use crate::config::ai::AiProviderKind;
36use serde_json::{json, Map, Value};
37
38/// The `max_tokens` the adapter substitutes when the client did not
39/// send one. Anthropic requires the field; OpenAI clients usually omit
40/// it. 4096 is a conservative default that no provider rejects.
41pub const DEFAULT_MAX_TOKENS: u64 = 4096;
42
43/// The Anthropic messages adapter. Stateless singleton.
44pub struct AnthropicAdapter;
45
46impl ProviderAdapter for AnthropicAdapter {
47    fn kind(&self) -> AiProviderKind {
48        AiProviderKind::Anthropic
49    }
50
51    fn build_request(
52        &self,
53        req: &ChatRequest,
54        provider_model: &str,
55    ) -> Result<ProviderRequest, AiError> {
56        let mut body = Map::new();
57        body.insert("model".into(), json!(provider_model));
58        // System messages lift out of the conversation.
59        let system: Vec<String> = req
60            .messages
61            .iter()
62            .filter(|m| m.role == ChatRole::System)
63            .map(|m| m.text_content())
64            .filter(|s| !s.is_empty())
65            .collect();
66        if !system.is_empty() {
67            body.insert("system".into(), json!(system.join("\n\n")));
68        }
69        let mut messages: Vec<Value> = Vec::new();
70        for m in &req.messages {
71            match m.role {
72                ChatRole::System => continue,
73                ChatRole::User | ChatRole::Assistant => messages.push(json!({
74                    "role": if m.role == ChatRole::User { "user" } else { "assistant" },
75                    "content": message_content_blocks(m),
76                })),
77                ChatRole::Tool => {
78                    // A tool result is a USER turn with a tool_result
79                    // block. Anthropic wants the originating tool_use
80                    // id; the OpenAI shape only carries tool_call_id,
81                    // which IS that id. A tool message without one is
82                    // an unanswerable conversation — reject precisely
83                    // rather than serializing an empty id the provider
84                    // would reject opaquely.
85                    let Some(tool_call_id) = m.tool_call_id.as_deref() else {
86                        return Err(AiError::InvalidRequest(
87                            "a tool message carries no tool_call_id (the id that \
88                             names the call it answers)"
89                                .to_string(),
90                        ));
91                    };
92                    let text = m.text_content();
93                    messages.push(json!({
94                        "role": "user",
95                        "content": [{
96                            "type": "tool_result",
97                            "tool_use_id": tool_call_id,
98                            "content": if text.is_empty() { Value::Null } else { json!(text) },
99                        }],
100                    }));
101                }
102            }
103        }
104        if messages.is_empty() {
105            return Err(AiError::InvalidRequest(
106                "the conversation has no non-system messages".to_string(),
107            ));
108        }
109        // Anthropic requires alternating user/assistant turns starting
110        // with user; consecutive same-role turns merge.
111        let messages = merge_consecutive_turns(messages);
112        body.insert("messages".into(), Value::Array(messages));
113        body.insert(
114            "max_tokens".into(),
115            json!(req.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS)),
116        );
117        if !req.tools.is_empty() {
118            let tools: Vec<Value> = req
119                .tools
120                .iter()
121                .map(|t| {
122                    let mut o = Map::new();
123                    o.insert("name".into(), json!(t.name));
124                    if let Some(d) = &t.description {
125                        o.insert("description".into(), json!(d));
126                    }
127                    o.insert(
128                        "input_schema".into(),
129                        t.parameters
130                            .clone()
131                            .unwrap_or_else(|| json!({"type": "object"})),
132                    );
133                    Value::Object(o)
134                })
135                .collect();
136            body.insert("tools".into(), Value::Array(tools));
137            if let Some(choice) = &req.tool_choice {
138                match choice {
139                    crate::ai::types::ToolChoice::Auto => {
140                        body.insert("tool_choice".into(), json!({"type": "auto"}));
141                    }
142                    crate::ai::types::ToolChoice::None => {
143                        // Anthropic has no "none": omitting
144                        // tool_choice with tools declared still lets
145                        // the model call them. The honest translation
146                        // of "none" is dropping the tools entirely.
147                        body.remove("tools");
148                    }
149                    crate::ai::types::ToolChoice::Any => {
150                        body.insert("tool_choice".into(), json!({"type": "any"}));
151                    }
152                    crate::ai::types::ToolChoice::Tool(name) => {
153                        body.insert("tool_choice".into(), json!({"type": "tool", "name": name}));
154                    }
155                }
156            }
157        }
158        if let Some(t) = req.temperature {
159            body.insert("temperature".into(), json!(t));
160        }
161        if let Some(p) = req.top_p {
162            body.insert("top_p".into(), json!(p));
163        }
164        if let Some(stop) = &req.stop {
165            body.insert("stop_sequences".into(), json!(stop));
166        }
167        if req.stream {
168            body.insert("stream".into(), json!(true));
169        }
170        // AI-06 (#196): translate response_format. Anthropic does not
171        // have a native response_format field. JsonObject and
172        // JsonSchema are translated to a forced tool call with the
173        // schema as input_schema; the model is forced to produce a
174        // tool_use block whose input conforms to the schema.
175        if let Some(rf) = &req.response_format {
176            apply_anthropic_response_format(&mut body, rf);
177        }
178        Ok(ProviderRequest {
179            method: http::Method::POST,
180            path: "/v1/messages".to_string(),
181            headers: vec![
182                (http::header::CONTENT_TYPE, "application/json".to_string()),
183                // Anthropic requires both version and (for newer
184                // features) the beta header is NOT needed here.
185                (
186                    http::HeaderName::from_static("anthropic-version"),
187                    "2023-06-01".to_string(),
188                ),
189            ],
190            body: Value::Object(body),
191        })
192    }
193
194    fn parse_response(&self, body: &Value) -> Result<ChatResponse, AiError> {
195        let obj = body.as_object().ok_or_else(|| {
196            AiError::Translation("anthropic response is not a JSON object".to_string())
197        })?;
198        let content = obj
199            .get("content")
200            .and_then(Value::as_array)
201            .ok_or_else(|| AiError::Translation("anthropic response has no content".to_string()))?;
202        let mut text = String::new();
203        let mut tool_calls = Vec::new();
204        let mut image_parts = Vec::new();
205        for block in content {
206            match block.get("type").and_then(Value::as_str) {
207                Some("text") => text.push_str(
208                    block
209                        .get("text")
210                        .and_then(Value::as_str)
211                        .unwrap_or_default(),
212                ),
213                Some("tool_use") => tool_calls.push(ToolCall {
214                    id: block
215                        .get("id")
216                        .and_then(Value::as_str)
217                        .unwrap_or_default()
218                        .to_string(),
219                    name: block
220                        .get("name")
221                        .and_then(Value::as_str)
222                        .unwrap_or_default()
223                        .to_string(),
224                    arguments: block
225                        .get("input")
226                        .cloned()
227                        .unwrap_or(Value::Null)
228                        .to_string(),
229                }),
230                Some("image") => {
231                    let source = block.get("source").cloned().unwrap_or(Value::Null);
232                    image_parts.push(ContentPart::Image {
233                        url: None,
234                        // source.type is the ENCODING ("base64"); the
235                        // media type lives in source.media_type and is
236                        // only derived from source.type when absent.
237                        media_type: source
238                            .get("media_type")
239                            .and_then(Value::as_str)
240                            .map(String::from)
241                            .or_else(|| {
242                                source
243                                    .get("type")
244                                    .and_then(Value::as_str)
245                                    .map(|t| format!("image/{t}"))
246                            }),
247                        data_b64: source.get("data").and_then(Value::as_str).map(String::from),
248                    });
249                }
250                _ => {}
251            }
252        }
253        let mut message_content: Vec<ContentPart> = vec![ContentPart::Text { text }];
254        message_content.extend(image_parts);
255        let usage = obj.get("usage").map(|u| Usage {
256            prompt_tokens: u.get("input_tokens").and_then(Value::as_u64),
257            completion_tokens: u.get("output_tokens").and_then(Value::as_u64),
258            total_tokens: match (
259                u.get("input_tokens").and_then(Value::as_u64),
260                u.get("output_tokens").and_then(Value::as_u64),
261            ) {
262                (Some(i), Some(o)) => Some(i + o),
263                (i, o) => i.or(o),
264            },
265            cached_tokens: u.get("cache_read_input_tokens").and_then(Value::as_u64),
266        });
267        let finish_reason = match obj.get("stop_reason").and_then(Value::as_str) {
268            Some("end_turn") | Some("stop_sequence") | None => FinishReason::Stop,
269            Some("max_tokens") => FinishReason::Length,
270            Some("tool_use") => FinishReason::ToolCalls,
271            Some("refusal") => FinishReason::ContentFilter,
272            Some(other) => FinishReason::Other(other.to_string()),
273        };
274        Ok(ChatResponse {
275            id: obj.get("id").and_then(Value::as_str).map(String::from),
276            model: obj.get("model").and_then(Value::as_str).map(String::from),
277            choices: vec![Choice {
278                index: 0,
279                message: ChatMessage {
280                    role: ChatRole::Assistant,
281                    content: message_content,
282                    name: None,
283                    tool_calls,
284                    tool_call_id: None,
285                },
286                finish_reason,
287            }],
288            usage,
289        })
290    }
291
292    fn parse_error(&self, body: &Value) -> ProviderErrorBody {
293        // Anthropic: {"type": "error", "error": {"type", "message"}}.
294        let e = body
295            .get("error")
296            .and_then(|e| e.as_object())
297            .or_else(|| body.as_object());
298        ProviderErrorBody {
299            message: e
300                .and_then(|e| e.get("message"))
301                .and_then(Value::as_str)
302                .unwrap_or("provider returned an error")
303                .to_string(),
304            error_type: e
305                .and_then(|e| e.get("type"))
306                .and_then(Value::as_str)
307                .map(String::from),
308            code: None,
309        }
310    }
311
312    fn parse_stream_event(&self, data: &Value) -> Result<Vec<StreamEvent>, AiError> {
313        let obj = data.as_object().ok_or_else(|| {
314            AiError::Translation("anthropic stream event is not a JSON object".to_string())
315        })?;
316        let kind = obj.get("type").and_then(Value::as_str).unwrap_or("");
317        let mut out = Vec::new();
318        match kind {
319            "message_start" => {
320                // The opening event carries input token usage,
321                // including cached prompt tokens when the provider
322                // reports them (DW-AI-03).
323                if let Some(u) = obj.get("message").and_then(|m| m.get("usage")) {
324                    out.push(StreamEvent::Usage(Usage {
325                        prompt_tokens: u.get("input_tokens").and_then(Value::as_u64),
326                        completion_tokens: None,
327                        total_tokens: None,
328                        cached_tokens: u.get("cache_read_input_tokens").and_then(Value::as_u64),
329                    }));
330                }
331                // First delta carries the role, OpenAI-style.
332                out.push(StreamEvent::Delta(StreamDelta {
333                    index: 0,
334                    role: Some(ChatRole::Assistant),
335                    content: None,
336                    tool_calls: Vec::new(),
337                    finish_reason: None,
338                }));
339            }
340            "content_block_start" => {
341                let index = obj.get("index").and_then(Value::as_u64).unwrap_or(0);
342                if let Some(block) = obj.get("content_block") {
343                    if block.get("type").and_then(Value::as_str) == Some("tool_use") {
344                        out.push(StreamEvent::Delta(StreamDelta {
345                            index: 0,
346                            role: None,
347                            content: None,
348                            tool_calls: vec![ToolCallDelta {
349                                index,
350                                id: block.get("id").and_then(Value::as_str).map(String::from),
351                                name: block.get("name").and_then(Value::as_str).map(String::from),
352                                arguments: String::new(),
353                            }],
354                            finish_reason: None,
355                        }));
356                    }
357                }
358            }
359            "content_block_delta" => {
360                let index = obj.get("index").and_then(Value::as_u64).unwrap_or(0);
361                let delta = obj.get("delta").cloned().unwrap_or(Value::Null);
362                match delta.get("type").and_then(Value::as_str) {
363                    Some("text_delta") => out.push(StreamEvent::Delta(StreamDelta {
364                        index: 0,
365                        role: None,
366                        content: delta.get("text").and_then(Value::as_str).map(String::from),
367                        tool_calls: Vec::new(),
368                        finish_reason: None,
369                    })),
370                    Some("input_json_delta") => out.push(StreamEvent::Delta(StreamDelta {
371                        index: 0,
372                        role: None,
373                        content: None,
374                        tool_calls: vec![ToolCallDelta {
375                            index,
376                            id: None,
377                            name: None,
378                            arguments: delta
379                                .get("partial_json")
380                                .and_then(Value::as_str)
381                                .unwrap_or_default()
382                                .to_string(),
383                        }],
384                        finish_reason: None,
385                    })),
386                    _ => {}
387                }
388            }
389            "message_delta" => {
390                let delta = obj.get("delta").cloned().unwrap_or(Value::Null);
391                let finish = match delta.get("stop_reason").and_then(Value::as_str) {
392                    Some("end_turn") | Some("stop_sequence") => FinishReason::Stop,
393                    Some("max_tokens") => FinishReason::Length,
394                    Some("tool_use") => FinishReason::ToolCalls,
395                    Some("refusal") => FinishReason::ContentFilter,
396                    other => other
397                        .map(|s| FinishReason::Other(s.to_string()))
398                        .unwrap_or(FinishReason::Stop),
399                };
400                out.push(StreamEvent::Delta(StreamDelta {
401                    index: 0,
402                    role: None,
403                    content: None,
404                    tool_calls: Vec::new(),
405                    finish_reason: Some(finish),
406                }));
407                if let Some(u) = obj.get("usage") {
408                    out.push(StreamEvent::Usage(Usage {
409                        prompt_tokens: None,
410                        completion_tokens: u.get("output_tokens").and_then(Value::as_u64),
411                        total_tokens: None,
412                        cached_tokens: None,
413                    }));
414                }
415            }
416            "message_stop" => out.push(StreamEvent::Done),
417            _ => {}
418        }
419        Ok(out)
420    }
421}
422
423/// Content blocks for a user/assistant message. Assistant tool calls
424/// become `tool_use` blocks (arguments object parsed from the string
425/// form); user images become `image` source blocks when they carry
426/// base64 data (directly, or via a decomposable `data:` URI).
427fn message_content_blocks(m: &ChatMessage) -> Value {
428    let mut blocks: Vec<Value> = Vec::new();
429    let mut text = String::new();
430    for part in &m.content {
431        match part {
432            ContentPart::Text { text: t } => text.push_str(t),
433            ContentPart::Image {
434                data_b64,
435                media_type,
436                url,
437            } => {
438                // data_b64 when present; else decompose a data: URI
439                // (the facade already decomposes, this is the defensive
440                // path for programmatically-built canonical requests).
441                // A REMOTE URL has no Anthropic translation (their API
442                // takes base64 sources only) and is dropped.
443                let decomposed = url.as_deref().and_then(crate::ai::types::split_data_uri);
444                let data = data_b64
445                    .clone()
446                    .or_else(|| decomposed.as_ref().map(|(_, data)| data.clone()));
447                if let Some(data) = data {
448                    let mime = media_type
449                        .clone()
450                        .or_else(|| decomposed.as_ref().map(|(mime, _)| mime.clone()));
451                    blocks.push(json!({
452                        "type": "image",
453                        "source": {
454                            "type": "base64",
455                            "media_type": mime.unwrap_or_else(|| "image/png".into()),
456                            "data": data,
457                        }
458                    }));
459                }
460            }
461        }
462    }
463    if !text.is_empty() {
464        blocks.insert(0, json!({"type": "text", "text": text}));
465    }
466    if m.role == ChatRole::Assistant {
467        for tc in &m.tool_calls {
468            let input: Value = tc.arguments.trim().parse().unwrap_or_else(|_| json!({}));
469            blocks.push(json!({
470                "type": "tool_use",
471                "id": tc.id,
472                "name": tc.name,
473                "input": input,
474            }));
475        }
476    }
477    Value::Array(blocks)
478}
479
480/// Merge consecutive same-role turns into one (Anthropic requires
481/// strict user/assistant alternation; OpenAI conversations do not).
482fn merge_consecutive_turns(messages: Vec<Value>) -> Vec<Value> {
483    let mut merged: Vec<Value> = Vec::with_capacity(messages.len());
484    for m in messages {
485        let role = m
486            .get("role")
487            .and_then(Value::as_str)
488            .unwrap_or("")
489            .to_string();
490        let content = m.get("content").cloned().unwrap_or(Value::Null);
491        if let Some(last) = merged.last_mut() {
492            if last.get("role").and_then(Value::as_str) == Some(role.as_str()) {
493                let existing = last.get("content").and_then(Value::as_array).cloned();
494                let mut combined = existing.unwrap_or_default();
495                match content {
496                    Value::Array(arr) => combined.extend(arr),
497                    other => combined.push(other),
498                }
499                if let Some(obj) = last.as_object_mut() {
500                    obj.insert("content".into(), Value::Array(combined));
501                }
502                continue;
503            }
504        }
505        merged.push(m);
506    }
507    merged
508}
509
510/// AI-06 (#196): Translate the canonical `ResponseFormat` to Anthropic.
511/// Anthropic has no native `response_format`. JsonObject and JsonSchema
512/// are translated to a forced tool call: a tool with the schema as
513/// `input_schema` and `tool_choice: {type: "any"}` to force the model
514/// to produce a tool_use block whose input conforms to the schema.
515/// Text is a no-op (the default).
516fn apply_anthropic_response_format(
517    body: &mut serde_json::Map<String, Value>,
518    rf: &crate::ai::types::ResponseFormat,
519) {
520    use crate::ai::types::ResponseFormat;
521    match rf {
522        ResponseFormat::Text => { /* no-op; text is the default */ }
523        ResponseFormat::JsonObject => {
524            // Force a tool call with a permissive schema.
525            let tool = serde_json::json!({
526                "name": "structured_output",
527                "description": "Output the result as a JSON object.",
528                "input_schema": {
529                    "type": "object",
530                    "properties": {},
531                    "additionalProperties": true
532                }
533            });
534            let tools = body
535                .entry("tools".to_string())
536                .or_insert_with(|| Value::Array(Vec::new()));
537            if let Some(arr) = tools.as_array_mut() {
538                arr.push(tool);
539            }
540            body.insert("tool_choice".into(), serde_json::json!({"type": "any"}));
541        }
542        ResponseFormat::JsonSchema { name, schema } => {
543            let tool = serde_json::json!({
544                "name": name,
545                "description": "Output the result conforming to the provided JSON schema.",
546                "input_schema": schema
547            });
548            let tools = body
549                .entry("tools".to_string())
550                .or_insert_with(|| Value::Array(Vec::new()));
551            if let Some(arr) = tools.as_array_mut() {
552                arr.push(tool);
553            }
554            body.insert("tool_choice".into(), serde_json::json!({"type": "any"}));
555        }
556    }
557}