Skip to content

g1t/services/billing/src/catalogue.rs

1,051 lines48,994 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Models: g1t keeps up with new models, and staff choose each default in sudo1//! The model catalogue: every model g1t can use, where each stands, and
2//! which one each purpose uses by default.
3//!
4//! - **One table.** `gateway_models` (migrations 0045, 0047, 0048) holds
5//! every model: the agents' tiers, the AI Gateway's models and the
6//! embeddings model, with prices per million tokens by kind (long-prompt
7//! and cache prices included), and its aliases, family, tier hint,
8//! context window, capabilities, status and where it came from. Billing
9//! owns it because billing owns prices: the gateway charges from it, and
10//! a second table of models would drift from the first.
11//! - **Discovery.** The models service lists each provider daily (and when
12//! staff press "Check for new models" in sudo): Anthropic's
13//! `GET /v1/models` through g1t's AI Gateway, and Workers AI's model
14//! search. Listing is free; nothing here calls a paid model.
15//! `record_discovery` compares the list with the catalogue (`diff`):
16//! an id it has never seen is added as `new` (priced from `known` or the
17//! listing's own price when either knows it, otherwise unpriced); a dated
18//! id of a model it has (`claude-haiku-4-5-20251001`) is that model; a
19//! model the provider stopped listing becomes `deprecated`, and comes
20//! back when it is listed again. Staff are emailed about anything new or
21//! gone, with a link to sudo. A `new` model is never routed to, offered
22//! or charged for until staff approve it with its prices.
23//! - **Defaults.** `model_defaults` holds staff's choice per purpose: the
24//! model behind each agent tier, the harness's background model, the AI
25//! Gateway's first Claude, and each job's starting tier and effort. Every
26//! change is audited with the old value, the new one and why.
27//! `resolve` never hands out a model that cannot be used: a chosen model
28//! that is deprecated, retired or unpriced falls back to the next
29//! available model suited to the purpose, with a sentence saying so.
30
31use g1t_contracts::billing::{
32 model_status, AdminDecideModelArgs, AdminModels, AdminSetModelDefaultArgs, CatalogueModel, DiscoveryResult, GatewayModel,
33 JobDefault, ModelCheck, ModelDefault, ModelDefaults, ModelPrices, ProviderModel, RecordDiscoveryArgs, ResolvedModel,
34 TypicalRun,
35};
36use g1t_contracts::time::rfc3339;
37use g1t_contracts::{FailureCode, Outcome, new_id};
38use g1t_kit::now_ms;
39use serde::Deserialize;
40use worker::Result;
41use worker::wasm_bindgen::JsValue;
42
43use crate::gateway::{ModelRow, Used, cost_micros};
44use crate::Billing;
45
46/// The tokens of a typical agent run, which sudo prices each model at: 40
47/// requests, each reading most of its context from the prompt cache, as a
48/// Sonnet implement run does (about 90% of its tokens cache reads). An
49/// estimate for comparing models, never a charge.
50pub(crate) const TYPICAL: TypicalRun = TypicalRun { requests: 40, input: 2_000, output: 1_500, cache_read: 45_000, cache_write: 4_000 };
51
52/// What `TYPICAL` costs on a model, in millionths of a dollar: 0 for an
53/// embeddings model. Each request is priced on its own, so a model priced
54/// by prompt length is at its lower prices unless one request's prompt is
55/// over the threshold.
56pub(crate) fn typical_run(model: &GatewayModel) -> i64 {
57 if model.kind != "chat" {
58 return 0;
59 }
60 let used = Used { input: TYPICAL.input, output: TYPICAL.output, cache_read: TYPICAL.cache_read, cache_write: TYPICAL.cache_write, cache_write_1h: 0 };
61 cost_micros(model, &used).saturating_mul(TYPICAL.requests as i64)
62}
63
64/// The purposes a default model is chosen for, in the order sudo shows them.
65pub(crate) const MODEL_PURPOSES: [&str; 5] = ["tier_small", "tier_large", "tier_frontier", "background", "gateway_first"];
66/// The kinds of agent job, each with a starting tier and effort (`job_<kind>`).
67pub(crate) const JOB_KINDS: [&str; 6] = ["implement", "revise", "answer", "review", "update", "plan"];
68pub(crate) const TIERS: [&str; 3] = ["small", "large", "frontier"];
69pub(crate) const EFFORTS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
70
71/// What sudo and the audit log call a purpose.
72pub(crate) fn purpose_label(purpose: &str) -> String {
73 match purpose {
74 "tier_small" => "Fast tier".to_owned(),
75 "tier_large" => "Standard tier".to_owned(),
76 "tier_frontier" => "Most capable tier".to_owned(),
77 "background" => "Background model".to_owned(),
78 "gateway_first" => "AI Gateway's first Claude".to_owned(),
79 other => match other.strip_prefix("job_") {
80 Some(kind) => format!("Job: {kind}"),
81 None => other.to_owned(),
82 },
83 }
84}
85
86/// The tier a model purpose is for, if it is one of the agent tiers or
87/// the background model (which is a fast model's job).
88fn tier_of_purpose(purpose: &str) -> Option<&'static str> {
89 match purpose {
90 "tier_small" | "background" => Some("small"),
91 "tier_large" => Some("large"),
92 "tier_frontier" => Some("frontier"),
93 _ => None,
94 }
95}
96
97// ---------------------------------------------------------------------------
98// What g1t knows about models before anyone tells it.
99// ---------------------------------------------------------------------------
100
101/// Prices per million tokens in millionths of a dollar: input, output,
102/// cache read, five-minute and hour-long cache write.
103type Five = [i64; 5];
104
105const fn five(input: i64, output: i64, read: i64, write: i64, write_1h: i64) -> Five {
106 [input, output, read, write, write_1h]
107}
108
109/// A model id, its prices, and its long-prompt threshold and prices above it.
110type Listed = (&'static str, Five, Option<(u64, Five)>);
111
112/// Anthropic's list prices by model, as published (checked 2026-10-08).
113/// The maintained price table: when Anthropic lists a model here, it is
114/// added priced and only needs staff to confirm it. Add a row when
115/// Anthropic publishes a new model's price; until then a new model is
116/// added unpriced and staff enter its prices when they approve it.
117const ANTHROPIC_PRICES: &[Listed] = &[
118 ("claude-fable-5-1", five(10_000_000, 50_000_000, 250_000, 12_500_000, 20_000_000), None),
119 ("claude-fable-5", five(10_000_000, 50_000_000, 1_000_000, 12_500_000, 20_000_000), None),
120 ("claude-opus-5-5", five(4_000_000, 20_000_000, 200_000, 5_000_000, 8_000_000), None),
121 ("claude-opus-5", five(5_000_000, 25_000_000, 500_000, 6_250_000, 10_000_000), None),
122 ("claude-opus-4-8", five(5_000_000, 25_000_000, 500_000, 6_250_000, 10_000_000), None),
123 ("claude-opus-4-7", five(5_000_000, 25_000_000, 500_000, 6_250_000, 10_000_000), None),
124 ("claude-opus-4-6", five(5_000_000, 25_000_000, 500_000, 6_250_000, 10_000_000), None),
125 ("claude-sonnet-5-5", five(2_000_000, 10_000_000, 100_000, 2_500_000, 4_000_000), None),
126 ("claude-sonnet-5", five(2_000_000, 10_000_000, 200_000, 2_500_000, 4_000_000), None),
127 ("claude-sonnet-4-6", five(3_000_000, 15_000_000, 300_000, 3_750_000, 6_000_000), None),
128 (
129 "claude-haiku-5-5",
130 five(100_000, 500_000, 10_000, 125_000, 200_000),
131 Some((100_000, five(500_000, 2_500_000, 50_000, 625_000, 1_000_000))),
132 ),
133 ("claude-haiku-4-5", five(1_000_000, 5_000_000, 100_000, 1_250_000, 2_000_000), None),
134];
135
136/// Whether `listed` is `model` with a date after it (`-YYYYMMDD`), as
137/// Anthropic lists a model it also names without one.
138pub(crate) fn is_dated(listed: &str, model: &str) -> bool {
139 listed
140 .strip_prefix(model)
141 .and_then(|rest| rest.strip_prefix('-'))
142 .is_some_and(|date| date.len() == 8 && date.bytes().all(|b| b.is_ascii_digit()))
143}
144
145/// The list price of an Anthropic model (by its id, or its dated id), if
146/// the table has it.
147pub(crate) fn known_prices(id: &str) -> Option<ModelPrices> {
148 let (_, base, over) = ANTHROPIC_PRICES.iter().find(|(model, _, _)| id == *model || is_dated(id, model))?;
149 let (threshold, above) = over.unwrap_or((0, [0; 5]));
150 Some(ModelPrices {
151 input_micros: base[0],
152 output_micros: base[1],
153 cache_read_micros: base[2],
154 cache_write_micros: base[3],
155 cache_write_1h_micros: base[4],
156 threshold,
157 over_input_micros: above[0],
158 over_output_micros: above[1],
159 over_cache_read_micros: above[2],
160 over_cache_write_micros: above[3],
161 over_cache_write_1h_micros: above[4],
162 })
163}
164
165/// A model's family, and the agent tier it suits, from its id:
166/// `claude-haiku-*` is fast, `claude-sonnet-*` standard, `claude-opus-*`
167/// and `claude-fable-*` the most capable. A Workers AI model's family is
168/// its author (`@cf/<author>/…`) and it suits no tier.
169pub(crate) fn family_of(provider: &str, id: &str) -> (String, String) {
170 if provider == "anthropic" {
171 for (family, tier) in [("haiku", "small"), ("sonnet", "large"), ("opus", "frontier"), ("fable", "frontier"), ("mythos", "frontier")] {
172 if id.starts_with(&format!("claude-{family}")) {
173 return (family.to_owned(), tier.to_owned());
174 }
175 }
176 return (String::new(), String::new());
177 }
178 let author = id.trim_start_matches('@').split('/').nth(1).unwrap_or_default();
179 (author.to_owned(), String::new())
180}
181
182/// A name for people when the provider gives none: a Workers AI id's last
183/// part.
184fn name_of(listed: &ProviderModel) -> String {
185 let name = listed.name.trim();
186 if !name.is_empty() && !name.starts_with('@') {
187 return name.chars().take(120).collect();
188 }
189 listed.id.rsplit('/').next().unwrap_or(&listed.id).chars().take(120).collect()
190}
191
192// ---------------------------------------------------------------------------
193// Discovery: what a provider lists, against the catalogue.
194// ---------------------------------------------------------------------------
195
196/// What a check found, before it is written.
197#[derive(Debug, Default, PartialEq)]
198pub(crate) struct Diff {
199 /// Ids never seen, to add as `new` (chat and embeddings models only).
200 pub added: Vec<ProviderModel>,
201 /// Catalogue models listed, with an id to add to their aliases when the
202 /// provider listed them by one they did not have.
203 pub seen: Vec<(String, Option<String>)>,
204 /// Catalogue models (available or new) the provider no longer lists.
205 pub gone: Vec<String>,
206 /// Deprecated models listed again.
207 pub restored: Vec<String>,
208}
209
210/// Whether the provider listing `listed` lists catalogue model `row`, and
211/// by an id it did not know (to keep as an alias).
212fn lists(row: &CatalogueModel, listed: &str) -> Option<Option<String>> {
213 if listed == row.prices.model || row.aliases.iter().any(|alias| alias == listed) {
214 return Some(None);
215 }
216 is_dated(listed, &row.prices.model).then(|| Some(listed.to_owned()))
217}
218
219/// Compares what `provider` lists with the catalogue. An empty list says
220/// nothing (a failed or empty answer is not every model gone), so nothing
221/// is found gone then. Retired models are left as they are.
222pub(crate) fn diff(provider: &str, catalogue: &[CatalogueModel], listed: &[ProviderModel]) -> Diff {
223 let mine: Vec<&CatalogueModel> = catalogue.iter().filter(|row| row.prices.provider == provider).collect();
224 let mut out = Diff::default();
225 let mut matched: Vec<&str> = vec![];
226 for model in listed {
227 let id = model.id.trim();
228 if id.is_empty() {
229 continue;
230 }
231 let hits: Vec<(&CatalogueModel, Option<String>)> = mine.iter().filter_map(|row| lists(row, id).map(|alias| (*row, alias))).collect();
232 if hits.is_empty() {
233 let kind = model.kind.as_str();
234 if (kind == "chat" || kind == "embeddings") && !out.added.iter().any(|added| added.id == id) {
235 out.added.push(ProviderModel { id: id.to_owned(), ..model.clone() });
236 }
237 continue;
238 }
239 for (row, alias) in hits {
240 let name = row.prices.model.as_str();
241 if matched.contains(&name) {
242 continue;
243 }
244 matched.push(name);
245 if row.status == model_status::DEPRECATED && row.missing_since.is_some() {
246 out.restored.push(name.to_owned());
247 }
248 out.seen.push((name.to_owned(), alias));
249 }
250 }
251 if listed.iter().any(|model| !model.id.trim().is_empty()) {
252 for row in mine {
253 let gone = !matched.contains(&row.prices.model.as_str());
254 if gone && (row.status == model_status::AVAILABLE || row.status == model_status::NEW) {
255 out.gone.push(row.prices.model.clone());
256 }
257 }
258 }
259 out
260}
261
262/// The row a newly found model is added as: `new`, from discovery, priced
263/// from `known_prices` (Anthropic), else the listing's own price (Workers
264/// AI, which has no cache prices: cached tokens cost what input does),
265/// else unpriced.
266pub(crate) fn new_row(provider: &str, listed: &ProviderModel, position: i64) -> (GatewayModel, CatalogueFacts) {
267 let (family, tier) = family_of(provider, &listed.id);
268 let kind = if listed.kind == "embeddings" { "embeddings" } else { "chat" };
269 let prices = known_prices(&listed.id).or_else(|| {
270 listed.price.as_ref().filter(|price| price.input_micros > 0).map(|price| ModelPrices {
271 input_micros: price.input_micros,
272 output_micros: price.output_micros.max(0),
273 cache_read_micros: price.input_micros,
274 cache_write_micros: price.input_micros,
275 cache_write_1h_micros: price.input_micros,
276 ..ModelPrices::default()
277 })
278 });
279 let priced = prices.is_some();
280 let prices = prices.unwrap_or_default();
281 let mut capabilities = listed.capabilities.clone();
282 if kind == "embeddings" && !capabilities.iter().any(|c| c == "embeddings") {
283 capabilities.push("embeddings".to_owned());
284 }
285 (
286 with_prices(
287 GatewayModel {
288 model: listed.id.clone(),
289 name: name_of(listed),
290 provider: provider.to_owned(),
291 kind: kind.to_owned(),
292 input_micros: 0,
293 output_micros: 0,
294 cache_read_micros: 0,
295 cache_write_micros: 0,
296 cache_write_1h_micros: 0,
297 threshold: 0,
298 over_input_micros: 0,
299 over_output_micros: 0,
300 over_cache_read_micros: 0,
301 over_cache_write_micros: 0,
302 over_cache_write_1h_micros: 0,
303 },
304 &prices,
305 ),
306 CatalogueFacts { family, tier_hint: tier, capabilities, priced, position },
307 )
308}
309
310/// What a new row carries besides its prices.
311#[derive(Debug, PartialEq)]
312pub(crate) struct CatalogueFacts {
313 pub family: String,
314 pub tier_hint: String,
315 pub capabilities: Vec<String>,
316 pub priced: bool,
317 pub position: i64,
318}
319
320/// `model` with `prices`.
321pub(crate) fn with_prices(model: GatewayModel, prices: &ModelPrices) -> GatewayModel {
322 GatewayModel {
323 input_micros: prices.input_micros,
324 output_micros: prices.output_micros,
325 cache_read_micros: prices.cache_read_micros,
326 cache_write_micros: prices.cache_write_micros,
327 cache_write_1h_micros: prices.cache_write_1h_micros,
328 threshold: prices.threshold,
329 over_input_micros: prices.over_input_micros,
330 over_output_micros: prices.over_output_micros,
331 over_cache_read_micros: prices.over_cache_read_micros,
332 over_cache_write_micros: prices.over_cache_write_micros,
333 over_cache_write_1h_micros: prices.over_cache_write_1h_micros,
334 ..model
335 }
336}
337
338/// Whether prices staff confirm make sense: none below nothing, a chat
339/// model with input and output prices, an embeddings model with an input
340/// price, and long-prompt prices only with a threshold.
341pub(crate) fn check_prices(kind: &str, prices: &ModelPrices) -> std::result::Result<(), String> {
342 let all = [
343 prices.input_micros,
344 prices.output_micros,
345 prices.cache_read_micros,
346 prices.cache_write_micros,
347 prices.cache_write_1h_micros,
348 prices.over_input_micros,
349 prices.over_output_micros,
350 prices.over_cache_read_micros,
351 prices.over_cache_write_micros,
352 prices.over_cache_write_1h_micros,
353 ];
354 if all.iter().any(|price| *price < 0) {
355 return Err("A price cannot be less than nothing.".into());
356 }
357 // $1,000 per million tokens: no model costs that; a slipped finger does.
358 if all.iter().any(|price| *price > 1_000_000_000) {
359 return Err("No price is over $1,000 per million tokens; check the decimal point.".into());
360 }
361 if prices.input_micros == 0 {
362 return Err("Give the input price per million tokens.".into());
363 }
364 if kind == "chat" && prices.output_micros == 0 {
365 return Err("Give the output price per million tokens.".into());
366 }
367 let over = prices.over_input_micros + prices.over_output_micros + prices.over_cache_read_micros + prices.over_cache_write_micros;
368 if prices.threshold == 0 && over > 0 {
369 return Err("Long-prompt prices need the prompt length they start above.".into());
370 }
371 if prices.threshold > 0 && (prices.over_input_micros == 0 || (kind == "chat" && prices.over_output_micros == 0)) {
372 return Err("With a long-prompt threshold, give the input and output prices above it.".into());
373 }
374 Ok(())
375}
376
377// ---------------------------------------------------------------------------
378// Defaults: what each purpose uses, and what it falls back to.
379// ---------------------------------------------------------------------------
380
381/// Why `model` cannot serve `purpose`, or None when it can. The agents'
382/// purposes need Claude (the harness speaks Anthropic's API), and so does
383/// the AI Gateway's first model.
384pub(crate) fn unsuited(purpose: &str, model: &CatalogueModel) -> Option<String> {
385 let name = &model.prices.name;
386 if !model.priced {
387 return Some(format!("{name} has no price yet: approve it with its prices first."));
388 }
389 match model.status.as_str() {
390 model_status::AVAILABLE => {}
391 model_status::NEW => return Some(format!("{name} is new: approve it first.")),
392 status => return Some(format!("{name} is {status}.")),
393 }
394 if MODEL_PURPOSES.contains(&purpose) && (model.prices.provider != "anthropic" || model.prices.kind != "chat") {
395 return Some(format!("{name} is not a Claude chat model, which {} needs.", purpose_label(purpose).to_lowercase()));
396 }
397 None
398}
399
400/// What one model purpose uses now: the chosen model while it can be used;
401/// otherwise the first available Claude suited to the purpose's tier (or,
402/// for the gateway, any available Claude), in the catalogue's order, with a
403/// sentence saying so. A model is never handed out that cannot be used.
404pub(crate) fn resolve(purpose: &str, chosen: &str, catalogue: &[CatalogueModel]) -> ResolvedModel {
405 let resolved = |model: &CatalogueModel, note: Option<String>| ResolvedModel {
406 purpose: purpose.to_owned(),
407 chosen: chosen.to_owned(),
408 model: Some(model.prices.clone()),
409 capabilities: model.capabilities.clone(),
410 note,
411 };
412 let picked = catalogue.iter().find(|model| model.prices.model == chosen);
413 if let Some(model) = picked
414 && unsuited(purpose, model).is_none()
415 {
416 return resolved(model, None);
417 }
418 let why = match picked {
419 Some(model) => match model.status.as_str() {
420 model_status::AVAILABLE if !model.priced => format!("{} has no price", model.prices.name),
421 model_status::AVAILABLE => format!("{} does not suit it", model.prices.name),
422 status => format!("{} is {status}", model.prices.name),
423 },
424 None => format!("{chosen} is not in the catalogue"),
425 };
426 let tier = tier_of_purpose(purpose);
427 let fallback = catalogue.iter().filter(|model| model.prices.model != chosen && unsuited(purpose, model).is_none()).find(|model| {
428 // Same tier first; a purpose with no tier takes any Claude.
429 tier.is_none_or(|tier| model.tier_hint == tier)
430 });
431 match fallback {
432 Some(model) => resolved(model, Some(format!("{why}; using {} instead.", model.prices.name))),
433 None => ResolvedModel {
434 purpose: purpose.to_owned(),
435 chosen: chosen.to_owned(),
436 model: None,
437 capabilities: vec![],
438 note: Some(format!("{why}, and no other model suits it.")),
439 },
440 }
441}
442
443/// Every purpose's model as it applies now, and each job's tier and
444/// effort; a purpose or job with no row is left out (callers keep theirs).
445pub(crate) fn defaults_view(defaults: &[ModelDefault], catalogue: &[CatalogueModel]) -> ModelDefaults {
446 let models = MODEL_PURPOSES
447 .iter()
448 .filter_map(|purpose| {
449 let row = defaults.iter().find(|row| row.purpose == *purpose)?;
450 Some(resolve(purpose, row.model.as_deref()?, catalogue))
451 })
452 .collect();
453 let jobs = JOB_KINDS
454 .iter()
455 .filter_map(|kind| {
456 let row = defaults.iter().find(|row| row.purpose == format!("job_{kind}"))?;
457 let tier = row.tier.as_deref().filter(|tier| TIERS.contains(tier) || *tier == "change")?;
458 Some(JobDefault {
459 kind: (*kind).to_owned(),
460 tier: tier.to_owned(),
461 effort: row.effort.clone().filter(|effort| EFFORTS.contains(&effort.as_str())),
462 })
463 })
464 .collect();
465 ModelDefaults { models, jobs }
466}
467
468/// Puts `first` at the top of the gateway's list, the rest in order.
469pub(crate) fn put_first(models: &mut Vec<GatewayModel>, first: Option<&str>) {
470 if let Some(at) = first.and_then(|first| models.iter().position(|model| model.model == first)) {
471 let model = models.remove(at);
472 models.insert(0, model);
473 }
474}
475
476/// A default as staff set it, checked: the purpose exists, a model purpose
477/// names a model that suits it, a job names a tier (and an effort or none).
478/// The reason is required.
479/// A checked default: its model, or its tier and effort.
480pub(crate) type Checked = (Option<String>, Option<String>, Option<String>);
481
482pub(crate) fn check_default(a: &AdminSetModelDefaultArgs, catalogue: &[CatalogueModel]) -> std::result::Result<Checked, String> {
483 if a.reason.trim().is_empty() {
484 return Err("Say why, for whoever looks next.".into());
485 }
486 let purpose = a.purpose.as_str();
487 if MODEL_PURPOSES.contains(&purpose) {
488 let wanted = a.model.as_deref().map(str::trim).filter(|m| !m.is_empty()).ok_or("Choose a model.")?;
489 let model = catalogue.iter().find(|model| model.prices.model == wanted).ok_or_else(|| format!("{wanted} is not in the catalogue."))?;
490 if let Some(why) = unsuited(purpose, model) {
491 return Err(why);
492 }
493 return Ok((Some(wanted.to_owned()), None, None));
494 }
495 let Some(kind) = purpose.strip_prefix("job_").filter(|kind| JOB_KINDS.contains(kind)) else {
496 return Err(format!("{purpose} is not something a default is chosen for."));
497 };
498 let tier = a.tier.as_deref().map(str::trim).unwrap_or_default();
499 // Only a review is sized by the change it reads.
500 if !(TIERS.contains(&tier) || (tier == "change" && kind == "review")) {
501 return Err(if kind == "review" { "Choose fast, standard, most capable, or by the change's size." } else { "Choose fast, standard or most capable." }.into());
502 }
503 let effort = a.effort.as_deref().map(str::trim).filter(|effort| !effort.is_empty());
504 if let Some(effort) = effort
505 && !EFFORTS.contains(&effort)
506 {
507 return Err("Effort is low, medium, high, xhigh or max, or the harness's own.".into());
508 }
509 Ok((None, Some(tier.to_owned()), effort.map(str::to_owned)))
510}
511
512/// How a default reads in the audit log: `claude-haiku-5-5`, or
513/// `small at high effort`.
514fn describe_default(model: Option<&str>, tier: Option<&str>, effort: Option<&str>) -> String {
515 match (model, tier) {
516 (Some(model), _) => model.to_owned(),
517 (None, Some(tier)) => match effort {
518 Some(effort) => format!("{tier} at {effort} effort"),
519 None => tier.to_owned(),
520 },
521 (None, None) => "nothing".to_owned(),
522 }
523}
524
525fn split(list: Option<&str>) -> Vec<String> {
526 list.unwrap_or_default().split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect()
527}
528
529impl From<ModelRow> for CatalogueModel {
530 fn from(row: ModelRow) -> Self {
531 let n = |value: Option<f64>| value.unwrap_or(0.0).max(0.0) as u64;
532 let aliases = split(row.aliases.as_deref());
533 let capabilities = split(row.capabilities.as_deref());
534 let (family, tier_hint) = (row.family.clone().unwrap_or_default(), row.tier_hint.clone().unwrap_or_default());
535 let status = row.status.clone().unwrap_or_else(|| model_status::AVAILABLE.to_owned());
536 let priced = row.priced.is_none_or(|priced| priced != 0.0);
537 let source = row.source.clone().unwrap_or_else(|| "staff".to_owned());
538 let (context_window, max_output, dimensions) = (n(row.context_window), n(row.max_output), n(row.dimensions) as u32);
539 let (first_seen_at, last_seen_at, missing_since) = (row.first_seen_at.clone(), row.last_seen_at.clone(), row.missing_since.clone());
540 let (approved_by, approved_at, note) = (row.approved_by.clone(), row.approved_at.clone(), row.note.clone().unwrap_or_default());
541 let prices = GatewayModel::from(row);
542 let typical_run_micros = if priced { typical_run(&prices) } else { 0 };
543 CatalogueModel {
544 prices,
545 aliases,
546 family,
547 tier_hint,
548 context_window,
549 max_output,
550 capabilities,
551 dimensions,
552 status,
553 priced,
554 source,
555 first_seen_at,
556 last_seen_at,
557 missing_since,
558 approved_by,
559 approved_at,
560 note,
561 typical_run_micros,
562 }
563 }
564}
565
566#[derive(Deserialize)]
567struct DefaultRow {
568 purpose: String,
569 model: Option<String>,
570 tier: Option<String>,
571 effort: Option<String>,
572 updated_at: String,
573 updated_by: String,
574 reason: Option<String>,
575}
576
577impl From<DefaultRow> for ModelDefault {
578 fn from(row: DefaultRow) -> Self {
579 ModelDefault {
580 purpose: row.purpose,
581 model: row.model,
582 tier: row.tier,
583 effort: row.effort,
584 updated_at: row.updated_at,
585 updated_by: row.updated_by,
586 reason: row.reason.unwrap_or_default(),
587 }
588 }
589}
590
591#[derive(Deserialize)]
592struct CheckRow {
593 id: String,
594 provider: String,
595 checked_at: String,
596 by: String,
597 listed: f64,
598 added: Option<String>,
599 deprecated: Option<String>,
600 error: Option<String>,
601}
602
603impl From<CheckRow> for ModelCheck {
604 fn from(row: CheckRow) -> Self {
605 ModelCheck {
606 id: row.id,
607 provider: row.provider,
608 checked_at: row.checked_at,
609 by: row.by,
610 listed: row.listed.max(0.0) as u32,
611 added: split(row.added.as_deref()),
612 deprecated: split(row.deprecated.as_deref()),
613 error: row.error,
614 }
615 }
616}
617
618/// How long checks are kept.
619const CHECK_DAYS: u64 = 90;
620const DAY_MS: u64 = 86_400_000;
621
622fn number(n: u64) -> JsValue {
623 JsValue::from_f64(n as f64)
624}
625
626impl Billing {
627 /// Every model in the catalogue, whatever its status: `new` ones first,
628 /// then by provider and position.
629 pub(crate) async fn catalogue(&self) -> Result<Vec<CatalogueModel>> {
630 Ok(self
631 .db
632 .prepare("SELECT * FROM gateway_models ORDER BY CASE status WHEN 'new' THEN 0 ELSE 1 END, provider, position, model")
633 .all()
634 .await?
635 .results::<ModelRow>()?
636 .into_iter()
637 .map(CatalogueModel::from)
638 .collect())
639 }
640
641 pub(crate) async fn model_default(&self, purpose: &str) -> Result<Option<ModelDefault>> {
642 Ok(self
643 .db
644 .prepare("SELECT * FROM model_defaults WHERE purpose = ?")
645 .bind(&[purpose.into()])?
646 .first::<DefaultRow>(None)
647 .await?
648 .map(ModelDefault::from))
649 }
650
651 async fn model_default_rows(&self) -> Result<Vec<ModelDefault>> {
652 Ok(self
653 .db
654 .prepare("SELECT * FROM model_defaults ORDER BY purpose")
655 .all()
656 .await?
657 .results::<DefaultRow>()?
658 .into_iter()
659 .map(ModelDefault::from)
660 .collect())
661 }
662
663 /// `model_defaults`: what the runner and the gateway use now.
664 pub(crate) async fn model_defaults(&self) -> Result<ModelDefaults> {
665 let (defaults, catalogue) = futures_util::future::try_join(self.model_default_rows(), self.catalogue()).await?;
666 Ok(defaults_view(&defaults, &catalogue))
667 }
668
669 /// `admin_models`.
670 pub(crate) async fn admin_models(&self) -> Result<AdminModels> {
671 let (defaults, catalogue) = futures_util::future::try_join(self.model_default_rows(), self.catalogue()).await?;
672 let checks = self
673 .db
674 .prepare("SELECT * FROM model_checks ORDER BY checked_at DESC, id DESC LIMIT 20")
675 .all()
676 .await?
677 .results::<CheckRow>()?
678 .into_iter()
679 .map(ModelCheck::from)
680 .collect();
681 let resolved = defaults_view(&defaults, &catalogue);
682 Ok(AdminModels { catalogue, defaults, resolved, checks, typical: TYPICAL })
683 }
684
685 /// `record_discovery`: what one provider lists, against the catalogue.
686 pub(crate) async fn record_discovery(&self, a: RecordDiscoveryArgs) -> Result<DiscoveryResult> {
687 let provider: String = a.provider.trim().chars().take(40).collect();
688 let by: String = a.by.trim().chars().take(200).collect();
689 let now = now_ms();
690 let checked_at = rfc3339(now);
691 let mut result = DiscoveryResult {
692 provider: provider.clone(),
693 checked_at: checked_at.clone(),
694 by: by.clone(),
695 listed: a.models.len().min(u32::MAX as usize) as u32,
696 error: a.error.as_deref().map(|e| e.chars().take(500).collect()),
697 ..DiscoveryResult::default()
698 };
699 if result.error.is_none() {
700 let catalogue = self.catalogue().await?;
701 let found = diff(&provider, &catalogue, &a.models);
702 let mut writes = vec![];
703 let position = catalogue.iter().filter(|row| row.prices.provider == provider).count() as i64 + 100;
704 for (at, listed) in found.added.iter().enumerate() {
705 let (model, facts) = new_row(&provider, listed, position + at as i64);
706 writes.push(
707 self.db
708 .prepare(
709 "INSERT OR IGNORE INTO gateway_models
710 (model, name, provider, kind, input_micros, output_micros, cache_read_micros, cache_write_micros,
711 cache_write_1h_micros, threshold, over_input_micros, over_output_micros, over_cache_read_micros,
712 over_cache_write_micros, over_cache_write_1h_micros, position, updated_at, family, tier_hint,
713 context_window, max_output, capabilities, status, priced, source, first_seen_at, last_seen_at)
714 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22,
715 'new', ?23, 'discovered', ?17, ?17)",
716 )
717 .bind(&[
718 model.model.as_str().into(),
719 model.name.as_str().into(),
720 provider.as_str().into(),
721 model.kind.as_str().into(),
722 (model.input_micros as f64).into(),
723 (model.output_micros as f64).into(),
724 (model.cache_read_micros as f64).into(),
725 (model.cache_write_micros as f64).into(),
726 (model.cache_write_1h_micros as f64).into(),
727 number(model.threshold),
728 (model.over_input_micros as f64).into(),
729 (model.over_output_micros as f64).into(),
730 (model.over_cache_read_micros as f64).into(),
731 (model.over_cache_write_micros as f64).into(),
732 (model.over_cache_write_1h_micros as f64).into(),
733 (facts.position as f64).into(),
734 checked_at.as_str().into(),
735 facts.family.as_str().into(),
736 facts.tier_hint.as_str().into(),
737 number(listed.context_window),
738 number(listed.max_output),
739 facts.capabilities.join(",").into(),
740 f64::from(u8::from(facts.priced)).into(),
741 ])?,
742 );
743 result.added.push(model.model);
744 }
745 for (model, alias) in &found.seen {
746 let listed = a.models.iter().find(|listed| &listed.id == model || alias.as_ref() == Some(&listed.id));
747 let (context, output) = listed.map_or((0, 0), |l| (l.context_window, l.max_output));
748 let capabilities = listed.map(|l| l.capabilities.join(",")).unwrap_or_default();
749 // What the provider says now fills in what g1t did not know;
750 // a restored model goes back to where it stood.
751 writes.push(
752 self.db
753 .prepare(
754 "UPDATE gateway_models SET last_seen_at = ?2,
755 context_window = CASE WHEN ?3 > 0 THEN ?3 ELSE context_window END,
756 max_output = CASE WHEN ?4 > 0 THEN ?4 ELSE max_output END,
757 capabilities = CASE WHEN ?5 <> '' AND kind = 'chat' THEN ?5 ELSE capabilities END,
758 aliases = CASE WHEN ?6 = '' THEN aliases WHEN aliases = '' THEN ?6 ELSE aliases || ',' || ?6 END,
759 status = CASE WHEN status = 'deprecated' AND missing_since IS NOT NULL
760 THEN CASE WHEN approved_at IS NOT NULL THEN 'available' ELSE 'new' END
761 ELSE status END,
762 missing_since = NULL
763 WHERE model = ?1",
764 )
765 .bind(&[
766 model.as_str().into(),
767 checked_at.as_str().into(),
768 number(context),
769 number(output),
770 capabilities.into(),
771 alias.as_deref().unwrap_or_default().into(),
772 ])?,
773 );
774 }
775 for model in &found.gone {
776 writes.push(
777 self.db
778 .prepare(
779 "UPDATE gateway_models SET status = 'deprecated', missing_since = ?2
780 WHERE model = ?1 AND status IN ('available', 'new')",
781 )
782 .bind(&[model.as_str().into(), checked_at.as_str().into()])?,
783 );
784 }
785 result.deprecated = found.gone.clone();
786 result.restored = found.restored.clone();
787 if !writes.is_empty() {
788 self.db.batch(writes).await?;
789 }
790 }
791 self.db
792 .prepare("INSERT INTO model_checks (id, provider, checked_at, by, listed, added, deprecated, error) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
793 .bind(&[
794 new_id("mck", now).into(),
795 provider.as_str().into(),
796 checked_at.as_str().into(),
797 by.as_str().into(),
798 f64::from(result.listed).into(),
799 result.added.join(",").into(),
800 result.deprecated.join(",").into(),
801 crate::optional(result.error.as_deref()),
802 ])?
803 .run()
804 .await?;
805 if !result.added.is_empty() || !result.deprecated.is_empty() || !result.restored.is_empty() {
806 let detail = discovery_detail(&result);
807 self.audit("models", "models_discovered", &detail, &by).await?;
808 self.tell_staff(&result).await;
809 }
810 Ok(result)
811 }
812
813 /// Emails staff what a check found; a failure is logged, never raised.
814 async fn tell_staff(&self, result: &DiscoveryResult) {
815 if self.caps.alert_to.is_empty() {
816 return;
817 }
818 let (subject, lines) = discovery_email(result);
819 let sent = crate::margin::email_staff_page(
820 &self.env,
821 &self.caps.alert_to,
822 &subject,
823 &lines,
824 ("Agents & models", "https://sudo.g1t.sh/agents"),
825 "g1t-billing's model catalogue",
826 )
827 .await;
828 if let Err(error) = sent {
829 worker::console_error!("emailing staff about models failed: {error}");
830 }
831 }
832
833 /// `admin_decide_model`.
834 pub(crate) async fn admin_decide_model(&self, a: AdminDecideModelArgs) -> Result<Outcome<CatalogueModel>> {
835 let reason = a.reason.trim();
836 if reason.is_empty() {
837 return Ok(Outcome::fail(FailureCode::Invalid, "Say why, for whoever looks next."));
838 }
839 let catalogue = self.catalogue().await?;
840 let Some(model) = catalogue.iter().find(|model| model.prices.model == a.model.trim()) else {
841 return Ok(Outcome::fail(FailureCode::NotFound, format!("{} is not in the catalogue.", a.model.trim())));
842 };
843 let now = rfc3339(now_ms());
844 let id = model.prices.model.as_str();
845 let detail;
846 match a.decision.as_str() {
847 "approve" => {
848 let prices = a.prices.clone().unwrap_or(ModelPrices {
849 input_micros: model.prices.input_micros,
850 output_micros: model.prices.output_micros,
851 cache_read_micros: model.prices.cache_read_micros,
852 cache_write_micros: model.prices.cache_write_micros,
853 cache_write_1h_micros: model.prices.cache_write_1h_micros,
854 threshold: model.prices.threshold,
855 over_input_micros: model.prices.over_input_micros,
856 over_output_micros: model.prices.over_output_micros,
857 over_cache_read_micros: model.prices.over_cache_read_micros,
858 over_cache_write_micros: model.prices.over_cache_write_micros,
859 over_cache_write_1h_micros: model.prices.over_cache_write_1h_micros,
860 });
861 if let Err(why) = check_prices(&model.prices.kind, &prices) {
862 return Ok(Outcome::fail(FailureCode::Invalid, why));
863 }
864 let name: String = a.name.as_deref().map(str::trim).filter(|n| !n.is_empty()).unwrap_or(&model.prices.name).chars().take(120).collect();
865 let tier = a.tier_hint.as_deref().map(str::trim).unwrap_or(&model.tier_hint).to_owned();
866 if !tier.is_empty() && !TIERS.contains(&tier.as_str()) {
867 return Ok(Outcome::fail(FailureCode::Invalid, "A tier hint is small, large, frontier, or none."));
868 }
869 self.db
870 .prepare(
871 "UPDATE gateway_models SET status = 'available', priced = 1, name = ?2, tier_hint = ?3,
872 input_micros = ?4, output_micros = ?5, cache_read_micros = ?6, cache_write_micros = ?7,
873 cache_write_1h_micros = ?8, threshold = ?9, over_input_micros = ?10, over_output_micros = ?11,
874 over_cache_read_micros = ?12, over_cache_write_micros = ?13, over_cache_write_1h_micros = ?14,
875 approved_by = ?15, approved_at = ?16, updated_at = ?16, missing_since = NULL, note = ?17
876 WHERE model = ?1",
877 )
878 .bind(&[
879 id.into(),
880 name.as_str().into(),
881 tier.as_str().into(),
882 (prices.input_micros as f64).into(),
883 (prices.output_micros as f64).into(),
884 (prices.cache_read_micros as f64).into(),
885 (prices.cache_write_micros as f64).into(),
886 (prices.cache_write_1h_micros as f64).into(),
887 number(prices.threshold),
888 (prices.over_input_micros as f64).into(),
889 (prices.over_output_micros as f64).into(),
890 (prices.over_cache_read_micros as f64).into(),
891 (prices.over_cache_write_micros as f64).into(),
892 (prices.over_cache_write_1h_micros as f64).into(),
893 a.by.as_str().into(),
894 now.as_str().into(),
895 reason.into(),
896 ])?
897 .run()
898 .await?;
899 detail = format!(
900 "{id} approved as {name}: ${} in, ${} out per million. {reason}",
901 dollars(prices.input_micros),
902 dollars(prices.output_micros)
903 );
904 self.audit("models", "model_approved", &detail, &a.by).await?;
905 }
906 "retire" => {
907 // A default that names it falls back on its own (`resolve`);
908 // say which, so staff can choose another.
909 let using: Vec<String> = self
910 .model_default_rows()
911 .await?
912 .into_iter()
913 .filter(|row| row.model.as_deref() == Some(id))
914 .map(|row| purpose_label(&row.purpose))
915 .collect();
916 self.db
917 .prepare("UPDATE gateway_models SET status = 'retired', updated_at = ?2, note = ?3 WHERE model = ?1")
918 .bind(&[id.into(), now.as_str().into(), reason.into()])?
919 .run()
920 .await?;
921 let defaults = if using.is_empty() { String::new() } else { format!(" Defaults that fall back now: {}.", using.join(", ")) };
922 detail = format!("{id} retired. {reason}{defaults}");
923 self.audit("models", "model_retired", &detail, &a.by).await?;
924 }
925 "restore" => {
926 // Back to available if it was ever approved; else waiting again.
927 self.db
928 .prepare(
929 "UPDATE gateway_models SET status = CASE WHEN approved_at IS NOT NULL THEN 'available' ELSE 'new' END,
930 missing_since = NULL, updated_at = ?2, note = ?3 WHERE model = ?1",
931 )
932 .bind(&[id.into(), now.as_str().into(), reason.into()])?
933 .run()
934 .await?;
935 detail = format!("{id} restored. {reason}");
936 self.audit("models", "model_restored", &detail, &a.by).await?;
937 }
938 _ => return Ok(Outcome::fail(FailureCode::Invalid, "Approve, retire or restore.")),
939 }
940 let updated = self.catalogue().await?.into_iter().find(|model| model.prices.model == id);
941 Ok(match updated {
942 Some(model) => Outcome::Ok(model),
943 None => Outcome::fail(FailureCode::NotFound, format!("{id} is not in the catalogue.")),
944 })
945 }
946
947 /// `admin_set_model_default`.
948 pub(crate) async fn admin_set_model_default(&self, a: AdminSetModelDefaultArgs) -> Result<Outcome<ModelDefault>> {
949 let catalogue = self.catalogue().await?;
950 let (model, tier, effort) = match check_default(&a, &catalogue) {
951 Ok(value) => value,
952 Err(why) => return Ok(Outcome::fail(FailureCode::Invalid, why)),
953 };
954 let before = self.model_default(&a.purpose).await?;
955 let reason: String = a.reason.trim().chars().take(500).collect();
956 let now = rfc3339(now_ms());
957 self.db
958 .prepare(
959 "INSERT INTO model_defaults (purpose, model, tier, effort, updated_at, updated_by, reason)
960 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
961 ON CONFLICT (purpose) DO UPDATE SET model = ?2, tier = ?3, effort = ?4, updated_at = ?5, updated_by = ?6, reason = ?7",
962 )
963 .bind(&[
964 a.purpose.as_str().into(),
965 crate::optional(model.as_deref()),
966 crate::optional(tier.as_deref()),
967 crate::optional(effort.as_deref()),
968 now.as_str().into(),
969 a.by.as_str().into(),
970 reason.as_str().into(),
971 ])?
972 .run()
973 .await?;
974 let old = before
975 .as_ref()
976 .map_or("nothing".to_owned(), |row| describe_default(row.model.as_deref(), row.tier.as_deref(), row.effort.as_deref()));
977 let new = describe_default(model.as_deref(), tier.as_deref(), effort.as_deref());
978 self.audit("models", "model_default", &format!("{}: {old} → {new}. {reason}", purpose_label(&a.purpose)), &a.by).await?;
979 Ok(Outcome::Ok(ModelDefault { purpose: a.purpose, model, tier, effort, updated_at: now, updated_by: a.by, reason }))
980 }
981
982 /// Daily: checks older than `CHECK_DAYS` go.
983 pub(crate) async fn forget_model_checks(&self) -> Result<()> {
984 let cutoff = rfc3339(now_ms().saturating_sub(CHECK_DAYS * DAY_MS));
985 self.db.prepare("DELETE FROM model_checks WHERE checked_at < ?").bind(&[cutoff.into()])?.run().await?;
986 Ok(())
987 }
988}
989
990/// `$0.10`, `$2`, `$12.50`: dollars per million from millionths.
991pub(crate) fn dollars(micros: i64) -> String {
992 let text = format!("{:.4}", micros as f64 / 1_000_000.0);
993 let text = text.trim_end_matches('0').trim_end_matches('.');
994 match text.split_once('.') {
995 Some((whole, cents)) if cents.len() == 1 => format!("{whole}.{cents}0"),
996 _ => text.to_owned(),
997 }
998}
999
1000/// What a check found, for the audit log.
1001pub(crate) fn discovery_detail(result: &DiscoveryResult) -> String {
1002 let mut parts = vec![];
1003 if !result.added.is_empty() {
1004 parts.push(format!("new: {}", result.added.join(", ")));
1005 }
1006 if !result.deprecated.is_empty() {
1007 parts.push(format!("no longer listed: {}", result.deprecated.join(", ")));
1008 }
1009 if !result.restored.is_empty() {
1010 parts.push(format!("listed again: {}", result.restored.join(", ")));
1011 }
1012 format!("{}: {}", result.provider, parts.join("; "))
1013}
1014
1015/// The email to staff about a check: its subject and paragraphs.
1016pub(crate) fn discovery_email(result: &DiscoveryResult) -> (String, Vec<String>) {
1017 let provider = match result.provider.as_str() {
1018 "anthropic" => "Anthropic",
1019 "workers-ai" => "Workers AI",
1020 other => other,
1021 };
1022 let subject = if result.added.is_empty() {
1023 format!("g1t: {provider} no longer lists {}", result.deprecated.join(", "))
1024 } else {
1025 let more = if result.added.len() > 3 { format!(" and {} more", result.added.len() - 3) } else { String::new() };
1026 format!("g1t: new {provider} models: {}{more}", result.added.iter().take(3).cloned().collect::<Vec<_>>().join(", "))
1027 };
1028 let mut lines = vec![];
1029 if !result.added.is_empty() {
1030 lines.push(format!(
1031 "{provider} lists {} model{} g1t has not used before: {}. Each is in the catalogue as new: nothing routes to it, offers it or charges for it until you approve it with its prices in sudo.",
1032 result.added.len(),
1033 if result.added.len() == 1 { "" } else { "s" },
1034 result.added.join(", ")
1035 ));
1036 }
1037 if !result.deprecated.is_empty() {
1038 lines.push(format!(
1039 "{provider} no longer lists {}. Each is deprecated now: no default routes to it, and any default that chose it uses the next suitable model. Choose another default, or retire it.",
1040 result.deprecated.join(", ")
1041 ));
1042 }
1043 if !result.restored.is_empty() {
1044 lines.push(format!("{provider} lists {} again; each is back where it stood.", result.restored.join(", ")));
1045 }
1046 (subject, lines)
1047}
1048
1049#[cfg(test)]
1050#[path = "catalogue_tests.rs"]
1051mod tests;