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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 16 additions & 11 deletions crates/douane/src/clients.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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()),
Expand Down Expand Up @@ -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));
}

Expand Down
14 changes: 14 additions & 0 deletions crates/douane/src/quota_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f64>, 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)})
}

Expand Down Expand Up @@ -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![
Expand Down
Loading