diff --git a/crates/cortexkit-provider-usage/Cargo.toml b/crates/cortexkit-provider-usage/Cargo.toml index 6128ac2..5b5a6ac 100644 --- a/crates/cortexkit-provider-usage/Cargo.toml +++ b/crates/cortexkit-provider-usage/Cargo.toml @@ -11,7 +11,7 @@ # says; this crate makes no guarantee about how a producer derived the numbers. [package] name = "cortexkit-provider-usage" -version = "0.4.1" +version = "0.5.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/cortexkit-provider-usage/src/lib.rs b/crates/cortexkit-provider-usage/src/lib.rs index abd0453..3546666 100644 --- a/crates/cortexkit-provider-usage/src/lib.rs +++ b/crates/cortexkit-provider-usage/src/lib.rs @@ -131,6 +131,128 @@ pub struct Usage { pub extra_rate_windows: Option>, } +/// An amount of money or credit, in integer minor units. +/// +/// Not a float, and the reason is not stylistic. A balance is compared against +/// zero on every routing decision that reads it, and binary floating point +/// cannot hold ordinary decimal amounts exactly — the nearest `f64` to `0.1` is +/// not `0.1`, so sums drift and a comparison near zero can fall either way. The +/// providers agree: DeepSeek and MiniMax both send decimal strings, and +/// Anthropic sends integer minor units with an exponent. +/// +/// Parse a provider's own representation once, where its precision is still +/// known, rather than passing a float along and re-rendering it. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Amount { + /// The amount in minor units: `1050` with `exponent: 2` is 10.50. + pub minor: i64, + /// Decimal places in `minor`. `2` for currencies with cents; `0` for whole + /// credits or points. + pub exponent: u8, + /// What the amount is denominated in: a currency code like `"USD"`, or a + /// provider's own label for its credits. + /// + /// A free string rather than a currency enum, because not every pool is + /// money — some are points that convert to no currency, and an enum would + /// force those into a currency slot or drop them. + pub unit: String, +} + +/// Where a pool's balance came from, which decides what a consumer may promise. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PoolFunding { + /// Given by the provider: a promotion, a trial grant, a voucher. Spendable + /// without a bill. + Granted, + /// Bought. Spending it costs money. + Purchased, + /// Included in a subscription the account already pays for. + Subscription, + /// The provider separates this pool but does not say what funds it, **or** + /// the producer named a funding kind this consumer does not recognise. + /// + /// A correct answer rather than a failure one: some providers name their + /// pools without defining them, and guessing the funding is how a consumer + /// ends up spending money it meant to protect. + /// + /// It is also the deserialization fallback, and the two meanings genuinely + /// agree — a funding kind added after this consumer was built is, to this + /// consumer, of unknown funding. Without the fallback an unrecognised value + /// fails the whole `ProviderUsage` entry rather than this one field, so a + /// new pool kind would take an account's *usage* down with it and read as + /// the provider being unavailable. + #[serde(other)] + Unknown, +} + +/// How a pool's `remaining` was obtained. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PoolBasis { + /// The provider states this pool's remaining balance directly. + Reported, + /// Computed from a total and a consumption figure that covers several pools + /// at once, so the split between them is not known. + /// + /// The distinction is load-bearing for any "spend only granted credits" + /// policy: against a `Reported` pool it is exact, and against a `Derived` + /// one it can only be a ceiling. + Derived, + /// No basis was stated, or one was stated that this consumer does not + /// recognise. **Treat `remaining` as a ceiling, never as exact.** + /// + /// This is deliberately its own variant rather than folding an unrecognised + /// value into [`Self::Derived`]. Both are read conservatively, so the + /// spending behaviour is the same either way — but `Derived` is a statement + /// about how a number was obtained, and answering "I do not know" with it + /// would have the producer assert a fact it does not hold. That is the + /// failure this type exists to prevent, one level up. + /// + /// Reading it conservatively is safe in the direction that matters: an + /// exact remainder treated as a ceiling under-spends, while a ceiling + /// treated as exact spends money that may not be there. + #[serde(other)] + Unstated, +} + +/// A prepaid balance or credit pool on an account. +/// +/// Plural by necessity: one figure cannot express "9.50 granted and 40 +/// purchased", which is exactly the distinction a consumer needs to spend the +/// first without spending the second. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Pool { + /// The provider's own name for this pool, never one invented here. + /// + /// Providers separate pools without always defining them — a wallet may list + /// voucher, cash and credit balances and document none of them. Passing the + /// provider's name through lets a consumer decide; renaming one `granted` + /// would be inventing the label a spend policy keys on. + pub id: String, + /// Human-readable name for display. + pub label: String, + /// What funds this pool. + pub funding: PoolFunding, + /// What is left, when it can be established. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub remaining: Option, + /// The pool's size, when the provider reports one. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub total: Option, + /// How `remaining` was obtained. Read it before acting on `remaining`. + pub basis: PoolBasis, + /// Whether the provider says this pool may currently be drawn on. + /// + /// Read from the provider, never inferred from `remaining > 0`: a pool can + /// be non-empty and closed, which several providers publish directly through + /// their own enable flags. Absent means the provider does not say. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub spendable: Option, +} + /// Account labels and subscription information supplied by a provider or vault. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] #[serde(rename_all = "camelCase")] @@ -242,6 +364,22 @@ pub struct ProviderUsage { /// `error_class` to tell those apart, rather than inferring from this field. #[serde(skip_serializing_if = "Option::is_none")] pub usage: Option, + /// Prepaid balances and credit pools on this account, when the provider + /// reports any. + /// + /// Deliberately apart from [`Self::usage`], because a pool and a rate window + /// are different facts that fail in opposite directions: over-consuming a + /// window gets you throttled and recovers by waiting, while over-consuming a + /// balance gets you billed and recovers by paying. Nothing in a routing loop + /// can undo the second, so a balance is never expressed as a window, never + /// carries a reset, and never appears as a percentage — a consumer that + /// found one where it expects headroom would pace into a bill. + /// + /// Absent means the producer has nothing to say, which is not the same as an + /// account having no credit. Empty means it looked and the provider reports + /// no pools. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub spend: Option>, /// Present only on a degraded entry. The consumer skips any entry with a /// truthy `error`. #[serde(skip_serializing_if = "Option::is_none")] @@ -292,6 +430,7 @@ impl ProviderUsage { fetched_at: None, saved_resets: None, usage: Some(usage), + spend: None, error: None, error_class: None, } @@ -309,6 +448,7 @@ impl ProviderUsage { fetched_at: None, saved_resets: None, usage: None, + spend: None, error: Some(error.to_string()), error_class: None, } @@ -536,6 +676,117 @@ mod tests { assert_eq!(entry.error.as_deref(), Some("something new")); } + /// An entry with no pools serializes exactly as it did before pools existed. + /// + /// Consumers pin these payloads, so an additive field that appears as `null` + /// on every existing entry is not additive in practice. The check is on the + /// rendered text rather than on the field, because that is what a consumer + /// parses. + #[test] + fn an_entry_without_pools_does_not_mention_them() { + let entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default()); + let json = serde_json::to_string(&entry).unwrap(); + assert!(!json.contains("spend"), "unexpected spend key: {json}"); + } + + /// Pools survive a round trip, including the two fields a consumer must read + /// before acting on an amount. + /// + /// `basis` and `funding` are what separate "you have 10 granted credits + /// left" from "you were granted 10 credits and we cannot tell how many + /// remain". A consumer that loses either one is left with a number it cannot + /// safely spend against. + #[test] + fn pools_round_trip_with_their_basis_and_funding() { + let pool = Pool { + id: "granted_balance".to_string(), + label: "Granted".to_string(), + funding: PoolFunding::Granted, + remaining: Some(Amount { + minor: 1050, + exponent: 2, + unit: "CNY".to_string(), + }), + total: None, + basis: PoolBasis::Reported, + spendable: Some(true), + }; + let mut entry = ProviderUsage::healthy("deepseek", None, "api", Usage::default()); + entry.spend = Some(vec![pool.clone()]); + + let json = serde_json::to_string(&entry).unwrap(); + let back: ProviderUsage = serde_json::from_str(&json).unwrap(); + assert_eq!(back.spend, Some(vec![pool])); + + // Rendered as the wire spells them, since consumers key on these. + assert!(json.contains(r#""funding":"granted""#), "{json}"); + assert!(json.contains(r#""basis":"reported""#), "{json}"); + // 10.50 CNY is carried as minor units, never as a float. + assert!(json.contains(r#""minor":1050"#), "{json}"); + assert!( + !json.contains("10.5"), + "an amount was rendered as a decimal: {json}" + ); + } + + /// An unrecognised funding kind must not take the entry down with it. + /// + /// This payload crosses a repository boundary: one project produces it, + /// others consume it, and their versions move independently. A closed enum + /// makes the first new funding kind fail deserialization of the WHOLE + /// `ProviderUsage` entry rather than one field, so an account's rate windows + /// would vanish because of a credit pool the consumer had never heard of -- + /// and a vanished entry reads as the provider being unavailable. + /// + /// Asserted on a mixed entry rather than on the enum alone, because the + /// blast radius is the point: the usage figure below is what a router acts + /// on, and it is downstream of the pool that failed. + #[test] + fn an_unknown_funding_kind_does_not_discard_the_entry() { + let json = r#"{ + "provider": "minimax", + "usage": { "primary": { "usedPercent": 42.0 } }, + "spend": [ + { "id": "a", "label": "A", "funding": "granted", "basis": "reported" }, + { "id": "b", "label": "B", "funding": "crypto_grant", "basis": "reported" } + ] + }"#; + + let entry: ProviderUsage = serde_json::from_str(json).expect("entry must survive"); + let pools = entry.spend.expect("pools present"); + assert_eq!(pools.len(), 2, "no pool may be dropped"); + assert_eq!(pools[0].funding, PoolFunding::Granted); + // The unrecognised kind lands on Unknown, which is the correct reading: + // a funding this consumer cannot name is one it must not spend from. + assert_eq!(pools[1].funding, PoolFunding::Unknown); + // And the part a router acts on survived. + assert_eq!( + entry.usage.and_then(|u| u.primary).map(|w| w.used_percent), + Some(42.0) + ); + } + + /// An unrecognised basis reads as unstated, never as exact. + /// + /// The two poles are not symmetrical. Treating an exact remainder as a + /// ceiling under-spends and costs nothing; treating a ceiling as exact + /// spends money that may not be there. So the fallback folds to the + /// conservative side, and does so under its own name rather than claiming + /// the number was derived -- which would assert a fact about a computation + /// the consumer knows nothing about. + #[test] + fn an_unknown_basis_is_unstated_rather_than_exact() { + let json = r#"{ "id": "a", "label": "A", "funding": "granted", + "basis": "sampled_hourly" }"#; + let pool: Pool = serde_json::from_str(json).expect("pool must survive"); + assert_eq!(pool.basis, PoolBasis::Unstated); + assert_ne!( + pool.basis, + PoolBasis::Reported, + "an unknown basis must never read as an exact remainder" + ); + } + /// A healthy entry must never carry a class: the field's presence is itself /// a signal, and a class on a working provider would be a contradiction a /// consumer has to resolve.