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
//! End-to-end shape checks.

use claude_cost::{default_pricing, normalize_model_id, Pricing, Usage, DEFAULT_PRICING_TABLE};

#[test]
fn the_table_is_not_empty() {
    assert!(!DEFAULT_PRICING_TABLE.is_empty());
}

#[test]
fn the_table_has_no_duplicate_keys() {
    let mut keys: Vec<&str> = DEFAULT_PRICING_TABLE.iter().map(|(k, _)| *k).collect();
    keys.sort();
    let len_before = keys.len();
    keys.dedup();
    assert_eq!(keys.len(), len_before, "duplicate model keys in table");
}

#[test]
fn every_table_entry_normalizes_to_itself() {
    for (key, _) in DEFAULT_PRICING_TABLE {
        assert_eq!(normalize_model_id(key), *key, "key not normalized: {key}");
    }
}

#[test]
fn cache_aware_cost_byhand_match() {
    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,
    };
    let want = (423.0 * 3.0 + 18.0 * 15.0 + 380.0 * 0.3) / 1_000_000.0;
    assert!((p.cost_for(&usage) - want).abs() < 1e-9);
}

#[test]
fn from_bedrock_converse_maps_correctly() {
    let usage = Usage::from_bedrock_converse(100, 50, 30, 10);
    assert_eq!(usage.input_tokens, 100);
    assert_eq!(usage.output_tokens, 50);
    assert_eq!(usage.cache_read_input_tokens, 30);
    assert_eq!(usage.cache_creation_input_tokens, 10);
}

#[test]
fn cache_hit_flag_matches_cache_read() {
    let cold = Usage {
        input_tokens: 100,
        ..Usage::default()
    };
    let warm = Usage {
        input_tokens: 100,
        cache_read_input_tokens: 50,
        ..Usage::default()
    };
    assert!(!cold.cache_hit());
    assert!(warm.cache_hit());
}

#[test]
fn byo_pricing_overrides_table() {
    let custom = Pricing {
        input_per_mtok: 1.0,
        output_per_mtok: 1.0,
        cache_read_per_mtok: 0.0,
        cache_write_per_mtok: 0.0,
    };
    let usage = Usage {
        input_tokens: 1_000_000,
        ..Usage::default()
    };
    assert_eq!(custom.cost_for(&usage), 1.0);
}

#[cfg(feature = "serde")]
#[test]
fn usage_round_trips_through_anthropic_json_shape() {
    let json = r#"{
      "input_tokens": 423,
      "output_tokens": 18,
      "cache_creation_input_tokens": 0,
      "cache_read_input_tokens": 380
    }"#;
    let usage: Usage = serde_json::from_str(json).unwrap();
    assert_eq!(usage.input_tokens, 423);
    assert_eq!(usage.cache_read_input_tokens, 380);

    let p = default_pricing("claude-sonnet-4-5").unwrap();
    let want = 0.001653;
    assert!((p.cost_for(&usage) - want).abs() < 1e-9);
}

#[cfg(feature = "serde")]
#[test]
fn usage_serde_default_zeros_missing_fields() {
    let partial = r#"{"input_tokens": 10, "output_tokens": 5}"#;
    let usage: Usage = serde_json::from_str(partial).unwrap();
    assert_eq!(usage.cache_read_input_tokens, 0);
    assert_eq!(usage.cache_creation_input_tokens, 0);
}