Skip to main content

open_agent/types/
anthropic.rs

1//! Wire types for the Anthropic messages API, and the translation into them.
2//!
3//! [`OpenAIRequest`] stays the SDK's single internal request representation. Both call sites
4//! that build one — `query()` and `Client::start_request()` — are protocol-agnostic, and the
5//! translation happens once, at the transport boundary. Branching earlier would mean two
6//! parallel request builders drifting apart, which is the defect this arrangement exists to
7//! prevent.
8//!
9//! Three shape differences carry real logic rather than field renaming:
10//!
11//! - **The system prompt is not a message.** OpenAI puts it in the `messages` array with
12//!   `role: "system"`; Anthropic takes it as a top-level `system` field. Multiple system
13//!   messages are joined rather than dropped, because the auto-execution loop can append one.
14//! - **Tool results are user turns.** OpenAI sends `role: "tool"` with a `tool_call_id`;
15//!   Anthropic sends a `user` message whose content opens with `tool_result` blocks. Runs of
16//!   consecutive tool results merge into one user turn, because the API rejects a turn that
17//!   answers only some of the outstanding calls.
18//! - **Tool schemas are flat.** OpenAI nests under `function` and calls the schema
19//!   `parameters`; Anthropic puts `name`/`description`/`input_schema` at the top level.
20
21use serde::Serialize;
22use serde_json::{Value, json};
23
24use super::{OpenAIContent, OpenAIContentPart, OpenAIMessage, OpenAIRequest};
25
26/// Request payload for `POST {base_url}/messages`.
27///
28/// Optional fields are omitted when `None` so the server applies its own defaults, matching
29/// [`OpenAIRequest`]'s treatment of `max_tokens` and `temperature`.
30#[derive(Debug, Clone, Serialize)]
31pub struct AnthropicRequest {
32    /// Model identifier (e.g. `"claude-opus-5"`, `"k3"`, `"MiniMax-M3"`).
33    pub model: String,
34
35    /// Conversation turns, alternating user and assistant. Never carries the system prompt.
36    pub messages: Vec<AnthropicMessage>,
37
38    /// The system prompt, as a top-level field rather than a message.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub system: Option<String>,
41
42    /// Whether to stream the response. The SDK always sets this.
43    pub stream: bool,
44
45    /// Maximum tokens to generate.
46    ///
47    /// Optional in the current Anthropic API, and omitted when unset so a long-context model
48    /// is not truncated by a client-invented ceiling. Some Anthropic-compatible third-party
49    /// endpoints still require it; that is the caller's to set, not this layer's to invent.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub max_tokens: Option<u32>,
52
53    /// Sampling temperature. Anthropic's accepted range is 0.0 to 1.0, narrower than
54    /// OpenAI's 0.0 to 2.0, and several compatible endpoints reject any value at all.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub temperature: Option<f32>,
57
58    /// Tool definitions in Anthropic's flat shape.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub tools: Option<Vec<Value>>,
61}
62
63/// One conversation turn.
64///
65/// `content` is deliberately a [`Value`]: Anthropic accepts either a bare string or an array
66/// of typed blocks, and the SDK emits whichever the turn actually needs.
67#[derive(Debug, Clone, Serialize)]
68pub struct AnthropicMessage {
69    /// `"user"` or `"assistant"`. Anthropic has no `"system"` or `"tool"` role.
70    pub role: String,
71
72    /// A string for text-only turns, or an array of content blocks.
73    pub content: Value,
74}
75
76impl AnthropicRequest {
77    /// Translates the SDK's canonical request into Anthropic's shape.
78    ///
79    /// Field-for-field except where the module documentation says otherwise.
80    pub fn from_openai(request: &OpenAIRequest) -> Self {
81        let mut system = Vec::new();
82        let mut messages: Vec<AnthropicMessage> = Vec::new();
83
84        for message in &request.messages {
85            match message.role.as_str() {
86                "system" => {
87                    if let Some(text) = plain_text(message) {
88                        system.push(text);
89                    }
90                }
91                "tool" => push_tool_result(&mut messages, message),
92                _ => messages.push(convert_turn(message)),
93            }
94        }
95
96        Self {
97            model: request.model.clone(),
98            messages,
99            system: (!system.is_empty()).then(|| system.join("\n\n")),
100            stream: request.stream,
101            max_tokens: request.max_tokens,
102            temperature: request.temperature,
103            tools: request
104                .tools
105                .as_ref()
106                .map(|tools| tools.iter().map(to_anthropic_tool).collect()),
107        }
108    }
109}
110
111/// The message's content as a plain string, if it has any.
112///
113/// A system message carrying image parts has no meaning in either API, so only the text
114/// parts survive.
115fn plain_text(message: &OpenAIMessage) -> Option<String> {
116    match message.content.as_ref()? {
117        OpenAIContent::Text(text) => Some(text.clone()),
118        OpenAIContent::Parts(parts) => {
119            let text: String = parts
120                .iter()
121                .filter_map(|part| match part {
122                    OpenAIContentPart::Text { text } => Some(text.as_str()),
123                    OpenAIContentPart::ImageUrl { .. } => None,
124                })
125                .collect();
126            (!text.is_empty()).then_some(text)
127        }
128    }
129}
130
131/// Converts a user or assistant turn.
132///
133/// An assistant turn carrying tool calls becomes a block array: its text first (when it said
134/// anything alongside the calls), then one `tool_use` block per call, in the order the model
135/// requested them.
136fn convert_turn(message: &OpenAIMessage) -> AnthropicMessage {
137    // A text-only turn serializes as a bare string, which is what the API's own examples show
138    // and what keeps a simple request readable on the wire. The decision reads the source
139    // message rather than re-inspecting the JSON a sibling function just built, so it does not
140    // depend on the shape of a literal no type checks.
141    if let Some(text) = bare_text(message) {
142        return AnthropicMessage {
143            role: message.role.clone(),
144            content: Value::String(text),
145        };
146    }
147
148    let mut blocks = content_blocks(message);
149
150    if let Some(tool_calls) = &message.tool_calls {
151        for call in tool_calls {
152            blocks.push(json!({
153                "type": "tool_use",
154                "id": call.id,
155                "name": call.function.name,
156                // Arguments cross the OpenAI wire as a JSON *string*; Anthropic wants the
157                // parsed object. An unparseable string means the model emitted malformed
158                // arguments, and an empty object is the same fallback the OpenAI-side
159                // accumulator applies rather than failing the whole turn.
160                "input": serde_json::from_str::<Value>(&call.function.arguments)
161                    .unwrap_or_else(|_| json!({})),
162            }));
163        }
164    }
165
166    AnthropicMessage {
167        role: message.role.clone(),
168        content: Value::Array(blocks),
169    }
170}
171
172/// The turn's content as a bare string, when it is text and nothing else.
173///
174/// A turn carrying tool calls, images, or several parts needs the block array, and a turn
175/// with no content at all becomes an empty one.
176fn bare_text(message: &OpenAIMessage) -> Option<String> {
177    if message.tool_calls.is_some() {
178        return None;
179    }
180
181    match message.content.as_ref()? {
182        OpenAIContent::Text(text) => Some(text.clone()),
183        OpenAIContent::Parts(parts) => match parts.as_slice() {
184            [OpenAIContentPart::Text { text }] => Some(text.clone()),
185            _ => None,
186        },
187    }
188}
189
190/// The typed content blocks of a message, excluding tool calls.
191fn content_blocks(message: &OpenAIMessage) -> Vec<Value> {
192    match message.content.as_ref() {
193        None => Vec::new(),
194        Some(OpenAIContent::Text(text)) => vec![json!({ "type": "text", "text": text })],
195        Some(OpenAIContent::Parts(parts)) => parts.iter().map(convert_part).collect(),
196    }
197}
198
199/// Converts one OpenAI content part.
200///
201/// A `data:` URI carries the bytes inline and becomes a `base64` source; anything else is
202/// passed through as a `url` source. Splitting on the scheme rather than fetching keeps this
203/// a pure transform.
204fn convert_part(part: &OpenAIContentPart) -> Value {
205    match part {
206        OpenAIContentPart::Text { text } => json!({ "type": "text", "text": text }),
207        OpenAIContentPart::ImageUrl { image_url } => match parse_data_uri(&image_url.url) {
208            Some((media_type, data)) => json!({
209                "type": "image",
210                "source": { "type": "base64", "media_type": media_type, "data": data },
211            }),
212            None => json!({
213                "type": "image",
214                "source": { "type": "url", "url": image_url.url },
215            }),
216        },
217    }
218}
219
220/// Splits `data:<media-type>[;<parameter>...];base64,<data>` into its media type and payload.
221///
222/// Returns `None` for any other URI, including a `data:` URI that is not base64-encoded —
223/// Anthropic's `base64` source would misread the payload, and a `url` source at least fails
224/// visibly.
225///
226/// The media type ends at the first `;`; anything between there and `;base64,` is a
227/// parameter such as `charset`. [`ImageBlock::from_url`](crate::ImageBlock::from_url) accepts
228/// such a URI and reads the media type the same way, so treating the parameters as part of it
229/// here would put a media type Anthropic rejects on the wire for an image the SDK had already
230/// accepted.
231fn parse_data_uri(url: &str) -> Option<(String, String)> {
232    let rest = url.strip_prefix("data:")?;
233    let (meta, data) = rest.split_once(";base64,")?;
234    let media_type = match meta.split_once(';') {
235        Some((media_type, _parameters)) => media_type,
236        None => meta,
237    };
238    (!media_type.is_empty()).then(|| (media_type.to_string(), data.to_string()))
239}
240
241/// Appends a tool result, merging it into the preceding user turn when there is one.
242///
243/// Anthropic requires every outstanding `tool_use` to be answered within a single user turn.
244/// Parallel tool calls arrive here as consecutive OpenAI `tool` messages, so a run of them
245/// has to collapse into one message rather than becoming several.
246fn push_tool_result(messages: &mut Vec<AnthropicMessage>, message: &OpenAIMessage) {
247    let block = json!({
248        "type": "tool_result",
249        "tool_use_id": message.tool_call_id.clone().unwrap_or_default(),
250        "content": plain_text(message).unwrap_or_default(),
251    });
252
253    // Tool results carry whole command outputs and file contents, so the block is moved into
254    // the merge and handed back only when there was no open turn to absorb it.
255    let Some(block) = merge_into_open_tool_turn(messages, block) else {
256        return;
257    };
258
259    messages.push(AnthropicMessage {
260        role: "user".to_string(),
261        content: json!([block]),
262    });
263}
264
265/// Appends `block` to the last message when that message is a tool-result turn.
266///
267/// Returns `None` when it merged, and gives `block` back untouched when it did not, so a
268/// payload that can be arbitrarily large is never copied. Merging only into a turn this module
269/// itself built is the point:
270/// a user turn the caller supplied is a real conversational turn, and appending a tool result
271/// to it would reorder the conversation.
272///
273/// The role is not checked, and checking it would be unfalsifiable code. A content array
274/// whose first block is a `tool_result` is only ever produced here, and this function always
275/// gives it `role: "user"` — so "opens with a tool result" already implies "is a user turn
276/// this module built", and a role test could never take its false branch.
277///
278/// A standalone function rather than a let-chain inside the caller, because let-chains are
279/// stable only from Rust 1.88 and this crate's MSRV is 1.85.
280fn merge_into_open_tool_turn(messages: &mut [AnthropicMessage], block: Value) -> Option<Value> {
281    let Some(last) = messages.last_mut() else {
282        return Some(block);
283    };
284    // A bare-string turn is text, and an array that opens with anything else is a real user
285    // turn — an image, say. Neither may absorb a tool result.
286    let Value::Array(blocks) = &mut last.content else {
287        return Some(block);
288    };
289    let opens_with_tool_result = blocks
290        .first()
291        .and_then(|block| block.get("type"))
292        .and_then(Value::as_str)
293        == Some("tool_result");
294    if !opens_with_tool_result {
295        return Some(block);
296    }
297
298    blocks.push(block);
299    None
300}
301
302/// Flattens an OpenAI tool definition into Anthropic's shape.
303///
304/// Anything that is not the expected `{"type":"function","function":{...}}` envelope is
305/// passed through untouched: a caller who hand-wrote a definition in Anthropic's shape
306/// already gets what it asked for, and mangling it would be worse than leaving it alone.
307fn to_anthropic_tool(tool: &Value) -> Value {
308    let Some(function) = tool.get("function") else {
309        return tool.clone();
310    };
311
312    let mut out = serde_json::Map::new();
313    if let Some(name) = function.get("name") {
314        out.insert("name".to_string(), name.clone());
315    }
316    if let Some(description) = function.get("description") {
317        out.insert("description".to_string(), description.clone());
318    }
319    out.insert(
320        "input_schema".to_string(),
321        function
322            .get("parameters")
323            .cloned()
324            .unwrap_or_else(|| json!({ "type": "object", "properties": {} })),
325    );
326    Value::Object(out)
327}
328
329#[cfg(test)]
330mod tests;