From aaa4c4d9866d31d3b826f5507e013e141809adb8 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 10 Nov 2026 16:45:17 +0900 Subject: [PATCH 1/3] fix(anthropic): rank Fable accounts by its share of the weekly limit Claude's usage endpoint now reports weekly_scoped_shares, naming by raw limits index the share of the weekly limit a scoped family may consume (Fable: "up to 50% of your weekly limit"). The Fable window now carries it as weeklyAllowancePercent, and lowest-usage pool ranking restates Fable on the weekly scale, so an account at 60% Fable with 20 weekly points left no longer outranks one with 30. Auto-switch thresholds, exhaustion and route headroom keep their existing meaning. --- src/oauth/anthropic-model-quota.ts | 12 ++++ src/oauth/anthropic-routing.ts | 10 ++-- src/providers/claude-cli-usage.ts | 5 +- src/providers/quota-types.ts | 7 +++ .../quota/anthropic-family-headers.ts | 8 ++- .../quota/anthropic-usage-windows.ts | 51 +++++++++++++++- structure/providers/anthropic-account-pool.md | 12 +++- .../anthropic-account-threshold.test.ts | 21 +++++++ .../provider-anthropic-usage-windows.test.ts | 60 ++++++++++++++++++- 9 files changed, 174 insertions(+), 12 deletions(-) diff --git a/src/oauth/anthropic-model-quota.ts b/src/oauth/anthropic-model-quota.ts index c846315aa80..3843018f29b 100644 --- a/src/oauth/anthropic-model-quota.ts +++ b/src/oauth/anthropic-model-quota.ts @@ -4,6 +4,7 @@ import { getCachedProviderAccountQuota } from "../providers/quota/account-cache" import type { ProviderQuota, ProviderQuotaWindow } from "../providers/quota-types"; import type { AnthropicInstanceId } from "../providers/anthropic-instance-id"; import type { GenerationContext } from "../lib/state-store-sweeper"; +import { familyWeeklyEquivalentPercent } from "../providers/quota/anthropic-usage-windows"; export const ANTHROPIC_PASSIVE_FAMILY_MAX_AGE_MS = 30 * 60_000; import { anthropicModelFamily } from "./anthropic-model-family"; @@ -161,3 +162,14 @@ export function anthropicModelWeeklyPercent(quota: ProviderQuota | null, model?: const family = anthropicModelFamily(model); return family ? quota?.customWindows?.find(window => window.scope === "model" && window.label === family)?.percent : undefined; } + +/** + * The requested family's usage for comparing accounts, restated on the shared weekly scale when + * upstream reported its allowance. Thresholds keep `anthropicModelWeeklyPercent`: a fresh Fable + * seat reads 50 here, which ranks correctly but is not usage an operator's threshold means. + */ +export function anthropicModelRankPercent(quota: ProviderQuota | null, model?: string): number | undefined { + const family = anthropicModelFamily(model); + const window = family ? quota?.customWindows?.find(window => window.scope === "model" && window.label === family) : undefined; + return window ? familyWeeklyEquivalentPercent(window) : undefined; +} diff --git a/src/oauth/anthropic-routing.ts b/src/oauth/anthropic-routing.ts index b04d576b2ba..ecfc5d8eb24 100644 --- a/src/oauth/anthropic-routing.ts +++ b/src/oauth/anthropic-routing.ts @@ -1,4 +1,4 @@ -import { clearAllAnthropicFamilyQuota, anthropicModelQuotaFor, anthropicModelPercents, anthropicModelWeeklyPercent } from "./anthropic-model-quota"; +import { clearAllAnthropicFamilyQuota, anthropicModelQuotaFor, anthropicModelPercents, anthropicModelRankPercent, anthropicModelWeeklyPercent } from "./anthropic-model-quota"; /** * Opt-in Anthropic OAuth account pool (#294). * @@ -584,9 +584,11 @@ function createAnthropicRouting(instance: AnthropicInstanceId): AnthropicRouting } } - function usageScore(config: OcxConfig, accountId: string, model?: string): number { + /** `rank` compares accounts, restating a family allowance on the weekly scale; thresholds use the family's own percent. */ + function usageScore(config: OcxConfig, accountId: string, model?: string, rank = false): number { if (model) { - const family = anthropicModelWeeklyPercent(getCachedProviderAccountQuota(PROVIDER, accountId), model); + const quota = getCachedProviderAccountQuota(PROVIDER, accountId); + const family = rank ? anthropicModelRankPercent(quota, model) : anthropicModelWeeklyPercent(quota, model); if (family === undefined) return usageScore(config, accountId); return hasKnownUsage(config, accountId) ? Math.max(family, usageScore(config, accountId)) : family; } @@ -804,7 +806,7 @@ function createAnthropicRouting(instance: AnthropicInstanceId): AnthropicRouting const scored: ScoredAccount[] = eligible.map(accountId => ({ accountId, hasKnownUsage: hasKnownUsage(config, accountId, model), - score: usageScore(config, accountId, model), + score: usageScore(config, accountId, model, true), fiveHourTieBreak: window === "five-hour" ? 0 : fiveHourScore(accountId), // Every window EXCEPT the legacy five-hour default is an explicit opt-in, so // known-before-unknown applies to all of them and to none of the default path. diff --git a/src/providers/claude-cli-usage.ts b/src/providers/claude-cli-usage.ts index de962f8ae7a..83ebde9bff0 100644 --- a/src/providers/claude-cli-usage.ts +++ b/src/providers/claude-cli-usage.ts @@ -40,7 +40,7 @@ export async function probeAnthropicUsageQuota(accessToken: string): Promise = {}; const customWindows: ProviderQuotaWindow[] = []; - for (const { window, row, percentField } of buckets) { + for (const { window, row, percentField, weeklyAllowancePercent } of buckets) { const percent = normalizePercent(row[percentField]); const resetAt = normalizeResetAt(row.resets_at); if (window === "5h" || window === "weekly") { @@ -48,7 +48,8 @@ export async function probeAnthropicUsageQuota(accessToken: string): Promise 0 && Number.isFinite(new Date(resetAt).getTime()) ? { resetAt } : {}) }]; } export function mergeAnthropicFamilyWindows(previous: ProviderQuotaWindow[] = [], observed: ProviderQuotaWindow[] = []): ProviderQuotaWindow[] { - return [...previous.filter(window => !observed.some(update => update.label === window.label && update.scope === window.scope)), ...observed]; + const same = (a: ProviderQuotaWindow, b: ProviderQuotaWindow) => a.label === b.label && a.scope === b.scope; + // Headers carry no allowance; the share is a seat policy the last usage probe already read. + return [...previous.filter(window => !observed.some(update => same(update, window))), ...observed.map(update => { + const allowance = update.weeklyAllowancePercent === undefined + ? previous.find(window => same(window, update))?.weeklyAllowancePercent : undefined; + return allowance === undefined ? update : { ...update, weeklyAllowancePercent: allowance }; + })]; } const enumerated = new WeakSet(); diff --git a/src/providers/quota/anthropic-usage-windows.ts b/src/providers/quota/anthropic-usage-windows.ts index 5fc10708dad..2702557172c 100644 --- a/src/providers/quota/anthropic-usage-windows.ts +++ b/src/providers/quota/anthropic-usage-windows.ts @@ -6,6 +6,8 @@ export interface AnthropicUsageBucket { window: UsageWindow; row: Record; percentField: "percent" | "utilization"; + /** Share of the weekly limit this scoped family may consume, from `weekly_scoped_shares`. */ + weeklyAllowancePercent?: number; } const legacyFields = { "5h": "five_hour", weekly: "seven_day", fable: "seven_day_fable", opus: "seven_day_opus", sonnet: "seven_day_sonnet" } as const; @@ -18,6 +20,47 @@ function scopedFamily(row: Record): "fable" | "opus" | "sonnet" return (["fable", "opus", "sonnet"] as const).find(family => label.includes(family)); } +/** + * A family window's usage restated on the shared weekly scale. + * + * A scoped percent is relative to the family's own allowance: Fable at 60% of an allowance + * that is 50% of the week has 20 weekly points left, not 40. Ranking that compares a family + * request across accounts must use this figure alongside the shared weekly percent; a window + * without a valid allowance keeps its own percent. 100 maps to 100, so exhaustion is unchanged. + */ +export function familyWeeklyEquivalentPercent(window: { percent?: number; weeklyAllowancePercent?: number }): number | undefined { + const percent = window.percent; + if (typeof percent !== "number" || !Number.isFinite(percent)) return undefined; + const allowance = window.weeklyAllowancePercent; + if (typeof allowance !== "number" || !Number.isFinite(allowance) || allowance <= 0 || allowance > 100) return percent; + const used = Math.max(0, Math.min(100, percent)); + return percent >= 100 ? percent : 100 - allowance * (100 - used) / 100; +} + +/** + * `weekly_scoped_shares[]` names, by raw `limits` index, the share of the weekly limit a scoped + * row may consume (Fable: "up to 50% of your weekly limit"). Only a selected scoped row + * receives it; a malformed, out-of-range or conflicting share is ignored. + */ +function applyWeeklyShares(body: Record, scopedRows: Map): void { + if (!Array.isArray(body.weekly_scoped_shares)) return; + const conflicted = new Set(); + for (const raw of body.weekly_scoped_shares) { + const share = asRecord(raw); + const index = share?.limit_index; + const allowance = toFiniteNumber(share?.allowance_percent_of_weekly); + if (typeof index !== "number" || !Number.isInteger(index) || allowance === undefined || allowance <= 0 || allowance > 100) continue; + const bucket = scopedRows.get(index); + if (!bucket || conflicted.has(bucket)) continue; + if (bucket.weeklyAllowancePercent !== undefined && bucket.weeklyAllowancePercent !== allowance) { + delete bucket.weeklyAllowancePercent; + conflicted.add(bucket); + continue; + } + bucket.weeklyAllowancePercent = allowance; + } +} + /** * Recognized rows supersede legacy duplicates even when skipped or malformed; duplicates * and malformed shared measurements make family enumeration incomplete. Shared finite @@ -30,9 +73,10 @@ export function anthropicUsageBuckets(body: Record): { } { const selected = new Map(); const represented = new Set(); + const scopedRows = new Map(); const limits = Array.isArray(body.limits) ? body.limits : []; let familyEnumeration = Array.isArray(body.limits); - for (const raw of limits) { + for (const [index, raw] of limits.entries()) { const row = asRecord(raw); const window = row?.kind === "session" ? "5h" : row?.kind === "weekly_all" ? "weekly" : row?.kind === "weekly_scoped" ? scopedFamily(row) : undefined; @@ -50,8 +94,11 @@ export function anthropicUsageBuckets(body: Record): { if (row.is_active === false) continue; if (row.is_active !== undefined && row.is_active !== true) { familyEnumeration = false; continue; } if (toFiniteNumber(row.percent) === undefined) familyEnumeration = false; - selected.set(window, { window, row, percentField: "percent" }); + const bucket: AnthropicUsageBucket = { window, row, percentField: "percent" }; + selected.set(window, bucket); + scopedRows.set(index, bucket); } + applyWeeklyShares(body, scopedRows); for (const window of Object.keys(legacyFields) as UsageWindow[]) { if (represented.has(window)) continue; const row = asRecord(body[legacyFields[window]]); diff --git a/structure/providers/anthropic-account-pool.md b/structure/providers/anthropic-account-pool.md index 863cd204456..9de88cfe163 100644 --- a/structure/providers/anthropic-account-pool.md +++ b/structure/providers/anthropic-account-pool.md @@ -203,8 +203,12 @@ unknown kinds or model families preserve usable shared windows without claiming family enumeration. An authoritative inactive family row can retire a previous passive family refusal; an incomplete enumeration cannot. Shared cooldown recovery still requires finite headroom for each rejected window, never an invented zero. +`weekly_scoped_shares[]` names, by raw `limits` index, the share of the weekly limit a +selected scoped row may consume (Fable: "up to 50% of your weekly limit"); the window carries +it as `weeklyAllowancePercent`. It is read from upstream and never inferred from a plan, and a +share that points at no selected scoped row, falls outside (0, 100] or conflicts is ignored. `tests/providers/provider-anthropic-usage-windows.test.ts` covers source precedence, scoped -admission, raw precision, pool projection and legacy compatibility. +admission, raw precision, pool projection, weekly shares and legacy compatibility. Profile tiers in `src/oauth/anthropic-identity.ts` accept both `default_claude_` and `claude_` prefixes and the Ultra display spelling. `max_20x` remains the legacy @@ -484,6 +488,12 @@ statuses retain soft utilization without creating a hard family refusal. strategy, reactive selection and physical dispatch use that model. A family-only refusal preserves unrelated sessions and account-wide health. Numeric thresholds stay soft: zero and the all-drained fallback remain preferences, with no hard billing cap introduced. +Lowest-usage ranking restates a family window that carries a weekly allowance on the shared +weekly scale (`anthropicModelRankPercent`): Fable at 60% of a 50% allowance ranks at 80, +because it has 20 weekly points left. Auto-switch thresholds keep the family's own percent, so +a fresh Fable seat does not read as half spent. Exhaustion is unchanged, since both scales +reach 100 together, and a passive header reading keeps the allowance the last usage probe read. +`tests/adapters/anthropic/anthropic-account-threshold.test.ts` covers both sides. Passive family evidence expires after thirty minutes or its known reset. An expired exclusion admits one request-driven revalidation send at a time, released at response diff --git a/tests/adapters/anthropic/anthropic-account-threshold.test.ts b/tests/adapters/anthropic/anthropic-account-threshold.test.ts index 999fb1ad88e..9a954ab8aee 100644 --- a/tests/adapters/anthropic/anthropic-account-threshold.test.ts +++ b/tests/adapters/anthropic/anthropic-account-threshold.test.ts @@ -311,3 +311,24 @@ test("management dispatcher exposes the saved override/default/effective DTO wit expect(dto.accounts.find((row: { id: string }) => row.id === ids[1])).toMatchObject({ autoSwitchThresholdOverride: null, effectiveAutoSwitchThreshold: 60, autoSwitchThreshold: 60 }); expect(JSON.stringify(dto)).not.toContain("synthetic"); }); + +const FABLE = "claude-fable-5-2"; +function fableQuota(id: string, weekly: number, fable: number, resetAt = Date.now() + 3600_000) { + setCachedProviderAccountQuotaForTests("anthropic", id, { weeklyPercent: weekly, weeklyResetAt: resetAt, updatedAt: Date.now(), + customWindows: [{ label: "Fable", scope: "model", percent: fable, resetAt, weeklyAllowancePercent: 50 }] }); +} +test("Fable ranks accounts on weekly headroom while thresholds read the Fable meter itself", async () => { + const [a, b, c] = ids; + const ranked = config("quota", "weekly"); ranked.anthropicAccountPool!.autoSwitchThreshold = 40; + // Fable headroom in weekly points: a min(50*0.4, 90) = 20, b min(50, 30) = 30, c min(50, 10) = 10. + fableQuota(a, 10, 60); fableQuota(b, 70, 0); fableQuota(c, 90, 0); + expect(resolveAnthropicAccountForSession("fable-rank", ranked, Date.now(), null, FABLE).accountId).toBe(b); + // A fresh seat ranks at 50 on the weekly scale, but must not trip a 40% fill-first threshold. + for (const id of ids) fableQuota(id, 0, 0); + const fill = config("fill-first", "weekly"); fill.anthropicAccountPool!.autoSwitchThreshold = 40; + const active = getAccountSet("anthropic")!.activeAccountId!; + for (const session of ["fable-fresh-1", "fable-fresh-2"]) { + expect(resolveAnthropicAccountForSession(session, fill, Date.now(), null, FABLE).accountId).toBe(active); + } +}); + diff --git a/tests/providers/provider-anthropic-usage-windows.test.ts b/tests/providers/provider-anthropic-usage-windows.test.ts index 610226a69e1..4b0a8ea9a2d 100644 --- a/tests/providers/provider-anthropic-usage-windows.test.ts +++ b/tests/providers/provider-anthropic-usage-windows.test.ts @@ -1,8 +1,8 @@ import { afterEach, expect, test } from "bun:test"; import { probeAnthropicUsageQuota } from "../../src/providers/claude-cli-usage"; import { rawAnthropicUsageWindows } from "../../src/providers/quota/anthropic-decision-windows"; -import { hasAnthropicFamilyEnumeration } from "../../src/providers/quota/anthropic-family-headers"; -import { anthropicModelPercents } from "../../src/oauth/anthropic-model-quota"; +import { hasAnthropicFamilyEnumeration, mergeAnthropicFamilyWindows, parseAnthropicFamilyHeaders } from "../../src/providers/quota/anthropic-family-headers"; +import { anthropicModelPercents, anthropicModelRankPercent, anthropicModelWeeklyPercent } from "../../src/oauth/anthropic-model-quota"; import { resolveAnthropicAccountIdentity } from "../../src/oauth/anthropic-identity"; import { providerQuotaWindows } from "../../src/quota-pool/assemble"; import { pooledWindows } from "../../src/quota-pool/pooled"; @@ -179,3 +179,59 @@ for (const [status, failure] of [[401, "access_denied"], [429, "rate_limited"]] expect(await probeAnthropicUsageQuota("synthetic-usage-bearer")).toEqual({ quota: null, failure }); }); } + +// Live /api/oauth/usage shape (2026-11-10): weekly_scoped_shares indexes the raw limits array. +const fableShareBody = (fable: number, weekly: number, shares: unknown, extraFirst: unknown[] = []) => ({ + limits: [...extraFirst, { kind: "session", percent: 3, is_active: true }, { kind: "weekly_all", percent: weekly, is_active: true }, + { kind: "weekly_scoped", percent: fable, is_active: true, resets_at: weeklyReset, scope: { model: { id: null, display_name: "Fable" }, surface: null } }], + weekly_scoped_shares: shares, +}); + +test("Fable carries its weekly allowance and ranks on the weekly scale", async () => { + const tight = await parse(fableShareBody(60, 10, [{ limit_index: 2, used_percent_of_weekly: 30, allowance_percent_of_weekly: 50 }])); + expect(tight?.customWindows).toEqual([{ label: "Fable", scope: "model", percent: 60, resetAt: Date.parse(weeklyReset), weeklyAllowancePercent: 50 }]); + // 60% of a half-week allowance leaves 20 weekly points: rank at 80, while exhaustion stays at the raw 100. + expect(anthropicModelRankPercent(tight, "claude-fable-5-2")).toBe(80); + expect(anthropicModelWeeklyPercent(tight, "claude-fable-5-2")).toBe(60); + const roomy = await parse(fableShareBody(0, 70, [{ limit_index: 2, used_percent_of_weekly: 0, allowance_percent_of_weekly: 50 }])); + const rank = (quota: typeof tight) => Math.max(quota!.weeklyPercent!, anthropicModelRankPercent(quota, "claude-fable-5-2")!); + // Real Fable headroom: tight min(20, 90) = 20, roomy min(50, 30) = 30, so roomy must rank first. + expect(rank(roomy)).toBeLessThan(rank(tight)); + expect(anthropicModelRankPercent(roomy, "claude-sonnet-5-5")).toBeUndefined(); + expect(anthropicModelPercents(tight, "claude-fable-5-2").some(percent => percent >= 100)).toBe(false); + const spent = await parse(fableShareBody(100, 40, [{ limit_index: 2, allowance_percent_of_weekly: 50 }])); + expect(anthropicModelRankPercent(spent, "claude-fable-5-2")).toBe(100); +}); + +test("weekly share follows the raw limits index, including skipped leading rows", async () => { + const quota = await parse(fableShareBody(20, 10, [{ limit_index: 3, allowance_percent_of_weekly: 50 }], [{ kind: "future_kind", percent: 1 }])); + expect(quota?.customWindows?.[0]?.weeklyAllowancePercent).toBe(50); +}); + +for (const [name, shares] of [ + ["a shared row", [{ limit_index: 1, allowance_percent_of_weekly: 50 }]], + ["an out-of-range index", [{ limit_index: 9, allowance_percent_of_weekly: 50 }]], + ["a fractional index", [{ limit_index: 2.5, allowance_percent_of_weekly: 50 }]], + ["a zero allowance", [{ limit_index: 2, allowance_percent_of_weekly: 0 }]], + ["an allowance above the week", [{ limit_index: 2, allowance_percent_of_weekly: 150 }]], + ["a malformed allowance", [{ limit_index: 2, allowance_percent_of_weekly: "x" }]], + ["conflicting shares", [{ limit_index: 2, allowance_percent_of_weekly: 50 }, { limit_index: 2, allowance_percent_of_weekly: 40 }]], + ["a non-array", { limit_index: 2, allowance_percent_of_weekly: 50 }], +] as const) test(`weekly share ignores ${name}`, async () => { + const quota = await parse(fableShareBody(60, 10, shares)); + expect(quota?.customWindows?.[0]).toEqual({ label: "Fable", scope: "model", percent: 60, resetAt: Date.parse(weeklyReset) }); + expect(anthropicModelRankPercent(quota, "claude-fable-5-2")).toBe(60); +}); + +test("an inactive Fable row stays unpublished even with a share", async () => { + const body = { ...ultraUsage, weekly_scoped_shares: [{ limit_index: 1, used_percent_of_weekly: 0, allowance_percent_of_weekly: 50 }] }; + expect((await parse(body))?.customWindows ?? []).toEqual([]); +}); + +test("a passive Fable header reading keeps the probed weekly allowance", () => { + const headers = new Headers({ "anthropic-ratelimit-unified-7d_oi-utilization": "0.3" }); + const merged = mergeAnthropicFamilyWindows([{ label: "Fable", scope: "model", percent: 10, weeklyAllowancePercent: 50 }], + parseAnthropicFamilyHeaders(headers, now)); + expect(merged).toEqual([{ label: "Fable", scope: "model", percent: 30, passiveObservedAt: now, weeklyAllowancePercent: 50 }]); +}); + From 3de1390d31d9feff5dbb027e688970a31ee291bc Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 10 Nov 2026 16:58:52 +0900 Subject: [PATCH 2/3] fix(anthropic): keep five-hour ranking order for fresh Fable seats Five-hour pool ranking has no five-hour tie-break, so the weekly-scale Fable figure (50 for a fresh seat) masked every lower five-hour reading. Apply the conversion only under the weekly and max-utilization windows. --- src/oauth/anthropic-routing.ts | 9 +++++++-- structure/providers/anthropic-account-pool.md | 8 +++++--- .../anthropic/anthropic-account-threshold.test.ts | 12 ++++++++++++ 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/oauth/anthropic-routing.ts b/src/oauth/anthropic-routing.ts index ecfc5d8eb24..36ffe9b0587 100644 --- a/src/oauth/anthropic-routing.ts +++ b/src/oauth/anthropic-routing.ts @@ -584,11 +584,16 @@ function createAnthropicRouting(instance: AnthropicInstanceId): AnthropicRouting } } - /** `rank` compares accounts, restating a family allowance on the weekly scale; thresholds use the family's own percent. */ + /** + * `rank` compares accounts, restating a family allowance on the weekly scale where the configured + * window ranks on weekly usage. Five-hour ranking has no five-hour tie-break, so a fresh Fable + * seat's 50 would mask every lower five-hour reading there; it and thresholds keep the family's own percent. + */ function usageScore(config: OcxConfig, accountId: string, model?: string, rank = false): number { if (model) { const quota = getCachedProviderAccountQuota(PROVIDER, accountId); - const family = rank ? anthropicModelRankPercent(quota, model) : anthropicModelWeeklyPercent(quota, model); + const weeklyScale = rank && anthropicQuotaWindow(anthropicAccountPoolConfig(config)) !== "five-hour"; + const family = weeklyScale ? anthropicModelRankPercent(quota, model) : anthropicModelWeeklyPercent(quota, model); if (family === undefined) return usageScore(config, accountId); return hasKnownUsage(config, accountId) ? Math.max(family, usageScore(config, accountId)) : family; } diff --git a/structure/providers/anthropic-account-pool.md b/structure/providers/anthropic-account-pool.md index 9de88cfe163..d4d55516598 100644 --- a/structure/providers/anthropic-account-pool.md +++ b/structure/providers/anthropic-account-pool.md @@ -488,9 +488,11 @@ statuses retain soft utilization without creating a hard family refusal. strategy, reactive selection and physical dispatch use that model. A family-only refusal preserves unrelated sessions and account-wide health. Numeric thresholds stay soft: zero and the all-drained fallback remain preferences, with no hard billing cap introduced. -Lowest-usage ranking restates a family window that carries a weekly allowance on the shared -weekly scale (`anthropicModelRankPercent`): Fable at 60% of a 50% allowance ranks at 80, -because it has 20 weekly points left. Auto-switch thresholds keep the family's own percent, so +Under the `weekly` and `max-utilization` windows, lowest-usage ranking restates a family window +that carries a weekly allowance on the shared weekly scale (`anthropicModelRankPercent`): Fable +at 60% of a 50% allowance ranks at 80, because it has 20 weekly points left. The default +`five-hour` window has no five-hour tie-break, so it keeps the family's own percent rather than +let a fresh seat's 50 mask lower five-hour readings. Auto-switch thresholds keep it too, so a fresh Fable seat does not read as half spent. Exhaustion is unchanged, since both scales reach 100 together, and a passive header reading keeps the allowance the last usage probe read. `tests/adapters/anthropic/anthropic-account-threshold.test.ts` covers both sides. diff --git a/tests/adapters/anthropic/anthropic-account-threshold.test.ts b/tests/adapters/anthropic/anthropic-account-threshold.test.ts index 9a954ab8aee..c0fe6c58efd 100644 --- a/tests/adapters/anthropic/anthropic-account-threshold.test.ts +++ b/tests/adapters/anthropic/anthropic-account-threshold.test.ts @@ -332,3 +332,15 @@ test("Fable ranks accounts on weekly headroom while thresholds read the Fable me } }); + +test("five-hour ranking keeps five-hour order for fresh Fable seats", async () => { + const [a, b, c] = ids; const resetAt = Date.now() + 3600_000; + const seat = (id: string, fiveHour: number) => setCachedProviderAccountQuotaForTests("anthropic", id, { fiveHourPercent: fiveHour, + weeklyPercent: 0, fiveHourResetAt: resetAt, weeklyResetAt: resetAt, updatedAt: Date.now(), + customWindows: [{ label: "Fable", scope: "model", percent: 0, resetAt, weeklyAllowancePercent: 50 }] }); + seat(a, 95); seat(b, 49); seat(c, 1); + const cfg = config("quota", "five-hour"); + await setActiveAccount("anthropic", a); + expect(resolveAnthropicAccountForSession("fable-5h", cfg, Date.now(), null, FABLE).accountId).toBe(c); +}); + From 3aa1328ea804d03de8ab04720f1a141960e5bb72 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 10 Nov 2026 17:04:32 +0900 Subject: [PATCH 3/3] fix(anthropic): carry the weekly allowance only onto header readings A usage probe that reports no share is itself the current reading, so the merge must not restore the previous allowance onto it. --- src/providers/quota/anthropic-family-headers.ts | 3 ++- tests/providers/provider-anthropic-usage-windows.test.ts | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/providers/quota/anthropic-family-headers.ts b/src/providers/quota/anthropic-family-headers.ts index 152dfcef20b..d2db80a2f03 100644 --- a/src/providers/quota/anthropic-family-headers.ts +++ b/src/providers/quota/anthropic-family-headers.ts @@ -16,8 +16,9 @@ export function parseAnthropicFamilyHeaders(headers: Headers, now: number, statu export function mergeAnthropicFamilyWindows(previous: ProviderQuotaWindow[] = [], observed: ProviderQuotaWindow[] = []): ProviderQuotaWindow[] { const same = (a: ProviderQuotaWindow, b: ProviderQuotaWindow) => a.label === b.label && a.scope === b.scope; // Headers carry no allowance; the share is a seat policy the last usage probe already read. + // A probe window (no passiveObservedAt) is itself that reading, so a missing share stays missing. return [...previous.filter(window => !observed.some(update => same(update, window))), ...observed.map(update => { - const allowance = update.weeklyAllowancePercent === undefined + const allowance = update.weeklyAllowancePercent === undefined && update.passiveObservedAt !== undefined ? previous.find(window => same(window, update))?.weeklyAllowancePercent : undefined; return allowance === undefined ? update : { ...update, weeklyAllowancePercent: allowance }; })]; diff --git a/tests/providers/provider-anthropic-usage-windows.test.ts b/tests/providers/provider-anthropic-usage-windows.test.ts index 4b0a8ea9a2d..6b3dca6fae6 100644 --- a/tests/providers/provider-anthropic-usage-windows.test.ts +++ b/tests/providers/provider-anthropic-usage-windows.test.ts @@ -235,3 +235,10 @@ test("a passive Fable header reading keeps the probed weekly allowance", () => { expect(merged).toEqual([{ label: "Fable", scope: "model", percent: 30, passiveObservedAt: now, weeklyAllowancePercent: 50 }]); }); + +test("a usage probe without a share drops the previous weekly allowance", () => { + const merged = mergeAnthropicFamilyWindows([{ label: "Fable", scope: "model", percent: 10, weeklyAllowancePercent: 50 }], + [{ label: "Fable", scope: "model", percent: 20 }]); + expect(merged).toEqual([{ label: "Fable", scope: "model", percent: 20 }]); +}); +