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 sudo | 1 | //! 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 | ||
| 31 | use g1t_contracts::billing::{ | |
| 32 | model_status, AdminDecideModelArgs, AdminModels, AdminSetModelDefaultArgs, CatalogueModel, DiscoveryResult, GatewayModel, | |
| 33 | JobDefault, ModelCheck, ModelDefault, ModelDefaults, ModelPrices, ProviderModel, RecordDiscoveryArgs, ResolvedModel, | |
| 34 | TypicalRun, | |
| 35 | }; | |
| 36 | use g1t_contracts::time::rfc3339; | |
| 37 | use g1t_contracts::{FailureCode, Outcome, new_id}; | |
| 38 | use g1t_kit::now_ms; | |
| 39 | use serde::Deserialize; | |
| 40 | use worker::Result; | |
| 41 | use worker::wasm_bindgen::JsValue; | |
| 42 | ||
| 43 | use crate::gateway::{ModelRow, Used, cost_micros}; | |
| 44 | use 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. | |
| 50 | pub(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. | |
| 56 | pub(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. | |
| 65 | pub(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>`). | |
| 67 | pub(crate) const JOB_KINDS: [&str; 6] = ["implement", "revise", "answer", "review", "update", "plan"]; | |
| 68 | pub(crate) const TIERS: [&str; 3] = ["small", "large", "frontier"]; | |
| 69 | pub(crate) const EFFORTS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"]; | |
| 70 | ||
| 71 | /// What sudo and the audit log call a purpose. | |
| 72 | pub(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). | |
| 88 | fn 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. | |
| 103 | type Five = [i64; 5]; | |
| 104 | ||
| 105 | const 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. | |
| 110 | type 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. | |
| 117 | const 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. | |
| 138 | pub(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. | |
| 147 | pub(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. | |
| 169 | pub(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. | |
| 184 | fn 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)] | |
| 198 | pub(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). | |
| 212 | fn 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. | |
| 222 | pub(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. | |
| 266 | pub(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)] | |
| 312 | pub(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`. | |
| 321 | pub(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. | |
| 341 | pub(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. | |
| 384 | pub(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. | |
| 404 | pub(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). | |
| 445 | pub(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. | |
| 469 | pub(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. | |
| 480 | pub(crate) type Checked = (Option<String>, Option<String>, Option<String>); | |
| 481 | ||
| 482 | pub(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`. | |
| 514 | fn 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 | ||
| 525 | fn 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 | ||
| 529 | impl 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)] | |
| 567 | struct 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 | ||
| 577 | impl 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)] | |
| 592 | struct 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 | ||
| 603 | impl 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. | |
| 619 | const CHECK_DAYS: u64 = 90; | |
| 620 | const DAY_MS: u64 = 86_400_000; | |
| 621 | ||
| 622 | fn number(n: u64) -> JsValue { | |
| 623 | JsValue::from_f64(n as f64) | |
| 624 | } | |
| 625 | ||
| 626 | impl 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. | |
| 991 | pub(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. | |
| 1001 | pub(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. | |
| 1016 | pub(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"] | |
| 1051 | mod tests; |