use std::cell::RefCell;
use std::collections::HashSet;
use crate::llm::api::{DeltaSender, LlmRequestPayload, LlmResult, ReasoningEffort, ThinkingConfig};
use crate::llm::provider::{LlmProvider, LlmProviderChat};
use crate::llm::providers::common::parse_major_minor_tail;
use crate::llm::providers::schema_compat::{
sanitize_schema_for_provider, SchemaCompatProfile, SchemaSurface,
};
use crate::value::VmError;
mod tool_history;
use tool_history::{
assistant_tool_use_ids, normalize_tool_call_ids, preserve_orphan_results_as_text,
};
pub(crate) const ANTHROPIC_INTERLEAVED_THINKING_BETA: &str = "interleaved-thinking-2025-05-14";
pub(crate) const COMPUTER_USE_BETA: &str = "computer-use-2025-11-24";
thread_local! {
static ANTHROPIC_PREFILL_WARN_ONCE: RefCell<HashSet<String>> =
RefCell::new(HashSet::new());
static ANTHROPIC_SAMPLING_WARN_ONCE: RefCell<HashSet<String>> =
RefCell::new(HashSet::new());
static ANTHROPIC_ADAPTIVE_WARN_ONCE: RefCell<HashSet<String>> =
RefCell::new(HashSet::new());
static ANTHROPIC_DISABLED_EFFORT_WARN_ONCE: RefCell<HashSet<String>> =
RefCell::new(HashSet::new());
static ANTHROPIC_FORCED_JSON_WARN_ONCE: RefCell<HashSet<String>> =
RefCell::new(HashSet::new());
}
#[expect(
clippy::string_slice,
reason = "idx comes from find() of the ASCII needle on the sliced string"
)]
pub(crate) fn claude_generation(model: &str) -> Option<(u32, u32)> {
let lower = model.to_lowercase();
if !is_claude_model_id(&lower) {
return None;
}
if let Some(tail) = lower.split("claude-").nth(1) {
if tail.as_bytes().first().is_some_and(u8::is_ascii_digit) {
return parse_major_minor_tail(tail);
}
}
for family in ["opus", "sonnet", "haiku", "fable", "mythos"] {
let needle = format!("{family}-");
if let Some(idx) = lower.find(&needle) {
return parse_major_minor_tail(&lower[idx + needle.len()..]);
}
}
None
}
fn is_claude_model_id(model: &str) -> bool {
let lower = model.to_lowercase();
lower.starts_with("claude-") || lower.contains("/claude-") || lower.contains(".claude-")
}
const ANTHROPIC_MESSAGE_KEYS: &[&str] = &["role", "content", "cache_control"];
fn anthropic_cache_control(ttl: Option<crate::llm::api::PromptCacheTtl>) -> serde_json::Value {
let mut cache_control = serde_json::json!({"type": "ephemeral"});
if let Some(ttl) = ttl.and_then(crate::llm::api::PromptCacheTtl::anthropic_ttl_field) {
cache_control["ttl"] = serde_json::json!(ttl);
}
cache_control
}
fn model_rejects_sampling_params(model: &str) -> bool {
let lower = model.to_lowercase();
matches!(claude_generation(&lower), Some((major, minor)) if (major, minor) >= (4, 7))
}
pub(crate) fn model_requires_adaptive_thinking(model: &str) -> bool {
let lower = model.to_lowercase();
matches!(claude_generation(&lower), Some((major, minor)) if (major, minor) >= (4, 7))
}
pub(super) fn model_defaults_to_adaptive_thinking(model: &str) -> bool {
matches!(claude_generation(model), Some((major, _)) if major >= 5)
}
const ANTHROPIC_EFFORT_LADDER: &[&str] = &["low", "medium", "high", "xhigh", "max"];
fn clamp_effort_for_disabled_thinking(body: &mut serde_json::Value, model: &str) {
const CEILING: &str = "high";
if !matches!(claude_generation(model), Some((major, _)) if major >= 5) {
return;
}
let thinking_disabled = body
.get("thinking")
.and_then(|thinking| thinking.get("type"))
.and_then(serde_json::Value::as_str)
== Some("disabled");
if !thinking_disabled {
return;
}
let Some(effort) = body
.get("output_config")
.and_then(|config| config.get("effort"))
.and_then(serde_json::Value::as_str)
else {
return;
};
let rank = |level: &str| ANTHROPIC_EFFORT_LADDER.iter().position(|&e| e == level);
if rank(effort) <= rank(CEILING) {
return;
}
warn_disabled_thinking_effort_clamped(model, effort);
set_output_config_effort(body, CEILING);
}
fn model_rejects_disabled_thinking(model: &str) -> bool {
let lower = model.to_lowercase();
lower.contains("claude-fable-") || lower.contains("claude-mythos-")
}
fn model_supports_anthropic_effort(model: &str) -> bool {
crate::llm::capabilities::lookup("anthropic", model).reasoning_effort_supported
}
fn anthropic_effort_value(level: ReasoningEffort) -> Option<&'static str> {
match level {
ReasoningEffort::None => None,
ReasoningEffort::Minimal | ReasoningEffort::Low => Some("low"),
ReasoningEffort::Medium => Some("medium"),
ReasoningEffort::High => Some("high"),
ReasoningEffort::XHigh => Some("xhigh"),
ReasoningEffort::Max => Some("max"),
}
}
fn set_output_config_effort(body: &mut serde_json::Value, effort: &str) {
set_output_config_field(body, "effort", serde_json::json!(effort));
}
fn set_output_config_field(body: &mut serde_json::Value, key: &str, value: serde_json::Value) {
let Some(body_object) = body.as_object_mut() else {
return;
};
let output_config = body_object
.entry("output_config")
.or_insert_with(|| serde_json::json!({}));
if !output_config.is_object() {
*output_config = serde_json::json!({});
}
output_config[key] = value;
}
#[allow(dead_code)]
pub(crate) fn claude_model_supports_tool_search(model: &str) -> bool {
let lower = model.to_lowercase();
match claude_generation(&lower) {
Some((major, minor)) => {
if lower.contains("haiku-") {
(major, minor) >= (4, 5)
} else {
major >= 4
}
}
None => false,
}
}
fn warn_anthropic_prefill_skipped(model: &str, reason: &str) {
ANTHROPIC_PREFILL_WARN_ONCE.with(|seen| {
let mut seen = seen.borrow_mut();
if seen.insert(model.to_string()) {
crate::events::log_warn(
"llm.prefill",
&format!(
"assistant prefill requested for {model}, but {reason}; sending without it",
),
);
}
});
}
fn warn_sampling_stripped(model: &str) {
ANTHROPIC_SAMPLING_WARN_ONCE.with(|seen| {
let mut seen = seen.borrow_mut();
if seen.insert(model.to_string()) {
crate::events::log_warn(
"llm.sampling",
&format!(
"temperature/top_p/top_k supplied for {model}, but this Anthropic \
request surface rejects non-default sampling params on newer \
Claude models or when thinking is active; stripping them from \
the request",
),
);
}
});
}
pub(crate) fn reconcile_request_body(
body: &mut serde_json::Value,
model: &str,
thinking: &ThinkingConfig,
) {
strip_unsupported_sampling_params(body, model, thinking);
if crate::llm::catalog_may_shape_requested_reasoning() {
clamp_effort_for_disabled_thinking(body, model);
}
}
pub(crate) fn strip_unsupported_sampling_params(
body: &mut serde_json::Value,
model: &str,
thinking: &ThinkingConfig,
) {
let strip_sampling = model_rejects_sampling_params(model)
|| !thinking.is_disabled()
|| body_activates_anthropic_thinking(body);
if !strip_sampling {
return;
}
let Some(object) = body.as_object_mut() else {
return;
};
let had_sampling = object.contains_key("temperature")
|| object.contains_key("top_p")
|| object.contains_key("top_k");
if had_sampling {
warn_sampling_stripped(model);
object.remove("temperature");
object.remove("top_p");
object.remove("top_k");
}
}
pub(crate) fn strip_unsupported_bedrock_converse_sampling_params(
body: &mut serde_json::Value,
model: &str,
thinking: &ThinkingConfig,
) {
if !is_claude_model_id(model) {
return;
}
let strip_sampling = model_rejects_sampling_params(model)
|| !thinking.is_disabled()
|| body_activates_anthropic_thinking(body);
if !strip_sampling {
return;
}
let Some(body_object) = body.as_object_mut() else {
return;
};
let Some(inference) = body_object
.get_mut("inferenceConfig")
.and_then(serde_json::Value::as_object_mut)
else {
return;
};
let had_temperature = inference.remove("temperature").is_some();
let had_top_p = inference.remove("topP").is_some();
let had_top_k = inference.remove("topK").is_some();
let had_sampling = had_temperature || had_top_p || had_top_k;
if had_sampling {
warn_sampling_stripped(model);
}
if inference.is_empty() {
body_object.remove("inferenceConfig");
}
}
fn body_activates_anthropic_thinking(body: &serde_json::Value) -> bool {
value_activates_anthropic_thinking(body.get("thinking"))
|| output_config_activates_thinking(body.get("output_config"))
|| body
.get("additionalModelRequestFields")
.is_some_and(|fields| {
value_activates_anthropic_thinking(fields.get("thinking"))
|| output_config_activates_thinking(fields.get("output_config"))
})
}
fn value_activates_anthropic_thinking(value: Option<&serde_json::Value>) -> bool {
match value {
Some(serde_json::Value::Bool(enabled)) => *enabled,
Some(serde_json::Value::String(mode)) => {
!mode.eq_ignore_ascii_case("disabled") && !mode.eq_ignore_ascii_case("none")
}
Some(serde_json::Value::Object(object)) => object
.get("type")
.and_then(serde_json::Value::as_str)
.is_none_or(|mode| {
!mode.eq_ignore_ascii_case("disabled") && !mode.eq_ignore_ascii_case("none")
}),
Some(serde_json::Value::Null) | None => false,
Some(_) => true,
}
}
fn output_config_activates_thinking(value: Option<&serde_json::Value>) -> bool {
value
.and_then(|config| config.get("effort"))
.and_then(serde_json::Value::as_str)
.is_some_and(|effort| !effort.eq_ignore_ascii_case("none") && !effort.is_empty())
}
fn warn_disabled_thinking_effort_clamped(model: &str, requested: &str) {
ANTHROPIC_DISABLED_EFFORT_WARN_ONCE.with(|seen| {
let mut seen = seen.borrow_mut();
if seen.insert(model.to_string()) {
crate::events::log_warn(
"llm.thinking",
&format!(
"effort `{requested}` is rejected for {model} while thinking is \
disabled; clamping to `high` (enable thinking to use \
`{requested}`)",
),
);
}
});
}
fn warn_adaptive_thinking_rewrite(model: &str) {
ANTHROPIC_ADAPTIVE_WARN_ONCE.with(|seen| {
let mut seen = seen.borrow_mut();
if seen.insert(model.to_string()) {
crate::events::log_warn(
"llm.thinking",
&format!(
"extended-thinking payload supplied for {model}, but Anthropic \
Opus 4.7+ removed that surface; rewriting to \
`thinking: {{type: adaptive}}` (budget_tokens ignored)",
),
);
}
});
}
pub(crate) struct AnthropicProvider;
impl LlmProvider for AnthropicProvider {
fn name(&self) -> &'static str {
"anthropic"
}
fn supports_thinking(&self, model: &str) -> bool {
!crate::llm::capabilities::lookup(self.name(), model)
.thinking_modes
.is_empty()
}
}
impl LlmProviderChat for AnthropicProvider {
fn chat<'a>(
&'a self,
request: &'a LlmRequestPayload,
delta_tx: Option<DeltaSender>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<LlmResult, VmError>> + 'a>> {
Box::pin(self.chat_impl(request, delta_tx))
}
}
impl AnthropicProvider {
pub(crate) fn build_request_body(opts: &LlmRequestPayload) -> serde_json::Value {
let caps = crate::llm::capabilities::lookup(&opts.provider, &opts.model);
let anthropic_max = if opts.max_tokens > 0 {
opts.max_tokens
} else {
8192
};
let mut messages: Vec<serde_json::Value> = opts
.messages
.iter()
.cloned()
.map(|mut message| {
crate::llm::reasoning_history::restore_anthropic_continuation(
&mut message,
caps.reasoning_round_trip,
);
message
})
.map(anthropic_translate_tool_role_message)
.map(anthropic_translate_assistant_tool_calls)
.filter_map(|mut message| {
if let Some(object) = message.as_object_mut() {
if let Some(content) = object.get("content").cloned() {
let content = drop_anthropic_whitespace_text_blocks(
crate::llm::content::anthropic_content_for_request(
&content,
caps.reasoning_round_trip,
),
);
object.insert("content".to_string(), content);
}
object.retain(|key, _| ANTHROPIC_MESSAGE_KEYS.contains(&key.as_str()));
}
if is_empty_anthropic_message(&message) {
None
} else {
Some(message)
}
})
.collect();
normalize_tool_call_ids(&mut messages);
let mut messages = enforce_tool_result_adjacency(messages);
preserve_orphan_results_as_text(&mut messages);
if let Some(ref prefill) = opts.prefill {
let uses_native_schema = matches!(
&opts.output_format,
crate::llm::api::OutputFormat::JsonSchema { .. }
) && caps.structured_output.as_deref() == Some("native");
if caps.supports_assistant_prefill && !uses_native_schema {
messages.push(serde_json::json!({
"role": "assistant",
"content": prefill,
}));
} else if uses_native_schema {
warn_anthropic_prefill_skipped(
&opts.model,
"Anthropic native structured output is incompatible with prefill",
);
} else {
warn_anthropic_prefill_skipped(
&opts.model,
"this Anthropic model does not support prefill",
);
}
}
let wire_model = crate::llm_config::wire_model_id(&opts.model);
let mut body = serde_json::json!({
"model": wire_model,
"messages": messages,
"max_tokens": anthropic_max,
});
if let Some(ref sys) = opts.system {
body["system"] = serde_json::json!(sys);
}
if let Some(temp) = opts.temperature {
body["temperature"] = serde_json::json!(temp);
}
if let Some(top_p) = opts.top_p {
body["top_p"] = serde_json::json!(top_p);
}
if let Some(top_k) = opts.top_k {
body["top_k"] = serde_json::json!(top_k);
}
strip_unsupported_sampling_params(&mut body, &opts.model, &opts.thinking);
if let Some(ref stop) = opts.stop {
body["stop_sequences"] = serde_json::json!(stop);
}
crate::llm::prompt_cache::apply_prompt_cache_breakpoint(
&mut body,
opts.cache,
&caps,
anthropic_cache_control(opts.prompt_cache_ttl),
);
if let Some(ref tools) = opts.native_tools {
if !tools.is_empty() {
let sanitized: Vec<serde_json::Value> = tools
.iter()
.map(|tool| {
sanitize_anthropic_tool_for_request(&opts.provider, &opts.model, tool)
})
.collect();
body["tools"] = serde_json::json!(sanitized);
}
}
if !opts.provider_tools.is_empty() {
let mut tools = body["tools"].as_array().cloned().unwrap_or_default();
for tool in &opts.provider_tools {
tools.push(sanitize_anthropic_tool_for_request(
&opts.provider,
&opts.model,
tool,
));
}
body["tools"] = serde_json::json!(tools);
}
if let Some(ref tc) = opts.tool_choice {
if let Some(normalized) = normalize_anthropic_tool_choice(tc) {
body["tool_choice"] = normalized;
}
}
if body.get("tools").is_some() {
if let Some(parallel) = opts.parallel_tool_calls {
if body.get("tool_choice").is_none() {
body["tool_choice"] = serde_json::json!({"type": "auto"});
}
body["tool_choice"]["disable_parallel_tool_use"] = serde_json::json!(!parallel);
}
}
match &opts.output_format {
crate::llm::api::OutputFormat::Text => {}
crate::llm::api::OutputFormat::JsonObject => {
force_json_via_tool_use(
&mut body,
&serde_json::json!({
"type": "object",
"additionalProperties": true
}),
&opts.model,
);
}
crate::llm::api::OutputFormat::JsonSchema { schema, .. } => {
if caps.structured_output.as_deref() == Some("native") {
set_native_json_schema_output(&mut body, schema, &opts.model);
} else {
force_json_via_tool_use(&mut body, schema, &opts.model);
}
}
}
match &opts.thinking {
ThinkingConfig::Disabled => {
if model_defaults_to_adaptive_thinking(&opts.model)
&& !model_rejects_disabled_thinking(&opts.model)
{
body["thinking"] = serde_json::json!({ "type": "disabled" });
}
}
ThinkingConfig::Adaptive => {
body["thinking"] = serde_json::json!({ "type": "adaptive" });
}
ThinkingConfig::Effort { level } => {
if let Some(effort) = anthropic_effort_value(*level) {
if model_supports_anthropic_effort(&opts.model)
|| !crate::llm::catalog_may_shape_requested_reasoning()
{
set_output_config_effort(&mut body, effort);
}
if !model_defaults_to_adaptive_thinking(&opts.model) {
body["thinking"] = serde_json::json!({ "type": "adaptive" });
}
} else if model_defaults_to_adaptive_thinking(&opts.model)
&& !model_rejects_disabled_thinking(&opts.model)
{
body["thinking"] = serde_json::json!({ "type": "disabled" });
}
}
ThinkingConfig::Enabled { budget_tokens }
if model_requires_adaptive_thinking(&opts.model) =>
{
warn_adaptive_thinking_rewrite(&opts.model);
body["thinking"] = serde_json::json!({ "type": "adaptive" });
}
ThinkingConfig::Enabled { budget_tokens } => {
body["thinking"] = serde_json::json!({
"type": "enabled",
"budget_tokens": budget_tokens.unwrap_or(10000),
});
}
}
crate::llm::serving_tiers::apply_fast_request_knob(&mut body, &opts.model, opts.fast);
body
}
pub(crate) async fn chat_impl(
&self,
request: &LlmRequestPayload,
delta_tx: Option<DeltaSender>,
) -> Result<LlmResult, VmError> {
let dialect = crate::llm::api::DialectContract::for_request(request);
crate::llm::api::vm_call_llm_api_with_body(
request,
delta_tx,
dialect.build_request_body(request),
dialect,
)
.await
}
}
fn drop_anthropic_whitespace_text_blocks(content: serde_json::Value) -> serde_json::Value {
match content {
serde_json::Value::Array(blocks) => serde_json::Value::Array(
blocks
.into_iter()
.filter(|block| !is_whitespace_text_block(block))
.collect(),
),
other => other,
}
}
fn is_whitespace_text_block(block: &serde_json::Value) -> bool {
block.get("type").and_then(|value| value.as_str()) == Some("text")
&& block
.get("text")
.and_then(|value| value.as_str())
.is_some_and(|text| text.trim().is_empty())
}
fn is_empty_anthropic_message(message: &serde_json::Value) -> bool {
match message.get("content") {
Some(serde_json::Value::String(text)) => text.trim().is_empty(),
Some(serde_json::Value::Array(blocks)) => blocks.is_empty(),
_ => false,
}
}
fn anthropic_translate_tool_role_message(message: serde_json::Value) -> serde_json::Value {
let role = message.get("role").and_then(|role| role.as_str());
if role != Some("tool") && role != Some("tool_result") {
return message;
}
let tool_use_id = message
.get("tool_call_id")
.or_else(|| message.get("tool_use_id"))
.or_else(|| message.get("call_id"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string();
let content = message
.get("content")
.cloned()
.unwrap_or(serde_json::Value::String(String::new()));
let mut tool_result = serde_json::Map::new();
tool_result.insert("type".to_string(), serde_json::json!("tool_result"));
tool_result.insert("tool_use_id".to_string(), serde_json::json!(tool_use_id));
tool_result.insert("content".to_string(), content);
if let Some(is_error) = message.get("is_error") {
tool_result.insert("is_error".to_string(), is_error.clone());
}
serde_json::json!({
"role": "user",
"content": [serde_json::Value::Object(tool_result)],
})
}
fn anthropic_translate_assistant_tool_calls(message: serde_json::Value) -> serde_json::Value {
if message.get("role").and_then(|role| role.as_str()) != Some("assistant") {
return message;
}
let Some(tool_calls) = message.get("tool_calls").and_then(|value| value.as_array()) else {
return message;
};
if tool_calls.is_empty() {
return message;
}
let mut blocks: Vec<serde_json::Value> = match message.get("content") {
Some(serde_json::Value::String(text)) if !text.is_empty() => {
vec![serde_json::json!({"type": "text", "text": text})]
}
Some(serde_json::Value::Array(existing)) => existing.clone(),
_ => Vec::new(),
};
for call in tool_calls {
let id = call
.get("id")
.and_then(|value| value.as_str())
.unwrap_or_default();
let function = call.get("function");
let name = function
.and_then(|f| f.get("name"))
.and_then(|value| value.as_str())
.or_else(|| call.get("name").and_then(|value| value.as_str()))
.unwrap_or_default();
let input = match function
.and_then(|f| f.get("arguments"))
.or_else(|| call.get("arguments"))
{
Some(serde_json::Value::String(raw)) => serde_json::from_str::<serde_json::Value>(raw)
.unwrap_or_else(|_| serde_json::json!({})),
Some(other) if other.is_object() => other.clone(),
_ => serde_json::json!({}),
};
blocks.push(serde_json::json!({
"type": "tool_use",
"id": id,
"name": name,
"input": input,
}));
}
let mut out = message;
if let Some(object) = out.as_object_mut() {
object.remove("tool_calls");
object.insert("content".to_string(), serde_json::Value::Array(blocks));
}
out
}
fn enforce_tool_result_adjacency(messages: Vec<serde_json::Value>) -> Vec<serde_json::Value> {
let mut normalized = Vec::with_capacity(messages.len());
let mut cursor = 0;
while cursor < messages.len() {
let message = messages[cursor].clone();
let Some(mut pending_ids) = assistant_tool_use_ids(&message) else {
normalized.push(message);
cursor += 1;
continue;
};
normalized.push(message);
cursor += 1;
let mut results = Vec::new();
let mut deferred = Vec::new();
while cursor < messages.len() && !pending_ids.is_empty() {
let next = messages[cursor].clone();
let matching_ids = matching_tool_result_ids(&next, &pending_ids);
if !matching_ids.is_empty() {
for id in matching_ids {
pending_ids.remove(&id);
}
results.push(next);
cursor += 1;
continue;
}
if is_user_message_without_tool_result(&next) {
deferred.push(next);
cursor += 1;
continue;
}
break;
}
normalized.extend(results);
if !pending_ids.is_empty() {
let mut missing: Vec<String> = pending_ids.into_iter().collect();
missing.sort_unstable();
let placeholders: Vec<serde_json::Value> = missing
.into_iter()
.map(|id| {
serde_json::json!({
"type": "tool_result",
"tool_use_id": id,
"content": "result unavailable (interrupted before dispatch)",
"is_error": true,
})
})
.collect();
normalized.push(serde_json::json!({
"role": "user",
"content": placeholders,
}));
}
normalized.extend(deferred);
}
normalized
}
fn matching_tool_result_ids(
message: &serde_json::Value,
pending_ids: &HashSet<String>,
) -> HashSet<String> {
if message.get("role").and_then(|role| role.as_str()) != Some("user") {
return HashSet::new();
}
message
.get("content")
.and_then(|content| content.as_array())
.into_iter()
.flatten()
.filter_map(|block| {
let block_type = block.get("type").and_then(|value| value.as_str());
let id = block.get("tool_use_id").and_then(|value| value.as_str());
match (block_type, id) {
(Some("tool_result"), Some(id)) if pending_ids.contains(id) => Some(id.to_string()),
_ => None,
}
})
.collect()
}
fn is_user_message_without_tool_result(message: &serde_json::Value) -> bool {
if message.get("role").and_then(|role| role.as_str()) != Some("user") {
return false;
}
!message
.get("content")
.and_then(|content| content.as_array())
.into_iter()
.flatten()
.any(|block| block.get("type").and_then(|value| value.as_str()) == Some("tool_result"))
}
fn sanitize_anthropic_tool_for_request(
provider: &str,
model: &str,
tool: &serde_json::Value,
) -> serde_json::Value {
let mut tool = tool.clone();
if let Some(object) = tool.as_object_mut() {
object.remove("x-harn-output-schema");
object.remove("defer_loading");
object.remove("namespace");
object.remove("namespaces");
if let Some(schema) = object.get("input_schema").cloned() {
object.insert(
"input_schema".to_string(),
sanitize_schema_for_provider(
provider,
model,
SchemaCompatProfile::AnthropicStrict,
SchemaSurface::ToolParameters,
&schema,
),
);
}
}
tool
}
fn normalize_anthropic_tool_choice(value: &serde_json::Value) -> Option<serde_json::Value> {
let attach_parallel = |mut obj: serde_json::Value, src: &serde_json::Value| {
if let Some(flag) = src.get("disable_parallel_tool_use") {
if let Some(map) = obj.as_object_mut() {
map.insert("disable_parallel_tool_use".to_string(), flag.clone());
}
}
obj
};
match value {
serde_json::Value::String(s) => match s.as_str() {
"auto" => Some(serde_json::json!({"type": "auto"})),
"any" | "required" => Some(serde_json::json!({"type": "any"})),
"none" => Some(serde_json::json!({"type": "none"})),
other => Some(serde_json::json!({"type": "tool", "name": other})),
},
serde_json::Value::Object(_) => {
let ty = value.get("type").and_then(|t| t.as_str());
match ty {
Some("auto") => Some(attach_parallel(serde_json::json!({"type": "auto"}), value)),
Some("any") | Some("required") => {
Some(attach_parallel(serde_json::json!({"type": "any"}), value))
}
Some("none") => Some(attach_parallel(serde_json::json!({"type": "none"}), value)),
Some("tool") => {
let name = value.get("name").and_then(|n| n.as_str());
name.map(|name| {
attach_parallel(serde_json::json!({"type": "tool", "name": name}), value)
})
}
Some("function") => {
let name = value
.get("function")
.and_then(|f| f.get("name"))
.and_then(|n| n.as_str());
name.map(|name| {
attach_parallel(serde_json::json!({"type": "tool", "name": name}), value)
})
}
_ => Some(serde_json::json!({"type": "auto"})),
}
}
_ => None,
}
}
pub(crate) fn tool_choice_forces_tool_use(value: &serde_json::Value) -> bool {
normalize_anthropic_tool_choice(value).is_some_and(|choice| {
matches!(
choice.get("type").and_then(serde_json::Value::as_str),
Some("any") | Some("tool")
)
})
}
fn force_json_via_tool_use(body: &mut serde_json::Value, schema: &serde_json::Value, model: &str) {
let had_native_tools = body
.get("tools")
.and_then(|tools| tools.as_array())
.is_some_and(|tools| !tools.is_empty());
let had_tool_choice = body.get("tool_choice").is_some();
if had_native_tools || had_tool_choice {
warn_forced_json_overrides_tools(model);
}
body["tools"] = {
let mut tools = body["tools"].as_array().cloned().unwrap_or_default();
let schema = sanitize_schema_for_provider(
"anthropic",
model,
SchemaCompatProfile::AnthropicStrict,
SchemaSurface::StructuredOutput,
schema,
);
tools.push(serde_json::json!({
"name": "json_response",
"description": "Return a structured JSON response matching the schema.",
"input_schema": schema,
}));
serde_json::json!(tools)
};
body["tool_choice"] = serde_json::json!({"type": "tool", "name": "json_response"});
}
fn set_native_json_schema_output(
body: &mut serde_json::Value,
schema: &serde_json::Value,
model: &str,
) {
let schema = sanitize_schema_for_provider(
"anthropic",
model,
SchemaCompatProfile::AnthropicStrict,
SchemaSurface::StructuredOutput,
schema,
);
set_output_config_field(
body,
"format",
serde_json::json!({"type": "json_schema", "schema": schema}),
);
}
fn warn_forced_json_overrides_tools(model: &str) {
ANTHROPIC_FORCED_JSON_WARN_ONCE.with(|seen| {
let mut seen = seen.borrow_mut();
if seen.insert(model.to_string()) {
crate::events::log_warn(
"llm.structured_output",
&format!(
"structured output (output_format) requested for {model} alongside \
native tools or a tool_choice; forcing the json_response tool, which \
overrides tool_choice and makes the other tools unreachable this turn",
),
);
}
});
}
#[cfg(test)]
mod tests;