diff --git a/src/client/catalog.rs b/src/client/catalog.rs index 030b848..48b5eb1 100644 --- a/src/client/catalog.rs +++ b/src/client/catalog.rs @@ -201,6 +201,8 @@ fn modelsdev_provider_models(api: &Value, provider_id: &str) -> Vec ModelData { .and_then(clamp_output_tokens); data.input_price = openrouter_price(&entry["pricing"]["prompt"]); 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"] .as_str() .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() { 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 { 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), input_price: hand.input_price.or(api.input_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), max_output_tokens: hand.max_output_tokens.or(api.max_output_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.input_price, Some(4.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_function_calling); assert_eq!( @@ -1022,6 +1036,8 @@ rules: assert_eq!(qwen.data.max_output_tokens, Some(8_192)); assert_eq!(qwen.data.input_price, Some(1.6)); 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_function_calling); assert!(qwen.data.reasoning_levels.is_empty()); @@ -1040,9 +1056,20 @@ rules: assert_eq!(data.max_output_tokens, Some(32_768)); assert_eq!(data.input_price, Some(2.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_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( "m", &json!({"pricing": {"prompt": "0.00000123456789", "completion": "0.0000025"}}), @@ -1056,7 +1083,7 @@ rules: let api = json!({ "p": {"models": {"m": { "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"); @@ -1067,19 +1094,23 @@ rules: assert_eq!(data.max_output_tokens, None); assert_eq!(data.input_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( "m", &json!({ "context_length": 60_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_output_tokens, None); assert_eq!(data.input_price, None); assert_eq!(data.output_price, None); + assert_eq!(data.cache_read_price, None); + assert_eq!(data.cache_write_price, None); } #[test] @@ -1135,7 +1166,7 @@ rules: fn merge_precedence_hand_owned_beats_rule_beats_modelsdev_beats_openrouter() { let modelsdev = json!({ "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}}, }} }) @@ -1144,7 +1175,7 @@ rules: { "id": "x-ai/m-1", "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}, }, {"id": "x-ai/m-2", "context_length": 2000}, @@ -1160,6 +1191,7 @@ rules: models: - name: m-1 input_price: 7 + cache_read_price: 0.7 "#, ); let build = build_catalog(&make_sources(Some(&modelsdev), Some(&openrouter)), &quirks); @@ -1168,11 +1200,13 @@ rules: let merged = find_model(xai, "m-1"); // hand-owned beats the rule, which beat models.dev, which beat openrouter assert_eq!(merged.input_price, Some(7.0)); + assert_eq!(merged.cache_read_price, Some(0.7)); // rule beats models.dev assert_eq!(merged.max_input_tokens, Some(3000)); // openrouter fills fields models.dev left unset assert_eq!(merged.output_price, Some(4.0)); 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 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); } + #[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] fn openrouter_native_models_use_full_ids() { let build = build_catalog(&fixture_sources(), &[]); diff --git a/src/client/model.rs b/src/client/model.rs index 6d9408e..a51f1c0 100644 --- a/src/client/model.rs +++ b/src/client/model.rs @@ -327,6 +327,10 @@ pub struct ModelData { #[serde(skip_serializing_if = "Option::is_none")] pub output_price: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub cache_read_price: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_write_price: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub patch: Option, // chat-only properties