Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion rust/crates/api/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ mod tests {
};

let cost = response.usage.estimated_cost_usd(&response.model);
assert_eq!(format_usd(cost.total_cost_usd()), "$54.6750");
assert_eq!(format_usd(cost.total_cost_usd()), "$10.9350");
assert_eq!(response.total_tokens(), 1_800_000);
}

Expand Down
6 changes: 3 additions & 3 deletions rust/crates/api/tests/client_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ async fn send_message_applies_request_profile_and_records_telemetry() {
"\"model\":\"claude-3-7-sonnet-latest\",",
"\"stop_reason\":\"end_turn\",",
"\"stop_sequence\":null,",
"\"usage\":{\"input_tokens\":1,\"cache_creation_input_tokens\":2,\"cache_read_input_tokens\":3,\"output_tokens\":1}",
"\"usage\":{\"input_tokens\":100,\"cache_creation_input_tokens\":200,\"cache_read_input_tokens\":300,\"output_tokens\":100}",
"}"
),
&[("request-id", "req_profile_123")],
Expand Down Expand Up @@ -283,8 +283,8 @@ async fn send_message_applies_request_profile_and_records_telemetry() {
if event.namespace == "api"
&& event.action == "message_usage"
&& event.properties.get("request_id") == Some(&json!("req_profile_123"))
&& event.properties.get("total_tokens") == Some(&json!(7))
&& event.properties.get("estimated_cost_usd") == Some(&json!("$0.0001"))
&& event.properties.get("total_tokens") == Some(&json!(700))
&& event.properties.get("estimated_cost_usd") == Some(&json!("$0.0026"))
));
assert!(matches!(
&events[5],
Expand Down
176 changes: 147 additions & 29 deletions rust/crates/runtime/src/usage.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
use crate::session::Session;

const DEFAULT_INPUT_COST_PER_MILLION: f64 = 15.0;
const DEFAULT_OUTPUT_COST_PER_MILLION: f64 = 75.0;
const DEFAULT_CACHE_CREATION_COST_PER_MILLION: f64 = 18.75;
const DEFAULT_CACHE_READ_COST_PER_MILLION: f64 = 1.5;
const DEFAULT_INPUT_COST_PER_MILLION: f64 = 3.0;
const DEFAULT_OUTPUT_COST_PER_MILLION: f64 = 15.0;
const DEFAULT_CACHE_CREATION_COST_PER_MILLION: f64 = 3.75;
const DEFAULT_CACHE_READ_COST_PER_MILLION: f64 = 0.3;

/// Per-million-token pricing used for cost estimation.
#[derive(Debug, Clone, Copy, PartialEq)]
Expand All @@ -17,11 +17,20 @@ pub struct ModelPricing {
impl ModelPricing {
#[must_use]
pub const fn default_sonnet_tier() -> Self {
Self::per_million(
DEFAULT_INPUT_COST_PER_MILLION,
DEFAULT_OUTPUT_COST_PER_MILLION,
DEFAULT_CACHE_CREATION_COST_PER_MILLION,
DEFAULT_CACHE_READ_COST_PER_MILLION,
)
}

const fn per_million(input: f64, output: f64, cache_creation: f64, cache_read: f64) -> Self {
Self {
input_cost_per_million: DEFAULT_INPUT_COST_PER_MILLION,
output_cost_per_million: DEFAULT_OUTPUT_COST_PER_MILLION,
cache_creation_cost_per_million: DEFAULT_CACHE_CREATION_COST_PER_MILLION,
cache_read_cost_per_million: DEFAULT_CACHE_READ_COST_PER_MILLION,
input_cost_per_million: input,
output_cost_per_million: output,
cache_creation_cost_per_million: cache_creation,
cache_read_cost_per_million: cache_read,
}
}
}
Expand Down Expand Up @@ -55,31 +64,79 @@ impl UsageCostEstimate {
}

/// Returns pricing metadata for a known model alias or family.
///
/// Anthropic rates follow <https://platform.claude.com/docs/en/about-claude/pricing>, using
/// 5-minute cache writes. Unversioned ids fall back to the tier the bare alias resolves to.
#[must_use]
pub fn pricing_for_model(model: &str) -> Option<ModelPricing> {
let normalized = model.to_ascii_lowercase();
if normalized.contains("fable") || normalized.contains("mythos") {
let version =
family_version(&normalized, "fable").or_else(|| family_version(&normalized, "mythos"));
return Some(match version {
Some((5, 1)) => ModelPricing::per_million(10.0, 50.0, 12.5, 0.25),
_ => ModelPricing::per_million(10.0, 50.0, 12.5, 1.0),
});
}
if normalized.contains("haiku") {
return Some(ModelPricing {
input_cost_per_million: 1.0,
output_cost_per_million: 5.0,
cache_creation_cost_per_million: 1.25,
cache_read_cost_per_million: 0.1,
if normalized.contains("3-5-haiku") {
return Some(ModelPricing::per_million(0.8, 4.0, 1.0, 0.08));
}
return Some(match family_version(&normalized, "haiku") {
// Haiku 5.5 bills prompts over 100K tokens at 5x; usage totals are not
// tracked per request, so estimates use the base tier.
Some((5, 5)) => ModelPricing::per_million(0.1, 0.5, 0.125, 0.01),
_ => ModelPricing::per_million(1.0, 5.0, 1.25, 0.1),
});
}
if normalized.contains("opus") {
return Some(ModelPricing {
input_cost_per_million: 15.0,
output_cost_per_million: 75.0,
cache_creation_cost_per_million: 18.75,
cache_read_cost_per_million: 1.5,
return Some(match family_version(&normalized, "opus") {
Some((5, 5)) => ModelPricing::per_million(4.0, 20.0, 5.0, 0.2),
// Opus 4/4.1 remain on Bedrock and Google Cloud at the original tier.
Some((4, 0 | 1)) => ModelPricing::per_million(15.0, 75.0, 18.75, 1.5),
_ => ModelPricing::per_million(5.0, 25.0, 6.25, 0.5),
});
}
if normalized.contains("sonnet") {
return Some(ModelPricing::default_sonnet_tier());
return Some(match family_version(&normalized, "sonnet") {
Some((5, 5)) => ModelPricing::per_million(2.0, 10.0, 2.5, 0.1),
Some((5, 0)) => ModelPricing::per_million(2.0, 10.0, 2.5, 0.2),
_ => ModelPricing::default_sonnet_tier(),
});
}
// DeepSeek API peak rates (<https://api-docs.deepseek.com/quick_start/pricing>);
// off-peak usage is billed at half. Cache misses are billed as regular input,
// with no separate cache-write charge. The retired `deepseek-chat` and
// `deepseek-reasoner` ids were served by V4 Flash.
if normalized.contains("deepseek-v4-pro") {
return Some(ModelPricing::per_million(1.32, 3.96, 1.32, 0.044));
}
if [
"deepseek-flash",
"deepseek-v4-flash",
"deepseek-chat",
"deepseek-reasoner",
]
.iter()
.any(|id| normalized.contains(id))
{
return Some(ModelPricing::per_million(0.3, 1.2, 0.3, 0.006));
}
None
}

/// Parses the `(major, minor)` version after `family` in ids such as
/// `claude-opus-4-1-20250805`, `anthropic/claude-sonnet-5-5`, or `claude-opus-4@20250514`.
/// Date suffixes are not treated as a minor version.
fn family_version(model: &str, family: &str) -> Option<(u32, u32)> {
let rest = model[model.find(family)? + family.len()..].strip_prefix('-')?;
let mut parts = rest.split(|c: char| !c.is_ascii_digit());
let parse = |part: &str| part.parse::<u32>().ok().filter(|_| part.len() <= 2);
let major = parse(parts.next()?)?;
let minor = parts.next().and_then(parse).unwrap_or(0);
Some((major, minor))
}

impl TokenUsage {
#[must_use]
pub fn total_tokens(self) -> u32 {
Expand Down Expand Up @@ -253,12 +310,12 @@ mod tests {
};

let cost = usage.estimate_cost_usd();
assert_eq!(format_usd(cost.input_cost_usd), "$15.0000");
assert_eq!(format_usd(cost.output_cost_usd), "$37.5000");
assert_eq!(format_usd(cost.input_cost_usd), "$3.0000");
assert_eq!(format_usd(cost.output_cost_usd), "$7.5000");
let lines = usage.summary_lines_for_model("usage", Some("claude-sonnet-4-20250514"));
assert!(lines[0].contains("estimated_cost=$54.6750"));
assert!(lines[0].contains("estimated_cost=$10.9350"));
assert!(lines[0].contains("model=claude-sonnet-4-20250514"));
assert!(lines[1].contains("cache_read=$0.3000"));
assert!(lines[1].contains("cache_read=$0.0600"));
}

#[test]
Expand All @@ -269,13 +326,74 @@ mod tests {
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
};
let total_for = |model: &str| {
let pricing = pricing_for_model(model).expect("known model pricing");
format_usd(
usage
.estimate_cost_usd_with_pricing(pricing)
.total_cost_usd(),
)
};

assert_eq!(total_for("claude-fable-5-1"), "$35.0000");
assert_eq!(total_for("claude-opus-4-6"), "$17.5000");
assert_eq!(total_for("anthropic/claude-opus-5-5"), "$14.0000");
assert_eq!(total_for("claude-opus-4-1-20250805"), "$52.5000");
assert_eq!(total_for("claude-opus-4@20250514"), "$52.5000");
assert_eq!(total_for("claude-sonnet-5-5"), "$7.0000");
assert_eq!(total_for("claude-sonnet-4-6"), "$10.5000");
assert_eq!(total_for("claude-haiku-5-5"), "$0.3500");
assert_eq!(total_for("claude-haiku-4-5-20251001"), "$3.5000");
assert_eq!(total_for("claude-3-5-haiku-20241022"), "$2.8000");
}

#[test]
fn distinguishes_cache_read_rates_across_versions() {
let usage = TokenUsage {
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 1_000_000,
};
let cache_read_for = |model: &str| {
let pricing = pricing_for_model(model).expect("known model pricing");
format_usd(
usage
.estimate_cost_usd_with_pricing(pricing)
.cache_read_cost_usd,
)
};

assert_eq!(cache_read_for("claude-fable-5-1"), "$0.2500");
assert_eq!(cache_read_for("claude-fable-5"), "$1.0000");
assert_eq!(cache_read_for("claude-sonnet-5-5"), "$0.1000");
assert_eq!(cache_read_for("claude-sonnet-5"), "$0.2000");
}

#[test]
fn prices_deepseek_api_models_at_peak_rates() {
let usage = TokenUsage {
input_tokens: 1_000_000,
output_tokens: 500_000,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 1_000_000,
};
let total_for = |model: &str| {
let pricing = pricing_for_model(model).expect("known model pricing");
format_usd(
usage
.estimate_cost_usd_with_pricing(pricing)
.total_cost_usd(),
)
};

let haiku = pricing_for_model("claude-haiku-4-5-20251001").expect("haiku pricing");
let opus = pricing_for_model("claude-opus-4-6").expect("opus pricing");
let haiku_cost = usage.estimate_cost_usd_with_pricing(haiku);
let opus_cost = usage.estimate_cost_usd_with_pricing(opus);
assert_eq!(format_usd(haiku_cost.total_cost_usd()), "$3.5000");
assert_eq!(format_usd(opus_cost.total_cost_usd()), "$52.5000");
assert_eq!(total_for("deepseek-v4-pro"), "$3.3440");
assert_eq!(total_for("openai/deepseek-v4-pro"), "$3.3440");
assert_eq!(total_for("deepseek-flash"), "$0.9060");
assert_eq!(total_for("deepseek/deepseek-v4-flash"), "$0.9060");
assert_eq!(total_for("deepseek-reasoner"), "$0.9060");
// Third-party hosts set their own prices for other DeepSeek models.
assert_eq!(pricing_for_model("deepseek-r1"), None);
}

#[test]
Expand Down