Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/cortexkit-provider-usage/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
251 changes: 251 additions & 0 deletions crates/cortexkit-provider-usage/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,128 @@ pub struct Usage {
pub extra_rate_windows: Option<Vec<ExtraWindow>>,
}

/// 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 {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
/// 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<Amount>,
/// The pool's size, when the provider reports one.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub total: Option<Amount>,
/// 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<bool>,
}

/// Account labels and subscription information supplied by a provider or vault.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
Expand Down Expand Up @@ -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<Usage>,
/// 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<Vec<Pool>>,
/// Present only on a degraded entry. The consumer skips any entry with a
/// truthy `error`.
#[serde(skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -292,6 +430,7 @@ impl ProviderUsage {
fetched_at: None,
saved_resets: None,
usage: Some(usage),
spend: None,
error: None,
error_class: None,
}
Expand All @@ -309,6 +448,7 @@ impl ProviderUsage {
fetched_at: None,
saved_resets: None,
usage: None,
spend: None,
error: Some(error.to_string()),
error_class: None,
}
Expand Down Expand Up @@ -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.
Expand Down