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
12 changes: 12 additions & 0 deletions src/oauth/anthropic-model-quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
15 changes: 11 additions & 4 deletions src/oauth/anthropic-routing.ts
Original file line number Diff line number Diff line change
@@ -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).
*
Expand Down Expand Up @@ -584,9 +584,16 @@ 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 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 family = anthropicModelWeeklyPercent(getCachedProviderAccountQuota(PROVIDER, accountId), model);
const quota = getCachedProviderAccountQuota(PROVIDER, accountId);
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;
}
Expand Down Expand Up @@ -804,7 +811,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.
Expand Down
5 changes: 3 additions & 2 deletions src/providers/claude-cli-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,16 @@ export async function probeAnthropicUsageQuota(accessToken: string): Promise<Ant
const { buckets, familyEnumeration } = anthropicUsageBuckets(body);
const shared: Partial<ProviderQuota> = {};
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") {
if (percent !== undefined) shared[window === "5h" ? "fiveHourPercent" : "weeklyPercent"] = percent;
if (resetAt !== undefined) shared[window === "5h" ? "fiveHourResetAt" : "weeklyResetAt"] = resetAt;
} else if (percent !== undefined) {
const label = { fable: "Fable", opus: "Opus", sonnet: "Sonnet" }[window];
customWindows.push({ label, scope: "model", percent, ...(resetAt !== undefined ? { resetAt } : {}) });
customWindows.push({ label, scope: "model", percent, ...(resetAt !== undefined ? { resetAt } : {}),
...(weeklyAllowancePercent !== undefined ? { weeklyAllowancePercent } : {}) });
}
}
const quota: ProviderQuota = {
Expand Down
7 changes: 7 additions & 0 deletions src/providers/quota-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ export interface ProviderQuotaWindow {
* provider-wide group named "Opus" there would otherwise be mistaken for a per-model window.
*/
scope?: "model";
/**
* Share of the provider-wide weekly limit a model-scoped window may consume, in percent:
* Anthropic's `weekly_scoped_shares[].allowance_percent_of_weekly` (Fable is "up to 50% of
* your weekly limit"). `percent` stays relative to this allowance. Read from upstream, never
* inferred from a plan; ranking restates family usage on the weekly scale with it.
*/
weeklyAllowancePercent?: number;
/** Passive family readings have their own freshness clock, independent of active probes. */
passiveObservedAt?: number;
/** Upstream refusal evidence, distinct from a measured utilization counter. */
Expand Down
9 changes: 8 additions & 1 deletion src/providers/quota/anthropic-family-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@ export function parseAnthropicFamilyHeaders(headers: Headers, now: number, statu
...(resetAt !== undefined && resetAt > 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.
// 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 && update.passiveObservedAt !== undefined
? previous.find(window => same(window, update))?.weeklyAllowancePercent : undefined;
return allowance === undefined ? update : { ...update, weeklyAllowancePercent: allowance };
})];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

const enumerated = new WeakSet<object>();
Expand Down
51 changes: 49 additions & 2 deletions src/providers/quota/anthropic-usage-windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export interface AnthropicUsageBucket {
window: UsageWindow;
row: Record<string, unknown>;
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;
Expand All @@ -18,6 +20,47 @@ function scopedFamily(row: Record<string, unknown>): "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<string, unknown>, scopedRows: Map<number, AnthropicUsageBucket>): void {
if (!Array.isArray(body.weekly_scoped_shares)) return;
const conflicted = new Set<AnthropicUsageBucket>();
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
Expand All @@ -30,9 +73,10 @@ export function anthropicUsageBuckets(body: Record<string, unknown>): {
} {
const selected = new Map<UsageWindow, AnthropicUsageBucket>();
const represented = new Set<UsageWindow>();
const scopedRows = new Map<number, AnthropicUsageBucket>();
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;
Expand All @@ -50,8 +94,11 @@ export function anthropicUsageBuckets(body: Record<string, unknown>): {
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]]);
Expand Down
14 changes: 13 additions & 1 deletion structure/providers/anthropic-account-pool.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -484,6 +488,14 @@ 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.
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.

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
Expand Down
33 changes: 33 additions & 0 deletions tests/adapters/anthropic/anthropic-account-threshold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,3 +311,36 @@ 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);
}
});


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);
});

Loading
Loading