返回 CodeWhale
anthropic.rs
根目录 / crates / tui / src / client / anthropic.rs
1 //! Native Anthropic Messages API adapter (#3014).
2 //!
3 //! CodeWhale's internal wire types are already Anthropic-shaped (the harness
4 //! speaks Messages internally and translates *out* to OpenAI dialects), so
5 //! this adapter is mostly native serialization plus an SSE pass-through:
6 //! `StreamEvent` deserializes Anthropic's `message_start` /
7 //! `content_block_*` / `message_delta` / `message_stop` / `ping` events
8 //! directly. What the adapter adds on top:
9 //!
10 //! - request shaping: adaptive thinking + `output_config.effort` from
11 //! CodeWhale's `reasoning_effort` tiers, sampling-parameter rules for
12 //! models that reject them, and `cache_control` breakpoint placement
13 //! aligned with the prefix-zone model in `prefix_cache.rs`;
14 //! - usage normalization (#2961 / #4318): `prompt_cache_hit_tokens` comes from
15 //! `cache_read_input_tokens`, `prompt_cache_write_tokens` from
16 //! `cache_creation_input_tokens`, `prompt_cache_miss_tokens` is the raw
17 //! non-cached `input_tokens`, and the normalized `input_tokens` is the sum
18 //! of all three (total prompt, the DeepSeek convention);
19 //! - signed-thinking handling: `signature_delta` is captured into
20 //! [`crate::models::Delta::SignatureDelta`] and assistant thinking blocks
21 //! replay verbatim (signature included); unsigned thinking blocks are
22 //! dropped from replay because the API rejects them.
23 //!
24 //! Modeled on `client/responses.rs` (separate file per dialect, no protocol
25 //! hacks in the shared paths).
26
27 use anyhow::{Context, Result};
28 use serde_json::{Value, json};
29
30 use crate::config::{ApiProvider, wire_model_for_provider_route};
31 use crate::llm_client::StreamEventBox;
32 use crate::logging;
33 use crate::models::{ContentBlock, MessageRequest, MessageResponse, StreamEvent, Usage};
34 use crate::tools::schema_sanitize;
35
36 use super::{DeepSeekClient, ERROR_BODY_MAX_BYTES, bounded_error_text};
37
38 /// Maximum `cache_control` breakpoints Anthropic accepts per request.
39 const MAX_CACHE_BREAKPOINTS: usize = 4;
40
41 impl DeepSeekClient {
42 /// Build the native Messages API request body from a [`MessageRequest`].
43 pub(super) fn build_anthropic_body(&self, request: &MessageRequest, stream: bool) -> Value {
44 let model =
45 wire_model_for_provider_route(self.api_provider, &self.base_url, &request.model);
46 let mut body = json!({
47 "model": model,
48 "max_tokens": request.max_tokens,
49 "stream": stream,
50 });
51
52 if let Some(system) = request.system.as_ref() {
53 body["system"] = match system {
54 crate::models::SystemPrompt::Text(text) => json!(text),
55 crate::models::SystemPrompt::Blocks(blocks) => json!(
56 blocks
57 .iter()
58 .map(|block| {
59 let mut value = json!({
60 "type": "text",
61 "text": block.text,
62 });
63 if let Some(cache) = block.cache_control.as_ref() {
64 value["cache_control"] = json!({ "type": cache.cache_type });
65 }
66 value
67 })
68 .collect::<Vec<_>>()
69 ),
70 };
71 }
72
73 let mut messages: Vec<Value> = request
74 .messages
75 .iter()
76 .filter_map(message_to_anthropic)
77 .collect();
78 repair_dangling_tool_uses(&mut messages);
79 body["messages"] = Value::Array(messages);
80
81 if let Some(tools) = request.tools.as_ref()
82 && !tools.is_empty()
83 {
84 body["tools"] = json!(
85 tools
86 .iter()
87 .map(|tool| {
88 // Sanitize the tool's input_schema the same way the
89 // OpenAI Responses adapter does: strip top-level
90 // oneOf/anyOf/allOf (which Anthropic rejects), merge
91 // alternative properties into the root, and surface
92 // the dropped constraint as a description note so the
93 // model still knows which parameters are expected.
94 let mut schema = tool.input_schema.clone();
95 let constraint_note = schema_sanitize::sanitize_for_responses(&mut schema);
96 let description = match constraint_note {
97 Some(note) if tool.description.trim().is_empty() => note,
98 Some(note) => format!("{}\n\n{}", tool.description.trim(), note),
99 None => tool.description.clone(),
100 };
101 let mut value = json!({
102 "name": tool.name,
103 "description": description,
104 "input_schema": schema,
105 });
106 if let Some(strict) = tool.strict {
107 value["strict"] = json!(strict);
108 }
109 if let Some(cache) = tool.cache_control.as_ref() {
110 value["cache_control"] = json!({ "type": cache.cache_type });
111 }
112 value
113 })
114 .collect::<Vec<_>>()
115 );
116 }
117
118 if let Some(tool_choice) = request.tool_choice.as_ref() {
119 body["tool_choice"] = anthropic_tool_choice(tool_choice);
120 }
121
122 // Thinking + effort shaping. MiniMax supports adaptive/disabled but
123 // not Anthropic's output_config effort field; native Anthropic routes
124 // keep the existing effort mapping. Other Messages-compatible
125 // gateways (#4978, e.g. Sensenova) only accept the documented
126 // enabled/disabled/auto thinking types, so non-native routes get the
127 // portable `{"type":"enabled","budget_tokens":N}` shape instead.
128 let thinking_capable = crate::models::model_supports_reasoning(&model);
129 let is_minimax_provider = self.api_provider == ApiProvider::MinimaxAnthropic;
130 let is_minimax = crate::config::is_exact_minimax_anthropic_m3_route(
131 self.api_provider,
132 &self.base_url,
133 &model,
134 );
135 let is_deepseek = self.api_provider == ApiProvider::DeepseekAnthropic;
136 // Model Studio's Anthropic-compatible endpoint documents the portable
137 // `{"type":"enabled","budget_tokens":N}` shape AND `{"type":"disabled"}`
138 // (alibabacloud.com/help/en/model-studio/anthropic-api-messages), so
139 // an explicit "off" can be honored on the wire instead of silently
140 // falling through to the server default (which is thinking-ON for the
141 // qwen3.x families).
142 let is_modelstudio = matches!(
143 self.api_provider,
144 ApiProvider::ModelstudioTokenPlan
145 | ApiProvider::ModelstudioTokenPlanAnthropic
146 | ApiProvider::ModelstudioCodingPlan
147 | ApiProvider::ModelstudioCodingPlanAnthropic
148 );
149 // MiniMax's exact M3 route and DeepSeek's Messages dialect both
150 // document adaptive support; everything else needs the native host.
151 let supports_adaptive =
152 is_native_anthropic_base_url(&self.base_url) || is_minimax || is_deepseek;
153 let effort = request
154 .reasoning_effort
155 .as_deref()
156 .map(|raw| raw.trim().to_ascii_lowercase());
157 match effort.as_deref() {
158 _ if is_minimax_provider && !is_minimax => {}
159 Some("off" | "disabled" | "none" | "false")
160 if (is_minimax || is_deepseek || is_modelstudio) && thinking_capable =>
161 {
162 // Deliberately includes thinking-only Model Studio models
163 // (qwen3.8-max family): unlike the chat dialect's
164 // enable_thinking switch, the Messages endpoint documents the
165 // portable {"type":"disabled"} shape for them
166 // (alibabacloud.com/help/en/model-studio/anthropic-api-messages)
167 // — pinned by modelstudio_messages_body_requests_thinking_
168 // with_budget. Re-checked 2026-08-04.
169 body["thinking"] = json!({ "type": "disabled" });
170 }
171 Some("off" | "disabled" | "none" | "false") => {}
172 Some(level) if thinking_capable && supports_adaptive => {
173 body["thinking"] = json!({ "type": "adaptive" });
174 if !is_minimax {
175 let mapped = match level {
176 "low" | "minimal" => "low",
177 "medium" | "mid" => "medium",
178 "max" | "xhigh" | "highest" => "max",
179 _ => "high",
180 };
181 body["output_config"] = json!({ "effort": mapped });
182 }
183 }
184 None if thinking_capable && supports_adaptive => {
185 body["thinking"] = json!({ "type": "adaptive" });
186 }
187 _ if thinking_capable => {
188 if let Some(budget) = compat_thinking_budget(effort.as_deref(), request.max_tokens)
189 {
190 body["thinking"] = json!({ "type": "enabled", "budget_tokens": budget });
191 }
192 }
193 _ => {}
194 }
195
196 // Sampling parameters: Claude 4.7+ rejects temperature/top_p
197 // entirely; earlier models reject the two together. Send at most one
198 // (temperature wins), or neither for models that forbid them.
199 if !anthropic_model_rejects_sampling(&request.model) {
200 if let Some(temperature) = request.temperature {
201 body["temperature"] = json!(temperature);
202 } else if let Some(top_p) = request.top_p {
203 body["top_p"] = json!(top_p);
204 }
205 }
206
207 apply_anthropic_cache_breakpoints(&mut body);
208 body
209 }
210
211 async fn send_anthropic_request(&self, url: &str, body: &Value) -> Result<reqwest::Response> {
212 let url = self.messages_transport_url(url);
213 self.wait_for_rate_limit().await;
214 let response = self
215 .http_client
216 .post(&url)
217 .header("Accept", "text/event-stream")
218 .json(body)
219 .send()
220 .await
221 .context("Anthropic Messages API request failed")?;
222 self.check_anthropic_response(response).await
223 }
224
225 /// Shared status/error-envelope handling for streaming and
226 /// non-streaming Messages responses.
227 async fn check_anthropic_response(
228 &self,
229 response: reqwest::Response,
230 ) -> Result<reqwest::Response> {
231 let status = response.status();
232 if !status.is_success() {
233 let raw = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
234 let (error_type, message) = parse_anthropic_error_envelope(&raw);
235 self.mark_request_failure(&format!("anthropic status={status}"))
236 .await;
237 anyhow::bail!("Anthropic API error (HTTP {status} {error_type}): {message}");
238 }
239 self.mark_request_success().await;
240 Ok(response)
241 }
242
243 /// Open the streaming Messages request through the shared stream-entry
244 /// transport policy: bounded header wait, dual-client selection, and at
245 /// most one HTTP/1.1 fallback retry on a classified H2 header stall.
246 /// Wire-specific request construction (headers, endpoint, body) stays
247 /// here at the adapter edge.
248 async fn open_anthropic_stream_response(
249 &self,
250 url: &str,
251 body: &Value,
252 ) -> Result<reqwest::Response> {
253 let url = self.messages_transport_url(url);
254 let open_req = super::stream_entry::StreamOpenRequest::new(
255 super::stream_entry::stream_open_timeout(),
256 self.stream_idle_timeout,
257 );
258 let opened = super::stream_entry::open_sse_response(&open_req, |policy| {
259 let url = url.clone();
260 async move {
261 self.wait_for_rate_limit().await;
262 let client = super::stream_entry::client_for_policy(
263 &self.http_client,
264 self.http1_fallback_client(),
265 policy,
266 );
267 client
268 .post(&url)
269 .header("Accept", "text/event-stream")
270 .json(body)
271 .send()
272 .await
273 .context("Anthropic Messages API request failed")
274 }
275 })
276 .await;
277 let response = match opened {
278 Ok(response) => response,
279 Err(err) => {
280 self.mark_request_failure(&format!("anthropic stream open: {err}"))
281 .await;
282 return Err(err);
283 }
284 };
285 self.check_anthropic_response(response).await
286 }
287
288 /// Handle a streaming Messages API request.
289 pub(super) async fn handle_anthropic_stream(
290 &self,
291 prepared: &super::PreparedOutboundRequest,
292 ) -> Result<StreamEventBox> {
293 // Body and endpoint come from the shared prepared-request seam
294 // (`prepare_outbound_request`), never from a second builder.
295 let body = &prepared.body;
296 let response = self
297 .open_anthropic_stream_response(&prepared.endpoint.url, body)
298 .await?;
299
300 let stream_idle_timeout = self.stream_idle_timeout;
301 let byte_stream = response.bytes_stream();
302
303 let stream = async_stream::stream! {
304 use futures_util::StreamExt;
305
306 // Raw byte buffer: decode only COMPLETE lines so a multi-byte
307 // UTF-8 char (CJK/emoji) split across two network reads is never
308 // corrupted to U+FFFD. Line boundaries ('\n') are ASCII and can
309 // never fall inside a multi-byte sequence. (Mirrors chat.rs.)
310 let mut buffer: Vec<u8> = Vec::new();
311 let stream_start = std::time::Instant::now();
312 let mut last_chunk_at = std::time::Instant::now();
313 let mut bytes_received: usize = 0;
314 tokio::pin!(byte_stream);
315
316 loop {
317 let chunk = match tokio::time::timeout(stream_idle_timeout, byte_stream.next()).await {
318 Ok(Some(Ok(chunk))) => chunk,
319 Ok(Some(Err(e))) => {
320 yield Err(anyhow::anyhow!("Stream read error: {e}"));
321 return;
322 }
323 Ok(None) => break,
324 Err(_) => {
325 yield Err(anyhow::anyhow!(super::stream_entry::idle_timeout_message(
326 stream_idle_timeout,
327 bytes_received,
328 stream_start.elapsed(),
329 last_chunk_at.elapsed(),
330 )));
331 return;
332 }
333 };
334
335 bytes_received += chunk.len();
336 last_chunk_at = std::time::Instant::now();
337 buffer.extend_from_slice(&chunk);
338
339 while let Some(line) = super::take_sse_line(&mut buffer) {
340
341 // `event:` lines are redundant (the data payload carries
342 // `type`) and comment/heartbeat lines are ignorable.
343 let Some(data) = super::extract_sse_data_value(&line) else {
344 continue;
345 };
346
347 match convert_anthropic_sse_data(data) {
348 Some(Ok(StreamEvent::Error { error })) => {
349 let (error_type, message) = anthropic_error_fields(&error);
350 yield Err(anyhow::anyhow!(
351 "Anthropic stream error ({error_type}): {message}"
352 ));
353 return;
354 }
355 Some(Ok(event)) => {
356 let is_stop = matches!(event, StreamEvent::MessageStop);
357 yield Ok(event);
358 if is_stop {
359 return;
360 }
361 }
362 Some(Err(e)) => {
363 logging::warn(format!("Failed to parse Anthropic SSE event: {e}"));
364 }
365 None => {}
366 }
367 }
368 }
369 };
370
371 Ok(Box::pin(stream))
372 }
373
374 /// Handle a non-streaming Messages API request.
375 pub(super) async fn handle_anthropic_message(
376 &self,
377 prepared: &super::PreparedOutboundRequest,
378 ) -> Result<MessageResponse> {
379 let response = self
380 .send_anthropic_request(&prepared.endpoint.url, &prepared.body)
381 .await?;
382 let mut value: Value = response
383 .json()
384 .await
385 .context("Failed to parse Anthropic Messages response")?;
386 if let Some(usage) = value.get_mut("usage") {
387 *usage = json!(parse_anthropic_usage(usage));
388 }
389 serde_json::from_value(value).context("Failed to decode Anthropic Messages response")
390 }
391 }
392
393 /// Build the `/v1/messages` endpoint URL, tolerating base URLs that already
394 /// carry a `/v1` suffix.
395 pub(super) fn anthropic_messages_url(base_url: &str) -> String {
396 let trimmed = base_url.trim_end_matches('/');
397 if trimmed.ends_with("/v1") {
398 format!("{trimmed}/messages")
399 } else {
400 format!("{trimmed}/v1/messages")
401 }
402 }
403
404 /// Whether the route targets first-party Anthropic (`api.anthropic.com`),
405 /// where the `{"type":"adaptive"}` thinking control is valid. Strict
406 /// Anthropic-compatible gateways reject it (#4978).
407 fn is_native_anthropic_base_url(base_url: &str) -> bool {
408 let rest = base_url
409 .trim()
410 .trim_start_matches("https://")
411 .trim_start_matches("http://");
412 let host = rest
413 .split(['/', ':', '?', '#'])
414 .next()
415 .unwrap_or("")
416 .to_ascii_lowercase();
417 host == "api.anthropic.com" || host.ends_with(".anthropic.com")
418 }
419
420 /// Minimum `budget_tokens` the Messages API accepts for extended thinking.
421 const MIN_THINKING_BUDGET_TOKENS: u32 = 1024;
422
423 /// Effort-tier `budget_tokens` for gateways that only accept the documented
424 /// `{"type":"enabled","budget_tokens":N}` thinking shape (#4978). The wire
425 /// contract requires `budget_tokens >= 1024` and `< max_tokens`, so requests
426 /// too small to fit the minimum budget send no thinking block at all.
427 fn compat_thinking_budget(effort: Option<&str>, max_tokens: u32) -> Option<u32> {
428 let tier: u32 = match effort {
429 Some("low" | "minimal") => 4_096,
430 Some("medium" | "mid") => 8_192,
431 Some("max" | "xhigh" | "highest") => 32_768,
432 // "high" and unspecified effort share the adaptive default tier.
433 _ => 16_384,
434 };
435 let budget = tier.min(max_tokens.checked_sub(1)?);
436 (budget >= MIN_THINKING_BUDGET_TOKENS).then_some(budget)
437 }
438
439 /// Placeholder body for a `tool_use` that never produced a `tool_result`.
440 const UNEXECUTED_TOOL_RESULT: &str = "tool call was not executed";
441
442 /// Defensive wire repair (#5002): every assistant `tool_use` must be answered
443 /// by a `tool_result` in the immediately following user message, or the API
444 /// rejects the whole conversation with a 400 on every retry. Pre-dispatch
445 /// failure paths (e.g. the model calling an unavailable tool) can strand an
446 /// orphaned `tool_use` in history, so missing results get an explicit
447 /// error placeholder instead of poisoning the session.
448 fn repair_dangling_tool_uses(messages: &mut Vec<Value>) {
449 let mut index = 0;
450 while index < messages.len() {
451 let ids = assistant_tool_use_ids(&messages[index]);
452 if ids.is_empty() {
453 index += 1;
454 continue;
455 }
456 let next_is_user = messages
457 .get(index + 1)
458 .and_then(|message| message.get("role"))
459 .and_then(Value::as_str)
460 == Some("user");
461 if !next_is_user {
462 messages.insert(index + 1, json!({ "role": "user", "content": [] }));
463 }
464 if let Some(blocks) = messages[index + 1]
465 .get_mut("content")
466 .and_then(Value::as_array_mut)
467 {
468 let answered: std::collections::HashSet<String> = blocks
469 .iter()
470 .filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_result"))
471 .filter_map(|block| block.get("tool_use_id").and_then(Value::as_str))
472 .map(str::to_string)
473 .collect();
474 // tool_result blocks must lead the user turn, so placeholders are
475 // prepended in tool_use order.
476 for (offset, id) in ids
477 .iter()
478 .filter(|id| !answered.contains(id.as_str()))
479 .enumerate()
480 {
481 blocks.insert(
482 offset,
483 json!({
484 "type": "tool_result",
485 "tool_use_id": id,
486 "content": UNEXECUTED_TOOL_RESULT,
487 "is_error": true,
488 }),
489 );
490 }
491 }
492 index += 1;
493 }
494 }
495
496 fn assistant_tool_use_ids(message: &Value) -> Vec<String> {
497 if message.get("role").and_then(Value::as_str) != Some("assistant") {
498 return Vec::new();
499 }
500 message
501 .get("content")
502 .and_then(Value::as_array)
503 .map(|blocks| {
504 blocks
505 .iter()
506 .filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use"))
507 .filter_map(|block| block.get("id").and_then(Value::as_str))
508 .map(str::to_string)
509 .collect()
510 })
511 .unwrap_or_default()
512 }
513
514 /// Models that reject `temperature` / `top_p` outright (Claude 4.7+).
515 fn anthropic_model_rejects_sampling(model: &str) -> bool {
516 let lower = model.to_ascii_lowercase();
517 lower.contains("opus-4-7")
518 || lower.contains("opus-4-8")
519 || lower.contains("fable")
520 || lower.contains("mythos")
521 }
522
523 /// Convert the engine's `tool_choice` value (OpenAI-style string or object)
524 /// to the Anthropic object form.
525 fn anthropic_tool_choice(tool_choice: &Value) -> Value {
526 match tool_choice.as_str() {
527 Some("auto") => json!({ "type": "auto" }),
528 Some("none") => json!({ "type": "none" }),
529 Some("any" | "required") => json!({ "type": "any" }),
530 Some(name) => json!({ "type": "tool", "name": name }),
531 None => tool_choice.clone(),
532 }
533 }
534
535 /// Convert one internal message to the Anthropic wire shape. Returns `None`
536 /// when no blocks survive conversion (Anthropic rejects empty content).
537 fn message_to_anthropic(message: &crate::models::Message) -> Option<Value> {
538 let mut blocks: Vec<Value> = message
539 .content
540 .iter()
541 .filter_map(content_block_to_anthropic)
542 .collect();
543 if blocks.is_empty() {
544 return None;
545 }
546 if message.role == crate::models::INTERRUPTED_ASSISTANT_ROLE
547 && let Some(text) = blocks
548 .iter_mut()
549 .find(|block| block.get("type").and_then(Value::as_str) == Some("text"))
550 {
551 let existing = text
552 .get("text")
553 .and_then(Value::as_str)
554 .unwrap_or_default()
555 .to_string();
556 text["text"] = json!(format!(
557 "{}{}",
558 crate::models::INTERRUPTED_ASSISTANT_CONTEXT_PREFIX,
559 existing
560 ));
561 }
562 Some(json!({
563 "role": if message.role == crate::models::INTERRUPTED_ASSISTANT_ROLE {
564 "assistant"
565 } else {
566 message.role.as_str()
567 },
568 "content": blocks
569 }))
570 }
571
572 /// Project the shared `ImageUrl` block onto Anthropic's tagged image source.
573 ///
574 /// The OpenAI dialects carry an image as a single URL string, so that is what
575 /// [`ContentBlock::ImageUrl`] stores. Anthropic instead models the source as a
576 /// tagged union, and — this is the part that used to be wrong here — it does
577 /// **not** accept a `data:` URL under `{"type":"url"}`. Sending a local
578 /// screenshot that way earns an opaque provider-side 400, which is exactly the
579 /// confusing failure this whole path exists to avoid, so the data URL is taken
580 /// back apart into `{"type":"base64", media_type, data}`.
581 fn anthropic_image_block(url: &str) -> Value {
582 if let Some((media_type, data)) = crate::image_attach::parse_data_url(url) {
583 return json!({
584 "type": "image",
585 "source": { "type": "base64", "media_type": media_type, "data": data },
586 });
587 }
588 if crate::image_attach::is_remote_image_url(url) {
589 return json!({
590 "type": "image",
591 "source": { "type": "url", "url": url },
592 });
593 }
594 // Anything else (a bare path, a `file://`, a truncated data URL) has no
595 // Anthropic representation. Degrade to visible text rather than emitting a
596 // source the API will reject: the turn survives and the model can see that
597 // something was meant to be here.
598 json!({
599 "type": "text",
600 "text": format!("[unsupported image reference: {url}]"),
601 })
602 }
603
604 fn content_block_to_anthropic(block: &ContentBlock) -> Option<Value> {
605 match block {
606 ContentBlock::Text {
607 text,
608 cache_control,
609 } => {
610 let mut value = json!({ "type": "text", "text": text });
611 if let Some(cache) = cache_control {
612 value["cache_control"] = json!({ "type": cache.cache_type });
613 }
614 Some(value)
615 }
616 ContentBlock::Thinking {
617 thinking,
618 signature,
619 } => {
620 // Anthropic rejects unsigned thinking blocks on replay (and the
621 // DeepSeek-era "(reasoning omitted)" placeholders mean nothing to
622 // it), so only signed blocks are replayed — verbatim, signature
623 // included.
624 signature.as_ref().map(|signature| {
625 json!({
626 "type": "thinking",
627 "thinking": thinking,
628 "signature": signature,
629 })
630 })
631 }
632 ContentBlock::ToolUse {
633 id, name, input, ..
634 } => Some(json!({
635 "type": "tool_use",
636 "id": id,
637 "name": name,
638 "input": input,
639 })),
640 ContentBlock::ToolResult {
641 tool_use_id,
642 content,
643 is_error,
644 ..
645 } => {
646 let mut value = json!({
647 "type": "tool_result",
648 "tool_use_id": tool_use_id,
649 "content": content,
650 });
651 if let Some(is_error) = is_error {
652 value["is_error"] = json!(is_error);
653 }
654 Some(value)
655 }
656 ContentBlock::ImageUrl { image_url } => Some(anthropic_image_block(&image_url.url)),
657 // Server-tool block types are DeepSeek/internal concepts with no
658 // Anthropic client-side wire equivalent.
659 ContentBlock::ServerToolUse { .. }
660 | ContentBlock::ToolSearchToolResult { .. }
661 | ContentBlock::CodeExecutionToolResult { .. } => None,
662 }
663 }
664
665 /// Enforce the prefix-zone breakpoint policy (#3014):
666 /// 1. the last tool in the catalog (or, with no tools, the last system
667 /// block) — caches the immutable prefix;
668 /// 2. the last content block of the most recent user turn — caches the
669 /// append-only history.
670 ///
671 /// Caller-provided breakpoints are preserved, but the total is capped at
672 /// [`MAX_CACHE_BREAKPOINTS`] by dropping the earliest markers first (the
673 /// latest markers cover the longest prefixes).
674 fn apply_anthropic_cache_breakpoints(body: &mut Value) {
675 // Place breakpoint 1: prefer the last tool; otherwise last system block.
676 let mut placed_prefix = false;
677 if let Some(tools) = body.get_mut("tools").and_then(Value::as_array_mut)
678 && let Some(last) = tools.last_mut()
679 {
680 last["cache_control"] = json!({ "type": "ephemeral" });
681 placed_prefix = true;
682 }
683 if !placed_prefix
684 && let Some(system) = body.get_mut("system").and_then(Value::as_array_mut)
685 && let Some(last) = system.last_mut()
686 {
687 last["cache_control"] = json!({ "type": "ephemeral" });
688 }
689
690 // Place breakpoint 2: last content block of the latest user message.
691 if let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut)
692 && let Some(last_user) = messages
693 .iter_mut()
694 .rev()
695 .find(|message| message.get("role").and_then(Value::as_str) == Some("user"))
696 && let Some(last_block) = last_user
697 .get_mut("content")
698 .and_then(Value::as_array_mut)
699 .and_then(|blocks| blocks.last_mut())
700 {
701 last_block["cache_control"] = json!({ "type": "ephemeral" });
702 }
703
704 // Cap at MAX_CACHE_BREAKPOINTS in render order (tools → system →
705 // messages), dropping the earliest extras.
706 let mut marked: Vec<*mut Value> = Vec::new();
707 let collect = |value: Option<&mut Value>| {
708 let Some(array) = value.and_then(Value::as_array_mut) else {
709 return Vec::new();
710 };
711 array
712 .iter_mut()
713 .filter(|item| item.get("cache_control").is_some())
714 .map(|item| item as *mut Value)
715 .collect::<Vec<_>>()
716 };
717 marked.extend(collect(body.get_mut("tools")));
718 marked.extend(collect(body.get_mut("system")));
719 if let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) {
720 for message in messages.iter_mut() {
721 if let Some(blocks) = message.get_mut("content").and_then(Value::as_array_mut) {
722 marked.extend(
723 blocks
724 .iter_mut()
725 .filter(|block| block.get("cache_control").is_some())
726 .map(|block| block as *mut Value),
727 );
728 }
729 }
730 }
731 if marked.len() > MAX_CACHE_BREAKPOINTS {
732 let excess = marked.len() - MAX_CACHE_BREAKPOINTS;
733 for pointer in marked.into_iter().take(excess) {
734 // SAFETY: the pointers were collected from `body`, which is
735 // exclusively borrowed for the duration of this function, and
736 // each pointer targets a distinct JSON node.
737 unsafe {
738 if let Some(map) = (*pointer).as_object_mut() {
739 map.remove("cache_control");
740 }
741 }
742 }
743 }
744 }
745
746 /// Convert one SSE `data:` payload into a [`StreamEvent`], normalizing usage
747 /// objects to the #2961 convention. Returns `None` for ignorable payloads.
748 fn convert_anthropic_sse_data(data: &str) -> Option<Result<StreamEvent>> {
749 let trimmed = data.trim();
750 if trimmed.is_empty() {
751 return None;
752 }
753 let mut value: Value = match serde_json::from_str(trimmed) {
754 Ok(value) => value,
755 Err(e) => return Some(Err(anyhow::anyhow!("invalid SSE JSON: {e}"))),
756 };
757
758 match value.get("type").and_then(Value::as_str) {
759 Some("message_start") => {
760 if let Some(usage) = value
761 .get_mut("message")
762 .and_then(|message| message.get_mut("usage"))
763 {
764 *usage = json!(parse_anthropic_usage(usage));
765 }
766 }
767 Some("message_delta") => {
768 if let Some(usage) = value.get_mut("usage") {
769 *usage = json!(parse_anthropic_usage(usage));
770 }
771 }
772 // Tolerate unknown event types (e.g. future additions) silently.
773 Some(known)
774 if !matches!(
775 known,
776 "message_start"
777 | "content_block_start"
778 | "content_block_delta"
779 | "content_block_stop"
780 | "message_delta"
781 | "message_stop"
782 | "ping"
783 | "error"
784 ) =>
785 {
786 return None;
787 }
788 _ => {}
789 }
790
791 Some(serde_json::from_value(value).map_err(|e| anyhow::anyhow!("unrecognized SSE event: {e}")))
792 }
793
794 /// Map Anthropic's usage payload onto the normalized [`Usage`] convention
795 /// (#2961 / #4318): hit = cache reads, write = cache creation, miss = raw
796 /// uncached input, `input_tokens` = the total prompt across all three.
797 fn parse_anthropic_usage(usage: &Value) -> Usage {
798 let field = |name: &str| {
799 usage
800 .get(name)
801 .and_then(Value::as_u64)
802 .and_then(|value| u32::try_from(value).ok())
803 .unwrap_or(0)
804 };
805 let input_raw = field("input_tokens");
806 let cache_creation = field("cache_creation_input_tokens");
807 let cache_read = field("cache_read_input_tokens");
808 let output = field("output_tokens");
809
810 Usage {
811 input_tokens: input_raw
812 .saturating_add(cache_creation)
813 .saturating_add(cache_read),
814 output_tokens: output,
815 prompt_cache_hit_tokens: Some(cache_read),
816 prompt_cache_miss_tokens: Some(input_raw),
817 prompt_cache_write_tokens: Some(cache_creation),
818 reasoning_tokens: None,
819 reasoning_replay_tokens: None,
820 server_tool_use: None,
821 }
822 }
823
824 /// Extract `error.type` / `error.message` from an Anthropic error envelope
825 /// (`{"type":"error","error":{"type":...,"message":...}}`), falling back to
826 /// the raw body so nothing is swallowed.
827 fn parse_anthropic_error_envelope(raw: &str) -> (String, String) {
828 let Ok(value) = serde_json::from_str::<Value>(raw) else {
829 return ("unknown".to_string(), raw.to_string());
830 };
831 let error = value.get("error").unwrap_or(&value);
832 anthropic_error_fields(error)
833 }
834
835 fn anthropic_error_fields(error: &Value) -> (String, String) {
836 let error_type = error
837 .get("type")
838 .and_then(Value::as_str)
839 .unwrap_or("unknown")
840 .to_string();
841 let message = error
842 .get("message")
843 .and_then(Value::as_str)
844 .map(str::to_string)
845 .unwrap_or_else(|| error.to_string());
846 (error_type, message)
847 }
848
849 #[cfg(test)]
850 mod tests {
851 use super::*;
852 use crate::models::{CacheControl, Message, SystemBlock, SystemPrompt, Tool};
853
854 fn request_with(
855 model: &str,
856 reasoning_effort: Option<&str>,
857 temperature: Option<f32>,
858 top_p: Option<f32>,
859 ) -> MessageRequest {
860 MessageRequest {
861 model: model.to_string(),
862 messages: vec![Message {
863 role: "user".to_string(),
864 content: vec![ContentBlock::Text {
865 text: "hello".to_string(),
866 cache_control: None,
867 }],
868 }],
869 max_tokens: 1024,
870 system: Some(SystemPrompt::Blocks(vec![SystemBlock {
871 block_type: "text".to_string(),
872 text: "be helpful".to_string(),
873 cache_control: Some(CacheControl {
874 cache_type: "ephemeral".to_string(),
875 }),
876 }])),
877 tools: None,
878 tool_choice: None,
879 metadata: None,
880 thinking: None,
881 reasoning_effort: reasoning_effort.map(str::to_string),
882 stream: Some(true),
883 temperature,
884 top_p,
885 }
886 }
887
888 fn test_client() -> DeepSeekClient {
889 anthropic_test_client(None)
890 }
891
892 fn anthropic_test_client(base_url: Option<&str>) -> DeepSeekClient {
893 let _ = rustls::crypto::ring::default_provider().install_default();
894 let config = crate::config::Config {
895 provider: Some("anthropic".to_string()),
896 providers: Some(crate::config::ProvidersConfig {
897 anthropic: crate::config::ProviderConfig {
898 api_key: Some("test-key".to_string()),
899 base_url: base_url.map(str::to_string),
900 ..Default::default()
901 },
902 ..Default::default()
903 }),
904 ..Default::default()
905 };
906 DeepSeekClient::new(&config).expect("anthropic client constructs")
907 }
908
909 fn minimax_test_client() -> DeepSeekClient {
910 minimax_test_client_for(crate::config::DEFAULT_MINIMAX_ANTHROPIC_BASE_URL)
911 }
912
913 fn minimax_test_client_for(base_url: &str) -> DeepSeekClient {
914 let _ = rustls::crypto::ring::default_provider().install_default();
915 let config = crate::config::Config {
916 provider: Some("minimax-anthropic".to_string()),
917 providers: Some(crate::config::ProvidersConfig {
918 minimax_anthropic: crate::config::ProviderConfig {
919 api_key: Some("test-key".to_string()),
920 base_url: Some(base_url.to_string()),
921 ..Default::default()
922 },
923 ..Default::default()
924 }),
925 ..Default::default()
926 };
927 DeepSeekClient::new(&config).expect("MiniMax Messages client constructs")
928 }
929
930 fn deepseek_test_client(base_url: &str) -> DeepSeekClient {
931 let _ = rustls::crypto::ring::default_provider().install_default();
932 let config = crate::config::Config {
933 provider: Some("deepseek-anthropic".to_string()),
934 providers: Some(crate::config::ProvidersConfig {
935 deepseek_anthropic: crate::config::ProviderConfig {
936 api_key: Some("test-key".to_string()),
937 base_url: Some(base_url.to_string()),
938 ..Default::default()
939 },
940 ..Default::default()
941 }),
942 ..Default::default()
943 };
944 DeepSeekClient::new(&config).expect("DeepSeek Messages client constructs")
945 }
946
947 fn modelstudio_test_client(base_url: &str) -> DeepSeekClient {
948 let _ = rustls::crypto::ring::default_provider().install_default();
949 let config = crate::config::Config {
950 provider: Some("modelstudio-token-plan-anthropic".to_string()),
951 providers: Some(crate::config::ProvidersConfig {
952 // All four plan/dialect variants share one key slot
953 // (modelstudio-token-plan); only the base URL is read from the
954 // anthropic entry.
955 modelstudio_token_plan: crate::config::ProviderConfig {
956 api_key: Some("test-key".to_string()),
957 ..Default::default()
958 },
959 modelstudio_token_plan_anthropic: crate::config::ProviderConfig {
960 base_url: Some(base_url.to_string()),
961 ..Default::default()
962 },
963 ..Default::default()
964 }),
965 ..Default::default()
966 };
967 DeepSeekClient::new(&config).expect("Model Studio Messages client constructs")
968 }
969
970 #[test]
971 fn body_keeps_native_cache_control_on_system_and_tools() {
972 let client = test_client();
973 let mut request = request_with("claude-sonnet-4-6", Some("high"), None, None);
974 request.tools = Some(vec![Tool {
975 tool_type: None,
976 name: "read_file".to_string(),
977 description: "Read a file".to_string(),
978 input_schema: json!({"type": "object", "additionalProperties": false}),
979 allowed_callers: None,
980 defer_loading: None,
981 input_examples: None,
982 strict: Some(true),
983 cache_control: None,
984 }]);
985
986 let body = client.build_anthropic_body(&request, true);
987
988 assert_eq!(
989 body.pointer("/system/0/cache_control/type")
990 .and_then(Value::as_str),
991 Some("ephemeral"),
992 "system cache_control must survive natively: {body}"
993 );
994 assert_eq!(
995 body.pointer("/tools/0/strict").and_then(Value::as_bool),
996 Some(true)
997 );
998 assert_eq!(
999 body.pointer("/tools/0/cache_control/type")
1000 .and_then(Value::as_str),
1001 Some("ephemeral"),
1002 "breakpoint 1 lands on the last tool: {body}"
1003 );
1004 // Breakpoint 2 lands on the latest user turn's last block.
1005 assert_eq!(
1006 body.pointer("/messages/0/content/0/cache_control/type")
1007 .and_then(Value::as_str),
1008 Some("ephemeral")
1009 );
1010 }
1011
1012 #[test]
1013 fn body_maps_reasoning_effort_to_adaptive_thinking_and_effort() {
1014 let client = test_client();
1015
1016 let body = client.build_anthropic_body(
1017 &request_with("claude-sonnet-4-6", Some("high"), None, None),
1018 true,
1019 );
1020 assert_eq!(
1021 body.pointer("/thinking/type").and_then(Value::as_str),
1022 Some("adaptive")
1023 );
1024 assert_eq!(
1025 body.pointer("/output_config/effort")
1026 .and_then(Value::as_str),
1027 Some("high")
1028 );
1029
1030 let body = client.build_anthropic_body(
1031 &request_with("claude-opus-4-8", Some("xhigh"), None, None),
1032 true,
1033 );
1034 assert_eq!(
1035 body.pointer("/output_config/effort")
1036 .and_then(Value::as_str),
1037 Some("max")
1038 );
1039
1040 let body = client.build_anthropic_body(
1041 &request_with("claude-sonnet-4-6", Some("off"), None, None),
1042 true,
1043 );
1044 assert!(body.get("thinking").is_none(), "off omits thinking: {body}");
1045 assert!(body.get("output_config").is_none());
1046
1047 // Haiku is not thinking-capable: no thinking, no effort.
1048 let body = client.build_anthropic_body(
1049 &request_with("claude-haiku-4-5", Some("high"), None, None),
1050 true,
1051 );
1052 assert!(body.get("thinking").is_none(), "{body}");
1053 assert!(body.get("output_config").is_none(), "{body}");
1054 }
1055
1056 #[test]
1057 fn compat_gateway_sends_enabled_budget_thinking_instead_of_adaptive() {
1058 // #4978: strict Anthropic-compatible gateways (e.g. Sensenova) reject
1059 // {"type":"adaptive"} with a 400; non-native routes must send the
1060 // documented enabled+budget shape and no output_config.
1061 let client = anthropic_test_client(Some("https://api.sensenova.example/v1"));
1062
1063 let mut request = request_with("claude-sonnet-4-6", Some("high"), None, None);
1064 request.max_tokens = 64_000;
1065 let body = client.build_anthropic_body(&request, true);
1066 assert_eq!(
1067 body.pointer("/thinking/type").and_then(Value::as_str),
1068 Some("enabled"),
1069 "{body}"
1070 );
1071 assert_eq!(
1072 body.pointer("/thinking/budget_tokens")
1073 .and_then(Value::as_u64),
1074 Some(16_384)
1075 );
1076 assert!(body.get("output_config").is_none(), "{body}");
1077
1078 // Effort tiers map onto budgets, capped below max_tokens.
1079 let mut request = request_with("claude-sonnet-4-6", Some("max"), None, None);
1080 request.max_tokens = 64_000;
1081 let body = client.build_anthropic_body(&request, true);
1082 assert_eq!(
1083 body.pointer("/thinking/budget_tokens")
1084 .and_then(Value::as_u64),
1085 Some(32_768)
1086 );
1087 let mut request = request_with("claude-sonnet-4-6", Some("max"), None, None);
1088 request.max_tokens = 8_000;
1089 let body = client.build_anthropic_body(&request, true);
1090 assert_eq!(
1091 body.pointer("/thinking/budget_tokens")
1092 .and_then(Value::as_u64),
1093 Some(7_999),
1094 "budget stays below max_tokens: {body}"
1095 );
1096
1097 // Unspecified effort defaults to the "high" tier.
1098 let mut request = request_with("claude-sonnet-4-6", None, None, None);
1099 request.max_tokens = 64_000;
1100 let body = client.build_anthropic_body(&request, true);
1101 assert_eq!(
1102 body.pointer("/thinking/type").and_then(Value::as_str),
1103 Some("enabled")
1104 );
1105 assert_eq!(
1106 body.pointer("/thinking/budget_tokens")
1107 .and_then(Value::as_u64),
1108 Some(16_384)
1109 );
1110
1111 // "off" and requests too small for the 1024-token minimum budget
1112 // omit thinking entirely.
1113 let mut request = request_with("claude-sonnet-4-6", Some("off"), None, None);
1114 request.max_tokens = 64_000;
1115 let body = client.build_anthropic_body(&request, true);
1116 assert!(body.get("thinking").is_none(), "{body}");
1117 let body = client.build_anthropic_body(
1118 &request_with("claude-sonnet-4-6", Some("high"), None, None),
1119 true,
1120 );
1121 assert!(
1122 body.get("thinking").is_none(),
1123 "max_tokens=1024 cannot fit the minimum budget: {body}"
1124 );
1125
1126 // The native route keeps adaptive; the compat shape is only for
1127 // non-anthropic.com hosts.
1128 let native = test_client().build_anthropic_body(
1129 &request_with("claude-sonnet-4-6", Some("high"), None, None),
1130 true,
1131 );
1132 assert_eq!(
1133 native.pointer("/thinking/type").and_then(Value::as_str),
1134 Some("adaptive")
1135 );
1136 }
1137
1138 #[test]
1139 fn dangling_tool_use_gets_placeholder_tool_result() {
1140 // #5002: an orphaned tool_use with no matching tool_result poisons
1141 // the conversation with repeated 400s; request preparation must
1142 // repair it with an explicit placeholder result.
1143 let client = test_client();
1144 let mut request = request_with("claude-sonnet-4-6", None, None, None);
1145 request.messages = vec![
1146 Message {
1147 role: "user".to_string(),
1148 content: vec![ContentBlock::Text {
1149 text: "run both tools".to_string(),
1150 cache_control: None,
1151 }],
1152 },
1153 Message {
1154 role: "assistant".to_string(),
1155 content: vec![
1156 ContentBlock::ToolUse {
1157 id: "toolu_ok".to_string(),
1158 name: "read_file".to_string(),
1159 input: json!({"path": "a.txt"}),
1160 caller: None,
1161 },
1162 ContentBlock::ToolUse {
1163 id: "toolu_orphan".to_string(),
1164 name: "task".to_string(),
1165 input: json!({}),
1166 caller: None,
1167 },
1168 ],
1169 },
1170 // Pre-dispatch failure left only one tool_result behind.
1171 Message {
1172 role: "user".to_string(),
1173 content: vec![ContentBlock::ToolResult {
1174 tool_use_id: "toolu_ok".to_string(),
1175 content: "contents".to_string(),
1176 is_error: None,
1177 content_blocks: None,
1178 }],
1179 },
1180 // Trailing assistant tool_use with no user turn at all.
1181 Message {
1182 role: "assistant".to_string(),
1183 content: vec![ContentBlock::ToolUse {
1184 id: "toolu_tail".to_string(),
1185 name: "task".to_string(),
1186 input: json!({}),
1187 caller: None,
1188 }],
1189 },
1190 ];
1191
1192 let body = client.build_anthropic_body(&request, true);
1193 let messages = body["messages"].as_array().expect("messages array");
1194 assert_eq!(messages.len(), 5, "a repair turn is appended: {body}");
1195
1196 // The orphaned id gets a leading placeholder; the answered one is
1197 // untouched (no duplicate result).
1198 let repaired = messages[2]["content"].as_array().expect("user content");
1199 assert_eq!(repaired.len(), 2, "{body}");
1200 assert_eq!(repaired[0]["type"].as_str(), Some("tool_result"));
1201 assert_eq!(repaired[0]["tool_use_id"].as_str(), Some("toolu_orphan"));
1202 assert_eq!(
1203 repaired[0]["content"].as_str(),
1204 Some(UNEXECUTED_TOOL_RESULT)
1205 );
1206 assert_eq!(repaired[0]["is_error"].as_bool(), Some(true));
1207 assert_eq!(repaired[1]["tool_use_id"].as_str(), Some("toolu_ok"));
1208 assert_eq!(repaired[1]["content"].as_str(), Some("contents"));
1209
1210 // The trailing tool_use gains a synthesized user turn.
1211 assert_eq!(messages[4]["role"].as_str(), Some("user"));
1212 let tail = messages[4]["content"].as_array().expect("tail content");
1213 assert_eq!(tail.len(), 1, "{body}");
1214 assert_eq!(tail[0]["type"].as_str(), Some("tool_result"));
1215 assert_eq!(tail[0]["tool_use_id"].as_str(), Some("toolu_tail"));
1216 assert_eq!(tail[0]["content"].as_str(), Some(UNEXECUTED_TOOL_RESULT));
1217
1218 // A fully answered history is left alone.
1219 request.messages.truncate(3);
1220 request.messages[1].content.retain(
1221 |block| !matches!(block, ContentBlock::ToolUse { id, .. } if id == "toolu_orphan"),
1222 );
1223 let body = client.build_anthropic_body(&request, true);
1224 let messages = body["messages"].as_array().expect("messages array");
1225 assert_eq!(messages.len(), 3, "no repair turn appended: {body}");
1226 let untouched = messages[2]["content"].as_array().expect("user content");
1227 assert_eq!(untouched.len(), 1, "{body}");
1228 assert_eq!(untouched[0]["tool_use_id"].as_str(), Some("toolu_ok"));
1229 }
1230
1231 #[test]
1232 fn modelstudio_messages_body_requests_thinking_with_budget() {
1233 // Model Studio's Anthropic-compatible endpoint documents the portable
1234 // {"type":"enabled","budget_tokens":N} shape plus {"type":"disabled"}
1235 // (alibabacloud.com/help/en/model-studio/anthropic-api-messages).
1236 let client = modelstudio_test_client(
1237 "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic",
1238 );
1239
1240 let mut request = request_with("qwen3.8-max", Some("high"), None, None);
1241 request.max_tokens = 64_000;
1242 let body = client.build_anthropic_body(&request, true);
1243 assert_eq!(
1244 body.pointer("/thinking/type").and_then(Value::as_str),
1245 Some("enabled"),
1246 "{body}"
1247 );
1248 assert!(
1249 body.pointer("/thinking/budget_tokens")
1250 .and_then(Value::as_u64)
1251 .is_some(),
1252 "{body}"
1253 );
1254 assert!(body.get("output_config").is_none(), "{body}");
1255 assert_eq!(
1256 body.get("model").and_then(Value::as_str),
1257 Some("qwen3.8-max"),
1258 "{body}"
1259 );
1260
1261 // An explicit "off" is honored on the wire instead of silently
1262 // falling through to the server default (thinking-ON for qwen3.x).
1263 let mut request = request_with("qwen3.8-max", Some("off"), None, None);
1264 request.max_tokens = 64_000;
1265 let body = client.build_anthropic_body(&request, true);
1266 assert_eq!(
1267 body.pointer("/thinking/type").and_then(Value::as_str),
1268 Some("disabled"),
1269 "{body}"
1270 );
1271 }
1272
1273 #[test]
1274 fn deepseek_messages_body_retires_aliases_and_keeps_thinking_control() {
1275 let client = deepseek_test_client(crate::config::DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL);
1276
1277 let chat = client.build_anthropic_body(
1278 &request_with("deepseek-chat", Some("off"), None, None),
1279 true,
1280 );
1281 assert_eq!(
1282 chat.get("model").and_then(Value::as_str),
1283 Some(crate::config::DEEPSEEK_ALIAS_REPLACEMENT)
1284 );
1285 assert_eq!(
1286 chat.pointer("/thinking/type").and_then(Value::as_str),
1287 Some("disabled")
1288 );
1289
1290 let reasoner = client.build_anthropic_body(
1291 &request_with("deepseek-reasoner", Some("high"), None, None),
1292 true,
1293 );
1294 assert_eq!(
1295 reasoner.get("model").and_then(Value::as_str),
1296 Some(crate::config::DEEPSEEK_ALIAS_REPLACEMENT)
1297 );
1298 assert_eq!(
1299 reasoner.pointer("/thinking/type").and_then(Value::as_str),
1300 Some("adaptive")
1301 );
1302 assert_eq!(
1303 reasoner
1304 .pointer("/output_config/effort")
1305 .and_then(Value::as_str),
1306 Some("high")
1307 );
1308
1309 let custom = deepseek_test_client("https://messages.example/v1");
1310 let custom_body = custom.build_anthropic_body(
1311 &request_with("deepseek-reasoner", Some("high"), None, None),
1312 true,
1313 );
1314 assert_eq!(
1315 custom_body.get("model").and_then(Value::as_str),
1316 Some("deepseek-reasoner")
1317 );
1318 }
1319
1320 #[test]
1321 fn omitted_alias_effort_is_migrated_into_deepseek_messages_body() {
1322 for (alias, expected_effort, expected_thinking) in [
1323 ("deepseek-chat", "off", "disabled"),
1324 ("deepseek-reasoner", "high", "adaptive"),
1325 ] {
1326 let mut config = crate::config::Config {
1327 provider: Some("deepseek-anthropic".to_string()),
1328 providers: Some(crate::config::ProvidersConfig {
1329 deepseek_anthropic: crate::config::ProviderConfig {
1330 api_key: Some("test-key".to_string()),
1331 model: Some(alias.to_string()),
1332 ..Default::default()
1333 },
1334 ..Default::default()
1335 }),
1336 ..Default::default()
1337 };
1338 assert!(
1339 config.reasoning_effort().is_none(),
1340 "fixture must omit effort"
1341 );
1342
1343 crate::config::normalize_model_config_for_test(&mut config);
1344 let client = DeepSeekClient::new(&config).expect("DeepSeek Messages client");
1345 let model = config.default_model();
1346 let body = client.build_anthropic_body(
1347 &request_with(&model, config.reasoning_effort(), None, None),
1348 true,
1349 );
1350
1351 assert_eq!(
1352 body.get("model").and_then(Value::as_str),
1353 Some(crate::config::DEEPSEEK_ALIAS_REPLACEMENT),
1354 "{alias}: {body}"
1355 );
1356 assert_eq!(config.reasoning_effort(), Some(expected_effort));
1357 assert_eq!(
1358 body.pointer("/thinking/type").and_then(Value::as_str),
1359 Some(expected_thinking),
1360 "{alias}: {body}"
1361 );
1362 if alias == "deepseek-reasoner" {
1363 assert_eq!(
1364 body.pointer("/output_config/effort")
1365 .and_then(Value::as_str),
1366 Some("high"),
1367 "{body}"
1368 );
1369 } else {
1370 assert!(body.get("output_config").is_none(), "{body}");
1371 }
1372 }
1373 }
1374
1375 #[test]
1376 fn minimax_body_uses_supported_thinking_controls() {
1377 let client = minimax_test_client();
1378 let body =
1379 client.build_anthropic_body(&request_with("MiniMax-M3", Some("off"), None, None), true);
1380 assert_eq!(
1381 body.pointer("/thinking/type").and_then(Value::as_str),
1382 Some("disabled")
1383 );
1384 assert!(body.get("output_config").is_none(), "{body}");
1385
1386 let mut enabled_bodies = Vec::new();
1387 for effort in ["high", "max"] {
1388 let body = client
1389 .build_anthropic_body(&request_with("MiniMax-M3", Some(effort), None, None), true);
1390 assert_eq!(
1391 body.pointer("/thinking/type").and_then(Value::as_str),
1392 Some("adaptive"),
1393 "{effort}: {body}"
1394 );
1395 assert!(body.get("output_config").is_none(), "{effort}: {body}");
1396 enabled_bodies.push(body);
1397 }
1398 assert_eq!(
1399 enabled_bodies[0].get("thinking"),
1400 enabled_bodies[1].get("thinking"),
1401 "MiniMax high/max select the same untiered adaptive wire control"
1402 );
1403 }
1404
1405 #[test]
1406 fn minimax_messages_reasoning_controls_require_exact_first_party_m3_route() {
1407 for (base_url, model) in [
1408 (
1409 "https://gateway.example/anthropic",
1410 crate::config::DEFAULT_MINIMAX_MODEL,
1411 ),
1412 (
1413 crate::config::DEFAULT_MINIMAX_ANTHROPIC_BASE_URL,
1414 "MiniMax-M2",
1415 ),
1416 ] {
1417 let client = minimax_test_client_for(base_url);
1418 for effort in ["off", "high", "max"] {
1419 let body = client
1420 .build_anthropic_body(&request_with(model, Some(effort), None, None), true);
1421 assert!(
1422 body.get("thinking").is_none(),
1423 "{base_url} {model} {effort}: {body}"
1424 );
1425 assert!(
1426 body.get("output_config").is_none(),
1427 "{base_url} {model} {effort}: {body}"
1428 );
1429 }
1430 }
1431 }
1432
1433 #[test]
1434 fn body_drops_sampling_params_for_models_that_reject_them() {
1435 let client = test_client();
1436
1437 let body = client.build_anthropic_body(
1438 &request_with("claude-opus-4-8", None, Some(0.7), Some(0.9)),
1439 true,
1440 );
1441 assert!(body.get("temperature").is_none(), "{body}");
1442 assert!(body.get("top_p").is_none(), "{body}");
1443
1444 // Older models accept ONE of temperature / top_p (temperature wins).
1445 let body = client.build_anthropic_body(
1446 &request_with("claude-sonnet-4-6", None, Some(0.7), Some(0.9)),
1447 true,
1448 );
1449 assert_eq!(
1450 body.get("temperature").and_then(Value::as_f64),
1451 Some(f64::from(0.7f32))
1452 );
1453 assert!(body.get("top_p").is_none(), "never send both: {body}");
1454 }
1455
1456 #[test]
1457 fn body_replays_signed_thinking_and_drops_unsigned_placeholders() {
1458 let client = test_client();
1459 let mut request = request_with("claude-sonnet-4-6", None, None, None);
1460 request.messages = vec![
1461 Message {
1462 role: "user".to_string(),
1463 content: vec![ContentBlock::Text {
1464 text: "do the thing".to_string(),
1465 cache_control: None,
1466 }],
1467 },
1468 Message {
1469 role: "assistant".to_string(),
1470 content: vec![
1471 ContentBlock::Thinking {
1472 thinking: "signed reasoning".to_string(),
1473 signature: Some("sig-abc".to_string()),
1474 },
1475 ContentBlock::Thinking {
1476 thinking: "(reasoning omitted)".to_string(),
1477 signature: None,
1478 },
1479 ContentBlock::ToolUse {
1480 id: "toolu_1".to_string(),
1481 name: "read_file".to_string(),
1482 input: json!({"path": "a.txt"}),
1483 caller: None,
1484 },
1485 ],
1486 },
1487 Message {
1488 role: "user".to_string(),
1489 content: vec![ContentBlock::ToolResult {
1490 tool_use_id: "toolu_1".to_string(),
1491 content: "contents".to_string(),
1492 is_error: None,
1493 content_blocks: None,
1494 }],
1495 },
1496 ];
1497
1498 let body = client.build_anthropic_body(&request, true);
1499 let assistant = &body["messages"][1]["content"];
1500 assert_eq!(assistant.as_array().map(Vec::len), Some(2));
1501 assert_eq!(
1502 assistant[0]["signature"].as_str(),
1503 Some("sig-abc"),
1504 "signed thinking replays verbatim: {assistant}"
1505 );
1506 assert_eq!(assistant[1]["type"].as_str(), Some("tool_use"));
1507 assert!(
1508 assistant[1].get("caller").is_none(),
1509 "internal caller metadata must not reach the wire"
1510 );
1511 assert_eq!(
1512 body["messages"][2]["content"][0]["type"].as_str(),
1513 Some("tool_result")
1514 );
1515 }
1516
1517 #[test]
1518 fn breakpoints_are_capped_at_four_dropping_earliest() {
1519 let client = test_client();
1520 let mut request = request_with("claude-sonnet-4-6", None, None, None);
1521 // Five caller-marked user turns + the two placed breakpoints.
1522 request.messages = (0..5)
1523 .map(|i| Message {
1524 role: "user".to_string(),
1525 content: vec![ContentBlock::Text {
1526 text: format!("turn {i}"),
1527 cache_control: Some(CacheControl {
1528 cache_type: "ephemeral".to_string(),
1529 }),
1530 }],
1531 })
1532 .collect();
1533
1534 let body = client.build_anthropic_body(&request, true);
1535 let mut count = 0;
1536 if body.pointer("/system/0/cache_control").is_some() {
1537 count += 1;
1538 }
1539 for message in body["messages"].as_array().unwrap() {
1540 for block in message["content"].as_array().unwrap() {
1541 if block.get("cache_control").is_some() {
1542 count += 1;
1543 }
1544 }
1545 }
1546 assert!(
1547 count <= MAX_CACHE_BREAKPOINTS,
1548 "breakpoints must be capped at {MAX_CACHE_BREAKPOINTS}, got {count}: {body}"
1549 );
1550 // The latest user turn keeps its marker (longest prefix coverage).
1551 assert!(
1552 body.pointer("/messages/4/content/0/cache_control")
1553 .is_some(),
1554 "{body}"
1555 );
1556 }
1557
1558 #[test]
1559 fn sse_fixture_decodes_text_thinking_signature_and_tool_use() {
1560 use crate::models::{ContentBlockStart, Delta};
1561
1562 let events = [
1563 r#"{"type":"message_start","message":{"id":"msg_01","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-6","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":2045,"cache_read_input_tokens":18000,"output_tokens":1}}}"#,
1564 r#"{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#,
1565 r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Let me check"}}"#,
1566 r#"{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig-xyz"}}"#,
1567 r#"{"type":"content_block_stop","index":0}"#,
1568 r#"{"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}"#,
1569 r#"{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Reading the file."}}"#,
1570 r#"{"type":"content_block_stop","index":1}"#,
1571 r#"{"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"toolu_9","name":"read_file","input":{}}}"#,
1572 r#"{"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"path\":"}}"#,
1573 r#"{"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"\"a.txt\"}"}}"#,
1574 r#"{"type":"content_block_stop","index":2}"#,
1575 r#"{"type":"ping"}"#,
1576 r#"{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":42}}"#,
1577 r#"{"type":"message_stop"}"#,
1578 ];
1579
1580 let decoded: Vec<StreamEvent> = events
1581 .iter()
1582 .map(|data| {
1583 convert_anthropic_sse_data(data)
1584 .expect("known event")
1585 .expect("decodes")
1586 })
1587 .collect();
1588
1589 // message_start usage normalized to the #2961 convention.
1590 let StreamEvent::MessageStart { message } = &decoded[0] else {
1591 panic!("expected MessageStart, got {:?}", decoded[0]);
1592 };
1593 assert_eq!(message.usage.input_tokens, 3 + 2045 + 18000);
1594 assert_eq!(message.usage.prompt_cache_hit_tokens, Some(18000));
1595 assert_eq!(message.usage.prompt_cache_miss_tokens, Some(3));
1596 assert_eq!(message.usage.prompt_cache_write_tokens, Some(2045));
1597
1598 assert!(matches!(
1599 &decoded[1],
1600 StreamEvent::ContentBlockStart {
1601 content_block: ContentBlockStart::Thinking { .. },
1602 ..
1603 }
1604 ));
1605 assert!(matches!(
1606 &decoded[3],
1607 StreamEvent::ContentBlockDelta {
1608 delta: Delta::SignatureDelta { signature },
1609 ..
1610 } if signature == "sig-xyz"
1611 ));
1612 assert!(matches!(
1613 &decoded[6],
1614 StreamEvent::ContentBlockDelta {
1615 delta: Delta::TextDelta { text },
1616 ..
1617 } if text == "Reading the file."
1618 ));
1619 let mut tool_json = String::new();
1620 for event in &decoded {
1621 if let StreamEvent::ContentBlockDelta {
1622 delta: Delta::InputJsonDelta { partial_json },
1623 ..
1624 } = event
1625 {
1626 tool_json.push_str(partial_json);
1627 }
1628 }
1629 assert_eq!(
1630 serde_json::from_str::<Value>(&tool_json).expect("accumulated tool args parse"),
1631 json!({"path": "a.txt"})
1632 );
1633 assert!(matches!(&decoded[12], StreamEvent::Ping));
1634 let StreamEvent::MessageDelta { delta, usage } = &decoded[13] else {
1635 panic!("expected MessageDelta");
1636 };
1637 assert_eq!(delta.stop_reason.as_deref(), Some("tool_use"));
1638 assert_eq!(usage.as_ref().map(|u| u.output_tokens), Some(42));
1639 assert!(matches!(&decoded[14], StreamEvent::MessageStop));
1640 }
1641
1642 #[test]
1643 fn sse_error_event_and_unknown_events_are_handled() {
1644 let error = convert_anthropic_sse_data(
1645 r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#,
1646 )
1647 .expect("error event decodes")
1648 .expect("error event is a StreamEvent");
1649 let StreamEvent::Error { error } = error else {
1650 panic!("expected StreamEvent::Error");
1651 };
1652 let (error_type, message) = anthropic_error_fields(&error);
1653 assert_eq!(error_type, "overloaded_error");
1654 assert_eq!(message, "Overloaded");
1655
1656 assert!(
1657 convert_anthropic_sse_data(r#"{"type":"content_block_started_v2","index":0}"#)
1658 .is_none(),
1659 "unknown event types are tolerated"
1660 );
1661 assert!(convert_anthropic_sse_data(" ").is_none());
1662 }
1663
1664 #[test]
1665 fn usage_mapping_handles_missing_cache_fields() {
1666 let usage = parse_anthropic_usage(&json!({"input_tokens": 10, "output_tokens": 5}));
1667 assert_eq!(usage.input_tokens, 10);
1668 assert_eq!(usage.output_tokens, 5);
1669 assert_eq!(usage.prompt_cache_hit_tokens, Some(0));
1670 assert_eq!(usage.prompt_cache_miss_tokens, Some(10));
1671 assert_eq!(usage.prompt_cache_write_tokens, Some(0));
1672 }
1673
1674 #[test]
1675 fn usage_mapping_keeps_cache_write_separate_from_miss() {
1676 let usage = parse_anthropic_usage(&json!({
1677 "input_tokens": 3,
1678 "cache_creation_input_tokens": 2045,
1679 "cache_read_input_tokens": 18000,
1680 "output_tokens": 1,
1681 }));
1682 assert_eq!(usage.input_tokens, 3 + 2045 + 18000);
1683 assert_eq!(usage.prompt_cache_hit_tokens, Some(18000));
1684 assert_eq!(usage.prompt_cache_miss_tokens, Some(3));
1685 assert_eq!(usage.prompt_cache_write_tokens, Some(2045));
1686 }
1687
1688 #[test]
1689 fn error_envelope_parses_type_and_message() {
1690 let (error_type, message) = parse_anthropic_error_envelope(
1691 r#"{"type":"error","error":{"type":"rate_limit_error","message":"Too many requests"},"request_id":"req_1"}"#,
1692 );
1693 assert_eq!(error_type, "rate_limit_error");
1694 assert_eq!(message, "Too many requests");
1695
1696 let (error_type, message) = parse_anthropic_error_envelope("upstream blew up");
1697 assert_eq!(error_type, "unknown");
1698 assert_eq!(message, "upstream blew up");
1699 }
1700
1701 #[test]
1702 fn data_url_image_becomes_a_base64_source_not_a_url_source() {
1703 // Anthropic rejects a `data:` URL under `{"type":"url"}`. This is the
1704 // whole reason the projection exists; if it regresses, every locally
1705 // attached screenshot 400s on the native route.
1706 let block = content_block_to_anthropic(&ContentBlock::ImageUrl {
1707 image_url: crate::models::ImageUrlContent {
1708 url: "data:image/png;base64,QUJD".to_string(),
1709 },
1710 })
1711 .expect("image block");
1712
1713 assert_eq!(block["type"], "image");
1714 assert_eq!(block["source"]["type"], "base64");
1715 assert_eq!(block["source"]["media_type"], "image/png");
1716 assert_eq!(block["source"]["data"], "QUJD");
1717 assert!(
1718 block["source"].get("url").is_none(),
1719 "base64 sources must not carry a url field: {block}"
1720 );
1721 }
1722
1723 #[test]
1724 fn remote_image_url_stays_a_url_source() {
1725 let block = content_block_to_anthropic(&ContentBlock::ImageUrl {
1726 image_url: crate::models::ImageUrlContent {
1727 url: "https://example.com/shot.png".to_string(),
1728 },
1729 })
1730 .expect("image block");
1731
1732 assert_eq!(block["type"], "image");
1733 assert_eq!(block["source"]["type"], "url");
1734 assert_eq!(block["source"]["url"], "https://example.com/shot.png");
1735 }
1736
1737 #[test]
1738 fn unrepresentable_image_reference_degrades_to_visible_text() {
1739 for url in [
1740 "file:///tmp/shot.png",
1741 "/tmp/shot.png",
1742 "data:image/png,QUJD",
1743 ] {
1744 let block = content_block_to_anthropic(&ContentBlock::ImageUrl {
1745 image_url: crate::models::ImageUrlContent {
1746 url: url.to_string(),
1747 },
1748 })
1749 .expect("block");
1750
1751 assert_eq!(block["type"], "text", "{url} should degrade: {block}");
1752 assert!(
1753 block["text"].as_str().expect("text").contains(url),
1754 "the degraded text should name the reference: {block}"
1755 );
1756 }
1757 }
1758
1759 #[test]
1760 fn messages_url_tolerates_v1_suffix() {
1761 assert_eq!(
1762 anthropic_messages_url("https://api.anthropic.com"),
1763 "https://api.anthropic.com/v1/messages"
1764 );
1765 assert_eq!(
1766 anthropic_messages_url("https://api.anthropic.com/"),
1767 "https://api.anthropic.com/v1/messages"
1768 );
1769 assert_eq!(
1770 anthropic_messages_url("https://gateway.example/v1"),
1771 "https://gateway.example/v1/messages"
1772 );
1773 assert_eq!(
1774 anthropic_messages_url("https://api.deepseek.com/anthropic"),
1775 "https://api.deepseek.com/anthropic/v1/messages"
1776 );
1777 assert_eq!(
1778 anthropic_messages_url("https://api.minimax.io/anthropic"),
1779 "https://api.minimax.io/anthropic/v1/messages"
1780 );
1781 assert_eq!(
1782 anthropic_messages_url("https://api.minimaxi.com/anthropic"),
1783 "https://api.minimaxi.com/anthropic/v1/messages"
1784 );
1785 }
1786
1787 #[test]
1788 fn anthropic_body_serializes_exactly_one_load_skill_definition() {
1789 // The real child catalog fixture (not a hand-built tool list) must
1790 // survive Messages serialization with exactly one load_skill entry —
1791 // no dedup, filter, or sanitizer may drop or duplicate it.
1792 let tools = crate::tools::subagent::kimi_general_child_request_tools_fixture();
1793 assert_eq!(
1794 tools
1795 .iter()
1796 .filter(|tool| tool.name == "load_skill")
1797 .count(),
1798 1,
1799 "catalog fixture carries one load_skill"
1800 );
1801 let client = test_client();
1802 let mut request = request_with("claude-sonnet-4-6", None, None, None);
1803 request.tools = Some(tools);
1804 let body = client.build_anthropic_body(&request, true);
1805 let serialized = body["tools"]
1806 .as_array()
1807 .expect("tools serialize as an array");
1808 let load_skills: Vec<_> = serialized
1809 .iter()
1810 .filter(|tool| tool["name"] == "load_skill")
1811 .collect();
1812 assert_eq!(
1813 load_skills.len(),
1814 1,
1815 "exactly one load_skill definition reaches the Messages wire"
1816 );
1817 assert!(
1818 load_skills[0]["input_schema"]["properties"].is_object(),
1819 "load_skill keeps a valid object schema: {}",
1820 load_skills[0]
1821 );
1822 }
1823
1824 #[tokio::test]
1825 async fn anthropic_stream_opens_through_shared_seam_preserving_headers() {
1826 use futures_util::StreamExt;
1827 use wiremock::matchers::{header, method, path};
1828 use wiremock::{Mock, MockServer, ResponseTemplate};
1829
1830 let server = MockServer::start().await;
1831 // The wire-specific Accept header must survive the shared stream-entry
1832 // open path; the mock only answers when it is present.
1833 Mock::given(method("POST"))
1834 .and(path("/v1/messages"))
1835 .and(header("Accept", "text/event-stream"))
1836 .respond_with(
1837 ResponseTemplate::new(200)
1838 .insert_header("Content-Type", "text/event-stream")
1839 .set_body_string("data: {\"type\":\"message_stop\"}\n\n"),
1840 )
1841 .expect(1)
1842 .mount(&server)
1843 .await;
1844
1845 let client = deepseek_test_client(&server.uri());
1846 let mut stream = client
1847 .handle_anthropic_stream(
1848 &client
1849 .prepare_outbound_request(request_with("deepseek-v4", None, None, None), true)
1850 .expect("anthropic request prepares"),
1851 )
1852 .await
1853 .expect("stream opens through the shared seam");
1854
1855 let mut saw_stop = false;
1856 tokio::time::timeout(std::time::Duration::from_secs(5), async {
1857 while let Some(event) = stream.next().await {
1858 if matches!(event.expect("stream event"), StreamEvent::MessageStop) {
1859 saw_stop = true;
1860 }
1861 }
1862 })
1863 .await
1864 .expect("stream finishes after message_stop");
1865 assert!(saw_stop, "message_stop should arrive through the seam");
1866 }
1867
1868 #[tokio::test]
1869 async fn anthropic_stream_open_error_is_not_retried() {
1870 use wiremock::matchers::{method, path};
1871 use wiremock::{Mock, MockServer, ResponseTemplate};
1872
1873 let server = MockServer::start().await;
1874 // A definitive provider error before any stream body must fail fast:
1875 // exactly one request, no H1 fallback, envelope preserved.
1876 Mock::given(method("POST"))
1877 .and(path("/v1/messages"))
1878 .respond_with(ResponseTemplate::new(401).set_body_string(
1879 "{\"error\":{\"type\":\"authentication_error\",\"message\":\"bad key\"}}",
1880 ))
1881 .expect(1)
1882 .mount(&server)
1883 .await;
1884
1885 let client = deepseek_test_client(&server.uri());
1886 let err = match client
1887 .handle_anthropic_stream(
1888 &client
1889 .prepare_outbound_request(request_with("deepseek-v4", None, None, None), true)
1890 .expect("anthropic request prepares"),
1891 )
1892 .await
1893 {
1894 Ok(_) => panic!("auth errors must fail fast"),
1895 Err(err) => err,
1896 };
1897 let text = err.to_string();
1898 assert!(
1899 text.contains("HTTP 401") && text.contains("authentication_error"),
1900 "error envelope should be preserved: {text}"
1901 );
1902 }
1903 }
1904
1904 lines RUST