From b812d88e8a6dc78d635ed3944387d19caa28ac10 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Mon, 10 Aug 2026 23:05:34 +0200 Subject: [PATCH 1/2] Add prepaid balances and credit pools to ProviderUsage Providers that sell credit alongside a subscription report a balance with no period, which the existing shape cannot carry: a rate window is a percentage of a period, and a pool is an amount with neither. Today that data is fetched by producers and discarded, so an account with a depleted window and a live credit pool reads as unusable when it would have served the request. Kept apart from `usage` rather than folded in, because the two fail in opposite directions: over-consuming a window gets you throttled and recovers by waiting, over-consuming a balance gets you billed and recovers by paying. A balance therefore never becomes a window, never carries a reset, and never appears as a percentage. Three choices in here were forced by real payloads rather than picked. Amounts are integer minor units because DeepSeek and MiniMax both send decimal strings and Anthropic sends minor units with an exponent -- and because a balance is compared against zero on every routing decision that reads it, where binary floats are not safe. Pool ids carry the provider's own name, since wallets separate voucher from cash and credit without defining which is a gift, and renaming one `granted` would invent the label a spend policy keys on. And `basis` distinguishes a reported remainder from one derived against a shared total, because DeepSeek reports per-pool remainders while others report only grants -- which is the difference between an exact policy and a ceiling. Additive: an entry without pools serializes exactly as before, pinned by a test on the rendered text rather than the field. --- crates/cortexkit-provider-usage/Cargo.toml | 2 +- crates/cortexkit-provider-usage/src/lib.rs | 169 +++++++++++++++++++++ 2 files changed, 170 insertions(+), 1 deletion(-) 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..52f34dd 100644 --- a/crates/cortexkit-provider-usage/src/lib.rs +++ b/crates/cortexkit-provider-usage/src/lib.rs @@ -131,6 +131,104 @@ 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. + /// + /// 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. + 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, +} + +/// 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 +340,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 +406,7 @@ impl ProviderUsage { fetched_at: None, saved_resets: None, usage: Some(usage), + spend: None, error: None, error_class: None, } @@ -309,6 +424,7 @@ impl ProviderUsage { fetched_at: None, saved_resets: None, usage: None, + spend: None, error: Some(error.to_string()), error_class: None, } @@ -536,6 +652,59 @@ 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}" + ); + } + /// 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. From ec9a015b68b5303c3bc6b2d5dd1b29690b51b59a Mon Sep 17 00:00:00 2001 From: ualtinok Date: Mon, 10 Aug 2026 23:14:30 +0200 Subject: [PATCH 2/2] Stop an unknown pool kind from discarding the whole entry PoolFunding and PoolBasis were closed enums, and this payload crosses a repository boundary: one project produces it, others consume it, and their versions move independently. So the first funding kind added after a consumer is built fails deserialization of the ENTIRE ProviderUsage entry rather than one field. Measured rather than argued: an entry carrying a healthy 42% window and two pools, one with an unrecognised funding, loses everything -- "unknown variant `crypto_grant`". 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. For funding the fallback costs nothing, because the fallback and the semantics already agree: a kind this consumer cannot name is one it must not spend from, which is what Unknown already meant. Basis needed a decision rather than a default. Its two poles are not symmetrical -- treating an exact remainder as a ceiling under-spends and costs nothing, while treating a ceiling as exact spends money that may not be there -- so an unrecognised value must fold to the conservative side. It folds to a new Unstated variant rather than to Derived, because both are read the same way but Derived is a claim about how a number was obtained, and answering "I do not know" with it would assert a fact the producer does not hold. That is the failure this type exists to prevent, one level up. Both proven by removing the fallbacks: each test reddens by name. --- crates/cortexkit-provider-usage/src/lib.rs | 84 +++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/crates/cortexkit-provider-usage/src/lib.rs b/crates/cortexkit-provider-usage/src/lib.rs index 52f34dd..3546666 100644 --- a/crates/cortexkit-provider-usage/src/lib.rs +++ b/crates/cortexkit-provider-usage/src/lib.rs @@ -170,11 +170,20 @@ pub enum PoolFunding { Purchased, /// Included in a subscription the account already pays for. Subscription, - /// The provider separates this pool but does not say what funds it. + /// 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, } @@ -191,6 +200,21 @@ pub enum PoolBasis { /// 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. @@ -705,6 +729,64 @@ mod tests { ); } + /// 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.