Skip to main content

agent_framework_anthropic/
convert.rs

1//! Conversion between framework types and the Anthropic Messages API wire
2//! format.
3
4use std::collections::{BTreeSet, HashMap};
5
6use agent_framework_core::tools::{ToolDefinition, ToolKind};
7use agent_framework_core::types::{
8    Annotation, ChatOptions, ChatResponse, Content, DataContent, FinishReason, FunctionArguments,
9    FunctionCallContent, FunctionResultContent, HostedFileContent, Message, ResponseFormat, Role,
10    TextContent, TextReasoningContent, TextSpanRegion, ToolMode, UriContent, UsageContent,
11    UsageDetails,
12};
13use serde_json::{json, Map, Value};
14
15/// The beta flags upstream's Python `AnthropicClient` unconditionally enables
16/// on every request (`BETA_FLAGS` in `agent_framework_anthropic/_chat_client.py`
17/// ~line 54), unioned with any client- or request-level additions and sent via
18/// the `anthropic-beta` header. Verified against `_create_run_options`
19/// (~line 254-264): `"betas": {*BETA_FLAGS, *self.additional_beta_flags, *betas}`
20/// is built for *every* `beta.messages.create` call, not only ones that pass
21/// hosted tools/MCP servers -- there is no conditional gating on tool
22/// presence.
23pub const DEFAULT_BETA_FLAGS: &[&str] = &["mcp-client-2025-04-04", "code-execution-2025-08-25"];
24
25/// The `ChatOptions::additional_properties` key upstream pops per-request
26/// additional beta flags from (`_create_run_options`, ~line 254-255).
27const ADDITIONAL_BETA_FLAGS_KEY: &str = "additional_beta_flags";
28
29/// Compute the full, deduplicated set of `anthropic-beta` flags for a
30/// request: the always-on [`DEFAULT_BETA_FLAGS`], the client-level
31/// `additional_beta_flags` (constructor option, mirroring upstream's
32/// `self.additional_beta_flags`), and any per-request `additional_beta_flags`
33/// found under `ChatOptions::additional_properties`.
34///
35/// Mirrors upstream's
36/// `chat_options.additional_properties.pop("additional_beta_flags")`: the key
37/// is removed from `options.additional_properties` so it is not also copied
38/// into the request body as a stray top-level field by [`build_request`].
39pub(crate) fn compute_beta_flags(
40    options: &mut ChatOptions,
41    client_additional: &[String],
42) -> Vec<String> {
43    let mut flags: BTreeSet<String> = DEFAULT_BETA_FLAGS.iter().map(|s| s.to_string()).collect();
44    flags.extend(client_additional.iter().cloned());
45    if let Some(value) = options
46        .additional_properties
47        .remove(ADDITIONAL_BETA_FLAGS_KEY)
48    {
49        if let Some(arr) = value.as_array() {
50            flags.extend(arr.iter().filter_map(Value::as_str).map(str::to_string));
51        }
52    }
53    flags.into_iter().collect()
54}
55
56/// Build a full Anthropic `POST /v1/messages` request body.
57pub fn build_request(
58    messages: &[Message],
59    options: &ChatOptions,
60    model: &str,
61    max_tokens: u32,
62    stream: bool,
63) -> Value {
64    let mut body = Map::new();
65    body.insert("model".into(), json!(model));
66    body.insert("max_tokens".into(), json!(max_tokens));
67    fill_request_body(body, messages, options, stream)
68}
69
70/// Build an Anthropic Messages-API-shaped request body for the multi-cloud
71/// transports ([`crate::bedrock`], [`crate::vertex`], [`crate::foundry`]).
72///
73/// AWS Bedrock, Google Vertex AI, and Azure AI Foundry all select the model
74/// through the request *URL* (a path segment or deployment), not the JSON
75/// body, and all three require an explicit `anthropic_version` string in the
76/// body in place of the direct API's top-level `model` field. Everything
77/// else — system prompt extraction, message conversion, sampling options,
78/// tools/tool_choice, and `additional_properties` passthrough — is identical
79/// to [`build_request`], via the same `fill_request_body` helper, so the
80/// two stay in lockstep by construction rather than by convention.
81pub fn build_cloud_request(
82    messages: &[Message],
83    options: &ChatOptions,
84    max_tokens: u32,
85    stream: bool,
86    anthropic_version: &str,
87) -> Value {
88    let mut body = Map::new();
89    body.insert("anthropic_version".into(), json!(anthropic_version));
90    body.insert("max_tokens".into(), json!(max_tokens));
91    fill_request_body(body, messages, options, stream)
92}
93
94/// Fill in the fields shared by [`build_request`] and [`build_cloud_request`]
95/// onto an already-started body map (which differs only in whether it leads
96/// with `model` or `anthropic_version`): `system`, `messages`, sampling
97/// parameters, `tools`/`mcp_servers`/`tool_choice`, `additional_properties`
98/// passthrough, and the `stream` flag.
99fn fill_request_body(
100    mut body: Map<String, Value>,
101    messages: &[Message],
102    options: &ChatOptions,
103    stream: bool,
104) -> Value {
105    let (system, rest) = extract_system(messages, options.instructions.as_deref());
106    let system = append_response_format_instructions(system, options.response_format.as_ref());
107    if let Some(system) = system {
108        body.insert("system".into(), json!(system));
109    }
110    body.insert("messages".into(), json!(messages_to_anthropic(rest)));
111
112    if let Some(t) = options.temperature {
113        body.insert("temperature".into(), json!(t));
114    }
115    if let Some(t) = options.top_p {
116        body.insert("top_p".into(), json!(t));
117    }
118    if let Some(stop) = &options.stop {
119        body.insert("stop_sequences".into(), json!(stop));
120    }
121
122    if !options.tools.is_empty() {
123        let (tools, mcp_servers) = tools_to_anthropic(&options.tools);
124        if !tools.is_empty() {
125            body.insert("tools".into(), json!(tools));
126        }
127        if !mcp_servers.is_empty() {
128            body.insert("mcp_servers".into(), json!(mcp_servers));
129        }
130    }
131    if let Some(tool_choice) = &options.tool_choice {
132        body.insert(
133            "tool_choice".into(),
134            tool_choice_to_anthropic(tool_choice, options.allow_multiple_tool_calls),
135        );
136    }
137
138    for (k, v) in &options.additional_properties {
139        body.entry(k.clone()).or_insert_with(|| v.clone());
140    }
141
142    if stream {
143        body.insert("stream".into(), json!(true));
144    }
145    Value::Object(body)
146}
147
148/// Split a leading system message (and/or `ChatOptions::instructions`) out
149/// into Anthropic's top-level `system` field, returning the remaining
150/// messages to convert into the `messages` array.
151///
152/// Mirrors the Python `AnthropicClient`, which only pulls `messages[0]` when
153/// it is a system message; any other content keeps its original position and
154/// maps to a `user` turn (see [`messages_to_anthropic`]'s role mapping).
155pub fn extract_system<'a>(
156    messages: &'a [Message],
157    options_instructions: Option<&str>,
158) -> (Option<String>, &'a [Message]) {
159    let mut parts = Vec::new();
160    if let Some(instr) = options_instructions {
161        if !instr.is_empty() {
162            parts.push(instr.to_string());
163        }
164    }
165    let mut rest = messages;
166    if let Some(first) = messages.first() {
167        if first.role == Role::system() {
168            let text = first.text();
169            if !text.is_empty() {
170                parts.push(text);
171            }
172            rest = &messages[1..];
173        }
174    }
175    if parts.is_empty() {
176        (None, rest)
177    } else {
178        (Some(parts.join("\n\n")), rest)
179    }
180}
181
182/// Fold a requested [`ResponseFormat`] into the system prompt.
183///
184/// The Anthropic Messages API has **no** native `response_format` /
185/// structured-output parameter — confirmed against the upstream Python
186/// `AnthropicClient._create_run_options` (`agent_framework_anthropic/_chat_client.py`),
187/// which builds its `run_options` dict from `temperature`, `top_p`, `stop`,
188/// `tool_choice`, tools, etc. but never reads `chat_options.response_format`
189/// at all, and against .NET's `Microsoft.Agents.AI.Anthropic` extensions,
190/// which likewise have no `ResponseFormat` handling. So this isn't a Rust
191/// port gap to close by mapping onto a wire field that doesn't exist; it's a
192/// gap in the underlying API. Rather than silently dropping the option (the
193/// previous behavior here, and the *actual* behavior of both reference
194/// implementations today), this appends an explicit natural-language
195/// instruction to the system prompt as a pragmatic, observable fallback:
196///
197/// * [`ResponseFormat::Text`] (or no format): no-op.
198/// * [`ResponseFormat::JsonObject`]: instructs the model to respond with a
199///   bare JSON object.
200/// * [`ResponseFormat::JsonSchema`]: instructs the model to respond with a
201///   JSON object conforming to the embedded schema, and includes the schema
202///   itself (pretty-printed) in the prompt.
203fn append_response_format_instructions(
204    system: Option<String>,
205    format: Option<&ResponseFormat>,
206) -> Option<String> {
207    let instruction = match format {
208        None | Some(ResponseFormat::Text) => return system,
209        Some(ResponseFormat::JsonObject) => {
210            "Respond only with a single valid JSON object. Do not include any \
211             explanation, preamble, or markdown code fences before or after the JSON."
212                .to_string()
213        }
214        Some(ResponseFormat::JsonSchema { name, schema, .. }) => {
215            let pretty =
216                serde_json::to_string_pretty(schema).unwrap_or_else(|_| schema.to_string());
217            format!(
218                "Respond only with a single valid JSON object that conforms exactly to \
219                 the following JSON Schema (named \"{name}\"). Do not include any \
220                 explanation, preamble, or markdown code fences before or after the JSON.\n\n\
221                 JSON Schema:\n{pretty}"
222            )
223        }
224    };
225    Some(match system {
226        Some(existing) if !existing.is_empty() => format!("{existing}\n\n{instruction}"),
227        _ => instruction,
228    })
229}
230
231/// Convert framework messages into Anthropic's `messages` array.
232///
233/// Anthropic has no `system` or `tool` role: everything that isn't
234/// `assistant` (including tool results) is sent as a `user` turn, matching
235/// the Python client's `ROLE_MAP`.
236pub fn messages_to_anthropic(messages: &[Message]) -> Vec<Value> {
237    let mut out = Vec::with_capacity(messages.len());
238    for msg in messages {
239        let role = if msg.role == Role::assistant() {
240            "assistant"
241        } else {
242            "user"
243        };
244        let mut blocks: Vec<Value> = Vec::new();
245        for content in &msg.contents {
246            match content {
247                Content::Text(t) => blocks.push(json!({ "type": "text", "text": t.text })),
248                Content::TextReasoning(t) => {
249                    blocks.push(json!({ "type": "thinking", "thinking": t.text }))
250                }
251                Content::FunctionCall(fc) => blocks.push(function_call_block(fc)),
252                Content::FunctionResult(fr) => blocks.push(function_result_block(fr)),
253                Content::Data(dc) => {
254                    if let Some(block) = image_block_from_data(dc) {
255                        blocks.push(block);
256                    }
257                }
258                Content::Uri(uc) => {
259                    if let Some(block) = image_block_from_uri(uc) {
260                        blocks.push(block);
261                    }
262                }
263                _ => {}
264            }
265        }
266        if blocks.is_empty() {
267            // Anthropic rejects messages with an empty content array.
268            continue;
269        }
270        out.push(json!({ "role": role, "content": blocks }));
271    }
272    normalize_role_alternation(out)
273}
274
275/// Enforce the Messages API's conversation-shape rules: messages must
276/// alternate between `user` and `assistant`, starting with `user`.
277/// Consecutive same-role messages (common in orchestration transcripts,
278/// e.g. several user turns from a group chat) are merged by concatenating
279/// their content blocks; a leading assistant message gets a minimal
280/// synthetic user turn inserted before it so the greeting is preserved.
281fn normalize_role_alternation(messages: Vec<Value>) -> Vec<Value> {
282    let mut out: Vec<Value> = Vec::with_capacity(messages.len());
283    for msg in messages {
284        match out.last_mut() {
285            Some(prev) if prev["role"] == msg["role"] => {
286                if let (Some(prev_blocks), Some(new_blocks)) =
287                    (prev["content"].as_array_mut(), msg["content"].as_array())
288                {
289                    prev_blocks.extend(new_blocks.iter().cloned());
290                }
291            }
292            _ => out.push(msg),
293        }
294    }
295    if out.first().map(|m| m["role"] == "assistant") == Some(true) {
296        out.insert(
297            0,
298            json!({
299                "role": "user",
300                "content": [{ "type": "text", "text": "(continuing the conversation)" }]
301            }),
302        );
303    }
304    out
305}
306
307fn function_call_block(fc: &FunctionCallContent) -> Value {
308    let input = fc.parse_arguments().unwrap_or_default();
309    json!({
310        "type": "tool_use",
311        "id": fc.call_id,
312        "name": fc.name,
313        "input": Value::Object(input.into_iter().collect()),
314    })
315}
316
317fn function_result_block(fr: &FunctionResultContent) -> Value {
318    let mut block = Map::new();
319    block.insert("type".into(), json!("tool_result"));
320    block.insert("tool_use_id".into(), json!(fr.call_id));
321    block.insert("content".into(), json!(result_text(fr)));
322    if fr.exception.is_some() {
323        block.insert("is_error".into(), json!(true));
324    }
325    Value::Object(block)
326}
327
328fn result_text(fr: &FunctionResultContent) -> String {
329    if let Some(exc) = &fr.exception {
330        return exc.clone();
331    }
332    match &fr.result {
333        Some(Value::String(s)) => s.clone(),
334        Some(v) => v.to_string(),
335        None => String::new(),
336    }
337}
338
339/// Build an `{"type":"image","source":{"type":"base64",...}}` block from a
340/// `data:` URI, without needing a base64 encoder: [`DataContent::uri`] is
341/// already base64 text after the `base64,` marker (per
342/// `DataContent::from_bytes` in `agent-framework-core`), so we just slice it
343/// out.
344fn image_block_from_data(dc: &DataContent) -> Option<Value> {
345    let is_image = dc
346        .media_type
347        .as_deref()
348        .map(is_image_media_type)
349        .unwrap_or_else(|| dc.uri.starts_with("data:image/"));
350    if !is_image {
351        return None;
352    }
353    let (parsed_media_type, data) = split_data_uri(&dc.uri)?;
354    let media_type = dc.media_type.clone().unwrap_or(parsed_media_type);
355    Some(json!({
356        "type": "image",
357        "source": { "type": "base64", "media_type": media_type, "data": data }
358    }))
359}
360
361fn image_block_from_uri(uc: &UriContent) -> Option<Value> {
362    if !is_image_media_type(&uc.media_type) {
363        return None;
364    }
365    Some(json!({ "type": "image", "source": { "type": "url", "url": uc.uri } }))
366}
367
368fn split_data_uri(uri: &str) -> Option<(String, String)> {
369    let rest = uri.strip_prefix("data:")?;
370    let (meta, data) = rest.split_once(',')?;
371    let media_type = meta
372        .split(';')
373        .next()
374        .filter(|s| !s.is_empty())
375        .unwrap_or("application/octet-stream")
376        .to_string();
377    Some((media_type, data.to_string()))
378}
379
380fn is_image_media_type(media_type: &str) -> bool {
381    media_type.starts_with("image/")
382}
383
384/// Convert tool definitions into Anthropic's request shape.
385///
386/// Returns `(tools, mcp_servers)`: ordinary function tools and most hosted
387/// tool markers become entries in the returned `tools` list (destined for the
388/// request's top-level `tools` field), while [`ToolKind::HostedMcp`] tools
389/// become entries in `mcp_servers` instead -- Anthropic's MCP connector is a
390/// separate top-level `mcp_servers` request field, not a `tools[]` entry.
391///
392/// Mirrors upstream's `_convert_tools_to_anthropic_format`
393/// (`_chat_client.py` ~379-430):
394///
395/// * [`ToolKind::Function`] -> `{"type":"custom","name":...,"description":...,"input_schema":...}`
396///   (~390-396).
397/// * [`ToolKind::HostedWebSearch`] -> `{"type":"web_search_20250305","name":"web_search"}`,
398///   optionally merged with extra config (~397-404). Upstream reads arbitrary
399///   keys from `tool.additional_properties`; Rust's [`ToolKind::HostedWebSearch`]
400///   has no such bag (core, out of scope here), so `"max_uses"` /
401///   `"user_location"` are read from [`ToolDefinition::parameters`] instead,
402///   the closest stand-in this crate can reach without touching core.
403/// * [`ToolKind::HostedCodeInterpreter`] -> `{"type":"code_execution_20250825","name":"code_execution"}`
404///   (~405-410), no extra config.
405/// * [`ToolKind::HostedMcp`] -> an `mcp_servers[]` entry
406///   `{"type":"url","name":...,"url":...}`, plus `tool_configuration.allowed_tools`
407///   when non-empty and `authorization_token` when an `"authorization"` header
408///   is present (~411-421). Rust's [`ToolKind::HostedMcp`] has no `headers`
409///   field (core), so the authorization header is read from
410///   `ToolDefinition::parameters["headers"]["authorization"]` instead.
411/// * [`ToolKind::HostedFileSearch`]: unknown to the Anthropic API (upstream
412///   has no case for it either -- it would fall through to the `case _:`
413///   debug log), so it is skipped with a `tracing::warn!`.
414///
415/// The `MutableMapping()` case upstream uses for raw pass-through dict tools
416/// has no Rust equivalent ([`ToolDefinition`] is always structured) and is
417/// not applicable here.
418pub fn tools_to_anthropic(tools: &[ToolDefinition]) -> (Vec<Value>, Vec<Value>) {
419    let mut tool_list = Vec::new();
420    let mut mcp_servers = Vec::new();
421    for t in tools {
422        match &t.kind {
423            ToolKind::Function => {
424                tool_list.push(json!({
425                    "type": "custom",
426                    "name": t.name,
427                    "description": t.description,
428                    "input_schema": t.parameters,
429                }));
430            }
431            ToolKind::HostedWebSearch => {
432                let mut search_tool = Map::new();
433                search_tool.insert("type".into(), json!("web_search_20250305"));
434                search_tool.insert("name".into(), json!("web_search"));
435                if let Some(max_uses) = t.parameters.get("max_uses") {
436                    search_tool.insert("max_uses".into(), max_uses.clone());
437                }
438                if let Some(user_location) = t.parameters.get("user_location") {
439                    search_tool.insert("user_location".into(), user_location.clone());
440                }
441                tool_list.push(Value::Object(search_tool));
442            }
443            ToolKind::HostedCodeInterpreter => {
444                tool_list.push(json!({
445                    "type": "code_execution_20250825",
446                    "name": "code_execution",
447                }));
448            }
449            ToolKind::HostedMcp { url, allowed_tools } => {
450                let mut server_def = Map::new();
451                server_def.insert("type".into(), json!("url"));
452                server_def.insert("name".into(), json!(t.name));
453                server_def.insert("url".into(), json!(url));
454                if let Some(allowed) = allowed_tools {
455                    if !allowed.is_empty() {
456                        server_def.insert(
457                            "tool_configuration".into(),
458                            json!({ "allowed_tools": allowed }),
459                        );
460                    }
461                }
462                // Case-insensitive: callers may reasonably write
463                // `Authorization` (HTTP header convention) in the map.
464                if let Some(auth) = t
465                    .parameters
466                    .get("headers")
467                    .and_then(|h| h.as_object())
468                    .and_then(|obj| {
469                        obj.iter()
470                            .find(|(k, _)| k.eq_ignore_ascii_case("authorization"))
471                            .and_then(|(_, v)| v.as_str())
472                    })
473                {
474                    server_def.insert("authorization_token".into(), json!(auth));
475                }
476                mcp_servers.push(Value::Object(server_def));
477            }
478            ToolKind::HostedFileSearch { .. } => {
479                tracing::warn!(
480                    tool = %t.name,
481                    "Anthropic: hosted file-search tools are not supported by the Anthropic Messages API; skipping"
482                );
483            }
484            ToolKind::HostedImageGeneration => {
485                tracing::warn!(
486                    tool = %t.name,
487                    "Anthropic: hosted image-generation tools are not supported by the Anthropic Messages API; skipping"
488                );
489            }
490        }
491    }
492    (tool_list, mcp_servers)
493}
494
495fn tool_choice_to_anthropic(mode: &ToolMode, allow_multiple: Option<bool>) -> Value {
496    let mut obj = Map::new();
497    match mode {
498        ToolMode::Auto => {
499            obj.insert("type".into(), json!("auto"));
500        }
501        ToolMode::Required(Some(name)) => {
502            obj.insert("type".into(), json!("tool"));
503            obj.insert("name".into(), json!(name));
504        }
505        ToolMode::Required(None) => {
506            obj.insert("type".into(), json!("any"));
507        }
508        ToolMode::None => {
509            obj.insert("type".into(), json!("none"));
510        }
511    }
512    if !matches!(mode, ToolMode::None) {
513        if let Some(allow) = allow_multiple {
514            obj.insert("disable_parallel_tool_use".into(), json!(!allow));
515        }
516    }
517    Value::Object(obj)
518}
519
520/// Parse a full (non-streaming) Anthropic `Message` response.
521pub fn parse_response(value: &Value) -> ChatResponse {
522    let mut response = ChatResponse {
523        response_id: value.get("id").and_then(Value::as_str).map(String::from),
524        model: value.get("model").and_then(Value::as_str).map(String::from),
525        ..Default::default()
526    };
527
528    let contents = value
529        .get("content")
530        .and_then(Value::as_array)
531        .map(|blocks| parse_content_blocks(blocks))
532        .unwrap_or_default();
533
534    let mut message = Message::with_contents(Role::assistant(), contents);
535    message.message_id = response.response_id.clone();
536    response.messages.push(message);
537
538    if let Some(reason) = value.get("stop_reason").and_then(Value::as_str) {
539        response.finish_reason = Some(map_stop_reason(reason));
540    }
541    if let Some(usage) = value.get("usage") {
542        response.usage_details = Some(parse_usage(usage));
543    }
544    response
545}
546
547/// Parse Anthropic content blocks (a full response's `content` array, or a
548/// single-element slice built from a streaming `content_block_start`'s
549/// `content_block`) into framework [`Content`] items.
550///
551/// Mirrors upstream's `_parse_message_contents` (`_chat_client.py` ~521-609),
552/// which takes and returns a list so that a single hosted-tool block can
553/// expand into more than one [`Content`] item (see the
554/// `code_execution_tool_result` case below). Handled block types:
555///
556/// * `text` -> [`Content::Text`], with citations parsed via
557///   [`parse_citations`] (~528-535).
558/// * `tool_use` | `mcp_tool_use` | `server_tool_use` -> [`Content::FunctionCall`]
559///   (~536-545): hosted-tool invocations (web search, code execution, MCP)
560///   surface the same way a plain function call does.
561/// * `mcp_tool_result` -> [`Content::FunctionResult`] (~546-557): if the
562///   block's `content` is a JSON array it is recursively parsed through this
563///   same function (mirroring `self._parse_message_contents(content_block.content)`);
564///   otherwise the raw value is used as-is.
565/// * `web_search_tool_result` | `web_fetch_tool_result` -> [`Content::FunctionResult`]
566///   (~558-567) with the raw `content` value (NOT recursively parsed --
567///   upstream only recurses for `mcp_tool_result`).
568/// * `code_execution_tool_result` | `bash_code_execution_tool_result` |
569///   `text_editor_code_execution_tool_result` -> (~568-594): when the nested
570///   `content.type` is `bash_code_execution_result` or
571///   `code_execution_result`, each item of its nested `content` array that
572///   carries a `file_id` becomes a [`Content::HostedFile`] emitted *before*
573///   the trailing [`Content::FunctionResult`] (whose `result` is always the
574///   whole nested `content` object, unparsed). `text_editor_code_execution_tool_result`'s
575///   nested content is never one of those two types, so it only ever
576///   produces the trailing `FunctionResult` -- verified against the
577///   `anthropic` Python SDK's block schemas (`BetaTextEditorCodeExecutionToolResultBlock`'s
578///   `content` union has no `code_execution_result`/`bash_code_execution_result`
579///   member).
580/// * `thinking` -> [`Content::TextReasoning`].
581/// * anything else -> skipped with a `tracing::debug!`, mirroring upstream's
582///   trailing `case _: logger.debug(...)`.
583///
584/// Two upstream behaviors have no Rust equivalent and are intentionally
585/// dropped:
586///
587/// * Upstream tracks `self._last_call_id_name` to backfill a `name` onto the
588///   `FunctionResultContent` produced for `mcp_tool_result` /
589///   `web_search_tool_result` / `code_execution_tool_result` blocks. Rust's
590///   [`FunctionResultContent`] (core, out of scope here) has no `name` field
591///   at all, so there is nothing to backfill.
592/// * Upstream also uses `self._last_call_id_name` to recover the `call_id`
593///   for a streaming `input_json_delta`. Rust's streaming path
594///   (`parse_stream_event` in `lib.rs`) already threads `call_id` per content
595///   block *index* via `tool_use_ids`, which is strictly more correct for
596///   interleaved concurrent tool calls, so it is left as-is rather than
597///   downgraded to upstream's single-slot tracking.
598pub(crate) fn parse_content_blocks(blocks: &[Value]) -> Vec<Content> {
599    let mut out = Vec::with_capacity(blocks.len());
600    for block in blocks {
601        let Some(block_type) = block.get("type").and_then(Value::as_str) else {
602            continue;
603        };
604        match block_type {
605            "text" => {
606                let text = block
607                    .get("text")
608                    .and_then(Value::as_str)
609                    .unwrap_or_default();
610                out.push(Content::Text(TextContent {
611                    text: text.to_string(),
612                    annotations: parse_citations(block),
613                }));
614            }
615            "tool_use" | "mcp_tool_use" | "server_tool_use" => {
616                let id = block
617                    .get("id")
618                    .and_then(Value::as_str)
619                    .unwrap_or_default()
620                    .to_string();
621                let name = block
622                    .get("name")
623                    .and_then(Value::as_str)
624                    .unwrap_or_default()
625                    .to_string();
626                let input = match block.get("input") {
627                    Some(Value::Object(m)) => m.clone().into_iter().collect(),
628                    _ => HashMap::new(),
629                };
630                out.push(Content::FunctionCall(FunctionCallContent::new(
631                    id,
632                    name,
633                    Some(FunctionArguments::Object(input)),
634                )));
635            }
636            "mcp_tool_result" => {
637                let call_id = tool_use_id(block);
638                let result = match block.get("content") {
639                    Some(Value::Array(items)) => {
640                        serde_json::to_value(parse_content_blocks(items)).unwrap_or(Value::Null)
641                    }
642                    Some(other) => other.clone(),
643                    None => Value::Null,
644                };
645                out.push(Content::FunctionResult(FunctionResultContent::new(
646                    call_id,
647                    Some(result),
648                )));
649            }
650            "web_search_tool_result" | "web_fetch_tool_result" => {
651                let call_id = tool_use_id(block);
652                let result = block.get("content").cloned().unwrap_or(Value::Null);
653                out.push(Content::FunctionResult(FunctionResultContent::new(
654                    call_id,
655                    Some(result),
656                )));
657            }
658            "code_execution_tool_result"
659            | "bash_code_execution_tool_result"
660            | "text_editor_code_execution_tool_result" => {
661                let call_id = tool_use_id(block);
662                let nested = block.get("content");
663                if let Some(nc) = nested {
664                    let nc_type = nc.get("type").and_then(Value::as_str);
665                    if matches!(
666                        nc_type,
667                        Some("bash_code_execution_result") | Some("code_execution_result")
668                    ) {
669                        if let Some(items) = nc.get("content").and_then(Value::as_array) {
670                            for item in items {
671                                if let Some(file_id) = item.get("file_id").and_then(Value::as_str) {
672                                    out.push(Content::HostedFile(HostedFileContent {
673                                        file_id: file_id.to_string(),
674                                    }));
675                                }
676                            }
677                        }
678                    }
679                }
680                out.push(Content::FunctionResult(FunctionResultContent::new(
681                    call_id,
682                    Some(nested.cloned().unwrap_or(Value::Null)),
683                )));
684            }
685            "thinking" => {
686                out.push(Content::TextReasoning(TextReasoningContent {
687                    text: block
688                        .get("thinking")
689                        .and_then(Value::as_str)
690                        .unwrap_or_default()
691                        .to_string(),
692                    annotations: None,
693                    ..Default::default()
694                }));
695            }
696            other => {
697                tracing::debug!(block_type = %other, "Anthropic: ignoring unsupported content block type");
698            }
699        }
700    }
701    out
702}
703
704/// The `tool_use_id` field shared by every hosted-tool result block type.
705fn tool_use_id(block: &Value) -> String {
706    block
707        .get("tool_use_id")
708        .and_then(Value::as_str)
709        .unwrap_or_default()
710        .to_string()
711}
712
713/// Parse the `citations` array on a text content block into
714/// [`Annotation`]s. Mirrors upstream's `_parse_citations`
715/// (`_chat_client.py` ~611-670), including which field feeds `title` for
716/// each citation type:
717///
718/// * `char_location` / `page_location` / `content_block_location`: `snippet`
719///   from `cited_text`, `file_id` when present, and one `annotated_regions`
720///   span from the type's start/end pair (char index, page number, or block
721///   index respectively).
722/// * `web_search_result_location`: `title`, `snippet` from `cited_text`, and
723///   `url`.
724/// * `search_result_location`: `title`, `snippet` from `cited_text`, `url`
725///   from `source`, and an `annotated_regions` span from the block index
726///   pair.
727/// * An unrecognized citation `type` still produces an (empty) annotation --
728///   upstream unconditionally appends `cit` after the `match` regardless of
729///   which arm (or the fallback `case _`) ran (~667-669).
730///
731/// `title` note: upstream's `page_location` and `content_block_location`
732/// cases read `citation.document_title`, but `char_location` reads
733/// `citation.title` (~622) -- and per the `anthropic` Python SDK's
734/// `BetaCitationCharLocation` model, `char_location` citations have *no*
735/// `title` field, only `document_title` (identical to its two siblings).
736/// This looks like an upstream copy/paste bug (`char_location`'s branch
737/// resembles `web_search_result_location`/`search_result_location`, which
738/// legitimately use `.title`) rather than intentional behavior, since the
739/// real API never sends a `title` key on a `char_location` citation. It is
740/// mirrored here literally (`char_location` reads wire key `"title"`, which
741/// in practice is always absent) rather than "corrected" to `document_title`,
742/// per this task's mandate to match upstream's exact behavior; flagged in
743/// the implementation report.
744pub(crate) fn parse_citations(block: &Value) -> Option<Vec<Annotation>> {
745    let citations = block.get("citations").and_then(Value::as_array)?;
746    if citations.is_empty() {
747        return None;
748    }
749    let mut annotations = Vec::with_capacity(citations.len());
750    for citation in citations {
751        let mut cit = Annotation::default();
752        // Plain (possibly-absent) string field, assigned unconditionally --
753        // mirrors upstream's bare `cit.title = citation.xxx` /
754        // `cit.snippet = citation.cited_text` / `cit.url = citation.xxx`,
755        // which set `None`/`""` through just as readily as a real value.
756        let str_field = |key: &str| {
757            citation
758                .get(key)
759                .and_then(Value::as_str)
760                .map(str::to_string)
761        };
762        // `file_id` is the one field upstream gates on truthiness
763        // (`if citation.file_id: cit.file_id = citation.file_id`), so an
764        // empty string is treated the same as absent.
765        let truthy_str = |key: &str| {
766            citation
767                .get(key)
768                .and_then(Value::as_str)
769                .filter(|s| !s.is_empty())
770                .map(str::to_string)
771        };
772        match citation.get("type").and_then(Value::as_str) {
773            Some("char_location") => {
774                // See doc comment: upstream reads `citation.title` here, not
775                // `citation.document_title` (likely a bug), mirrored as-is.
776                cit.title = str_field("title");
777                cit.snippet = str_field("cited_text");
778                cit.file_id = truthy_str("file_id");
779                cit.annotated_regions = Some(vec![TextSpanRegion {
780                    start_index: citation.get("start_char_index").and_then(Value::as_i64),
781                    end_index: citation.get("end_char_index").and_then(Value::as_i64),
782                }]);
783            }
784            Some("page_location") => {
785                cit.title = str_field("document_title");
786                cit.snippet = str_field("cited_text");
787                cit.file_id = truthy_str("file_id");
788                cit.annotated_regions = Some(vec![TextSpanRegion {
789                    start_index: citation.get("start_page_number").and_then(Value::as_i64),
790                    end_index: citation.get("end_page_number").and_then(Value::as_i64),
791                }]);
792            }
793            Some("content_block_location") => {
794                cit.title = str_field("document_title");
795                cit.snippet = str_field("cited_text");
796                cit.file_id = truthy_str("file_id");
797                cit.annotated_regions = Some(vec![TextSpanRegion {
798                    start_index: citation.get("start_block_index").and_then(Value::as_i64),
799                    end_index: citation.get("end_block_index").and_then(Value::as_i64),
800                }]);
801            }
802            Some("web_search_result_location") => {
803                cit.title = str_field("title");
804                cit.snippet = str_field("cited_text");
805                cit.url = str_field("url");
806            }
807            Some("search_result_location") => {
808                cit.title = str_field("title");
809                cit.snippet = str_field("cited_text");
810                cit.url = str_field("source");
811                cit.annotated_regions = Some(vec![TextSpanRegion {
812                    start_index: citation.get("start_block_index").and_then(Value::as_i64),
813                    end_index: citation.get("end_block_index").and_then(Value::as_i64),
814                }]);
815            }
816            other => {
817                tracing::debug!(
818                    citation_type = ?other,
819                    "Anthropic: unknown citation type encountered"
820                );
821            }
822        }
823        annotations.push(cit);
824    }
825    if annotations.is_empty() {
826        None
827    } else {
828        Some(annotations)
829    }
830}
831
832/// Map Anthropic's `stop_reason` to the shared [`FinishReason`].
833pub(crate) fn map_stop_reason(reason: &str) -> FinishReason {
834    match reason {
835        "end_turn" | "stop_sequence" => FinishReason::stop(),
836        "max_tokens" => FinishReason::new(FinishReason::LENGTH),
837        "tool_use" => FinishReason::tool_calls(),
838        "refusal" => FinishReason::new(FinishReason::CONTENT_FILTER),
839        "pause_turn" => FinishReason::stop(),
840        other => FinishReason::new(other),
841    }
842}
843
844/// Parse an Anthropic `usage` object (input/output tokens plus prompt-cache
845/// counts) into [`UsageDetails`].
846pub(crate) fn parse_usage(usage: &Value) -> UsageDetails {
847    let mut details = UsageDetails {
848        input_token_count: usage.get("input_tokens").and_then(Value::as_u64),
849        output_token_count: usage.get("output_tokens").and_then(Value::as_u64),
850        cache_creation_input_token_count: usage
851            .get("cache_creation_input_tokens")
852            .and_then(Value::as_u64),
853        cache_read_input_token_count: usage.get("cache_read_input_tokens").and_then(Value::as_u64),
854        ..Default::default()
855    };
856    if let (Some(i), Some(o)) = (details.input_token_count, details.output_token_count) {
857        details.total_token_count = Some(i + o);
858    }
859    details
860}
861
862/// Parse `usage` at `message_start` time: only `input_tokens` (plus cache
863/// counts) are taken. `message_start.usage.output_tokens` is a small
864/// in-progress placeholder, not a real count — `message_delta.usage` later
865/// carries the authoritative final `output_tokens`. Emitting both as
866/// additive [`UsageContent`] (as `ChatResponse::absorb_update` does when
867/// aggregating a stream) would double-count output tokens, so this
868/// deliberately omits `output_tokens` here.
869pub(crate) fn parse_message_start_usage(usage: &Value) -> Option<UsageContent> {
870    let details = UsageDetails {
871        input_token_count: usage.get("input_tokens").and_then(Value::as_u64),
872        cache_creation_input_token_count: usage
873            .get("cache_creation_input_tokens")
874            .and_then(Value::as_u64),
875        cache_read_input_token_count: usage.get("cache_read_input_tokens").and_then(Value::as_u64),
876        ..Default::default()
877    };
878    if details.input_token_count.is_none()
879        && details.cache_creation_input_token_count.is_none()
880        && details.cache_read_input_token_count.is_none()
881    {
882        return None;
883    }
884    Some(UsageContent { details })
885}
886
887/// Per-stream accumulator that turns Anthropic's *cumulative* usage snapshots
888/// into the *increments* the framework's additive aggregation expects.
889///
890/// Anthropic streams cumulative usage: `message_start` seeds it and every
891/// `message_delta` reports the running total for the message, not a per-delta
892/// increment. [`ChatResponse::absorb_update`] sums every usage `Content`, so
893/// emitting the raw snapshots inflates the total by all the earlier ones — a
894/// stream whose `message_start` reports `input_tokens: 10` and whose
895/// `message_delta` reports the cumulative `{input_tokens: 10, output_tokens:
896/// 25}` aggregates to 20 input tokens instead of 10.
897///
898/// Emitting the increment over what has already been emitted makes that
899/// summation reconstruct the final cumulative usage instead. Mirrors upstream's
900/// `_incremental_usage` (`_chat_client.py`), threaded across a single stream.
901///
902/// Counts absent from a snapshot leave their accumulated value untouched, so a
903/// partial delta carrying only `output_tokens` does not reset the input side.
904/// (Upstream's Python clears its whole accumulator on each snapshot, which
905/// drops the running total for any key the snapshot omits; this implementation
906/// follows the documented partial-delta intent instead.)
907#[derive(Debug, Default)]
908pub(crate) struct StreamUsageAccumulator {
909    emitted: UsageDetails,
910}
911
912impl StreamUsageAccumulator {
913    /// Convert a cumulative snapshot into the increment since the last one,
914    /// recording the snapshot as the new high-water mark.
915    pub(crate) fn increment(&mut self, cumulative: &UsageDetails) -> UsageDetails {
916        fn delta(emitted: &mut Option<u64>, cumulative: Option<u64>) -> Option<u64> {
917            let total = cumulative?;
918            let previous = emitted.unwrap_or(0);
919            // `saturating_sub` guards a provider that reports a *decreasing*
920            // cumulative count: clamp to 0 rather than underflowing u64. The
921            // baseline must then stay at the high-water mark rather than follow
922            // the lower total — otherwise 30 -> 20 -> 25 emits 30 + 0 + 5 = 35,
923            // inventing 5 tokens the provider never reported.
924            let increment = total.saturating_sub(previous);
925            *emitted = Some(total.max(previous));
926            Some(increment)
927        }
928
929        let mut out = UsageDetails {
930            input_token_count: delta(
931                &mut self.emitted.input_token_count,
932                cumulative.input_token_count,
933            ),
934            output_token_count: delta(
935                &mut self.emitted.output_token_count,
936                cumulative.output_token_count,
937            ),
938            cache_creation_input_token_count: delta(
939                &mut self.emitted.cache_creation_input_token_count,
940                cumulative.cache_creation_input_token_count,
941            ),
942            cache_read_input_token_count: delta(
943                &mut self.emitted.cache_read_input_token_count,
944                cumulative.cache_read_input_token_count,
945            ),
946            reasoning_output_token_count: delta(
947                &mut self.emitted.reasoning_output_token_count,
948                cumulative.reasoning_output_token_count,
949            ),
950            ..Default::default()
951        };
952        for (key, total) in &cumulative.additional_counts {
953            let emitted = self
954                .emitted
955                .additional_counts
956                .entry(key.clone())
957                .or_insert(0);
958            out.additional_counts
959                .insert(key.clone(), total.saturating_sub(*emitted));
960            // High-water mark, as above.
961            *emitted = (*total).max(*emitted);
962        }
963        // `total_token_count` is derived, not reported: recompute it from the
964        // increments so it stays consistent with them rather than carrying a
965        // stale cumulative sum.
966        out.total_token_count = match (out.input_token_count, out.output_token_count) {
967            (None, None) => None,
968            (i, o) => Some(i.unwrap_or(0) + o.unwrap_or(0)),
969        };
970        out
971    }
972}
973
974#[cfg(test)]
975mod tests {
976    use super::*;
977
978    /// The canonical samples the core contract claims render on every
979    /// provider. If this converter stops emitting one of them, the failure
980    /// belongs here — next to the converter — not in compaction.
981    fn universal_content_samples() -> Vec<Content> {
982        use agent_framework_core::types::{
983            DataContent, FunctionArguments, FunctionCallContent, FunctionResultContent,
984        };
985        vec![
986            Content::text("hello"),
987            Content::FunctionCall(FunctionCallContent::new(
988                "contract_call_1",
989                "get_weather",
990                Some(FunctionArguments::Raw("{\"city\":\"SF\"}".into())),
991            )),
992            Content::FunctionResult(FunctionResultContent::new(
993                "contract_call_1",
994                Some(serde_json::json!("sunny")),
995            )),
996            Content::Data(DataContent::from_bytes(b"png-bytes", "image/png")),
997            Content::Data(DataContent::from_bytes(b"jpeg-bytes", "image/jpeg")),
998            Content::Data(DataContent::from_bytes(b"webp-bytes", "image/webp")),
999            Content::Data(DataContent::from_bytes(b"gif-bytes", "image/gif")),
1000        ]
1001    }
1002
1003    #[test]
1004    fn every_universal_content_produces_an_anthropic_block() {
1005        for content in universal_content_samples() {
1006            assert!(content.renders_on_every_provider(), "sample not universal");
1007            let msg = Message::with_contents(Role::user(), vec![content.clone()]);
1008            let body = build_request(&[msg], &ChatOptions::new(), "claude-test", 128, false);
1009            let blocks = body["messages"][0]["content"]
1010                .as_array()
1011                .map(|a| a.len())
1012                .unwrap_or(0);
1013            assert!(
1014                blocks > 0,
1015                "core claims this renders everywhere but Anthropic emits nothing: {content:?}"
1016            );
1017        }
1018    }
1019
1020    // region: cumulative -> incremental streaming usage (upstream #7162)
1021
1022    #[test]
1023    fn stream_usage_accumulator_emits_increments_not_cumulative_totals() {
1024        let mut acc = StreamUsageAccumulator::default();
1025
1026        // `message_start`: the first cumulative snapshot (input + cache only).
1027        let start = parse_message_start_usage(&json!({
1028            "input_tokens": 10,
1029            "cache_read_input_tokens": 4,
1030        }))
1031        .unwrap();
1032        let first = acc.increment(&start.details);
1033        assert_eq!(first.input_token_count, Some(10));
1034        assert_eq!(first.cache_read_input_token_count, Some(4));
1035
1036        // `message_delta`: the *running total*, which repeats input_tokens.
1037        // Emitting it raw would aggregate to 20 input tokens instead of 10.
1038        let delta = acc.increment(&parse_usage(&json!({
1039            "input_tokens": 10,
1040            "output_tokens": 25,
1041            "cache_read_input_tokens": 4,
1042        })));
1043        assert_eq!(delta.input_token_count, Some(0));
1044        assert_eq!(delta.output_token_count, Some(25));
1045        assert_eq!(delta.cache_read_input_token_count, Some(0));
1046
1047        // Summing the emitted increments reconstructs the true cumulative usage.
1048        let mut aggregated = first;
1049        aggregated.add_assign(&delta);
1050        assert_eq!(aggregated.input_token_count, Some(10));
1051        assert_eq!(aggregated.output_token_count, Some(25));
1052        assert_eq!(aggregated.cache_read_input_token_count, Some(4));
1053    }
1054
1055    #[test]
1056    fn stream_usage_accumulator_handles_several_deltas() {
1057        let mut acc = StreamUsageAccumulator::default();
1058        let mut aggregated = UsageDetails::default();
1059        // Three cumulative snapshots of a growing output count.
1060        for output in [5u64, 17, 25] {
1061            let inc = acc.increment(&parse_usage(&json!({
1062                "input_tokens": 10,
1063                "output_tokens": output,
1064            })));
1065            aggregated.add_assign(&inc);
1066        }
1067        assert_eq!(aggregated.input_token_count, Some(10));
1068        assert_eq!(aggregated.output_token_count, Some(25));
1069    }
1070
1071    #[test]
1072    fn stream_usage_accumulator_leaves_absent_counts_untouched() {
1073        let mut acc = StreamUsageAccumulator::default();
1074        acc.increment(&parse_usage(&json!({ "input_tokens": 10 })));
1075        // A partial delta reporting only output must not reset the input side.
1076        let inc = acc.increment(&parse_usage(&json!({ "output_tokens": 7 })));
1077        assert_eq!(inc.input_token_count, None);
1078        assert_eq!(inc.output_token_count, Some(7));
1079        // ...and the input high-water mark survives for the next full snapshot.
1080        let inc = acc.increment(&parse_usage(&json!({
1081            "input_tokens": 10,
1082            "output_tokens": 9,
1083        })));
1084        assert_eq!(inc.input_token_count, Some(0));
1085        assert_eq!(inc.output_token_count, Some(2));
1086    }
1087
1088    #[test]
1089    fn stream_usage_accumulator_holds_its_high_water_mark() {
1090        // A decreasing snapshot must not lower the baseline: 30 -> 20 -> 25
1091        // otherwise emits 30 + 0 + 5 = 35, inventing tokens the provider never
1092        // reported.
1093        let mut acc = StreamUsageAccumulator::default();
1094        let mut aggregated = UsageDetails::default();
1095        for output in [30u64, 20, 25] {
1096            let inc = acc.increment(&parse_usage(&json!({ "output_tokens": output })));
1097            aggregated.add_assign(&inc);
1098        }
1099        assert_eq!(aggregated.output_token_count, Some(30));
1100    }
1101
1102    #[test]
1103    fn stream_usage_accumulator_holds_its_high_water_mark_for_extra_counts() {
1104        let mut acc = StreamUsageAccumulator::default();
1105        let mut aggregated = UsageDetails::default();
1106        for value in [30u64, 20, 25] {
1107            let mut cumulative = UsageDetails::default();
1108            cumulative.additional_counts.insert("extra".into(), value);
1109            aggregated.add_assign(&acc.increment(&cumulative));
1110        }
1111        assert_eq!(aggregated.additional_counts.get("extra"), Some(&30));
1112    }
1113
1114    #[test]
1115    fn stream_usage_accumulator_clamps_a_decreasing_snapshot() {
1116        let mut acc = StreamUsageAccumulator::default();
1117        acc.increment(&parse_usage(&json!({ "output_tokens": 30 })));
1118        // A provider reporting a lower total must clamp to 0, not underflow.
1119        let inc = acc.increment(&parse_usage(&json!({ "output_tokens": 20 })));
1120        assert_eq!(inc.output_token_count, Some(0));
1121    }
1122
1123    use agent_framework_core::tools::ApprovalMode;
1124
1125    fn user(text: &str) -> Message {
1126        Message::user(text)
1127    }
1128
1129    // region: request building
1130
1131    #[test]
1132    fn build_request_simple_text() {
1133        let body = build_request(
1134            &[user("Hello there")],
1135            &ChatOptions::new(),
1136            "claude-x",
1137            4096,
1138            false,
1139        );
1140        assert_eq!(
1141            body,
1142            json!({
1143                "model": "claude-x",
1144                "max_tokens": 4096,
1145                "messages": [
1146                    { "role": "user", "content": [{ "type": "text", "text": "Hello there" }] }
1147                ],
1148            })
1149        );
1150    }
1151
1152    #[test]
1153    fn build_request_extracts_leading_system_message() {
1154        let messages = vec![Message::system("Be terse."), user("Hi")];
1155        let body = build_request(&messages, &ChatOptions::new(), "claude-x", 4096, false);
1156        assert_eq!(body["system"], json!("Be terse."));
1157        assert_eq!(
1158            body["messages"],
1159            json!([{ "role": "user", "content": [{ "type": "text", "text": "Hi" }] }])
1160        );
1161    }
1162
1163    #[test]
1164    fn build_request_combines_options_instructions_and_system_message() {
1165        let messages = vec![Message::system("Also be nice."), user("Hi")];
1166        let options = ChatOptions::new().with_instructions("Be terse.");
1167        let body = build_request(&messages, &options, "claude-x", 4096, false);
1168        assert_eq!(body["system"], json!("Be terse.\n\nAlso be nice."));
1169    }
1170
1171    #[test]
1172    fn build_request_tool_role_message_becomes_user_tool_result() {
1173        let tool_msg = Message::with_contents(
1174            Role::tool(),
1175            vec![Content::FunctionResult(FunctionResultContent::new(
1176                "call_1",
1177                Some(json!("18C and sunny")),
1178            ))],
1179        );
1180        let body = build_request(&[tool_msg], &ChatOptions::new(), "claude-x", 4096, false);
1181        assert_eq!(
1182            body["messages"],
1183            json!([{
1184                "role": "user",
1185                "content": [{ "type": "tool_result", "tool_use_id": "call_1", "content": "18C and sunny" }]
1186            }])
1187        );
1188    }
1189
1190    #[test]
1191    fn build_request_tool_result_error_sets_is_error() {
1192        let mut result = FunctionResultContent::new("call_1", None);
1193        result.exception = Some("boom".into());
1194        let tool_msg = Message::with_contents(Role::tool(), vec![Content::FunctionResult(result)]);
1195        let body = build_request(&[tool_msg], &ChatOptions::new(), "claude-x", 4096, false);
1196        assert_eq!(
1197            body["messages"][0]["content"][0],
1198            json!({ "type": "tool_result", "tool_use_id": "call_1", "content": "boom", "is_error": true })
1199        );
1200    }
1201
1202    #[test]
1203    fn build_request_assistant_function_call() {
1204        let call = FunctionCallContent::new(
1205            "call_1",
1206            "get_weather",
1207            Some(FunctionArguments::Object(HashMap::from([(
1208                "city".to_string(),
1209                json!("Paris"),
1210            )]))),
1211        );
1212        let assistant_msg =
1213            Message::with_contents(Role::assistant(), vec![Content::FunctionCall(call)]);
1214        let body = build_request(
1215            &[assistant_msg],
1216            &ChatOptions::new(),
1217            "claude-x",
1218            4096,
1219            false,
1220        );
1221        assert_eq!(
1222            body["messages"],
1223            json!([
1224                {
1225                    "role": "user",
1226                    "content": [{ "type": "text", "text": "(continuing the conversation)" }]
1227                },
1228                {
1229                    "role": "assistant",
1230                    "content": [{ "type": "tool_use", "id": "call_1", "name": "get_weather", "input": { "city": "Paris" } }]
1231                }
1232            ])
1233        );
1234    }
1235
1236    #[test]
1237    fn build_request_data_content_image_uses_embedded_base64() {
1238        let dc = DataContent::from_bytes(b"hello", "image/png");
1239        let msg = Message::with_contents(Role::user(), vec![Content::Data(dc.clone())]);
1240        let body = build_request(&[msg], &ChatOptions::new(), "claude-x", 4096, false);
1241        let (_, expected_data) = split_data_uri(&dc.uri).unwrap();
1242        assert_eq!(
1243            body["messages"][0]["content"][0],
1244            json!({ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": expected_data } })
1245        );
1246    }
1247
1248    #[test]
1249    fn build_request_uri_content_image_uses_url_source() {
1250        let uc = UriContent {
1251            uri: "https://example.com/cat.png".into(),
1252            media_type: "image/png".into(),
1253        };
1254        let msg = Message::with_contents(Role::user(), vec![Content::Uri(uc)]);
1255        let body = build_request(&[msg], &ChatOptions::new(), "claude-x", 4096, false);
1256        assert_eq!(
1257            body["messages"][0]["content"][0],
1258            json!({ "type": "image", "source": { "type": "url", "url": "https://example.com/cat.png" } })
1259        );
1260    }
1261
1262    #[test]
1263    fn build_request_tools_and_tool_choice() {
1264        let tool = ToolDefinition {
1265            name: "get_weather".into(),
1266            description: "Get the weather".into(),
1267            parameters: json!({ "type": "object", "properties": {} }),
1268            kind: ToolKind::Function,
1269            approval_mode: ApprovalMode::NeverRequire,
1270            executor: None,
1271        };
1272        let options = ChatOptions::new()
1273            .with_tool(tool)
1274            .with_tool_choice(ToolMode::Required(Some("get_weather".into())));
1275        let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1276        assert_eq!(
1277            body["tools"],
1278            json!([{ "type": "custom", "name": "get_weather", "description": "Get the weather", "input_schema": { "type": "object", "properties": {} } }])
1279        );
1280        assert_eq!(
1281            body["tool_choice"],
1282            json!({ "type": "tool", "name": "get_weather" })
1283        );
1284    }
1285
1286    #[test]
1287    fn build_request_tool_choice_auto_with_disabled_parallel() {
1288        let mut options = ChatOptions::new().with_tool_choice(ToolMode::Auto);
1289        options.allow_multiple_tool_calls = Some(false);
1290        let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1291        assert_eq!(
1292            body["tool_choice"],
1293            json!({ "type": "auto", "disable_parallel_tool_use": true })
1294        );
1295    }
1296
1297    #[test]
1298    fn build_request_temperature_top_p_stop_sequences() {
1299        let mut options = ChatOptions::new().with_temperature(0.5);
1300        options.top_p = Some(0.9);
1301        options.stop = Some(vec!["STOP".into()]);
1302        let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1303        // `temperature`/`top_p` are `f32` on `ChatOptions`; compare against
1304        // `f32` literals too so the widened-to-f64 JSON values match exactly
1305        // (0.9_f32 as f64 != 0.9_f64).
1306        assert_eq!(body["temperature"], json!(0.5_f32));
1307        assert_eq!(body["top_p"], json!(0.9_f32));
1308        assert_eq!(body["stop_sequences"], json!(["STOP"]));
1309    }
1310
1311    #[test]
1312    fn build_request_stream_flag() {
1313        let body = build_request(&[user("hi")], &ChatOptions::new(), "claude-x", 4096, true);
1314        assert_eq!(body["stream"], json!(true));
1315    }
1316
1317    #[test]
1318    fn build_request_uses_given_max_tokens() {
1319        let body = build_request(&[user("hi")], &ChatOptions::new(), "claude-x", 2048, false);
1320        assert_eq!(body["max_tokens"], json!(2048));
1321    }
1322
1323    // endregion
1324
1325    // region: build_cloud_request (multi-cloud transports)
1326
1327    #[test]
1328    fn build_cloud_request_omits_model_and_sets_anthropic_version() {
1329        let body = build_cloud_request(
1330            &[user("hi")],
1331            &ChatOptions::new(),
1332            4096,
1333            false,
1334            "bedrock-2023-05-31",
1335        );
1336        assert!(body.get("model").is_none());
1337        assert_eq!(body["anthropic_version"], json!("bedrock-2023-05-31"));
1338        assert_eq!(body["max_tokens"], json!(4096));
1339    }
1340
1341    #[test]
1342    fn build_cloud_request_uses_given_anthropic_version() {
1343        let body = build_cloud_request(
1344            &[user("hi")],
1345            &ChatOptions::new(),
1346            4096,
1347            false,
1348            "vertex-2023-10-16",
1349        );
1350        assert_eq!(body["anthropic_version"], json!("vertex-2023-10-16"));
1351    }
1352
1353    #[test]
1354    fn build_cloud_request_messages_system_and_tools_match_build_request() {
1355        let tool = ToolDefinition {
1356            name: "get_weather".into(),
1357            description: "Get the weather".into(),
1358            parameters: json!({ "type": "object", "properties": {} }),
1359            kind: ToolKind::Function,
1360            approval_mode: ApprovalMode::NeverRequire,
1361            executor: None,
1362        };
1363        let messages = vec![Message::system("Be terse."), user("Hi")];
1364        let options = ChatOptions::new()
1365            .with_tool(tool)
1366            .with_tool_choice(ToolMode::Required(Some("get_weather".into())));
1367
1368        let direct = build_request(&messages, &options, "claude-x", 4096, false);
1369        let cloud = build_cloud_request(&messages, &options, 4096, false, "bedrock-2023-05-31");
1370
1371        assert_eq!(cloud["messages"], direct["messages"]);
1372        assert_eq!(cloud["system"], direct["system"]);
1373        assert_eq!(cloud["tools"], direct["tools"]);
1374        assert_eq!(cloud["tool_choice"], direct["tool_choice"]);
1375    }
1376
1377    #[test]
1378    fn build_cloud_request_stream_flag_and_additional_properties() {
1379        let mut options = ChatOptions::new();
1380        options
1381            .additional_properties
1382            .insert("top_k".into(), json!(5));
1383        let body = build_cloud_request(&[user("hi")], &options, 4096, true, "foundry-2025-01-01");
1384        assert_eq!(body["stream"], json!(true));
1385        assert_eq!(body["top_k"], json!(5));
1386    }
1387
1388    // endregion
1389
1390    // region: response_format (structured output)
1391    //
1392    // Anthropic's Messages API has no native `response_format` field (see
1393    // `append_response_format_instructions`'s doc comment for the upstream
1394    // Python/.NET investigation), so these assert the pragmatic fallback:
1395    // the request body's `system` string, rather than a silent no-op.
1396
1397    #[test]
1398    fn build_request_response_format_none_leaves_system_untouched() {
1399        let body = build_request(&[user("hi")], &ChatOptions::new(), "claude-x", 4096, false);
1400        assert!(body.get("system").is_none());
1401    }
1402
1403    #[test]
1404    fn build_request_response_format_text_is_a_noop() {
1405        let mut options = ChatOptions::new();
1406        options.response_format = Some(ResponseFormat::Text);
1407        let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1408        assert!(body.get("system").is_none());
1409    }
1410
1411    #[test]
1412    fn build_request_response_format_json_object_appends_system_instruction() {
1413        let mut options = ChatOptions::new();
1414        options.response_format = Some(ResponseFormat::JsonObject);
1415        let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1416        let system = body["system"].as_str().expect("system must be a string");
1417        assert!(
1418            system.to_lowercase().contains("json"),
1419            "expected a JSON instruction, got: {system}"
1420        );
1421    }
1422
1423    #[test]
1424    fn build_request_response_format_json_schema_embeds_schema_in_system() {
1425        let mut options = ChatOptions::new();
1426        options.response_format = Some(ResponseFormat::json_schema(
1427            "Person",
1428            json!({ "type": "object", "properties": { "name": { "type": "string" } } }),
1429        ));
1430        let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1431        let system = body["system"].as_str().expect("system must be a string");
1432        assert!(system.contains("Person"), "system: {system}");
1433        assert!(system.contains("\"name\""), "system: {system}");
1434        assert!(system.contains("\"type\": \"object\""), "system: {system}");
1435    }
1436
1437    #[test]
1438    fn build_request_response_format_json_schema_appends_after_existing_system() {
1439        // A leading system message and `response_format` must combine, not
1440        // clobber one another.
1441        let messages = vec![Message::system("Be terse."), user("Hi")];
1442        let mut options = ChatOptions::new();
1443        options.response_format = Some(ResponseFormat::JsonObject);
1444        let body = build_request(&messages, &options, "claude-x", 4096, false);
1445        let system = body["system"].as_str().expect("system must be a string");
1446        assert!(
1447            system.starts_with("Be terse."),
1448            "existing system text must be preserved first: {system}"
1449        );
1450        assert!(system.to_lowercase().contains("json"), "system: {system}");
1451    }
1452
1453    // endregion
1454
1455    // region: response parsing
1456
1457    #[test]
1458    fn parse_response_text_and_usage() {
1459        let value = json!({
1460            "id": "msg_123",
1461            "model": "claude-x",
1462            "stop_reason": "end_turn",
1463            "content": [{ "type": "text", "text": "Hello!" }],
1464            "usage": { "input_tokens": 10, "output_tokens": 5 },
1465        });
1466        let resp = parse_response(&value);
1467        assert_eq!(resp.response_id.as_deref(), Some("msg_123"));
1468        assert_eq!(resp.text(), "Hello!");
1469        assert_eq!(resp.finish_reason, Some(FinishReason::stop()));
1470        let usage = resp.usage_details.unwrap();
1471        assert_eq!(usage.input_token_count, Some(10));
1472        assert_eq!(usage.output_token_count, Some(5));
1473        assert_eq!(usage.total_token_count, Some(15));
1474    }
1475
1476    #[test]
1477    fn parse_response_tool_use() {
1478        let value = json!({
1479            "id": "msg_123",
1480            "stop_reason": "tool_use",
1481            "content": [
1482                { "type": "text", "text": "Let me check." },
1483                { "type": "tool_use", "id": "call_1", "name": "get_weather", "input": { "city": "Paris" } },
1484            ],
1485        });
1486        let resp = parse_response(&value);
1487        assert_eq!(resp.finish_reason, Some(FinishReason::tool_calls()));
1488        let calls = resp.function_calls();
1489        assert_eq!(calls.len(), 1);
1490        assert_eq!(calls[0].call_id, "call_1");
1491        assert_eq!(calls[0].name, "get_weather");
1492        assert_eq!(
1493            calls[0].parse_arguments().unwrap().get("city").unwrap(),
1494            &json!("Paris")
1495        );
1496    }
1497
1498    #[test]
1499    fn parse_response_cache_usage_fields() {
1500        let value = json!({
1501            "id": "msg_123",
1502            "content": [],
1503            "usage": {
1504                "input_tokens": 100,
1505                "output_tokens": 10,
1506                "cache_creation_input_tokens": 50,
1507                "cache_read_input_tokens": 20,
1508            },
1509        });
1510        let resp = parse_response(&value);
1511        let usage = resp.usage_details.unwrap();
1512        assert_eq!(usage.cache_creation_input_token_count, Some(50));
1513        assert_eq!(usage.cache_read_input_token_count, Some(20));
1514    }
1515
1516    #[test]
1517    fn map_stop_reason_covers_documented_mapping() {
1518        assert_eq!(map_stop_reason("end_turn"), FinishReason::stop());
1519        assert_eq!(map_stop_reason("stop_sequence"), FinishReason::stop());
1520        assert_eq!(
1521            map_stop_reason("max_tokens"),
1522            FinishReason::new(FinishReason::LENGTH)
1523        );
1524        assert_eq!(map_stop_reason("tool_use"), FinishReason::tool_calls());
1525    }
1526
1527    #[test]
1528    fn message_start_usage_omits_output_tokens() {
1529        let usage = json!({ "input_tokens": 25, "output_tokens": 1 });
1530        let content = parse_message_start_usage(&usage).unwrap();
1531        assert_eq!(content.details.input_token_count, Some(25));
1532        assert_eq!(content.details.output_token_count, None);
1533    }
1534
1535    // endregion
1536    #[test]
1537    fn consecutive_same_role_messages_are_merged() {
1538        let msgs = vec![
1539            Message::user("first"),
1540            Message::user("second"),
1541            Message::assistant("reply"),
1542            Message::assistant("more"),
1543            Message::user("third"),
1544        ];
1545        let out = messages_to_anthropic(&msgs);
1546        assert_eq!(out.len(), 3);
1547        assert_eq!(out[0]["role"], "user");
1548        assert_eq!(out[0]["content"].as_array().unwrap().len(), 2);
1549        assert_eq!(out[1]["role"], "assistant");
1550        assert_eq!(out[1]["content"].as_array().unwrap().len(), 2);
1551        assert_eq!(out[2]["role"], "user");
1552    }
1553
1554    #[test]
1555    fn leading_assistant_message_gets_synthetic_user_turn() {
1556        let msgs = vec![Message::assistant("greeting"), Message::user("hello")];
1557        let out = messages_to_anthropic(&msgs);
1558        assert_eq!(out.len(), 3);
1559        assert_eq!(out[0]["role"], "user");
1560        assert_eq!(
1561            out[0]["content"][0]["text"],
1562            "(continuing the conversation)"
1563        );
1564        assert_eq!(out[1]["role"], "assistant");
1565        assert_eq!(out[2]["role"], "user");
1566    }
1567
1568    // region: beta flags
1569
1570    #[test]
1571    fn compute_beta_flags_default_includes_both_upstream_flags() {
1572        let mut options = ChatOptions::new();
1573        let flags = compute_beta_flags(&mut options, &[]);
1574        assert!(flags.contains(&"mcp-client-2025-04-04".to_string()));
1575        assert!(flags.contains(&"code-execution-2025-08-25".to_string()));
1576        assert_eq!(flags.len(), 2);
1577    }
1578
1579    #[test]
1580    fn compute_beta_flags_merges_client_level_additional_flags() {
1581        let mut options = ChatOptions::new();
1582        let flags = compute_beta_flags(&mut options, &["my-beta-flag".to_string()]);
1583        assert!(flags.contains(&"my-beta-flag".to_string()));
1584        assert_eq!(flags.len(), 3);
1585    }
1586
1587    #[test]
1588    fn compute_beta_flags_merges_and_removes_per_request_additional_flags() {
1589        let mut options = ChatOptions::new();
1590        options.additional_properties.insert(
1591            "additional_beta_flags".into(),
1592            json!(["request-level-flag"]),
1593        );
1594        let flags = compute_beta_flags(&mut options, &[]);
1595        assert!(flags.contains(&"request-level-flag".to_string()));
1596        // Popped out, like upstream's `.pop(...)` -- must not leak into the
1597        // body via `additional_properties`.
1598        assert!(!options
1599            .additional_properties
1600            .contains_key("additional_beta_flags"));
1601    }
1602
1603    #[test]
1604    fn compute_beta_flags_deduplicates_overlapping_flags() {
1605        let mut options = ChatOptions::new();
1606        options.additional_properties.insert(
1607            "additional_beta_flags".into(),
1608            json!(["mcp-client-2025-04-04"]),
1609        );
1610        let flags = compute_beta_flags(&mut options, &["mcp-client-2025-04-04".to_string()]);
1611        assert_eq!(flags.len(), 2);
1612    }
1613
1614    #[test]
1615    fn compute_beta_flags_does_not_leak_into_request_body() {
1616        let mut options = ChatOptions::new();
1617        options.additional_properties.insert(
1618            "additional_beta_flags".into(),
1619            json!(["request-level-flag"]),
1620        );
1621        let _ = compute_beta_flags(&mut options, &[]);
1622        let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1623        assert!(body.get("additional_beta_flags").is_none());
1624    }
1625
1626    // endregion
1627
1628    // region: hosted tool mapping (Anthropic wire shape)
1629
1630    fn make_tool(kind: ToolKind, name: &str, parameters: Value) -> ToolDefinition {
1631        ToolDefinition {
1632            name: name.into(),
1633            description: String::new(),
1634            parameters,
1635            kind,
1636            approval_mode: ApprovalMode::NeverRequire,
1637            executor: None,
1638        }
1639    }
1640
1641    #[test]
1642    fn tools_to_anthropic_web_search_basic() {
1643        let tool = make_tool(ToolKind::HostedWebSearch, "web_search", json!({}));
1644        let (tools, mcp_servers) = tools_to_anthropic(&[tool]);
1645        assert_eq!(
1646            tools,
1647            vec![json!({ "type": "web_search_20250305", "name": "web_search" })]
1648        );
1649        assert!(mcp_servers.is_empty());
1650    }
1651
1652    #[test]
1653    fn tools_to_anthropic_web_search_reads_max_uses_and_user_location_from_parameters() {
1654        let tool = make_tool(
1655            ToolKind::HostedWebSearch,
1656            "web_search",
1657            json!({ "max_uses": 3, "user_location": { "type": "approximate", "city": "Seattle" } }),
1658        );
1659        let (tools, _) = tools_to_anthropic(&[tool]);
1660        assert_eq!(
1661            tools[0],
1662            json!({
1663                "type": "web_search_20250305",
1664                "name": "web_search",
1665                "max_uses": 3,
1666                "user_location": { "type": "approximate", "city": "Seattle" },
1667            })
1668        );
1669    }
1670
1671    #[test]
1672    fn tools_to_anthropic_code_interpreter() {
1673        let tool = make_tool(
1674            ToolKind::HostedCodeInterpreter,
1675            "code_interpreter",
1676            json!({}),
1677        );
1678        let (tools, mcp_servers) = tools_to_anthropic(&[tool]);
1679        assert_eq!(
1680            tools,
1681            vec![json!({ "type": "code_execution_20250825", "name": "code_execution" })]
1682        );
1683        assert!(mcp_servers.is_empty());
1684    }
1685
1686    #[test]
1687    fn tools_to_anthropic_mcp_goes_to_mcp_servers_not_tools() {
1688        let tool = make_tool(
1689            ToolKind::HostedMcp {
1690                url: "https://example.com/mcp".into(),
1691                allowed_tools: None,
1692            },
1693            "my-mcp",
1694            json!({}),
1695        );
1696        let (tools, mcp_servers) = tools_to_anthropic(&[tool]);
1697        assert!(tools.is_empty());
1698        assert_eq!(
1699            mcp_servers,
1700            vec![json!({ "type": "url", "name": "my-mcp", "url": "https://example.com/mcp" })]
1701        );
1702    }
1703
1704    #[test]
1705    fn tools_to_anthropic_mcp_with_allowed_tools() {
1706        let tool = make_tool(
1707            ToolKind::HostedMcp {
1708                url: "https://example.com/mcp".into(),
1709                allowed_tools: Some(vec!["a".into(), "b".into()]),
1710            },
1711            "my-mcp",
1712            json!({}),
1713        );
1714        let (_, mcp_servers) = tools_to_anthropic(&[tool]);
1715        assert_eq!(
1716            mcp_servers[0]["tool_configuration"],
1717            json!({ "allowed_tools": ["a", "b"] })
1718        );
1719    }
1720
1721    #[test]
1722    fn tools_to_anthropic_mcp_empty_allowed_tools_is_omitted() {
1723        let tool = make_tool(
1724            ToolKind::HostedMcp {
1725                url: "https://example.com/mcp".into(),
1726                allowed_tools: Some(vec![]),
1727            },
1728            "my-mcp",
1729            json!({}),
1730        );
1731        let (_, mcp_servers) = tools_to_anthropic(&[tool]);
1732        assert!(mcp_servers[0].get("tool_configuration").is_none());
1733    }
1734
1735    #[test]
1736    fn tools_to_anthropic_mcp_authorization_header_becomes_authorization_token() {
1737        let tool = make_tool(
1738            ToolKind::HostedMcp {
1739                url: "https://example.com/mcp".into(),
1740                allowed_tools: None,
1741            },
1742            "my-mcp",
1743            json!({ "headers": { "authorization": "Bearer token123" } }),
1744        );
1745        let (_, mcp_servers) = tools_to_anthropic(&[tool]);
1746        assert_eq!(
1747            mcp_servers[0]["authorization_token"],
1748            json!("Bearer token123")
1749        );
1750    }
1751
1752    #[test]
1753    fn tools_to_anthropic_mcp_authorization_header_lookup_is_case_insensitive() {
1754        let tool = make_tool(
1755            ToolKind::HostedMcp {
1756                url: "https://example.com/mcp".into(),
1757                allowed_tools: None,
1758            },
1759            "my-mcp",
1760            json!({ "headers": { "Authorization": "Bearer token456" } }),
1761        );
1762        let (_, mcp_servers) = tools_to_anthropic(&[tool]);
1763        assert_eq!(
1764            mcp_servers[0]["authorization_token"],
1765            json!("Bearer token456")
1766        );
1767    }
1768
1769    #[test]
1770    fn tools_to_anthropic_function_tool_has_custom_type() {
1771        let tool = make_tool(
1772            ToolKind::Function,
1773            "get_weather",
1774            json!({ "type": "object", "properties": {} }),
1775        );
1776        let (tools, _) = tools_to_anthropic(&[tool]);
1777        assert_eq!(tools[0]["type"], json!("custom"));
1778    }
1779
1780    #[test]
1781    fn tools_to_anthropic_unknown_hosted_kind_is_skipped() {
1782        let tool = make_tool(
1783            ToolKind::HostedFileSearch { max_results: None },
1784            "file_search",
1785            json!({}),
1786        );
1787        let (tools, mcp_servers) = tools_to_anthropic(&[tool]);
1788        assert!(tools.is_empty());
1789        assert!(mcp_servers.is_empty());
1790    }
1791
1792    #[test]
1793    fn tools_to_anthropic_mixed_tools_and_mcp_servers_both_populate_body() {
1794        let function_tool = make_tool(ToolKind::Function, "get_weather", json!({}));
1795        let mcp_tool = make_tool(
1796            ToolKind::HostedMcp {
1797                url: "https://example.com/mcp".into(),
1798                allowed_tools: None,
1799            },
1800            "my-mcp",
1801            json!({}),
1802        );
1803        let options = ChatOptions::new()
1804            .with_tool(function_tool)
1805            .with_tool(mcp_tool);
1806        let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1807        assert_eq!(body["tools"].as_array().unwrap().len(), 1);
1808        assert_eq!(body["mcp_servers"].as_array().unwrap().len(), 1);
1809    }
1810
1811    // endregion
1812
1813    // region: hosted result block parsing
1814
1815    #[test]
1816    fn parse_content_blocks_server_tool_use_is_function_call() {
1817        let blocks = vec![json!({
1818            "type": "server_tool_use",
1819            "id": "srvtoolu_1",
1820            "name": "web_search",
1821            "input": { "query": "rust" }
1822        })];
1823        let contents = parse_content_blocks(&blocks);
1824        assert_eq!(contents.len(), 1);
1825        match &contents[0] {
1826            Content::FunctionCall(fc) => {
1827                assert_eq!(fc.call_id, "srvtoolu_1");
1828                assert_eq!(fc.name, "web_search");
1829                assert_eq!(
1830                    fc.parse_arguments().unwrap().get("query").unwrap(),
1831                    &json!("rust")
1832                );
1833            }
1834            other => panic!("expected FunctionCall, got {other:?}"),
1835        }
1836    }
1837
1838    #[test]
1839    fn parse_content_blocks_mcp_tool_use_is_function_call() {
1840        let blocks = vec![json!({
1841            "type": "mcp_tool_use",
1842            "id": "mcptoolu_1",
1843            "name": "search_docs",
1844            "input": {}
1845        })];
1846        let contents = parse_content_blocks(&blocks);
1847        assert!(matches!(
1848            &contents[0],
1849            Content::FunctionCall(fc) if fc.call_id == "mcptoolu_1" && fc.name == "search_docs"
1850        ));
1851    }
1852
1853    #[test]
1854    fn parse_content_blocks_mcp_tool_result_with_list_content_is_recursively_parsed() {
1855        let blocks = vec![json!({
1856            "type": "mcp_tool_result",
1857            "tool_use_id": "mcptoolu_1",
1858            "is_error": false,
1859            "content": [{ "type": "text", "text": "result text" }]
1860        })];
1861        let contents = parse_content_blocks(&blocks);
1862        assert_eq!(contents.len(), 1);
1863        match &contents[0] {
1864            Content::FunctionResult(fr) => {
1865                assert_eq!(fr.call_id, "mcptoolu_1");
1866                assert_eq!(fr.exception, None);
1867                // The nested `content` array is itself parsed via
1868                // `parse_content_blocks` and serialized back to JSON.
1869                assert_eq!(
1870                    fr.result,
1871                    Some(json!([{ "type": "text", "text": "result text" }]))
1872                );
1873            }
1874            other => panic!("expected FunctionResult, got {other:?}"),
1875        }
1876    }
1877
1878    #[test]
1879    fn parse_content_blocks_mcp_tool_result_with_string_content_passes_through() {
1880        let blocks = vec![json!({
1881            "type": "mcp_tool_result",
1882            "tool_use_id": "mcptoolu_1",
1883            "content": "plain string result"
1884        })];
1885        let contents = parse_content_blocks(&blocks);
1886        match &contents[0] {
1887            Content::FunctionResult(fr) => {
1888                assert_eq!(fr.result, Some(json!("plain string result")));
1889            }
1890            other => panic!("expected FunctionResult, got {other:?}"),
1891        }
1892    }
1893
1894    #[test]
1895    fn parse_content_blocks_web_search_tool_result_is_not_recursively_parsed() {
1896        let blocks = vec![json!({
1897            "type": "web_search_tool_result",
1898            "tool_use_id": "srvtoolu_1",
1899            "content": [{ "type": "web_search_result", "url": "https://example.com", "title": "Example" }]
1900        })];
1901        let contents = parse_content_blocks(&blocks);
1902        match &contents[0] {
1903            Content::FunctionResult(fr) => {
1904                assert_eq!(fr.call_id, "srvtoolu_1");
1905                // Raw content passed through as-is, unlike `mcp_tool_result`.
1906                assert_eq!(
1907                    fr.result,
1908                    Some(
1909                        json!([{ "type": "web_search_result", "url": "https://example.com", "title": "Example" }])
1910                    )
1911                );
1912            }
1913            other => panic!("expected FunctionResult, got {other:?}"),
1914        }
1915    }
1916
1917    #[test]
1918    fn parse_content_blocks_web_fetch_tool_result_uses_same_mapping() {
1919        let blocks = vec![json!({
1920            "type": "web_fetch_tool_result",
1921            "tool_use_id": "srvtoolu_2",
1922            "content": { "type": "web_fetch_result", "url": "https://example.com" }
1923        })];
1924        let contents = parse_content_blocks(&blocks);
1925        assert_eq!(contents.len(), 1);
1926        assert!(matches!(&contents[0], Content::FunctionResult(fr) if fr.call_id == "srvtoolu_2"));
1927    }
1928
1929    #[test]
1930    fn parse_content_blocks_code_execution_tool_result_extracts_hosted_files_before_result() {
1931        let blocks = vec![json!({
1932            "type": "code_execution_tool_result",
1933            "tool_use_id": "srvtoolu_3",
1934            "content": {
1935                "type": "code_execution_result",
1936                "stdout": "",
1937                "stderr": "",
1938                "return_code": 0,
1939                "content": [
1940                    { "type": "code_execution_output", "file_id": "file_abc" },
1941                    { "type": "code_execution_output", "file_id": "file_def" }
1942                ]
1943            }
1944        })];
1945        let contents = parse_content_blocks(&blocks);
1946        assert_eq!(contents.len(), 3);
1947        assert_eq!(
1948            contents[0],
1949            Content::HostedFile(HostedFileContent {
1950                file_id: "file_abc".into()
1951            })
1952        );
1953        assert_eq!(
1954            contents[1],
1955            Content::HostedFile(HostedFileContent {
1956                file_id: "file_def".into()
1957            })
1958        );
1959        match &contents[2] {
1960            Content::FunctionResult(fr) => assert_eq!(fr.call_id, "srvtoolu_3"),
1961            other => panic!("expected FunctionResult, got {other:?}"),
1962        }
1963    }
1964
1965    #[test]
1966    fn parse_content_blocks_bash_code_execution_tool_result_extracts_hosted_files() {
1967        let blocks = vec![json!({
1968            "type": "bash_code_execution_tool_result",
1969            "tool_use_id": "srvtoolu_4",
1970            "content": {
1971                "type": "bash_code_execution_result",
1972                "stdout": "",
1973                "stderr": "",
1974                "return_code": 0,
1975                "content": [{ "type": "bash_code_execution_output", "file_id": "file_ghi" }]
1976            }
1977        })];
1978        let contents = parse_content_blocks(&blocks);
1979        assert_eq!(contents.len(), 2);
1980        assert_eq!(
1981            contents[0],
1982            Content::HostedFile(HostedFileContent {
1983                file_id: "file_ghi".into()
1984            })
1985        );
1986    }
1987
1988    #[test]
1989    fn parse_content_blocks_code_execution_tool_result_no_files_only_function_result() {
1990        let blocks = vec![json!({
1991            "type": "code_execution_tool_result",
1992            "tool_use_id": "srvtoolu_5",
1993            "content": { "type": "code_execution_result", "stdout": "hi", "stderr": "", "return_code": 0, "content": [] }
1994        })];
1995        let contents = parse_content_blocks(&blocks);
1996        assert_eq!(contents.len(), 1);
1997        assert!(matches!(&contents[0], Content::FunctionResult(_)));
1998    }
1999
2000    #[test]
2001    fn parse_content_blocks_text_editor_code_execution_tool_result_never_extracts_files() {
2002        // `text_editor_code_execution_tool_result`'s nested content type is
2003        // never `code_execution_result`/`bash_code_execution_result`
2004        // (verified against the `anthropic` SDK's
2005        // `BetaTextEditorCodeExecutionToolResultBlock`), so this only ever
2006        // produces the trailing FunctionResult.
2007        let blocks = vec![json!({
2008            "type": "text_editor_code_execution_tool_result",
2009            "tool_use_id": "srvtoolu_6",
2010            "content": { "type": "text_editor_code_execution_view_result", "file_type": "text", "content": "print('hi')" }
2011        })];
2012        let contents = parse_content_blocks(&blocks);
2013        assert_eq!(contents.len(), 1);
2014        assert!(matches!(&contents[0], Content::FunctionResult(_)));
2015    }
2016
2017    #[test]
2018    fn parse_content_blocks_unknown_block_type_is_skipped() {
2019        let blocks = vec![json!({ "type": "totally_unknown_block" })];
2020        let contents = parse_content_blocks(&blocks);
2021        assert!(contents.is_empty());
2022    }
2023
2024    #[test]
2025    fn parse_response_includes_server_tool_use_and_web_search_result() {
2026        let value = json!({
2027            "id": "msg_1",
2028            "content": [
2029                { "type": "server_tool_use", "id": "srvtoolu_1", "name": "web_search", "input": { "query": "rust" } },
2030                { "type": "web_search_tool_result", "tool_use_id": "srvtoolu_1", "content": [{ "type": "web_search_result", "url": "https://example.com", "title": "Example" }] },
2031            ],
2032        });
2033        let resp = parse_response(&value);
2034        let contents = &resp.messages[0].contents;
2035        assert_eq!(contents.len(), 2);
2036        assert!(matches!(&contents[0], Content::FunctionCall(_)));
2037        assert!(matches!(&contents[1], Content::FunctionResult(_)));
2038    }
2039
2040    // endregion
2041
2042    // region: citations
2043
2044    #[test]
2045    fn parse_citations_char_location() {
2046        let block = json!({
2047            "type": "text",
2048            "text": "cited",
2049            "citations": [{
2050                "type": "char_location",
2051                "cited_text": "The grass is green.",
2052                "document_index": 0,
2053                "document_title": "Example Document",
2054                "start_char_index": 0,
2055                "end_char_index": 20,
2056            }]
2057        });
2058        let annotations = parse_citations(&block).unwrap();
2059        assert_eq!(annotations.len(), 1);
2060        let cit = &annotations[0];
2061        // Mirrors upstream's `char_location` branch, which reads
2062        // `citation.title` rather than `citation.document_title` -- absent
2063        // on the real wire payload, so `title` ends up `None` here too. See
2064        // `parse_citations`'s doc comment for the upstream-bug analysis.
2065        assert_eq!(cit.title, None);
2066        assert_eq!(cit.snippet.as_deref(), Some("The grass is green."));
2067        assert_eq!(
2068            cit.annotated_regions,
2069            Some(vec![TextSpanRegion {
2070                start_index: Some(0),
2071                end_index: Some(20)
2072            }])
2073        );
2074    }
2075
2076    #[test]
2077    fn parse_citations_page_location_uses_document_title() {
2078        let block = json!({
2079            "type": "text",
2080            "text": "cited",
2081            "citations": [{
2082                "type": "page_location",
2083                "cited_text": "Water is essential for life.",
2084                "document_index": 1,
2085                "document_title": "PDF Document",
2086                "start_page_number": 5,
2087                "end_page_number": 6,
2088            }]
2089        });
2090        let annotations = parse_citations(&block).unwrap();
2091        let cit = &annotations[0];
2092        assert_eq!(cit.title.as_deref(), Some("PDF Document"));
2093        assert_eq!(cit.snippet.as_deref(), Some("Water is essential for life."));
2094        assert_eq!(
2095            cit.annotated_regions,
2096            Some(vec![TextSpanRegion {
2097                start_index: Some(5),
2098                end_index: Some(6)
2099            }])
2100        );
2101    }
2102
2103    #[test]
2104    fn parse_citations_content_block_location_uses_document_title() {
2105        let block = json!({
2106            "type": "text",
2107            "text": "cited",
2108            "citations": [{
2109                "type": "content_block_location",
2110                "cited_text": "These are important findings.",
2111                "document_index": 2,
2112                "document_title": "Custom Content Document",
2113                "start_block_index": 0,
2114                "end_block_index": 1,
2115            }]
2116        });
2117        let annotations = parse_citations(&block).unwrap();
2118        let cit = &annotations[0];
2119        assert_eq!(cit.title.as_deref(), Some("Custom Content Document"));
2120        assert_eq!(
2121            cit.annotated_regions,
2122            Some(vec![TextSpanRegion {
2123                start_index: Some(0),
2124                end_index: Some(1)
2125            }])
2126        );
2127    }
2128
2129    #[test]
2130    fn parse_citations_file_id_only_set_when_present() {
2131        let block = json!({
2132            "type": "text",
2133            "text": "cited",
2134            "citations": [{
2135                "type": "page_location",
2136                "cited_text": "text",
2137                "document_index": 0,
2138                "document_title": "Doc",
2139                "start_page_number": 1,
2140                "end_page_number": 2,
2141                "file_id": "file_123",
2142            }]
2143        });
2144        let annotations = parse_citations(&block).unwrap();
2145        assert_eq!(annotations[0].file_id.as_deref(), Some("file_123"));
2146    }
2147
2148    #[test]
2149    fn parse_citations_web_search_result_location() {
2150        let block = json!({
2151            "type": "text",
2152            "text": "cited",
2153            "citations": [{
2154                "type": "web_search_result_location",
2155                "cited_text": "some cited snippet",
2156                "url": "https://example.com/page",
2157                "title": "Example Page",
2158                "encrypted_index": "abc123",
2159            }]
2160        });
2161        let annotations = parse_citations(&block).unwrap();
2162        let cit = &annotations[0];
2163        assert_eq!(cit.title.as_deref(), Some("Example Page"));
2164        assert_eq!(cit.snippet.as_deref(), Some("some cited snippet"));
2165        assert_eq!(cit.url.as_deref(), Some("https://example.com/page"));
2166        assert_eq!(cit.annotated_regions, None);
2167    }
2168
2169    #[test]
2170    fn parse_citations_search_result_location_uses_source_as_url() {
2171        let block = json!({
2172            "type": "text",
2173            "text": "cited",
2174            "citations": [{
2175                "type": "search_result_location",
2176                "cited_text": "some cited snippet",
2177                "source": "https://example.com/doc",
2178                "title": "Search Result",
2179                "search_result_index": 0,
2180                "start_block_index": 0,
2181                "end_block_index": 1,
2182            }]
2183        });
2184        let annotations = parse_citations(&block).unwrap();
2185        let cit = &annotations[0];
2186        assert_eq!(cit.title.as_deref(), Some("Search Result"));
2187        assert_eq!(cit.url.as_deref(), Some("https://example.com/doc"));
2188        assert_eq!(
2189            cit.annotated_regions,
2190            Some(vec![TextSpanRegion {
2191                start_index: Some(0),
2192                end_index: Some(1)
2193            }])
2194        );
2195    }
2196
2197    #[test]
2198    fn parse_citations_unknown_type_still_produces_empty_annotation() {
2199        // Mirrors upstream: `annotations.append(cit)` runs unconditionally
2200        // after the match, even for an unrecognized `citation.type`
2201        // (~667-669).
2202        let block = json!({
2203            "type": "text",
2204            "text": "cited",
2205            "citations": [{ "type": "some_future_citation_type" }]
2206        });
2207        let annotations = parse_citations(&block).unwrap();
2208        assert_eq!(annotations.len(), 1);
2209        assert_eq!(annotations[0], Annotation::default());
2210    }
2211
2212    #[test]
2213    fn parse_citations_absent_returns_none() {
2214        let block = json!({ "type": "text", "text": "no citations here" });
2215        assert_eq!(parse_citations(&block), None);
2216    }
2217
2218    #[test]
2219    fn parse_citations_empty_array_returns_none() {
2220        let block = json!({ "type": "text", "text": "no citations here", "citations": [] });
2221        assert_eq!(parse_citations(&block), None);
2222    }
2223
2224    #[test]
2225    fn parse_response_text_block_carries_citations_as_annotations() {
2226        let value = json!({
2227            "id": "msg_1",
2228            "content": [{
2229                "type": "text",
2230                "text": "the grass is green",
2231                "citations": [{
2232                    "type": "char_location",
2233                    "cited_text": "The grass is green.",
2234                    "document_index": 0,
2235                    "document_title": "Example Document",
2236                    "start_char_index": 0,
2237                    "end_char_index": 20,
2238                }]
2239            }]
2240        });
2241        let resp = parse_response(&value);
2242        match &resp.messages[0].contents[0] {
2243            Content::Text(t) => {
2244                assert_eq!(t.text, "the grass is green");
2245                assert!(t.annotations.is_some());
2246            }
2247            other => panic!("expected Text, got {other:?}"),
2248        }
2249    }
2250
2251    // endregion
2252}