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
//! Strip prefixes from a Claude model identifier so the same pricing table
//! works for both Anthropic API names and AWS Bedrock IDs.
//!
//! Bedrock IDs come in three shapes:
//!
//! * `anthropic.claude-sonnet-4-5` - regional model id
//! * `anthropic.claude-sonnet-4-5-v1:0` - versioned regional model id
//! * `arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-5` - ARN
//! * `us.anthropic.claude-sonnet-4-5` - cross-region inference profile
//! * `eu.anthropic.claude-sonnet-4-5` / `apac.anthropic.claude-sonnet-4-5`
//!
//! The Anthropic API just uses `claude-sonnet-4-5`. We normalize all the
//! Bedrock variants to the API shape, then look up the same pricing key.

const INFERENCE_PROFILE_PREFIXES: &[&str] = &["us.", "eu.", "apac."];
const ANTHROPIC_BEDROCK_PREFIX: &str = "anthropic.";

/// Strip ARN, inference-profile, version, and `anthropic.` prefixes.
///
/// Returns the bare API-style name (`claude-sonnet-4-5`).
pub fn normalize_model_id(id: &str) -> &str {
    let mut s = id;

    // ARN -> tail after final `/`
    if s.starts_with("arn:aws:bedrock:") {
        if let Some(slash) = s.rfind('/') {
            s = &s[slash + 1..];
        }
    }

    // Cross-region inference-profile prefix
    for prefix in INFERENCE_PROFILE_PREFIXES {
        if let Some(rest) = s.strip_prefix(prefix) {
            s = rest;
            break;
        }
    }

    // Anthropic vendor prefix on Bedrock
    if let Some(rest) = s.strip_prefix(ANTHROPIC_BEDROCK_PREFIX) {
        s = rest;
    }

    // Version suffix `-v1:0`, `-v2:0`, etc.
    if let Some(idx) = s.rfind("-v") {
        let tail = &s[idx + 2..];
        // tail must look like `\d+:\d+` for us to treat it as a version suffix
        if tail
            .splitn(2, ':')
            .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
            && tail.contains(':')
        {
            s = &s[..idx];
        }
    }

    s
}

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

    #[test]
    fn passthrough_anthropic_api_name() {
        assert_eq!(normalize_model_id("claude-sonnet-4-5"), "claude-sonnet-4-5");
    }

    #[test]
    fn strips_anthropic_bedrock_prefix() {
        assert_eq!(
            normalize_model_id("anthropic.claude-sonnet-4-5"),
            "claude-sonnet-4-5"
        );
    }

    #[test]
    fn strips_versioned_bedrock_id() {
        assert_eq!(
            normalize_model_id("anthropic.claude-sonnet-4-5-v1:0"),
            "claude-sonnet-4-5"
        );
    }

    #[test]
    fn strips_inference_profile_us() {
        assert_eq!(
            normalize_model_id("us.anthropic.claude-sonnet-4-5"),
            "claude-sonnet-4-5"
        );
    }

    #[test]
    fn strips_inference_profile_eu_with_version() {
        assert_eq!(
            normalize_model_id("eu.anthropic.claude-sonnet-4-5-v1:0"),
            "claude-sonnet-4-5"
        );
    }

    #[test]
    fn strips_arn() {
        let arn = "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-4-7-v1:0";
        assert_eq!(normalize_model_id(arn), "claude-opus-4-7");
    }

    #[test]
    fn does_not_strip_non_version_suffix() {
        // "v1" without a colon-digit tail should not be stripped
        assert_eq!(
            normalize_model_id("claude-sonnet-4-5-vNext"),
            "claude-sonnet-4-5-vNext"
        );
    }
}