claude-cost 0.1.0

Calculate Claude API call cost from a usage block. Cache-aware (cache_creation, cache_read), supports Anthropic API and AWS Bedrock model IDs, BYO pricing override. No SDK dependency.
Documentation
//! Per-model price table.
//!
//! All values are USD per 1,000,000 tokens, matching how Anthropic and AWS
//! publish their rates. cache_read is typically 10% of input; cache_write
//! is typically 1.25x input.

use crate::normalize::normalize_model_id;
use crate::usage::Usage;

/// Per-model rates, USD per 1M tokens.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Pricing {
    /// Fresh input tokens (no cache).
    pub input_per_mtok: f64,
    /// Output tokens.
    pub output_per_mtok: f64,
    /// Cache-read tokens (cache hit). Typically 10% of input.
    pub cache_read_per_mtok: f64,
    /// Cache-write tokens (cache creation). Typically 1.25x input.
    pub cache_write_per_mtok: f64,
}

impl Pricing {
    /// Compute USD cost for the given usage.
    pub fn cost_for(&self, usage: &Usage) -> f64 {
        (usage.input_tokens as f64 * self.input_per_mtok
            + usage.output_tokens as f64 * self.output_per_mtok
            + usage.cache_read_input_tokens as f64 * self.cache_read_per_mtok
            + usage.cache_creation_input_tokens as f64 * self.cache_write_per_mtok)
            / 1_000_000.0
    }
}

/// Built-in pricing table. Source: anthropic.com/pricing and
/// aws.amazon.com/bedrock/pricing as of 2026-Q2. VERIFY before billing.
///
/// Keys are normalized model names (no `anthropic.` prefix, no version
/// suffix, no inference-profile prefix). Use [`default_pricing`] to look
/// up by any Bedrock or Anthropic ID.
pub const DEFAULT_PRICING_TABLE: &[(&str, Pricing)] = &[
    (
        "claude-sonnet-4-5",
        Pricing {
            input_per_mtok: 3.0,
            output_per_mtok: 15.0,
            cache_read_per_mtok: 0.3,
            cache_write_per_mtok: 3.75,
        },
    ),
    (
        "claude-opus-4-7",
        Pricing {
            input_per_mtok: 15.0,
            output_per_mtok: 75.0,
            cache_read_per_mtok: 1.5,
            cache_write_per_mtok: 18.75,
        },
    ),
    (
        "claude-haiku-4-5",
        Pricing {
            input_per_mtok: 1.0,
            output_per_mtok: 5.0,
            cache_read_per_mtok: 0.1,
            cache_write_per_mtok: 1.25,
        },
    ),
    (
        // Older sonnet, still in production for some teams
        "claude-3-5-sonnet-20241022",
        Pricing {
            input_per_mtok: 3.0,
            output_per_mtok: 15.0,
            cache_read_per_mtok: 0.3,
            cache_write_per_mtok: 3.75,
        },
    ),
    (
        "claude-3-5-haiku-20241022",
        Pricing {
            input_per_mtok: 0.8,
            output_per_mtok: 4.0,
            cache_read_per_mtok: 0.08,
            cache_write_per_mtok: 1.0,
        },
    ),
];

/// Look up pricing for a model id. Accepts Anthropic API names, Bedrock
/// model ids (with or without version suffix), inference-profile prefixes,
/// and ARNs. Returns `None` for unknown models.
pub fn default_pricing(model_id: &str) -> Option<Pricing> {
    let key = normalize_model_id(model_id);
    DEFAULT_PRICING_TABLE
        .iter()
        .find_map(|(name, p)| if *name == key { Some(*p) } else { None })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn pricing_zero_usage_is_zero() {
        let p = default_pricing("claude-sonnet-4-5").unwrap();
        let cost = p.cost_for(&Usage::default());
        assert_eq!(cost, 0.0);
    }

    #[test]
    fn sonnet_million_input_is_three_dollars() {
        let p = default_pricing("claude-sonnet-4-5").unwrap();
        let usage = Usage {
            input_tokens: 1_000_000,
            ..Usage::default()
        };
        assert!((p.cost_for(&usage) - 3.0).abs() < 1e-9);
    }

    #[test]
    fn cache_read_is_one_tenth_input_for_sonnet() {
        let p = default_pricing("claude-sonnet-4-5").unwrap();
        let usage = Usage {
            cache_read_input_tokens: 1_000_000,
            ..Usage::default()
        };
        assert!((p.cost_for(&usage) - 0.3).abs() < 1e-9);
    }

    #[test]
    fn opus_pricing_is_5x_sonnet() {
        let s = default_pricing("claude-sonnet-4-5").unwrap();
        let o = default_pricing("claude-opus-4-7").unwrap();
        assert_eq!(o.input_per_mtok / s.input_per_mtok, 5.0);
        assert_eq!(o.output_per_mtok / s.output_per_mtok, 5.0);
    }

    #[test]
    fn lookup_via_bedrock_versioned_id() {
        assert!(default_pricing("anthropic.claude-sonnet-4-5-v1:0").is_some());
    }

    #[test]
    fn lookup_via_inference_profile() {
        assert!(default_pricing("us.anthropic.claude-haiku-4-5").is_some());
        assert!(default_pricing("eu.anthropic.claude-opus-4-7-v1:0").is_some());
    }

    #[test]
    fn unknown_model_returns_none() {
        assert!(default_pricing("gpt-5").is_none());
    }

    #[test]
    fn aggregate_cost_matches_byhand() {
        let p = default_pricing("claude-sonnet-4-5").unwrap();
        let usage = Usage {
            input_tokens: 423,
            output_tokens: 18,
            cache_creation_input_tokens: 0,
            cache_read_input_tokens: 380,
        };
        // 423 * 3 + 18 * 15 + 380 * 0.3 = 1269 + 270 + 114 = 1653 / 1M = 0.001653
        assert!((p.cost_for(&usage) - 0.001653).abs() < 1e-9);
    }
}