use serde_json::{json, Map, Value};
use crate::compat::{
AnthropicCacheControl, AnthropicThinkingFormat, ResolvedAnthropicCompat, SendReject,
};
use crate::errors::Lm15Error;
use crate::types::{
CacheConfig, CacheMode, CachePrefix, CacheRetention, Part, Reasoning, ReasoningEffort,
ReasoningSummary, Request, SystemContent, Tool, ToolChoiceMode,
};
use crate::wire::BuildContext;
use super::parts::{self, text_block, PartContext};
use super::tables::{
anthropic_adaptive_class, builtin_tool_type, claude_output_ceiling, effort_thinking_budget,
DEFAULT_VISIBLE_TOKENS,
};
use super::Refuse;
pub const RESERVED_EXTENSION_KEYS: &[&str] = &["prompt_caching"];
pub fn payload(
request: &Request,
stream: bool,
cx: &BuildContext<'_>,
compat: &ResolvedAnthropicCompat,
) -> Result<Map<String, Value>, Lm15Error> {
let refuse = Refuse {
provider: cx.provider,
};
crate::dialects::content::validate_slots(
request,
cx.provider,
"anthropic",
compat.tool_result_media,
)?;
crate::judgments::note_unmeasurable_probabilities(request, cx.provider)?;
let prepared = prepare(request, cx.model, compat, cx.provider)?;
let request = &prepared;
let model = cx.model;
let config = &request.config;
if let Some(prefixes) = compat.model_prefixes {
if !prefixes.iter().any(|prefix| model.starts_with(prefix)) {
return Err(refuse.model(format!(
"model {model:?} is not one this endpoint serves as typed (expected a prefix in \
{prefixes:?}); it would be silently substituted by another model. Name the model \
you actually want"
)));
}
}
let cache = cache_plan(config.cache.as_ref(), compat, &refuse)?;
let thinking = thinking_plan(config.reasoning.as_ref(), model, compat, &refuse)?;
let part_cx = PartContext {
refuse: &refuse,
thinking_replay: compat.thinking_replay,
tool_result_media: compat.tool_result_media,
};
let mut messages: Vec<Value> = request
.messages
.iter()
.map(|m| parts::message(m, &part_cx))
.collect::<Result<_, _>>()?;
if let Some(index) = cache.message_mark(messages.len()) {
mark_last_block(&mut messages[index], cache.marker());
}
let mut body = Map::new();
body.insert("model".into(), Value::String(model.to_string()));
body.insert("messages".into(), Value::Array(messages));
body.insert("stream".into(), Value::Bool(stream));
body.insert(
"max_tokens".into(),
Value::from(thinking.max_tokens(config.max_tokens)?),
);
if let Some(system) = &request.system {
body.insert("system".into(), system_value(system, &cache, &part_cx)?);
}
if compat.sampling_params == SendReject::Reject {
for (name, set) in [
("temperature", config.temperature.is_some()),
("top_p", config.top_p.is_some()),
("top_k", config.top_k.is_some()),
] {
if set {
return Err(refuse.feature(format!(
"config.{name} is silently ignored by this server (the model's sampling is \
fixed); omit it"
)));
}
}
}
if let Some(temperature) = config.temperature {
body.insert("temperature".into(), Value::from(temperature));
}
if let Some(top_p) = config.top_p {
body.insert("top_p".into(), Value::from(top_p));
}
if let Some(top_k) = config.top_k {
body.insert("top_k".into(), Value::from(top_k));
}
if !config.stop.is_empty() {
body.insert("stop_sequences".into(), json!(config.stop));
}
if !request.tools.is_empty() {
body.insert(
"tools".into(),
Value::Array(request.tools.iter().map(tool_value).collect()),
);
}
if let Some(tool_choice) = tool_choice_value(request, compat, &refuse)? {
body.insert("tool_choice".into(), tool_choice);
}
let mut output_config = Map::new();
if let Some(thinking_value) = thinking.wire(config.reasoning.as_ref()) {
body.insert("thinking".into(), thinking_value);
}
if let Some(effort) = thinking.effort(config.reasoning.as_ref()) {
output_config.insert("effort".into(), Value::String(effort.as_str().into()));
}
if let Some(format) = &config.response_format {
if compat.structured_output == SendReject::Reject {
return Err(refuse.feature(
"response_format is silently ignored by this server (output_config.format is \
accepted and not applied); describe the shape in the prompt",
));
}
output_config.insert("format".into(), output_format(format, &refuse)?);
}
if !output_config.is_empty() {
body.insert("output_config".into(), Value::Object(output_config));
}
if let Some(tier) = &config.service_tier {
body.insert("service_tier".into(), Value::String(tier.clone()));
}
if let Some(user_id) = &config.user_id {
body.insert("metadata".into(), json!({"user_id": user_id}));
}
if config.store.is_some() {
return Err(refuse.feature(
"config.store is not supported — the Messages API has no response-storage opt-out \
field (OpenAI and Gemini carry it)",
));
}
if config.logprobs.is_some() {
return Err(refuse.feature(
"config.logprobs is not supported — the Messages API does not expose token log \
probabilities (OpenAI and Gemini carry them)",
));
}
if let Some(extensions) = &config.extensions {
for (key, value) in extensions {
if RESERVED_EXTENSION_KEYS.contains(&key.as_str()) {
continue;
}
body.insert(key.clone(), value.clone());
}
}
if let Some(prefix) = cx.policy.system_prefix {
let mut blocks = vec![text_block(prefix)];
match body.get("system") {
None => {}
Some(Value::Array(existing)) => blocks.extend(existing.iter().cloned()),
Some(Value::String(existing)) => blocks.push(text_block(existing)),
Some(other) => blocks.push(text_block(&other.to_string())),
}
body.insert("system".into(), Value::Array(blocks));
}
Ok(body)
}
fn prepare(
request: &Request,
model: &str,
compat: &ResolvedAnthropicCompat,
provider: &str,
) -> Result<Request, Lm15Error> {
use crate::adaptation::{
adapt, clamp_effort, drop_value, prepare_cache, summary_auto, AdaptationAction::*,
};
let mut out = request.clone();
prepare_cache(&mut out, false, provider)?;
let c = &mut out.config;
if compat.sampling_params == SendReject::Reject {
drop_value(
"config.temperature",
&mut c.temperature,
"this server ignores sampling parameters",
)?;
drop_value(
"config.top_p",
&mut c.top_p,
"this server ignores sampling parameters",
)?;
drop_value(
"config.top_k",
&mut c.top_k,
"this server ignores sampling parameters",
)?;
}
drop_value(
"config.seed",
&mut c.seed,
"the Messages API has no seed field",
)?;
drop_value(
"config.frequency_penalty",
&mut c.frequency_penalty,
"the Messages API has no frequency_penalty field",
)?;
drop_value(
"config.presence_penalty",
&mut c.presence_penalty,
"the Messages API has no presence_penalty field",
)?;
if let Some(t) = c.temperature.filter(|t| *t > 1.0) {
adapt(
"config.temperature",
Clamped,
Some(json!(t)),
Some(json!(1.0)),
"the Messages API accepts temperature in [0, 1]; the canonical range is [0, 2]",
)?;
c.temperature = Some(1.0);
}
if let Some(r) = &mut c.reasoning {
if !r.is_off() {
if let Some(levels) = compat.reasoning_efforts {
clamp_effort(r, levels)?;
}
summary_auto(r)?;
let adaptive = compat.thinking_format != AnthropicThinkingFormat::Anthropic
|| anthropic_adaptive_class(model);
if adaptive {
drop_value(
"config.reasoning.thinking_budget",
&mut r.thinking_budget,
"this model class has no thinking budget; effort carries the intent. Thinking is \
bounded only by max_tokens, which covers thinking and answer together: lower \
the effort or raise max_tokens",
)?;
if compat.thinking_format == AnthropicThinkingFormat::Anthropic
&& r.effort == ReasoningEffort::Minimal
{
clamp_effort(
r,
&[
ReasoningEffort::Low,
ReasoningEffort::Medium,
ReasoningEffort::High,
ReasoningEffort::Xhigh,
ReasoningEffort::Max,
],
)?;
}
}
}
}
if c.max_tokens.is_none() {
let manual_budget = c
.reasoning
.as_ref()
.filter(|r| !r.is_off())
.filter(|_| {
compat.thinking_format == AnthropicThinkingFormat::Anthropic
&& !anthropic_adaptive_class(model)
})
.and_then(|r| {
r.thinking_budget
.or_else(|| effort_thinking_budget(r.effort))
});
let ceiling = claude_output_ceiling(model);
let visible = match (ceiling, manual_budget) {
(None, _) => DEFAULT_VISIBLE_TOKENS,
(Some(ceiling), None) => ceiling,
(Some(ceiling), Some(budget)) if budget < ceiling => ceiling - budget,
(Some(_), Some(_)) => DEFAULT_VISIBLE_TOKENS,
};
adapt(
"config.max_tokens",
Defaulted,
None,
Some(json!(visible)),
if ceiling.is_some() {
"the Messages API requires max_tokens and none was set; the model's output ceiling was used"
} else {
"the Messages API requires max_tokens and none was set; 16384 was used (this server's ceiling is its own)"
},
)?;
c.max_tokens = Some(visible);
}
if let Some(cache) = &mut c.cache {
drop_value(
"config.cache.key",
&mut cache.key,
"the Messages API has no cache affinity key; marks are its mechanism",
)?;
if compat.cache_control != AnthropicCacheControl::Anthropic
&& cache.retention == Some(CacheRetention::Long)
{
adapt(
"config.cache.retention",
Dropped,
Some(json!("long")),
None,
"this server caches implicitly and has no cache-control TTL",
)?;
cache.retention = None;
}
}
if let Some(choice) = &mut c.tool_choice {
if choice.mode != ToolChoiceMode::None
&& !choice.allowed.is_empty()
&& !(choice.allowed.len() == 1 && choice.mode == ToolChoiceMode::Required)
&& out
.tools
.iter()
.any(|t| !choice.allowed.iter().any(|n| n == t.name()))
{
adapt(
"config.tool_choice.allowed",
ClientSide,
Some(json!(choice.allowed)),
Some(json!(choice.allowed)),
"only allowed tools were sent; the wire cannot express a proper subset",
)?;
out.tools
.retain(|t| choice.allowed.iter().any(|n| n == t.name()));
}
if compat.parallel_tool_calls == SendReject::Reject {
drop_value(
"config.tool_choice.parallel",
&mut choice.parallel,
"this server ignores disable_parallel_tool_use",
)?;
}
}
if compat.structured_output == SendReject::Reject {
drop_value(
"config.response_format",
&mut c.response_format,
"this server accepts output_config.format without applying the schema",
)?;
}
if let Some(store) = c.store.take() {
adapt(
"config.store",
if store { Dropped } else { Satisfied },
Some(json!(store)),
None,
"the Messages API has no retrievable stored-response object",
)?;
}
drop_value(
"config.logprobs",
&mut c.logprobs,
"the Messages API does not expose token log probabilities",
)?;
Ok(out)
}
struct CachePlan {
active: bool,
long: bool,
prefix_until_index: Option<u64>,
history: bool,
}
impl CachePlan {
fn marker(&self) -> Value {
if self.long {
json!({"type": "ephemeral", "ttl": "1h"})
} else {
json!({"type": "ephemeral"})
}
}
fn message_mark(&self, count: usize) -> Option<usize> {
if !self.active || count == 0 {
return None;
}
let last = count - 1;
if let Some(index) = self.prefix_until_index {
return Some(usize::try_from(index).map_or(last, |i| i.min(last)));
}
self.history.then_some(last)
}
}
fn cache_plan(
cache: Option<&CacheConfig>,
compat: &ResolvedAnthropicCompat,
refuse: &Refuse<'_>,
) -> Result<CachePlan, Lm15Error> {
let Some(cache) = cache else {
return Ok(CachePlan {
active: false,
long: false,
prefix_until_index: None,
history: false,
});
};
let marks = compat.cache_control == AnthropicCacheControl::Anthropic;
if cache.resource.is_some() {
return Err(refuse.feature(
"cache.resource is not supported — the Messages API has no stored-cache tier; it \
caches by marks on blocks",
));
}
let active = cache.mode != CacheMode::Off && marks;
if active && cache.key.is_some() {
return Err(refuse.feature(
"cache.key is not supported — the Messages API has no cache affinity key (OpenAI's \
prompt_cache_key); marks on blocks are the mechanism (prefix / prefix_until_index)",
));
}
let long = cache.retention == Some(CacheRetention::Long);
if long && !marks {
return Err(refuse.feature(
"cache.retention='long' is not supported on this server — it caches implicitly and \
has no cache_control ttl",
));
}
Ok(CachePlan {
active,
long: long && active,
prefix_until_index: cache.prefix_until_index,
history: cache.prefix == Some(CachePrefix::History),
})
}
fn mark_last_block(message: &mut Value, marker: Value) {
if let Some(Value::Object(block)) = message
.get_mut("content")
.and_then(Value::as_array_mut)
.and_then(|blocks| blocks.last_mut())
{
block.entry("cache_control").or_insert(marker);
}
}
fn system_value(
system: &SystemContent,
cache: &CachePlan,
cx: &PartContext<'_>,
) -> Result<Value, Lm15Error> {
let mut blocks = match system {
SystemContent::Text(text) => {
if !cache.active {
return Ok(Value::String(text.clone()));
}
vec![text_block(text)]
}
SystemContent::Parts(parts) => parts
.iter()
.map(|part| match part {
Part::Text(t) => Ok(text_block(&t.text)),
Part::Data(d) => Ok(text_block(&d.value.to_string())),
other => Err(cx.refuse.feature(format!(
"system accepts text blocks only on the Messages API; a {} part has no \
system block (put it in the first user message)",
other.type_name()
))),
})
.collect::<Result<Vec<_>, _>>()?,
};
if cache.active {
if let Some(Value::Object(last)) = blocks.last_mut() {
last.insert("cache_control".into(), cache.marker());
}
}
Ok(Value::Array(blocks))
}
struct ThinkingPlan {
format: AnthropicThinkingFormat,
adaptive: bool,
budget: Option<u64>,
}
impl ThinkingPlan {
fn max_tokens(&self, configured: Option<u64>) -> Result<u64, Lm15Error> {
let visible = configured.unwrap_or(DEFAULT_VISIBLE_TOKENS);
visible
.checked_add(self.budget.unwrap_or(0))
.ok_or_else(|| {
Lm15Error::InvalidRequestError(crate::errors::ErrorMeta::new(
"max_tokens plus thinking_budget overflows; a bounded output cap is required",
))
})
}
fn wire(&self, reasoning: Option<&Reasoning>) -> Option<Value> {
let off = reasoning.is_some_and(Reasoning::is_off);
match self.format {
AnthropicThinkingFormat::Deepseek | AnthropicThinkingFormat::Effort if off => {
Some(json!({"type": "disabled"}))
}
AnthropicThinkingFormat::Deepseek if self.adaptive => Some(json!({"type": "enabled"})),
AnthropicThinkingFormat::Effort => None,
AnthropicThinkingFormat::Adaptive if off => Some(json!({"type": "disabled"})),
_ if self.adaptive => Some(json!({"type": "adaptive"})),
_ => self
.budget
.map(|budget| json!({"type": "enabled", "budget_tokens": budget})),
}
}
fn effort(&self, reasoning: Option<&Reasoning>) -> Option<ReasoningEffort> {
if !self.adaptive {
return None;
}
reasoning
.map(|r| r.effort)
.filter(|e| *e != ReasoningEffort::Off)
}
}
fn thinking_plan(
reasoning: Option<&Reasoning>,
model: &str,
compat: &ResolvedAnthropicCompat,
refuse: &Refuse<'_>,
) -> Result<ThinkingPlan, Lm15Error> {
let format = compat.thinking_format;
let mut plan = ThinkingPlan {
format,
adaptive: false,
budget: None,
};
let Some(reasoning) = reasoning.filter(|r| !r.is_off()) else {
return Ok(plan);
};
if let Some(levels) = compat.reasoning_efforts {
if !levels.contains(&reasoning.effort) {
let accepted: Vec<&str> = levels.iter().map(|e| e.as_str()).collect();
return Err(refuse.feature(format!(
"reasoning.effort={:?} has no level on this server (it accepts {}) and would be \
accepted silently",
reasoning.effort.as_str(),
accepted.join(", ")
)));
}
}
if matches!(
reasoning.summary,
Some(ReasoningSummary::Concise) | Some(ReasoningSummary::Detailed)
) {
return Err(refuse.feature(format!(
"reasoning.summary={:?} is an OpenAI detail level; the Messages API returns thinking \
blocks whenever thinking runs (use 'auto' or none)",
reasoning.summary.map(|s| s.as_str()).unwrap_or_default()
)));
}
let model_table = format == AnthropicThinkingFormat::Anthropic;
plan.adaptive = !model_table || anthropic_adaptive_class(model);
if plan.adaptive {
if reasoning.thinking_budget.is_some() {
let why = match format {
AnthropicThinkingFormat::Deepseek => {
"this server ignores budget_tokens (a silent no-op); effort is the dial"
}
AnthropicThinkingFormat::Adaptive | AnthropicThinkingFormat::Effort => {
"this server accepts budget_tokens without translating it (a silent no-op); \
effort is the dial"
}
AnthropicThinkingFormat::Anthropic => {
"this model class takes thinking.type 'adaptive' with output_config.effort; \
budget_tokens is rejected by the API (live 2026-09-02)"
}
};
return Err(refuse.feature(format!(
"reasoning.thinking_budget is not supported on {model} — {why}"
)));
}
if model_table && reasoning.effort == ReasoningEffort::Minimal {
return Err(refuse.feature(
"reasoning.effort='minimal' has no level on this model class \
(output_config.effort is low|medium|high|xhigh|max); 'low' is the floor",
));
}
return Ok(plan);
}
plan.budget = reasoning
.thinking_budget
.or_else(|| effort_thinking_budget(reasoning.effort));
Ok(plan)
}
fn tool_value(tool: &Tool) -> Value {
match tool {
Tool::Function(function) => {
let mut out = Map::new();
out.insert("name".into(), Value::String(function.name.clone()));
function.insert_wire_description(&mut out);
out.insert(
"input_schema".into(),
Value::Object(function.parameters.clone()),
);
Value::Object(out)
}
Tool::Builtin(builtin) => {
let mut out = Map::new();
out.insert("type".into(), builtin_tool_type(&builtin.name).into());
out.insert("name".into(), Value::String(builtin.name.clone()));
if let Some(config) = &builtin.config {
for (key, value) in config {
out.insert(key.clone(), value.clone());
}
}
Value::Object(out)
}
}
}
fn tool_choice_value(
request: &Request,
compat: &ResolvedAnthropicCompat,
refuse: &Refuse<'_>,
) -> Result<Option<Value>, Lm15Error> {
let Some(choice) = &request.config.tool_choice else {
return Ok(None);
};
let mut payload = Map::new();
if choice.mode == ToolChoiceMode::None {
payload.insert("type".into(), "none".into());
} else if !choice.allowed.is_empty() {
let required = choice.mode == ToolChoiceMode::Required;
let every_declared = request
.tools
.iter()
.all(|tool| choice.allowed.iter().any(|name| name == tool.name()));
if choice.allowed.len() == 1 && required {
payload.insert("type".into(), "tool".into());
payload.insert("name".into(), Value::String(choice.allowed[0].clone()));
} else if every_declared {
payload.insert("type".into(), if required { "any" } else { "auto" }.into());
} else {
return Err(refuse.feature(
"tool_choice.allowed subsets are not supported — the Messages API can force one \
named tool or allow all declared tools, but cannot restrict to a subset. Send \
only the allowed tools in Request.tools instead",
));
}
} else if choice.mode == ToolChoiceMode::Required {
payload.insert("type".into(), "any".into());
} else {
payload.insert("type".into(), "auto".into());
}
if compat.parallel_tool_calls == SendReject::Reject && choice.parallel.is_some() {
return Err(refuse.feature(
"tool_choice.parallel is silently ignored by this server (disable_parallel_tool_use \
is not applied); omit it",
));
}
if choice.parallel == Some(false) && choice.mode != ToolChoiceMode::None {
payload.insert("disable_parallel_tool_use".into(), Value::Bool(true));
}
Ok(Some(Value::Object(payload)))
}
fn output_format(
format: &crate::types::JsonObject,
refuse: &Refuse<'_>,
) -> Result<Value, Lm15Error> {
let kind = format
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
if kind == "json_object" {
return Err(crate::adaptation::refusal(refuse.provider,"config.response_format","no sensible adaptation exists: the Messages API has no any-JSON mode; give a json_schema (objects need additionalProperties: false)"));
}
let mut schema = format.get("schema").cloned().unwrap_or(Value::Null);
let found = crate::judgments::judgments_in_schema(&schema);
if let Some(obj) = schema.as_object() {
schema = Value::Object(crate::judgments::anthropic_schema(obj, &found));
}
Ok(json!({"type": "json_schema", "schema": schema}))
}