feat: added support for input/output token cache cost tracking

This commit is contained in:
Dark-Alex-17 committed 2026-09-14 14:47:01 -06:00
1 parent 755b25f9b2
commit d84dea21b6
2 files changed
+57 -4

No files matched your search

+53 -4
View File
@@ -201,6 +201,8 @@ fn modelsdev_provider_models(api: &Value, provider_id: &str) -> Vec<SourcedModel
.and_then(clamp_output_tokens); .and_then(clamp_output_tokens);
data.input_price = entry["cost"]["input"].as_f64().and_then(clamp_price); data.input_price = entry["cost"]["input"].as_f64().and_then(clamp_price);
data.output_price = entry["cost"]["output"].as_f64().and_then(clamp_price); data.output_price = entry["cost"]["output"].as_f64().and_then(clamp_price);
data.cache_read_price = entry["cost"]["cache_read"].as_f64().and_then(clamp_price);
data.cache_write_price = entry["cost"]["cache_write"].as_f64().and_then(clamp_price);
data.supports_vision = entry["modalities"]["input"] data.supports_vision = entry["modalities"]["input"]
.as_array() .as_array()
.is_some_and(|modes| modes.iter().any(|mode| mode.as_str() == Some("image"))); .is_some_and(|modes| modes.iter().any(|mode| mode.as_str() == Some("image")));
@@ -241,6 +243,8 @@ fn openrouter_model_data(name: &str, entry: &Value) -> ModelData {
.and_then(clamp_output_tokens); .and_then(clamp_output_tokens);
data.input_price = openrouter_price(&entry["pricing"]["prompt"]); data.input_price = openrouter_price(&entry["pricing"]["prompt"]);
data.output_price = openrouter_price(&entry["pricing"]["completion"]); data.output_price = openrouter_price(&entry["pricing"]["completion"]);
data.cache_read_price = openrouter_price(&entry["pricing"]["input_cache_read"]);
data.cache_write_price = openrouter_price(&entry["pricing"]["input_cache_write"]);
data.supports_vision = entry["architecture"]["modality"] data.supports_vision = entry["architecture"]["modality"]
.as_str() .as_str()
.is_some_and(|modality| modality.contains("image")); .is_some_and(|modality| modality.contains("image"));
@@ -294,6 +298,12 @@ fn fill_from_openrouter(data: &mut ModelData, entry: &Value) {
if data.output_price.is_none() { if data.output_price.is_none() {
data.output_price = filler.output_price; data.output_price = filler.output_price;
} }
if data.cache_read_price.is_none() {
data.cache_read_price = filler.cache_read_price;
}
if data.cache_write_price.is_none() {
data.cache_write_price = filler.cache_write_price;
}
if !data.supports_vision { if !data.supports_vision {
data.supports_vision = filler.supports_vision; data.supports_vision = filler.supports_vision;
} }
@@ -570,6 +580,8 @@ fn overlay_hand_owned(hand: ModelData, api: ModelData) -> ModelData {
max_input_tokens: hand.max_input_tokens.or(api.max_input_tokens), max_input_tokens: hand.max_input_tokens.or(api.max_input_tokens),
input_price: hand.input_price.or(api.input_price), input_price: hand.input_price.or(api.input_price),
output_price: hand.output_price.or(api.output_price), output_price: hand.output_price.or(api.output_price),
cache_read_price: hand.cache_read_price.or(api.cache_read_price),
cache_write_price: hand.cache_write_price.or(api.cache_write_price),
patch: hand.patch.or(api.patch), patch: hand.patch.or(api.patch),
max_output_tokens: hand.max_output_tokens.or(api.max_output_tokens), max_output_tokens: hand.max_output_tokens.or(api.max_output_tokens),
require_max_tokens: hand.require_max_tokens || api.require_max_tokens, require_max_tokens: hand.require_max_tokens || api.require_max_tokens,
@@ -1006,6 +1018,8 @@ rules:
assert_eq!(gpt56.data.max_output_tokens, Some(128_000)); assert_eq!(gpt56.data.max_output_tokens, Some(128_000));
assert_eq!(gpt56.data.input_price, Some(4.0)); assert_eq!(gpt56.data.input_price, Some(4.0));
assert_eq!(gpt56.data.output_price, Some(20.0)); assert_eq!(gpt56.data.output_price, Some(20.0));
assert_eq!(gpt56.data.cache_read_price, Some(0.4));
assert_eq!(gpt56.data.cache_write_price, Some(5.0));
assert!(gpt56.data.supports_vision); assert!(gpt56.data.supports_vision);
assert!(gpt56.data.supports_function_calling); assert!(gpt56.data.supports_function_calling);
assert_eq!( assert_eq!(
@@ -1022,6 +1036,8 @@ rules:
assert_eq!(qwen.data.max_output_tokens, Some(8_192)); assert_eq!(qwen.data.max_output_tokens, Some(8_192));
assert_eq!(qwen.data.input_price, Some(1.6)); assert_eq!(qwen.data.input_price, Some(1.6));
assert_eq!(qwen.data.output_price, Some(6.4)); assert_eq!(qwen.data.output_price, Some(6.4));
assert_eq!(qwen.data.cache_read_price, None);
assert_eq!(qwen.data.cache_write_price, None);
assert!(!qwen.data.supports_vision); assert!(!qwen.data.supports_vision);
assert!(qwen.data.supports_function_calling); assert!(qwen.data.supports_function_calling);
assert!(qwen.data.reasoning_levels.is_empty()); assert!(qwen.data.reasoning_levels.is_empty());
@@ -1040,9 +1056,20 @@ rules:
assert_eq!(data.max_output_tokens, Some(32_768)); assert_eq!(data.max_output_tokens, Some(32_768));
assert_eq!(data.input_price, Some(2.0)); assert_eq!(data.input_price, Some(2.0));
assert_eq!(data.output_price, Some(8.0)); assert_eq!(data.output_price, Some(8.0));
assert_eq!(data.cache_read_price, Some(0.5));
assert_eq!(data.cache_write_price, None);
assert!(data.supports_vision); assert!(data.supports_vision);
assert!(data.supports_function_calling); assert!(data.supports_function_calling);
let qwen = openrouter_model_data("qwen3.8-max", index["qwen/qwen3.8-max"]);
assert_eq!(qwen.cache_read_price, Some(0.25));
assert_eq!(qwen.cache_write_price, Some(2.5));
// input_cache_write_1h must not leak into cache_write_price
let sonnet =
openrouter_model_data("claude-sonnet-4.6", index["anthropic/claude-sonnet-4.6"]);
assert_eq!(sonnet.cache_write_price, Some(3.75));
let rounded = openrouter_model_data( let rounded = openrouter_model_data(
"m", "m",
&json!({"pricing": {"prompt": "0.00000123456789", "completion": "0.0000025"}}), &json!({"pricing": {"prompt": "0.00000123456789", "completion": "0.0000025"}}),
@@ -1056,7 +1083,7 @@ rules:
let api = json!({ let api = json!({
"p": {"models": {"m": { "p": {"models": {"m": {
"limit": {"context": 100_000_000, "output": 0}, "limit": {"context": 100_000_000, "output": 0},
"cost": {"input": -1.0, "output": 20_000.0}, "cost": {"input": -1.0, "output": 20_000.0, "cache_read": -0.5, "cache_write": 20_000.0},
}}} }}}
}); });
let models = modelsdev_provider_models(&api, "p"); let models = modelsdev_provider_models(&api, "p");
@@ -1067,19 +1094,23 @@ rules:
assert_eq!(data.max_output_tokens, None); assert_eq!(data.max_output_tokens, None);
assert_eq!(data.input_price, None); assert_eq!(data.input_price, None);
assert_eq!(data.output_price, None); assert_eq!(data.output_price, None);
assert_eq!(data.cache_read_price, None);
assert_eq!(data.cache_write_price, None);
let data = openrouter_model_data( let data = openrouter_model_data(
"m", "m",
&json!({ &json!({
"context_length": 60_000_000, "context_length": 60_000_000,
"top_provider": {"max_completion_tokens": 20_000_000}, "top_provider": {"max_completion_tokens": 20_000_000},
"pricing": {"prompt": "-0.000001", "completion": "999"}, "pricing": {"prompt": "-0.000001", "completion": "999", "input_cache_read": "-0.000001", "input_cache_write": "999"},
}), }),
); );
assert_eq!(data.max_input_tokens, None); assert_eq!(data.max_input_tokens, None);
assert_eq!(data.max_output_tokens, None); assert_eq!(data.max_output_tokens, None);
assert_eq!(data.input_price, None); assert_eq!(data.input_price, None);
assert_eq!(data.output_price, None); assert_eq!(data.output_price, None);
assert_eq!(data.cache_read_price, None);
assert_eq!(data.cache_write_price, None);
} }
#[test] #[test]
@@ -1135,7 +1166,7 @@ rules:
fn merge_precedence_hand_owned_beats_rule_beats_modelsdev_beats_openrouter() { fn merge_precedence_hand_owned_beats_rule_beats_modelsdev_beats_openrouter() {
let modelsdev = json!({ let modelsdev = json!({
"xai": {"models": { "xai": {"models": {
"m-1": {"limit": {"context": 1000}, "cost": {"input": 1.0}}, "m-1": {"limit": {"context": 1000}, "cost": {"input": 1.0, "cache_read": 0.2}},
"m-2": {"limit": {"context": 1000}}, "m-2": {"limit": {"context": 1000}},
}} }}
}) })
@@ -1144,7 +1175,7 @@ rules:
{ {
"id": "x-ai/m-1", "id": "x-ai/m-1",
"context_length": 2000, "context_length": 2000,
"pricing": {"prompt": "0.000003", "completion": "0.000004"}, "pricing": {"prompt": "0.000003", "completion": "0.000004", "input_cache_read": "0.0000001", "input_cache_write": "0.000001"},
"top_provider": {"max_completion_tokens": 512}, "top_provider": {"max_completion_tokens": 512},
}, },
{"id": "x-ai/m-2", "context_length": 2000}, {"id": "x-ai/m-2", "context_length": 2000},
@@ -1160,6 +1191,7 @@ rules:
models: models:
- name: m-1 - name: m-1
input_price: 7 input_price: 7
cache_read_price: 0.7
"#, "#,
); );
let build = build_catalog(&make_sources(Some(&modelsdev), Some(&openrouter)), &quirks); let build = build_catalog(&make_sources(Some(&modelsdev), Some(&openrouter)), &quirks);
@@ -1168,11 +1200,13 @@ rules:
let merged = find_model(xai, "m-1"); let merged = find_model(xai, "m-1");
// hand-owned beats the rule, which beat models.dev, which beat openrouter // hand-owned beats the rule, which beat models.dev, which beat openrouter
assert_eq!(merged.input_price, Some(7.0)); assert_eq!(merged.input_price, Some(7.0));
assert_eq!(merged.cache_read_price, Some(0.7));
// rule beats models.dev // rule beats models.dev
assert_eq!(merged.max_input_tokens, Some(3000)); assert_eq!(merged.max_input_tokens, Some(3000));
// openrouter fills fields models.dev left unset // openrouter fills fields models.dev left unset
assert_eq!(merged.output_price, Some(4.0)); assert_eq!(merged.output_price, Some(4.0));
assert_eq!(merged.max_output_tokens, Some(512)); assert_eq!(merged.max_output_tokens, Some(512));
assert_eq!(merged.cache_write_price, Some(1.0));
// models.dev beats openrouter on the same field // models.dev beats openrouter on the same field
assert_eq!(find_model(xai, "m-2").max_input_tokens, Some(1000)); assert_eq!(find_model(xai, "m-2").max_input_tokens, Some(1000));
@@ -1292,6 +1326,21 @@ rules:
assert_eq!(serde_yaml::to_string(&parsed).unwrap(), yaml); assert_eq!(serde_yaml::to_string(&parsed).unwrap(), yaml);
} }
#[test]
fn cache_prices_omitted_when_absent_round_trip_when_set() {
let yaml = serde_yaml::to_string(&ModelData::new("m")).unwrap();
assert!(!yaml.contains("cache_read_price"));
assert!(!yaml.contains("cache_write_price"));
let mut data = ModelData::new("m");
data.cache_read_price = Some(0.25);
data.cache_write_price = Some(2.5);
let parsed: ModelData =
serde_yaml::from_str(&serde_yaml::to_string(&data).unwrap()).unwrap();
assert_eq!(parsed.cache_read_price, Some(0.25));
assert_eq!(parsed.cache_write_price, Some(2.5));
}
#[test] #[test]
fn openrouter_native_models_use_full_ids() { fn openrouter_native_models_use_full_ids() {
let build = build_catalog(&fixture_sources(), &[]); let build = build_catalog(&fixture_sources(), &[]);
+4
View File
@@ -327,6 +327,10 @@ pub struct ModelData {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub output_price: Option<f64>, pub output_price: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub cache_read_price: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_write_price: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub patch: Option<Value>, pub patch: Option<Value>,
// chat-only properties // chat-only properties