use super::auth::Auth;
use super::response::Usage;
use super::wire::{AppIdentity, CostFuture, CostOutcome, PostStreamCtx, Provider, TokenPrice};
use crate::error::{MiniLLMError, Result};
use crate::generator::CompletionParameters;
use crate::message::{messages_to_payload, Message};
use secrecy::ExposeSecret;
pub(crate) fn kept_cache_breakpoints(
messages: &[Message],
max: usize,
) -> std::collections::HashSet<usize> {
let marked: Vec<usize> = messages
.iter()
.enumerate()
.filter(|(_, m)| m.cache_breakpoint)
.map(|(i, _)| i)
.collect();
if marked.len() > max {
tracing::warn!(
"this provider allows at most {} cache breakpoints per request; {} were marked, keeping the last {}",
max,
marked.len(),
max
);
}
marked.iter().rev().take(max).copied().collect()
}
fn mark_openai_message(msg: &mut serde_json::Value) {
let marker = serde_json::json!({ "type": "ephemeral" });
match &mut msg["content"] {
serde_json::Value::String(s) if !s.is_empty() => {
let text = s.clone();
msg["content"] =
serde_json::json!([{ "type": "text", "text": text, "cache_control": marker }]);
}
serde_json::Value::Array(parts) => {
match parts.iter_mut().rev().find(|p| p["type"] == "text") {
Some(part) => part["cache_control"] = marker,
None => tracing::warn!(
"cache breakpoint on a message with no text part; marker dropped"
),
}
}
_ => tracing::warn!(
"cache breakpoint on a message with no markable text content; marker dropped"
),
}
}
pub(crate) fn openai_auth_headers(auth: &Auth) -> Result<Vec<(String, String)>> {
match auth {
Auth::ApiKey(s) | Auth::BearerToken(s) => Ok(vec![(
"Authorization".to_string(),
format!("Bearer {}", s.expose_secret()),
)]),
Auth::None => Ok(Vec::new()),
}
}
pub(crate) fn openai_build_request<P: Provider + ?Sized>(
model: &str,
messages: &[Message],
params: &CompletionParameters,
stream: bool,
include_usage: bool,
provider: &P,
) -> Result<serde_json::Value> {
let mut body = serde_json::json!({
"model": model,
"messages": provider.openai_messages_value(model, messages),
"stream": stream,
});
let obj = body.as_object_mut().expect("json object");
if let Some(v) = params.max_tokens {
obj.insert(
provider.openai_token_limit_field().to_string(),
serde_json::json!(v),
);
}
if let Some(v) = params.temperature {
obj.insert("temperature".into(), serde_json::json!(v));
}
if let Some(v) = params.top_p {
obj.insert("top_p".into(), serde_json::json!(v));
}
if let Some(v) = params.top_k {
obj.insert("top_k".into(), serde_json::json!(v));
}
if let Some(v) = params.frequency_penalty {
obj.insert("frequency_penalty".into(), serde_json::json!(v));
}
if let Some(v) = params.presence_penalty {
obj.insert("presence_penalty".into(), serde_json::json!(v));
}
if let Some(v) = params.repetition_penalty {
obj.insert("repetition_penalty".into(), serde_json::json!(v));
}
if let Some(v) = ¶ms.stop {
obj.insert("stop".into(), serde_json::json!(v));
}
if let Some(v) = params.seed {
obj.insert("seed".into(), serde_json::json!(v));
}
if let Some(v) = ¶ms.response_format {
obj.insert("response_format".into(), v.to_openai_value());
}
if let Some(v) = ¶ms.tools {
obj.insert("tools".into(), provider.openai_tools_value(v));
}
if let Some(v) = ¶ms.tool_choice {
obj.insert("tool_choice".into(), provider.openai_tool_choice_value(v));
}
if let Some(v) = params.parallel_tool_calls {
obj.insert("parallel_tool_calls".into(), serde_json::json!(v));
}
if let Some(v) = ¶ms.reasoning {
obj.insert("reasoning".into(), serde_json::to_value(v)?);
}
if include_usage {
provider.openai_request_usage(&mut body, stream);
}
if let (Some(extra), Some(obj)) = (params.extra.clone(), body.as_object_mut()) {
for (key, value) in extra {
if obj.contains_key(&key) {
return Err(MiniLLMError::InvalidParameter(format!(
"extra param '{}' collides with a built-in request key; set it via the typed builder instead of with_extra",
key
)));
}
obj.insert(key, value);
}
}
Ok(body)
}
fn parse_openai_usage(u: &serde_json::Value) -> Option<Usage> {
if u.is_null() {
return None;
}
let total_input = u["prompt_tokens"].as_u64().unwrap_or(0) as u32;
let cache_read = u["prompt_tokens_details"]["cached_tokens"]
.as_u64()
.unwrap_or(0) as u32;
let cache_write = u["prompt_tokens_details"]["cache_write_tokens"]
.as_u64()
.unwrap_or(0) as u32;
if cache_read > total_input {
tracing::error!(
prompt_tokens = total_input,
cached_tokens = cache_read,
"OpenAI-wire usage reports cached_tokens > prompt_tokens; cached is not a subset on this wire, cost would be wrong, reporting Unknown"
);
return None;
}
Some(Usage {
uncached_input_tokens: total_input - cache_read,
cache_read_tokens: cache_read,
cache_write_tokens: cache_write,
completion_tokens: u["completion_tokens"].as_u64().unwrap_or(0) as u32,
cost: None,
upstream_inference_cost: None,
reasoning_tokens: u["completion_tokens_details"]["reasoning_tokens"]
.as_u64()
.map(|v| v as u32),
})
}
fn usage_field(value: &serde_json::Value) -> Option<&serde_json::Value> {
value.get("usage").filter(|u| !u.is_null())
}
pub(crate) fn parse_openai_usage_field(raw: &serde_json::Value) -> Option<Usage> {
parse_openai_usage(usage_field(raw)?)
}
fn price_or_unpriced(usage: Usage, price: Option<&TokenPrice>) -> CostOutcome {
match price {
Some(p) => CostOutcome::resolved(p.cost_of(&usage), usage),
None => CostOutcome::unpriced(usage),
}
}
#[derive(Debug, Clone, Default)]
pub struct OpenRouterProvider;
impl OpenRouterProvider {
fn with_or_cost(mut usage: Usage, u: &serde_json::Value) -> Usage {
usage.cost = u["cost"].as_f64();
usage.upstream_inference_cost = u["cost_details"]["upstream_inference_cost"].as_f64();
usage
}
}
impl Provider for OpenRouterProvider {
fn openai_request_usage(&self, body: &mut serde_json::Value, _stream: bool) {
body["usage"] = serde_json::json!({ "include": true });
}
fn wire_keeps_estimation_metadata(&self) -> bool {
true
}
fn max_cache_breakpoints(&self) -> usize {
4
}
fn openai_messages_value(&self, model: &str, messages: &[Message]) -> Vec<serde_json::Value> {
let mut payload = messages_to_payload(messages, self.wire_keeps_estimation_metadata());
let lower = model.to_ascii_lowercase();
if !lower.contains("claude") && !lower.contains("anthropic") {
return payload;
}
for i in kept_cache_breakpoints(messages, self.max_cache_breakpoints()) {
mark_openai_message(&mut payload[i]);
}
payload
}
fn attribution_headers(&self, app: Option<&AppIdentity>) -> Vec<(String, String)> {
match app {
Some(app) => vec![
("HTTP-Referer".to_string(), app.url.clone()),
("X-Title".to_string(), app.title.clone()),
],
None => Vec::new(),
}
}
fn parse_usage(&self, response: &serde_json::Value) -> Option<Usage> {
let u = usage_field(response)?;
Some(Self::with_or_cost(parse_openai_usage(u)?, u))
}
fn cost_of(&self, usage: Usage, price: Option<&TokenPrice>) -> CostOutcome {
match usage.cost {
Some(or_fee) => {
let usd = or_fee + usage.upstream_inference_cost.unwrap_or(0.0);
CostOutcome::resolved(usd, usage)
}
None => price_or_unpriced(usage, price),
}
}
fn resolve_post_stream<'a>(&'a self, ctx: PostStreamCtx<'a>) -> CostFuture<'a> {
Box::pin(async move {
if ctx.generation_id.is_empty() {
return CostOutcome::unknown();
}
for _ in 0..25 {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
if let Some(usage) =
query_generation(&ctx.client, ctx.base_url, ctx.generation_id, ctx.auth).await
{
return self.cost_of(usage, ctx.price);
}
tracing::debug!("OpenRouter generation {} not found yet", ctx.generation_id);
}
CostOutcome::unknown()
})
}
}
async fn query_generation(
client: &reqwest_middleware::ClientWithMiddleware,
base_url: &str,
generation_id: &str,
auth: &Auth,
) -> Option<Usage> {
let api_key = auth.secret()?;
let encoded =
url::form_urlencoded::byte_serialize(generation_id.as_bytes()).collect::<String>();
let url = format!(
"{}/generation?id={}",
base_url.trim_end_matches('/'),
encoded
);
let response = match client
.get(&url)
.header(
"Authorization",
format!("Bearer {}", api_key.expose_secret()),
)
.send()
.await
{
Ok(r) => r,
Err(e) => {
tracing::error!("Generation cost query for {} failed: {}", generation_id, e);
return None;
}
};
if !response.status().is_success() {
tracing::warn!(
"Generation cost query for {} returned {}",
generation_id,
response.status()
);
return None;
}
let json: serde_json::Value = response.json().await.ok()?;
usage_from_generation_record(json.get("data")?)
}
fn usage_from_generation_record(data: &serde_json::Value) -> Option<Usage> {
let cost =
data["total_cost"].as_f64()? + data["upstream_inference_cost"].as_f64().unwrap_or(0.0);
let prompt = data["tokens_prompt"].as_u64().unwrap_or(0) as u32;
let completion = data["tokens_completion"].as_u64().unwrap_or(0) as u32;
let cache_read = data["native_tokens_cached"].as_u64().unwrap_or(0) as u32;
if cache_read > prompt {
tracing::warn!(
tokens_prompt = prompt,
native_tokens_cached = cache_read,
"/generation reports cached > prompt; token breakdown clamped (cost is authoritative)"
);
}
Some(Usage {
uncached_input_tokens: prompt.saturating_sub(cache_read),
cache_read_tokens: cache_read,
cache_write_tokens: 0,
completion_tokens: completion,
cost: Some(cost),
upstream_inference_cost: None,
reasoning_tokens: data["native_tokens_reasoning"].as_u64().map(|v| v as u32),
})
}
#[derive(Debug, Clone, Default)]
pub struct OpenAiProvider;
impl Provider for OpenAiProvider {
fn openrouter_slug(&self) -> Option<&'static str> {
Some("openai")
}
fn openai_request_usage(&self, body: &mut serde_json::Value, stream: bool) {
if stream {
body["stream_options"] = serde_json::json!({ "include_usage": true });
}
}
fn cost_of(&self, usage: Usage, price: Option<&TokenPrice>) -> CostOutcome {
price_or_unpriced(usage, price)
}
}
#[derive(Debug, Clone, Default)]
pub struct GenericProvider {
pub legacy_token_limit: bool,
}
impl Provider for GenericProvider {
fn openai_token_limit_field(&self) -> &'static str {
if self.legacy_token_limit {
"max_tokens"
} else {
"max_completion_tokens"
}
}
fn emits_stream_usage(&self, _requested: bool) -> bool {
false
}
fn cost_of(&self, usage: Usage, price: Option<&TokenPrice>) -> CostOutcome {
price_or_unpriced(usage, price)
}
}
use super::response::{CompletionResponse, StreamChunk};
#[derive(Debug, Clone, Default)]
pub struct AnthropicProvider;
const ANTHROPIC_VERSION: &str = "2023-06-01";
impl AnthropicProvider {
fn text_only(msg: &Message) -> Result<String> {
use crate::message::MessageContent;
if let MessageContent::Parts(parts) = &msg.content {
if parts.iter().any(|p| p.as_text().is_none()) {
return Err(MiniLLMError::InvalidParameter(
"the Anthropic provider does not yet support multimodal content (image/audio/video); send text-only messages or use an OpenAI-wire provider".to_string(),
));
}
}
Ok(msg.content.all_text())
}
fn turn_blocks(msg: &Message, cached: bool) -> Result<(&'static str, Vec<serde_json::Value>)> {
use crate::message::Role;
let text = Self::text_only(msg)?;
let mut blocks: Vec<serde_json::Value> = Vec::new();
let role = match msg.role {
Role::Tool => {
let Some(call_id) = &msg.tool_call_id else {
return Err(MiniLLMError::InvalidParameter(
"a tool-result message needs a tool_call_id (build it via Message::tool)"
.to_string(),
));
};
blocks.push(serde_json::json!({
"type": "tool_result",
"tool_use_id": call_id,
"content": text,
}));
"user"
}
role => {
if !text.is_empty() || msg.tool_calls.is_none() {
blocks.push(serde_json::json!({ "type": "text", "text": text }));
}
if let Some(calls) = &msg.tool_calls {
for call in calls {
blocks.push(call.to_anthropic_block()?);
}
}
if role == Role::Assistant {
"assistant"
} else {
"user"
}
}
};
if cached {
let last = blocks
.last_mut()
.expect("every turn has at least one block");
last["cache_control"] = serde_json::json!({ "type": "ephemeral" });
}
Ok((role, blocks))
}
}
impl Provider for AnthropicProvider {
fn openrouter_slug(&self) -> Option<&'static str> {
Some("anthropic")
}
fn endpoint_url(&self, base_url: &str) -> String {
format!("{}/v1/messages", base_url.trim_end_matches('/'))
}
fn max_cache_breakpoints(&self) -> usize {
4
}
fn auth_headers(&self, auth: &Auth) -> Result<Vec<(String, String)>> {
let mut headers = vec![(
"anthropic-version".to_string(),
ANTHROPIC_VERSION.to_string(),
)];
match auth {
Auth::ApiKey(k) => {
headers.push(("x-api-key".to_string(), k.expose_secret().to_string()));
}
Auth::BearerToken(t) => {
headers.push((
"Authorization".to_string(),
format!("Bearer {}", t.expose_secret()),
));
headers.push(("anthropic-beta".to_string(), "oauth-2025-04-20".to_string()));
}
Auth::None => {}
}
Ok(headers)
}
fn build_request(
&self,
model: &str,
messages: &[Message],
params: &CompletionParameters,
stream: bool,
_include_usage: bool,
) -> Result<serde_json::Value> {
use crate::message::Role;
for (present, field) in [
(params.response_format.is_some(), "response_format"),
(params.reasoning.is_some(), "reasoning"),
] {
if present {
return Err(MiniLLMError::InvalidParameter(format!(
"the Anthropic provider does not yet translate `{field}`; omit it or use an OpenAI-wire provider"
)));
}
}
let kept = kept_cache_breakpoints(messages, self.max_cache_breakpoints());
let mut system = String::new();
let mut system_cached = false;
let mut turns: Vec<(&'static str, Vec<serde_json::Value>)> = Vec::new();
for (i, msg) in messages.iter().enumerate() {
let cached = kept.contains(&i);
if msg.role == Role::System {
let text = Self::text_only(msg)?;
if !system.is_empty() {
system.push('\n');
}
system.push_str(&text);
system_cached |= cached;
} else {
let (role, blocks) = Self::turn_blocks(msg, cached)?;
match turns.last_mut() {
Some((last_role, last_blocks)) if *last_role == role => {
last_blocks.extend(blocks)
}
_ => turns.push((role, blocks)),
}
}
}
let turns: Vec<serde_json::Value> = turns
.into_iter()
.map(|(role, blocks)| {
let content = match blocks.as_slice() {
[only] if only["type"] == "text" && only.get("cache_control").is_none() => {
only["text"].clone()
}
_ => serde_json::json!(blocks),
};
serde_json::json!({ "role": role, "content": content })
})
.collect();
let mut body = serde_json::json!({
"model": model,
"messages": turns,
"stream": stream,
"max_tokens": params.max_tokens.unwrap_or(4096),
});
if !system.is_empty() {
body["system"] = if system_cached {
serde_json::json!([{
"type": "text",
"text": system,
"cache_control": {"type": "ephemeral"},
}])
} else {
serde_json::json!(system)
};
}
if let Some(t) = params.temperature {
body["temperature"] = serde_json::json!(t);
}
if let Some(p) = params.top_p {
body["top_p"] = serde_json::json!(p);
}
if let Some(k) = params.top_k {
body["top_k"] = serde_json::json!(k);
}
if let Some(stop) = ¶ms.stop {
body["stop_sequences"] = serde_json::json!(stop);
}
if let Some(tools) = ¶ms.tools {
body["tools"] = serde_json::Value::Array(
tools
.iter()
.map(crate::tools::ToolDefinition::to_anthropic_value)
.collect(),
);
}
let choice = match (¶ms.tool_choice, params.parallel_tool_calls) {
(Some(c), _) => Some(c.clone()),
(None, Some(false)) => Some(crate::tools::ToolChoice::Auto),
(None, _) => None,
};
if let Some(choice) = choice {
let mut value = choice.to_anthropic_value();
if params.parallel_tool_calls == Some(false) && choice != crate::tools::ToolChoice::None
{
value["disable_parallel_tool_use"] = serde_json::json!(true);
}
body["tool_choice"] = value;
}
if let (Some(extra), Some(obj)) = (params.extra.clone(), body.as_object_mut()) {
for (key, value) in extra {
if obj.contains_key(&key) {
return Err(MiniLLMError::InvalidParameter(format!(
"extra param '{}' collides with a built-in Anthropic request key",
key
)));
}
obj.insert(key, value);
}
}
Ok(body)
}
fn parse_response(&self, raw: serde_json::Value) -> Result<CompletionResponse> {
super::response::parse_anthropic_response(raw)
}
fn parse_chunk(&self, data: &str) -> Option<Result<StreamChunk>> {
super::response::parse_anthropic_chunk(data)
}
fn emits_stream_usage(&self, requested: bool) -> bool {
requested
}
fn cost_of(&self, usage: Usage, price: Option<&TokenPrice>) -> CostOutcome {
price_or_unpriced(usage, price)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::CostResolution;
fn usage(prompt: u32, completion: u32) -> Usage {
Usage {
uncached_input_tokens: prompt,
completion_tokens: completion,
..Default::default()
}
}
#[test]
fn token_price_costs_prompt_and_completion_per_mtok() {
let price = TokenPrice::new(3.0, 15.0); let u = usage(1_000_000, 1_000_000);
assert!((price.cost_of(&u) - 18.0).abs() < 1e-9);
}
#[test]
fn token_price_bills_cache_read_and_write_at_their_own_rates() {
let price = TokenPrice::new(3.0, 15.0).with_cache_rates(0.3, 3.75);
let u = Usage {
uncached_input_tokens: 200_000,
cache_read_tokens: 800_000,
cache_write_tokens: 100_000,
..Default::default()
};
assert!(
(price.cost_of(&u) - 1.215).abs() < 1e-9,
"got {}",
price.cost_of(&u)
);
}
#[test]
fn cache_rates_fall_back_to_input_rate_when_unset() {
let price = TokenPrice::new(2.0, 0.0);
let u = Usage {
uncached_input_tokens: 0,
cache_read_tokens: 1_000_000,
cache_write_tokens: 1_000_000,
..Default::default()
};
assert!((price.cost_of(&u) - 4.0).abs() < 1e-9);
}
#[test]
fn openai_is_unpriced_without_a_price_and_resolved_with_one() {
let acct = OpenAiProvider;
let unpriced = acct.cost_of(usage(100, 50), None);
assert_eq!(unpriced.resolution, CostResolution::Unpriced);
assert_eq!(unpriced.usd, 0.0);
assert_eq!(unpriced.usage.prompt_tokens(), 100);
let price = TokenPrice::new(1.0, 1.0); let resolved = acct.cost_of(usage(1_000_000, 0), Some(&price));
assert_eq!(resolved.resolution, CostResolution::Resolved);
assert!((resolved.usd - 1.0).abs() < 1e-9);
}
#[test]
fn openrouter_aggregates_fee_plus_byok_upstream() {
let acct = OpenRouterProvider;
let mut u = usage(10, 5);
u.cost = Some(0.001);
u.upstream_inference_cost = Some(0.009);
let outcome = acct.cost_of(u, None);
assert_eq!(outcome.resolution, CostResolution::Resolved);
assert!((outcome.usd - 0.010).abs() < 1e-9);
}
#[test]
fn openrouter_all_in_generation_cost_is_not_double_counted() {
let acct = OpenRouterProvider;
let mut u = usage(10, 5);
u.cost = Some(0.010); u.upstream_inference_cost = None;
let outcome = acct.cost_of(u, None);
assert!(
(outcome.usd - 0.010).abs() < 1e-9,
"must not re-add upstream"
);
}
#[test]
fn openrouter_no_native_cost_falls_back_to_price_then_unpriced() {
let acct = OpenRouterProvider;
let no_price = acct.cost_of(usage(1_000_000, 0), None);
assert_eq!(no_price.resolution, CostResolution::Unpriced);
let price = TokenPrice::new(2.0, 0.0);
let priced = acct.cost_of(usage(1_000_000, 0), Some(&price));
assert_eq!(priced.resolution, CostResolution::Resolved);
assert!((priced.usd - 2.0).abs() < 1e-9);
}
use crate::generator::CompletionParameters;
use crate::message::Message;
#[test]
fn anthropic_endpoint_is_v1_messages() {
let p = AnthropicProvider;
assert_eq!(
p.endpoint_url("https://api.anthropic.com"),
"https://api.anthropic.com/v1/messages"
);
assert_eq!(
p.endpoint_url("https://api.anthropic.com/"),
"https://api.anthropic.com/v1/messages"
);
}
#[test]
fn anthropic_auth_headers_api_key_vs_bearer() {
let p = AnthropicProvider;
let h = p.auth_headers(&Auth::ApiKey("sk-ant-key".into())).unwrap();
assert!(h.iter().any(|(k, v)| k == "x-api-key" && v == "sk-ant-key"));
assert!(h.iter().any(|(k, _)| k == "anthropic-version"));
assert!(!h.iter().any(|(k, _)| k == "Authorization"));
let h = p
.auth_headers(&Auth::BearerToken("sk-ant-oat01-tok".into()))
.unwrap();
assert!(h
.iter()
.any(|(k, v)| k == "Authorization" && v == "Bearer sk-ant-oat01-tok"));
assert!(h.iter().any(|(k, _)| k == "anthropic-version"));
assert!(h
.iter()
.any(|(k, v)| k == "anthropic-beta" && v == "oauth-2025-04-20"));
assert!(!h.iter().any(|(k, _)| k == "x-api-key"));
}
#[test]
fn anthropic_build_request_hoists_system_and_requires_max_tokens() {
let p = AnthropicProvider;
let messages = vec![
Message::system("You are terse."),
Message::user("Hi"),
Message::assistant("Hello."),
Message::user("Bye"),
];
let params = CompletionParameters::new().with_temperature(0.5);
let body = p
.build_request("claude-haiku-4-5", &messages, ¶ms, false, true)
.unwrap();
assert_eq!(body["system"], "You are terse.");
let msgs = body["messages"].as_array().unwrap();
assert_eq!(msgs.len(), 3, "system turn is hoisted out of messages");
assert_eq!(msgs[0]["role"], "user");
assert_eq!(msgs[0]["content"], "Hi");
assert_eq!(msgs[1]["role"], "assistant");
assert_eq!(body["max_tokens"], 4096);
assert_eq!(body["temperature"], 0.5);
assert_eq!(body["model"], "claude-haiku-4-5");
assert_eq!(body["stream"], false);
}
#[test]
fn anthropic_build_request_respects_explicit_max_tokens_and_stop() {
let p = AnthropicProvider;
let messages = vec![Message::user("Hi")];
let params = CompletionParameters::new()
.with_max_tokens(64)
.with_stop(vec!["END".to_string()]);
let body = p
.build_request("m", &messages, ¶ms, true, true)
.unwrap();
assert_eq!(body["max_tokens"], 64);
assert_eq!(body["stop_sequences"][0], "END");
assert_eq!(body["stream"], true);
assert!(body.get("system").is_none());
}
#[test]
fn anthropic_build_request_rejects_extra_collision() {
let p = AnthropicProvider;
let messages = vec![Message::user("Hi")];
let params = CompletionParameters::new().with_extra("model", serde_json::json!("x"));
assert!(p
.build_request("m", &messages, ¶ms, false, true)
.is_err());
let params =
CompletionParameters::new().with_extra("metadata", serde_json::json!({"user": "u1"}));
assert!(p
.build_request("m", &messages, ¶ms, false, true)
.is_ok());
}
#[test]
fn anthropic_build_request_fails_loudly_on_every_untranslated_field() {
use crate::generator::ReasoningConfig;
let p = AnthropicProvider;
let messages = vec![Message::user("Hi")];
let cases: Vec<(&str, CompletionParameters)> = vec![
(
"response_format",
CompletionParameters::new().with_json_response(),
),
(
"reasoning",
CompletionParameters::new().with_reasoning(ReasoningConfig {
effort: Some("high".into()),
max_tokens: None,
exclude: None,
}),
),
];
for (field, params) in cases {
assert!(
p.build_request("m", &messages, ¶ms, false, true)
.is_err(),
"{field} must fail loudly, not vanish"
);
}
}
#[test]
fn anthropic_build_request_fails_loudly_on_multimodal_content() {
use crate::message::{ImageData, MessageContent};
let p = AnthropicProvider;
let img = ImageData::from_url("https://example.com/x.png");
let mut msg = Message::user("look at this");
msg.content = MessageContent::with_images("look at this", &[img]);
assert!(p
.build_request("m", &[msg], &CompletionParameters::new(), false, true)
.is_err());
}
#[test]
fn anthropic_build_request_keeps_all_text_parts_of_a_multitext_message() {
use crate::message::{ContentPart, MessageContent, Role};
let p = AnthropicProvider;
let mut user = Message::user("");
user.content = MessageContent::Parts(vec![
ContentPart::text("first"),
ContentPart::text("second"),
]);
let mut system = Message {
role: Role::System,
..Message::user("")
};
system.content =
MessageContent::Parts(vec![ContentPart::text("sysA"), ContentPart::text("sysB")]);
let body = p
.build_request(
"m",
&[system, user],
&CompletionParameters::new(),
false,
true,
)
.unwrap();
assert_eq!(body["messages"][0]["content"], "first\nsecond");
assert_eq!(body["system"], "sysA\nsysB");
}
use crate::tools::{ToolCall, ToolChoice, ToolDefinition};
fn weather_tool() -> ToolDefinition {
ToolDefinition::new(
"get_weather",
"Get the weather",
serde_json::json!({
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"],
}),
)
}
#[test]
fn anthropic_build_request_emits_tools_and_tool_choice() {
let p = AnthropicProvider;
let messages = vec![Message::user("weather in Paris?")];
let params = CompletionParameters::new()
.with_tool(weather_tool().with_strict(true))
.with_tool_choice(ToolChoice::Required)
.with_parallel_tool_calls(false);
let body = p
.build_request("m", &messages, ¶ms, false, true)
.unwrap();
assert_eq!(body["tools"][0]["name"], "get_weather");
assert_eq!(body["tools"][0]["input_schema"]["type"], "object");
assert_eq!(body["tools"][0]["strict"], true);
assert_eq!(body["tool_choice"]["type"], "any");
assert_eq!(body["tool_choice"]["disable_parallel_tool_use"], true);
}
#[test]
fn anthropic_parallel_false_without_choice_forces_auto_choice() {
let p = AnthropicProvider;
let params = CompletionParameters::new()
.with_tool(weather_tool())
.with_parallel_tool_calls(false);
let body = p
.build_request("m", &[Message::user("hi")], ¶ms, false, true)
.unwrap();
assert_eq!(body["tool_choice"]["type"], "auto");
assert_eq!(body["tool_choice"]["disable_parallel_tool_use"], true);
let params = CompletionParameters::new()
.with_tool(weather_tool())
.with_parallel_tool_calls(true);
let body = p
.build_request("m", &[Message::user("hi")], ¶ms, false, true)
.unwrap();
assert!(body.get("tool_choice").is_none());
}
#[test]
fn anthropic_assistant_tool_calls_become_tool_use_blocks() {
let p = AnthropicProvider;
let mut assistant = Message::assistant("checking");
assistant.tool_calls = Some(vec![ToolCall::new(
"tu_1",
"get_weather",
r#"{"city":"Paris"}"#,
)]);
let messages = vec![Message::user("weather?"), assistant];
let body = p
.build_request("m", &messages, &CompletionParameters::new(), false, true)
.unwrap();
let blocks = body["messages"][1]["content"].as_array().unwrap();
assert_eq!(blocks[0]["type"], "text");
assert_eq!(blocks[0]["text"], "checking");
assert_eq!(blocks[1]["type"], "tool_use");
assert_eq!(blocks[1]["id"], "tu_1");
assert_eq!(blocks[1]["name"], "get_weather");
assert_eq!(blocks[1]["input"]["city"], "Paris", "input is an object");
}
#[test]
fn anthropic_assistant_tool_call_without_text_has_no_empty_text_block() {
let p = AnthropicProvider;
let mut assistant = Message::assistant("");
assistant.tool_calls = Some(vec![ToolCall::new("tu_1", "get_weather", "{}")]);
let body = p
.build_request(
"m",
&[Message::user("weather?"), assistant],
&CompletionParameters::new(),
false,
true,
)
.unwrap();
let blocks = body["messages"][1]["content"].as_array().unwrap();
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0]["type"], "tool_use");
}
#[test]
fn anthropic_tool_results_become_one_user_turn_with_tool_result_blocks() {
let p = AnthropicProvider;
let mut assistant = Message::assistant("");
assistant.tool_calls = Some(vec![
ToolCall::new("tu_1", "get_weather", r#"{"city":"Paris"}"#),
ToolCall::new("tu_2", "get_weather", r#"{"city":"Lyon"}"#),
]);
let messages = vec![
Message::user("weather?"),
assistant,
Message::tool("tu_1", "15 degrees"),
Message::tool("tu_2", "18 degrees"),
Message::user("thanks, summarize"),
];
let body = p
.build_request("m", &messages, &CompletionParameters::new(), false, true)
.unwrap();
let turns = body["messages"].as_array().unwrap();
assert_eq!(
turns.len(),
3,
"user / assistant / merged results+text user"
);
assert_eq!(turns[2]["role"], "user");
let blocks = turns[2]["content"].as_array().unwrap();
assert_eq!(blocks.len(), 3);
assert_eq!(blocks[0]["type"], "tool_result");
assert_eq!(blocks[0]["tool_use_id"], "tu_1");
assert_eq!(blocks[0]["content"], "15 degrees");
assert_eq!(blocks[1]["type"], "tool_result");
assert_eq!(blocks[1]["tool_use_id"], "tu_2");
assert_eq!(blocks[2]["type"], "text");
assert_eq!(blocks[2]["text"], "thanks, summarize");
}
#[test]
fn anthropic_tool_result_without_call_id_fails_loudly() {
let p = AnthropicProvider;
let mut orphan = Message::tool("x", "result");
orphan.tool_call_id = None;
assert!(p
.build_request(
"m",
&[Message::user("hi"), orphan],
&CompletionParameters::new(),
false,
true
)
.is_err());
}
#[test]
fn anthropic_invalid_tool_call_arguments_fail_loudly() {
let p = AnthropicProvider;
let mut assistant = Message::assistant("");
assistant.tool_calls = Some(vec![ToolCall::new("tu_1", "t", "{not json")]);
assert!(p
.build_request(
"m",
&[Message::user("hi"), assistant],
&CompletionParameters::new(),
false,
true
)
.is_err());
}
fn cached_msg(m: Message) -> Message {
Message {
cache_breakpoint: true,
..m
}
}
#[test]
fn anthropic_no_breakpoint_uses_plain_string_content() {
let p = AnthropicProvider;
let messages = vec![Message::system("sys"), Message::user("hi")];
let body = p
.build_request("m", &messages, &CompletionParameters::new(), false, true)
.unwrap();
assert!(body["system"].is_string());
assert!(body["messages"][0]["content"].is_string());
}
#[test]
fn anthropic_breakpoint_on_system_emits_block_with_cache_control() {
let p = AnthropicProvider;
let messages = vec![
cached_msg(Message::system("big system")),
Message::user("hi"),
];
let body = p
.build_request("m", &messages, &CompletionParameters::new(), false, true)
.unwrap();
assert_eq!(body["system"][0]["type"], "text");
assert_eq!(body["system"][0]["text"], "big system");
assert_eq!(body["system"][0]["cache_control"]["type"], "ephemeral");
}
#[test]
fn anthropic_breakpoint_on_turn_emits_block_with_cache_control() {
let p = AnthropicProvider;
let messages = vec![
Message::system("sys"),
cached_msg(Message::user("cache me")),
Message::user("new"),
];
let body = p
.build_request("m", &messages, &CompletionParameters::new(), false, true)
.unwrap();
let blocks = body["messages"][0]["content"].as_array().unwrap();
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0]["text"], "cache me");
assert_eq!(blocks[0]["cache_control"]["type"], "ephemeral");
assert_eq!(blocks[1]["text"], "new");
assert!(blocks[1].get("cache_control").is_none());
}
#[test]
fn anthropic_caps_breakpoints_at_four_keeping_the_last() {
let p = AnthropicProvider;
let messages: Vec<Message> = (0..5)
.map(|i| cached_msg(Message::user(format!("turn{i}"))))
.collect();
let body = p
.build_request("m", &messages, &CompletionParameters::new(), false, true)
.unwrap();
let blocks = body["messages"][0]["content"].as_array().unwrap();
assert_eq!(blocks.len(), 5);
assert!(
blocks[0].get("cache_control").is_none(),
"oldest mark dropped"
);
for b in &blocks[1..5] {
assert_eq!(b["cache_control"]["type"], "ephemeral");
}
}
#[test]
fn openrouter_claude_marked_messages_carry_cache_control() {
let p = OpenRouterProvider;
let messages = vec![
cached_msg(Message::system("big system")),
Message::user("hi"),
cached_msg(Message::user("monitor")),
];
let body = p
.build_request(
"anthropic/claude-sonnet-4.5",
&messages,
&CompletionParameters::new(),
false,
true,
)
.unwrap();
let msgs = body["messages"].as_array().unwrap();
assert_eq!(msgs[0]["content"][0]["text"], "big system");
assert_eq!(msgs[0]["content"][0]["cache_control"]["type"], "ephemeral");
assert_eq!(msgs[1]["content"], "hi");
assert_eq!(msgs[2]["content"][0]["cache_control"]["type"], "ephemeral");
}
#[test]
fn openrouter_non_claude_model_emits_no_markers() {
let p = OpenRouterProvider;
let messages = vec![cached_msg(Message::user("x"))];
let payload = p.openai_messages_value("openai/gpt-5", &messages);
assert_eq!(payload[0]["content"], "x");
}
#[test]
fn openrouter_caps_markers_at_four_keeping_the_last() {
let p = OpenRouterProvider;
let messages: Vec<Message> = (0..5)
.map(|i| cached_msg(Message::user(format!("m{i}"))))
.collect();
let payload = p.openai_messages_value("anthropic/claude-opus-4", &messages);
assert_eq!(payload[0]["content"], "m0", "oldest mark dropped");
for msg in &payload[1..5] {
assert_eq!(msg["content"][0]["cache_control"]["type"], "ephemeral");
}
}
#[test]
fn openrouter_marked_tool_result_carries_cache_control() {
let p = OpenRouterProvider;
let messages = vec![cached_msg(Message::tool("c1", "result"))];
let payload = p.openai_messages_value("anthropic/claude-opus-4", &messages);
assert_eq!(payload[0]["tool_call_id"], "c1");
assert_eq!(
payload[0]["content"][0]["cache_control"]["type"],
"ephemeral"
);
}
#[test]
fn openrouter_marked_pure_tool_call_assistant_drops_marker() {
let p = OpenRouterProvider;
let call = crate::tools::ToolCall {
id: "c1".to_string(),
name: "f".to_string(),
arguments: "{}".to_string(),
};
let assistant = Message {
tool_calls: Some(vec![call]),
..Message::assistant("")
};
let payload = p.openai_messages_value("anthropic/claude-opus-4", &[cached_msg(assistant)]);
assert_eq!(payload[0]["content"], "");
assert!(payload[0]["tool_calls"].is_array());
}
#[test]
fn anthropic_cost_is_token_priced_or_unpriced() {
let p = AnthropicProvider;
let unpriced = p.cost_of(usage(100, 50), None);
assert_eq!(unpriced.resolution, CostResolution::Unpriced);
assert_eq!(unpriced.usage.prompt_tokens(), 100);
let price = TokenPrice::new(1.0, 5.0); let resolved = p.cost_of(usage(1_000_000, 1_000_000), Some(&price));
assert_eq!(resolved.resolution, CostResolution::Resolved);
assert!((resolved.usd - 6.0).abs() < 1e-9);
}
#[test]
fn a_byok_generation_record_books_the_upstream_charge() {
let data = serde_json::json!({
"tokens_prompt": 22, "tokens_completion": 2625,
"native_tokens_prompt": 22, "native_tokens_completion": 2230,
"native_tokens_reasoning": 0, "native_tokens_cached": 0,
"is_byok": true, "total_cost": 0, "upstream_inference_cost": 0.0008942,
});
let usage = usage_from_generation_record(&data).expect("parses");
assert!((usage.cost.unwrap() - 0.0008942).abs() < 1e-12);
assert_eq!(
usage.upstream_inference_cost, None,
"already summed; must not re-add"
);
assert_eq!(usage.uncached_input_tokens, 22);
assert_eq!(usage.completion_tokens, 2625);
}
#[test]
fn a_credits_generation_record_books_total_cost() {
let data = serde_json::json!({
"tokens_prompt": 30, "tokens_completion": 1800,
"total_cost": 0.000723, "upstream_inference_cost": null,
});
let usage = usage_from_generation_record(&data).expect("parses");
assert!((usage.cost.unwrap() - 0.000723).abs() < 1e-12);
}
#[test]
fn a_generation_record_without_total_cost_is_unresolved_not_free() {
let data = serde_json::json!({ "tokens_prompt": 30, "tokens_completion": 1800 });
assert!(usage_from_generation_record(&data).is_none());
}
}