Skip to main content

mermaid_model/models/adapters/
anthropic.rs

1//! Anthropic Claude adapter — bespoke handling for the Messages API.
2//!
3//! Anthropic's wire format is structurally different from OpenAI's Chat
4//! Completions in ways that prevent base-URL reuse: a top-level `system`
5//! field instead of a system message, strict alternating roles, no
6//! `tool` role (tool results are content blocks inside a user message),
7//! flat tool definitions, and typed SSE streaming events. This adapter
8//! handles the translation in one focused file.
9//!
10//! Critical detail: thinking blocks carry an encrypted `signature` that
11//! MUST round-trip in conversation history when extended thinking is
12//! enabled. Mermaid's `ChatMessage::provider_continuation` field (Step 3
13//! Wave 1) holds it across turns. The signature is per-thinking-block
14//! server state — drop it and the API returns 400 invalid_request_error
15//! claiming reasoning continuity is broken.
16//!
17//! Streaming uses standard SSE framing (reused from Step 2's
18//! `drain_sse_events`) but emits TYPED events (`message_start`,
19//! `content_block_start`, `content_block_delta`, etc.) rather than
20//! OpenAI's flat delta-shape. Wave 3 implements the state machine.
21
22use std::collections::HashMap;
23use std::time::Duration;
24
25use async_trait::async_trait;
26use futures::StreamExt;
27use reqwest::Client;
28use serde::Deserialize;
29use serde_json::{Value, json};
30
31use crate::constants::MAX_RESPONSE_CHARS;
32use crate::models::ModelCapabilities;
33use crate::models::config::ModelConfig;
34use crate::models::error::{BackendError, ModelError, Result};
35use crate::models::reasoning::{
36    ReasoningCapability, ReasoningChunk, ReasoningLevel, nearest_effort,
37};
38use crate::models::stream::{StreamCallback, StreamEvent};
39use crate::models::tool_call::{FunctionCall, ToolCall};
40use crate::models::traits::Model;
41
42use super::ModelLimits;
43use super::output_budget::{OutputBudgetInputs, OutputCapMode, resolve_output_budget};
44use crate::models::types::{
45    ChatMessage, FinishReason, MessageAudience, MessageRole, ModelResponse, ProviderContinuation,
46    TokenUsage,
47};
48use crate::utils::drain_sse_events;
49
50const TRUNCATION_MARKER: &str = "\n\n[TRUNCATED: response exceeded size limit]";
51/// API version pin per Anthropic stability guarantee. Bump when a feature
52/// we use moves to a newer version line.
53const ANTHROPIC_VERSION: &str = "2023-06-01";
54
55/// Append `chunk` to `buf`, char-boundary-safe truncation at `cap` bytes.
56/// Sets `*truncated` once tripped; subsequent calls become no-ops. Same
57/// shape as the helpers in the Ollama and OpenAI-compat adapters.
58fn push_capped(buf: &mut String, chunk: &str, truncated: &mut bool, cap: usize) {
59    if *truncated {
60        return;
61    }
62    buf.push_str(chunk);
63    if buf.len() > cap {
64        let end = buf.floor_char_boundary(cap);
65        buf.truncate(end);
66        buf.push_str(TRUNCATION_MARKER);
67        *truncated = true;
68    }
69}
70
71/// Append a streaming tool-argument fragment, hard-capping the buffer at
72/// `MAX_TOOL_ARG_BYTES`. A crafted stream could otherwise send unbounded
73/// `partial_json` fragments and grow this buffer without limit (the daemon is
74/// long-lived). Past the cap we stop appending at a char boundary; the
75/// now-truncated JSON simply fails to parse and falls back to a raw string —
76/// bounded, not an OOM (#14).
77fn push_tool_arg(buf: &mut String, frag: &str) {
78    let cap = crate::constants::MAX_TOOL_ARG_BYTES;
79    if buf.len() >= cap {
80        return;
81    }
82    if buf.len() + frag.len() <= cap {
83        buf.push_str(frag);
84    } else {
85        let room = cap - buf.len();
86        let end = frag.floor_char_boundary(room);
87        buf.push_str(&frag[..end]);
88    }
89}
90
91/// Map Anthropic's `stop_reason` onto the normalized [`FinishReason`].
92fn map_anthropic_stop_reason(s: &str) -> FinishReason {
93    match s {
94        "end_turn" | "stop_sequence" => FinishReason::Stop,
95        "tool_use" => FinishReason::ToolUse,
96        "max_tokens" => FinishReason::Length,
97        "refusal" => FinishReason::ContentFilter,
98        other => FinishReason::Other(other.to_string()),
99    }
100}
101
102/// F56: whether an Anthropic stream ended abnormally — the connection closed
103/// before ANY terminal frame was observed. A normal stream ends with a
104/// `message_stop` event, preceded by a `message_delta` that carries the
105/// terminal `stop_reason`. If NEITHER was seen the turn is truncated, not
106/// complete; surfacing a clean `Ok` (with `stop_reason: None`) here would be
107/// indistinguishable from a real completion, so the caller returns a stream
108/// error instead. A `max_tokens` truncation is NOT abnormal — it arrives as a
109/// real `stop_reason` (`Length`), so it's preserved and the runtime's
110/// compact-and-continue path still fires.
111fn stream_closed_abnormally(saw_message_stop: bool, stop_reason: Option<&FinishReason>) -> bool {
112    !saw_message_stop && stop_reason.is_none()
113}
114
115/// Finalize one completed (or interrupted) content block into the response
116/// accumulators. Shared by the `content_block_stop` handler and the post-loop
117/// drain, so a block that never received its stop event (a mid-message cutoff)
118/// is recovered identically — including a fully-streamed `tool_use`.
119fn finalize_block(
120    acc: BlockAccumulator,
121    text_acc: &mut String,
122    thinking_acc: &mut String,
123    signature_acc: &mut Option<String>,
124    tool_calls_done: &mut Vec<ToolCall>,
125    callback: &StreamCallback,
126) {
127    match acc {
128        BlockAccumulator::Text(s) => text_acc.push_str(&s),
129        BlockAccumulator::Thinking { content, signature } => {
130            thinking_acc.push_str(&content);
131            if signature.is_some() {
132                *signature_acc = signature;
133            }
134        },
135        BlockAccumulator::ToolUse {
136            id,
137            name,
138            input_buf,
139        } => {
140            let arguments: Value = if input_buf.is_empty() {
141                json!({})
142            } else {
143                match serde_json::from_str(&input_buf) {
144                    Ok(v) => v,
145                    Err(_) => Value::String(input_buf),
146                }
147            };
148            let tc = ToolCall {
149                id: if id.is_empty() { None } else { Some(id) },
150                function: FunctionCall { name, arguments },
151            };
152            callback(StreamEvent::ToolCall(tc.clone()));
153            tool_calls_done.push(tc);
154        },
155        BlockAccumulator::Other => {},
156    }
157}
158
159/// Adaptive (Claude 4.6+) vs legacy (`budget_tokens`) thinking-config shape.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161enum ThinkingFormat {
162    /// `thinking: {type: "adaptive"}` + top-level `effort: "low|medium|high|max"`.
163    /// Required on Opus 4.7; recommended on Sonnet 4.6 / Opus 4.6.
164    Adaptive,
165    /// `thinking: {type: "enabled", budget_tokens: N}`. Required on
166    /// Sonnet 4.5 / Opus 4.5 / Haiku 4.5.
167    Legacy,
168}
169
170/// Pick the thinking-config shape this Claude model accepts, from the
171/// capability catalog. The 4.6+ line uses adaptive; the 4.5 family
172/// (Sonnet 4.5 / Opus 4.5 / Haiku 4.5) uses legacy `budget_tokens`.
173/// Defaults to `Legacy` for genuinely unknown models — if a future model
174/// rejects legacy, the API's 400 names the fix, and the catalog should gain
175/// a row when that model is added (the pre-catalog table predated Opus 4.8 /
176/// Fable 5 and wrongly sent them legacy → 400).
177fn thinking_format_for(model: &str) -> ThinkingFormat {
178    match crate::models::catalog::lookup(model).thinking {
179        crate::models::catalog::ThinkingShape::AnthropicAdaptive => ThinkingFormat::Adaptive,
180        _ => ThinkingFormat::Legacy,
181    }
182}
183
184/// Translate `ReasoningLevel` to a legacy `budget_tokens` value, clamped
185/// so it never exceeds `max_tokens - 1024` (the API rejects budgets that
186/// don't leave headroom for the actual output).
187///
188/// Budgets climb monotonically with rank so XHigh (between High and Max)
189/// gets a between-the-two budget rather than collapsing onto either
190/// neighbor. Legacy models don't expose XHigh on-paper, but the value
191/// preserves semantic ordering for callers that snap into this path.
192fn legacy_budget_for(level: ReasoningLevel, max_tokens: usize) -> Option<u32> {
193    let proposed: u32 = match level {
194        ReasoningLevel::None => return None,
195        ReasoningLevel::Minimal | ReasoningLevel::Low => 2048,
196        ReasoningLevel::Medium => 4096,
197        ReasoningLevel::High => 16000,
198        // Between High (16k) and Max (32k).
199        ReasoningLevel::XHigh => 24000,
200        ReasoningLevel::Max => 32000,
201    };
202    // Anthropic requires `budget_tokens < max_tokens` with a 1024 floor; when
203    // max_tokens can't fit a 1024 budget strictly below it, disable thinking
204    // rather than emit `budget >= max_tokens` — a guaranteed 400 (#53).
205    if max_tokens <= 1024 {
206        return None;
207    }
208    let ceiling = max_tokens.saturating_sub(1024) as u32;
209    Some(proposed.min(ceiling).max(1024))
210}
211
212/// Floor for AUTO `max_tokens` when live discovery didn't resolve an output
213/// ceiling (models endpoint unreachable, or a gateway id it doesn't list).
214/// Anthropic REQUIRES `max_tokens`, so some concrete value must go on the
215/// wire; every current Claude model accepts 8192. Escape hatch for capped
216/// gateway ids: an explicit user `max_tokens`.
217const ANTHROPIC_FALLBACK_MAX_OUTPUT_TOKENS: usize = 8_192;
218
219/// Rough prompt-token estimate (≈4 chars/token, mirroring the Ollama sizing
220/// estimator); only used to bound `max_tokens` to the window room.
221fn estimate_prompt_tokens(messages: &[ChatMessage], system: Option<&str>) -> usize {
222    let chars =
223        messages.iter().map(|m| m.content.len()).sum::<usize>() + system.map_or(0, str::len);
224    chars / 4
225}
226
227/// Translate `ReasoningLevel` to Anthropic's `effort` string, gated by the
228/// catalog's per-model [`EffortCeiling`]. The `effort` parameter shapes
229/// overall token spend including text + tool calls (not just thinking); per
230/// the official effort doc it's accepted by Mythos, Fable 5, Opus 4.5–4.8,
231/// and Sonnet 4.6 — it ERRORS on Sonnet 4.5 / Haiku 4.5 and older, so those
232/// get no effort field at all.
233///
234/// Snap semantics: `XHigh` sits BETWEEN `High` and `Max` — when a model's
235/// ceiling doesn't cover the requested tier we snap DOWN to `"high"`, never
236/// UP (the user picked something below max; delivering max would over-spend
237/// their intent).
238fn adaptive_effort_for(level: ReasoningLevel, model: &str) -> Option<&'static str> {
239    use crate::models::catalog::EffortCeiling;
240    let ceiling = crate::models::catalog::lookup(model).effort_ceiling;
241    // Models that don't accept `effort` at all must get no effort field —
242    // sending one 400s the request.
243    if ceiling == EffortCeiling::None {
244        return None;
245    }
246    match level {
247        ReasoningLevel::None => None,
248        ReasoningLevel::Minimal | ReasoningLevel::Low => Some("low"),
249        ReasoningLevel::Medium => Some("medium"),
250        ReasoningLevel::High => Some("high"),
251        ReasoningLevel::XHigh => {
252            if ceiling >= EffortCeiling::XHigh {
253                Some("xhigh")
254            } else {
255                Some("high")
256            }
257        },
258        ReasoningLevel::Max => {
259            if ceiling >= EffortCeiling::Max {
260                Some("max")
261            } else {
262                Some("high")
263            }
264        },
265    }
266}
267
268/// Convert Mermaid's OpenAI-shaped tool definitions to Anthropic's flat
269/// shape. The translation is mechanical: drop the `{type: "function",
270/// function: {...}}` wrapper, rename `parameters` → `input_schema`,
271/// add `type: "custom"` so the API can disambiguate from server-managed
272/// tool types (`web_search`, `code_interpreter`, `computer_use`). The
273/// `type: "custom"` field is documented in the official SDK examples;
274/// the API also accepts omission, but explicit is forward-compatible.
275fn to_anthropic_tools(openai_tools: &[&Value]) -> Vec<Value> {
276    openai_tools
277        .iter()
278        .filter_map(|tool| {
279            let function = tool.get("function")?;
280            let name = function.get("name")?.as_str()?;
281            let description = function
282                .get("description")
283                .and_then(|d| d.as_str())
284                .unwrap_or("");
285            let input_schema = function.get("parameters").cloned().unwrap_or(json!({
286                "type": "object",
287                "properties": {}
288            }));
289            Some(json!({
290                "type": "custom",
291                "name": name,
292                "description": description,
293                "input_schema": input_schema,
294            }))
295        })
296        .collect()
297}
298
299/// Wrap harness steering in a `<system-reminder>` tag and emit it as a user
300/// turn. `coalesce_consecutive_roles` folds it into the neighbouring user turn
301/// when there is one, so this only has to get the tagging right.
302///
303/// The tag matters: untagged, the text reads as something the USER said, and
304/// models answer it, thank the user for it, or treat it as a new instruction.
305/// Standing alone after an assistant message is safe here because the
306/// continuation design deliberately carries no assistant-prefill dependency.
307fn push_system_reminder(out: &mut Vec<Value>, text: &str) {
308    out.push(json!({
309        "role": "user",
310        "content": [{
311            "type": "text",
312            "text": format!("<system-reminder>\n{text}\n</system-reminder>"),
313        }],
314    }));
315}
316
317/// Normalize a message `content` field to a block array.
318///
319/// Anthropic accepts a bare string for a lone text block and `convert_messages`
320/// emits that shape as a wire optimization, so both forms round-trip here. An
321/// empty string yields no blocks: Anthropic rejects empty text blocks, so an
322/// empty turn must contribute nothing to a merge rather than poison it.
323fn content_blocks(content: &Value) -> Vec<Value> {
324    match content {
325        Value::Array(blocks) => blocks.clone(),
326        Value::String(text) if !text.is_empty() => {
327            vec![json!({"type": "text", "text": text})]
328        },
329        _ => Vec::new(),
330    }
331}
332
333/// Collapse same-role neighbours so the payload always alternates.
334///
335/// Anthropic rejects a history whose roles do not alternate. The arms of
336/// `convert_messages` each push naively, and several ordinary histories put
337/// two same-role turns next to each other: a `Tool` batch followed by a typed
338/// user message, a model-directed reminder sitting between two user turns
339/// (a request that errored before any assistant turn committed, then a
340/// retype), or two assistant turns from an interrupted continuation. Enforcing
341/// the rule at the single exit point makes the invalid payload unrepresentable
342/// however the history is shaped, instead of asking each arm to remember it.
343///
344/// Merged turns also get their block order normalized, because Anthropic has
345/// placement rules of its own: `tool_result` blocks must lead a user turn and
346/// `thinking` must lead an assistant turn. A merge that ignored those would
347/// trade one 400 for another.
348fn coalesce_consecutive_roles(msgs: Vec<Value>) -> Vec<Value> {
349    let mut out: Vec<Value> = Vec::with_capacity(msgs.len());
350    for msg in msgs {
351        let same_role = out.last().is_some_and(|prev| prev["role"] == msg["role"]);
352        let Some(prev) = out.last_mut().filter(|_| same_role) else {
353            out.push(msg);
354            continue;
355        };
356        let incoming = content_blocks(&msg["content"]);
357        if incoming.is_empty() {
358            continue;
359        }
360        let mut blocks = content_blocks(&prev["content"]);
361        blocks.extend(incoming);
362        // Stable partition: the role's must-lead block kind first, the rest
363        // in their original relative order behind it.
364        let lead = if msg["role"] == "user" {
365            "tool_result"
366        } else {
367            "thinking"
368        };
369        let (mut leading, rest): (Vec<Value>, Vec<Value>) =
370            blocks.into_iter().partition(|b| b["type"] == lead);
371        leading.extend(rest);
372        prev["content"] = Value::Array(leading);
373    }
374    out
375}
376
377/// Translate Mermaid's `ChatMessage` history into Anthropic's
378/// `(system, messages)` shape. The system prompt comes from
379/// `ModelConfig::system_prompt`. `MessageRole::System` messages in the history
380/// are TUI affordances and stay out of `messages` — EXCEPT model-directed ones
381/// (`MessageAudience::ModelDirected`), which are harness steering the model
382/// must see and ride a tagged user block instead of being dropped.
383///
384/// Consecutive `MessageRole::Tool` messages are merged into a single
385/// user-role message with multiple `tool_result` content blocks because
386/// tool results always render as user-role. Anthropic forbids consecutive
387/// same-role messages in general, which `coalesce_consecutive_roles`
388/// guarantees on the way out — no arm below has to maintain it.
389///
390/// Assistant messages with `thinking + provider_continuation` emit a
391/// `thinking` content block paired with the text/tool_use blocks; the
392/// signature round-trips so subsequent turns don't 400.
393fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<Value>) {
394    let mut system: Option<String> = None;
395    let mut out: Vec<Value> = Vec::new();
396
397    let mut i = 0;
398    while i < messages.len() {
399        let msg = &messages[i];
400        match msg.role {
401            MessageRole::System
402                if msg.kind.audience() == MessageAudience::ModelDirected
403                    && !msg.content.is_empty() =>
404            {
405                // Anthropic has no mid-conversation system role, but this
406                // content exists to steer the model and must not be dropped
407                // (it silently was — plan reminders, context markers,
408                // auto-continue and stalled-turn nudges all vanished here).
409                // Deliver it as a tagged block on the adjacent user turn:
410                // that keeps the tail position the steering depends on and
411                // leaves the cached system prefix untouched, while the tag
412                // keeps it distinguishable from what the user actually typed.
413                push_system_reminder(&mut out, &msg.content);
414                i += 1;
415            },
416            MessageRole::System => {
417                // Use the FIRST system message as the top-level system
418                // value. Subsequent system messages (rare) are dropped.
419                if system.is_none() {
420                    system = Some(msg.content.clone());
421                }
422                i += 1;
423            },
424            MessageRole::User => {
425                let mut content_blocks: Vec<Value> = Vec::new();
426                if !msg.content.is_empty() {
427                    content_blocks.push(json!({
428                        "type": "text",
429                        "text": msg.content,
430                    }));
431                }
432                // Vision: convert each base64 image to an image block.
433                if let Some(ref images) = msg.images {
434                    for data in images {
435                        // Default media type is png — matches Mermaid's
436                        // clipboard module output. Unsupported formats
437                        // surface a clear 415 from the API.
438                        content_blocks.push(json!({
439                            "type": "image",
440                            "source": {
441                                "type": "base64",
442                                "media_type": "image/png",
443                                "data": data,
444                            },
445                        }));
446                    }
447                }
448                let content = if content_blocks.len() == 1 && content_blocks[0]["type"] == "text" {
449                    // Optimization: a single text block can serialize as
450                    // a string (Anthropic accepts both shapes; string is
451                    // shorter on the wire).
452                    content_blocks[0]["text"].clone()
453                } else if content_blocks.is_empty() {
454                    // Empty content — emit an empty string (Anthropic
455                    // requires non-empty messages, but a downstream 400
456                    // is the right signal here).
457                    json!("")
458                } else {
459                    json!(content_blocks)
460                };
461                out.push(json!({"role": "user", "content": content}));
462                i += 1;
463            },
464            MessageRole::Assistant => {
465                let mut content_blocks: Vec<Value> = Vec::new();
466                // Thinking block FIRST per Anthropic ordering rules — but ONLY
467                // when we also have its signature. The API rejects a
468                // signature-less thinking block in history with a 400
469                // invalid_request_error, which would break the agent loop. If
470                // the signature is missing (failed to persist, migrated row,
471                // etc.) drop the thinking trace: the text/tool_use blocks alone
472                // are a valid assistant turn.
473                if let (Some(thinking), Some(sig)) = (
474                    &msg.thinking,
475                    msg.provider_continuation
476                        .as_ref()
477                        .and_then(ProviderContinuation::anthropic_signature),
478                ) && !thinking.is_empty()
479                    && !sig.is_empty()
480                {
481                    content_blocks.push(json!({
482                        "type": "thinking",
483                        "thinking": thinking,
484                        "signature": sig,
485                    }));
486                } else if msg.thinking.as_deref().is_some_and(|t| !t.is_empty()) {
487                    tracing::debug!(
488                        "dropping assistant thinking block that lacks a signature (would 400)",
489                    );
490                }
491                if !msg.content.is_empty() {
492                    content_blocks.push(json!({
493                        "type": "text",
494                        "text": msg.content,
495                    }));
496                }
497                if let Some(ref tool_calls) = msg.tool_calls {
498                    for tc in tool_calls {
499                        content_blocks.push(json!({
500                            "type": "tool_use",
501                            "id": tc.id.clone().unwrap_or_default(),
502                            "name": tc.function.name,
503                            "input": tc.function.arguments,
504                        }));
505                    }
506                }
507                if content_blocks.is_empty() {
508                    // Skip empty assistant messages — an artifact of
509                    // tool-only responses where content is "" and there
510                    // were no tool_calls. Anthropic rejects empty
511                    // assistant turns.
512                    i += 1;
513                    continue;
514                }
515                out.push(json!({"role": "assistant", "content": content_blocks}));
516                i += 1;
517            },
518            MessageRole::Tool => {
519                // Merge consecutive Tool messages into one user-role
520                // message containing multiple tool_result blocks.
521                let mut tool_blocks: Vec<Value> = Vec::new();
522                while i < messages.len() && messages[i].role == MessageRole::Tool {
523                    let t = &messages[i];
524                    let tool_use_id = t.tool_call_id.clone().unwrap_or_default();
525                    tool_blocks.push(json!({
526                        "type": "tool_result",
527                        "tool_use_id": tool_use_id,
528                        "content": t.content,
529                    }));
530                    i += 1;
531                }
532                out.push(json!({"role": "user", "content": tool_blocks}));
533            },
534        }
535    }
536
537    (system, coalesce_consecutive_roles(out))
538}
539
540/// Anthropic Claude adapter.
541pub struct AnthropicAdapter {
542    client: Client,
543    api_key: String,
544    base_url: String,
545    model_name: String,
546    capabilities: ModelCapabilities,
547}
548
549impl AnthropicAdapter {
550    /// Create a new adapter. `api_key` is already resolved (caller uses
551    /// `crate::utils::resolve_api_key`).
552    pub fn new(api_key: String, model_name: String, base_url: String) -> Result<Self> {
553        let client = Client::builder()
554            .pool_max_idle_per_host(10)
555            .pool_idle_timeout(Duration::from_secs(90))
556            .tcp_keepalive(Duration::from_secs(60))
557            .connect_timeout(Duration::from_secs(10))
558            .build()
559            .map_err(|e| {
560                ModelError::Backend(BackendError::ConnectionFailed {
561                    backend: "anthropic".to_string(),
562                    url: base_url.clone(),
563                    reason: e.to_string(),
564                })
565            })?;
566
567        // All current Claude models (Sonnet 4.5+, Opus 4.5+, Haiku 4.5)
568        // support extended thinking with reasoning levels. The TUI maps
569        // `ReasoningLevel` onto the adapter's chosen format (adaptive vs
570        // legacy `budget_tokens`) inside `build_request_body`. `XHigh` is
571        // advertised on-paper; `adaptive_effort_for` snaps it to `max` or
572        // `high` based on the specific model (Opus 4.7 is the only model
573        // that accepts `xhigh` verbatim).
574        let capabilities = ModelCapabilities {
575            supports_tools: true,
576            supports_vision: true,
577            supports_reasoning: ReasoningCapability::Levels(vec![
578                ReasoningLevel::None,
579                ReasoningLevel::Low,
580                ReasoningLevel::Medium,
581                ReasoningLevel::High,
582                ReasoningLevel::Max,
583                ReasoningLevel::XHigh,
584            ]),
585            // Unknown until live discovery: the provider wrapper's
586            // `resolve_context_window` fetches real per-model limits from
587            // the Models API (cache-first). No static pins — they rot.
588            max_context_tokens: None,
589            max_output_tokens: None,
590        };
591
592        Ok(Self {
593            client,
594            api_key,
595            base_url,
596            model_name,
597            capabilities,
598        })
599    }
600
601    /// Build the JSON request body for `POST /v1/messages`.
602    fn build_request_body(&self, messages: &[ChatMessage], config: &ModelConfig) -> Value {
603        let (system_from_msgs, anthropic_messages) = convert_messages(messages);
604        // ModelConfig.system_prompt wins over any system message in the
605        // history (matches the OpenAICompatAdapter pattern). Falls back
606        // to whatever convert_messages found.
607        let system = config.system_prompt.clone().or(system_from_msgs);
608
609        // Anthropic REQUIRES `max_tokens`. AUTO (config.max_tokens == 0) sends
610        // the model's live-discovered output ceiling (`resolved_max_output`,
611        // from the Models API via the effect layer) or a conservative floor
612        // when discovery didn't resolve; an explicit user cap is honored.
613        // Both are bounded by the room the discovered window leaves after the
614        // prompt, so `input + max_tokens` can't overrun the window (a 400).
615        // `window: None` (unknown) applies no window clamp — the API's own
616        // limit is the real gate.
617        let max_tokens = resolve_output_budget(
618            &OutputBudgetInputs {
619                requested_cap: config.max_tokens,
620                window: config.resolved_context_window,
621                prompt_estimate: estimate_prompt_tokens(messages, system.as_deref()),
622                provider_max_output: Some(
623                    config
624                        .resolved_max_output
625                        .unwrap_or(ANTHROPIC_FALLBACK_MAX_OUTPUT_TOKENS),
626                ),
627                // Slop for the chars/4 estimate + structural JSON overhead.
628                margin: 1_024,
629                floor: 1,
630            },
631            OutputCapMode::Required,
632        )
633        .expect("Required mode always resolves a concrete max_tokens");
634
635        let mut body = json!({
636            "model": self.model_name,
637            "messages": anthropic_messages,
638            "max_tokens": max_tokens,
639            "stream": true,
640        });
641
642        // System prompt: emit as a typed-block array with a
643        // `cache_control: ephemeral` marker so Anthropic caches the
644        // system prompt across requests (Step 5b). Anthropic's caching
645        // gives ~90% input-cost reduction + ~2x latency improvement on
646        // cache hits, with a 1,024-token minimum that Mermaid's ~1.6k
647        // system prompt easily clears. The flat-string shape is also
648        // accepted but doesn't get cached.
649        // Step 5h: emit one or two typed-text blocks. Block 1 is the
650        // static base prompt (cached forever); block 2, when present,
651        // is MERMAID.md content (cached per-project, invalidates on
652        // file edit). Two cache_control markers means switching
653        // projects invalidates only the dynamic block — the static
654        // base stays cached across all your projects.
655        if let Some(s) = system
656            && !s.is_empty()
657        {
658            let mut blocks = vec![json!({
659                "type": "text",
660                "text": s,
661                "cache_control": {"type": "ephemeral"},
662            })];
663            if let Some(suffix) = config.dynamic_system_suffix.as_deref()
664                && !suffix.is_empty()
665            {
666                blocks.push(json!({
667                    "type": "text",
668                    "text": suffix,
669                    "cache_control": {"type": "ephemeral"},
670                }));
671            }
672            body["system"] = json!(blocks);
673        }
674
675        // Temperature: Anthropic accepts 0.0..=1.0 (NOT 0..=2 like OpenAI).
676        // Clamp defensively so a user with `temperature = 1.5` in their
677        // config doesn't get a 400. The 4.6+ adaptive line removed sampling
678        // params entirely (Opus 4.7/4.8, Fable 5, Mythos) — sending any
679        // temperature there is itself a 400, so only emit it where accepted.
680        // The 4.6+ adaptive line removed sampling params — temperature 400s
681        // on Opus 4.7/4.8, Fable 5, and Mythos (catalog column).
682        if crate::models::catalog::lookup(&self.model_name).supports_temperature {
683            let temp = config.temperature.clamp(0.0, 1.0);
684            body["temperature"] = json!(temp);
685        }
686
687        // Tool registration is the single capability boundary. Translate every
688        // registered tool; native fetch and SearXNG do not need an Ollama key.
689        let registered: Vec<&Value> = config.tools.iter().collect();
690        let mut anthropic_tools = to_anthropic_tools(&registered);
691        if !anthropic_tools.is_empty() {
692            // Mark the LAST tool with `cache_control: ephemeral` (Step
693            // 5b). Anthropic caches everything BEFORE the marker too, so
694            // a single marker on the last tool covers all tools + the
695            // system prompt above (one big cache breakpoint instead of
696            // multiple — there's a hard limit of 4 per request).
697            if let Some(last) = anthropic_tools.last_mut()
698                && let Some(obj) = last.as_object_mut()
699            {
700                obj.insert("cache_control".to_string(), json!({"type": "ephemeral"}));
701            }
702            body["tools"] = json!(anthropic_tools);
703        }
704
705        // Reasoning depth: snap onto supported levels first (defensive —
706        // current capabilities advertise the full enum, but a future
707        // model-specific shrink lands cleanly through this path).
708        let effective_reasoning = match &self.capabilities.supports_reasoning {
709            ReasoningCapability::Levels(supported) => {
710                nearest_effort(config.reasoning, supported).unwrap_or(ReasoningLevel::None)
711            },
712            _ => config.reasoning,
713        };
714
715        // Effort: applies to ALL Anthropic models — it's a separate,
716        // broader knob from `thinking` that shapes overall token spend
717        // including text + tool calls. Lives at `output_config.effort`,
718        // NOT top-level. (We were sending it top-level prior to Step
719        // 5c — silently ignored by the API, the model defaulted to
720        // `high`. Bug fix.)
721        if let Some(effort) = adaptive_effort_for(effective_reasoning, &self.model_name) {
722            body["output_config"] = json!({"effort": effort});
723        }
724
725        // Native structured output: `output_config.format` (GA, no beta
726        // header). Anthropic accepts a JSON-Schema subset (no recursion,
727        // no numeric/string constraints, `additionalProperties: false`
728        // required on objects) — arbitrary user schemas can 400, and older
729        // models reject `format` entirely; the run falls back to the
730        // prompt-driven turn and client-side validation remains the gate.
731        if let Some(schema) = &config.output_schema {
732            body["output_config"]["format"] = json!({
733                "type": "json_schema",
734                "schema": schema,
735            });
736        }
737
738        // Thinking format: per-model dispatch.
739        match thinking_format_for(&self.model_name) {
740            ThinkingFormat::Adaptive => {
741                // For adaptive, only emit `thinking` when the user
742                // actually wants thinking — adaptive models accept
743                // omission as disabled. Bundle the `display` field so
744                // Opus 4.7 surfaces reasoning chunks (it defaults to
745                // `"omitted"` — would otherwise hide the trace). The
746                // `hide_reasoning_trace` flag wires it: `omitted` for
747                // hidden, `summarized` for visible.
748                if effective_reasoning != ReasoningLevel::None {
749                    let display = if config.hide_reasoning_trace {
750                        "omitted"
751                    } else {
752                        "summarized"
753                    };
754                    body["thinking"] = json!({
755                        "type": "adaptive",
756                        "display": display,
757                    });
758                }
759            },
760            ThinkingFormat::Legacy => {
761                if let Some(budget) = legacy_budget_for(effective_reasoning, max_tokens) {
762                    body["thinking"] = json!({
763                        "type": "enabled",
764                        "budget_tokens": budget,
765                    });
766                }
767            },
768        }
769
770        body
771    }
772
773    /// POST `/v1/messages` and return the raw response.
774    /// Transparently retries on 5xx, 429, or reqwest connect failures
775    /// via `crate::models::retry::retry_transient_http`.
776    async fn send_chat(&self, body: &Value) -> Result<reqwest::Response> {
777        let url = format!("{}/messages", self.base_url.trim_end_matches('/'));
778        crate::models::retry::retry_transient_http(|| async {
779            self.client
780                .post(&url)
781                .header("x-api-key", &self.api_key)
782                .header("anthropic-version", ANTHROPIC_VERSION)
783                .header("content-type", "application/json")
784                .json(body)
785                .send()
786                .await
787                .map_err(|e| {
788                    ModelError::Backend(BackendError::ConnectionFailed {
789                        backend: "anthropic".to_string(),
790                        url: url.clone(),
791                        reason: e.to_string(),
792                    })
793                })
794        })
795        .await
796    }
797
798    /// GET `{base_url}/models/{model}` — the Models API reports each model's
799    /// real limits (`max_input_tokens` = context window, `max_tokens` =
800    /// output ceiling). A 404 is a definitive "id not in the catalog"
801    /// (gateway alias, fine-tune) → `Ok` all-`None` so callers can cache the
802    /// absence; transport/auth/5xx failures are `Err` (never cached).
803    pub async fn fetch_model_limits(&self) -> Result<ModelLimits> {
804        let url = format!(
805            "{}/models/{}",
806            self.base_url.trim_end_matches('/'),
807            self.model_name
808        );
809        let response = self
810            .client
811            .get(&url)
812            .header("x-api-key", &self.api_key)
813            .header("anthropic-version", ANTHROPIC_VERSION)
814            .send()
815            .await
816            .map_err(|e| {
817                ModelError::Backend(BackendError::ConnectionFailed {
818                    backend: "anthropic".to_string(),
819                    url: url.clone(),
820                    reason: e.to_string(),
821                })
822            })?;
823        if response.status() == reqwest::StatusCode::NOT_FOUND {
824            return Ok(ModelLimits::default());
825        }
826        if !response.status().is_success() {
827            return Err(http_error_from_response(response).await);
828        }
829        let info: AnthropicModelInfo =
830            response.json().await.map_err(|e| ModelError::ParseError {
831                message: format!("Failed to parse Anthropic model info: {}", e),
832                raw: None,
833            })?;
834        Ok(info.into())
835    }
836
837    /// Decode a single non-streaming response into `ModelResponse`.
838    /// Anthropic doesn't actually have a non-streaming path the way
839    /// OpenAI does — even non-stream requests return a Messages object
840    /// directly, not chunked. We use this when the caller passes no
841    /// stream callback.
842    async fn decode_non_streaming(&self, response: reqwest::Response) -> Result<ModelResponse> {
843        if !response.status().is_success() {
844            return Err(http_error_from_response(response).await);
845        }
846
847        let json: AnthropicResponse =
848            response.json().await.map_err(|e| ModelError::ParseError {
849                message: format!("Failed to parse Anthropic response: {}", e),
850                raw: None,
851            })?;
852
853        let mut text_acc = String::new();
854        let mut thinking_acc = String::new();
855        let mut signature: Option<String> = None;
856        let mut tool_calls: Vec<ToolCall> = Vec::new();
857
858        for block in json.content {
859            match block {
860                ContentBlockOut::Text { text } => text_acc.push_str(&text),
861                ContentBlockOut::Thinking {
862                    thinking,
863                    signature: sig,
864                } => {
865                    thinking_acc.push_str(&thinking);
866                    if sig.is_some() {
867                        signature = sig;
868                    }
869                },
870                ContentBlockOut::ToolUse { id, name, input } => {
871                    tool_calls.push(ToolCall {
872                        id: Some(id),
873                        function: FunctionCall {
874                            name,
875                            arguments: input,
876                        },
877                    });
878                },
879                ContentBlockOut::Other => {},
880            }
881        }
882
883        // Anthropic's `input_tokens` excludes both cache buckets, so the
884        // components map 1:1. Thinking tokens ride inside `output_tokens`
885        // (no separate reasoning count on this wire).
886        let prompt_tokens = json.usage.input_tokens.unwrap_or(0);
887        let completion_tokens = json.usage.output_tokens.unwrap_or(0);
888        let cache_creation = json.usage.cache_creation_input_tokens.unwrap_or(0);
889        let cache_read = json.usage.cache_read_input_tokens.unwrap_or(0);
890        let usage = TokenUsage::provider(prompt_tokens, completion_tokens)
891            .with_cache_creation(cache_creation)
892            .with_cached_input(cache_read);
893
894        let stop_reason = json.stop_reason.as_deref().map(map_anthropic_stop_reason);
895        if text_acc.is_empty()
896            && tool_calls.is_empty()
897            && stop_reason == Some(FinishReason::ContentFilter)
898        {
899            return Err(ModelError::Backend(BackendError::ProviderError {
900                provider: "anthropic".to_string(),
901                code: Some("refusal".to_string()),
902                message: "Anthropic returned no content (refusal / content filter)".to_string(),
903                debug: crate::models::error::ResponseDebugContext::default(),
904            }));
905        }
906
907        Ok(ModelResponse {
908            content: text_acc,
909            usage: Some(usage),
910            model_name: self.model_name.clone(),
911            stop_reason,
912            thinking: if thinking_acc.is_empty() {
913                None
914            } else {
915                Some(thinking_acc)
916            },
917            tool_calls: if tool_calls.is_empty() {
918                None
919            } else {
920                Some(tool_calls)
921            },
922            provider_continuation: signature
923                .map(|signature| ProviderContinuation::Anthropic { signature }),
924        })
925    }
926
927    /// Stream the response, emit typed events, return the final
928    /// `ModelResponse`. Wave 3 implementation.
929    async fn handle_stream(
930        &self,
931        response: reqwest::Response,
932        callback: StreamCallback,
933        hide_reasoning_trace: bool,
934    ) -> Result<ModelResponse> {
935        if !response.status().is_success() {
936            return Err(http_error_from_response(response).await);
937        }
938
939        let mut stream = response.bytes_stream();
940        let mut buf: Vec<u8> = Vec::new();
941
942        let mut text_acc = String::new();
943        let mut thinking_acc = String::new();
944        let mut signature_acc: Option<String> = None;
945        let mut tool_calls_done: Vec<ToolCall> = Vec::new();
946        let mut truncated = false;
947        let mut prompt_tokens: usize = 0;
948        let mut completion_tokens: usize = 0;
949        let mut cache_creation_tokens: usize = 0;
950        let mut cache_read_tokens: usize = 0;
951        let mut stop_reason: Option<FinishReason> = None;
952        // F56: set when the terminal `message_stop` frame is observed, so an
953        // abnormal close (connection dropped before any terminal frame) can be
954        // told apart from a clean completion after the loop.
955        let mut saw_message_stop = false;
956        // Per-block-index accumulators. Anthropic emits content_block_*
957        // events tagged with an `index` field; multiple blocks (text +
958        // thinking + tool_use) interleave, so we track each by index.
959        let mut blocks: HashMap<usize, BlockAccumulator> = HashMap::new();
960
961        'stream: while let Some(chunk_result) = stream.next().await {
962            let chunk = chunk_result.map_err(|e| ModelError::StreamError(e.to_string()))?;
963            // Bound SSE reassembly: a server that streams bytes but never emits
964            // the `\n\n` event separator would otherwise grow `buf` without
965            // bound. At this point `buf` holds only the un-terminated residue
966            // from the previous drain, so this never trips on legitimately
967            // buffered complete events (#50).
968            if buf.len() > crate::constants::MAX_SSE_BUFFER_BYTES {
969                return Err(ModelError::StreamError(format!(
970                    "SSE stream exceeded {} byte reassembly cap without a complete event",
971                    crate::constants::MAX_SSE_BUFFER_BYTES
972                )));
973            }
974            buf.extend_from_slice(&chunk);
975
976            for payload in drain_sse_events(&mut buf) {
977                let parsed: Value = match serde_json::from_str(&payload) {
978                    Ok(v) => v,
979                    Err(e) => {
980                        return Err(ModelError::ParseError {
981                            message: format!("Failed to parse Anthropic stream chunk: {}", e),
982                            raw: Some(payload),
983                        });
984                    },
985                };
986                let event_type = parsed.get("type").and_then(|v| v.as_str()).unwrap_or("");
987
988                match event_type {
989                    "message_start" => {
990                        if let Some(input) = parsed
991                            .pointer("/message/usage/input_tokens")
992                            .and_then(|v| v.as_u64())
993                        {
994                            prompt_tokens = input as usize;
995                        }
996                        if let Some(cache_creation) = parsed
997                            .pointer("/message/usage/cache_creation_input_tokens")
998                            .and_then(|v| v.as_u64())
999                        {
1000                            cache_creation_tokens = cache_creation as usize;
1001                        }
1002                        if let Some(cache_read) = parsed
1003                            .pointer("/message/usage/cache_read_input_tokens")
1004                            .and_then(|v| v.as_u64())
1005                        {
1006                            cache_read_tokens = cache_read as usize;
1007                        }
1008                    },
1009                    "content_block_start" => {
1010                        let index =
1011                            parsed.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
1012                        let block = parsed.get("content_block");
1013                        let block_type = block
1014                            .and_then(|b| b.get("type"))
1015                            .and_then(|t| t.as_str())
1016                            .unwrap_or("");
1017                        let acc = match block_type {
1018                            "text" => BlockAccumulator::Text(String::new()),
1019                            "thinking" => BlockAccumulator::Thinking {
1020                                content: String::new(),
1021                                signature: None,
1022                            },
1023                            "tool_use" => {
1024                                let id = block
1025                                    .and_then(|b| b.get("id"))
1026                                    .and_then(|v| v.as_str())
1027                                    .unwrap_or("")
1028                                    .to_string();
1029                                let name = block
1030                                    .and_then(|b| b.get("name"))
1031                                    .and_then(|v| v.as_str())
1032                                    .unwrap_or("")
1033                                    .to_string();
1034                                BlockAccumulator::ToolUse {
1035                                    id,
1036                                    name,
1037                                    input_buf: String::new(),
1038                                }
1039                            },
1040                            // Unknown block types (e.g., server-tool
1041                            // results we don't request) — track as inert.
1042                            _ => BlockAccumulator::Other,
1043                        };
1044                        blocks.insert(index, acc);
1045                    },
1046                    "content_block_delta" => {
1047                        let index =
1048                            parsed.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
1049                        let delta = parsed.get("delta");
1050                        let delta_type = delta
1051                            .and_then(|d| d.get("type"))
1052                            .and_then(|t| t.as_str())
1053                            .unwrap_or("");
1054                        let Some(acc) = blocks.get_mut(&index) else {
1055                            continue;
1056                        };
1057                        match (acc, delta_type) {
1058                            (BlockAccumulator::Text(buf_s), "text_delta") => {
1059                                let text = delta
1060                                    .and_then(|d| d.get("text"))
1061                                    .and_then(|v| v.as_str())
1062                                    .unwrap_or("");
1063                                if !text.is_empty() && !truncated {
1064                                    callback(StreamEvent::Text(text.to_string()));
1065                                    push_capped(buf_s, text, &mut truncated, MAX_RESPONSE_CHARS);
1066                                }
1067                            },
1068                            (
1069                                BlockAccumulator::Thinking { content, signature },
1070                                "thinking_delta",
1071                            ) => {
1072                                let text = delta
1073                                    .and_then(|d| d.get("thinking"))
1074                                    .and_then(|v| v.as_str())
1075                                    .unwrap_or("");
1076                                if !text.is_empty() && !truncated {
1077                                    if !hide_reasoning_trace {
1078                                        // #9: this is intentionally `None` here —
1079                                        // `signature_delta` arrives AFTER the
1080                                        // thinking deltas, so streamed reasoning
1081                                        // chunks can't carry it. The final
1082                                        // `ModelResponse.provider_continuation`
1083                                        // (captured at block stop) is correct and
1084                                        // is what round-trips; streamed chunks are
1085                                        // display-only.
1086                                        callback(StreamEvent::Reasoning(ReasoningChunk {
1087                                            text: text.to_string(),
1088                                            signature: signature.clone(),
1089                                        }));
1090                                    }
1091                                    push_capped(content, text, &mut truncated, MAX_RESPONSE_CHARS);
1092                                }
1093                            },
1094                            (BlockAccumulator::Thinking { signature, .. }, "signature_delta") => {
1095                                let sig = delta
1096                                    .and_then(|d| d.get("signature"))
1097                                    .and_then(|v| v.as_str())
1098                                    .unwrap_or("");
1099                                if !sig.is_empty() {
1100                                    *signature = Some(sig.to_string());
1101                                }
1102                            },
1103                            (BlockAccumulator::ToolUse { input_buf, .. }, "input_json_delta") => {
1104                                let frag = delta
1105                                    .and_then(|d| d.get("partial_json"))
1106                                    .and_then(|v| v.as_str())
1107                                    .unwrap_or("");
1108                                push_tool_arg(input_buf, frag);
1109                            },
1110                            _ => {
1111                                // delta type doesn't match block type
1112                                // (shouldn't happen per spec). Ignore.
1113                            },
1114                        }
1115                    },
1116                    "content_block_stop" => {
1117                        let index =
1118                            parsed.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
1119                        if let Some(acc) = blocks.remove(&index) {
1120                            finalize_block(
1121                                acc,
1122                                &mut text_acc,
1123                                &mut thinking_acc,
1124                                &mut signature_acc,
1125                                &mut tool_calls_done,
1126                                &callback,
1127                            );
1128                        }
1129                    },
1130                    "message_delta" => {
1131                        // Cumulative output tokens — overwrite each time.
1132                        if let Some(out) = parsed
1133                            .pointer("/usage/output_tokens")
1134                            .and_then(|v| v.as_u64())
1135                        {
1136                            completion_tokens = out as usize;
1137                        }
1138                        // The terminal stop_reason rides on `message_delta`.
1139                        if let Some(sr) = parsed
1140                            .pointer("/delta/stop_reason")
1141                            .and_then(|v| v.as_str())
1142                        {
1143                            stop_reason = Some(map_anthropic_stop_reason(sr));
1144                        }
1145                    },
1146                    "message_stop" => {
1147                        // Stream complete — record the terminal frame (F56)
1148                        // before breaking. Break the OUTER stream loop, not just
1149                        // this SSE-event `for` — otherwise the adapter keeps
1150                        // awaiting `stream.next()` until the connection actually
1151                        // closes, which can stall on a kept-alive/proxied body
1152                        // (#138). The `Done` event is emitted below after the loop.
1153                        saw_message_stop = true;
1154                        break 'stream;
1155                    },
1156                    "error" => {
1157                        let err_type = parsed
1158                            .pointer("/error/type")
1159                            .and_then(|v| v.as_str())
1160                            .unwrap_or("api_error");
1161                        let err_msg = parsed
1162                            .pointer("/error/message")
1163                            .and_then(|v| v.as_str())
1164                            .unwrap_or("Anthropic stream error");
1165                        return Err(ModelError::Backend(BackendError::ProviderError {
1166                            provider: "anthropic".to_string(),
1167                            code: Some(err_type.to_string()),
1168                            message: err_msg.to_string(),
1169                            debug: crate::models::error::ResponseDebugContext::default(),
1170                        }));
1171                    },
1172                    "ping" | "" => {
1173                        // Heartbeats and untyped events — ignore.
1174                    },
1175                    _ => {
1176                        // Unknown event type — log via debug, ignore.
1177                        tracing::debug!("Anthropic: unknown event type: {}", event_type);
1178                    },
1179                }
1180            }
1181        }
1182
1183        // F56: tell a genuinely abnormal close (the connection dropped before
1184        // ANY terminal frame) apart from a clean completion. If we saw neither
1185        // `message_stop` nor a `message_delta` `stop_reason`, the turn is
1186        // truncated — returning a clean `Ok` (with `stop_reason: None`) would be
1187        // indistinguishable from a real completion, and the open-block drain
1188        // below would even hand back partial content as if finished. Surface a
1189        // stream error instead. A `max_tokens` truncation set a real
1190        // `stop_reason`, so it does NOT trip this and is preserved.
1191        if stream_closed_abnormally(saw_message_stop, stop_reason.as_ref()) {
1192            return Err(ModelError::StreamError(
1193                "Anthropic stream closed before any terminal frame (message_stop / \
1194                 message_delta stop_reason); the connection was likely dropped \
1195                 mid-response"
1196                    .to_string(),
1197            ));
1198        }
1199
1200        // The stream may end without a `message_stop` but WITH a `message_delta`
1201        // `stop_reason` (e.g. a proxy sends `Connection: close` after the final
1202        // delta). That's a complete turn missing only its framing event, so
1203        // finalize any blocks still open — a fully-streamed `tool_use` or text
1204        // block isn't silently dropped, and the agent doesn't "forget" the call.
1205        if !blocks.is_empty() {
1206            tracing::warn!(
1207                open_blocks = blocks.len(),
1208                "Anthropic stream ended without message_stop; draining open blocks"
1209            );
1210            let mut remaining: Vec<(usize, BlockAccumulator)> = blocks.into_iter().collect();
1211            remaining.sort_by_key(|(idx, _)| *idx);
1212            for (_idx, acc) in remaining {
1213                finalize_block(
1214                    acc,
1215                    &mut text_acc,
1216                    &mut thinking_acc,
1217                    &mut signature_acc,
1218                    &mut tool_calls_done,
1219                    &callback,
1220                );
1221            }
1222        }
1223
1224        // F3: `Done` is emitted by the v0.7 wrapper from the returned
1225        // `ModelResponse` so the `provider_continuation` round-trips. If we
1226        // emitted it here, the reducer would commit the assistant
1227        // message on our signature-less Done and drop the real one.
1228
1229        // A refusal / content block that produced no usable output is an
1230        // error, not an empty success (matches the non-streaming path).
1231        if text_acc.is_empty()
1232            && tool_calls_done.is_empty()
1233            && stop_reason == Some(FinishReason::ContentFilter)
1234        {
1235            return Err(ModelError::Backend(BackendError::ProviderError {
1236                provider: "anthropic".to_string(),
1237                code: Some("refusal".to_string()),
1238                message: "Anthropic returned no content (refusal / content filter)".to_string(),
1239                debug: crate::models::error::ResponseDebugContext::default(),
1240            }));
1241        }
1242
1243        Ok(ModelResponse {
1244            content: text_acc,
1245            usage: Some(
1246                TokenUsage::provider(prompt_tokens, completion_tokens)
1247                    .with_cache_creation(cache_creation_tokens)
1248                    .with_cached_input(cache_read_tokens),
1249            ),
1250            model_name: self.model_name.clone(),
1251            stop_reason,
1252            thinking: if thinking_acc.is_empty() {
1253                None
1254            } else {
1255                Some(thinking_acc)
1256            },
1257            tool_calls: if tool_calls_done.is_empty() {
1258                None
1259            } else {
1260                Some(tool_calls_done)
1261            },
1262            provider_continuation: signature_acc
1263                .map(|signature| ProviderContinuation::Anthropic { signature }),
1264        })
1265    }
1266}
1267
1268#[async_trait]
1269impl Model for AnthropicAdapter {
1270    fn name(&self) -> &str {
1271        &self.model_name
1272    }
1273
1274    fn capabilities(&self) -> &ModelCapabilities {
1275        &self.capabilities
1276    }
1277
1278    /// Anthropic DOES expose `GET /v1/models` these days — mermaid uses the
1279    /// per-model variant for limit discovery (`fetch_model_limits`) — but
1280    /// interactive model listing stays registry/config-driven, so this stub
1281    /// remains Unsupported rather than growing a third listing path.
1282    async fn list_models(&self) -> Result<Vec<String>> {
1283        Err(ModelError::Unsupported {
1284            feature: "list_models (anthropic)".to_string(),
1285        })
1286    }
1287
1288    async fn chat(
1289        &self,
1290        messages: &[ChatMessage],
1291        config: &ModelConfig,
1292        callback: Option<StreamCallback>,
1293    ) -> Result<ModelResponse> {
1294        let mut body = self.build_request_body(messages, config);
1295        let stream = callback.is_some();
1296        if !stream {
1297            body["stream"] = json!(false);
1298        }
1299        let response = self.send_chat(&body).await?;
1300        if let Some(cb) = callback {
1301            self.handle_stream(response, cb, config.hide_reasoning_trace)
1302                .await
1303        } else {
1304            self.decode_non_streaming(response).await
1305        }
1306    }
1307}
1308
1309// ===== Wire types =====
1310
1311/// `GET /v1/models/{id}` response — only the limit fields matter here.
1312/// `max_input_tokens` is the context window; `max_tokens` is the per-response
1313/// output ceiling. Both `#[serde(default)]` so an API that stops reporting
1314/// one degrades to `None` (unknown) instead of a parse error.
1315#[derive(Debug, Default, Deserialize)]
1316struct AnthropicModelInfo {
1317    #[serde(default)]
1318    max_input_tokens: Option<usize>,
1319    #[serde(default)]
1320    max_tokens: Option<usize>,
1321}
1322
1323impl From<AnthropicModelInfo> for ModelLimits {
1324    fn from(info: AnthropicModelInfo) -> Self {
1325        ModelLimits {
1326            max_context_tokens: info.max_input_tokens,
1327            max_output_tokens: info.max_tokens,
1328        }
1329    }
1330}
1331
1332/// Non-streaming response shape (`POST /v1/messages` without `stream`).
1333#[derive(Debug, Deserialize)]
1334struct AnthropicResponse {
1335    content: Vec<ContentBlockOut>,
1336    #[serde(default)]
1337    usage: UsageOut,
1338    #[serde(default)]
1339    stop_reason: Option<String>,
1340}
1341
1342#[derive(Debug, Default, Deserialize)]
1343struct UsageOut {
1344    #[serde(default)]
1345    input_tokens: Option<usize>,
1346    #[serde(default)]
1347    output_tokens: Option<usize>,
1348    #[serde(default)]
1349    cache_creation_input_tokens: Option<usize>,
1350    #[serde(default)]
1351    cache_read_input_tokens: Option<usize>,
1352}
1353
1354/// Output content blocks Anthropic returns (subset we care about).
1355#[derive(Debug, Deserialize)]
1356#[serde(tag = "type", rename_all = "snake_case")]
1357enum ContentBlockOut {
1358    Text {
1359        text: String,
1360    },
1361    Thinking {
1362        thinking: String,
1363        #[serde(default)]
1364        signature: Option<String>,
1365    },
1366    ToolUse {
1367        id: String,
1368        name: String,
1369        input: Value,
1370    },
1371    /// Catch-all for content types we don't model (server-tool results,
1372    /// future block types). Falls through cleanly via serde's untagged
1373    /// enum semantics. We use a struct variant rather than `#[serde(other)]`
1374    /// because the latter only works on unit variants.
1375    #[serde(other)]
1376    Other,
1377}
1378
1379/// Per-block-index streaming accumulator. Anthropic interleaves
1380/// content_block events for multiple blocks (text + thinking + tool_use),
1381/// indexed by `index`. We keep one accumulator per active block.
1382#[derive(Debug)]
1383enum BlockAccumulator {
1384    Text(String),
1385    Thinking {
1386        content: String,
1387        signature: Option<String>,
1388    },
1389    ToolUse {
1390        id: String,
1391        name: String,
1392        input_buf: String,
1393    },
1394    /// Catch-all for unknown content block types — e.g., server-tool
1395    /// results we never requested. Ignored on the way in and out.
1396    Other,
1397}
1398
1399/// Translate a non-success HTTP response into a structured `ModelError`.
1400async fn http_error_from_response(response: reqwest::Response) -> ModelError {
1401    let status = response.status().as_u16();
1402    let debug = crate::models::error::ResponseDebugContext::from_headers(response.headers());
1403    let body = response
1404        .text()
1405        .await
1406        .unwrap_or_else(|_| "Unknown error".to_string());
1407    // Try to parse Anthropic's error JSON shape so the user sees the
1408    // actual error message rather than a raw JSON blob.
1409    if let Ok(parsed) = serde_json::from_str::<Value>(&body)
1410        && let (Some(err_type), Some(err_msg)) = (
1411            parsed.pointer("/error/type").and_then(|v| v.as_str()),
1412            parsed.pointer("/error/message").and_then(|v| v.as_str()),
1413        )
1414    {
1415        // 400 invalid_request_error mentioning thinking is the
1416        // signature round-trip going wrong — flag it specifically so
1417        // future debugging starts at the right place.
1418        if status == 400 && err_msg.to_lowercase().contains("thinking") {
1419            return ModelError::Backend(BackendError::ProviderError {
1420                provider: "anthropic".to_string(),
1421                code: Some(err_type.to_string()),
1422                message: format!(
1423                    "{} (thinking-block round-trip failed; this is a Mermaid bug — \
1424                         please open an issue with the conversation that triggered it)",
1425                    err_msg
1426                ),
1427                debug: debug.clone(),
1428            });
1429        }
1430        return ModelError::Backend(BackendError::ProviderError {
1431            provider: "anthropic".to_string(),
1432            code: Some(err_type.to_string()),
1433            message: err_msg.to_string(),
1434            debug: debug.clone(),
1435        });
1436    }
1437    ModelError::Backend(BackendError::HttpError {
1438        status,
1439        message: body,
1440        debug,
1441    })
1442}
1443
1444#[cfg(test)]
1445mod tests {
1446    use super::*;
1447
1448    fn has_thinking_block(msgs: &[serde_json::Value]) -> bool {
1449        msgs.iter().any(|msg| {
1450            msg.get("content")
1451                .and_then(|c| c.as_array())
1452                .map(|blocks| {
1453                    blocks
1454                        .iter()
1455                        .any(|b| b.get("type").and_then(|t| t.as_str()) == Some("thinking"))
1456                })
1457                .unwrap_or(false)
1458        })
1459    }
1460
1461    #[test]
1462    fn model_info_parses_documented_limit_fields() {
1463        // Documented `GET /v1/models/{id}` shape (Models API): the limit
1464        // fields ride alongside identity fields we ignore.
1465        let body = r#"{
1466            "id": "claude-sonnet-4-6",
1467            "type": "model",
1468            "display_name": "Claude Sonnet 4.6",
1469            "created_at": "2026-02-01T00:00:00Z",
1470            "max_input_tokens": 1000000,
1471            "max_tokens": 128000
1472        }"#;
1473        let info: AnthropicModelInfo = serde_json::from_str(body).expect("parse");
1474        let limits: ModelLimits = info.into();
1475        assert_eq!(limits.max_context_tokens, Some(1_000_000));
1476        assert_eq!(limits.max_output_tokens, Some(128_000));
1477    }
1478
1479    #[test]
1480    fn model_info_missing_limit_fields_degrade_to_none() {
1481        // An API that stops reporting limits must degrade to unknown, not a
1482        // parse error (which would be treated as a failed — uncached — fetch).
1483        let body = r#"{"id": "claude-sonnet-4-6", "type": "model"}"#;
1484        let info: AnthropicModelInfo = serde_json::from_str(body).expect("parse");
1485        let limits: ModelLimits = info.into();
1486        assert_eq!(limits.max_context_tokens, None);
1487        assert_eq!(limits.max_output_tokens, None);
1488    }
1489
1490    #[test]
1491    fn maps_anthropic_stop_reasons() {
1492        assert_eq!(map_anthropic_stop_reason("end_turn"), FinishReason::Stop);
1493        assert_eq!(
1494            map_anthropic_stop_reason("max_tokens"),
1495            FinishReason::Length
1496        );
1497        assert_eq!(map_anthropic_stop_reason("tool_use"), FinishReason::ToolUse);
1498        assert_eq!(
1499            map_anthropic_stop_reason("refusal"),
1500            FinishReason::ContentFilter
1501        );
1502    }
1503
1504    #[test]
1505    fn stream_closed_abnormally_distinguishes_drop_from_completion() {
1506        // F56: closed before ANY terminal frame (no message_stop, no
1507        // message_delta stop_reason) → abnormal, surfaced as a stream error.
1508        assert!(stream_closed_abnormally(false, None));
1509        // Clean completion: message_stop observed.
1510        assert!(!stream_closed_abnormally(true, Some(&FinishReason::Stop)));
1511        // Dropped after message_delta (stop_reason set) but before message_stop:
1512        // we have the real finish reason, so it's complete — NOT abnormal (the
1513        // open-block drain then recovers any fully-streamed tool_use/text).
1514        assert!(!stream_closed_abnormally(false, Some(&FinishReason::Stop)));
1515        // CRUCIAL: a max_tokens truncation arrives as a real stop_reason
1516        // (Length) — it must NOT be misclassified as an abnormal close.
1517        assert!(!stream_closed_abnormally(
1518            false,
1519            Some(&FinishReason::Length)
1520        ));
1521        // Defensive: a message_stop frame is terminal even with no stop_reason.
1522        assert!(!stream_closed_abnormally(true, None));
1523    }
1524
1525    #[test]
1526    fn finalize_block_recovers_tool_use() {
1527        // #4: a fully-streamed tool_use block must be recovered even when it's
1528        // drained outside `content_block_stop` (the mid-cutoff path).
1529        let events: std::sync::Arc<std::sync::Mutex<Vec<StreamEvent>>> =
1530            std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1531        let ev = events.clone();
1532        let cb: StreamCallback = std::sync::Arc::new(move |e| ev.lock().unwrap().push(e));
1533        let mut text = String::new();
1534        let mut thinking = String::new();
1535        let mut sig = None;
1536        let mut tools = Vec::new();
1537        finalize_block(
1538            BlockAccumulator::ToolUse {
1539                id: "tu_1".to_string(),
1540                name: "read_file".to_string(),
1541                input_buf: r#"{"path":"a.txt"}"#.to_string(),
1542            },
1543            &mut text,
1544            &mut thinking,
1545            &mut sig,
1546            &mut tools,
1547            &cb,
1548        );
1549        assert_eq!(tools.len(), 1);
1550        assert_eq!(tools[0].function.name, "read_file");
1551        assert_eq!(events.lock().unwrap().len(), 1);
1552    }
1553
1554    #[test]
1555    fn thinking_block_requires_signature() {
1556        // H22: a thinking block without a signature 400s the next request, so
1557        // it must be dropped; with a signature it must be emitted.
1558        let mut unsigned = ChatMessage::assistant("answer");
1559        unsigned.thinking = Some("private reasoning".to_string());
1560        let (_sys, msgs) = convert_messages(&[unsigned]);
1561        assert!(
1562            !has_thinking_block(&msgs),
1563            "unsigned thinking must be dropped"
1564        );
1565
1566        let mut signed = ChatMessage::assistant("answer").with_provider_continuation(
1567            ProviderContinuation::Anthropic {
1568                signature: "sig123".to_string(),
1569            },
1570        );
1571        signed.thinking = Some("private reasoning".to_string());
1572        let (_sys, msgs) = convert_messages(&[signed]);
1573        assert!(has_thinking_block(&msgs), "signed thinking must be present");
1574    }
1575
1576    fn test_adapter() -> AnthropicAdapter {
1577        AnthropicAdapter::new(
1578            "test-key".to_string(),
1579            "claude-sonnet-4-6".to_string(),
1580            "https://api.anthropic.com/v1".to_string(),
1581        )
1582        .expect("adapter constructs")
1583    }
1584
1585    // --- Helpers ---
1586
1587    #[test]
1588    fn thinking_format_dispatch() {
1589        assert_eq!(
1590            thinking_format_for("claude-opus-4-7"),
1591            ThinkingFormat::Adaptive
1592        );
1593        assert_eq!(
1594            thinking_format_for("claude-sonnet-4-6"),
1595            ThinkingFormat::Adaptive
1596        );
1597        assert_eq!(
1598            thinking_format_for("claude-opus-4-6"),
1599            ThinkingFormat::Adaptive
1600        );
1601        // Current models that the old table misclassified as Legacy → 400.
1602        assert_eq!(
1603            thinking_format_for("claude-opus-4-8"),
1604            ThinkingFormat::Adaptive
1605        );
1606        assert_eq!(
1607            thinking_format_for("claude-fable-5"),
1608            ThinkingFormat::Adaptive
1609        );
1610        assert_eq!(
1611            thinking_format_for("claude-sonnet-4-5"),
1612            ThinkingFormat::Legacy
1613        );
1614        assert_eq!(
1615            thinking_format_for("claude-opus-4-5"),
1616            ThinkingFormat::Legacy
1617        );
1618        assert_eq!(
1619            thinking_format_for("claude-haiku-4-5"),
1620            ThinkingFormat::Legacy
1621        );
1622        // Case insensitive.
1623        assert_eq!(
1624            thinking_format_for("Claude-Opus-4-7-Special"),
1625            ThinkingFormat::Adaptive
1626        );
1627        // Unknown defaults to Legacy.
1628        assert_eq!(
1629            thinking_format_for("claude-future-99"),
1630            ThinkingFormat::Legacy
1631        );
1632    }
1633
1634    #[test]
1635    fn legacy_budget_clamps_to_max_tokens() {
1636        // High level normally maps to 16000; with max_tokens=8000 we
1637        // clamp to 8000 - 1024 = 6976. The result also has a 1024 floor.
1638        assert_eq!(legacy_budget_for(ReasoningLevel::High, 8000), Some(6976));
1639        // Low level (2048) fits within max_tokens (4096), no clamp.
1640        assert_eq!(legacy_budget_for(ReasoningLevel::Low, 4096), Some(2048));
1641        // None → None.
1642        assert_eq!(legacy_budget_for(ReasoningLevel::None, 4096), None);
1643        // Max with generous max_tokens → 32000.
1644        assert_eq!(legacy_budget_for(ReasoningLevel::Max, 64000), Some(32000));
1645        // Max with low max_tokens → clamped, but not below 1024.
1646        assert_eq!(legacy_budget_for(ReasoningLevel::Max, 2000), Some(1024));
1647        // #53: max_tokens at/below the 1024 floor can't fit a budget strictly
1648        // below it → None (a budget >= max_tokens is a guaranteed 400).
1649        assert_eq!(legacy_budget_for(ReasoningLevel::High, 1024), None);
1650        assert_eq!(legacy_budget_for(ReasoningLevel::Max, 512), None);
1651        // Just above the floor: a budget is returned and is strictly < max_tokens.
1652        let b = legacy_budget_for(ReasoningLevel::High, 2048).expect("fits");
1653        assert!(b < 2048, "budget {b} must be < max_tokens");
1654    }
1655
1656    #[test]
1657    fn adaptive_effort_per_level() {
1658        let m = "claude-sonnet-4-6";
1659        assert_eq!(adaptive_effort_for(ReasoningLevel::None, m), None);
1660        assert_eq!(adaptive_effort_for(ReasoningLevel::Minimal, m), Some("low"));
1661        assert_eq!(adaptive_effort_for(ReasoningLevel::Low, m), Some("low"));
1662        assert_eq!(
1663            adaptive_effort_for(ReasoningLevel::Medium, m),
1664            Some("medium")
1665        );
1666        assert_eq!(adaptive_effort_for(ReasoningLevel::High, m), Some("high"));
1667        // Sonnet 4.6 supports `max` per the effort-doc table.
1668        assert_eq!(adaptive_effort_for(ReasoningLevel::Max, m), Some("max"));
1669    }
1670
1671    /// Opus 4.7 supports the `xhigh` effort tier (between `high` and
1672    /// `max` in our enum; Anthropic exposes it as a distinct string on
1673    /// the wire). Other models would 400 on `xhigh`, so the gate is
1674    /// Opus 4.7-only.
1675    #[test]
1676    fn adaptive_effort_uses_xhigh_on_opus_4_7_for_xhigh() {
1677        assert_eq!(
1678            adaptive_effort_for(ReasoningLevel::XHigh, "claude-opus-4-7"),
1679            Some("xhigh")
1680        );
1681        // Opus 4.7 also supports `max` — verify Max still maps to max
1682        // (distinct tier from xhigh).
1683        assert_eq!(
1684            adaptive_effort_for(ReasoningLevel::Max, "claude-opus-4-7"),
1685            Some("max")
1686        );
1687        // XHigh on Opus 4.6 (no xhigh support): XHigh sits between High
1688        // and Max in our enum, so we snap DOWN to "high" — never up to
1689        // "max". Upgrading would over-spend the user's explicit choice.
1690        assert_eq!(
1691            adaptive_effort_for(ReasoningLevel::XHigh, "claude-opus-4-6"),
1692            Some("high")
1693        );
1694    }
1695
1696    /// Effort gating on the 4.5 family (RC-H). Sonnet 4.5 / Haiku 4.5 don't
1697    /// accept the `effort` parameter at all — it 400s — so they must get no
1698    /// effort field (`None`). Opus 4.5 accepts effort but not `max`, so `Max`
1699    /// and `XHigh` snap down to `high`.
1700    #[test]
1701    fn adaptive_effort_gates_max_on_4_5_family() {
1702        for m in ["claude-sonnet-4-5", "claude-haiku-4-5"] {
1703            assert_eq!(
1704                adaptive_effort_for(ReasoningLevel::Max, m),
1705                None,
1706                "model {} does not support the effort parameter at all",
1707                m
1708            );
1709            assert_eq!(
1710                adaptive_effort_for(ReasoningLevel::XHigh, m),
1711                None,
1712                "model {} does not support the effort parameter at all",
1713                m
1714            );
1715        }
1716        // Opus 4.5: supports effort but not `max` → snap down to `high`.
1717        assert_eq!(
1718            adaptive_effort_for(ReasoningLevel::Max, "claude-opus-4-5"),
1719            Some("high"),
1720            "Opus 4.5 should snap Max → high (no max effort support)"
1721        );
1722        assert_eq!(
1723            adaptive_effort_for(ReasoningLevel::XHigh, "claude-opus-4-5"),
1724            Some("high"),
1725            "Opus 4.5 should snap XHigh → high"
1726        );
1727    }
1728
1729    // --- Tool translation ---
1730
1731    #[test]
1732    fn tool_translation_drops_function_wrapper() {
1733        let openai_tool = json!({
1734            "type": "function",
1735            "function": {
1736                "name": "read_file",
1737                "description": "Read a file",
1738                "parameters": {
1739                    "type": "object",
1740                    "properties": {"path": {"type": "string"}},
1741                    "required": ["path"]
1742                }
1743            }
1744        });
1745        let translated = to_anthropic_tools(&[&openai_tool]);
1746        assert_eq!(translated.len(), 1);
1747        assert_eq!(translated[0]["name"], "read_file");
1748        assert_eq!(translated[0]["description"], "Read a file");
1749        // Step 5c: `type: "custom"` is added explicitly so the API can
1750        // disambiguate from server-managed tool types.
1751        assert_eq!(translated[0]["type"], "custom");
1752        // The OpenAI `{type: "function", function: {...}}` wrapper is
1753        // gone — only the inner fields plus `type: "custom"` remain.
1754        assert!(translated[0].get("function").is_none());
1755        // `parameters` was renamed to `input_schema`.
1756        assert_eq!(
1757            translated[0]["input_schema"]["properties"]["path"]["type"],
1758            "string"
1759        );
1760    }
1761
1762    #[test]
1763    fn tool_translation_handles_missing_description() {
1764        let openai_tool = json!({
1765            "type": "function",
1766            "function": {
1767                "name": "no_description_tool",
1768                "parameters": {"type": "object", "properties": {}}
1769            }
1770        });
1771        let translated = to_anthropic_tools(&[&openai_tool]);
1772        assert_eq!(translated[0]["description"], "");
1773    }
1774
1775    // --- Message conversion ---
1776
1777    #[test]
1778    fn convert_messages_extracts_system_only_first() {
1779        let messages = vec![
1780            ChatMessage::system("You are helpful."),
1781            ChatMessage::user("Hello"),
1782            ChatMessage::system("This second system message is dropped."),
1783        ];
1784        let (system, msgs) = convert_messages(&messages);
1785        assert_eq!(system.as_deref(), Some("You are helpful."));
1786        // Only the user message ends up in the messages array.
1787        assert_eq!(msgs.len(), 1);
1788        assert_eq!(msgs[0]["role"], "user");
1789    }
1790
1791    #[test]
1792    fn convert_messages_merges_consecutive_tool_messages() {
1793        // Agent loop produces: assistant(tool_calls) → tool → tool → tool
1794        // → assistant(text). The three Tool messages must collapse into
1795        // ONE user-role message with three tool_result blocks so the
1796        // role-alternation rule isn't violated.
1797        let messages = vec![
1798            ChatMessage::user("Read three files"),
1799            {
1800                let mut m = ChatMessage::assistant("I will read them.");
1801                m.tool_calls = Some(vec![
1802                    ToolCall {
1803                        id: Some("c1".to_string()),
1804                        function: FunctionCall {
1805                            name: "read_file".into(),
1806                            arguments: json!({"path": "a.txt"}),
1807                        },
1808                    },
1809                    ToolCall {
1810                        id: Some("c2".to_string()),
1811                        function: FunctionCall {
1812                            name: "read_file".into(),
1813                            arguments: json!({"path": "b.txt"}),
1814                        },
1815                    },
1816                    ToolCall {
1817                        id: Some("c3".to_string()),
1818                        function: FunctionCall {
1819                            name: "read_file".into(),
1820                            arguments: json!({"path": "c.txt"}),
1821                        },
1822                    },
1823                ]);
1824                m
1825            },
1826            ChatMessage::tool("c1", "read_file", "contents of a"),
1827            ChatMessage::tool("c2", "read_file", "contents of b"),
1828            ChatMessage::tool("c3", "read_file", "contents of c"),
1829            ChatMessage::assistant("Done."),
1830        ];
1831        let (_, msgs) = convert_messages(&messages);
1832        // Sequence after merge: user → assistant(text+tool_use*3) →
1833        // user(tool_result*3) → assistant(text). 4 messages.
1834        assert_eq!(msgs.len(), 4);
1835        assert_eq!(msgs[0]["role"], "user");
1836        assert_eq!(msgs[1]["role"], "assistant");
1837        assert_eq!(msgs[2]["role"], "user");
1838        assert_eq!(msgs[3]["role"], "assistant");
1839        // The tool-results message is an array of three tool_result blocks.
1840        let tool_results = msgs[2]["content"].as_array().expect("array");
1841        assert_eq!(tool_results.len(), 3);
1842        for (i, expected_id) in ["c1", "c2", "c3"].iter().enumerate() {
1843            assert_eq!(tool_results[i]["type"], "tool_result");
1844            assert_eq!(tool_results[i]["tool_use_id"], *expected_id);
1845        }
1846    }
1847
1848    #[test]
1849    fn convert_messages_emits_thinking_block_with_signature() {
1850        let mut msg = ChatMessage::assistant("Final answer.");
1851        msg.thinking = Some("reasoning content".to_string());
1852        msg.provider_continuation = Some(ProviderContinuation::Anthropic {
1853            signature: "sig_xyz".to_string(),
1854        });
1855        let messages = vec![ChatMessage::user("Q?"), msg];
1856        let (_, msgs) = convert_messages(&messages);
1857        let assistant_content = msgs[1]["content"].as_array().expect("array");
1858        // Thinking block first, text block second.
1859        assert_eq!(assistant_content[0]["type"], "thinking");
1860        assert_eq!(assistant_content[0]["thinking"], "reasoning content");
1861        assert_eq!(assistant_content[0]["signature"], "sig_xyz");
1862        assert_eq!(assistant_content[1]["type"], "text");
1863        assert_eq!(assistant_content[1]["text"], "Final answer.");
1864    }
1865
1866    #[test]
1867    fn convert_messages_image_block_for_user_with_images() {
1868        let msg = ChatMessage::user("What is this?").with_images(vec!["BASE64DATA".to_string()]);
1869        let messages = vec![msg];
1870        let (_, msgs) = convert_messages(&messages);
1871        let content = msgs[0]["content"].as_array().expect("array");
1872        assert_eq!(content[0]["type"], "text");
1873        assert_eq!(content[0]["text"], "What is this?");
1874        assert_eq!(content[1]["type"], "image");
1875        assert_eq!(content[1]["source"]["type"], "base64");
1876        assert_eq!(content[1]["source"]["media_type"], "image/png");
1877        assert_eq!(content[1]["source"]["data"], "BASE64DATA");
1878    }
1879
1880    // --- Request body ---
1881
1882    #[test]
1883    fn build_request_body_includes_required_fields() {
1884        let adapter = test_adapter();
1885        let messages = vec![ChatMessage::user("Hello")];
1886        let config = ModelConfig::default();
1887        let body = adapter.build_request_body(&messages, &config);
1888        assert_eq!(body["model"], "claude-sonnet-4-6");
1889        assert_eq!(body["stream"], true);
1890        assert!(body["max_tokens"].is_u64());
1891        assert!(body["messages"].is_array());
1892    }
1893
1894    #[test]
1895    fn auto_max_tokens_uses_live_discovered_ceiling() {
1896        // AUTO (max_tokens == 0) sends the live-discovered output ceiling —
1897        // a 1M-window / 128k-ceiling model gets the full 128k, and a tiny
1898        // prompt leaves the window's room above it.
1899        let adapter = test_adapter();
1900        let config = ModelConfig {
1901            max_tokens: 0,
1902            resolved_context_window: Some(1_000_000),
1903            resolved_max_output: Some(128_000),
1904            ..Default::default()
1905        };
1906        let body = adapter.build_request_body(&[ChatMessage::user("Hello")], &config);
1907        assert_eq!(body["max_tokens"], 128_000);
1908    }
1909
1910    #[test]
1911    fn auto_max_tokens_floors_when_discovery_unresolved() {
1912        // Discovery failed (both resolved_* None): Anthropic still REQUIRES
1913        // max_tokens, so AUTO falls back to the conservative 8192 floor and
1914        // applies no window clamp.
1915        let adapter = test_adapter();
1916        let config = ModelConfig {
1917            max_tokens: 0,
1918            ..Default::default()
1919        };
1920        let body = adapter.build_request_body(&[ChatMessage::user("Hello")], &config);
1921        assert_eq!(body["max_tokens"], 8_192);
1922    }
1923
1924    #[test]
1925    fn auto_max_tokens_clamps_to_window_room() {
1926        // A tight discovered window bounds AUTO below the output ceiling:
1927        // room = window − prompt_estimate − margin. Pin a tiny system prompt
1928        // so the estimate is deterministic: ("Hello" 5 + "sys" 3) / 4 = 2.
1929        let adapter = test_adapter();
1930        let config = ModelConfig {
1931            max_tokens: 0,
1932            system_prompt: Some("sys".to_string()),
1933            resolved_context_window: Some(16_384),
1934            resolved_max_output: Some(128_000),
1935            ..Default::default()
1936        };
1937        let body = adapter.build_request_body(&[ChatMessage::user("Hello")], &config);
1938        assert_eq!(body["max_tokens"], 16_384 - 2 - 1_024);
1939    }
1940
1941    /// Harness steering (`RecoveryNudge`, `ContextMarker`) MUST reach the
1942    /// model. Anthropic has no mid-conversation system role, and this adapter
1943    /// used to drop such messages outright — silently deleting the plan-mode
1944    /// reminder, context markers, and the auto-continue and stalled-turn
1945    /// nudges on every `claude/*` model.
1946    #[test]
1947    fn model_directed_system_messages_reach_the_wire_as_tagged_user_blocks() {
1948        use crate::models::ChatMessageKind;
1949        let mut nudge = ChatMessage::system("Reminder: plan mode is active.");
1950        nudge.kind = ChatMessageKind::RecoveryNudge;
1951        let messages = vec![ChatMessage::user("ok"), nudge];
1952
1953        let (_system, out) = convert_messages(&messages);
1954        assert_eq!(out.len(), 1, "merged into the adjacent user turn");
1955        assert_eq!(out[0]["role"], "user");
1956        let blocks = out[0]["content"].as_array().expect("content array");
1957        assert_eq!(blocks.len(), 2, "original text plus the reminder");
1958        assert_eq!(blocks[0]["text"], "ok");
1959        let tagged = blocks[1]["text"].as_str().unwrap();
1960        assert!(
1961            tagged.contains("<system-reminder>") && tagged.contains("plan mode is active"),
1962            "steering must be delivered and tagged: {tagged}",
1963        );
1964    }
1965
1966    /// With no user turn to attach to, one is created rather than dropping the
1967    /// steering. (The output-cap continuation nudge lands right after an
1968    /// assistant partial; that design carries no prefill dependency.)
1969    #[test]
1970    fn model_directed_system_message_creates_a_user_turn_when_needed() {
1971        use crate::models::ChatMessageKind;
1972        let mut nudge = ChatMessage::system("Resume where you stopped.");
1973        nudge.kind = ChatMessageKind::ContextMarker;
1974        let messages = vec![ChatMessage::assistant("partial reply"), nudge];
1975
1976        let (_system, out) = convert_messages(&messages);
1977        assert_eq!(out.len(), 2);
1978        assert_eq!(out[0]["role"], "assistant");
1979        assert_eq!(out[1]["role"], "user", "alternation stays valid");
1980        assert!(
1981            out[1]["content"][0]["text"]
1982                .as_str()
1983                .unwrap()
1984                .contains("Resume where you stopped"),
1985        );
1986    }
1987
1988    // ── Role alternation ────────────────────────────────────────────────
1989
1990    /// Anthropic rejects a history whose roles do not alternate, and several
1991    /// ordinary shapes put two same-role turns next to each other. Asserted
1992    /// as a property over the family rather than as one example: the ordering
1993    /// that motivated the fix (steering between two user turns — a request
1994    /// that errored before any assistant turn committed, then a retype) is
1995    /// only one member, and the next one added should be caught here.
1996    #[test]
1997    fn convert_messages_never_emits_consecutive_same_role_turns() {
1998        use crate::models::ChatMessageKind;
1999        let steering = || {
2000            let mut m = ChatMessage::system("Reminder: plan mode is active.");
2001            m.kind = ChatMessageKind::ContextMarker;
2002            m
2003        };
2004        let tool_call = || {
2005            let mut m = ChatMessage::assistant("");
2006            m.tool_calls = Some(vec![ToolCall {
2007                id: Some("c1".to_string()),
2008                function: FunctionCall {
2009                    name: "read_file".into(),
2010                    arguments: json!({"path": "a.txt"}),
2011                },
2012            }]);
2013            m
2014        };
2015
2016        let shapes: Vec<(&str, Vec<ChatMessage>)> = vec![
2017            (
2018                "steering between two user turns",
2019                vec![
2020                    ChatMessage::user("first"),
2021                    steering(),
2022                    ChatMessage::user("second"),
2023                ],
2024            ),
2025            (
2026                "two user turns in a row",
2027                vec![ChatMessage::user("first"), ChatMessage::user("second")],
2028            ),
2029            (
2030                "user types while tool results are pending",
2031                vec![
2032                    ChatMessage::user("read it"),
2033                    tool_call(),
2034                    ChatMessage::tool("c1", "read_file", "contents"),
2035                    ChatMessage::user("actually, stop"),
2036                ],
2037            ),
2038            (
2039                "two assistant turns from an interrupted continuation",
2040                vec![
2041                    ChatMessage::user("go"),
2042                    ChatMessage::assistant("part one"),
2043                    ChatMessage::assistant("part two"),
2044                ],
2045            ),
2046            (
2047                "back-to-back steering",
2048                vec![ChatMessage::user("go"), steering(), steering()],
2049            ),
2050            (
2051                "steering with no user turn to attach to",
2052                vec![ChatMessage::assistant("partial"), steering()],
2053            ),
2054        ];
2055
2056        for (name, messages) in shapes {
2057            let (_system, out) = convert_messages(&messages);
2058            assert!(!out.is_empty(), "{name}: the history must not vanish");
2059            for pair in out.windows(2) {
2060                assert_ne!(
2061                    pair[0]["role"], pair[1]["role"],
2062                    "{name}: emitted consecutive {} turns, which Anthropic rejects: {out:#?}",
2063                    pair[0]["role"],
2064                );
2065            }
2066        }
2067    }
2068
2069    /// Coalescing must not lose content. An implementation that simply dropped
2070    /// the second of two same-role turns would satisfy the alternation
2071    /// property above, so the content has to be pinned separately.
2072    #[test]
2073    fn coalescing_two_user_turns_keeps_both_texts() {
2074        let messages = vec![ChatMessage::user("first"), ChatMessage::user("second")];
2075        let (_system, out) = convert_messages(&messages);
2076        assert_eq!(out.len(), 1);
2077        let blocks = out[0]["content"].as_array().expect("content array");
2078        assert_eq!(blocks.len(), 2, "both texts survive: {blocks:#?}");
2079        assert_eq!(blocks[0]["text"], "first");
2080        assert_eq!(blocks[1]["text"], "second");
2081    }
2082
2083    /// `tool_result` blocks must LEAD the user turn they sit in. When a typed
2084    /// message merges into a pending tool batch, naive concatenation would put
2085    /// the text first — trading a role-alternation 400 for a placement one.
2086    #[test]
2087    fn merged_user_turn_keeps_tool_results_first() {
2088        let mut call = ChatMessage::assistant("");
2089        call.tool_calls = Some(vec![ToolCall {
2090            id: Some("c1".to_string()),
2091            function: FunctionCall {
2092                name: "read_file".into(),
2093                arguments: json!({"path": "a.txt"}),
2094            },
2095        }]);
2096        let messages = vec![
2097            ChatMessage::user("read it"),
2098            call,
2099            ChatMessage::tool("c1", "read_file", "contents"),
2100            ChatMessage::user("actually, stop"),
2101        ];
2102        let (_system, out) = convert_messages(&messages);
2103        let blocks = out[2]["content"].as_array().expect("content array");
2104        assert_eq!(out[2]["role"], "user");
2105        assert_eq!(blocks[0]["type"], "tool_result", "{blocks:#?}");
2106        assert_eq!(blocks[1]["type"], "text");
2107        assert_eq!(blocks[1]["text"], "actually, stop");
2108    }
2109
2110    /// The assistant-side counterpart: `thinking` must lead its turn, so a
2111    /// merge that lands a thinking block behind a text block is a 400.
2112    #[test]
2113    fn merged_assistant_turn_keeps_thinking_first() {
2114        let mut second = ChatMessage::assistant("part two");
2115        second.thinking = Some("more reasoning".to_string());
2116        second.provider_continuation = Some(ProviderContinuation::Anthropic {
2117            signature: "sig_xyz".to_string(),
2118        });
2119        let messages = vec![
2120            ChatMessage::user("go"),
2121            ChatMessage::assistant("part one"),
2122            second,
2123        ];
2124        let (_system, out) = convert_messages(&messages);
2125        assert_eq!(out.len(), 2);
2126        let blocks = out[1]["content"].as_array().expect("content array");
2127        assert_eq!(blocks[0]["type"], "thinking", "{blocks:#?}");
2128        assert_eq!(blocks[1]["text"], "part one");
2129        assert_eq!(blocks[2]["text"], "part two");
2130    }
2131
2132    #[test]
2133    fn build_request_body_sets_system_field_not_message() {
2134        let adapter = test_adapter();
2135        let messages = vec![ChatMessage::user("Hi")];
2136        let config = ModelConfig {
2137            system_prompt: Some("You are Mermaid.".to_string()),
2138            ..Default::default()
2139        };
2140        let body = adapter.build_request_body(&messages, &config);
2141        // Step 5b: system serializes as a typed-block array carrying a
2142        // `cache_control: ephemeral` marker so Anthropic caches it.
2143        let sys = body["system"].as_array().expect("system is array");
2144        assert_eq!(sys.len(), 1);
2145        assert_eq!(sys[0]["type"], "text");
2146        assert_eq!(sys[0]["text"], "You are Mermaid.");
2147        assert_eq!(sys[0]["cache_control"]["type"], "ephemeral");
2148        // System should NOT also appear as a message.
2149        let msgs = body["messages"].as_array().unwrap();
2150        for m in msgs {
2151            assert_ne!(m["role"], "system");
2152        }
2153    }
2154
2155    /// Step 5h: when MERMAID.md content is present, the static base
2156    /// stays in cache slot #1 and the dynamic suffix gets its own
2157    /// cache slot #2. Two separately-cached typed-text blocks → static
2158    /// base survives across project switches; only the suffix re-caches
2159    /// when the file changes.
2160    #[test]
2161    fn build_request_body_emits_two_cache_blocks_when_suffix_present() {
2162        let adapter = test_adapter();
2163        let messages = vec![ChatMessage::user("Hi")];
2164        let config = ModelConfig {
2165            system_prompt: Some("You are Mermaid.".to_string()),
2166            dynamic_system_suffix: Some("Project rule: always snake_case.".to_string()),
2167            ..Default::default()
2168        };
2169        let body = adapter.build_request_body(&messages, &config);
2170        let sys = body["system"].as_array().expect("system is array");
2171        assert_eq!(sys.len(), 2);
2172        assert_eq!(sys[0]["text"], "You are Mermaid.");
2173        assert_eq!(sys[0]["cache_control"]["type"], "ephemeral");
2174        assert_eq!(sys[1]["text"], "Project rule: always snake_case.");
2175        assert_eq!(sys[1]["cache_control"]["type"], "ephemeral");
2176    }
2177
2178    /// Regression guard: with no dynamic suffix, behavior is byte-equivalent
2179    /// to pre-Step-5h — single block, single cache marker. Existing sessions
2180    /// without MERMAID.md must not change cache shape.
2181    #[test]
2182    fn build_request_body_emits_single_block_when_suffix_absent() {
2183        let adapter = test_adapter();
2184        let messages = vec![ChatMessage::user("Hi")];
2185        let config = ModelConfig {
2186            system_prompt: Some("You are Mermaid.".to_string()),
2187            dynamic_system_suffix: None,
2188            ..Default::default()
2189        };
2190        let body = adapter.build_request_body(&messages, &config);
2191        let sys = body["system"].as_array().expect("system is array");
2192        assert_eq!(sys.len(), 1);
2193        assert_eq!(sys[0]["text"], "You are Mermaid.");
2194    }
2195
2196    /// Native structured output rides in `output_config.format` and must
2197    /// merge with (not clobber) `output_config.effort` when both are set.
2198    #[test]
2199    fn build_request_body_maps_output_schema_to_output_config_format() {
2200        let adapter = test_adapter();
2201        let messages = vec![ChatMessage::user("format it")];
2202        let config = ModelConfig {
2203            reasoning: ReasoningLevel::High,
2204            output_schema: Some(serde_json::json!({
2205                "type": "object",
2206                "properties": {"answer": {"type": "integer"}}
2207            })),
2208            ..Default::default()
2209        };
2210        let body = adapter.build_request_body(&messages, &config);
2211        assert_eq!(body["output_config"]["format"]["type"], "json_schema");
2212        assert_eq!(body["output_config"]["format"]["schema"]["type"], "object");
2213        // Effort coexists in the same object.
2214        assert_eq!(body["output_config"]["effort"], "high");
2215        // Absent -> no format key at all.
2216        let body = adapter.build_request_body(&messages, &ModelConfig::default());
2217        assert!(body["output_config"].get("format").is_none());
2218    }
2219
2220    /// Step 5c bug fix: `effort` lives at `output_config.effort`, NOT
2221    /// top-level. Adaptive models also need `display: "summarized"` so
2222    /// Opus 4.7 (which defaults to "omitted") surfaces reasoning chunks.
2223    #[test]
2224    fn build_request_body_uses_adaptive_for_sonnet_4_6() {
2225        let adapter = test_adapter(); // claude-sonnet-4-6
2226        let messages = vec![ChatMessage::user("Hi")];
2227        let config = ModelConfig {
2228            reasoning: ReasoningLevel::High,
2229            ..Default::default()
2230        };
2231        let body = adapter.build_request_body(&messages, &config);
2232        assert_eq!(body["thinking"]["type"], "adaptive");
2233        assert_eq!(body["thinking"]["display"], "summarized");
2234        // Effort is in output_config, NOT top-level (Step 5c fix).
2235        assert_eq!(body["output_config"]["effort"], "high");
2236        assert!(body.get("effort").is_none(), "effort must NOT be top-level");
2237        assert!(body["thinking"].get("budget_tokens").is_none());
2238    }
2239
2240    /// Sonnet 4.5 uses legacy `budget_tokens` thinking AND must NOT receive an
2241    /// `effort` field — the effort parameter 400s on Sonnet 4.5 / Haiku 4.5
2242    /// (RC-H: the old code sent effort to every model, including these). A
2243    /// temperature is still accepted here.
2244    #[test]
2245    fn build_request_body_uses_legacy_for_sonnet_4_5() {
2246        let adapter = AnthropicAdapter::new(
2247            "k".to_string(),
2248            "claude-sonnet-4-5".to_string(),
2249            "https://api.anthropic.com/v1".to_string(),
2250        )
2251        .unwrap();
2252        let messages = vec![ChatMessage::user("Hi")];
2253        let config = ModelConfig {
2254            reasoning: ReasoningLevel::Medium,
2255            max_tokens: 8000,
2256            ..Default::default()
2257        };
2258        let body = adapter.build_request_body(&messages, &config);
2259        assert_eq!(body["thinking"]["type"], "enabled");
2260        assert_eq!(body["thinking"]["budget_tokens"], 4096);
2261        // Effort is NOT supported on Sonnet 4.5 — emitting it would 400.
2262        assert!(
2263            body.get("output_config").is_none(),
2264            "Sonnet 4.5 must not get an effort field"
2265        );
2266        // Sampling params are still accepted on the 4.5 family.
2267        assert!(body.get("temperature").is_some());
2268    }
2269
2270    /// RC-H: Opus 4.8 / Fable 5 are on the 4.6+ adaptive line — adaptive
2271    /// thinking, effort in output_config, and NO temperature (it 400s there).
2272    #[test]
2273    fn build_request_body_adaptive_no_temperature_for_opus_4_8() {
2274        let adapter = AnthropicAdapter::new(
2275            "k".to_string(),
2276            "claude-opus-4-8".to_string(),
2277            "https://api.anthropic.com/v1".to_string(),
2278        )
2279        .unwrap();
2280        let messages = vec![ChatMessage::user("Hi")];
2281        let config = ModelConfig {
2282            reasoning: ReasoningLevel::High,
2283            ..Default::default()
2284        };
2285        let body = adapter.build_request_body(&messages, &config);
2286        assert_eq!(body["thinking"]["type"], "adaptive");
2287        assert!(
2288            body["thinking"].get("budget_tokens").is_none(),
2289            "Opus 4.8 rejects legacy budget_tokens"
2290        );
2291        assert_eq!(body["output_config"]["effort"], "high");
2292        assert!(
2293            body.get("temperature").is_none(),
2294            "Opus 4.8 rejects a top-level temperature"
2295        );
2296    }
2297
2298    #[test]
2299    fn build_request_body_omits_thinking_when_reasoning_is_none() {
2300        let adapter = test_adapter();
2301        let messages = vec![ChatMessage::user("Hi")];
2302        let config = ModelConfig {
2303            reasoning: ReasoningLevel::None,
2304            ..Default::default()
2305        };
2306        let body = adapter.build_request_body(&messages, &config);
2307        assert!(body.get("thinking").is_none());
2308        // None level also means no effort hint (effort defaults to
2309        // "high" on the API side, which is what we'd want for
2310        // not-explicitly-controlled requests).
2311        assert!(body.get("output_config").is_none());
2312        assert!(body.get("effort").is_none(), "no top-level effort either");
2313    }
2314
2315    /// Opus 4.7 + XHigh maps to `xhigh` — the highest tier, available
2316    /// only on Opus 4.7 per the official docs. Max on Opus 4.7 stays at
2317    /// `max` (distinct tier from xhigh).
2318    #[test]
2319    fn build_request_body_uses_xhigh_on_opus_4_7_for_xhigh() {
2320        let adapter = AnthropicAdapter::new(
2321            "k".to_string(),
2322            "claude-opus-4-7".to_string(),
2323            "https://api.anthropic.com/v1".to_string(),
2324        )
2325        .unwrap();
2326        let messages = vec![ChatMessage::user("Hi")];
2327        let config = ModelConfig {
2328            reasoning: ReasoningLevel::XHigh,
2329            ..Default::default()
2330        };
2331        let body = adapter.build_request_body(&messages, &config);
2332        assert_eq!(body["output_config"]["effort"], "xhigh");
2333        assert_eq!(body["thinking"]["type"], "adaptive");
2334    }
2335
2336    /// Opus 4.6 + Max maps to `max` (NOT xhigh — that's Opus 4.7-only).
2337    /// Sending xhigh to Opus 4.6 would 400.
2338    #[test]
2339    fn build_request_body_uses_max_on_opus_4_6_for_max() {
2340        let adapter = AnthropicAdapter::new(
2341            "k".to_string(),
2342            "claude-opus-4-6".to_string(),
2343            "https://api.anthropic.com/v1".to_string(),
2344        )
2345        .unwrap();
2346        let messages = vec![ChatMessage::user("Hi")];
2347        let config = ModelConfig {
2348            reasoning: ReasoningLevel::Max,
2349            ..Default::default()
2350        };
2351        let body = adapter.build_request_body(&messages, &config);
2352        assert_eq!(body["output_config"]["effort"], "max");
2353    }
2354
2355    /// Opus 4.5 accepts `effort` but not `max`, so the adapter snaps Max to
2356    /// `high` to avoid a 400. (Sonnet 4.5 / Haiku 4.5 get no effort field at
2357    /// all — covered by `build_request_body_uses_legacy_for_sonnet_4_5`.)
2358    #[test]
2359    fn build_request_body_snaps_max_to_high_on_opus_4_5() {
2360        let adapter = AnthropicAdapter::new(
2361            "k".to_string(),
2362            "claude-opus-4-5".to_string(),
2363            "https://api.anthropic.com/v1".to_string(),
2364        )
2365        .unwrap();
2366        let messages = vec![ChatMessage::user("Hi")];
2367        let config = ModelConfig {
2368            reasoning: ReasoningLevel::Max,
2369            max_tokens: 8000,
2370            ..Default::default()
2371        };
2372        let body = adapter.build_request_body(&messages, &config);
2373        assert_eq!(
2374            body["output_config"]["effort"], "high",
2375            "Opus 4.5 should snap Max → high (no max effort support)"
2376        );
2377    }
2378
2379    /// Step 5c: `display` defaults to `"summarized"` on adaptive models
2380    /// so reasoning chunks are visible in the response stream. Without
2381    /// this, Opus 4.7 users see no reasoning content (it defaults to
2382    /// `"omitted"` on Opus 4.7 specifically).
2383    #[test]
2384    fn build_request_body_sets_display_summarized_by_default() {
2385        let adapter = test_adapter(); // claude-sonnet-4-6 (adaptive)
2386        let messages = vec![ChatMessage::user("Hi")];
2387        let config = ModelConfig {
2388            reasoning: ReasoningLevel::Medium,
2389            hide_reasoning_trace: false,
2390            ..Default::default()
2391        };
2392        let body = adapter.build_request_body(&messages, &config);
2393        assert_eq!(body["thinking"]["display"], "summarized");
2394    }
2395
2396    /// Step 5c: when the user enables hide_reasoning_trace, send
2397    /// `display: "omitted"` so the API doesn't waste bandwidth streaming
2398    /// thinking tokens we'd just discard client-side.
2399    #[test]
2400    fn build_request_body_sets_display_omitted_when_hide_reasoning_trace() {
2401        let adapter = test_adapter();
2402        let messages = vec![ChatMessage::user("Hi")];
2403        let config = ModelConfig {
2404            reasoning: ReasoningLevel::Medium,
2405            hide_reasoning_trace: true,
2406            ..Default::default()
2407        };
2408        let body = adapter.build_request_body(&messages, &config);
2409        assert_eq!(body["thinking"]["display"], "omitted");
2410    }
2411
2412    #[test]
2413    fn build_request_body_clamps_temperature_to_anthropic_range() {
2414        let adapter = test_adapter();
2415        let messages = vec![ChatMessage::user("Hi")];
2416        let config = ModelConfig {
2417            temperature: 1.5, // OpenAI accepts up to 2.0; Anthropic caps at 1.0
2418            ..Default::default()
2419        };
2420        let body = adapter.build_request_body(&messages, &config);
2421        assert_eq!(body["temperature"].as_f64().unwrap(), 1.0);
2422    }
2423
2424    #[test]
2425    fn build_request_body_includes_tools_in_anthropic_shape() {
2426        let adapter = test_adapter();
2427        let messages = vec![ChatMessage::user("Hi")];
2428        // Config carries OpenAI-shape tools (populated by the v7
2429        // provider wrapper from ChatRequest.tools); the adapter
2430        // translates to Anthropic's flat `type: "custom"` shape.
2431        let config = ModelConfig {
2432            tools: vec![serde_json::json!({
2433                "type": "function",
2434                "function": {
2435                    "name": "test_tool",
2436                    "description": "a test tool",
2437                    "parameters": {"type": "object", "properties": {}}
2438                }
2439            })],
2440            ..Default::default()
2441        };
2442        let body = adapter.build_request_body(&messages, &config);
2443        let tools = body["tools"].as_array().expect("tools array");
2444        assert!(!tools.is_empty());
2445        for tool in tools {
2446            assert_eq!(tool["type"], "custom");
2447            assert!(tool.get("function").is_none());
2448            assert!(tool.get("name").is_some());
2449            assert!(tool.get("input_schema").is_some());
2450        }
2451    }
2452
2453    #[test]
2454    fn build_request_body_preserves_registry_selected_web_tools() {
2455        let adapter = test_adapter();
2456        let config = ModelConfig {
2457            tools: ["web_fetch", "web_search"]
2458                .into_iter()
2459                .map(|name| {
2460                    serde_json::json!({
2461                        "type": "function",
2462                        "function": {
2463                            "name": name,
2464                            "description": "registered web tool",
2465                            "parameters": {"type": "object"}
2466                        }
2467                    })
2468                })
2469                .collect(),
2470            ..Default::default()
2471        };
2472
2473        let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config);
2474        let names: Vec<&str> = body["tools"]
2475            .as_array()
2476            .expect("tools array")
2477            .iter()
2478            .filter_map(|tool| tool.get("name").and_then(Value::as_str))
2479            .collect();
2480        assert_eq!(names, ["web_fetch", "web_search"]);
2481    }
2482
2483    /// Step 5b: only the LAST tool gets `cache_control: ephemeral`.
2484    /// Anthropic caches everything BEFORE the marker too, so a single
2485    /// marker on the last tool is enough — adding more wastes one of
2486    /// the 4 cache breakpoints per request.
2487    #[test]
2488    fn build_request_body_marks_only_last_tool_with_cache_control() {
2489        let adapter = test_adapter();
2490        let messages = vec![ChatMessage::user("Hi")];
2491        let config = ModelConfig {
2492            tools: vec![
2493                serde_json::json!({
2494                    "type": "function",
2495                    "function": {
2496                        "name": "tool_a",
2497                        "description": "first",
2498                        "parameters": {"type": "object"}
2499                    }
2500                }),
2501                serde_json::json!({
2502                    "type": "function",
2503                    "function": {
2504                        "name": "tool_b",
2505                        "description": "second",
2506                        "parameters": {"type": "object"}
2507                    }
2508                }),
2509                serde_json::json!({
2510                    "type": "function",
2511                    "function": {
2512                        "name": "tool_c",
2513                        "description": "third",
2514                        "parameters": {"type": "object"}
2515                    }
2516                }),
2517            ],
2518            ..Default::default()
2519        };
2520        let body = adapter.build_request_body(&messages, &config);
2521        let tools = body["tools"].as_array().expect("tools array");
2522        assert!(
2523            tools.len() >= 2,
2524            "need at least 2 tools to verify marker placement"
2525        );
2526
2527        // All tools except the last must NOT have cache_control.
2528        for tool in &tools[..tools.len() - 1] {
2529            assert!(
2530                tool.get("cache_control").is_none(),
2531                "non-last tool should not carry cache_control: {:?}",
2532                tool
2533            );
2534        }
2535        // The last tool MUST have cache_control: ephemeral.
2536        let last = &tools[tools.len() - 1];
2537        assert_eq!(
2538            last["cache_control"]["type"], "ephemeral",
2539            "last tool should carry the cache_control marker"
2540        );
2541    }
2542
2543    /// When the tool list is empty (no tools registered for this
2544    /// request), the request body must omit the `tools` field
2545    /// entirely. No orphan `cache_control` marker on a non-existent
2546    /// last tool, no panic.
2547    #[test]
2548    fn build_request_body_handles_empty_tools_without_panicking() {
2549        // The translation helper is the right unit-of-test here:
2550        // if `to_anthropic_tools(&[])` returned a non-empty vec, the
2551        // adapter's `if !anthropic_tools.is_empty()` guard would let us
2552        // reach the cache_control insertion with no last element.
2553        let result = to_anthropic_tools(&[]);
2554        assert!(result.is_empty(), "empty input must produce empty output");
2555    }
2556
2557    #[test]
2558    fn capabilities_advertise_full_reasoning_levels_and_vision() {
2559        let adapter = test_adapter();
2560        let caps = adapter.capabilities();
2561        assert!(caps.supports_tools);
2562        assert!(caps.supports_vision);
2563        match &caps.supports_reasoning {
2564            ReasoningCapability::Levels(levels) => {
2565                assert!(levels.contains(&ReasoningLevel::None));
2566                assert!(levels.contains(&ReasoningLevel::Max));
2567            },
2568            other => panic!("expected Levels, got {:?}", other),
2569        }
2570    }
2571
2572    #[test]
2573    fn name_returns_model_id() {
2574        let adapter = test_adapter();
2575        assert_eq!(adapter.name(), "claude-sonnet-4-6");
2576    }
2577}