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