blob: 043740d4363d558fb0e60561e77e7f7577cdeb33 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
pub mod options;
pub mod profile;
mod reasoning;
use super::{
CacheBudget, FunctionTool, HostedTool, PreparedTools, Tool, ToolChoice, ToolPolicy, Warning,
citations::Document,
constrained_schema,
prompt::{PreparedPrompt, PromptOptions},
response,
};
use crate::{
Error, Result,
input::{Message, UserPart},
};
use options::*;
use profile::Profile;
use serde::Serialize;
use serde_json::{Value, json};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, Default)]
pub enum Reasoning {
#[default]
ProviderDefault,
None,
Minimal,
Low,
Medium,
High,
Xhigh,
Max,
}
impl Reasoning {
pub fn as_str(self) -> &'static str {
match self {
Self::ProviderDefault => "provider-default",
Self::None => "none",
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Xhigh => "xhigh",
Self::Max => "max",
}
}
}
#[derive(Debug, Default)]
pub struct Sampling {
pub max_output_tokens: Option<u64>,
pub temperature: Option<f64>,
pub top_k: Option<f64>,
pub top_p: Option<f64>,
pub stop_sequences: Option<Vec<String>>,
pub frequency_penalty: Option<f64>,
pub presence_penalty: Option<f64>,
pub seed: Option<u64>,
}
#[derive(Debug, Default)]
pub enum Format {
#[default]
Text,
Json {
schema: Option<Value>,
},
}
#[derive(Debug)]
pub struct ToolBinding {
pub name: String,
pub definition: Tool,
}
#[derive(Debug)]
pub struct Config {
pub provider_namespace: String,
pub cache_namespace: String,
pub structured_output: bool,
pub strict_tools: bool,
pub max_temperature: Option<f64>,
pub sampling_with_thinking: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
provider_namespace: "anthropic".into(),
cache_namespace: "anthropic".into(),
structured_output: true,
strict_tools: true,
max_temperature: None,
sampling_with_thinking: false,
}
}
}
pub struct Request<'a> {
pub model: String,
pub messages: &'a [Message],
pub tools: Vec<ToolBinding>,
pub choice: Option<ToolChoice>,
pub sampling: Sampling,
pub reasoning: Reasoning,
pub format: Format,
pub options: ProviderOptions,
pub custom_namespace_used: bool,
pub streaming: bool,
pub header_betas: Vec<String>,
}
#[derive(Debug, Clone, Copy)]
pub enum DiagnosticKind {
Unsupported,
Compatibility,
}
#[derive(Debug)]
pub enum Diagnostic {
Setting {
kind: DiagnosticKind,
feature: String,
details: Option<String>,
},
Message(String),
Tool(Warning),
}
impl Diagnostic {
fn unsupported(feature: impl Into<String>, details: impl Into<String>) -> Self {
Self::Setting {
kind: DiagnosticKind::Unsupported,
feature: feature.into(),
details: Some(details.into()),
}
}
fn compatibility(feature: impl Into<String>, details: impl Into<String>) -> Self {
Self::Setting {
kind: DiagnosticKind::Compatibility,
feature: feature.into(),
details: Some(details.into()),
}
}
}
#[derive(Debug)]
pub struct Prepared {
pub body: Body,
pub diagnostics: Vec<Diagnostic>,
pub betas: Vec<String>,
pub response: response::Options,
}
#[derive(Debug, Serialize)]
pub struct Body {
pub model: String,
pub max_tokens: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_k: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop_sequences: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking: Option<Thinking>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_config: Option<OutputConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub speed: Option<Speed>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<ServiceTier>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inference_geo: Option<InferenceGeo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fallbacks: Option<Fallbacks>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_control: Option<super::CacheDirective>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Metadata>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mcp_servers: Option<Vec<McpServer>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub container: Option<WireContainer>,
#[serde(skip_serializing_if = "Option::is_none")]
pub safeguards: Option<Vec<Safeguard>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compaction: Option<Compaction>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_management: Option<ContextManagement>,
#[serde(flatten)]
pub prompt: PreparedPrompt,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<super::tools::WireChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct OutputConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub effort: Option<Effort>,
#[serde(skip_serializing_if = "Option::is_none")]
pub task_budget: Option<TaskBudget>,
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<SchemaFormat>,
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename = "json_schema")]
pub struct SchemaFormat {
pub schema: Value,
}
#[derive(Debug, Serialize)]
pub struct Metadata {
pub user_id: String,
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum WireContainer {
Id(String),
Skills {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
skills: Vec<WireSkill>,
},
}
#[derive(Debug, Serialize)]
pub struct WireSkill {
#[serde(rename = "type")]
pub kind: &'static str,
pub skill_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
fn beta(betas: &mut Vec<String>, name: impl Into<String>) {
let name = name.into();
if !betas.contains(&name) {
betas.push(name);
}
}
fn nonempty<T>(value: Option<Vec<T>>) -> Option<Vec<T>> {
value.filter(|v| !v.is_empty())
}
pub fn prepare(mut request: Request<'_>, config: &Config) -> Result<Prepared> {
let mut diagnostics = Vec::new();
let model = &request.model;
let profile = Profile::for_model(model);
let sampling = &mut request.sampling;
for (present, name) in [
(sampling.frequency_penalty.is_some(), "frequencyPenalty"),
(sampling.presence_penalty.is_some(), "presencePenalty"),
(sampling.seed.is_some(), "seed"),
] {
if present {
diagnostics.push(Diagnostic::Setting {
kind: DiagnosticKind::Unsupported,
feature: name.into(),
details: None,
});
}
}
let max_temperature = config.max_temperature.unwrap_or(1.0);
let temperature_provider = if config.max_temperature.is_some() {
config.provider_namespace.as_str()
} else {
"anthropic"
};
if let Some(temperature) = sampling.temperature {
if temperature > max_temperature {
diagnostics.push(Diagnostic::unsupported("temperature",format!("{temperature} exceeds {temperature_provider} maximum of {max_temperature:.1}. clamped to {max_temperature:.1}")));
sampling.temperature = Some(max_temperature);
} else if temperature < 0.0 {
diagnostics.push(Diagnostic::unsupported(
"temperature",
format!("{temperature} is below {temperature_provider} minimum of 0. clamped to 0"),
));
sampling.temperature = Some(0.0);
}
}
if matches!(request.format, Format::Json { schema: None }) {
diagnostics.push(Diagnostic::unsupported(
"responseFormat",
"JSON response format requires a schema. The response format is ignored.",
));
}
if !profile.known && sampling.max_output_tokens.is_none() {
diagnostics.push(Diagnostic::compatibility("maxOutputTokens",format!("The model \"{model}\" is unknown. The max output tokens have been limited to {}. Set maxOutputTokens explicitly to override this limit.",profile.max_output_tokens)));
}
if profile.reject_sampling {
for (value, name) in [
(&mut sampling.temperature, "temperature"),
(&mut sampling.top_k, "topK"),
(&mut sampling.top_p, "topP"),
] {
if value.take().is_some() {
diagnostics.push(Diagnostic::unsupported(
name,
format!("{name} is not supported by {model} and will be ignored"),
));
}
}
}
let options = &mut request.options;
let native_supported = config.structured_output && profile.structured;
let has_schema = matches!(request.format, Format::Json { schema: Some(_) });
let mut mode = options
.structured_output_mode
.unwrap_or(StructuredOutputMode::Auto);
if mode == StructuredOutputMode::JsonTool
&& profile.reject_forced
&& native_supported
&& has_schema
{
diagnostics.push(Diagnostic::unsupported("providerOptions.anthropic.structuredOutputMode",format!("structuredOutputMode 'jsonTool' is not supported by {model} because it rejects forced tool use. Using 'outputFormat' instead.")));
mode = StructuredOutputMode::OutputFormat;
}
let native_output = mode == StructuredOutputMode::OutputFormat
|| (mode == StructuredOutputMode::Auto && native_supported);
let json_tool = has_schema && !native_output;
if json_tool && options.disable_parallel_tool_use == Some(false) {
diagnostics.push(Diagnostic::unsupported("providerOptions.anthropic.disableParallelToolUse","`disableParallelToolUse: false` is ignored when using the JSON response tool. Parallel tool use is disabled to ensure a single coherent JSON tool call."));
}
if options.compaction.is_some() && options.context_management.is_some() {
return Err(Error::InvalidConfiguration {argument:"providerOptions".into(),message:"Anthropic provider options `compaction` and `contextManagement` cannot be used together.".into()});
}
let mut names = BTreeMap::new();
let mut toolsets = BTreeMap::new();
let mut response_names = BTreeMap::new();
let mut explicit_code = false;
let mut dynamic_web = false;
let mut skills_code = false;
for binding in &request.tools {
if let Tool::Hosted(tool) = &binding.definition {
let provider = tool.name().unwrap_or("computer");
names.insert(binding.name.clone(), provider.into());
response_names.insert(provider.into(), binding.name.clone());
if matches!(tool, HostedTool::ComputerToolset(_)) {
toolsets.insert(binding.name.clone(), "computer".into());
}
explicit_code |= matches!(tool, HostedTool::CodeExecution(_));
skills_code |= matches!(
tool,
HostedTool::CodeExecution(
super::CodeVersion::V20250825 | super::CodeVersion::V20260120
)
);
dynamic_web |= matches!(
tool,
HostedTool::WebFetch(
super::FetchVersion::V20260209 | super::FetchVersion::V20260318,
_
) | HostedTool::WebSearch(
super::SearchVersion::V20260209 | super::SearchVersion::V20260318,
_
)
);
}
}
let mut cache = CacheBudget::default();
let mut prompt = PreparedPrompt::encode(
request.messages,
PromptOptions {
send_reasoning: options.send_reasoning.unwrap_or(true),
cache_namespace: &config.cache_namespace,
tool_names: &names,
toolsets: &toolsets,
},
&mut cache,
)?;
diagnostics.extend(prompt.warnings.drain(..).map(Diagnostic::Message));
let mut betas = prompt
.features
.iter()
.map(|s| (*s).into())
.collect::<Vec<String>>();
reasoning::resolve(model, profile, request.reasoning, options, &mut diagnostics);
let active_thinking = options.thinking.as_ref().is_some_and(Thinking::active);
let budgeted = options.thinking.as_ref().is_some_and(Thinking::budgeted);
let mut thinking_budget = match options.thinking.as_ref() {
Some(Thinking::Mode(ThinkingMode::Enabled { budget_tokens })) => *budget_tokens,
_ => None,
};
if budgeted && thinking_budget.is_none() {
diagnostics.push(Diagnostic::compatibility("extended thinking","thinking budget is required when thinking is enabled. using default budget of 1024 tokens."));
thinking_budget = Some(1024);
options.thinking = Some(Thinking::Mode(ThinkingMode::Enabled {
budget_tokens: thinking_budget,
}));
}
if active_thinking {
for (value, name) in [
(&mut sampling.temperature, "temperature"),
(&mut sampling.top_k, "topK"),
(&mut sampling.top_p, "topP"),
] {
if !(config.sampling_with_thinking && matches!(name, "temperature" | "topP"))
&& value.take().is_some()
{
diagnostics.push(Diagnostic::unsupported(
name,
format!("{name} is not supported when thinking is enabled"),
));
}
}
} else if (profile.known || model.contains("claude-"))
&& sampling.temperature.is_some()
&& sampling.top_p.take().is_some()
{
diagnostics.push(Diagnostic::unsupported(
"topP",
"topP is not supported when temperature is set. topP is ignored.",
));
}
let mut max_tokens = sampling
.max_output_tokens
.unwrap_or(profile.max_output_tokens)
.checked_add(thinking_budget.unwrap_or(0))
.ok_or_else(|| Error::InvalidArgument {
field: "max output token overflow".into(),
value: Value::Null,
})?;
if profile.known && max_tokens > profile.max_output_tokens {
if sampling.max_output_tokens.is_some() {
diagnostics.push(Diagnostic::unsupported("maxOutputTokens",format!("{max_tokens} (maxOutputTokens + thinkingBudget) is greater than {model} {} max output tokens. The max output tokens have been limited to {}.",profile.max_output_tokens,profile.max_output_tokens)));
}
max_tokens = profile.max_output_tokens;
}
let mcp_servers = nonempty(options.mcp_servers.take());
let safeguards = nonempty(options.safeguards.take());
if mcp_servers.is_some() {
beta(&mut betas, "mcp-client-2025-04-04");
}
if safeguards.is_some() {
beta(&mut betas, "dangerous-tool-use-2026-09-03");
}
if options.compaction.is_some() {
beta(&mut betas, "compact-2026-09-04");
}
if let Some(context) = &options.context_management {
beta(&mut betas, "context-management-2025-06-27");
if context
.edits
.iter()
.any(|edit| matches!(edit, ContextEdit::Compact { .. }))
{
beta(&mut betas, "compact-2026-01-12");
}
}
let container = container(options.container.take())?;
if matches!(&container,Some(WireContainer::Skills {skills,..}) if !skills.is_empty()) {
for name in [
"code-execution-2025-08-25",
"skills-2025-10-02",
"files-api-2025-04-14",
] {
beta(&mut betas, name);
}
if !skills_code {
diagnostics.push(Diagnostic::Message(
"code execution tool is required when using skills".into(),
));
}
}
if options.task_budget.is_some() {
beta(&mut betas, "task-budgets-2026-03-13");
}
if options.speed == Some(Speed::Fast) {
beta(&mut betas, "fast-mode-2026-02-01");
}
if matches!(
options.thinking,
Some(Thinking::Mode(ThinkingMode::Adaptive {
display: Some(ThinkingDisplay::Updates),
..
}))
) {
beta(&mut betas, "thinking-display-updates-2026-08-18");
}
if options.thinking.as_ref().is_some_and(Thinking::bound) {
beta(&mut betas, "thinking-binding-controls-2026-08-01");
}
let fallbacks = options
.fallbacks
.take()
.filter(|f| !matches!(f,Fallbacks::Chain(chain) if chain.is_empty()));
if let Some(fallbacks) = &fallbacks {
beta(
&mut betas,
match fallbacks {
Fallbacks::Default(_) => "server-side-fallback-2026-07-01",
Fallbacks::Chain(_) => "server-side-fallback-2026-06-01",
},
);
}
let mut tools = request
.tools
.into_iter()
.map(|b| b.definition)
.collect::<Vec<_>>();
let schema = match request.format {
Format::Json { schema } => schema,
Format::Text => None,
};
if json_tool {
tools.push(Tool::Function(FunctionTool {
name: "json".into(),
description: Some("Respond with a JSON object.".into()),
input_schema: schema.clone().unwrap(),
cache_control: None,
strict: None,
defer_loading: None,
eager_input_streaming: None,
allowed_callers: None,
input_examples: None,
}));
}
let has_tools = !tools.is_empty();
let prepared = PreparedTools::build(
tools,
if json_tool {
Some(ToolChoice::Required)
} else {
request.choice
},
if json_tool {
Some(true)
} else {
options.disable_parallel_tool_use
},
ToolPolicy {
structured_output: !json_tool && native_supported,
strict: config.strict_tools && profile.structured,
eager_streaming: request.streaming && options.tool_streaming.unwrap_or(true),
forced_choice: !profile.reject_forced && !budgeted,
},
&mut cache,
);
for feature in &prepared.features {
beta(&mut betas, feature.header());
}
for header in request.header_betas {
for name in header.split(',').map(str::trim).filter(|s| !s.is_empty()) {
beta(&mut betas, name.to_lowercase());
}
}
for name in options.anthropic_beta.take().unwrap_or_default() {
beta(&mut betas, name);
}
let wire_tools = has_tools.then(|| prepared.wire_tools());
diagnostics.extend(prepared.warnings.into_iter().map(Diagnostic::Tool));
diagnostics.extend(cache.into_warnings().into_iter().map(Diagnostic::Tool));
let format = if native_output {
schema.map(|schema| SchemaFormat {
schema: constrained_schema(schema),
})
} else {
None
};
let output_config =
if options.effort.is_some() || options.task_budget.is_some() || format.is_some() {
Some(OutputConfig {
effort: options.effort,
task_budget: options.task_budget.take(),
format,
})
} else {
None
};
let response = response::Options {
tool_names: response_names,
documents: documents(request.messages),
json_tool,
implicit_code_execution: dynamic_web && !explicit_code,
metadata_namespace: request
.custom_namespace_used
.then(|| config.provider_namespace.clone()),
};
Ok(Prepared {
body: Body {
model: request.model,
max_tokens,
temperature: sampling.temperature,
top_k: sampling.top_k,
top_p: sampling.top_p,
stop_sequences: sampling.stop_sequences.take(),
thinking: options.thinking.take(),
output_config,
speed: options.speed,
service_tier: options.service_tier.take(),
inference_geo: options.inference_geo.take(),
fallbacks,
cache_control: options.cache_control,
metadata: options
.metadata
.take()
.and_then(|m| m.user_id)
.map(|user_id| Metadata { user_id }),
mcp_servers,
container,
safeguards,
compaction: options.compaction.take(),
context_management: options.context_management.take(),
prompt,
tools: wire_tools,
tool_choice: prepared.choice,
stream: request.streaming.then_some(true),
},
diagnostics,
betas,
response,
})
}
fn container(value: Option<Container>) -> Result<Option<WireContainer>> {
let Some(value) = value else {
return Ok(None);
};
let Some(skills) = value.skills.filter(|s| !s.is_empty()) else {
return Ok(value.id.map(WireContainer::Id));
};
let skills = skills
.into_iter()
.map(|skill| match skill {
Skill::Anthropic { skill_id, version } => Ok(WireSkill {
kind: "anthropic",
skill_id,
version,
}),
Skill::Custom {
provider_reference,
version,
} => {
let skill_id = provider_reference
.get("anthropic")
.cloned()
.ok_or_else(|| Error::MissingReference {
provider: "anthropic".into(),
reference: json!(provider_reference),
})?;
Ok(WireSkill {
kind: "custom",
skill_id,
version,
})
}
})
.collect::<Result<Vec<_>>>()?;
Ok(Some(WireContainer::Skills {
id: value.id,
skills,
}))
}
fn documents(messages: &[Message]) -> Vec<Document> {
messages
.iter()
.filter_map(|m| match m {
Message::User { parts, .. } => Some(parts),
_ => None,
})
.flatten()
.filter_map(|part| {
let UserPart::File(file) = part else {
return None;
};
if !matches!(file.media_type.as_str(), "application/pdf" | "text/plain") {
return None;
}
if file.extensions.as_ref()?.get("anthropic")?["citations"]["enabled"] != true {
return None;
}
Some(Document {
title: file
.filename
.clone()
.unwrap_or_else(|| "Untitled Document".into()),
filename: file.filename.clone(),
media_type: file.media_type.clone(),
})
})
.collect()
}