Skip to content

g1t/services/billing/src/catalogue_tests.rs

333 lines16,060 bytesCodeBlame
1//! The model catalogue: discovery's diff, prices, typical runs, defaults
2//! and their fallbacks (`catalogue.rs`).
3
4use super::*;
5
6fn model(id: &str, name: &str, tier: &str, status: &str) -> CatalogueModel {
7 let prices = known_prices(id).unwrap_or(ModelPrices { input_micros: 1_000_000, output_micros: 5_000_000, ..ModelPrices::default() });
8 let base = GatewayModel {
9 model: id.into(),
10 name: name.into(),
11 provider: "anthropic".into(),
12 kind: "chat".into(),
13 input_micros: 0,
14 output_micros: 0,
15 cache_read_micros: 0,
16 cache_write_micros: 0,
17 cache_write_1h_micros: 0,
18 threshold: 0,
19 over_input_micros: 0,
20 over_output_micros: 0,
21 over_cache_read_micros: 0,
22 over_cache_write_micros: 0,
23 over_cache_write_1h_micros: 0,
24 };
25 CatalogueModel {
26 prices: with_prices(base, &prices),
27 aliases: vec![],
28 family: String::new(),
29 tier_hint: tier.into(),
30 context_window: 0,
31 max_output: 0,
32 capabilities: vec!["effort".into()],
33 dimensions: 0,
34 status: status.into(),
35 priced: true,
36 source: "staff".into(),
37 first_seen_at: None,
38 last_seen_at: None,
39 missing_since: None,
40 approved_by: None,
41 approved_at: None,
42 note: String::new(),
43 typical_run_micros: 0,
44 }
45}
46
47fn listed(id: &str) -> ProviderModel {
48 ProviderModel { id: id.into(), name: String::new(), kind: "chat".into(), ..ProviderModel::default() }
49}
50
51fn catalogue() -> Vec<CatalogueModel> {
52 let mut haiku_4_5 = model("claude-haiku-4-5", "Claude Haiku 4.5", "small", "available");
53 haiku_4_5.aliases = vec!["claude-haiku-4-5-20251001".into()];
54 vec![
55 model("claude-haiku-5-5", "Claude Haiku 5.5", "small", "available"),
56 haiku_4_5,
57 model("claude-haiku-4-5-20251001", "Claude Haiku 4.5", "small", "available"),
58 model("claude-sonnet-5-5", "Claude Sonnet 5.5", "large", "available"),
59 model("claude-opus-5-5", "Claude Opus 5.5", "frontier", "available"),
60 ]
61}
62
63#[test]
64fn a_model_never_seen_is_added_and_one_no_longer_listed_is_gone() {
65 let found = diff(
66 "anthropic",
67 &catalogue(),
68 &[listed("claude-haiku-6"), listed("claude-haiku-5-5"), listed("claude-haiku-4-5-20251001"), listed("claude-sonnet-5-5")],
69 );
70 assert_eq!(found.added.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(), ["claude-haiku-6"]);
71 // Opus 5.5 was not listed: gone. Haiku 4.5 is listed by its dated id, which it has as an alias.
72 assert_eq!(found.gone, ["claude-opus-5-5"]);
73 let seen: Vec<&str> = found.seen.iter().map(|(m, _)| m.as_str()).collect();
74 assert_eq!(seen, ["claude-haiku-5-5", "claude-haiku-4-5", "claude-haiku-4-5-20251001", "claude-sonnet-5-5"]);
75 assert!(found.restored.is_empty());
76}
77
78#[test]
79fn a_dated_id_of_a_model_it_has_is_that_model_and_becomes_an_alias() {
80 let found = diff("anthropic", &catalogue(), &[listed("claude-sonnet-5-5-20261001")]);
81 assert!(found.added.is_empty());
82 assert_eq!(found.seen, [("claude-sonnet-5-5".to_owned(), Some("claude-sonnet-5-5-20261001".to_owned()))]);
83 assert!(is_dated("claude-sonnet-5-5-20261001", "claude-sonnet-5-5"));
84 assert!(!is_dated("claude-sonnet-5-5-preview", "claude-sonnet-5-5"));
85 assert!(!is_dated("claude-sonnet-5", "claude-sonnet-5-5"));
86}
87
88#[test]
89fn an_empty_or_failed_list_finds_nothing_gone() {
90 let found = diff("anthropic", &catalogue(), &[]);
91 assert_eq!(found, Diff::default());
92 // Another provider's models are never its to judge.
93 let found = diff("workers-ai", &catalogue(), &[listed("@cf/meta/llama-5")]);
94 assert!(found.gone.is_empty());
95 assert_eq!(found.added.len(), 1);
96}
97
98#[test]
99fn a_deprecated_model_listed_again_is_restored_and_a_retired_one_is_left_alone() {
100 let mut models = catalogue();
101 models[3].status = "deprecated".into();
102 models[3].missing_since = Some("2026-10-01T04:17:00Z".into());
103 models[4].status = "retired".into();
104 let found = diff("anthropic", &models, &[listed("claude-sonnet-5-5"), listed("claude-haiku-5-5")]);
105 assert_eq!(found.restored, ["claude-sonnet-5-5"]);
106 // Retired Opus is not listed and stays retired; Haiku 4.5 is gone.
107 assert_eq!(found.gone, ["claude-haiku-4-5", "claude-haiku-4-5-20251001"]);
108}
109
110#[test]
111fn only_chat_and_embeddings_models_are_added() {
112 let mut speech = listed("@cf/openai/whisper");
113 speech.kind = "other".into();
114 let mut embed = listed("@cf/baai/bge-large-en-v1.5");
115 embed.kind = "embeddings".into();
116 let found = diff("workers-ai", &[], &[speech, embed, listed("@cf/meta/llama-5")]);
117 assert_eq!(found.added.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(), ["@cf/baai/bge-large-en-v1.5", "@cf/meta/llama-5"]);
118}
119
120#[test]
121fn a_new_model_is_priced_from_the_table_or_its_listing_or_not_at_all() {
122 // A dated Opus 5.5 is in the table.
123 let (row, facts) = new_row("anthropic", &listed("claude-opus-5-5-20261101"), 120);
124 assert!(facts.priced);
125 assert_eq!((row.input_micros, row.output_micros, row.cache_read_micros), (4_000_000, 20_000_000, 200_000));
126 assert_eq!((facts.family.as_str(), facts.tier_hint.as_str(), facts.position), ("opus", "frontier", 120));
127 // A Haiku g1t has no price for: unpriced, a fast model by its name.
128 let (row, facts) = new_row("anthropic", &listed("claude-haiku-6"), 0);
129 assert!(!facts.priced);
130 assert_eq!((row.input_micros, facts.tier_hint.as_str()), (0, "small"));
131 // Workers AI says its own price; cached tokens cost what input does.
132 let mut open = listed("@cf/meta/llama-5-70b");
133 open.price = Some(g1t_contracts::billing::ListedPrice { input_micros: 290_000, output_micros: 2_250_000 });
134 let (row, facts) = new_row("workers-ai", &open, 0);
135 assert!(facts.priced);
136 assert_eq!((row.input_micros, row.output_micros, row.cache_read_micros, row.name.as_str()), (290_000, 2_250_000, 290_000, "llama-5-70b"));
137 assert_eq!((facts.family.as_str(), facts.tier_hint.as_str()), ("meta", ""));
138}
139
140#[test]
141fn haiku_5_5_is_known_with_its_long_prompt_prices() {
142 let prices = known_prices("claude-haiku-5-5").unwrap();
143 assert_eq!((prices.input_micros, prices.output_micros, prices.cache_read_micros), (100_000, 500_000, 10_000));
144 assert_eq!((prices.threshold, prices.over_input_micros, prices.over_output_micros), (100_000, 500_000, 2_500_000));
145 assert!(known_prices("claude-haiku-6").is_none());
146 // `claude-opus-5` is not `claude-opus-5-5`.
147 assert_eq!(known_prices("claude-opus-5").unwrap().input_micros, 5_000_000);
148}
149
150#[test]
151fn a_typical_run_is_priced_request_by_request() {
152 let models = catalogue();
153 let haiku = &models[0].prices;
154 let sonnet = &models[3].prices;
155 let opus = &models[4].prices;
156 // 40 requests of 2k in, 1.5k out, 45k cache reads and 4k cache writes.
157 assert_eq!(typical_run(sonnet), 1_340_000);
158 assert_eq!(typical_run(opus), 2_680_000);
159 // Each prompt is 51k, under Haiku 5.5's 100k threshold: its lower prices.
160 assert_eq!(typical_run(haiku), 76_000);
161 let mut embeddings = haiku.clone();
162 embeddings.kind = "embeddings".into();
163 assert_eq!(typical_run(&embeddings), 0);
164}
165
166#[test]
167fn a_default_that_can_be_used_is_used() {
168 let resolved = resolve("tier_small", "claude-haiku-5-5", &catalogue());
169 assert_eq!(resolved.model.unwrap().model, "claude-haiku-5-5");
170 assert!(resolved.note.is_none());
171}
172
173#[test]
174fn a_retired_default_falls_back_to_the_next_model_of_its_tier_and_says_so() {
175 let mut models = catalogue();
176 models[0].status = "retired".into();
177 let resolved = resolve("tier_small", "claude-haiku-5-5", &models);
178 assert_eq!(resolved.model.unwrap().model, "claude-haiku-4-5");
179 assert_eq!(resolved.note.as_deref(), Some("Claude Haiku 5.5 is retired; using Claude Haiku 4.5 instead."));
180 // Deprecated and unpriced models are never handed out either.
181 models[1].status = "deprecated".into();
182 models[2].priced = false;
183 let resolved = resolve("background", "claude-haiku-5-5", &models);
184 assert!(resolved.model.is_none());
185 assert_eq!(resolved.note.as_deref(), Some("Claude Haiku 5.5 is retired, and no other model suits it."));
186 // The gateway's first model takes any Claude.
187 let resolved = resolve("gateway_first", "claude-haiku-5-5", &models);
188 assert_eq!(resolved.model.unwrap().model, "claude-sonnet-5-5");
189}
190
191#[test]
192fn a_new_model_is_never_a_default_until_approved() {
193 let mut models = catalogue();
194 models.push(model("claude-haiku-6", "Claude Haiku 6", "small", "new"));
195 let resolved = resolve("tier_small", "claude-haiku-6", &models);
196 assert_eq!(resolved.model.unwrap().model, "claude-haiku-5-5");
197 assert_eq!(resolved.note.as_deref(), Some("Claude Haiku 6 is new; using Claude Haiku 5.5 instead."));
198 let set = AdminSetModelDefaultArgs { purpose: "tier_small".into(), model: Some("claude-haiku-6".into()), reason: "cheaper".into(), ..Default::default() };
199 assert_eq!(check_default(&set, &models).unwrap_err(), "Claude Haiku 6 is new: approve it first.");
200}
201
202#[test]
203fn defaults_are_checked_before_they_are_kept() {
204 let models = catalogue();
205 let mut open = model("@cf/openai/gpt-oss-120b", "gpt-oss-120b", "", "available");
206 open.prices.provider = "workers-ai".into();
207 let models = [models, vec![open]].concat();
208 let args = |purpose: &str, model: Option<&str>, tier: Option<&str>, effort: Option<&str>, reason: &str| AdminSetModelDefaultArgs {
209 purpose: purpose.into(),
210 model: model.map(Into::into),
211 tier: tier.map(Into::into),
212 effort: effort.map(Into::into),
213 reason: reason.into(),
214 by: "staff@g1t.sh".into(),
215 };
216 assert_eq!(check_default(&args("tier_large", Some("claude-opus-5-5"), None, None, "try it"), &models), Ok((Some("claude-opus-5-5".into()), None, None)));
217 assert_eq!(check_default(&args("tier_large", Some("claude-opus-5-5"), None, None, " "), &models).unwrap_err(), "Say why, for whoever looks next.");
218 assert!(check_default(&args("tier_large", Some("@cf/openai/gpt-oss-120b"), None, None, "x"), &models).unwrap_err().contains("not a Claude chat model"));
219 assert_eq!(check_default(&args("tier_large", Some("claude-nope"), None, None, "x"), &models).unwrap_err(), "claude-nope is not in the catalogue.");
220 assert_eq!(
221 check_default(&args("job_plan", None, Some("small"), Some("xhigh"), "x"), &models),
222 Ok((None, Some("small".into()), Some("xhigh".into())))
223 );
224 assert_eq!(check_default(&args("job_update", None, Some("small"), Some(""), "x"), &models), Ok((None, Some("small".into()), None)));
225 assert!(check_default(&args("job_plan", None, Some("change"), None, "x"), &models).is_err());
226 assert!(check_default(&args("job_review", None, Some("change"), None, "x"), &models).is_ok());
227 assert!(check_default(&args("job_plan", None, Some("small"), Some("huge"), "x"), &models).is_err());
228 assert!(check_default(&args("job_dance", None, Some("small"), None, "x"), &models).is_err());
229}
230
231#[test]
232fn the_runtime_view_has_each_purpose_and_job() {
233 let row = |purpose: &str, model: Option<&str>, tier: Option<&str>, effort: Option<&str>| ModelDefault {
234 purpose: purpose.into(),
235 model: model.map(Into::into),
236 tier: tier.map(Into::into),
237 effort: effort.map(Into::into),
238 updated_at: "2026-10-08T00:00:00Z".into(),
239 updated_by: "migration".into(),
240 reason: String::new(),
241 };
242 let defaults = vec![
243 row("tier_small", Some("claude-haiku-5-5"), None, None),
244 row("gateway_first", Some("claude-haiku-5-5"), None, None),
245 row("job_plan", None, Some("small"), Some("high")),
246 row("job_review", None, Some("change"), None),
247 row("job_answer", None, Some("nonsense"), None),
248 ];
249 let view = defaults_view(&defaults, &catalogue());
250 assert_eq!(view.models.iter().map(|m| m.purpose.as_str()).collect::<Vec<_>>(), ["tier_small", "gateway_first"]);
251 assert_eq!(
252 view.jobs,
253 [
254 JobDefault { kind: "review".into(), tier: "change".into(), effort: None },
255 JobDefault { kind: "plan".into(), tier: "small".into(), effort: Some("high".into()) },
256 ]
257 );
258}
259
260#[test]
261fn the_gateway_lists_staffs_first_claude_first() {
262 let mut models: Vec<GatewayModel> = catalogue().into_iter().map(|m| m.prices).collect();
263 put_first(&mut models, Some("claude-sonnet-5-5"));
264 assert_eq!(models[0].model, "claude-sonnet-5-5");
265 assert_eq!(models[1].model, "claude-haiku-5-5");
266 put_first(&mut models, Some("claude-nope"));
267 assert_eq!(models[0].model, "claude-sonnet-5-5");
268}
269
270#[test]
271fn prices_staff_confirm_must_make_sense() {
272 let good = known_prices("claude-haiku-5-5").unwrap();
273 assert!(check_prices("chat", &good).is_ok());
274 assert!(check_prices("chat", &ModelPrices { output_micros: 0, ..good.clone() }).is_err());
275 assert!(check_prices("chat", &ModelPrices { input_micros: -1, ..good.clone() }).is_err());
276 assert!(check_prices("chat", &ModelPrices { threshold: 0, ..good.clone() }).is_err());
277 assert!(check_prices("chat", &ModelPrices { input_micros: 2_000_000_000, ..good.clone() }).is_err());
278 assert!(check_prices("embeddings", &ModelPrices { input_micros: 12_000, ..ModelPrices::default() }).is_ok());
279}
280
281#[test]
282fn staff_are_told_what_a_check_found() {
283 let result = DiscoveryResult {
284 provider: "anthropic".into(),
285 added: vec!["claude-haiku-6".into()],
286 deprecated: vec!["claude-haiku-4-5".into()],
287 ..DiscoveryResult::default()
288 };
289 let (subject, lines) = discovery_email(&result);
290 assert_eq!(subject, "g1t: new Anthropic models: claude-haiku-6");
291 assert!(lines[0].contains("until you approve it with its prices in sudo"));
292 assert!(lines[1].contains("no longer lists claude-haiku-4-5"));
293 assert_eq!(discovery_detail(&result), "anthropic: new: claude-haiku-6; no longer listed: claude-haiku-4-5");
294}
295
296#[test]
297fn dollars_read_plainly() {
298 assert_eq!(dollars(100_000), "0.10");
299 assert_eq!(dollars(2_000_000), "2");
300 assert_eq!(dollars(12_500_000), "12.50");
301 assert_eq!(dollars(10_000), "0.01");
302 assert_eq!(dollars(125_000), "0.125");
303}
304
305#[test]
306fn the_migration_seeds_every_purpose_as_routing_had_it() {
307 let sql = include_str!("../migrations/0048_model_catalogue.sql");
308 for purpose in MODEL_PURPOSES {
309 assert!(sql.contains(&format!("('{purpose}', 'claude-")), "no seed for {purpose}");
310 }
311 for kind in JOB_KINDS {
312 assert!(sql.contains(&format!("('job_{kind}', NULL, '")), "no seed for job_{kind}");
313 }
314 // As AGENT_ROUTING (services/runner/wrangler.jsonc) had them.
315 let runner = include_str!("../../runner/wrangler.jsonc");
316 for (purpose, model) in [("tier_small", "claude-haiku-5-5"), ("tier_large", "claude-sonnet-5-5"), ("tier_frontier", "claude-opus-5-5")] {
317 assert!(sql.contains(&format!("('{purpose}', '{model}'")));
318 assert!(runner.contains(&format!("\\\"model\\\":\\\"{model}\\\"")), "{model} is not in AGENT_ROUTING");
319 }
320 assert!(sql.contains("('job_plan', NULL, 'small', 'high'"));
321 assert!(sql.contains("('job_review', NULL, 'change', NULL"));
322 // Every column the discovery insert names exists.
323 let insert = "model, name, provider, kind, input_micros, output_micros, cache_read_micros, cache_write_micros, cache_write_1h_micros, threshold, over_input_micros, over_output_micros, over_cache_read_micros, over_cache_write_micros, over_cache_write_1h_micros, position, updated_at, family, tier_hint, context_window, max_output, capabilities, status, priced, source, first_seen_at, last_seen_at";
324 let all = [
325 include_str!("../migrations/0045_gateway.sql"),
326 include_str!("../migrations/0047_gateway_formats.sql"),
327 sql,
328 ]
329 .concat();
330 for column in insert.split(", ") {
331 assert!(all.contains(&format!("{column} ")) || all.contains(&format!("{column},")), "gateway_models has no {column}");
332 }
333}