diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 218a7f21feb18..109144bcd4e9c 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -15,6 +15,8 @@ import { remainingPercent, 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"; @@ -235,7 +237,10 @@ 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, + window.id, + ); const resetsIn = formatResetsIn(window, now)?.replace("resets in ", ""); return ( {credits} + {scope ? ( + {scope} + ) : null} ) : null} @@ -323,13 +331,16 @@ 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, + 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 +384,7 @@ function PoolWindowCard({ {credits} + {scope ? {scope} : null} ) : null} @@ -447,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 0d669659a09ce..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,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, + resolveAccountUsage?: ( + version: string, + overageIncluded: string | undefined, + ) => Effect.Effect, ): Effect.fn.Return< ServerProviderDraft, never, @@ -862,12 +865,15 @@ 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(parsedVersion) + ? yield* resolveAccountUsage( + parsedVersion, + scopedLimitNames ? (yield* Ref.get(scopedLimitNames)).overageIncluded : undefined, + ) : undefined; return buildServerProvider({ presentation: CLAUDE_PRESENTATION, @@ -888,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 d75b1cad86402..75a41d5e66a83 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -141,6 +141,11 @@ export const ClaudeDriver: ProviderDriver - ClaudeResetCredits.readClaudeResetCredits(configDir, version).pipe( + (version, overageIncluded) => + ClaudeResetCredits.readClaudeAccountUsage( + configDir, + version, + overageIncluded, + resetCreditsCacheFile, + ).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), @@ -297,6 +307,10 @@ export const ClaudeDriver: ProviderDriver>({ resolveMaintenance, @@ -309,7 +323,7 @@ export const ClaudeDriver: ProviderDriver resolveMaintenance().pipe( Effect.flatMap((maintenanceCapabilities) => @@ -378,13 +392,13 @@ export const ClaudeDriver: ProviderDriver invalidateUsageCache), Effect.tap((outcome) => ResetCreditCoordinator.refreshAfterRedemption({ outcome, getSnapshot: snapshot.getSnapshot, - refresh: Cache.invalidateAll(capabilitiesProbeCache).pipe( - Effect.andThen(snapshot.refresh), - ), + refresh: invalidateUsageCache.pipe(Effect.andThen(snapshot.refresh)), }).pipe( Effect.filterOrFail( (confirmed) => confirmed, @@ -411,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 a3a1110d87d34..6291ab0ee0eb6 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"; @@ -36,6 +37,28 @@ 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 }), + grant({ id: "model_weekly", clears: ["seven_day_overage_included"] }), + ], + }, + 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", () => { @@ -82,29 +105,49 @@ 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", }), - 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: "full_a", + type: "full", + 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, + "Fable", ); expect( - credits?.options?.map(({ creditId, label, availableCount }) => [ + credits?.options?.map(({ creditId, label, availableCount, clearsWindowIds, scope }) => [ creditId, label, availableCount, + clearsWindowIds, + scope, ]), ).toEqual([ - ["session_a", "5-hour reset", 1], - ["weekly_a", "Weekly reset", 1], - ["fable_a", "Fable reset", 2], + ["session_a", "5-hour reset", 1, ["five_hour"], undefined], + ["weekly_a", "Weekly reset", 3, ["seven_day"], undefined], + ["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).toMatchObject({ availableCount: 4, nextCreditId: "weekly_a" }); + expect(credits?.options?.find((option) => option.creditId === "model_weekly")).toMatchObject({ + description: "Refills your Fable weekly allowance.", + }); + expect(credits).toMatchObject({ availableCount: 8, nextCreditId: "weekly_a" }); }); it("offers nothing to redeem without a usable next grant or an eligible account", () => { @@ -129,7 +172,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; @@ -140,31 +183,75 @@ 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 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.", + ]); + }), + ); + + it.effect("serves a recent read from disk until it is forgotten or expires", () => + Effect.gen(function* () { + const { configDir } = yield* writeLogin; + const cacheFile = ClaudeResetCredits.claudeResetCreditsCacheFile( + yield* Path.Path, + configDir, + configDir, + ); + let requests = 0; + 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.readClaudeAccountUsage(configDir, version, undefined, cacheFile).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HttpClient.HttpClient, client), + ); + yield* read(); + 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"); + 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); + body = {}; + yield* TestClock.adjust("2 minutes"); + yield* read("2.2.0"); + yield* read("2.2.0"); + expect(requests).toBe(5); }), ); 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 d2000804cf4f6..b71d1d62efe6e 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -1,7 +1,12 @@ +// @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, ServerProviderResetCredits, + ServerProviderUsageLimits, + ServerProviderUsageWindow, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DateTime from "effect/DateTime"; @@ -12,6 +17,12 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import { + claudeScopedWindowId, + claudeUsageWindowId, + OVERAGE_INCLUDED_EVENT_TYPE, +} from "./claudeUsageLimits.ts"; + const API_BASE = "https://api.anthropic.com"; const PROGRAM = "cedar_ember"; const GRANT_ID = /^[a-z0-9_-]{1,40}$/; @@ -47,8 +58,31 @@ 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 CachedUsage = Schema.fromJsonString( + Schema.Struct({ at: Schema.Number, key: Schema.String, body: UsageResponse }), +); +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([ "reset", @@ -115,6 +149,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"; @@ -130,6 +170,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; @@ -146,27 +187,60 @@ 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 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", + ); + // 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): Array => { - const grants = byKind.get(kind); - const target = grants?.[0]; - if (!grants || !target) return []; - const optionExpiresAt = expiresAt(target); - return [ - { + 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], + ...(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)), + ...(scope ? { scope } : {}), + }; + }), ); const nextExpiresAt = expiresAt(next); return { @@ -177,6 +251,82 @@ 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 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.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 { + ...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, @@ -207,10 +357,36 @@ const withClaudeHeaders = (token: string, version: string) => "user-agent": `claude-cli/${version} (external, cli)`, }); -export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( - function* (configDir: string, version: string) { +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.fn("claudeResetCredits.forget")(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 readClaudeAccountUsage = Effect.fn("readClaudeAccountUsage")( + 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(decodeCachedUsage), 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 accountUsageFromResponse(cached.value.body, now, overageIncluded); + } const client = yield* HttpClient.HttpClient; const response = yield* client.execute( HttpClientRequest.get(`${API_BASE}/api/oauth/usage`, { @@ -220,10 +396,18 @@ 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), - ); + if (cacheFile) { + 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 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 ba1263ef704bb..af64737711bab 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({ @@ -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)}`; } @@ -63,13 +63,20 @@ 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 + ? claudeScopedWindowId(overageIncluded) + : bucket; +} + function scopedWindow( displayName: string, usedPercent: number, 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 8ec1c7a02afcd..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, @@ -15,12 +17,14 @@ import { remainingPercent, 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, @@ -30,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("@"); @@ -147,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(", ") @@ -182,6 +187,13 @@ function SegmentPopover({
{remaining}% + {shared ? ( + label.replace(`${window.label} · `, "")).join(", ")} + > + {shared.percent}% of usage + + ) : null} {window.resetsAt ? ( {formatUpcomingTimestamp(window.resetsAt, timestampFormat, now)} @@ -326,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, @@ -349,7 +374,8 @@ 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, window.id); + const shared = sharedUsage(window, account.limits.windows); return ( <> } @@ -369,9 +395,28 @@ function SegmentTriggers({
{/* 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 ? ( @@ -425,6 +470,9 @@ function SegmentTriggers({ {credits} + {scope ? ( + {scope} + ) : null} ) : null} @@ -452,7 +500,7 @@ 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, window.id); return ( {credits} + {scope ? {scope} : null} - {credits} reset {credits === 1 ? "credit" : "credits"} banked + {credits} {scope ? `${scope} ` : ""}reset {credits === 1 ? "credit" : "credits"}{" "} + banked ) : null} @@ -604,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 446d4f1f31849..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; @@ -30,6 +40,10 @@ export const ServerProviderResetCreditOption = Schema.Struct({ description: TrimmedNonEmptyString, availableCount: NonNegativeInt, expiresAt: Schema.optional(IsoDateTime), + /** Usage windows this reset is counted on. Absent means every window. */ + clearsWindowIds: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + /** Set when the reset only covers one model, such as "Fable". */ + scope: Schema.optional(TrimmedNonEmptyString), }); export type ServerProviderResetCreditOption = typeof ServerProviderResetCreditOption.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 b66001ca19244..b07d678ce8f87 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -25,6 +25,9 @@ import { paceRemainingPercent, usesChatGptSharing, resolveNewThreadModelSelection, + resetCreditsForWindow, + sharedUsage, + usageShareSummary, } from "./usageLimits.ts"; const now = Date.parse("2026-09-03T12:00:00.000Z"); @@ -220,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 = { @@ -1837,3 +1856,49 @@ describe("ChatGPT sharing presentation", () => { ).toBe(false); }); }); + +describe("reset credits per window", () => { + 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", 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, "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", + }); + }); + + it("counts resets without clears details against every window", () => { + expect( + resetCreditsForWindow({ availableCount: 2, nextCreditId: "grant_a" }, "seven_day"), + ).toEqual({ count: 2, scope: undefined }); + expect( + resetCreditsForWindow( + { availableCount: 1, options: [option("grant_a", 1)] }, + "seven_day_fable", + ), + ).toEqual({ count: 1, scope: undefined }); + }); +}); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 6fe6c70a89a4f..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))); } @@ -1025,3 +1047,23 @@ 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, + windowId: string, +): WindowResetCredits { + if (!credits?.options) return { count: credits?.availableCount ?? 0, scope: undefined }; + 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 }; +}