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::value::VmError;
pub(crate) use harn_provider_catalog::capabilities::claude_generation;
use harn_provider_catalog::capabilities::is_claude_model_id;
pub(crate) use harn_llm::wire::anthropic::{
forced_tool_choice_allowed, tool_choice_forces_tool_use,
};
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());
}
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 = is_thinking_off_type(
body.get("thinking")
.and_then(|thinking| thinking.get("type"))
.and_then(serde_json::Value::as_str),
);
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 {
!crate::llm::capabilities::lookup("anthropic", model).reasoning_disable_supported
}
fn thinking_off_type(model: &str) -> crate::llm::capabilities::ThinkingOffType {
crate::llm::capabilities::lookup("anthropic", model).thinking_off_type
}
fn is_thinking_off_type(value: Option<&str>) -> bool {
use crate::llm::capabilities::ThinkingOffType;
[ThinkingOffType::Disabled, ThinkingOffType::BetweenTools]
.iter()
.any(|off| value == Some(off.as_str()))
}
fn lower_thinking_off(body: &mut serde_json::Value, model: &str) {
if !model_defaults_to_adaptive_thinking(model) {
let off_type = body
.get("thinking")
.and_then(|thinking| thinking.get("type"))
.and_then(serde_json::Value::as_str);
if off_type == Some(crate::llm::capabilities::ThinkingOffType::BetweenTools.as_str()) {
if let Some(object) = body.as_object_mut() {
object.remove("thinking");
}
}
return;
}
if !model_rejects_disabled_thinking(model) {
body["thinking"] = serde_json::json!({ "type": thinking_off_type(model).as_str() });
return;
}
if let Some(object) = body.as_object_mut() {
object.remove("thinking");
}
let has_effort = body
.get("output_config")
.and_then(|config| config.get("effort"))
.is_some();
if !has_effort && model_supports_anthropic_effort(model) {
set_output_config_effort(body, ANTHROPIC_EFFORT_LADDER[0]);
}
}
fn relower_disabled_thinking_override(body: &mut serde_json::Value, model: &str) {
let requested = body
.get("thinking")
.and_then(|thinking| thinking.get("type"))
.and_then(serde_json::Value::as_str);
if !is_thinking_off_type(requested) {
return;
}
if model_rejects_disabled_thinking(model)
|| requested != Some(thinking_off_type(model).as_str())
{
lower_thinking_off(body, model);
}
}
pub(super) fn default_thinking_budget(max_tokens: i64) -> i64 {
(max_tokens - 1).clamp(1024, 10_000)
}
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,
provider: &str,
fallback_model: &str,
thinking: &ThinkingConfig,
provider_contract_probe: Option<crate::llm::capabilities::PortableOption>,
) {
let wire_model = body.get("model").map_or_else(
|| fallback_model.to_owned(),
|value| value.as_str().unwrap_or_default().to_owned(),
);
remove_unsupported_assistant_prefill(body, provider, &wire_model);
strip_unsupported_sampling_params(body, &wire_model, thinking, provider_contract_probe);
if crate::llm::catalog_may_shape_requested_reasoning() {
relower_disabled_thinking_override(body, &wire_model);
clamp_effort_for_disabled_thinking(body, &wire_model);
}
}
fn remove_unsupported_assistant_prefill(body: &mut serde_json::Value, provider: &str, model: &str) {
let supports_prefill =
crate::llm::capabilities::lookup(provider, model).supports_assistant_prefill;
let uses_native_schema = body
.pointer("/output_config/format/type")
.and_then(serde_json::Value::as_str)
== Some("json_schema");
let thinking_on = body.get("thinking").is_some_and(|thinking| {
thinking.get("type").and_then(serde_json::Value::as_str) != Some("disabled")
});
if supports_prefill && !uses_native_schema && !thinking_on {
return;
}
let Some(messages) = body
.get_mut("messages")
.and_then(serde_json::Value::as_array_mut)
else {
return;
};
let original_len = messages.len();
while messages.last().and_then(|message| message["role"].as_str()) == Some("assistant") {
messages.pop();
}
if messages.len() == original_len {
return;
}
let reason = if uses_native_schema {
"Anthropic native structured output is incompatible with prefill"
} else if thinking_on {
"Anthropic thinking is incompatible with prefill"
} else {
"this Anthropic model does not support prefill"
};
warn_anthropic_prefill_skipped(model, reason);
}
pub(crate) fn strip_unsupported_sampling_params(
body: &mut serde_json::Value,
model: &str,
thinking: &ThinkingConfig,
provider_contract_probe: Option<crate::llm::capabilities::PortableOption>,
) {
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 mut had_sampling = false;
for (field, option) in [
(
"temperature",
crate::llm::capabilities::PortableOption::Temperature,
),
("top_p", crate::llm::capabilities::PortableOption::TopP),
("top_k", crate::llm::capabilities::PortableOption::TopK),
] {
if crate::llm::provider_contract_probe::catalog_may_shape_requested_portable_option(
provider_contract_probe,
option,
) {
had_sampling = object.remove(field).is_some() || had_sampling;
}
}
if had_sampling {
warn_sampling_stripped(model);
}
}
pub(crate) fn strip_unsupported_bedrock_converse_sampling_params(
body: &mut serde_json::Value,
model: &str,
thinking: &ThinkingConfig,
provider_contract_probe: Option<crate::llm::capabilities::PortableOption>,
) {
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 =
crate::llm::provider_contract_probe::catalog_may_shape_requested_portable_option(
provider_contract_probe,
crate::llm::capabilities::PortableOption::Temperature,
) && inference.remove("temperature").is_some();
let had_top_p = crate::llm::provider_contract_probe::catalog_may_shape_requested_portable_option(
provider_contract_probe,
crate::llm::capabilities::PortableOption::TopP,
) && inference.remove("topP").is_some();
let had_top_k = crate::llm::provider_contract_probe::catalog_may_shape_requested_portable_option(
provider_contract_probe,
crate::llm::capabilities::PortableOption::TopK,
) && 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>,
) -> crate::llm::provider::ProviderChatFuture<'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 wire_model = crate::llm_config::wire_model_id(&opts.model);
let context = harn_llm::wire::anthropic::AnthropicWireContext {
capabilities: &caps,
wire_model: &wire_model,
prompt_cache: crate::llm::prompt_cache::for_request(opts),
tool_search_extensions:
crate::llm::provider::anthropic_tool_search_wire_extensions_enabled(
&opts.provider,
&opts.model,
opts.native_tools.as_deref().unwrap_or(&[]),
),
};
let mut encoded = harn_llm::wire::anthropic::encode_request(opts, &context);
strip_unsupported_sampling_params(
&mut encoded.body,
&opts.model,
&opts.thinking,
opts.provider_contract_probe,
);
for effect in encoded.effects {
match effect {
harn_llm::wire::anthropic::AnthropicWireEffect::SchemaAudit(metadata) => {
crate::llm::providers::schema_compat::publish_audit(metadata);
}
harn_llm::wire::anthropic::AnthropicWireEffect::ForcedJsonOverridesTools => {
warn_forced_json_overrides_tools(&opts.model);
}
}
}
let mut body = encoded.body;
let anthropic_max = body["max_tokens"]
.as_i64()
.expect("encoder writes an integer token cap");
match &opts.thinking {
ThinkingConfig::Disabled => lower_thinking_off(&mut body, &opts.model),
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 {
lower_thinking_off(&mut body, &opts.model);
}
}
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
.map(i64::from)
.unwrap_or_else(|| default_thinking_budget(anthropic_max)),
});
}
}
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 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;