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#[cfg(test)]
888mod tests {
889    use super::*;
890    use agent_framework_core::tools::ApprovalMode;
891
892    fn user(text: &str) -> Message {
893        Message::user(text)
894    }
895
896    // region: request building
897
898    #[test]
899    fn build_request_simple_text() {
900        let body = build_request(
901            &[user("Hello there")],
902            &ChatOptions::new(),
903            "claude-x",
904            4096,
905            false,
906        );
907        assert_eq!(
908            body,
909            json!({
910                "model": "claude-x",
911                "max_tokens": 4096,
912                "messages": [
913                    { "role": "user", "content": [{ "type": "text", "text": "Hello there" }] }
914                ],
915            })
916        );
917    }
918
919    #[test]
920    fn build_request_extracts_leading_system_message() {
921        let messages = vec![Message::system("Be terse."), user("Hi")];
922        let body = build_request(&messages, &ChatOptions::new(), "claude-x", 4096, false);
923        assert_eq!(body["system"], json!("Be terse."));
924        assert_eq!(
925            body["messages"],
926            json!([{ "role": "user", "content": [{ "type": "text", "text": "Hi" }] }])
927        );
928    }
929
930    #[test]
931    fn build_request_combines_options_instructions_and_system_message() {
932        let messages = vec![Message::system("Also be nice."), user("Hi")];
933        let options = ChatOptions::new().with_instructions("Be terse.");
934        let body = build_request(&messages, &options, "claude-x", 4096, false);
935        assert_eq!(body["system"], json!("Be terse.\n\nAlso be nice."));
936    }
937
938    #[test]
939    fn build_request_tool_role_message_becomes_user_tool_result() {
940        let tool_msg = Message::with_contents(
941            Role::tool(),
942            vec![Content::FunctionResult(FunctionResultContent::new(
943                "call_1",
944                Some(json!("18C and sunny")),
945            ))],
946        );
947        let body = build_request(&[tool_msg], &ChatOptions::new(), "claude-x", 4096, false);
948        assert_eq!(
949            body["messages"],
950            json!([{
951                "role": "user",
952                "content": [{ "type": "tool_result", "tool_use_id": "call_1", "content": "18C and sunny" }]
953            }])
954        );
955    }
956
957    #[test]
958    fn build_request_tool_result_error_sets_is_error() {
959        let mut result = FunctionResultContent::new("call_1", None);
960        result.exception = Some("boom".into());
961        let tool_msg = Message::with_contents(Role::tool(), vec![Content::FunctionResult(result)]);
962        let body = build_request(&[tool_msg], &ChatOptions::new(), "claude-x", 4096, false);
963        assert_eq!(
964            body["messages"][0]["content"][0],
965            json!({ "type": "tool_result", "tool_use_id": "call_1", "content": "boom", "is_error": true })
966        );
967    }
968
969    #[test]
970    fn build_request_assistant_function_call() {
971        let call = FunctionCallContent::new(
972            "call_1",
973            "get_weather",
974            Some(FunctionArguments::Object(HashMap::from([(
975                "city".to_string(),
976                json!("Paris"),
977            )]))),
978        );
979        let assistant_msg =
980            Message::with_contents(Role::assistant(), vec![Content::FunctionCall(call)]);
981        let body = build_request(
982            &[assistant_msg],
983            &ChatOptions::new(),
984            "claude-x",
985            4096,
986            false,
987        );
988        assert_eq!(
989            body["messages"],
990            json!([
991                {
992                    "role": "user",
993                    "content": [{ "type": "text", "text": "(continuing the conversation)" }]
994                },
995                {
996                    "role": "assistant",
997                    "content": [{ "type": "tool_use", "id": "call_1", "name": "get_weather", "input": { "city": "Paris" } }]
998                }
999            ])
1000        );
1001    }
1002
1003    #[test]
1004    fn build_request_data_content_image_uses_embedded_base64() {
1005        let dc = DataContent::from_bytes(b"hello", "image/png");
1006        let msg = Message::with_contents(Role::user(), vec![Content::Data(dc.clone())]);
1007        let body = build_request(&[msg], &ChatOptions::new(), "claude-x", 4096, false);
1008        let (_, expected_data) = split_data_uri(&dc.uri).unwrap();
1009        assert_eq!(
1010            body["messages"][0]["content"][0],
1011            json!({ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": expected_data } })
1012        );
1013    }
1014
1015    #[test]
1016    fn build_request_uri_content_image_uses_url_source() {
1017        let uc = UriContent {
1018            uri: "https://example.com/cat.png".into(),
1019            media_type: "image/png".into(),
1020        };
1021        let msg = Message::with_contents(Role::user(), vec![Content::Uri(uc)]);
1022        let body = build_request(&[msg], &ChatOptions::new(), "claude-x", 4096, false);
1023        assert_eq!(
1024            body["messages"][0]["content"][0],
1025            json!({ "type": "image", "source": { "type": "url", "url": "https://example.com/cat.png" } })
1026        );
1027    }
1028
1029    #[test]
1030    fn build_request_tools_and_tool_choice() {
1031        let tool = ToolDefinition {
1032            name: "get_weather".into(),
1033            description: "Get the weather".into(),
1034            parameters: json!({ "type": "object", "properties": {} }),
1035            kind: ToolKind::Function,
1036            approval_mode: ApprovalMode::NeverRequire,
1037            executor: None,
1038        };
1039        let options = ChatOptions::new()
1040            .with_tool(tool)
1041            .with_tool_choice(ToolMode::Required(Some("get_weather".into())));
1042        let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1043        assert_eq!(
1044            body["tools"],
1045            json!([{ "type": "custom", "name": "get_weather", "description": "Get the weather", "input_schema": { "type": "object", "properties": {} } }])
1046        );
1047        assert_eq!(
1048            body["tool_choice"],
1049            json!({ "type": "tool", "name": "get_weather" })
1050        );
1051    }
1052
1053    #[test]
1054    fn build_request_tool_choice_auto_with_disabled_parallel() {
1055        let mut options = ChatOptions::new().with_tool_choice(ToolMode::Auto);
1056        options.allow_multiple_tool_calls = Some(false);
1057        let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1058        assert_eq!(
1059            body["tool_choice"],
1060            json!({ "type": "auto", "disable_parallel_tool_use": true })
1061        );
1062    }
1063
1064    #[test]
1065    fn build_request_temperature_top_p_stop_sequences() {
1066        let mut options = ChatOptions::new().with_temperature(0.5);
1067        options.top_p = Some(0.9);
1068        options.stop = Some(vec!["STOP".into()]);
1069        let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1070        // `temperature`/`top_p` are `f32` on `ChatOptions`; compare against
1071        // `f32` literals too so the widened-to-f64 JSON values match exactly
1072        // (0.9_f32 as f64 != 0.9_f64).
1073        assert_eq!(body["temperature"], json!(0.5_f32));
1074        assert_eq!(body["top_p"], json!(0.9_f32));
1075        assert_eq!(body["stop_sequences"], json!(["STOP"]));
1076    }
1077
1078    #[test]
1079    fn build_request_stream_flag() {
1080        let body = build_request(&[user("hi")], &ChatOptions::new(), "claude-x", 4096, true);
1081        assert_eq!(body["stream"], json!(true));
1082    }
1083
1084    #[test]
1085    fn build_request_uses_given_max_tokens() {
1086        let body = build_request(&[user("hi")], &ChatOptions::new(), "claude-x", 2048, false);
1087        assert_eq!(body["max_tokens"], json!(2048));
1088    }
1089
1090    // endregion
1091
1092    // region: build_cloud_request (multi-cloud transports)
1093
1094    #[test]
1095    fn build_cloud_request_omits_model_and_sets_anthropic_version() {
1096        let body = build_cloud_request(
1097            &[user("hi")],
1098            &ChatOptions::new(),
1099            4096,
1100            false,
1101            "bedrock-2023-05-31",
1102        );
1103        assert!(body.get("model").is_none());
1104        assert_eq!(body["anthropic_version"], json!("bedrock-2023-05-31"));
1105        assert_eq!(body["max_tokens"], json!(4096));
1106    }
1107
1108    #[test]
1109    fn build_cloud_request_uses_given_anthropic_version() {
1110        let body = build_cloud_request(
1111            &[user("hi")],
1112            &ChatOptions::new(),
1113            4096,
1114            false,
1115            "vertex-2023-10-16",
1116        );
1117        assert_eq!(body["anthropic_version"], json!("vertex-2023-10-16"));
1118    }
1119
1120    #[test]
1121    fn build_cloud_request_messages_system_and_tools_match_build_request() {
1122        let tool = ToolDefinition {
1123            name: "get_weather".into(),
1124            description: "Get the weather".into(),
1125            parameters: json!({ "type": "object", "properties": {} }),
1126            kind: ToolKind::Function,
1127            approval_mode: ApprovalMode::NeverRequire,
1128            executor: None,
1129        };
1130        let messages = vec![Message::system("Be terse."), user("Hi")];
1131        let options = ChatOptions::new()
1132            .with_tool(tool)
1133            .with_tool_choice(ToolMode::Required(Some("get_weather".into())));
1134
1135        let direct = build_request(&messages, &options, "claude-x", 4096, false);
1136        let cloud = build_cloud_request(&messages, &options, 4096, false, "bedrock-2023-05-31");
1137
1138        assert_eq!(cloud["messages"], direct["messages"]);
1139        assert_eq!(cloud["system"], direct["system"]);
1140        assert_eq!(cloud["tools"], direct["tools"]);
1141        assert_eq!(cloud["tool_choice"], direct["tool_choice"]);
1142    }
1143
1144    #[test]
1145    fn build_cloud_request_stream_flag_and_additional_properties() {
1146        let mut options = ChatOptions::new();
1147        options
1148            .additional_properties
1149            .insert("top_k".into(), json!(5));
1150        let body = build_cloud_request(&[user("hi")], &options, 4096, true, "foundry-2025-01-01");
1151        assert_eq!(body["stream"], json!(true));
1152        assert_eq!(body["top_k"], json!(5));
1153    }
1154
1155    // endregion
1156
1157    // region: response_format (structured output)
1158    //
1159    // Anthropic's Messages API has no native `response_format` field (see
1160    // `append_response_format_instructions`'s doc comment for the upstream
1161    // Python/.NET investigation), so these assert the pragmatic fallback:
1162    // the request body's `system` string, rather than a silent no-op.
1163
1164    #[test]
1165    fn build_request_response_format_none_leaves_system_untouched() {
1166        let body = build_request(&[user("hi")], &ChatOptions::new(), "claude-x", 4096, false);
1167        assert!(body.get("system").is_none());
1168    }
1169
1170    #[test]
1171    fn build_request_response_format_text_is_a_noop() {
1172        let mut options = ChatOptions::new();
1173        options.response_format = Some(ResponseFormat::Text);
1174        let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1175        assert!(body.get("system").is_none());
1176    }
1177
1178    #[test]
1179    fn build_request_response_format_json_object_appends_system_instruction() {
1180        let mut options = ChatOptions::new();
1181        options.response_format = Some(ResponseFormat::JsonObject);
1182        let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1183        let system = body["system"].as_str().expect("system must be a string");
1184        assert!(
1185            system.to_lowercase().contains("json"),
1186            "expected a JSON instruction, got: {system}"
1187        );
1188    }
1189
1190    #[test]
1191    fn build_request_response_format_json_schema_embeds_schema_in_system() {
1192        let mut options = ChatOptions::new();
1193        options.response_format = Some(ResponseFormat::json_schema(
1194            "Person",
1195            json!({ "type": "object", "properties": { "name": { "type": "string" } } }),
1196        ));
1197        let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1198        let system = body["system"].as_str().expect("system must be a string");
1199        assert!(system.contains("Person"), "system: {system}");
1200        assert!(system.contains("\"name\""), "system: {system}");
1201        assert!(system.contains("\"type\": \"object\""), "system: {system}");
1202    }
1203
1204    #[test]
1205    fn build_request_response_format_json_schema_appends_after_existing_system() {
1206        // A leading system message and `response_format` must combine, not
1207        // clobber one another.
1208        let messages = vec![Message::system("Be terse."), user("Hi")];
1209        let mut options = ChatOptions::new();
1210        options.response_format = Some(ResponseFormat::JsonObject);
1211        let body = build_request(&messages, &options, "claude-x", 4096, false);
1212        let system = body["system"].as_str().expect("system must be a string");
1213        assert!(
1214            system.starts_with("Be terse."),
1215            "existing system text must be preserved first: {system}"
1216        );
1217        assert!(system.to_lowercase().contains("json"), "system: {system}");
1218    }
1219
1220    // endregion
1221
1222    // region: response parsing
1223
1224    #[test]
1225    fn parse_response_text_and_usage() {
1226        let value = json!({
1227            "id": "msg_123",
1228            "model": "claude-x",
1229            "stop_reason": "end_turn",
1230            "content": [{ "type": "text", "text": "Hello!" }],
1231            "usage": { "input_tokens": 10, "output_tokens": 5 },
1232        });
1233        let resp = parse_response(&value);
1234        assert_eq!(resp.response_id.as_deref(), Some("msg_123"));
1235        assert_eq!(resp.text(), "Hello!");
1236        assert_eq!(resp.finish_reason, Some(FinishReason::stop()));
1237        let usage = resp.usage_details.unwrap();
1238        assert_eq!(usage.input_token_count, Some(10));
1239        assert_eq!(usage.output_token_count, Some(5));
1240        assert_eq!(usage.total_token_count, Some(15));
1241    }
1242
1243    #[test]
1244    fn parse_response_tool_use() {
1245        let value = json!({
1246            "id": "msg_123",
1247            "stop_reason": "tool_use",
1248            "content": [
1249                { "type": "text", "text": "Let me check." },
1250                { "type": "tool_use", "id": "call_1", "name": "get_weather", "input": { "city": "Paris" } },
1251            ],
1252        });
1253        let resp = parse_response(&value);
1254        assert_eq!(resp.finish_reason, Some(FinishReason::tool_calls()));
1255        let calls = resp.function_calls();
1256        assert_eq!(calls.len(), 1);
1257        assert_eq!(calls[0].call_id, "call_1");
1258        assert_eq!(calls[0].name, "get_weather");
1259        assert_eq!(
1260            calls[0].parse_arguments().unwrap().get("city").unwrap(),
1261            &json!("Paris")
1262        );
1263    }
1264
1265    #[test]
1266    fn parse_response_cache_usage_fields() {
1267        let value = json!({
1268            "id": "msg_123",
1269            "content": [],
1270            "usage": {
1271                "input_tokens": 100,
1272                "output_tokens": 10,
1273                "cache_creation_input_tokens": 50,
1274                "cache_read_input_tokens": 20,
1275            },
1276        });
1277        let resp = parse_response(&value);
1278        let usage = resp.usage_details.unwrap();
1279        assert_eq!(usage.cache_creation_input_token_count, Some(50));
1280        assert_eq!(usage.cache_read_input_token_count, Some(20));
1281    }
1282
1283    #[test]
1284    fn map_stop_reason_covers_documented_mapping() {
1285        assert_eq!(map_stop_reason("end_turn"), FinishReason::stop());
1286        assert_eq!(map_stop_reason("stop_sequence"), FinishReason::stop());
1287        assert_eq!(
1288            map_stop_reason("max_tokens"),
1289            FinishReason::new(FinishReason::LENGTH)
1290        );
1291        assert_eq!(map_stop_reason("tool_use"), FinishReason::tool_calls());
1292    }
1293
1294    #[test]
1295    fn message_start_usage_omits_output_tokens() {
1296        let usage = json!({ "input_tokens": 25, "output_tokens": 1 });
1297        let content = parse_message_start_usage(&usage).unwrap();
1298        assert_eq!(content.details.input_token_count, Some(25));
1299        assert_eq!(content.details.output_token_count, None);
1300    }
1301
1302    // endregion
1303    #[test]
1304    fn consecutive_same_role_messages_are_merged() {
1305        let msgs = vec![
1306            Message::user("first"),
1307            Message::user("second"),
1308            Message::assistant("reply"),
1309            Message::assistant("more"),
1310            Message::user("third"),
1311        ];
1312        let out = messages_to_anthropic(&msgs);
1313        assert_eq!(out.len(), 3);
1314        assert_eq!(out[0]["role"], "user");
1315        assert_eq!(out[0]["content"].as_array().unwrap().len(), 2);
1316        assert_eq!(out[1]["role"], "assistant");
1317        assert_eq!(out[1]["content"].as_array().unwrap().len(), 2);
1318        assert_eq!(out[2]["role"], "user");
1319    }
1320
1321    #[test]
1322    fn leading_assistant_message_gets_synthetic_user_turn() {
1323        let msgs = vec![Message::assistant("greeting"), Message::user("hello")];
1324        let out = messages_to_anthropic(&msgs);
1325        assert_eq!(out.len(), 3);
1326        assert_eq!(out[0]["role"], "user");
1327        assert_eq!(
1328            out[0]["content"][0]["text"],
1329            "(continuing the conversation)"
1330        );
1331        assert_eq!(out[1]["role"], "assistant");
1332        assert_eq!(out[2]["role"], "user");
1333    }
1334
1335    // region: beta flags
1336
1337    #[test]
1338    fn compute_beta_flags_default_includes_both_upstream_flags() {
1339        let mut options = ChatOptions::new();
1340        let flags = compute_beta_flags(&mut options, &[]);
1341        assert!(flags.contains(&"mcp-client-2025-04-04".to_string()));
1342        assert!(flags.contains(&"code-execution-2025-08-25".to_string()));
1343        assert_eq!(flags.len(), 2);
1344    }
1345
1346    #[test]
1347    fn compute_beta_flags_merges_client_level_additional_flags() {
1348        let mut options = ChatOptions::new();
1349        let flags = compute_beta_flags(&mut options, &["my-beta-flag".to_string()]);
1350        assert!(flags.contains(&"my-beta-flag".to_string()));
1351        assert_eq!(flags.len(), 3);
1352    }
1353
1354    #[test]
1355    fn compute_beta_flags_merges_and_removes_per_request_additional_flags() {
1356        let mut options = ChatOptions::new();
1357        options.additional_properties.insert(
1358            "additional_beta_flags".into(),
1359            json!(["request-level-flag"]),
1360        );
1361        let flags = compute_beta_flags(&mut options, &[]);
1362        assert!(flags.contains(&"request-level-flag".to_string()));
1363        // Popped out, like upstream's `.pop(...)` -- must not leak into the
1364        // body via `additional_properties`.
1365        assert!(!options
1366            .additional_properties
1367            .contains_key("additional_beta_flags"));
1368    }
1369
1370    #[test]
1371    fn compute_beta_flags_deduplicates_overlapping_flags() {
1372        let mut options = ChatOptions::new();
1373        options.additional_properties.insert(
1374            "additional_beta_flags".into(),
1375            json!(["mcp-client-2025-04-04"]),
1376        );
1377        let flags = compute_beta_flags(&mut options, &["mcp-client-2025-04-04".to_string()]);
1378        assert_eq!(flags.len(), 2);
1379    }
1380
1381    #[test]
1382    fn compute_beta_flags_does_not_leak_into_request_body() {
1383        let mut options = ChatOptions::new();
1384        options.additional_properties.insert(
1385            "additional_beta_flags".into(),
1386            json!(["request-level-flag"]),
1387        );
1388        let _ = compute_beta_flags(&mut options, &[]);
1389        let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1390        assert!(body.get("additional_beta_flags").is_none());
1391    }
1392
1393    // endregion
1394
1395    // region: hosted tool mapping (Anthropic wire shape)
1396
1397    fn make_tool(kind: ToolKind, name: &str, parameters: Value) -> ToolDefinition {
1398        ToolDefinition {
1399            name: name.into(),
1400            description: String::new(),
1401            parameters,
1402            kind,
1403            approval_mode: ApprovalMode::NeverRequire,
1404            executor: None,
1405        }
1406    }
1407
1408    #[test]
1409    fn tools_to_anthropic_web_search_basic() {
1410        let tool = make_tool(ToolKind::HostedWebSearch, "web_search", json!({}));
1411        let (tools, mcp_servers) = tools_to_anthropic(&[tool]);
1412        assert_eq!(
1413            tools,
1414            vec![json!({ "type": "web_search_20250305", "name": "web_search" })]
1415        );
1416        assert!(mcp_servers.is_empty());
1417    }
1418
1419    #[test]
1420    fn tools_to_anthropic_web_search_reads_max_uses_and_user_location_from_parameters() {
1421        let tool = make_tool(
1422            ToolKind::HostedWebSearch,
1423            "web_search",
1424            json!({ "max_uses": 3, "user_location": { "type": "approximate", "city": "Seattle" } }),
1425        );
1426        let (tools, _) = tools_to_anthropic(&[tool]);
1427        assert_eq!(
1428            tools[0],
1429            json!({
1430                "type": "web_search_20250305",
1431                "name": "web_search",
1432                "max_uses": 3,
1433                "user_location": { "type": "approximate", "city": "Seattle" },
1434            })
1435        );
1436    }
1437
1438    #[test]
1439    fn tools_to_anthropic_code_interpreter() {
1440        let tool = make_tool(
1441            ToolKind::HostedCodeInterpreter,
1442            "code_interpreter",
1443            json!({}),
1444        );
1445        let (tools, mcp_servers) = tools_to_anthropic(&[tool]);
1446        assert_eq!(
1447            tools,
1448            vec![json!({ "type": "code_execution_20250825", "name": "code_execution" })]
1449        );
1450        assert!(mcp_servers.is_empty());
1451    }
1452
1453    #[test]
1454    fn tools_to_anthropic_mcp_goes_to_mcp_servers_not_tools() {
1455        let tool = make_tool(
1456            ToolKind::HostedMcp {
1457                url: "https://example.com/mcp".into(),
1458                allowed_tools: None,
1459            },
1460            "my-mcp",
1461            json!({}),
1462        );
1463        let (tools, mcp_servers) = tools_to_anthropic(&[tool]);
1464        assert!(tools.is_empty());
1465        assert_eq!(
1466            mcp_servers,
1467            vec![json!({ "type": "url", "name": "my-mcp", "url": "https://example.com/mcp" })]
1468        );
1469    }
1470
1471    #[test]
1472    fn tools_to_anthropic_mcp_with_allowed_tools() {
1473        let tool = make_tool(
1474            ToolKind::HostedMcp {
1475                url: "https://example.com/mcp".into(),
1476                allowed_tools: Some(vec!["a".into(), "b".into()]),
1477            },
1478            "my-mcp",
1479            json!({}),
1480        );
1481        let (_, mcp_servers) = tools_to_anthropic(&[tool]);
1482        assert_eq!(
1483            mcp_servers[0]["tool_configuration"],
1484            json!({ "allowed_tools": ["a", "b"] })
1485        );
1486    }
1487
1488    #[test]
1489    fn tools_to_anthropic_mcp_empty_allowed_tools_is_omitted() {
1490        let tool = make_tool(
1491            ToolKind::HostedMcp {
1492                url: "https://example.com/mcp".into(),
1493                allowed_tools: Some(vec![]),
1494            },
1495            "my-mcp",
1496            json!({}),
1497        );
1498        let (_, mcp_servers) = tools_to_anthropic(&[tool]);
1499        assert!(mcp_servers[0].get("tool_configuration").is_none());
1500    }
1501
1502    #[test]
1503    fn tools_to_anthropic_mcp_authorization_header_becomes_authorization_token() {
1504        let tool = make_tool(
1505            ToolKind::HostedMcp {
1506                url: "https://example.com/mcp".into(),
1507                allowed_tools: None,
1508            },
1509            "my-mcp",
1510            json!({ "headers": { "authorization": "Bearer token123" } }),
1511        );
1512        let (_, mcp_servers) = tools_to_anthropic(&[tool]);
1513        assert_eq!(
1514            mcp_servers[0]["authorization_token"],
1515            json!("Bearer token123")
1516        );
1517    }
1518
1519    #[test]
1520    fn tools_to_anthropic_mcp_authorization_header_lookup_is_case_insensitive() {
1521        let tool = make_tool(
1522            ToolKind::HostedMcp {
1523                url: "https://example.com/mcp".into(),
1524                allowed_tools: None,
1525            },
1526            "my-mcp",
1527            json!({ "headers": { "Authorization": "Bearer token456" } }),
1528        );
1529        let (_, mcp_servers) = tools_to_anthropic(&[tool]);
1530        assert_eq!(
1531            mcp_servers[0]["authorization_token"],
1532            json!("Bearer token456")
1533        );
1534    }
1535
1536    #[test]
1537    fn tools_to_anthropic_function_tool_has_custom_type() {
1538        let tool = make_tool(
1539            ToolKind::Function,
1540            "get_weather",
1541            json!({ "type": "object", "properties": {} }),
1542        );
1543        let (tools, _) = tools_to_anthropic(&[tool]);
1544        assert_eq!(tools[0]["type"], json!("custom"));
1545    }
1546
1547    #[test]
1548    fn tools_to_anthropic_unknown_hosted_kind_is_skipped() {
1549        let tool = make_tool(
1550            ToolKind::HostedFileSearch { max_results: None },
1551            "file_search",
1552            json!({}),
1553        );
1554        let (tools, mcp_servers) = tools_to_anthropic(&[tool]);
1555        assert!(tools.is_empty());
1556        assert!(mcp_servers.is_empty());
1557    }
1558
1559    #[test]
1560    fn tools_to_anthropic_mixed_tools_and_mcp_servers_both_populate_body() {
1561        let function_tool = make_tool(ToolKind::Function, "get_weather", json!({}));
1562        let mcp_tool = make_tool(
1563            ToolKind::HostedMcp {
1564                url: "https://example.com/mcp".into(),
1565                allowed_tools: None,
1566            },
1567            "my-mcp",
1568            json!({}),
1569        );
1570        let options = ChatOptions::new()
1571            .with_tool(function_tool)
1572            .with_tool(mcp_tool);
1573        let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1574        assert_eq!(body["tools"].as_array().unwrap().len(), 1);
1575        assert_eq!(body["mcp_servers"].as_array().unwrap().len(), 1);
1576    }
1577
1578    // endregion
1579
1580    // region: hosted result block parsing
1581
1582    #[test]
1583    fn parse_content_blocks_server_tool_use_is_function_call() {
1584        let blocks = vec![json!({
1585            "type": "server_tool_use",
1586            "id": "srvtoolu_1",
1587            "name": "web_search",
1588            "input": { "query": "rust" }
1589        })];
1590        let contents = parse_content_blocks(&blocks);
1591        assert_eq!(contents.len(), 1);
1592        match &contents[0] {
1593            Content::FunctionCall(fc) => {
1594                assert_eq!(fc.call_id, "srvtoolu_1");
1595                assert_eq!(fc.name, "web_search");
1596                assert_eq!(
1597                    fc.parse_arguments().unwrap().get("query").unwrap(),
1598                    &json!("rust")
1599                );
1600            }
1601            other => panic!("expected FunctionCall, got {other:?}"),
1602        }
1603    }
1604
1605    #[test]
1606    fn parse_content_blocks_mcp_tool_use_is_function_call() {
1607        let blocks = vec![json!({
1608            "type": "mcp_tool_use",
1609            "id": "mcptoolu_1",
1610            "name": "search_docs",
1611            "input": {}
1612        })];
1613        let contents = parse_content_blocks(&blocks);
1614        assert!(matches!(
1615            &contents[0],
1616            Content::FunctionCall(fc) if fc.call_id == "mcptoolu_1" && fc.name == "search_docs"
1617        ));
1618    }
1619
1620    #[test]
1621    fn parse_content_blocks_mcp_tool_result_with_list_content_is_recursively_parsed() {
1622        let blocks = vec![json!({
1623            "type": "mcp_tool_result",
1624            "tool_use_id": "mcptoolu_1",
1625            "is_error": false,
1626            "content": [{ "type": "text", "text": "result text" }]
1627        })];
1628        let contents = parse_content_blocks(&blocks);
1629        assert_eq!(contents.len(), 1);
1630        match &contents[0] {
1631            Content::FunctionResult(fr) => {
1632                assert_eq!(fr.call_id, "mcptoolu_1");
1633                assert_eq!(fr.exception, None);
1634                // The nested `content` array is itself parsed via
1635                // `parse_content_blocks` and serialized back to JSON.
1636                assert_eq!(
1637                    fr.result,
1638                    Some(json!([{ "type": "text", "text": "result text" }]))
1639                );
1640            }
1641            other => panic!("expected FunctionResult, got {other:?}"),
1642        }
1643    }
1644
1645    #[test]
1646    fn parse_content_blocks_mcp_tool_result_with_string_content_passes_through() {
1647        let blocks = vec![json!({
1648            "type": "mcp_tool_result",
1649            "tool_use_id": "mcptoolu_1",
1650            "content": "plain string result"
1651        })];
1652        let contents = parse_content_blocks(&blocks);
1653        match &contents[0] {
1654            Content::FunctionResult(fr) => {
1655                assert_eq!(fr.result, Some(json!("plain string result")));
1656            }
1657            other => panic!("expected FunctionResult, got {other:?}"),
1658        }
1659    }
1660
1661    #[test]
1662    fn parse_content_blocks_web_search_tool_result_is_not_recursively_parsed() {
1663        let blocks = vec![json!({
1664            "type": "web_search_tool_result",
1665            "tool_use_id": "srvtoolu_1",
1666            "content": [{ "type": "web_search_result", "url": "https://example.com", "title": "Example" }]
1667        })];
1668        let contents = parse_content_blocks(&blocks);
1669        match &contents[0] {
1670            Content::FunctionResult(fr) => {
1671                assert_eq!(fr.call_id, "srvtoolu_1");
1672                // Raw content passed through as-is, unlike `mcp_tool_result`.
1673                assert_eq!(
1674                    fr.result,
1675                    Some(
1676                        json!([{ "type": "web_search_result", "url": "https://example.com", "title": "Example" }])
1677                    )
1678                );
1679            }
1680            other => panic!("expected FunctionResult, got {other:?}"),
1681        }
1682    }
1683
1684    #[test]
1685    fn parse_content_blocks_web_fetch_tool_result_uses_same_mapping() {
1686        let blocks = vec![json!({
1687            "type": "web_fetch_tool_result",
1688            "tool_use_id": "srvtoolu_2",
1689            "content": { "type": "web_fetch_result", "url": "https://example.com" }
1690        })];
1691        let contents = parse_content_blocks(&blocks);
1692        assert_eq!(contents.len(), 1);
1693        assert!(matches!(&contents[0], Content::FunctionResult(fr) if fr.call_id == "srvtoolu_2"));
1694    }
1695
1696    #[test]
1697    fn parse_content_blocks_code_execution_tool_result_extracts_hosted_files_before_result() {
1698        let blocks = vec![json!({
1699            "type": "code_execution_tool_result",
1700            "tool_use_id": "srvtoolu_3",
1701            "content": {
1702                "type": "code_execution_result",
1703                "stdout": "",
1704                "stderr": "",
1705                "return_code": 0,
1706                "content": [
1707                    { "type": "code_execution_output", "file_id": "file_abc" },
1708                    { "type": "code_execution_output", "file_id": "file_def" }
1709                ]
1710            }
1711        })];
1712        let contents = parse_content_blocks(&blocks);
1713        assert_eq!(contents.len(), 3);
1714        assert_eq!(
1715            contents[0],
1716            Content::HostedFile(HostedFileContent {
1717                file_id: "file_abc".into()
1718            })
1719        );
1720        assert_eq!(
1721            contents[1],
1722            Content::HostedFile(HostedFileContent {
1723                file_id: "file_def".into()
1724            })
1725        );
1726        match &contents[2] {
1727            Content::FunctionResult(fr) => assert_eq!(fr.call_id, "srvtoolu_3"),
1728            other => panic!("expected FunctionResult, got {other:?}"),
1729        }
1730    }
1731
1732    #[test]
1733    fn parse_content_blocks_bash_code_execution_tool_result_extracts_hosted_files() {
1734        let blocks = vec![json!({
1735            "type": "bash_code_execution_tool_result",
1736            "tool_use_id": "srvtoolu_4",
1737            "content": {
1738                "type": "bash_code_execution_result",
1739                "stdout": "",
1740                "stderr": "",
1741                "return_code": 0,
1742                "content": [{ "type": "bash_code_execution_output", "file_id": "file_ghi" }]
1743            }
1744        })];
1745        let contents = parse_content_blocks(&blocks);
1746        assert_eq!(contents.len(), 2);
1747        assert_eq!(
1748            contents[0],
1749            Content::HostedFile(HostedFileContent {
1750                file_id: "file_ghi".into()
1751            })
1752        );
1753    }
1754
1755    #[test]
1756    fn parse_content_blocks_code_execution_tool_result_no_files_only_function_result() {
1757        let blocks = vec![json!({
1758            "type": "code_execution_tool_result",
1759            "tool_use_id": "srvtoolu_5",
1760            "content": { "type": "code_execution_result", "stdout": "hi", "stderr": "", "return_code": 0, "content": [] }
1761        })];
1762        let contents = parse_content_blocks(&blocks);
1763        assert_eq!(contents.len(), 1);
1764        assert!(matches!(&contents[0], Content::FunctionResult(_)));
1765    }
1766
1767    #[test]
1768    fn parse_content_blocks_text_editor_code_execution_tool_result_never_extracts_files() {
1769        // `text_editor_code_execution_tool_result`'s nested content type is
1770        // never `code_execution_result`/`bash_code_execution_result`
1771        // (verified against the `anthropic` SDK's
1772        // `BetaTextEditorCodeExecutionToolResultBlock`), so this only ever
1773        // produces the trailing FunctionResult.
1774        let blocks = vec![json!({
1775            "type": "text_editor_code_execution_tool_result",
1776            "tool_use_id": "srvtoolu_6",
1777            "content": { "type": "text_editor_code_execution_view_result", "file_type": "text", "content": "print('hi')" }
1778        })];
1779        let contents = parse_content_blocks(&blocks);
1780        assert_eq!(contents.len(), 1);
1781        assert!(matches!(&contents[0], Content::FunctionResult(_)));
1782    }
1783
1784    #[test]
1785    fn parse_content_blocks_unknown_block_type_is_skipped() {
1786        let blocks = vec![json!({ "type": "totally_unknown_block" })];
1787        let contents = parse_content_blocks(&blocks);
1788        assert!(contents.is_empty());
1789    }
1790
1791    #[test]
1792    fn parse_response_includes_server_tool_use_and_web_search_result() {
1793        let value = json!({
1794            "id": "msg_1",
1795            "content": [
1796                { "type": "server_tool_use", "id": "srvtoolu_1", "name": "web_search", "input": { "query": "rust" } },
1797                { "type": "web_search_tool_result", "tool_use_id": "srvtoolu_1", "content": [{ "type": "web_search_result", "url": "https://example.com", "title": "Example" }] },
1798            ],
1799        });
1800        let resp = parse_response(&value);
1801        let contents = &resp.messages[0].contents;
1802        assert_eq!(contents.len(), 2);
1803        assert!(matches!(&contents[0], Content::FunctionCall(_)));
1804        assert!(matches!(&contents[1], Content::FunctionResult(_)));
1805    }
1806
1807    // endregion
1808
1809    // region: citations
1810
1811    #[test]
1812    fn parse_citations_char_location() {
1813        let block = json!({
1814            "type": "text",
1815            "text": "cited",
1816            "citations": [{
1817                "type": "char_location",
1818                "cited_text": "The grass is green.",
1819                "document_index": 0,
1820                "document_title": "Example Document",
1821                "start_char_index": 0,
1822                "end_char_index": 20,
1823            }]
1824        });
1825        let annotations = parse_citations(&block).unwrap();
1826        assert_eq!(annotations.len(), 1);
1827        let cit = &annotations[0];
1828        // Mirrors upstream's `char_location` branch, which reads
1829        // `citation.title` rather than `citation.document_title` -- absent
1830        // on the real wire payload, so `title` ends up `None` here too. See
1831        // `parse_citations`'s doc comment for the upstream-bug analysis.
1832        assert_eq!(cit.title, None);
1833        assert_eq!(cit.snippet.as_deref(), Some("The grass is green."));
1834        assert_eq!(
1835            cit.annotated_regions,
1836            Some(vec![TextSpanRegion {
1837                start_index: Some(0),
1838                end_index: Some(20)
1839            }])
1840        );
1841    }
1842
1843    #[test]
1844    fn parse_citations_page_location_uses_document_title() {
1845        let block = json!({
1846            "type": "text",
1847            "text": "cited",
1848            "citations": [{
1849                "type": "page_location",
1850                "cited_text": "Water is essential for life.",
1851                "document_index": 1,
1852                "document_title": "PDF Document",
1853                "start_page_number": 5,
1854                "end_page_number": 6,
1855            }]
1856        });
1857        let annotations = parse_citations(&block).unwrap();
1858        let cit = &annotations[0];
1859        assert_eq!(cit.title.as_deref(), Some("PDF Document"));
1860        assert_eq!(cit.snippet.as_deref(), Some("Water is essential for life."));
1861        assert_eq!(
1862            cit.annotated_regions,
1863            Some(vec![TextSpanRegion {
1864                start_index: Some(5),
1865                end_index: Some(6)
1866            }])
1867        );
1868    }
1869
1870    #[test]
1871    fn parse_citations_content_block_location_uses_document_title() {
1872        let block = json!({
1873            "type": "text",
1874            "text": "cited",
1875            "citations": [{
1876                "type": "content_block_location",
1877                "cited_text": "These are important findings.",
1878                "document_index": 2,
1879                "document_title": "Custom Content Document",
1880                "start_block_index": 0,
1881                "end_block_index": 1,
1882            }]
1883        });
1884        let annotations = parse_citations(&block).unwrap();
1885        let cit = &annotations[0];
1886        assert_eq!(cit.title.as_deref(), Some("Custom Content Document"));
1887        assert_eq!(
1888            cit.annotated_regions,
1889            Some(vec![TextSpanRegion {
1890                start_index: Some(0),
1891                end_index: Some(1)
1892            }])
1893        );
1894    }
1895
1896    #[test]
1897    fn parse_citations_file_id_only_set_when_present() {
1898        let block = json!({
1899            "type": "text",
1900            "text": "cited",
1901            "citations": [{
1902                "type": "page_location",
1903                "cited_text": "text",
1904                "document_index": 0,
1905                "document_title": "Doc",
1906                "start_page_number": 1,
1907                "end_page_number": 2,
1908                "file_id": "file_123",
1909            }]
1910        });
1911        let annotations = parse_citations(&block).unwrap();
1912        assert_eq!(annotations[0].file_id.as_deref(), Some("file_123"));
1913    }
1914
1915    #[test]
1916    fn parse_citations_web_search_result_location() {
1917        let block = json!({
1918            "type": "text",
1919            "text": "cited",
1920            "citations": [{
1921                "type": "web_search_result_location",
1922                "cited_text": "some cited snippet",
1923                "url": "https://example.com/page",
1924                "title": "Example Page",
1925                "encrypted_index": "abc123",
1926            }]
1927        });
1928        let annotations = parse_citations(&block).unwrap();
1929        let cit = &annotations[0];
1930        assert_eq!(cit.title.as_deref(), Some("Example Page"));
1931        assert_eq!(cit.snippet.as_deref(), Some("some cited snippet"));
1932        assert_eq!(cit.url.as_deref(), Some("https://example.com/page"));
1933        assert_eq!(cit.annotated_regions, None);
1934    }
1935
1936    #[test]
1937    fn parse_citations_search_result_location_uses_source_as_url() {
1938        let block = json!({
1939            "type": "text",
1940            "text": "cited",
1941            "citations": [{
1942                "type": "search_result_location",
1943                "cited_text": "some cited snippet",
1944                "source": "https://example.com/doc",
1945                "title": "Search Result",
1946                "search_result_index": 0,
1947                "start_block_index": 0,
1948                "end_block_index": 1,
1949            }]
1950        });
1951        let annotations = parse_citations(&block).unwrap();
1952        let cit = &annotations[0];
1953        assert_eq!(cit.title.as_deref(), Some("Search Result"));
1954        assert_eq!(cit.url.as_deref(), Some("https://example.com/doc"));
1955        assert_eq!(
1956            cit.annotated_regions,
1957            Some(vec![TextSpanRegion {
1958                start_index: Some(0),
1959                end_index: Some(1)
1960            }])
1961        );
1962    }
1963
1964    #[test]
1965    fn parse_citations_unknown_type_still_produces_empty_annotation() {
1966        // Mirrors upstream: `annotations.append(cit)` runs unconditionally
1967        // after the match, even for an unrecognized `citation.type`
1968        // (~667-669).
1969        let block = json!({
1970            "type": "text",
1971            "text": "cited",
1972            "citations": [{ "type": "some_future_citation_type" }]
1973        });
1974        let annotations = parse_citations(&block).unwrap();
1975        assert_eq!(annotations.len(), 1);
1976        assert_eq!(annotations[0], Annotation::default());
1977    }
1978
1979    #[test]
1980    fn parse_citations_absent_returns_none() {
1981        let block = json!({ "type": "text", "text": "no citations here" });
1982        assert_eq!(parse_citations(&block), None);
1983    }
1984
1985    #[test]
1986    fn parse_citations_empty_array_returns_none() {
1987        let block = json!({ "type": "text", "text": "no citations here", "citations": [] });
1988        assert_eq!(parse_citations(&block), None);
1989    }
1990
1991    #[test]
1992    fn parse_response_text_block_carries_citations_as_annotations() {
1993        let value = json!({
1994            "id": "msg_1",
1995            "content": [{
1996                "type": "text",
1997                "text": "the grass is green",
1998                "citations": [{
1999                    "type": "char_location",
2000                    "cited_text": "The grass is green.",
2001                    "document_index": 0,
2002                    "document_title": "Example Document",
2003                    "start_char_index": 0,
2004                    "end_char_index": 20,
2005                }]
2006            }]
2007        });
2008        let resp = parse_response(&value);
2009        match &resp.messages[0].contents[0] {
2010            Content::Text(t) => {
2011                assert_eq!(t.text, "the grass is green");
2012                assert!(t.annotations.is_some());
2013            }
2014            other => panic!("expected Text, got {other:?}"),
2015        }
2016    }
2017
2018    // endregion
2019}