mod hardening;
mod messages;
mod system;
mod thinking;
pub(crate) mod tools;
use crate::provider::{
AnthropicOptionalStringOverride, AnthropicOptionalU32Override, AnthropicThinkingConfig, LLMError, LLMRequest,
};
use crate::providers::anthropic_types::{
AnthropicAdvisorCaching, AnthropicAdvisorTool, AnthropicFallbackParam, AnthropicFallbacksKeyword,
AnthropicFallbacksParam, AnthropicOutputConfig, AnthropicOutputFormat, AnthropicRequest, AnthropicTaskBudget,
AnthropicTool, CacheControl, ThinkingConfig, ThinkingDisplay,
};
use serde_json::{Value, json};
use vtcode_config::constants::reasoning;
use vtcode_config::core::{
AdvisorConfig, AnthropicConfig, AnthropicFallbackMode, AnthropicFallbacks, AnthropicPromptCacheSettings,
};
use vtcode_config::types::ReasoningEffortLevel;
use super::capabilities::{
default_effort_for_model, default_max_tokens_for_model, effort_allowed_for_model, preserves_thinking_across_turns,
rejects_forced_tool_choice, resolve_model_name, supports_assistant_prefill, supports_effort,
supports_mid_conversation_system_messages, supports_server_side_fallback, supports_task_budget, thinking_is_on,
};
use super::prompt_cache::{get_tools_cache_ttl, messages_cache_ttl_for_profile};
use messages::{build_messages, hoist_largest_user_message};
use system::{HistorySystemPlacement, SystemPromptBuildResult, build_system_prompt};
use thinking::build_thinking_config;
pub(crate) use thinking::rewrite_thinking_for_model;
use tools::{build_tool_choice, build_tools};
#[cfg(test)]
pub(crate) use messages::tool_result_blocks;
pub(crate) struct RequestBuilderContext<'a> {
pub(crate) prompt_cache_enabled: bool,
pub(crate) prompt_cache_settings: &'a AnthropicPromptCacheSettings,
pub(crate) anthropic_config: &'a AnthropicConfig,
pub(crate) model: &'a str,
pub(crate) server_side_fallbacks_available: bool,
}
fn resolve_messages_ttl(request: &LLMRequest, ctx: &RequestBuilderContext<'_>) -> &'static str {
if !ctx.prompt_cache_enabled {
return "5m";
}
messages_cache_ttl_for_profile(ctx.prompt_cache_settings, request.prompt_cache_profile)
}
pub(crate) fn convert_to_anthropic_format(
request: &LLMRequest,
ctx: &RequestBuilderContext,
) -> Result<Value, LLMError> {
let resolved_model = resolve_model_name(&request.model, ctx.model);
let tools_ttl = if ctx.prompt_cache_enabled {
get_tools_cache_ttl(ctx.prompt_cache_settings)
} else {
"5m"
};
let messages_ttl = resolve_messages_ttl(request, ctx);
let tools_cache_control = if ctx.prompt_cache_enabled && ctx.prompt_cache_settings.cache_tool_definitions {
Some(CacheControl {
control_type: "ephemeral".into(),
ttl: Some(tools_ttl.into()),
})
} else {
None
};
let system_cache_control = if ctx.prompt_cache_enabled && ctx.prompt_cache_settings.cache_system_messages {
Some(CacheControl {
control_type: "ephemeral".into(),
ttl: Some(tools_ttl.into()),
})
} else {
None
};
let max_breakpoints = if ctx.prompt_cache_enabled {
ctx.prompt_cache_settings.max_breakpoints as usize
} else {
0
};
let mut breakpoints_remaining = max_breakpoints;
let tools_breakpoints_before = breakpoints_remaining;
let mut tools = build_tools(request, tools_cache_control.as_ref(), &mut breakpoints_remaining)?;
let tools_breakpoints_used = tools_breakpoints_before.saturating_sub(breakpoints_remaining);
let advisor_injected =
if let Some(advisor_tool) = resolve_advisor_tool(resolved_model, &ctx.anthropic_config.advisor) {
let mut built = tools.unwrap_or_default();
built.push(advisor_tool);
tools = Some(built);
true
} else {
false
};
let allow_mid_conversation_system = supports_mid_conversation_system_messages(resolved_model, ctx.model);
let history_system_placement = HistorySystemPlacement::for_route(allow_mid_conversation_system);
let SystemPromptBuildResult {
mut system_value,
breakpoints_used,
has_uncached_runtime_context,
} = build_system_prompt(request, system_cache_control.as_ref(), breakpoints_remaining, history_system_placement);
breakpoints_remaining = breakpoints_remaining.saturating_sub(breakpoints_used);
if advisor_injected {
let advisor_guidance = concat!(
"You have access to an advisor tool that pairs a faster executor model with a ",
"higher-intelligence advisor model for strategic guidance mid-generation. ",
"Use the advisor tool when you:\n",
"- Need a second opinion on a complex architectural decision\n",
"- Are unsure about the best approach to a multi-step problem\n",
"- Want to validate your plan before executing many tool calls\n",
"- Hit a blocker you cannot resolve alone\n",
"When the advisor returns guidance, incorporate it into your response. ",
"If the advisor suggests a different approach, weigh it against your own reasoning.",
);
let guidance_block = json!({
"type": "text",
"text": advisor_guidance,
});
match &mut system_value {
Some(Value::Array(blocks)) => {
blocks.push(guidance_block);
}
Some(Value::String(text)) => {
let existing = std::mem::take(text);
system_value = Some(Value::Array(vec![json!({ "type": "text", "text": existing }), guidance_block]));
}
_ => {
system_value = Some(Value::Array(vec![guidance_block]));
}
}
}
let messages_cache_control = if ctx.prompt_cache_enabled && ctx.prompt_cache_settings.cache_user_messages {
Some(CacheControl {
control_type: "ephemeral".into(),
ttl: Some(messages_ttl.into()),
})
} else {
None
};
let conversation_messages = &request.messages[history_system_placement.leading_folded_count(&request.messages)..];
let needs_hoisting = request
.coding_agent_settings
.as_ref()
.is_some_and(|s| s.long_context_optimization)
&& conversation_messages.len() > 1
&& !preserves_thinking_across_turns(resolved_model, ctx.model);
let mut hoisted_messages: Vec<crate::provider::Message>;
let messages_to_process: &[crate::provider::Message] = if needs_hoisting {
hoisted_messages = conversation_messages.to_vec();
hoist_largest_user_message(&mut hoisted_messages);
&hoisted_messages
} else {
conversation_messages
};
let messages_breakpoints_before = breakpoints_remaining;
let messages = build_messages(
request,
messages_to_process,
messages_cache_control.as_ref(),
ctx.prompt_cache_settings,
&mut breakpoints_remaining,
ctx.model,
)?;
let messages_breakpoints_used = messages_breakpoints_before.saturating_sub(breakpoints_remaining);
let explicit_breakpoints_used = max_breakpoints.saturating_sub(breakpoints_remaining);
let (thinking_val, reasoning_val) = build_thinking_config(request, ctx.anthropic_config, ctx.model)?;
let anthropic_overrides = request.anthropic_request_overrides.as_ref();
let thinking_is_adaptive = matches!(thinking_val, Some(ThinkingConfig::Adaptive { .. }));
let adaptive_effort = if thinking_is_adaptive && request.effort.is_none() {
request
.reasoning_effort
.map(|effort| effort_from_reasoning_for_adaptive(effort).to_string())
} else {
None
};
let effort_value = if supports_effort(resolved_model, ctx.model) && thinking_is_adaptive {
match anthropic_overrides.map(|overrides| &overrides.effort) {
Some(AnthropicOptionalStringOverride::Explicit(effort)) => Some(effort.to_ascii_lowercase()),
Some(AnthropicOptionalStringOverride::Omit) => None,
_ => request
.effort
.as_ref()
.map(|effort| effort.to_ascii_lowercase())
.or_else(|| adaptive_effort.as_ref().map(|effort| effort.to_ascii_lowercase()))
.or_else(|| {
thinking_val.as_ref().and_then(|_| {
ctx.anthropic_config
.effort
.map(|effort| effort.as_str())
.filter(|effort| effort_allowed_for_model(resolved_model, ctx.model, effort))
.or_else(|| default_effort_for_model(resolved_model, ctx.model))
.map(str::to_string)
})
}),
}
} else {
None
};
let task_budget = if supports_task_budget(resolved_model, ctx.model) {
match anthropic_overrides.map(|overrides| &overrides.task_budget_tokens) {
Some(AnthropicOptionalU32Override::Explicit(total)) => {
Some(AnthropicTaskBudget { budget_type: "tokens".to_string(), total: *total })
}
Some(AnthropicOptionalU32Override::Omit) => None,
_ => ctx
.anthropic_config
.task_budget_tokens
.map(|total| AnthropicTaskBudget { budget_type: "tokens".to_string(), total }),
}
} else {
None
};
let fallbacks = build_fallbacks(request, ctx, resolved_model, thinking_val.as_ref(), effort_value.as_deref());
let forced_tool_choice_allowed = !thinking_is_on(thinking_val.as_ref(), resolved_model, ctx.model)
&& !rejects_forced_tool_choice(resolved_model, ctx.model)
&& fallbacks.as_ref().is_none_or(|fallbacks| {
fallbacks.models().iter().all(|fb| {
let fallback_thinking = fb.thinking.as_ref().or(thinking_val.as_ref());
!thinking_is_on(fallback_thinking, &fb.model, ctx.model)
&& !rejects_forced_tool_choice(&fb.model, ctx.model)
})
});
let final_tool_choice = build_tool_choice(request, forced_tool_choice_allowed);
let output_format = request
.output_format
.as_ref()
.map(|schema| AnthropicOutputFormat::JsonSchema { schema: schema.clone() });
let output_config = if effort_value.is_some() || task_budget.is_some() || output_format.is_some() {
Some(AnthropicOutputConfig {
effort: effort_value,
task_budget,
format: output_format,
})
} else {
None
};
let top_level_cache_control =
if ctx.prompt_cache_enabled && explicit_breakpoints_used < max_breakpoints && !has_uncached_runtime_context {
let ttl = if messages_breakpoints_used > 0 {
messages_ttl
} else if breakpoints_used > 0 || tools_breakpoints_used > 0 {
tools_ttl
} else {
messages_ttl
};
Some(CacheControl {
control_type: "ephemeral".into(),
ttl: Some(ttl.into()),
})
} else {
None
};
let mut anthropic_request = AnthropicRequest {
model: resolved_model.to_string(),
max_tokens: request
.max_tokens
.unwrap_or_else(|| default_max_tokens_for_model(resolved_model, ctx.model, thinking_val.is_some())),
cache_control: top_level_cache_control,
messages,
system: system_value,
tools,
tool_choice: final_tool_choice,
thinking: thinking_val,
reasoning: reasoning_val,
output_config: output_config.map(Into::into),
context_management: request.context_management.clone(),
fallbacks,
fallback_credit_token: request.fallback_credit_token.clone(),
stream: request.stream,
};
hardening::strip_globally_orphaned_tool_blocks(&mut anthropic_request.messages);
hardening::enforce_tool_use_result_adjacency(&mut anthropic_request.messages);
hardening::hoist_tool_results_to_front(&mut anthropic_request.messages);
hardening::guard_trailing_assistant_message(
&mut anthropic_request.messages,
supports_assistant_prefill(resolved_model, ctx.model),
);
serde_json::to_value(anthropic_request).map_err(|e| LLMError::Provider {
message: format!("Serialization error: {e}"),
metadata: None,
})
}
fn build_fallbacks(
request: &LLMRequest,
ctx: &RequestBuilderContext<'_>,
resolved_model: &str,
primary_thinking: Option<&ThinkingConfig>,
effort: Option<&str>,
) -> Option<AnthropicFallbacksParam> {
if request.fallback_credit_token.is_some()
|| !ctx.server_side_fallbacks_available
|| !supports_server_side_fallback(resolved_model, ctx.model)
{
return None;
}
if let Some(fallbacks) = request.fallbacks.as_ref() {
if fallbacks.is_empty() {
return None;
}
if let Some(reason) = AnthropicFallbacks::entries_validation_error(
"fallbacks",
fallbacks.iter().map(|fb| (fb.model.as_str(), fb.max_tokens)),
) {
tracing::warn!(%reason, "request-level fallbacks dropped: invalid entries");
return None;
}
let entries = fallbacks
.iter()
.map(|fb| (fb.model.trim(), fb.max_tokens, fb.thinking.as_ref().map(fallback_thinking_config)));
return Some(AnthropicFallbacksParam::Models(sanitize_fallback_entries(
entries,
primary_thinking,
effort,
ctx.model,
)));
}
match &ctx.anthropic_config.fallbacks {
AnthropicFallbacks::Mode(AnthropicFallbackMode::Default) => {
Some(AnthropicFallbacksParam::Mode(AnthropicFallbacksKeyword::Default))
}
AnthropicFallbacks::Mode(AnthropicFallbackMode::Off) => None,
configured @ AnthropicFallbacks::Models(targets) => {
if configured.validation_error("provider.anthropic.fallbacks").is_some() {
return None;
}
let entries = targets.iter().map(|target| (target.model.trim(), target.max_tokens, None));
Some(AnthropicFallbacksParam::Models(sanitize_fallback_entries(
entries,
primary_thinking,
effort,
ctx.model,
)))
}
}
}
fn sanitize_fallback_entries<'m>(
entries: impl Iterator<Item = (&'m str, Option<u32>, Option<ThinkingConfig>)>,
primary_thinking: Option<&ThinkingConfig>,
effort: Option<&str>,
default_model: &str,
) -> Vec<AnthropicFallbackParam> {
entries
.map(|(model, max_tokens, explicit_thinking)| {
let thinking = match explicit_thinking {
Some(explicit) => {
Some(rewrite_thinking_for_model(&explicit, model, default_model, effort).unwrap_or(explicit))
}
None => primary_thinking
.and_then(|inherited| rewrite_thinking_for_model(inherited, model, default_model, effort)),
};
AnthropicFallbackParam { model: model.to_string(), max_tokens, thinking }
})
.collect()
}
fn fallback_thinking_config(thinking: &AnthropicThinkingConfig) -> ThinkingConfig {
match thinking {
AnthropicThinkingConfig::Disabled => ThinkingConfig::Disabled,
AnthropicThinkingConfig::Enabled { budget_tokens, display } => ThinkingConfig::Enabled {
budget_tokens: *budget_tokens,
display: parse_thinking_display(display.as_deref()),
},
AnthropicThinkingConfig::Adaptive { display } => ThinkingConfig::Adaptive {
display: parse_thinking_display(display.as_deref()),
},
}
}
fn parse_thinking_display(display: Option<&str>) -> Option<ThinkingDisplay> {
match display? {
"summarized" => Some(ThinkingDisplay::Summarized),
"omitted" => Some(ThinkingDisplay::Omitted),
"updates" => Some(ThinkingDisplay::Updates),
_ => None,
}
}
fn effort_from_reasoning_for_adaptive(effort: ReasoningEffortLevel) -> &'static str {
match effort {
ReasoningEffortLevel::None | ReasoningEffortLevel::Minimal | ReasoningEffortLevel::Low => reasoning::LOW,
_ => effort.as_str(),
}
}
fn is_anthropic_executor_model(model: &str) -> bool {
use vtcode_config::constants::models::anthropic::{SUPPORTED_MODELS, normalize_model_id};
let normalized = normalize_model_id(model);
SUPPORTED_MODELS.contains(&normalized)
}
pub(crate) fn resolve_advisor_tool(executor: &str, advisor: &AdvisorConfig) -> Option<AnthropicTool> {
if !advisor.enabled {
return None;
}
if !is_anthropic_executor_model(executor) {
return None;
}
let advisor_model = if advisor.model.is_empty() {
vtcode_config::constants::models::anthropic::default_advisor_model(executor).to_string()
} else {
advisor.model.clone()
};
if let Err(reason) = vtcode_config::constants::models::anthropic::validate_advisor_pair(executor, &advisor_model) {
tracing::warn!(%reason, "advisor tool disabled: invalid model pair");
return None;
}
if let Some(max_tokens) = advisor.max_tokens
&& max_tokens < 1024
{
tracing::warn!(max_tokens, "advisor tool disabled: max_tokens must be >= 1024");
return None;
}
let caching = advisor.caching.and_then(|c| {
c.enabled.then_some(AnthropicAdvisorCaching {
cache_type: "ephemeral".to_string(),
ttl: c.ttl.as_str().to_string(),
})
});
Some(AnthropicTool::Advisor(AnthropicAdvisorTool {
tool_type: "advisor_20260301".to_string(),
name: "advisor".to_string(),
model: advisor_model,
max_uses: advisor.max_uses,
max_tokens: advisor.max_tokens,
caching,
}))
}
#[cfg(test)]
mod tests;