From 4b47ac50f39a48f159c0c04b0bb275caf20dd4d4 Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:52:36 +0100 Subject: [PATCH 01/10] fix(usage): Claude reset badges only show on the limits they clear Claude's reset credits come as grants that each list the usage windows they clear. The Limits UI treated every credit as clearing every window, so a Fable-only reset was badged on Session and Weekly too. The server now forwards each grant's cleared windows and gives_back_weekly, clients count credits per window, and redeeming spends the chosen grant. Fixes #33509 Co-Authored-By: Claude Opus 5.5 --- .../src/features/usage/UsageLimitsPooled.tsx | 19 ++++++- apps/server/src/provider/ClaudeProvider.ts | 10 +++- .../src/provider/Drivers/ClaudeDriver.ts | 4 +- .../src/provider/claudeResetCredits.test.ts | 17 ++++-- .../server/src/provider/claudeResetCredits.ts | 16 +++++- apps/server/src/provider/claudeUsageLimits.ts | 7 +++ .../components/usage/UsageLimitsPooled.tsx | 22 +++++-- packages/contracts/src/providerUsageLimits.ts | 2 + packages/shared/src/usageLimits.test.ts | 57 +++++++++++++++++++ packages/shared/src/usageLimits.ts | 33 +++++++++++ 10 files changed, 170 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 218a7f21feb18..64d64759d9e8d 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -15,6 +15,7 @@ import { remainingPercent, type LimitAccount, type LimitPoolWindow, + resetCreditsForWindow, } from "@t3tools/shared/usageLimits"; import { Fragment, type ReactNode, useId, useState } from "react"; import { Linking, Pressable, View } from "react-native"; @@ -235,7 +236,11 @@ function PoolWindowCard({ ); const { account, window } = member; const timeLeft = paceRemainingPercent(window, now); - const credits = account.limits.resetCredits?.availableCount ?? 0; + const { count: credits, scope } = resetCreditsForWindow( + account.limits.resetCredits, + account.limits.windows, + window.id, + ); const resetsIn = formatResetsIn(window, now)?.replace("resets in ", ""); return ( {credits} + {scope ? ( + {scope} + ) : null} ) : null} @@ -323,13 +331,17 @@ function PoolWindowCard({ {pool.columns.map(({ account, window }, index) => { if (!window) return null; const segmentColor = normalizeProviderAccentColor(account.accentColor) ?? color; - const credits = account.limits.resetCredits?.availableCount ?? 0; + const { count: credits, scope } = resetCreditsForWindow( + account.limits.resetCredits, + account.limits.windows, + window.id, + ); const resetsIn = formatResetsIn(window, now); return ( openAccount(account)} className="min-h-[44px] flex-row items-center gap-2 active:opacity-60" @@ -373,6 +385,7 @@ function PoolWindowCard({ {credits} + {scope ? {scope} : null} ) : null} diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 0d669659a09ce..5d0d86897a69f 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -657,7 +657,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, /** Shared with the adapter so turn events reuse the scoped-bucket names this probe saw. */ scopedLimitNames?: Ref.Ref, - resolveResetCredits?: (version: string) => Effect.Effect, + resolveResetCredits?: ( + version: string, + overageIncluded: string | undefined, + ) => Effect.Effect, ): Effect.fn.Return< ServerProviderDraft, never, @@ -867,7 +870,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( capabilities.subscriptionType && !usageLimits.unavailable && parsedVersion - ? yield* resolveResetCredits(parsedVersion) + ? yield* resolveResetCredits( + parsedVersion, + scopedLimitNames ? (yield* Ref.get(scopedLimitNames)).overageIncluded : undefined, + ) : undefined; return buildServerProvider({ presentation: CLAUDE_PRESENTATION, diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index d75b1cad86402..9128bef588e62 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -274,8 +274,8 @@ export const ClaudeDriver: ProviderDriver - ClaudeResetCredits.readClaudeResetCredits(configDir, version).pipe( + (version, overageIncluded) => + ClaudeResetCredits.readClaudeResetCredits(configDir, version, overageIncluded).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), diff --git a/apps/server/src/provider/claudeResetCredits.test.ts b/apps/server/src/provider/claudeResetCredits.test.ts index a3a1110d87d34..63ceba28db31e 100644 --- a/apps/server/src/provider/claudeResetCredits.test.ts +++ b/apps/server/src/provider/claudeResetCredits.test.ts @@ -86,23 +86,30 @@ describe("claudeResetCreditsToContract", () => { gives_back_weekly: true, ends_at: "2027-03-09T07:59:00Z", }), - grant({ id: "fable_b", gives_back_weekly: true, type: "weekly" }), + grant({ + id: "fable_b", + clears: ["seven_day_overage_included"], + gives_back_weekly: true, + type: "weekly", + }), grant({ id: "session_a", type: "session" }), grant({ id: "spent", clears: ["seven_day"], resets_left: 0 }), ], }, NOW, + "Fable", ); expect( - credits?.options?.map(({ creditId, label, availableCount }) => [ + credits?.options?.map(({ creditId, label, availableCount, clearsWindowIds }) => [ creditId, label, availableCount, + clearsWindowIds, ]), ).toEqual([ - ["session_a", "5-hour reset", 1], - ["weekly_a", "Weekly reset", 1], - ["fable_a", "Fable reset", 2], + ["session_a", "5-hour reset", 1, undefined], + ["weekly_a", "Weekly reset", 1, ["seven_day"]], + ["fable_a", "Fable reset", 2, ["seven_day_fable"]], ]); expect(credits).toMatchObject({ availableCount: 4, nextCreditId: "weekly_a" }); }); diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index d2000804cf4f6..6a930bd49952c 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -12,6 +12,8 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import { claudeUsageWindowId } from "./claudeUsageLimits.ts"; + const API_BASE = "https://api.anthropic.com"; const PROGRAM = "cedar_ember"; const GRANT_ID = /^[a-z0-9_-]{1,40}$/; @@ -130,6 +132,7 @@ const grantKind = (grant: typeof Grant.Type): ResetKind => { export function claudeResetCreditsToContract( block: unknown, nowMs: number, + overageIncluded?: string, ): ServerProviderResetCredits | undefined { const parsed = decodeCedarEmber(block); if (Option.isNone(parsed) || !parsed.value.eligible) return undefined; @@ -158,12 +161,22 @@ export function claudeResetCreditsToContract( const target = grants?.[0]; if (!grants || !target) return []; const optionExpiresAt = expiresAt(target); + const clears = grants.every((grant) => grant.clears?.length) + ? new Set( + grants.flatMap((grant) => + (grant.clears ?? []).flatMap((bucket) => + typeof bucket === "string" ? [claudeUsageWindowId(bucket, overageIncluded)] : [], + ), + ), + ) + : undefined; return [ { creditId: target.id, ...RESET_KINDS[kind], availableCount: grants.reduce((sum, grant) => sum + grant.resets_left, 0), ...(optionExpiresAt ? { expiresAt: optionExpiresAt } : {}), + ...(clears?.size ? { clearsWindowIds: [...clears] } : {}), }, ]; }, @@ -208,7 +221,7 @@ const withClaudeHeaders = (token: string, version: string) => }); export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( - function* (configDir: string, version: string) { + function* (configDir: string, version: string, overageIncluded?: string) { const token = yield* readAccessToken(configDir); if (!token) return undefined; const client = yield* HttpClient.HttpClient; @@ -223,6 +236,7 @@ export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( return claudeResetCreditsToContract( body.cedar_ember, DateTime.toEpochMillis(yield* DateTime.now), + overageIncluded, ); }, Effect.timeout("10 seconds"), diff --git a/apps/server/src/provider/claudeUsageLimits.ts b/apps/server/src/provider/claudeUsageLimits.ts index ba1263ef704bb..f4a043b8fae95 100644 --- a/apps/server/src/provider/claudeUsageLimits.ts +++ b/apps/server/src/provider/claudeUsageLimits.ts @@ -63,6 +63,13 @@ export function windowLimitsModel( return model !== null && scopeToken(model).includes(window.id.slice(SCOPED_WINDOW_PREFIX.length)); } +/** Maps a usage bucket named by Claude's API to the id of the window shown for it. */ +export function claudeUsageWindowId(bucket: string, overageIncluded: string | undefined): string { + return bucket === OVERAGE_INCLUDED_EVENT_TYPE && overageIncluded + ? scopedWindowId(overageIncluded) + : bucket; +} + function scopedWindow( displayName: string, usedPercent: number, diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 8ec1c7a02afcd..b5a12bf6722e1 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -15,6 +15,7 @@ import { remainingPercent, type ResetCreditChoice, resetCreditChoices, + resetCreditsForWindow, } from "@t3tools/shared/usageLimits"; import type { Popover as PopoverPrimitive } from "@base-ui/react/popover"; import { normalizeProviderAccentColor } from "@t3tools/client-runtime/state/provider-instance-display"; @@ -349,7 +350,11 @@ function SegmentTriggers({ const timeLeft = paceRemainingPercent(window, now); const [segmentRef, paceMark] = usePaceMark(timeLeft); const resetsIn = formatResetsIn(window, now); - const credits = account.limits.resetCredits?.availableCount ?? 0; + const { count: credits, scope } = resetCreditsForWindow( + account.limits.resetCredits, + account.limits.windows, + window.id, + ); return ( <> } @@ -425,6 +430,9 @@ function SegmentTriggers({ {credits} + {scope ? ( + {scope} + ) : null} ) : null} @@ -452,7 +460,11 @@ function LegendRow({ const { account, window } = payload; const remaining = remainingPercent(window); const resetsIn = formatResetsIn(window, now); - const credits = account.limits.resetCredits?.availableCount ?? 0; + const { count: credits, scope } = resetCreditsForWindow( + account.limits.resetCredits, + account.limits.windows, + window.id, + ); return ( {credits} + {scope ? {scope} : null} - {credits} reset {credits === 1 ? "credit" : "credits"} banked + {credits} {scope ? `${scope} ` : ""}reset {credits === 1 ? "credit" : "credits"}{" "} + banked ) : null} diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 446d4f1f31849..d8fdcb958156f 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -30,6 +30,8 @@ export const ServerProviderResetCreditOption = Schema.Struct({ description: TrimmedNonEmptyString, availableCount: NonNegativeInt, expiresAt: Schema.optional(IsoDateTime), + /** Usage window ids this reset clears. Absent means it clears every window. */ + clearsWindowIds: Schema.optional(Schema.Array(TrimmedNonEmptyString)), }); export type ServerProviderResetCreditOption = typeof ServerProviderResetCreditOption.Type; diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index b66001ca19244..0f15ccf5484e8 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -25,6 +25,7 @@ import { paceRemainingPercent, usesChatGptSharing, resolveNewThreadModelSelection, + resetCreditsForWindow, } from "./usageLimits.ts"; const now = Date.parse("2026-09-03T12:00:00.000Z"); @@ -1837,3 +1838,59 @@ describe("ChatGPT sharing presentation", () => { ).toBe(false); }); }); + +describe("reset credits per window", () => { + const windows = [ + window, + { ...window, id: "seven_day", kind: "weekly", label: "Weekly" }, + { ...window, id: "seven_day_fable", kind: "weekly", label: "Weekly · Fable" }, + { id: "monthly_spend", kind: "monthly", label: "Monthly spend", usedPercent: 10 }, + ] as const; + const option = (creditId: string, availableCount: number, clearsWindowIds?: string[]) => ({ + creditId, + label: creditId, + description: creditId, + availableCount, + ...(clearsWindowIds ? { clearsWindowIds } : {}), + }); + const credits = { + availableCount: 4, + nextCreditId: "fable", + options: [ + option("fable", 1, ["five_hour", "seven_day_fable"]), + option("weekly", 2, ["seven_day"]), + option("everything", 1, ["five_hour", "seven_day", "seven_day_fable"]), + ], + }; + + it("counts each reset only on the windows it clears", () => { + expect(resetCreditsForWindow(credits, windows, "seven_day")).toEqual({ + count: 3, + scope: undefined, + }); + expect(resetCreditsForWindow(credits, windows, "five_hour")).toEqual({ + count: 1, + scope: undefined, + }); + expect( + resetCreditsForWindow( + { ...credits, options: credits.options.slice(0, 2) }, + windows, + "seven_day_fable", + ), + ).toEqual({ count: 1, scope: "Fable" }); + }); + + it("counts resets without clears details against every window", () => { + expect( + resetCreditsForWindow({ availableCount: 2, nextCreditId: "grant_a" }, windows, "seven_day"), + ).toEqual({ count: 2, scope: undefined }); + expect( + resetCreditsForWindow( + { availableCount: 1, options: [option("grant_a", 1)] }, + windows, + "seven_day_fable", + ), + ).toEqual({ count: 1, scope: undefined }); + }); +}); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 6fe6c70a89a4f..b579cba0fc3b9 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -1025,3 +1025,36 @@ export function collectProviderUsageLimits( } return { createdAt: DateTime.formatIso(DateTime.makeUnsafe(now)), accounts, notices }; } + +export interface WindowResetCredits { + readonly count: number; + /** Set when every counted reset only clears part of the account, e.g. "Fable". */ + readonly scope: string | undefined; +} + +/** Resets without a clears list count against every window. */ +export function resetCreditsForWindow( + credits: ServerProviderResetCredits | undefined, + windows: ReadonlyArray, + windowId: string, +): WindowResetCredits { + if (!credits?.options) return { count: credits?.availableCount ?? 0, scope: undefined }; + const resettable = windows.filter((window) => window.kind !== "monthly"); + const scopeOf = (window: ServerProviderUsageWindow) => window.label.split(" · ")[1]; + let count = 0; + const scopes = new Set(); + for (const option of credits.options) { + const clears = option.clearsWindowIds; + let scope: string | undefined; + if (clears) { + const listed = resettable.filter((window) => clears.includes(window.id)); + scope = listed.length < resettable.length ? listed.map(scopeOf).find(Boolean) : undefined; + // Claude lists the shared Session in a model-scoped reset's clears, but redeeming leaves it. + const cleared = scope ? listed.filter((window) => scopeOf(window) === scope) : listed; + if (!cleared.some((window) => window.id === windowId)) continue; + } + count += option.availableCount; + scopes.add(scope); + } + return { count, scope: scopes.size === 1 ? [...scopes][0] : undefined }; +} From 117e4faaa0837c003280a253218635a123cc4338 Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:50:27 +0100 Subject: [PATCH 02/10] fix(usage): a reset that clears Weekly is no longer counted as Fable-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reset listing Weekly and Weekly · Fable but not Session was treated as model-scoped, so Weekly got no badge. A reset is now model-scoped only when it lists no all-models weekly window. Co-Authored-By: Claude Fable 5.2 --- packages/shared/src/usageLimits.test.ts | 10 ++++++++++ packages/shared/src/usageLimits.ts | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 0f15ccf5484e8..24f70c9408967 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1879,6 +1879,16 @@ describe("reset credits per window", () => { "seven_day_fable", ), ).toEqual({ count: 1, scope: "Fable" }); + const mixed = { + ...credits, + options: [ + option("full", 1, ["seven_day", "seven_day_fable"]), + option("fable", 2, ["seven_day_fable"]), + ], + }; + expect(resetCreditsForWindow(mixed, windows, "seven_day").count).toBe(1); + expect(resetCreditsForWindow(mixed, windows, "seven_day_fable").count).toBe(3); + expect(resetCreditsForWindow(mixed, windows, "five_hour").count).toBe(0); }); it("counts resets without clears details against every window", () => { diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index b579cba0fc3b9..0933e4eaddbdb 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -1048,7 +1048,9 @@ export function resetCreditsForWindow( let scope: string | undefined; if (clears) { const listed = resettable.filter((window) => clears.includes(window.id)); - scope = listed.length < resettable.length ? listed.map(scopeOf).find(Boolean) : undefined; + scope = listed.some((window) => window.kind === "weekly" && !scopeOf(window)) + ? undefined + : listed.map(scopeOf).find(Boolean); // Claude lists the shared Session in a model-scoped reset's clears, but redeeming leaves it. const cleared = scope ? listed.filter((window) => scopeOf(window) === scope) : listed; if (!cleared.some((window) => window.id === windowId)) continue; From d7943a0e975b0815bf820bfe836c25a1c0087c60 Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:58:38 +0100 Subject: [PATCH 03/10] fix(usage): Claude reset grants are cached on disk for two minutes Every provider check fetched the grants from Claude's usage endpoint, and so did every server start. A dev server restarting on each edit got the account rate limited while testing this branch. The last response is kept in the state dir, keyed by login and CLI version, and dropped on a manual refresh or after redeeming a reset. Co-Authored-By: Claude Fable 5.2 --- .../src/provider/Drivers/ClaudeDriver.ts | 21 ++++++-- .../src/provider/claudeResetCredits.test.ts | 37 +++++++++++++ .../server/src/provider/claudeResetCredits.ts | 53 ++++++++++++++++--- 3 files changed, 100 insertions(+), 11 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 9128bef588e62..9fbe3941d53f9 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -141,6 +141,10 @@ export const ClaudeDriver: ProviderDriver - ClaudeResetCredits.readClaudeResetCredits(configDir, version, overageIncluded).pipe( + ClaudeResetCredits.readClaudeResetCredits( + configDir, + version, + overageIncluded, + resetCreditsCacheFile, + ).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), @@ -297,6 +306,10 @@ export const ClaudeDriver: ProviderDriver>({ resolveMaintenance, @@ -309,7 +322,7 @@ export const ClaudeDriver: ProviderDriver resolveMaintenance().pipe( Effect.flatMap((maintenanceCapabilities) => @@ -382,9 +395,7 @@ export const ClaudeDriver: ProviderDriver confirmed, diff --git a/apps/server/src/provider/claudeResetCredits.test.ts b/apps/server/src/provider/claudeResetCredits.test.ts index 63ceba28db31e..747995e7675a3 100644 --- a/apps/server/src/provider/claudeResetCredits.test.ts +++ b/apps/server/src/provider/claudeResetCredits.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse, UrlParams } from "effect/http"; import { describe, expect, it } from "vite-plus/test"; @@ -164,6 +165,42 @@ effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { }), ); + it.effect("serves a recent read from disk until it is forgotten or expires", () => + Effect.gen(function* () { + const { configDir } = yield* writeLogin; + const cacheFile = yield* ClaudeResetCredits.claudeResetCreditsCacheFile(configDir, configDir); + let requests = 0; + const client = HttpClient.make((request) => { + requests += 1; + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + cedar_ember: { eligible: true, next_grant_id: "grant_a", grants: [grant({})] }, + }), + ), + ); + }); + const read = (version = "2.1.0") => + ClaudeResetCredits.readClaudeResetCredits(configDir, version, undefined, cacheFile).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HttpClient.HttpClient, client), + ); + yield* read(); + expect(yield* read()).toMatchObject({ availableCount: 1, nextCreditId: "grant_a" }); + expect(requests).toBe(1); + yield* read("2.2.0"); + yield* read("2.2.0"); + expect(requests).toBe(2); + yield* ClaudeResetCredits.forgetClaudeResetCredits(cacheFile); + yield* read("2.2.0"); + expect(requests).toBe(3); + yield* TestClock.adjust("2 minutes"); + yield* read("2.2.0"); + expect(requests).toBe(4); + }), + ); + it.effect("reads nothing from keychain logins or failed requests", () => Effect.gen(function* () { const { configDir } = yield* writeLogin; diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 6a930bd49952c..9e840c359de16 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -1,3 +1,6 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- a synchronous hash for the cache key and file name. +import * as NodeCrypto from "node:crypto"; + import type { ProviderConsumeResetCreditOutcome, ServerProviderResetCreditOption, @@ -51,6 +54,13 @@ const UsageResponse = Schema.Struct({ cedar_ember: Schema.optional(Schema.NullOr(Schema.Unknown)), }); const decodeCedarEmber = Schema.decodeUnknownOption(CedarEmber); +const GRANTS_CACHE_TTL_MS = 2 * 60_000; +const CachedGrants = Schema.fromJsonString( + Schema.Struct({ at: Schema.Number, key: Schema.String, block: Schema.Unknown }), +); +const decodeCachedGrants = Schema.decodeEffect(CachedGrants); +const encodeCachedGrants = Schema.encodeEffect(CachedGrants); +const sha256 = (value: string) => NodeCrypto.createHash("sha256").update(value).digest("hex"); const ClaimResponse = Schema.Struct({ result: Schema.Literals([ "reset", @@ -220,10 +230,39 @@ const withClaudeHeaders = (token: string, version: string) => "user-agent": `claude-cli/${version} (external, cli)`, }); +export const claudeResetCreditsCacheFile = Effect.fnUntraced(function* ( + stateDir: string, + configDir: string, +) { + const path = yield* Path.Path; + return path.join(stateDir, `claude-reset-credits-${sha256(configDir).slice(0, 16)}.json`); +}); + +export const forgetClaudeResetCredits = Effect.fnUntraced(function* (cacheFile: string) { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(cacheFile, { force: true }).pipe(Effect.ignore); +}); + +// The cache is on disk because a restart refetches, and a dev server restarting on each edit gets +// the account rate limited. export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( - function* (configDir: string, version: string, overageIncluded?: string) { + function* (configDir: string, version: string, overageIncluded?: string, cacheFile?: string) { const token = yield* readAccessToken(configDir); if (!token) return undefined; + const fs = yield* FileSystem.FileSystem; + const now = DateTime.toEpochMillis(yield* DateTime.now); + const key = sha256(`${token}\n${version}`); + const cached = cacheFile + ? yield* fs.readFileString(cacheFile).pipe(Effect.flatMap(decodeCachedGrants), Effect.option) + : Option.none(); + if ( + Option.isSome(cached) && + cached.value.key === key && + now >= cached.value.at && + now - cached.value.at < GRANTS_CACHE_TTL_MS + ) { + return claudeResetCreditsToContract(cached.value.block, now, overageIncluded); + } const client = yield* HttpClient.HttpClient; const response = yield* client.execute( HttpClientRequest.get(`${API_BASE}/api/oauth/usage`, { @@ -233,11 +272,13 @@ export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( const body = yield* HttpClientResponse.schemaBodyJson(UsageResponse)( yield* HttpClientResponse.filterStatusOk(response), ); - return claudeResetCreditsToContract( - body.cedar_ember, - DateTime.toEpochMillis(yield* DateTime.now), - overageIncluded, - ); + if (cacheFile) { + yield* encodeCachedGrants({ at: now, key, block: body.cedar_ember }).pipe( + Effect.flatMap((json) => fs.writeFileString(cacheFile, json)), + Effect.ignore, + ); + } + return claudeResetCreditsToContract(body.cedar_ember, now, overageIncluded); }, Effect.timeout("10 seconds"), Effect.orElseSucceed(() => undefined), From dd6dc667c9c88d4aab197bcbb6b702ab2d829979 Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:08:46 +0100 Subject: [PATCH 04/10] fix(usage): a model's limit row only counts that model's own resets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A full reset also clears Weekly · Fable, but counting it there made the Fable row read as if it held two Fable resets. It is now counted on Session and Weekly only. Co-Authored-By: Claude Fable 5.2 --- packages/shared/src/usageLimits.test.ts | 5 ++++- packages/shared/src/usageLimits.ts | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 24f70c9408967..fff1a1651baad 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1887,7 +1887,10 @@ describe("reset credits per window", () => { ], }; expect(resetCreditsForWindow(mixed, windows, "seven_day").count).toBe(1); - expect(resetCreditsForWindow(mixed, windows, "seven_day_fable").count).toBe(3); + expect(resetCreditsForWindow(mixed, windows, "seven_day_fable")).toEqual({ + count: 2, + scope: "Fable", + }); expect(resetCreditsForWindow(mixed, windows, "five_hour").count).toBe(0); }); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 0933e4eaddbdb..0cd8cf7f6e090 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -1052,7 +1052,8 @@ export function resetCreditsForWindow( ? undefined : listed.map(scopeOf).find(Boolean); // Claude lists the shared Session in a model-scoped reset's clears, but redeeming leaves it. - const cleared = scope ? listed.filter((window) => scopeOf(window) === scope) : listed; + // A model's row only counts that model's own resets, though a full reset clears it too. + const cleared = listed.filter((window) => scopeOf(window) === scope); if (!cleared.some((window) => window.id === windowId)) continue; } count += option.availableCount; From d07aaeff2ddfeb6cb42397573083ed363a2f876c Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:12:12 +0100 Subject: [PATCH 05/10] fix(usage): a full reset keeps its Session badge when Weekly is not shown The reset's scope was read from the windows on screen, so with the Weekly window missing from the usage response a full reset looked Fable-only. The scope now comes from the whole clears list: an id with no window rules out a model scope. Co-Authored-By: Claude Fable 5.2 --- packages/shared/src/usageLimits.test.ts | 2 ++ packages/shared/src/usageLimits.ts | 8 +++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index fff1a1651baad..475b1d0eed928 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1892,6 +1892,8 @@ describe("reset credits per window", () => { scope: "Fable", }); expect(resetCreditsForWindow(mixed, windows, "five_hour").count).toBe(0); + const withoutWeekly = windows.filter((shown) => shown.id !== "seven_day"); + expect(resetCreditsForWindow(credits, withoutWeekly, "five_hour").count).toBe(1); }); it("counts resets without clears details against every window", () => { diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 0cd8cf7f6e090..1bd5cf496002b 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -1048,9 +1048,11 @@ export function resetCreditsForWindow( let scope: string | undefined; if (clears) { const listed = resettable.filter((window) => clears.includes(window.id)); - scope = listed.some((window) => window.kind === "weekly" && !scopeOf(window)) - ? undefined - : listed.map(scopeOf).find(Boolean); + // An id with no window shown may be the all-models Weekly, so it rules out a model scope. + const modelOnly = clears.every((id) => + listed.some((window) => window.id === id && (scopeOf(window) || window.kind === "session")), + ); + scope = modelOnly ? listed.map(scopeOf).find(Boolean) : undefined; // Claude lists the shared Session in a model-scoped reset's clears, but redeeming leaves it. // A model's row only counts that model's own resets, though a full reset clears it too. const cleared = listed.filter((window) => scopeOf(window) === scope); From 0d21c89ccf55ed94c46c8e4c3fb7b6ab85fa2c23 Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:25:15 +0100 Subject: [PATCH 06/10] fix(usage): the server decides which limit rows a Claude reset is counted on Clients guessed a reset's model scope from window labels and from which windows happened to be shown. The server now sends the rows and the scope, taken from the grant's own clears list, or from its kind when the list is missing. Bucket names are trimmed before they reach the snapshot. The grants cache is also dropped on every forced refresh and after a failed claim, and it now holds a response that has no grants block. Co-Authored-By: Claude Fable 5.2 --- .../src/features/usage/UsageLimitsPooled.tsx | 2 - .../src/provider/Drivers/ClaudeDriver.ts | 9 ++-- .../src/provider/claudeResetCredits.test.ts | 42 +++++++++------ .../server/src/provider/claudeResetCredits.ts | 54 ++++++++++++------- apps/server/src/provider/claudeUsageLimits.ts | 2 +- .../components/usage/UsageLimitsPooled.tsx | 12 +---- packages/contracts/src/providerUsageLimits.ts | 4 +- packages/shared/src/usageLimits.test.ts | 53 +++++------------- packages/shared/src/usageLimits.ts | 28 ++-------- 9 files changed, 92 insertions(+), 114 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 64d64759d9e8d..54492deb7a186 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -238,7 +238,6 @@ function PoolWindowCard({ const timeLeft = paceRemainingPercent(window, now); const { count: credits, scope } = resetCreditsForWindow( account.limits.resetCredits, - account.limits.windows, window.id, ); const resetsIn = formatResetsIn(window, now)?.replace("resets in ", ""); @@ -333,7 +332,6 @@ function PoolWindowCard({ const segmentColor = normalizeProviderAccentColor(account.accentColor) ?? color; const { count: credits, scope } = resetCreditsForWindow( account.limits.resetCredits, - account.limits.windows, window.id, ); const resetsIn = formatResetsIn(window, now); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 9fbe3941d53f9..db2acacc5ea13 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -141,10 +141,11 @@ export const ClaudeDriver: ProviderDriver invalidateUsageCache), Effect.tap((outcome) => ResetCreditCoordinator.refreshAfterRedemption({ outcome, @@ -422,7 +425,7 @@ export const ClaudeDriver: ProviderDriver snapshot.getSnapshot.pipe( Effect.flatMap((machineSnapshot) => diff --git a/apps/server/src/provider/claudeResetCredits.test.ts b/apps/server/src/provider/claudeResetCredits.test.ts index 747995e7675a3..118675b93f775 100644 --- a/apps/server/src/provider/claudeResetCredits.test.ts +++ b/apps/server/src/provider/claudeResetCredits.test.ts @@ -83,7 +83,7 @@ describe("claudeResetCreditsToContract", () => { grant({ id: "weekly_a", clears: ["seven_day"], ends_at: "2026-12-10T22:00:00Z" }), grant({ id: "fable_a", - clears: ["seven_day_overage_included"], + clears: ["five_hour", "seven_day_overage_included"], gives_back_weekly: true, ends_at: "2027-03-09T07:59:00Z", }), @@ -94,6 +94,11 @@ describe("claudeResetCreditsToContract", () => { type: "weekly", }), grant({ id: "session_a", type: "session" }), + grant({ + id: "full_a", + type: "full", + clears: ["seven_day", "seven_day_overage_included", ""], + }), grant({ id: "spent", clears: ["seven_day"], resets_left: 0 }), ], }, @@ -101,18 +106,20 @@ describe("claudeResetCreditsToContract", () => { "Fable", ); expect( - credits?.options?.map(({ creditId, label, availableCount, clearsWindowIds }) => [ + credits?.options?.map(({ creditId, label, availableCount, clearsWindowIds, scope }) => [ creditId, label, availableCount, clearsWindowIds, + scope, ]), ).toEqual([ - ["session_a", "5-hour reset", 1, undefined], - ["weekly_a", "Weekly reset", 1, ["seven_day"]], - ["fable_a", "Fable reset", 2, ["seven_day_fable"]], + ["session_a", "5-hour reset", 1, ["five_hour"], undefined], + ["weekly_a", "Weekly reset", 1, ["seven_day"], undefined], + ["fable_a", "Fable reset", 2, ["seven_day_fable"], "Fable"], + ["full_a", "Full reset", 1, ["seven_day"], undefined], ]); - expect(credits).toMatchObject({ availableCount: 4, nextCreditId: "weekly_a" }); + expect(credits).toMatchObject({ availableCount: 5, nextCreditId: "weekly_a" }); }); it("offers nothing to redeem without a usable next grant or an eligible account", () => { @@ -168,18 +175,18 @@ effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { it.effect("serves a recent read from disk until it is forgotten or expires", () => Effect.gen(function* () { const { configDir } = yield* writeLogin; - const cacheFile = yield* ClaudeResetCredits.claudeResetCreditsCacheFile(configDir, configDir); + const cacheFile = ClaudeResetCredits.claudeResetCreditsCacheFile( + yield* Path.Path, + configDir, + configDir, + ); let requests = 0; + let body: unknown = { + cedar_ember: { eligible: true, next_grant_id: "grant_a", grants: [grant({})] }, + }; const client = HttpClient.make((request) => { requests += 1; - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ - cedar_ember: { eligible: true, next_grant_id: "grant_a", grants: [grant({})] }, - }), - ), - ); + return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(body))); }); const read = (version = "2.1.0") => ClaudeResetCredits.readClaudeResetCredits(configDir, version, undefined, cacheFile).pipe( @@ -198,6 +205,11 @@ effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { yield* TestClock.adjust("2 minutes"); yield* read("2.2.0"); expect(requests).toBe(4); + body = {}; + yield* TestClock.adjust("2 minutes"); + yield* read("2.2.0"); + yield* read("2.2.0"); + expect(requests).toBe(5); }), ); diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 9e840c359de16..86fbebb9f0ff8 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -15,7 +15,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; -import { claudeUsageWindowId } from "./claudeUsageLimits.ts"; +import { claudeUsageWindowId, OVERAGE_INCLUDED_EVENT_TYPE } from "./claudeUsageLimits.ts"; const API_BASE = "https://api.anthropic.com"; const PROGRAM = "cedar_ember"; @@ -127,6 +127,12 @@ const RESET_KINDS = { full: { label: "Full reset", description: "Refills your 5-hour and weekly limits." }, } as const; type ResetKind = keyof typeof RESET_KINDS; +const KIND_BUCKETS: Record> = { + session: ["five_hour"], + weekly: ["seven_day"], + fable: [OVERAGE_INCLUDED_EVENT_TYPE], + full: ["five_hour", "seven_day"], +}; const grantKind = (grant: typeof Grant.Type): ResetKind => { if (grant.gives_back_weekly) return "fable"; @@ -171,22 +177,33 @@ export function claudeResetCreditsToContract( const target = grants?.[0]; if (!grants || !target) return []; const optionExpiresAt = expiresAt(target); - const clears = grants.every((grant) => grant.clears?.length) - ? new Set( - grants.flatMap((grant) => - (grant.clears ?? []).flatMap((bucket) => - typeof bucket === "string" ? [claudeUsageWindowId(bucket, overageIncluded)] : [], - ), - ), - ) - : undefined; + const buckets = new Set( + grants.flatMap((grant) => { + const listed = (grant.clears ?? []).flatMap((bucket) => + typeof bucket === "string" && bucket.trim() ? [bucket.trim()] : [], + ); + return listed.length > 0 ? listed : KIND_BUCKETS[kind]; + }), + ); + // Claude lists the shared Session in a model-scoped reset's clears, but redeeming leaves it. + const modelOnly = + buckets.has(OVERAGE_INCLUDED_EVENT_TYPE) && + [...buckets].every( + (bucket) => bucket === OVERAGE_INCLUDED_EVENT_TYPE || bucket === "five_hour", + ); + // A model's row only counts that model's own resets, so a wider reset stays off it. + const cleared = [...buckets].filter( + (bucket) => (bucket === OVERAGE_INCLUDED_EVENT_TYPE) === modelOnly, + ); + const scope = modelOnly ? overageIncluded?.trim() : undefined; return [ { creditId: target.id, ...RESET_KINDS[kind], availableCount: grants.reduce((sum, grant) => sum + grant.resets_left, 0), ...(optionExpiresAt ? { expiresAt: optionExpiresAt } : {}), - ...(clears?.size ? { clearsWindowIds: [...clears] } : {}), + clearsWindowIds: cleared.map((bucket) => claudeUsageWindowId(bucket, overageIncluded)), + ...(scope ? { scope } : {}), }, ]; }, @@ -230,15 +247,12 @@ const withClaudeHeaders = (token: string, version: string) => "user-agent": `claude-cli/${version} (external, cli)`, }); -export const claudeResetCreditsCacheFile = Effect.fnUntraced(function* ( - stateDir: string, - configDir: string, -) { - const path = yield* Path.Path; - return path.join(stateDir, `claude-reset-credits-${sha256(configDir).slice(0, 16)}.json`); -}); +export const claudeResetCreditsCacheFile = (path: Path.Path, stateDir: string, configDir: string) => + path.join(stateDir, `claude-reset-credits-${sha256(configDir).slice(0, 16)}.json`); -export const forgetClaudeResetCredits = Effect.fnUntraced(function* (cacheFile: string) { +export const forgetClaudeResetCredits = Effect.fn("claudeResetCredits.forget")(function* ( + cacheFile: string, +) { const fs = yield* FileSystem.FileSystem; yield* fs.remove(cacheFile, { force: true }).pipe(Effect.ignore); }); @@ -273,7 +287,7 @@ export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( yield* HttpClientResponse.filterStatusOk(response), ); if (cacheFile) { - yield* encodeCachedGrants({ at: now, key, block: body.cedar_ember }).pipe( + yield* encodeCachedGrants({ at: now, key, block: body.cedar_ember ?? null }).pipe( Effect.flatMap((json) => fs.writeFileString(cacheFile, json)), Effect.ignore, ); diff --git a/apps/server/src/provider/claudeUsageLimits.ts b/apps/server/src/provider/claudeUsageLimits.ts index f4a043b8fae95..6767cdf44ed00 100644 --- a/apps/server/src/provider/claudeUsageLimits.ts +++ b/apps/server/src/provider/claudeUsageLimits.ts @@ -27,7 +27,7 @@ const WINDOWS: Readonly< }; /** Reuse probed display names for streamed bucket types so mid-turn updates target the same row. */ -const OVERAGE_INCLUDED_EVENT_TYPE = "seven_day_overage_included"; +export const OVERAGE_INCLUDED_EVENT_TYPE = "seven_day_overage_included"; // The SDK does not yet type the spend budget returned by `get_usage`. const SpendMoney = Schema.Struct({ diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index b5a12bf6722e1..530a256a5a21a 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -350,11 +350,7 @@ function SegmentTriggers({ const timeLeft = paceRemainingPercent(window, now); const [segmentRef, paceMark] = usePaceMark(timeLeft); const resetsIn = formatResetsIn(window, now); - const { count: credits, scope } = resetCreditsForWindow( - account.limits.resetCredits, - account.limits.windows, - window.id, - ); + const { count: credits, scope } = resetCreditsForWindow(account.limits.resetCredits, window.id); return ( <> { }); describe("reset credits per window", () => { - const windows = [ - window, - { ...window, id: "seven_day", kind: "weekly", label: "Weekly" }, - { ...window, id: "seven_day_fable", kind: "weekly", label: "Weekly · Fable" }, - { id: "monthly_spend", kind: "monthly", label: "Monthly spend", usedPercent: 10 }, - ] as const; - const option = (creditId: string, availableCount: number, clearsWindowIds?: string[]) => ({ + const option = ( + creditId: string, + availableCount: number, + clearsWindowIds?: string[], + scope?: string, + ) => ({ creditId, label: creditId, description: creditId, availableCount, ...(clearsWindowIds ? { clearsWindowIds } : {}), + ...(scope ? { scope } : {}), }); const credits = { availableCount: 4, nextCreditId: "fable", options: [ - option("fable", 1, ["five_hour", "seven_day_fable"]), - option("weekly", 2, ["seven_day"]), - option("everything", 1, ["five_hour", "seven_day", "seven_day_fable"]), + option("fable", 2, ["seven_day_fable"], "Fable"), + option("weekly", 1, ["seven_day"]), + option("full", 1, ["five_hour", "seven_day"]), ], }; it("counts each reset only on the windows it clears", () => { - expect(resetCreditsForWindow(credits, windows, "seven_day")).toEqual({ - count: 3, - scope: undefined, - }); - expect(resetCreditsForWindow(credits, windows, "five_hour")).toEqual({ - count: 1, - scope: undefined, - }); - expect( - resetCreditsForWindow( - { ...credits, options: credits.options.slice(0, 2) }, - windows, - "seven_day_fable", - ), - ).toEqual({ count: 1, scope: "Fable" }); - const mixed = { - ...credits, - options: [ - option("full", 1, ["seven_day", "seven_day_fable"]), - option("fable", 2, ["seven_day_fable"]), - ], - }; - expect(resetCreditsForWindow(mixed, windows, "seven_day").count).toBe(1); - expect(resetCreditsForWindow(mixed, windows, "seven_day_fable")).toEqual({ + expect(resetCreditsForWindow(credits, "seven_day")).toEqual({ count: 2, scope: undefined }); + expect(resetCreditsForWindow(credits, "five_hour")).toEqual({ count: 1, scope: undefined }); + expect(resetCreditsForWindow(credits, "seven_day_fable")).toEqual({ count: 2, scope: "Fable", }); - expect(resetCreditsForWindow(mixed, windows, "five_hour").count).toBe(0); - const withoutWeekly = windows.filter((shown) => shown.id !== "seven_day"); - expect(resetCreditsForWindow(credits, withoutWeekly, "five_hour").count).toBe(1); }); it("counts resets without clears details against every window", () => { expect( - resetCreditsForWindow({ availableCount: 2, nextCreditId: "grant_a" }, windows, "seven_day"), + resetCreditsForWindow({ availableCount: 2, nextCreditId: "grant_a" }, "seven_day"), ).toEqual({ count: 2, scope: undefined }); expect( resetCreditsForWindow( { availableCount: 1, options: [option("grant_a", 1)] }, - windows, "seven_day_fable", ), ).toEqual({ count: 1, scope: undefined }); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 1bd5cf496002b..0bc2096e18fde 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -1035,31 +1035,13 @@ export interface WindowResetCredits { /** Resets without a clears list count against every window. */ export function resetCreditsForWindow( credits: ServerProviderResetCredits | undefined, - windows: ReadonlyArray, windowId: string, ): WindowResetCredits { if (!credits?.options) return { count: credits?.availableCount ?? 0, scope: undefined }; - const resettable = windows.filter((window) => window.kind !== "monthly"); - const scopeOf = (window: ServerProviderUsageWindow) => window.label.split(" · ")[1]; - let count = 0; - const scopes = new Set(); - for (const option of credits.options) { - const clears = option.clearsWindowIds; - let scope: string | undefined; - if (clears) { - const listed = resettable.filter((window) => clears.includes(window.id)); - // An id with no window shown may be the all-models Weekly, so it rules out a model scope. - const modelOnly = clears.every((id) => - listed.some((window) => window.id === id && (scopeOf(window) || window.kind === "session")), - ); - scope = modelOnly ? listed.map(scopeOf).find(Boolean) : undefined; - // Claude lists the shared Session in a model-scoped reset's clears, but redeeming leaves it. - // A model's row only counts that model's own resets, though a full reset clears it too. - const cleared = listed.filter((window) => scopeOf(window) === scope); - if (!cleared.some((window) => window.id === windowId)) continue; - } - count += option.availableCount; - scopes.add(scope); - } + const counted = credits.options.filter( + (option) => option.clearsWindowIds?.includes(windowId) ?? true, + ); + const count = counted.reduce((sum, option) => sum + option.availableCount, 0); + const scopes = new Set(counted.map((option) => option.scope)); return { count, scope: scopes.size === 1 ? [...scopes][0] : undefined }; } From fd7d5d041b344f6e89ffa72cd513d333272b629c Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:59:03 +0100 Subject: [PATCH 07/10] fix(usage): Claude resets of one kind that clear different limits get separate options Co-Authored-By: Claude Opus 5.5 --- .../src/provider/claudeResetCredits.test.ts | 7 +- .../server/src/provider/claudeResetCredits.ts | 70 ++++++++++--------- 2 files changed, 43 insertions(+), 34 deletions(-) diff --git a/apps/server/src/provider/claudeResetCredits.test.ts b/apps/server/src/provider/claudeResetCredits.test.ts index 118675b93f775..6222b6142ffff 100644 --- a/apps/server/src/provider/claudeResetCredits.test.ts +++ b/apps/server/src/provider/claudeResetCredits.test.ts @@ -100,6 +100,8 @@ describe("claudeResetCreditsToContract", () => { clears: ["seven_day", "seven_day_overage_included", ""], }), grant({ id: "spent", clears: ["seven_day"], resets_left: 0 }), + grant({ id: "weekly_b", clears: ["seven_day"], resets_left: 2 }), + grant({ id: "model_weekly", clears: ["seven_day_overage_included"] }), ], }, NOW, @@ -115,11 +117,12 @@ describe("claudeResetCreditsToContract", () => { ]), ).toEqual([ ["session_a", "5-hour reset", 1, ["five_hour"], undefined], - ["weekly_a", "Weekly reset", 1, ["seven_day"], undefined], + ["weekly_a", "Weekly reset", 3, ["seven_day"], undefined], + ["model_weekly", "Weekly reset", 1, ["seven_day_fable"], "Fable"], ["fable_a", "Fable reset", 2, ["seven_day_fable"], "Fable"], ["full_a", "Full reset", 1, ["seven_day"], undefined], ]); - expect(credits).toMatchObject({ availableCount: 5, nextCreditId: "weekly_a" }); + expect(credits).toMatchObject({ availableCount: 8, nextCreditId: "weekly_a" }); }); it("offers nothing to redeem without a usable next grant or an eligible account", () => { diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 86fbebb9f0ff8..fdd3651d346fa 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -165,48 +165,54 @@ export function claudeResetCreditsToContract( const at = grant?.ends_at ? DateTime.make(grant.ends_at) : Option.none(); return Option.isSome(at) ? DateTime.formatIso(at.value) : undefined; }; - const byKind = new Map>(); + // Grants of one kind can still clear different limits, so they group by what they clear too. + const groups = new Map< + string, + { + kind: ResetKind; + cleared: Array; + modelOnly: boolean; + grants: Array; + } + >(); for (const grant of live) { if (grant.resets_left === 0) continue; const kind = grantKind(grant); - byKind.set(kind, [...(byKind.get(kind) ?? []), grant]); - } - const options = (Object.keys(RESET_KINDS) as Array).flatMap( - (kind): Array => { - const grants = byKind.get(kind); - const target = grants?.[0]; - if (!grants || !target) return []; - const optionExpiresAt = expiresAt(target); - const buckets = new Set( - grants.flatMap((grant) => { - const listed = (grant.clears ?? []).flatMap((bucket) => - typeof bucket === "string" && bucket.trim() ? [bucket.trim()] : [], - ); - return listed.length > 0 ? listed : KIND_BUCKETS[kind]; - }), - ); - // Claude lists the shared Session in a model-scoped reset's clears, but redeeming leaves it. - const modelOnly = - buckets.has(OVERAGE_INCLUDED_EVENT_TYPE) && - [...buckets].every( - (bucket) => bucket === OVERAGE_INCLUDED_EVENT_TYPE || bucket === "five_hour", - ); - // A model's row only counts that model's own resets, so a wider reset stays off it. - const cleared = [...buckets].filter( - (bucket) => (bucket === OVERAGE_INCLUDED_EVENT_TYPE) === modelOnly, + const listed = (grant.clears ?? []).flatMap((bucket) => + typeof bucket === "string" && bucket.trim() ? [bucket.trim()] : [], + ); + const buckets = new Set(listed.length > 0 ? listed : KIND_BUCKETS[kind]); + // Claude lists the shared Session in a model-scoped reset's clears, but redeeming leaves it. + const modelOnly = + buckets.has(OVERAGE_INCLUDED_EVENT_TYPE) && + [...buckets].every( + (bucket) => bucket === OVERAGE_INCLUDED_EVENT_TYPE || bucket === "five_hour", ); - const scope = modelOnly ? overageIncluded?.trim() : undefined; - return [ - { + // A model's row only counts that model's own resets, so a wider reset stays off it. + const cleared = [...buckets] + .filter((bucket) => (bucket === OVERAGE_INCLUDED_EVENT_TYPE) === modelOnly) + .toSorted(); + const key = `${kind}\n${cleared.join("\n")}`; + const group = groups.get(key); + if (group) group.grants.push(grant); + else groups.set(key, { kind, cleared, modelOnly, grants: [grant] }); + } + const options = (Object.keys(RESET_KINDS) as Array).flatMap((kind) => + [...groups.values()] + .filter((group) => group.kind === kind) + .map(({ cleared, modelOnly, grants }): ServerProviderResetCreditOption => { + const target = grants[0]!; + const optionExpiresAt = expiresAt(target); + const scope = modelOnly ? overageIncluded?.trim() : undefined; + return { creditId: target.id, ...RESET_KINDS[kind], availableCount: grants.reduce((sum, grant) => sum + grant.resets_left, 0), ...(optionExpiresAt ? { expiresAt: optionExpiresAt } : {}), clearsWindowIds: cleared.map((bucket) => claudeUsageWindowId(bucket, overageIncluded)), ...(scope ? { scope } : {}), - }, - ]; - }, + }; + }), ); const nextExpiresAt = expiresAt(next); return { From 529e0ab6ba8f5444d74c149b558c73e589311a08 Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:07:54 +0100 Subject: [PATCH 08/10] feat(usage): show how much of Weekly a Claude model-scoped limit used Co-Authored-By: Claude Opus 5.5 --- .../src/features/usage/UsageLimitsPooled.tsx | 6 +- .../src/features/usage/UsageLimitsSection.tsx | 39 ++++-- apps/server/src/provider/ClaudeProvider.ts | 14 +- .../src/provider/Drivers/ClaudeDriver.ts | 2 +- .../src/provider/claudeResetCredits.test.ts | 59 ++++++--- .../server/src/provider/claudeResetCredits.ts | 122 ++++++++++++++++-- apps/server/src/provider/claudeUsageLimits.ts | 6 +- apps/web/src/components/usage/UsageLimits.tsx | 39 +++++- .../components/usage/UsageLimitsPooled.tsx | 63 +++++++-- packages/contracts/src/providerUsageLimits.ts | 10 ++ .../src/server/usageLimits.test.ts | 10 +- .../provider-core/src/server/usageLimits.ts | 8 +- packages/shared/src/usageLimits.test.ts | 18 +++ packages/shared/src/usageLimits.ts | 22 ++++ 14 files changed, 349 insertions(+), 69 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 54492deb7a186..109144bcd4e9c 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -16,6 +16,7 @@ import { type LimitAccount, type LimitPoolWindow, resetCreditsForWindow, + usageShareSummary, } from "@t3tools/shared/usageLimits"; import { Fragment, type ReactNode, useId, useState } from "react"; import { Linking, Pressable, View } from "react-native"; @@ -458,6 +459,7 @@ export function UsageLimitsSection({ {windows.map((window) => { const details = pool.driver === "cursor" ? cursorUsageWindowDetails(window.id, pool) : undefined; + const share = window.members.find((member) => member.window.share)?.window.share; return ( ); diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index be4bf0c929d11..5d40b361e8217 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -18,6 +18,8 @@ import { paceOf, paceRemainingPercent, remainingPercent, + sharedUsage, + usageShareSummary, } from "@t3tools/shared/usageLimits"; import { type ReactNode, useContext, useRef, useState } from "react"; import { Atom } from "effect/reactivity"; @@ -50,11 +52,16 @@ function useBarColor(driver: Driver): string | null { function WindowRow(props: { readonly window: ServerProviderUsageWindow; + readonly windows: ReadonlyArray; readonly color: string | null; readonly now: number; }) { const { window, now } = props; const remaining = remainingPercent(window); + const shared = sharedUsage(window, props.windows)?.percent ?? 0; + const shareSummary = usageShareSummary(window.share, props.windows); + const fill = remaining <= 10 ? "bg-red-500" : remaining <= 30 ? "bg-amber-500" : "bg-foreground"; + const fillColor = remaining > 30 && props.color ? { backgroundColor: props.color } : null; const timeLeft = paceRemainingPercent(window, now); const pace = paceOf(window, now); const resetsIn = formatResetsIn(window, now); @@ -69,19 +76,16 @@ function WindowRow(props: { style={barWidth > 0 ? { width: barWidth } : undefined} > 30 && props.color ? { backgroundColor: props.color } : null, - ]} + className={`h-full ${shared > 0 ? "rounded-l-full" : "rounded-full"} ${window.share ? "opacity-40" : ""} ${fill}`} + style={[{ flex: remaining }, fillColor]} /> - + {shared > 0 ? ( + 0 ? "rounded-r-full" : "rounded-full"} ${fill}`} + style={[{ flex: shared }, fillColor]} + /> + ) : null} + ); return ( @@ -92,6 +96,9 @@ function WindowRow(props: { {remaining}% left + {shareSummary ? ( + {shareSummary} + ) : null} setBarWidth(event.nativeEvent.layout.width)} @@ -203,7 +210,13 @@ export function AccountLimits(props: { ) : ( {limits.windows.map((window) => ( - + ))} )} diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 5d0d86897a69f..5163514ed4eaa 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -8,7 +8,6 @@ import { type ServerProvider, type ServerProviderModel, type ServerProviderSlashCommand, - type ServerProviderResetCredits, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; @@ -64,6 +63,7 @@ import { claudeUsageResponseToLimits, recordClaudeUsageResponse, } from "./claudeUsageLimits.ts"; +import { type ClaudeAccountUsage, withClaudeAccountUsage } from "./claudeResetCredits.ts"; import { BUNDLED_CLAUDE_MODEL_CATALOG, CLAUDE_EFFORT_LEVELS, @@ -657,10 +657,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, /** Shared with the adapter so turn events reuse the scoped-bucket names this probe saw. */ scopedLimitNames?: Ref.Ref, - resolveResetCredits?: ( + resolveAccountUsage?: ( version: string, overageIncluded: string | undefined, - ) => Effect.Effect, + ) => Effect.Effect, ): Effect.fn.Return< ServerProviderDraft, never, @@ -865,12 +865,12 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( response: capabilities.usage, checkedAt: usageCheckedAt, }).limits; - const resetCredits = - resolveResetCredits && + const accountUsage = + resolveAccountUsage && capabilities.subscriptionType && !usageLimits.unavailable && parsedVersion - ? yield* resolveResetCredits( + ? yield* resolveAccountUsage( parsedVersion, scopedLimitNames ? (yield* Ref.get(scopedLimitNames)).overageIncluded : undefined, ) @@ -894,7 +894,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ...(authMetadata ? authMetadata : {}), }, ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), - usageLimits: resetCredits ? { ...usageLimits, resetCredits } : usageLimits, + usageLimits: withClaudeAccountUsage(usageLimits, accountUsage), }, }); }); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index db2acacc5ea13..75a41d5e66a83 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -280,7 +280,7 @@ export const ClaudeDriver: ProviderDriver - ClaudeResetCredits.readClaudeResetCredits( + ClaudeResetCredits.readClaudeAccountUsage( configDir, version, overageIncluded, diff --git a/apps/server/src/provider/claudeResetCredits.test.ts b/apps/server/src/provider/claudeResetCredits.test.ts index 6222b6142ffff..1b15462d8c9cf 100644 --- a/apps/server/src/provider/claudeResetCredits.test.ts +++ b/apps/server/src/provider/claudeResetCredits.test.ts @@ -37,6 +37,27 @@ const respond = (status: number, body: unknown) => Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(body, { status }))), ); const refuseRequests = HttpClient.make(() => Effect.die("must not send a request")); +const usageBody = { + cedar_ember: { + eligible: true, + next_grant_id: "grant_a", + grants: [ + grant({}), + grant({ id: "fable_a", clears: ["seven_day_overage_included"], gives_back_weekly: true }), + ], + }, + limits: [ + { kind: "session", percent: 20, scope: null }, + { kind: "weekly_all", percent: 38, scope: null }, + { kind: "weekly_scoped", percent: 16, scope: { model: { id: null, display_name: "Fable" } } }, + ], + weekly_scoped_shares: [ + { limit_index: 2, used_percent_of_weekly: 8, allowance_percent_of_weekly: 50 }, + { limit_index: 1, used_percent_of_weekly: 38, allowance_percent_of_weekly: 100 }, + { limit_index: 9, used_percent_of_weekly: 1, allowance_percent_of_weekly: 10 }, + ], +}; +const fableShare = { ofWindowId: "seven_day", usedPercent: 8, allowancePercent: 50 }; describe("claudeResetCreditsToContract", () => { it("counts live grants and pins the next usable one", () => { @@ -147,7 +168,7 @@ describe("claudeResetCreditsToContract", () => { }); }); -effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { +effectIt.layer(NodeServices.layer)("readClaudeAccountUsage", (it) => { it.effect("reads the grants with the CLI's request", () => Effect.gen(function* () { const { configDir } = yield* writeLogin; @@ -158,20 +179,22 @@ effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { expect(request.headers.authorization).toBe("Bearer oauth-token"); expect(request.headers["anthropic-beta"]).toBe("oauth-2025-04-20"); expect(request.headers["user-agent"]).toBe("claude-cli/2.1.0 (external, cli)"); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ - cedar_ember: { eligible: true, next_grant_id: "grant_a", grants: [grant({})] }, - }), - ), - ); + return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(usageBody))); }); - const credits = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( + const usage = yield* ClaudeResetCredits.readClaudeAccountUsage( + configDir, + "2.1.0", + "Fable", + ).pipe( Effect.provideService(HostProcess.Platform, "linux"), Effect.provideService(HttpClient.HttpClient, client), ); - expect(credits).toMatchObject({ availableCount: 1, nextCreditId: "grant_a" }); + expect(usage?.resetCredits).toMatchObject({ nextCreditId: "grant_a" }); + expect([...(usage?.sharesByWindowId ?? [])]).toEqual([["seven_day_fable", fableShare]]); + expect(usage?.resetCredits?.options?.map((option) => option.description)).toEqual([ + "Refills your Fable allowance and gives back the 8% of your weekly limit Fable used. It doesn't reset the rest of your weekly limit.", + "Refills your 5-hour and weekly limits.", + ]); }), ); @@ -184,20 +207,20 @@ effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { configDir, ); let requests = 0; - let body: unknown = { - cedar_ember: { eligible: true, next_grant_id: "grant_a", grants: [grant({})] }, - }; + let body: unknown = usageBody; const client = HttpClient.make((request) => { requests += 1; return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(body))); }); const read = (version = "2.1.0") => - ClaudeResetCredits.readClaudeResetCredits(configDir, version, undefined, cacheFile).pipe( + ClaudeResetCredits.readClaudeAccountUsage(configDir, version, undefined, cacheFile).pipe( Effect.provideService(HostProcess.Platform, "linux"), Effect.provideService(HttpClient.HttpClient, client), ); yield* read(); - expect(yield* read()).toMatchObject({ availableCount: 1, nextCreditId: "grant_a" }); + const cached = yield* read(); + expect(cached?.resetCredits).toMatchObject({ nextCreditId: "grant_a" }); + expect(cached?.sharesByWindowId.get("seven_day_fable")).toEqual(fableShare); expect(requests).toBe(1); yield* read("2.2.0"); yield* read("2.2.0"); @@ -219,11 +242,11 @@ effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { it.effect("reads nothing from keychain logins or failed requests", () => Effect.gen(function* () { const { configDir } = yield* writeLogin; - const darwin = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( + const darwin = yield* ClaudeResetCredits.readClaudeAccountUsage(configDir, "2.1.0").pipe( Effect.provideService(HostProcess.Platform, "darwin"), Effect.provideService(HttpClient.HttpClient, refuseRequests), ); - const limited = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( + const limited = yield* ClaudeResetCredits.readClaudeAccountUsage(configDir, "2.1.0").pipe( Effect.provideService(HostProcess.Platform, "linux"), Effect.provideService(HttpClient.HttpClient, respond(429, {})), ); diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index fdd3651d346fa..0ceb6faa3f97d 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -5,6 +5,8 @@ import type { ProviderConsumeResetCreditOutcome, ServerProviderResetCreditOption, ServerProviderResetCredits, + ServerProviderUsageLimits, + ServerProviderUsageWindow, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DateTime from "effect/DateTime"; @@ -15,7 +17,11 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; -import { claudeUsageWindowId, OVERAGE_INCLUDED_EVENT_TYPE } from "./claudeUsageLimits.ts"; +import { + claudeScopedWindowId, + claudeUsageWindowId, + OVERAGE_INCLUDED_EVENT_TYPE, +} from "./claudeUsageLimits.ts"; const API_BASE = "https://api.anthropic.com"; const PROGRAM = "cedar_ember"; @@ -52,14 +58,30 @@ const CedarEmber = Schema.Struct({ }); const UsageResponse = Schema.Struct({ cedar_ember: Schema.optional(Schema.NullOr(Schema.Unknown)), + limits: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), + weekly_scoped_shares: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), }); const decodeCedarEmber = Schema.decodeUnknownOption(CedarEmber); +const ScopedLimit = Schema.Struct({ + kind: Schema.Literal("weekly_scoped"), + scope: Schema.Struct({ + model: Schema.Struct({ display_name: Schema.String.check(Schema.isNonEmpty()) }), + }), +}); +const decodeScopedLimit = Schema.decodeUnknownOption(ScopedLimit); +const Percent = Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 100 })); +const WeeklyShare = Schema.Struct({ + limit_index: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + used_percent_of_weekly: Percent, + allowance_percent_of_weekly: Percent, +}); +const decodeWeeklyShare = Schema.decodeUnknownOption(WeeklyShare); const GRANTS_CACHE_TTL_MS = 2 * 60_000; -const CachedGrants = Schema.fromJsonString( - Schema.Struct({ at: Schema.Number, key: Schema.String, block: Schema.Unknown }), +const CachedUsage = Schema.fromJsonString( + Schema.Struct({ at: Schema.Number, key: Schema.String, body: UsageResponse }), ); -const decodeCachedGrants = Schema.decodeEffect(CachedGrants); -const encodeCachedGrants = Schema.encodeEffect(CachedGrants); +const decodeCachedUsage = Schema.decodeEffect(CachedUsage); +const encodeCachedUsage = Schema.encodeEffect(CachedUsage); const sha256 = (value: string) => NodeCrypto.createHash("sha256").update(value).digest("hex"); const ClaimResponse = Schema.Struct({ result: Schema.Literals([ @@ -223,6 +245,81 @@ export function claudeResetCreditsToContract( }; } +export type ClaudeWindowShare = NonNullable; + +/** Claude caps a model's weekly usage at a share of Weekly, such as Fable at 50%. */ +function claudeWeeklySharesByWindowId( + limits: ReadonlyArray | null | undefined, + shares: ReadonlyArray | null | undefined, +): ReadonlyMap { + const byWindowId = new Map(); + for (const share of (shares ?? []).flatMap((raw) => Option.toArray(decodeWeeklyShare(raw)))) { + const limit = decodeScopedLimit(limits?.[share.limit_index]); + if (Option.isNone(limit)) continue; + byWindowId.set(claudeScopedWindowId(limit.value.scope.model.display_name), { + ofWindowId: "seven_day", + usedPercent: share.used_percent_of_weekly, + allowancePercent: share.allowance_percent_of_weekly, + }); + } + return byWindowId; +} + +export interface ClaudeAccountUsage { + readonly resetCredits: ServerProviderResetCredits | undefined; + readonly sharesByWindowId: ReadonlyMap; +} + +// A model's reset gives back what that model used of Weekly, so say how much. +const describeShare = ( + option: ServerProviderResetCreditOption, + sharesByWindowId: ReadonlyMap, +): ServerProviderResetCreditOption => { + const share = option.scope + ? option.clearsWindowIds?.flatMap((id) => sharesByWindowId.get(id) ?? [])[0] + : undefined; + if (!share || !option.scope) return option; + const used = Math.round(share.usedPercent); + return { + ...option, + description: `Refills your ${option.scope} allowance${used > 0 ? ` and gives back the ${used}% of your weekly limit ${option.scope} used` : ""}. It doesn't reset the rest of your weekly limit.`, + }; +}; + +const accountUsageFromResponse = ( + body: typeof UsageResponse.Type, + nowMs: number, + overageIncluded: string | undefined, +): ClaudeAccountUsage => { + const sharesByWindowId = claudeWeeklySharesByWindowId(body.limits, body.weekly_scoped_shares); + const resetCredits = claudeResetCreditsToContract(body.cedar_ember, nowMs, overageIncluded); + return { + resetCredits: resetCredits?.options + ? { + ...resetCredits, + options: resetCredits.options.map((option) => describeShare(option, sharesByWindowId)), + } + : resetCredits, + sharesByWindowId, + }; +}; + +export function withClaudeAccountUsage( + usageLimits: ServerProviderUsageLimits, + accountUsage: ClaudeAccountUsage | undefined, +): ServerProviderUsageLimits { + if (!accountUsage) return usageLimits; + const { resetCredits, sharesByWindowId } = accountUsage; + return { + ...usageLimits, + windows: usageLimits.windows.map((window) => { + const share = sharesByWindowId.get(window.id); + return share ? { ...window, share } : window; + }), + ...(resetCredits ? { resetCredits } : {}), + }; +} + const readJson = Effect.fn("claudeResetCredits.readJson")(function* ( schema: S, file: string, @@ -265,7 +362,7 @@ export const forgetClaudeResetCredits = Effect.fn("claudeResetCredits.forget")(f // The cache is on disk because a restart refetches, and a dev server restarting on each edit gets // the account rate limited. -export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( +export const readClaudeAccountUsage = Effect.fn("readClaudeAccountUsage")( function* (configDir: string, version: string, overageIncluded?: string, cacheFile?: string) { const token = yield* readAccessToken(configDir); if (!token) return undefined; @@ -273,7 +370,7 @@ export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( const now = DateTime.toEpochMillis(yield* DateTime.now); const key = sha256(`${token}\n${version}`); const cached = cacheFile - ? yield* fs.readFileString(cacheFile).pipe(Effect.flatMap(decodeCachedGrants), Effect.option) + ? yield* fs.readFileString(cacheFile).pipe(Effect.flatMap(decodeCachedUsage), Effect.option) : Option.none(); if ( Option.isSome(cached) && @@ -281,7 +378,7 @@ export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( now >= cached.value.at && now - cached.value.at < GRANTS_CACHE_TTL_MS ) { - return claudeResetCreditsToContract(cached.value.block, now, overageIncluded); + return accountUsageFromResponse(cached.value.body, now, overageIncluded); } const client = yield* HttpClient.HttpClient; const response = yield* client.execute( @@ -293,12 +390,17 @@ export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( yield* HttpClientResponse.filterStatusOk(response), ); if (cacheFile) { - yield* encodeCachedGrants({ at: now, key, block: body.cedar_ember ?? null }).pipe( + const { cedar_ember, limits, weekly_scoped_shares } = body; + yield* encodeCachedUsage({ + at: now, + key, + body: { cedar_ember, limits, weekly_scoped_shares }, + }).pipe( Effect.flatMap((json) => fs.writeFileString(cacheFile, json)), Effect.ignore, ); } - return claudeResetCreditsToContract(body.cedar_ember, now, overageIncluded); + return accountUsageFromResponse(body, now, overageIncluded); }, Effect.timeout("10 seconds"), Effect.orElseSucceed(() => undefined), diff --git a/apps/server/src/provider/claudeUsageLimits.ts b/apps/server/src/provider/claudeUsageLimits.ts index 6767cdf44ed00..af64737711bab 100644 --- a/apps/server/src/provider/claudeUsageLimits.ts +++ b/apps/server/src/provider/claudeUsageLimits.ts @@ -50,7 +50,7 @@ export const makeClaudeScopedLimitNames = Ref.make({ const SCOPED_WINDOW_PREFIX = "seven_day_"; const scopeToken = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, "_"); -function scopedWindowId(displayName: string): string { +export function claudeScopedWindowId(displayName: string): string { return `${SCOPED_WINDOW_PREFIX}${scopeToken(displayName)}`; } @@ -66,7 +66,7 @@ export function windowLimitsModel( /** Maps a usage bucket named by Claude's API to the id of the window shown for it. */ export function claudeUsageWindowId(bucket: string, overageIncluded: string | undefined): string { return bucket === OVERAGE_INCLUDED_EVENT_TYPE && overageIncluded - ? scopedWindowId(overageIncluded) + ? claudeScopedWindowId(overageIncluded) : bucket; } @@ -76,7 +76,7 @@ function scopedWindow( resetsAt: string | undefined, ): ServerProviderUsageWindow { return { - id: scopedWindowId(displayName), + id: claudeScopedWindowId(displayName), kind: "weekly", label: `Weekly · ${displayName}`, windowDurationMins: WEEK_MINS, diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index e41aa20998b1c..640539ca1b976 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -16,6 +16,8 @@ import { paceOf, paceRemainingPercent, remainingPercent, + sharedUsage, + usageShareSummary, type ResetCreditChoice, resetCreditChoices, } from "@t3tools/shared/usageLimits"; @@ -24,6 +26,7 @@ import { Fragment, useLayoutEffect, useState } from "react"; import { usePrimarySettings } from "../../hooks/useSettings"; import { observeResize } from "../../lib/observeResize"; +import { cn } from "../../lib/utils"; import { environmentPresentations } from "../../state/presentation"; import { useEnvironmentScope, readEnvironmentScope } from "../../state/session"; import { serverEnvironment } from "../../state/server"; @@ -117,10 +120,12 @@ export function usePaceMark(timeLeft: number | null) { function WindowBar({ color, window, + shared, now, }: { readonly color: string; readonly window: ServerProviderUsageWindow; + readonly shared: ReturnType; readonly now: number; }) { const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); @@ -153,10 +158,24 @@ function WindowBar({
{remaining > 0 ? (
) : null} + {shared ? ( +
0 ? "rounded-r-full" : "rounded-full", + )} + style={{ left: `${remaining}%`, width: `${shared.percent}%`, backgroundColor: color }} + /> + ) : null}
{paceMark ? ( The line is where even spending would be. ) : null} + {shared ? ( + + {shared.labels.join(", ")} used {shared.percent}% of this, shown lighter. + + ) : null} {resetsAt ? ( Resets {resetsAt} @@ -211,6 +235,7 @@ export function LimitWindows({ {windows.map((window) => { const pace = paceOf(window, now); const resetsIn = formatResetsIn(window, now); + const shareSummary = usageShareSummary(window.share, windows); return ( @@ -219,11 +244,21 @@ export function LimitWindows({ {remainingPercent(window)}% left - + {pace ? : null} {resetsIn ?? ""} + {shareSummary ? ( + + {shareSummary} + + ) : null} ); })} diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 530a256a5a21a..f390d9113a117 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -1,7 +1,9 @@ +import type { Popover as PopoverPrimitive } from "@base-ui/react/popover"; +import { normalizeProviderAccentColor } from "@t3tools/client-runtime/state/provider-instance-display"; import { CHATGPT_USAGE_URL, - collectLimitAccounts, collectExternalUsageLinks, + collectLimitAccounts, collectLimitNotices, collectLimitPools, cursorUsageWindowDetails, @@ -16,12 +18,13 @@ import { type ResetCreditChoice, resetCreditChoices, resetCreditsForWindow, + sharedUsage, + usageShareSummary, } from "@t3tools/shared/usageLimits"; -import type { Popover as PopoverPrimitive } from "@base-ui/react/popover"; -import { normalizeProviderAccentColor } from "@t3tools/client-runtime/state/provider-instance-display"; import { AlertTriangleIcon, ExternalLinkIcon, TicketIcon } from "lucide-react"; import { createContext, + type CSSProperties, Fragment, type ReactNode, type RefObject, @@ -31,18 +34,18 @@ import { useState, } from "react"; -import { ensureLocalApi } from "../../localApi"; import { usePrimarySettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; +import { ensureLocalApi } from "../../localApi"; import { formatUpcomingTimestamp } from "../../timestampFormat"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; +import { OpenAI } from "../Icons"; import { providerClients } from "../settings/providerDriverMeta"; import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; -import { Button } from "../ui/button"; -import { OpenAI } from "../Icons"; import { Alert, AlertTitle } from "../ui/alert"; +import { Button } from "../ui/button"; import { Popover, PopoverCreateHandle, PopoverPopup, PopoverTrigger } from "../ui/popover"; -import { PaceIcon, ResetCreditDialog, barColor, usePaceMark, useResetCredit } from "./UsageLimits"; +import { barColor, PaceIcon, ResetCreditDialog, usePaceMark, useResetCredit } from "./UsageLimits"; function accountInitials(email: string): string { const [local = "", domain = ""] = email.split("@"); @@ -148,6 +151,7 @@ function SegmentPopover({ const remaining = remainingPercent(window); const timeLeft = paceRemainingPercent(window, now); const resetsIn = formatResetsIn(window, now); + const shared = sharedUsage(window, account.limits.windows); const where = account.environments.length > 0 ? account.environments.map((environment) => environment.label).join(", ") @@ -183,6 +187,13 @@ function SegmentPopover({
{remaining}% + {shared ? ( + label.replace(`${window.label} · `, "")).join(", ")} + > + {shared.percent}% of usage + + ) : null} {window.resetsAt ? ( {formatUpcomingTimestamp(window.resetsAt, timestampFormat, now)} @@ -327,6 +338,19 @@ function RedeemableSegment({ ); } +// Reaches back under the fill's rounded end and paints only the two corners it leaves bare, +// so the translucent layers never overlap. +const fillCornerNotches = { + maskImage: [ + "linear-gradient(to right, transparent var(--radius-md), #000 var(--radius-md))", + "radial-gradient(circle at 0 100%, transparent calc(var(--radius-md) - 0.5px), #000 calc(var(--radius-md) + 0.5px))", + "radial-gradient(circle at 0 0, transparent calc(var(--radius-md) - 0.5px), #000 calc(var(--radius-md) + 0.5px))", + ].join(", "), + maskSize: "100% 100%, var(--radius-md) var(--radius-md), var(--radius-md) var(--radius-md)", + maskPosition: "0 0, 0 0, 0 100%", + maskRepeat: "no-repeat", +} satisfies CSSProperties; + function SegmentTriggers({ account, window, @@ -351,6 +375,7 @@ function SegmentTriggers({ const [segmentRef, paceMark] = usePaceMark(timeLeft); const resetsIn = formatResetsIn(window, now); const { count: credits, scope } = resetCreditsForWindow(account.limits.resetCredits, window.id); + const shared = sharedUsage(window, account.limits.windows); return ( <> {/* Clipped inside the rounded clip so the bar's own corners keep their antialiasing. */}
+ {shared ? ( +
0 ? fillCornerNotches : undefined), + left: remaining > 0 ? `calc(${remaining}% - var(--radius-md))` : 0, + width: + remaining > 0 + ? `calc(${shared.percent}% + var(--radius-md))` + : `${shared.percent}%`, + backgroundColor: color, + }} + /> + ) : null}
{remaining < 100 && reset && refillPercent > 0 ? ( @@ -610,6 +654,7 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu {windows.map((window) => { const details = pool.driver === "cursor" ? cursorUsageWindowDetails(window.id, pool) : undefined; + const share = window.members.find((member) => member.window.share)?.window.share; return ( ); })} diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 0c08737981c1c..9cb4ec9814fb8 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -20,6 +20,16 @@ export const ServerProviderUsageWindow = Schema.Struct({ usedPercent: Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 100 })), resetsAt: Schema.optional(IsoDateTime), windowDurationMins: Schema.optional(NonNegativeInt), + /** Set when this window draws from part of another, such as Fable's share of Weekly. */ + share: Schema.optional( + Schema.Struct({ + ofWindowId: TrimmedNonEmptyString, + /** Percent of the other window this one has used. */ + usedPercent: Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 100 })), + /** Most of the other window this one may use, in percent. */ + allowancePercent: Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 100 })), + }), + ), }); export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type; diff --git a/packages/provider-core/src/server/usageLimits.test.ts b/packages/provider-core/src/server/usageLimits.test.ts index 2acdb0ba15d79..196ab28c2d550 100644 --- a/packages/provider-core/src/server/usageLimits.test.ts +++ b/packages/provider-core/src/server/usageLimits.test.ts @@ -58,18 +58,20 @@ describe("applyUsageLimitsUpdate", () => { ).toEqual(["opencode/b"]); }); - it("upserts by id and keeps the reset a percent-only update omits", () => { + it("upserts by id and keeps the reset and share a percent-only update omits", () => { + const share = { ofWindowId: "seven_day", usedPercent: 8, allowancePercent: 50 }; + const previous = { checkedAt, windows: [{ ...session, share }, weekly] }; const next = applyUsageLimitsUpdate({ - previous: published, + previous, checkedAt: "2026-09-03T12:00:05.000Z", update: { windows: [{ id: "five_hour", kind: "session", label: "Session", usedPercent: 55 }], }, }); - expect(next).not.toBe(published); + expect(next).not.toBe(previous); expect(next).toEqual({ checkedAt: "2026-09-03T12:00:05.000Z", - windows: [{ ...session, usedPercent: 55 }, weekly], + windows: [{ ...session, usedPercent: 55, share }, weekly], }); }); diff --git a/packages/provider-core/src/server/usageLimits.ts b/packages/provider-core/src/server/usageLimits.ts index 7d1f6e6a910e9..b82cb2d6adbce 100644 --- a/packages/provider-core/src/server/usageLimits.ts +++ b/packages/provider-core/src/server/usageLimits.ts @@ -70,6 +70,9 @@ export function applyUsageLimitsUpdate(input: { ...(window.windowDurationMins === undefined && existing?.windowDurationMins !== undefined ? { windowDurationMins: existing.windowDurationMins } : {}), + ...(window.share === undefined && existing?.share !== undefined + ? { share: existing.share } + : {}), }; if (existing === undefined || !usageWindowEquals(existing, next)) { merged.set(window.id, next); @@ -106,7 +109,10 @@ function usageWindowEquals(a: ServerProviderUsageWindow, b: ServerProviderUsageW a.providerIds.length === b.providerIds.length && a.providerIds.every((id, index) => id === b.providerIds?.[index]))) && a.resetsAt === b.resetsAt && - a.windowDurationMins === b.windowDurationMins + a.windowDurationMins === b.windowDurationMins && + a.share?.ofWindowId === b.share?.ofWindowId && + a.share?.usedPercent === b.share?.usedPercent && + a.share?.allowancePercent === b.share?.allowancePercent ); } diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 0a29301454eff..b07d678ce8f87 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -26,6 +26,8 @@ import { usesChatGptSharing, resolveNewThreadModelSelection, resetCreditsForWindow, + sharedUsage, + usageShareSummary, } from "./usageLimits.ts"; const now = Date.parse("2026-09-03T12:00:00.000Z"); @@ -221,6 +223,22 @@ describe("pace", () => { }); }); +describe("shares", () => { + it("labels a scoped window's allowance and lightens what it used of the shared window", () => { + const weekly = { id: "seven_day", kind: "weekly", label: "Weekly", usedPercent: 38 } as const; + const share = { ofWindowId: "seven_day", usedPercent: 8.4, allowancePercent: 50 }; + const fable = { ...weekly, id: "seven_day_fable", label: "Weekly · Fable", share }; + expect(usageShareSummary(fable.share, [weekly, fable])).toBe("Up to 50% of your weekly limit"); + expect(usageShareSummary(fable.share, [fable])).toBeNull(); + expect(sharedUsage(weekly, [weekly, fable])).toEqual({ + percent: 8, + labels: ["Weekly · Fable"], + }); + expect(sharedUsage({ ...weekly, usedPercent: 5 }, [weekly, fable])?.percent).toBe(5); + expect(sharedUsage(fable, [weekly, fable])).toBeNull(); + }); +}); + describe("pools", () => { const checkedAt = "2026-09-03T11:00:00.000Z"; const weekly = { diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 0bc2096e18fde..0e733ae77bba7 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -715,6 +715,28 @@ export function limitsNotice(limits: ServerProviderUsageLimits): string | null { return limits.windows.length === 0 ? "No limits reported." : null; } +/** "Up to 50% of your weekly limit" for a window that draws from part of another. */ +export function usageShareSummary( + share: ServerProviderUsageWindow["share"], + windows: ReadonlyArray<{ readonly id: string; readonly label: string }>, +): string | null { + const parent = share && windows.find((window) => window.id === share.ofWindowId); + return share && parent + ? `Up to ${Math.round(share.allowancePercent)}% of your ${parent.label.toLowerCase()} limit` + : null; +} + +/** What narrower windows, such as Fable's share of Weekly, used of this window. */ +export function sharedUsage( + window: ServerProviderUsageWindow, + windows: ReadonlyArray, +): { readonly percent: number; readonly labels: ReadonlyArray } | null { + const sharing = windows.filter((other) => other.share?.ofWindowId === window.id); + const used = sharing.reduce((sum, other) => sum + (other.share?.usedPercent ?? 0), 0); + const percent = Math.min(Math.round(used), 100 - remainingPercent(window)); + return percent > 0 ? { percent, labels: sharing.map((other) => other.label) } : null; +} + export function remainingPercent(window: ServerProviderUsageWindow): number { return Math.round(100 - Math.max(0, Math.min(100, window.usedPercent))); } From fe58a099783d431ca0d394ed5dc576736158997c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 12 Nov 2026 01:38:54 +0000 Subject: [PATCH 09/10] fix(usage): name model-only reset choices by their scope --- apps/server/src/provider/claudeResetCredits.test.ts | 5 ++++- apps/server/src/provider/claudeResetCredits.ts | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/claudeResetCredits.test.ts b/apps/server/src/provider/claudeResetCredits.test.ts index 1b15462d8c9cf..0a1534ac85f07 100644 --- a/apps/server/src/provider/claudeResetCredits.test.ts +++ b/apps/server/src/provider/claudeResetCredits.test.ts @@ -139,10 +139,13 @@ describe("claudeResetCreditsToContract", () => { ).toEqual([ ["session_a", "5-hour reset", 1, ["five_hour"], undefined], ["weekly_a", "Weekly reset", 3, ["seven_day"], undefined], - ["model_weekly", "Weekly reset", 1, ["seven_day_fable"], "Fable"], + ["model_weekly", "Fable weekly reset", 1, ["seven_day_fable"], "Fable"], ["fable_a", "Fable reset", 2, ["seven_day_fable"], "Fable"], ["full_a", "Full reset", 1, ["seven_day"], undefined], ]); + expect(credits?.options?.find((option) => option.creditId === "model_weekly")).toMatchObject({ + description: "Refills your Fable weekly allowance.", + }); expect(credits).toMatchObject({ availableCount: 8, nextCreditId: "weekly_a" }); }); diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 0ceb6faa3f97d..39e876cf5276b 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -229,6 +229,12 @@ export function claudeResetCreditsToContract( return { creditId: target.id, ...RESET_KINDS[kind], + ...(modelOnly && kind !== "fable" + ? { + label: `${scope ?? "Model"} weekly reset`, + description: `Refills your ${scope ?? "model"} weekly allowance.`, + } + : {}), availableCount: grants.reduce((sum, grant) => sum + grant.resets_left, 0), ...(optionExpiresAt ? { expiresAt: optionExpiresAt } : {}), clearsWindowIds: cleared.map((bucket) => claudeUsageWindowId(bucket, overageIncluded)), From a6cc2f98f2ddae2a661119a9f22c021a6dae0109 Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:14:56 +0100 Subject: [PATCH 10/10] fix(usage): only a Fable reset says it gives back weekly usage Co-Authored-By: Claude Opus 5.5 --- apps/server/src/provider/claudeResetCredits.test.ts | 2 ++ apps/server/src/provider/claudeResetCredits.ts | 9 +++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/claudeResetCredits.test.ts b/apps/server/src/provider/claudeResetCredits.test.ts index 0a1534ac85f07..6291ab0ee0eb6 100644 --- a/apps/server/src/provider/claudeResetCredits.test.ts +++ b/apps/server/src/provider/claudeResetCredits.test.ts @@ -44,6 +44,7 @@ const usageBody = { grants: [ grant({}), grant({ id: "fable_a", clears: ["seven_day_overage_included"], gives_back_weekly: true }), + grant({ id: "model_weekly", clears: ["seven_day_overage_included"] }), ], }, limits: [ @@ -195,6 +196,7 @@ effectIt.layer(NodeServices.layer)("readClaudeAccountUsage", (it) => { expect(usage?.resetCredits).toMatchObject({ nextCreditId: "grant_a" }); expect([...(usage?.sharesByWindowId ?? [])]).toEqual([["seven_day_fable", fableShare]]); expect(usage?.resetCredits?.options?.map((option) => option.description)).toEqual([ + "Refills your Fable weekly allowance.", "Refills your Fable allowance and gives back the 8% of your weekly limit Fable used. It doesn't reset the rest of your weekly limit.", "Refills your 5-hour and weekly limits.", ]); diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 39e876cf5276b..b71d1d62efe6e 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -276,14 +276,15 @@ export interface ClaudeAccountUsage { readonly sharesByWindowId: ReadonlyMap; } -// A model's reset gives back what that model used of Weekly, so say how much. +// A Fable reset gives back what that model used of Weekly, so say how much. const describeShare = ( option: ServerProviderResetCreditOption, sharesByWindowId: ReadonlyMap, ): ServerProviderResetCreditOption => { - const share = option.scope - ? option.clearsWindowIds?.flatMap((id) => sharesByWindowId.get(id) ?? [])[0] - : undefined; + const share = + option.scope && option.label === RESET_KINDS.fable.label + ? option.clearsWindowIds?.flatMap((id) => sharesByWindowId.get(id) ?? [])[0] + : undefined; if (!share || !option.scope) return option; const used = Math.round(share.usedPercent); return {