From 510ef0961d18948da844ca5520e476146796ce98 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:28:55 +0200 Subject: [PATCH] fix: whole quota percents, raw quota for clients without a share --- README.md | 2 +- crates/douane/src/clients.rs | 27 ++++++++++++++++----------- crates/douane/src/quota_view.rs | 14 ++++++++++++++ 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 6557f94..641a0b6 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,7 @@ Every `/v1` call needs a client key, sent as the tool's API key (`Authorization: - **Shares.** A share is a percentage of each account-wide window (five hours, day, week, month) of every account of a provider. Shares of a provider add up to 100 % at most. What no share covers is common to every client. A client's unused share stays reserved for it until the last seventh of the window (the last day of a week), then any client may use it. A client with no share uses the common part, then freed shares. A client held back gets 429 until the window resets. In the hours before the weekly reset (`free_before_reset_hours`, see above), shares hold no one back. - **Usage.** Providers report usage per account, not per caller, so douane attributes it: each rise of a window goes to the clients whose calls ended on that account since the previous reading, weighted by tokens, or to the calls still running. A rise that no call through douane explains counts as outside use. Calls of several clients interleaving on one account make the split an estimate; one client per account keeps it exact. - **Consumption.** The **Clients** page shows, over 24 hours, 7 or 30 days, each client's tokens, calls, cache reads and errors, its part of the total, its tokens over time and its models, limits or not. `GET /admin/api/clients/stats?hours=168` returns the same totals by client, provider and model, and by client and hour. -- **Quota bars.** With a client key, `/v1/quotas` shows each window as the part the client has used of what it may use, so a full bar means it is held back. +- **Quota bars.** With a client key, `/v1/quotas` shows a client holding a share of a provider each window as the part it has used of what it may use, so a full bar means it is held back. A client without a share sees each account's whole quota, other shares included. Percents are whole numbers. ```sh douanectl client add Family --pools claude --share claude=25 --key-file family.key diff --git a/crates/douane/src/clients.rs b/crates/douane/src/clients.rs index 7329b64..f83672b 100644 --- a/crates/douane/src/clients.rs +++ b/crates/douane/src/clients.rs @@ -706,10 +706,12 @@ impl Clients { .map(|r| r.reset) } - /// The snapshot as `client` sees it: each account-wide window shows the - /// part of what it may use that it has used, so a bar at 100% means it - /// is held back. Windows of pools without shares, or of an account whose - /// shares are freed before its weekly reset, stay as they are. + /// The snapshot as `client` sees it: with a share of the pool, each + /// account-wide window shows the part of what it may use that it has + /// used, so a bar at 100% means it is held back. A client without a share + /// of the pool sees the account's whole quota, other shares included. + /// Windows of an account whose shares are freed before its weekly reset + /// stay as they are. pub fn client_view( &self, client: &Client, @@ -718,10 +720,12 @@ impl Clients { now: i64, free_before: i64, ) -> QuotaSnapshot { - let holders = self.holders(pool); - if holders.is_empty() || snapshot.longest_window_ends_within(free_before, now) { + if !client.shares.contains_key(pool) + || snapshot.longest_window_ends_within(free_before, now) + { return snapshot; } + let holders = self.holders(pool); let account = snapshot.account_id.clone(); let readings = readings(&snapshot); for window in &mut snapshot.windows { @@ -983,14 +987,15 @@ mod tests { clients.share_blocks(Some(&famille), &a, Some(&full), NOW, 0), None ); - // Perso's bar is full; famille's shows nothing used of its 25. + // Famille's bar shows nothing used of its 25; perso, without a + // share, sees the account's whole quota, famille's share included. let view = |c: &Client| { clients .client_view(c, full.clone(), "claude", NOW, 0) .windows[0] .remaining_percent }; - assert_eq!(view(&perso), Some(0.0)); + assert_eq!(view(&perso), Some(25.0)); assert_eq!(view(&famille), Some(100.0)); // In the last day of the week, famille's unused share opens to all. let late = NOW + 3 * 86_400 - 20 * 3600; @@ -1010,7 +1015,7 @@ mod tests { #[test] fn shares_stop_holding_back_hours_before_the_weekly_reset() { let (_dir, clients) = clients(); - let (_famille, _) = clients + let (famille, _) = clients .create(ClientPatch { name: Some("famille".into()), shares: Some([("claude".to_owned(), 25.0)].into()), @@ -1056,11 +1061,11 @@ mod tests { ); let view = |free_before: i64| { clients - .client_view(&perso, full.clone(), "claude", NOW, free_before) + .client_view(&famille, full.clone(), "claude", NOW, free_before) .windows[1] .remaining_percent }; - assert_eq!(view(0), Some(0.0)); + assert_eq!(view(0), Some(100.0)); assert_eq!(view(6 * 3600), Some(25.0)); } diff --git a/crates/douane/src/quota_view.rs b/crates/douane/src/quota_view.rs index d3be3ee..c42ea8a 100644 --- a/crates/douane/src/quota_view.rs +++ b/crates/douane/src/quota_view.rs @@ -268,7 +268,10 @@ fn window(window: &QuotaWindow) -> Value { ) } +/// Percents go out whole: averages and client views would otherwise show +/// decimals that readings never had. fn window_value(id: &str, label: &str, remaining: Option, reset_at: Option<&str>) -> Value { + let remaining = remaining.map(f64::round); json!({"id":id,"label":label,"used_percent":remaining.map(|r| 100.0 - r),"remaining_percent":remaining,"reset_at":timestamp(reset_at)}) } @@ -428,6 +431,17 @@ mod tests { settings } + #[test] + fn percents_go_out_whole() { + let value = window_value("weekly", "Weekly", Some(70.4), None); + assert_eq!(value["remaining_percent"], 70.0); + assert_eq!(value["used_percent"], 30.0); + assert_eq!( + window_value("weekly", "Weekly", Some(99.6), None)["remaining_percent"], + 100.0 + ); + } + #[test] fn providers_without_a_quota_reader_stay_out() { let accounts = vec![