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;