diff --git a/apps/mobile/src/features/threads/ComposerUsageLimits.tsx b/apps/mobile/src/features/threads/ComposerUsageLimits.tsx index 49f43cc2161b..1aec0b7900da 100644 --- a/apps/mobile/src/features/threads/ComposerUsageLimits.tsx +++ b/apps/mobile/src/features/threads/ComposerUsageLimits.tsx @@ -64,6 +64,7 @@ export function ComposerUsageLimits({ environmentId={environmentId} input={resetCreditInput} credits={account.limits.resetCredits} + windows={account.limits.windows} now={now} /> ) : undefined diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 2f0ad0f89dfc..502d3d987edb 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"; @@ -233,7 +234,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} @@ -321,13 +329,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" @@ -371,6 +383,7 @@ function PoolWindowCard({ {credits} + {scope ? {scope} : null} ) : null} @@ -624,6 +637,7 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) { environmentId={account.redeem.environmentId} input={account.redeem.input} credits={account.limits.resetCredits} + windows={account.limits.windows} now={now} /> diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index 214e64fab628..e7e679e02fe8 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -17,6 +17,8 @@ import { paceOf, paceRemainingPercent, remainingPercent, + resetCreditsForWindow, + withResetCreditId, } from "@t3tools/shared/usageLimits"; import { type ReactNode, useContext, useRef, useState } from "react"; import { Atom } from "effect/reactivity"; @@ -221,7 +223,7 @@ export function AccountLimits(props: { } const OUTCOME_TEXT: Record = { - reset: "Reset applied. Your windows have cleared.", + reset: "Reset applied.", nothingToReset: "Nothing to reset right now.", noCredit: "No reset credit left.", alreadyRedeemed: "That credit was already redeemed.", @@ -231,34 +233,27 @@ export function ResetCredits(props: { readonly environmentId: EnvironmentId; readonly input: ProviderConsumeResetCreditInput; readonly credits: ServerProviderResetCredits; + readonly windows: ReadonlyArray; readonly now: number; readonly dense?: boolean; }) { - const { environmentId, input, credits, now, dense = false } = props; + const { environmentId, input, credits, windows, now, dense = false } = props; const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope); const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false, }); - const [busy, setBusy] = useState(false); + const [busyCreditId, setBusyCreditId] = useState(null); const [status, setStatus] = useState(null); - if (dense && credits.availableCount === 0 && status === null) return null; + const { grants, waiting } = resetCreditsForWindow(credits, windows); + if (dense && grants.length === 0 && !waiting && status === null) return null; + const busy = busyCreditId !== null; - const expiresIn = credits.nextExpiresAt - ? formatDuration(Date.parse(credits.nextExpiresAt) - now) - : null; - const summary = - credits.availableCount === 0 - ? "No reset credits banked" - : `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${ - expiresIn ? ` · next expires in ${expiresIn}` : "" - }`; - - const redeem = async () => { + const redeem = async (creditId: string | undefined) => { if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; - setBusy(true); + setBusyCreditId(creditId); setStatus(null); - const result = await consume({ environmentId, input }); - setBusy(false); + const result = await consume({ environmentId, input: withResetCreditId(input, creditId) }); + setBusyCreditId(null); if (result._tag === "Success") { setStatus(result.value.warning ?? OUTCOME_TEXT[result.value.outcome]); return; @@ -270,44 +265,62 @@ export function ResetCredits(props: { ); }; - const confirm = () => { + const confirm = (creditId: string | undefined, effect: string | undefined) => { if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; Dialog.alert( "Use a reset credit?", - "This redeems one credit on your account and clears the current rate-limit windows. It cannot be undone.", + `${effect ?? "Clears the current rate-limit windows."} This can't be undone.`, [ { text: "Cancel", style: "cancel" }, - { text: "Use credit", onPress: () => void redeem() }, + { text: "Use credit", onPress: () => void redeem(creditId) }, ], ); }; return ( - - {summary} - {credits.availableCount > 0 ? ( - - - {busy ? "Using…" : "Use reset"} - - + + {grants.length === 0 && !waiting ? ( + No reset credits banked ) : null} + {grants.map((grant) => { + const expiresIn = grant.expiresAt + ? formatDuration(Date.parse(grant.expiresAt) - now) + : null; + const scope = grant.scope ? `${grant.scope} ` : ""; + return ( + + + {`${grant.count} ${scope}${grant.count === 1 ? "reset credit" : "reset credits"} banked${ + expiresIn + ? ` · ${grant.count > 1 ? "next expires" : "expires"} in ${expiresIn}` + : "" + }`} + + confirm(grant.id, grant.effect)} + className={ + dense + ? "rounded-full bg-subtle-strong px-2.5 py-1 disabled:opacity-[0.45]" + : "min-h-[44px] justify-center rounded-full bg-subtle-strong px-3 py-1.5 disabled:opacity-[0.45]" + } + > + + {busy && busyCreditId === grant.id ? "Using…" : "Use reset"} + + + + ); + })} + {waiting ? {waiting} : null} {!canManageProviders ? ( This connection cannot manage provider accounts. diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 72221ff3187b..7dec98dad5cf 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -525,7 +525,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, @@ -727,7 +730,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 c2d4b6386107..02abff29e6df 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -246,8 +246,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), @@ -309,9 +309,13 @@ export const ClaudeDriver: ProviderDriver = Effect.fn( "ClaudeDriver.consumeResetCredit", )( - function* () { + function* (creditId) { const current = yield* snapshot.getSnapshot; - const grantId = current.usageLimits?.resetCredits?.nextCreditId; + const credits = current.usageLimits?.resetCredits; + const grantId = + creditId === undefined + ? credits?.nextCreditId + : credits?.grants?.find((grant) => grant.id === creditId && !grant.waiting)?.id; if (!grantId || !current.version) return "noCredit" as const; const version = current.version; return yield* resetCreditCoordinator.redeem( @@ -321,7 +325,7 @@ export const ClaudeDriver: ProviderDriver { }).pipe(Effect.provide(layerTest)), ); - const redeemClaudeReset = (claim: { result: string; usageFailsAfterClaim: boolean }) => + const redeemClaudeReset = (claim: { + result: string; + usageFailsAfterClaim: boolean; + creditId?: string; + }) => Effect.gen(function* () { + const claimedGrantIds: Array = []; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const fixtures = yield* makeTildeProviderFixtures(); @@ -626,12 +631,20 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { cedar_ember: { eligible: true, next_grant_id: "grant_a", - grants: [{ id: "grant_a", resets_left: 1, usable_now: true }], + grants: [ + { id: "grant_a", resets_left: 1, usable_now: true }, + { id: "grant_b", resets_left: 1, usable_now: true }, + ], }, }), ); } if (request.url.endsWith("/reset_rate_limits")) { + if (request.body._tag === "Uint8Array") { + claimedGrantIds.push( + JSON.parse(new TextDecoder().decode(request.body.body)).grant_id, + ); + } yield* fs.writeFileString(marker, "redeemed").pipe(Effect.orDie); return HttpClientResponse.fromWeb(request, Response.json({ result: claim.result })); } @@ -664,8 +677,8 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { const before = yield* instance!.snapshot.refresh; expect(before.usageLimits?.windows[0]?.usedPercent).toBe(100); expect(before.usageLimits?.resetCredits?.nextCreditId).toBe("grant_a"); - const outcome = yield* instance!.consumeResetCredit!().pipe(Effect.result); - return { outcome, after: yield* instance!.snapshot.getSnapshot }; + const outcome = yield* instance!.consumeResetCredit!(claim.creditId).pipe(Effect.result); + return { outcome, claimedGrantIds, after: yield* instance!.snapshot.getSnapshot }; }).pipe( // macOS logins live in the Keychain, where resets are never read. Effect.provideService(HostProcess.Platform, "linux"), @@ -674,11 +687,13 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { it.live.skipIf(windowsHost)("refreshes Claude usage after redeeming a reset", () => Effect.gen(function* () { - const { outcome, after } = yield* redeemClaudeReset({ + const { outcome, claimedGrantIds, after } = yield* redeemClaudeReset({ result: "reset", usageFailsAfterClaim: false, + creditId: "grant_b", }); expect(outcome).toMatchObject({ _tag: "Success", success: "reset" }); + expect(claimedGrantIds).toEqual(["grant_b"]); expect(after.usageLimits?.windows[0]?.usedPercent).toBe(0); }), ); diff --git a/apps/server/src/provider/claudeResetCredits.test.ts b/apps/server/src/provider/claudeResetCredits.test.ts index 8db751657454..f09fd187a6ce 100644 --- a/apps/server/src/provider/claudeResetCredits.test.ts +++ b/apps/server/src/provider/claudeResetCredits.test.ts @@ -38,7 +38,7 @@ const respond = (status: number, body: unknown) => const refuseRequests = HttpClient.make(() => Effect.die("must not send a request")); describe("claudeResetCreditsToContract", () => { - it("counts live grants and pins the next usable one", () => { + it("counts live grants, pins the next usable one and lists waiting grants", () => { expect( ClaudeResetCredits.claudeResetCreditsToContract( { @@ -54,6 +54,8 @@ describe("claudeResetCreditsToContract", () => { grant({ id: "empty", ends_at: "" }), grant({ id: "Not Valid" }), grant({ id: "grant_b", resets_left: 3, usable_now: false }), + grant({ id: "at_limit", usable_now: false, use_requires_limit: true }), + grant({ id: "spent", resets_left: 0 }), ], }, NOW, @@ -62,16 +64,64 @@ describe("claudeResetCreditsToContract", () => { availableCount: 2, nextCreditId: "grant_a", nextExpiresAt: "2026-10-01T00:00:00.000Z", + grants: [ + { id: "grant_a", availableCount: 2, expiresAt: "2026-10-01T00:00:00.000Z" }, + { id: "paused", availableCount: 1, waiting: "paused" }, + { id: "grant_b", availableCount: 3, waiting: "unavailable" }, + { id: "at_limit", availableCount: 1, waiting: "afterLimit" }, + ], + }); + }); + + it("keeps the usage windows each grant clears", () => { + expect( + ClaudeResetCredits.claudeResetCreditsToContract( + { + eligible: true, + next_grant_id: "launch-fable", + grants: [ + grant({ id: "standard", clears: ["five_hour", "seven_day"] }), + grant({ + id: "launch-fable", + clears: ["five_hour", "seven_day_overage_included"], + gives_back_weekly: true, + }), + grant({ id: "legacy" }), + ], + }, + NOW, + "Fable", + ), + ).toEqual({ + availableCount: 3, + nextCreditId: "launch-fable", + grants: [ + { + id: "standard", + availableCount: 1, + clearsWindowIds: ["five_hour", "seven_day"], + }, + { + id: "launch-fable", + availableCount: 1, + clearsWindowIds: ["five_hour", "seven_day_fable"], + givesBackWeekly: true, + }, + { id: "legacy", availableCount: 1 }, + ], }); }); it("offers nothing to redeem without a usable next grant or an eligible account", () => { expect( ClaudeResetCredits.claudeResetCreditsToContract( - { eligible: true, next_grant_id: "grant_a", grants: [grant({ usable_now: false })] }, + { eligible: true, next_grant_id: "grant_a", grants: [grant({ paused: true })] }, NOW, ), - ).toEqual({ availableCount: 0 }); + ).toEqual({ + availableCount: 0, + grants: [{ id: "grant_a", availableCount: 1, waiting: "paused" }], + }); expect( ClaudeResetCredits.claudeResetCreditsToContract({ eligible: true, grants: [grant({})] }, NOW), ).toEqual({ @@ -85,6 +135,52 @@ describe("claudeResetCreditsToContract", () => { ).toBeUndefined(); expect(ClaudeResetCredits.claudeResetCreditsToContract(undefined, NOW)).toBeUndefined(); }); + + it("uses an available weekly reset before banked grants", () => { + const banked = { + eligible: true, + next_grant_id: "grant_a", + grants: [grant({ resets_left: 2, ends_at: "2026-10-01T00:00:00Z" })], + }; + expect( + ClaudeResetCredits.claudeResetCreditsToContract(banked, NOW, undefined, { available: true }), + ).toEqual({ + availableCount: 3, + nextCreditId: ClaudeResetCredits.WEEKLY_RESET_ID, + nextExpiresAt: "2026-10-01T00:00:00.000Z", + grants: [ + { + id: ClaudeResetCredits.WEEKLY_RESET_ID, + availableCount: 1, + clearsWindowIds: ["seven_day"], + }, + { id: "grant_a", availableCount: 2, expiresAt: "2026-10-01T00:00:00.000Z" }, + ], + }); + expect( + ClaudeResetCredits.claudeResetCreditsToContract({ eligible: false }, NOW, undefined, { + eligible: true, + available: true, + }), + ).toEqual({ + availableCount: 1, + nextCreditId: ClaudeResetCredits.WEEKLY_RESET_ID, + grants: [ + { + id: ClaudeResetCredits.WEEKLY_RESET_ID, + availableCount: 1, + clearsWindowIds: ["seven_day"], + }, + ], + }); + for (const weekly of [{ available: false }, { available: "true" }, null]) { + expect( + ClaudeResetCredits.claudeResetCreditsToContract(banked, NOW, undefined, weekly), + ).toMatchObject({ + nextCreditId: "grant_a", + }); + } + }); }); effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { @@ -94,7 +190,7 @@ effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { const client = HttpClient.make((request) => { expect(request.method).toBe("GET"); expect(request.url).toBe("https://api.anthropic.com/api/oauth/usage"); - expect(UrlParams.toString(request.urlParams)).toBe("cedar_ember=1&skip_spend=1"); + expect(UrlParams.toString(request.urlParams)).toBe("cedar_ember=1&at_wall=1&skip_spend=1"); 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)"); @@ -107,22 +203,38 @@ effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { ), ); }); - const credits = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( + const credits = yield* ClaudeResetCredits.readClaudeResetCredits( + configDir, + "2.1.0", + undefined, + ).pipe( Effect.provideService(HostProcess.Platform, "linux"), Effect.provideService(HttpClient.HttpClient, client), ); - expect(credits).toEqual({ availableCount: 1, nextCreditId: "grant_a" }); + expect(credits).toEqual({ + availableCount: 1, + nextCreditId: "grant_a", + grants: [{ id: "grant_a", availableCount: 1 }], + }); }), ); 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.readClaudeResetCredits( + configDir, + "2.1.0", + undefined, + ).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.readClaudeResetCredits( + configDir, + "2.1.0", + undefined, + ).pipe( Effect.provideService(HostProcess.Platform, "linux"), Effect.provideService(HttpClient.HttpClient, respond(429, {})), ); @@ -136,7 +248,7 @@ const ClaimBody = Schema.fromJsonString( ); const decodeClaimBody = Schema.decodeEffect(ClaimBody); -const consume = (client: HttpClient.HttpClient, ids = { grantId: "grant_a", requestId: "r-1" }) => +const consume = (client: HttpClient.HttpClient, ids = { creditId: "grant_a", requestId: "r-1" }) => Effect.gen(function* () { const login = yield* writeLogin; return yield* ClaudeResetCredits.consumeClaudeResetCredit({ @@ -174,6 +286,25 @@ effectIt.layer(NodeServices.layer)("consumeClaudeResetCredit", (it) => { }), ); + it.effect("claims the weekly reset by its program alone", () => + Effect.gen(function* () { + const client = HttpClient.make((request) => { + expect(request.url).toBe( + "https://api.anthropic.com/api/organizations/org-1/reset_rate_limits", + ); + const body = + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""; + expect(JSON.parse(body)).toEqual({ program: "juniper_tide" }); + return Effect.succeed( + HttpClientResponse.fromWeb(request, Response.json({ result: "reset" })), + ); + }); + expect( + yield* consume(client, { creditId: ClaudeResetCredits.WEEKLY_RESET_ID, requestId: "r-1" }), + ).toMatchObject({ _tag: "Success", success: "reset" }); + }), + ); + it.effect("maps each answer to an outcome or a failure", () => Effect.gen(function* () { for (const [result, outcome] of [ @@ -209,8 +340,8 @@ effectIt.layer(NodeServices.layer)("consumeClaudeResetCredit", (it) => { it.effect("refuses malformed ids without sending anything", () => Effect.gen(function* () { for (const ids of [ - { grantId: "Bad Grant", requestId: "r-1" }, - { grantId: "grant_a", requestId: "has space" }, + { creditId: "Bad Grant", requestId: "r-1" }, + { creditId: "grant_a", requestId: "has space" }, ]) { expect(yield* consume(refuseRequests, ids)).toMatchObject({ _tag: "Failure" }); } diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 35973378edad..7640a3599a71 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -11,9 +11,13 @@ 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}$/; +/** The weekly at-limit reset (`juniper_tide`) has no grant; the colon keeps this id out of GRANT_ID. */ +export const WEEKLY_RESET_ID = "weekly:juniper_tide"; const REQUEST_ID = /^[A-Za-z0-9_-]{1,64}$/; const COMPLETE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; @@ -31,6 +35,9 @@ const Grant = Schema.Struct({ ends_at: Schema.optional(Schema.NullOr(Schema.String)), paused: Schema.optional(Schema.Boolean), usable_now: Schema.optional(Schema.Boolean), + use_requires_limit: Schema.optional(Schema.NullOr(Schema.Boolean)), + clears: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), + gives_back_weekly: Schema.optional(Schema.NullOr(Schema.Boolean)), }); const decodeGrant = Schema.decodeUnknownOption(Grant); const CedarEmber = Schema.Struct({ @@ -40,9 +47,13 @@ const CedarEmber = Schema.Struct({ }); const UsageResponse = Schema.Struct({ cedar_ember: Schema.optional(Schema.NullOr(Schema.Unknown)), + juniper_tide: Schema.optional(Schema.NullOr(Schema.Unknown)), }); const decodeCedarEmber = Schema.decodeUnknownOption(CedarEmber); -const ClaimResponse = Schema.Struct({ +const decodeJuniperTide = Schema.decodeUnknownOption( + Schema.Struct({ available: Schema.optional(Schema.Boolean) }), +); +export const ClaimResponse = Schema.Struct({ result: Schema.Literals([ "reset", "already_used", @@ -95,28 +106,80 @@ const isFutureTimestamp = (value: string, nowMs: number) => { ); }; +/** + * Maps the usage endpoint's `cedar_ember` (banked grants) and `juniper_tide` (weekly + * at-limit reset) blocks. An available weekly reset is used first: it is free and + * comes back every week, while banked grants expire. + */ export function claudeResetCreditsToContract( block: unknown, nowMs: number, + overageIncluded?: string, + weeklyBlock?: unknown, +): ServerProviderResetCredits | undefined { + const banked = bankedCredits(block, nowMs, overageIncluded); + if (!Option.exists(decodeJuniperTide(weeklyBlock), (weekly) => weekly.available === true)) { + return banked; + } + return { + ...banked, + availableCount: (banked?.availableCount ?? 0) + 1, + nextCreditId: WEEKLY_RESET_ID, + grants: [ + { id: WEEKLY_RESET_ID, availableCount: 1, clearsWindowIds: ["seven_day"] }, + ...(banked?.grants ?? []), + ], + }; +} + +function bankedCredits( + block: unknown, + nowMs: number, + overageIncluded: string | undefined, ): ServerProviderResetCredits | undefined { const parsed = decodeCedarEmber(block); if (Option.isNone(parsed) || !parsed.value.eligible) return undefined; - const live = (parsed.value.grants ?? []) + const current = (parsed.value.grants ?? []) .flatMap((raw) => Option.toArray(decodeGrant(raw))) .filter( (grant) => - !grant.paused && - grant.usable_now && - (grant.ends_at == null || isFutureTimestamp(grant.ends_at, nowMs)), + grant.resets_left > 0 && (grant.ends_at == null || isFutureTimestamp(grant.ends_at, nowMs)), ); + const waitingReason = (grant: (typeof current)[number]) => + grant.paused + ? ("paused" as const) + : grant.usable_now + ? undefined + : grant.use_requires_limit + ? ("afterLimit" as const) + : ("unavailable" as const); + const live = current.filter((grant) => waitingReason(grant) === undefined); const next = live.find((grant) => grant.id === parsed.value.next_grant_id); - const nextExpiresAt = next?.ends_at ? DateTime.make(next.ends_at) : Option.none(); + const expiresAt = (grant: (typeof current)[number]) => { + const date = grant.ends_at ? DateTime.make(grant.ends_at) : Option.none(); + return Option.isSome(date) ? DateTime.formatIso(date.value) : undefined; + }; + // Without Claude's next grant nothing is offered, but waiting grants are still listed. + const grants = (next ? current : current.filter(waitingReason)).map((grant) => { + const grantExpiresAt = expiresAt(grant); + const clears = grant.clears?.map((bucket) => claudeUsageWindowId(bucket, overageIncluded)); + const waiting = waitingReason(grant); + return { + id: grant.id, + availableCount: grant.resets_left, + ...(grantExpiresAt ? { expiresAt: grantExpiresAt } : {}), + ...(clears?.length ? { clearsWindowIds: clears } : {}), + ...(grant.gives_back_weekly ? { givesBackWeekly: true } : {}), + ...(waiting ? { waiting } : {}), + }; + }); + if (!next) return { availableCount: 0, ...(grants.length > 0 ? { grants } : {}) }; + const nextExpiresAt = expiresAt(next); return { - availableCount: next ? live.reduce((sum, grant) => sum + grant.resets_left, 0) : 0, - ...(Option.isSome(nextExpiresAt) - ? { nextExpiresAt: DateTime.formatIso(nextExpiresAt.value) } - : {}), - ...(next ? { nextCreditId: next.id } : {}), + availableCount: live.reduce((sum, grant) => sum + grant.resets_left, 0), + ...(nextExpiresAt ? { nextExpiresAt } : {}), + nextCreditId: next.id, + grants, }; } @@ -151,13 +214,13 @@ const withClaudeHeaders = (token: string, version: string) => }); export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( - function* (configDir: string, version: string) { + function* (configDir: string, version: string, overageIncluded: string | undefined) { const token = yield* readAccessToken(configDir); if (!token) return undefined; const client = yield* HttpClient.HttpClient; const response = yield* client.execute( HttpClientRequest.get(`${API_BASE}/api/oauth/usage`, { - urlParams: { cedar_ember: "1", skip_spend: "1" }, + urlParams: { cedar_ember: "1", at_wall: "1", skip_spend: "1" }, }).pipe(withClaudeHeaders(token, version)), ); const body = yield* HttpClientResponse.schemaBodyJson(UsageResponse)( @@ -166,6 +229,8 @@ export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( return claudeResetCreditsToContract( body.cedar_ember, DateTime.toEpochMillis(yield* DateTime.now), + overageIncluded, + body.juniper_tide, ); }, Effect.timeout("10 seconds"), @@ -187,22 +252,37 @@ export const claudeAccountConfigPath = Effect.fn("claudeAccountConfigPath")(func : path.join(yield* HostProcess.HomeDirectory, ".claude.json"); }); -const CLAIM_OUTCOMES = { +export const CLAIM_OUTCOMES = { reset: "reset", not_limited: "nothingToReset", already_used: "alreadyRedeemed", ineligible: "noCredit", } as const satisfies Record; +/** + * Builds the body Claude's CLI posts to `reset_rate_limits` for each attempt: the weekly + * reset names only its program, a grant also carries its id and the attempt's request id. + * Undefined for a malformed credit id. + */ +export const claudeClaim = ( + creditId: string, +): ((requestId: string) => Record) | undefined => + creditId === WEEKLY_RESET_ID + ? () => ({ program: "juniper_tide" }) + : GRANT_ID.test(creditId) + ? (requestId) => ({ program: PROGRAM, grant_id: creditId, request_id: requestId }) + : undefined; + /** requestId is the claim's idempotency key. */ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(function* (input: { readonly configDir: string; readonly accountConfigPath: string; readonly version: string; - readonly grantId: string; + readonly creditId: string; readonly requestId: string; }) { - if (!GRANT_ID.test(input.grantId) || !REQUEST_ID.test(input.requestId)) { + const claim = claudeClaim(input.creditId); + if (!claim || !REQUEST_ID.test(input.requestId)) { return yield* new ClaudeResetCreditError({ reason: "malformedCredit" }); } const token = yield* readAccessToken(input.configDir).pipe( @@ -225,11 +305,7 @@ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(fu ), ).pipe( withClaudeHeaders(token, input.version), - HttpClientRequest.bodyJsonUnsafe({ - program: PROGRAM, - grant_id: input.grantId, - request_id: input.requestId, - }), + HttpClientRequest.bodyJsonUnsafe(claim(input.requestId)), ), ) .pipe( diff --git a/apps/server/src/provider/claudeUsageLimits.ts b/apps/server/src/provider/claudeUsageLimits.ts index d0acf43bb496..b753182a18a3 100644 --- a/apps/server/src/provider/claudeUsageLimits.ts +++ b/apps/server/src/provider/claudeUsageLimits.ts @@ -51,6 +51,13 @@ function scopedWindowId(displayName: string): string { return `seven_day_${displayName.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`; } +/** 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/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 431af7b5971f..50b663cf2762 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -340,25 +340,43 @@ describe("CLIProxyAPI built-in management API", () => { }), ); - it.effect("maps Claude scoped windows without a scheduler plugin", () => + const claudeUsage = { + five_hour: { utilization: 10, resets_at: null }, + seven_day: { utilization: 50, resets_at: "2099-01-01T00:00:00Z" }, + limits: [ + { + kind: "weekly_scoped", + percent: 80, + resets_at: null, + scope: { model: { display_name: "Fable" } }, + }, + ], + cedar_ember: { + eligible: true, + grants: [ + { + id: "free_reset", + resets_total: 1, + resets_left: 1, + ends_at: "2099-01-01T00:00:00Z", + paused: false, + usable_now: true, + clears: ["five_hour", "seven_day_overage_included"], + }, + ], + next_grant_id: "free_reset", + }, + }; + + it.effect("maps Claude scoped windows and banked resets without a scheduler plugin", () => Effect.gen(function* () { const test = fixture({ accounts: [{ ...accounts[0]!, provider: "claude" }], - // The usage endpoint answers 429 to requests without a Claude Code user agent. + // The usage endpoint answers 429, and reports no reset grants, to requests + // without a Claude CLI user agent. upstream: (request) => ({ - status: request.header?.["User-Agent"]?.startsWith("claude-code/") ? 200 : 429, - body: { - five_hour: { utilization: 10, resets_at: null }, - seven_day: { utilization: 50, resets_at: "2099-01-01T00:00:00Z" }, - limits: [ - { - kind: "weekly_scoped", - percent: 80, - resets_at: null, - scope: { model: { display_name: "Fable" } }, - }, - ], - }, + status: request.header?.["User-Agent"]?.startsWith("claude-cli/") ? 200 : 429, + body: claudeUsage, }), }); const api = yield* test.api; @@ -370,6 +388,127 @@ describe("CLIProxyAPI built-in management API", () => { ["seven_day", 50], ["seven_day_fable", 80], ]); + expect(result[0]?.usageLimits.resetCredits).toEqual({ + availableCount: 1, + nextCreditId: "free_reset", + nextExpiresAt: "2099-01-01T00:00:00.000Z", + grants: [ + { + id: "free_reset", + availableCount: 1, + expiresAt: "2099-01-01T00:00:00.000Z", + clearsWindowIds: ["five_hour", "seven_day_fable"], + }, + ], + }); + expect(test.requests.find((request) => request.body?.url)?.body?.url).toContain( + "cedar_ember=1&at_wall=1", + ); + }), + ); + + it.effect("offers a Claude hub account's weekly reset first and claims it by program", () => + Effect.gen(function* () { + const test = fixture({ + accounts: [{ ...accounts[0]!, provider: "claude" }], + upstream: (request) => + request.url?.endsWith("/api/oauth/profile") + ? { status: 200, body: { organization: { uuid: "org-1" } } } + : request.url?.endsWith("/reset_rate_limits") + ? { status: 200, body: { result: "reset" } } + : { status: 200, body: { ...claudeUsage, juniper_tide: { available: true } } }, + }); + const api = yield* test.api; + const [account] = yield* api.readAccounts(config); + expect(account?.usageLimits.resetCredits).toMatchObject({ + availableCount: 2, + nextCreditId: "weekly:juniper_tide", + nextExpiresAt: "2099-01-01T00:00:00.000Z", + grants: [ + { id: "weekly:juniper_tide", availableCount: 1, clearsWindowIds: ["seven_day"] }, + { id: "free_reset", availableCount: 1 }, + ], + }); + expect(yield* api.consume(config, "first.json", "weekly:juniper_tide")).toEqual({ + outcome: "reset", + }); + const claim = test.requests.find((request) => + request.body?.url?.endsWith("/api/organizations/org-1/reset_rate_limits"), + ); + expect(JSON.parse(claim?.body?.data ?? "{}")).toEqual({ program: "juniper_tide" }); + expect(test.requests.filter((request) => request.path.endsWith("/reset-quota"))).toHaveLength( + 1, + ); + }), + ); + + it.effect("claims a Claude grant through the hub and clears only that account's cooldown", () => + Effect.gen(function* () { + let result = "reset"; + let status = 200; + const outcomes = new Map(); + const test = fixture({ + accounts: [{ ...accounts[0]!, provider: "claude" }], + upstream: (request) => { + if (request.url?.endsWith("/api/oauth/profile")) { + return { status: 200, body: { organization: { uuid: "org-1" } } }; + } + const key = request.data!; + const outcome = outcomes.get(key) ?? result; + if (status === 200) outcomes.set(key, outcome); + return { status, body: { result: outcome } }; + }, + }); + const api = yield* test.api; + expect(yield* api.consume(config, "first.json", "free_reset")).toEqual({ outcome: "reset" }); + result = "not_limited"; + expect(yield* api.consume(config, "first.json", "free_reset")).toEqual({ + outcome: "nothingToReset", + }); + status = 503; + expect( + (yield* api.consume(config, "first.json", "free_reset").pipe(Effect.result))._tag, + ).toBe("Failure"); + status = 200; + result = "reset"; + expect(yield* api.consume(config, "first.json", "free_reset")).toEqual({ outcome: "reset" }); + result = "cooldown"; + expect( + (yield* api.consume(config, "first.json", "free_reset").pipe(Effect.result))._tag, + ).toBe("Failure"); + result = "reset"; + expect(yield* api.consume(config, "first.json", "free_reset")).toEqual({ outcome: "reset" }); + for (const rejectedStatus of [401, 403, 429]) { + status = rejectedStatus; + expect( + (yield* api.consume(config, "first.json", "free_reset").pipe(Effect.result))._tag, + ).toBe("Failure"); + status = 200; + expect(yield* api.consume(config, "first.json", "free_reset")).toEqual({ + outcome: "reset", + }); + } + const claims = test.requests.filter((request) => + request.body?.url?.endsWith("/api/organizations/org-1/reset_rate_limits"), + ); + expect(claims).toHaveLength(12); + for (const index of [6, 8, 10]) { + expect(claims[index]?.body?.data).not.toBe(claims[index + 1]?.body?.data); + } + expect(claims[0]?.body?.data).not.toBe(claims[1]?.body?.data); + expect(claims[1]?.body?.data).not.toBe(claims[2]?.body?.data); + expect(claims[2]?.body?.data).toBe(claims[3]?.body?.data); + expect(claims[4]?.body?.data).not.toBe(claims[5]?.body?.data); + expect(JSON.parse(claims[0]?.body?.data ?? "{}")).toMatchObject({ + program: "cedar_ember", + grant_id: "free_reset", + request_id: expect.stringMatching(/^[0-9a-f-]{36}$/), + }); + expect( + test.requests + .filter((request) => request.path.endsWith("/reset-quota")) + .map((request) => request.body?.auth_index), + ).toEqual(["a", "a", "a", "a", "a", "a"]); }), ); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 030983f50e33..ac86dc38bcf1 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -23,6 +23,13 @@ import { import { codexPlanLabel } from "../provider/CodexProvider.ts"; import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts"; import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts"; +import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts"; +import { + CLAIM_OUTCOMES as CLAUDE_CLAIM_OUTCOMES, + ClaimResponse as ClaudeClaimResponse, + claudeClaim, + claudeResetCreditsToContract, +} from "../provider/claudeResetCredits.ts"; import { antigravityAccountFingerprint, antigravitySummaryWindows, @@ -104,6 +111,11 @@ const ClaudeUsage = Schema.Struct({ }), ), ), + cedar_ember: Schema.optional(Schema.NullOr(Schema.Unknown)), + juniper_tide: Schema.optional(Schema.NullOr(Schema.Unknown)), +}); +const ClaudeProfile = Schema.Struct({ + organization: Schema.optional(Schema.NullOr(Schema.Struct({ uuid: Schema.String }))), }); const CreditList = Schema.Struct({ credits: Schema.Array( @@ -121,6 +133,8 @@ const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const decodeApiResponse = Schema.decodeUnknownEffect(ApiResponse); const decodeCreditList = Schema.decodeUnknownEffect(Schema.fromJsonString(CreditList)); const decodeClaudeUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(ClaudeUsage)); +const decodeClaudeProfile = Schema.decodeUnknownEffect(Schema.fromJsonString(ClaudeProfile)); +const decodeClaudeClaim = Schema.decodeUnknownEffect(Schema.fromJsonString(ClaudeClaimResponse)); const decodeCodexUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(CodexUsage)); const decodeGrokUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(GrokUsageResponse)); const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); @@ -168,6 +182,7 @@ const decodeConsumeResponse = Schema.decodeUnknownEffect( ), ); +const CLAUDE_BASE = "https://api.anthropic.com"; const CODEX_BASE = "https://chatgpt.com/backend-api/wham"; const CREDIT_URL = `${CODEX_BASE}/rate-limit-reset-credits`; @@ -290,6 +305,60 @@ const CODEX_HUB: HubProvider = { }), }; +const CLAUDE_HUB: HubProvider = { + driver: ProviderDriverKind.make("claudeAgent"), + header: () => ({ + Authorization: "Bearer $TOKEN$", + "Content-Type": "application/json", + "anthropic-beta": "oauth-2025-04-20", + // The OAuth usage endpoint answers 429 without a Claude CLI user agent and + // reports banked reset grants only to CLI versions from 2.1.280 on. + "User-Agent": "claude-cli/2.1.312 (external, cli)", + }), + read: Effect.fnUntraced(function* ({ account, call, checkedAt }) { + // at_wall=1 adds the weekly at-limit reset (juniper_tide) beside the banked grants. + const usage = yield* decodeClaudeUsage( + yield* call(`${CLAUDE_BASE}/api/oauth/usage?cedar_ember=1&at_wall=1`), + ); + const model_scoped = (usage.limits ?? []).flatMap((limit) => + limit.kind === "weekly_scoped" && limit.scope?.model && typeof limit.percent === "number" + ? [ + { + display_name: limit.scope.model.display_name, + utilization: limit.percent, + resets_at: limit.resets_at ?? null, + }, + ] + : [], + ); + // A model-scoped grant clears the scoped weekly window, named after its model. + const resetCredits = claudeResetCreditsToContract( + usage.cedar_ember, + DateTime.toEpochMillis(yield* DateTime.now), + model_scoped[0]?.display_name, + usage.juniper_tide, + ); + const rateLimits = { + five_hour: usage.five_hour ?? null, + seven_day: usage.seven_day ?? null, + model_scoped, + spend: usage.spend, + extra_usage: usage.extra_usage ?? null, + }; + return { + ...listedIdentity(account), + plan: "Claude Subscription", + usageLimits: { + ...claudeUsageResponseToLimits({ + checkedAt, + response: { rate_limits_available: true, rate_limits: rateLimits }, + }).limits, + ...(resetCredits ? { resetCredits } : {}), + }, + }; + }), +}; + const GROK_CLI_VERSION = "1.0.77"; const ANTIGRAVITY_SUMMARY_URL = "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"; @@ -297,49 +366,7 @@ const ANTIGRAVITY_SUMMARY_URL = // Keyed by the auth-files `provider`; accounts of any other provider are skipped. const HUB_PROVIDERS = new Map([ ["codex", CODEX_HUB], - [ - "claude", - { - driver: ProviderDriverKind.make("claudeAgent"), - header: () => ({ - Authorization: "Bearer $TOKEN$", - "anthropic-beta": "oauth-2025-04-20", - // The OAuth usage endpoint can return 429 without a Claude Code user agent. - "User-Agent": "claude-code/2.1.273", - }), - read: Effect.fnUntraced(function* ({ account, call, checkedAt }) { - const usage = yield* decodeClaudeUsage( - yield* call("https://api.anthropic.com/api/oauth/usage"), - ); - const model_scoped = (usage.limits ?? []).flatMap((limit) => - limit.kind === "weekly_scoped" && limit.scope?.model && typeof limit.percent === "number" - ? [ - { - display_name: limit.scope.model.display_name, - utilization: limit.percent, - resets_at: limit.resets_at ?? null, - }, - ] - : [], - ); - const rateLimits = { - five_hour: usage.five_hour ?? null, - seven_day: usage.seven_day ?? null, - model_scoped, - spend: usage.spend, - extra_usage: usage.extra_usage ?? null, - }; - return { - ...listedIdentity(account), - plan: "Claude Subscription", - usageLimits: claudeUsageResponseToLimits({ - checkedAt, - response: { rate_limits_available: true, rate_limits: rateLimits }, - }).limits, - }; - }), - }, - ], + ["claude", CLAUDE_HUB], [ "xai", { @@ -400,6 +427,7 @@ const HUB_PROVIDERS = new Map([ export const makeCliproxyApi = Effect.gen(function* () { const client = yield* HttpClient.HttpClient; const digest = yield* Digest.Digest; + const resetCreditCoordinator = yield* ResetCreditCoordinator.make; const platform = yield* HostProcess.Platform; const architecture = yield* HostProcess.Architecture; @@ -444,6 +472,7 @@ export const makeCliproxyApi = Effect.gen(function* () { header: Record, url: string, data?: unknown, + onRejected?: (status: number) => UsageLimitSourceError | undefined, ) { const raw = yield* management(hub, "api-call", { auth_index: account.auth_index, @@ -454,9 +483,12 @@ export const makeCliproxyApi = Effect.gen(function* () { }); const response = yield* decodeApiResponse(raw); if (response.status_code < 200 || response.status_code >= 300) { - return yield* new UsageLimitSourceError({ - detail: `The provider refused the hub request (HTTP ${response.status_code}).`, - }); + return yield* ( + onRejected?.(response.status_code) ?? + new UsageLimitSourceError({ + detail: `The provider refused the hub request (HTTP ${response.status_code}).`, + }) + ); } return response.body; }); @@ -503,6 +535,93 @@ export const makeCliproxyApi = Effect.gen(function* () { ); }); + const redeemCodexCredit = Effect.fn("CliproxyApi.redeemCodexCredit")(function* ( + hub: CliproxyHub, + account: AuthFile, + creditId: string, + ) { + const body = yield* apiCall( + hub, + account, + CODEX_HUB.header(account, platform, architecture), + `${CREDIT_URL}/consume`, + { + redeem_request_id: yield* creditRedeemRequestId( + account.id_token?.chatgpt_account_id ?? account.id, + creditId, + ).pipe(Effect.provideService(Digest.Digest, digest)), + credit_id: creditId, + }, + ); + const response = yield* decodeConsumeResponse(body); + return ( + { + reset: "reset", + nothing_to_reset: "nothingToReset", + no_credit: "noCredit", + already_redeemed: "alreadyRedeemed", + } as const + )[response.code]; + }); + + // Same claim the Claude CLI sends; a grant's request_id is the idempotency key, so a + // retry addresses the claim already made. + const claimClaudeGrant = Effect.fn("CliproxyApi.claimClaudeGrant")(function* ( + hub: CliproxyHub, + account: AuthFile, + creditId: string, + ) { + const claimBody = claudeClaim(creditId); + if (!claimBody) { + return yield* new UsageLimitSourceError({ detail: "The Claude reset grant is not valid." }); + } + const claudeHeader = CLAUDE_HUB.header(account, platform, architecture); + const profile = yield* decodeClaudeProfile( + yield* apiCall(hub, account, claudeHeader, `${CLAUDE_BASE}/api/oauth/profile`), + ); + const organization = profile.organization?.uuid.trim(); + if (!organization) { + return yield* new UsageLimitSourceError({ + detail: "The hub could not read the Claude account's organization.", + }); + } + const coolingDown = new UsageLimitSourceError({ + detail: "Claude resets are cooling down. Try again later.", + }); + const signedOut = new UsageLimitSourceError({ + detail: "The Claude hub account needs to sign in again.", + }); + return yield* resetCreditCoordinator.redeem( + yield* encodeJson([hub.url, organization, creditId]), + (requestId) => + Effect.gen(function* () { + const body = yield* apiCall( + hub, + account, + claudeHeader, + `${CLAUDE_BASE}/api/organizations/${encodeURIComponent(organization)}/reset_rate_limits`, + claimBody(requestId), + (status) => + status === 429 + ? coolingDown + : status === 401 || status === 403 + ? signedOut + : undefined, + ); + const claim = yield* decodeClaudeClaim(body); + if (claim.result === "cooldown") return yield* coolingDown; + if (claim.result === "unavailable") { + return yield* new UsageLimitSourceError({ + detail: + "Claude could not confirm the reset. If you are still limited in a moment, try again.", + }); + } + return CLAUDE_CLAIM_OUTCOMES[claim.result]; + }), + (error) => error === coolingDown || error === signedOut, + ); + }); + const consume = Effect.fn("CliproxyApi.consume")(function* ( hub: CliproxyHub, accountId: string, @@ -510,33 +629,20 @@ export const makeCliproxyApi = Effect.gen(function* () { ): Effect.fn.Return { const operation = Effect.gen(function* () { const account = (yield* authFiles(hub)).find((account) => account.id === accountId); - if (!account || account.disabled || account.provider !== "codex") { + if ( + !account || + account.disabled || + (account.provider !== "codex" && account.provider !== "claude") + ) { return yield* new UsageLimitSourceError({ - detail: "The Codex hub account is missing or disabled.", + detail: "The hub account is missing or disabled.", }); } - const body = yield* apiCall( - hub, - account, - CODEX_HUB.header(account, platform, architecture), - `${CREDIT_URL}/consume`, - { - redeem_request_id: yield* creditRedeemRequestId( - account.id_token?.chatgpt_account_id ?? account.id, - creditId, - ).pipe(Effect.provideService(Digest.Digest, digest)), - credit_id: creditId, - }, - ); - const response = yield* decodeConsumeResponse(body); - const outcome = ( - { - reset: "reset", - nothing_to_reset: "nothingToReset", - no_credit: "noCredit", - already_redeemed: "alreadyRedeemed", - } as const - )[response.code]; + const outcome = + account.provider === "claude" + ? yield* claimClaudeGrant(hub, account, creditId) + : yield* redeemCodexCredit(hub, account, creditId); + if (outcome !== "reset" && outcome !== "alreadyRedeemed") return { outcome }; const cleared = yield* management(hub, "reset-quota", { auth_index: account.auth_index, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8a8c4e1be719..9721864fcdfd 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2649,7 +2649,7 @@ export const layerWsRpc = ( detail: "This provider does not bank reset credits.", }); } - const outcome = yield* instance.consumeResetCredit().pipe( + const outcome = yield* instance.consumeResetCredit(input.creditId).pipe( Effect.mapError( (error) => new ProviderSetupError({ diff --git a/apps/web/src/components/chat/ComposerUsageLimits.tsx b/apps/web/src/components/chat/ComposerUsageLimits.tsx index b46614a8b418..605714e13d4e 100644 --- a/apps/web/src/components/chat/ComposerUsageLimits.tsx +++ b/apps/web/src/components/chat/ComposerUsageLimits.tsx @@ -152,6 +152,7 @@ function UsageLimitsBannerBody({ environmentId={environmentId} input={resetCreditInput} credits={account.limits.resetCredits} + windows={account.limits.windows} now={now} /> ) : null} diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 43ee023af240..1c8b5cd8225a 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -17,6 +17,9 @@ import { paceOf, paceRemainingPercent, remainingPercent, + type ResetCreditGrantSummary, + resetCreditsForWindow, + withResetCreditId, } from "@t3tools/shared/usageLimits"; import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; import { Fragment, useLayoutEffect, useState } from "react"; @@ -231,7 +234,7 @@ export function LimitWindows({ } const OUTCOME_TEXT: Record = { - reset: "Reset applied. Your windows have cleared.", + reset: "Reset applied.", nothingToReset: "Nothing to reset right now.", noCredit: "No reset credit left.", alreadyRedeemed: "That credit was already redeemed.", @@ -244,15 +247,22 @@ export function useResetCredit( const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope); const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false }); const [confirming, setConfirming] = useState(false); + const [creditId, setCreditId] = useState(undefined); const [busy, setBusy] = useState(false); const [status, setStatus] = useState(null); + const confirm = (grantId: string | undefined) => { + if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; + setCreditId(grantId); + setConfirming(true); + }; + const redeem = async () => { setConfirming(false); if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; setBusy(true); setStatus(null); - const result = await consume({ environmentId, input }); + const result = await consume({ environmentId, input: withResetCreditId(input, creditId) }); setBusy(false); if (result._tag === "Success") { setStatus(result.value.warning ?? OUTCOME_TEXT[result.value.outcome]); @@ -265,7 +275,7 @@ export function useResetCredit( ); }; - return { canManageProviders, confirming, setConfirming, busy, status, redeem }; + return { canManageProviders, confirming, setConfirming, confirm, creditId, busy, status, redeem }; } /** Require confirmation because redemption spends credits. Mount outside popovers, which stack above @@ -274,11 +284,13 @@ export function ResetCreditDialog({ open, onOpenChange, onConfirm, + effect, disabled = false, }: { readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly onConfirm: () => void; + readonly effect?: string | undefined; readonly disabled?: boolean; }) { return ( @@ -287,8 +299,7 @@ export function ResetCreditDialog({ Use a reset credit? - This redeems one credit on your account and clears the current rate-limit windows. It - cannot be undone. + {effect ?? "Clears the current rate-limit windows."} This can't be undone. @@ -303,57 +314,60 @@ export function ResetCreditDialog({ } export function resetCreditsSummary( - credits: ServerProviderResetCredits, + grant: Pick, now: number, compact = false, ): string { - const expiresIn = credits.nextExpiresAt - ? formatDuration(Date.parse(credits.nextExpiresAt) - now) - : null; - if (credits.availableCount === 0) return "No reset credits banked"; - if (compact) - return `${credits.availableCount} banked${expiresIn ? ` · expires in ${expiresIn}` : ""}`; - return `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${ - expiresIn ? ` · next expires in ${expiresIn}` : "" - }`; + const expiresIn = grant.expiresAt ? formatDuration(Date.parse(grant.expiresAt) - now) : null; + if (grant.count === 0) return "No reset credits banked"; + const scope = grant.scope ? `${grant.scope} ` : ""; + // A row can merge grants, so only its first reset is known to expire then. + const expiry = expiresIn + ? ` · ${grant.count > 1 ? "next expires" : "expires"} in ${expiresIn}` + : ""; + if (compact) return `${grant.count} ${scope}${grant.count === 1 ? "reset" : "resets"}${expiry}`; + return `${grant.count} ${scope}${grant.count === 1 ? "reset credit" : "reset credits"} banked${expiry}`; } export function ResetCredits({ environmentId, input, credits, + windows, now, }: { readonly environmentId: EnvironmentId; readonly input: ProviderConsumeResetCreditInput; readonly credits: ServerProviderResetCredits; + readonly windows: ReadonlyArray; readonly now: number; }) { - const { canManageProviders, confirming, setConfirming, busy, status, redeem } = useResetCredit( - environmentId, - input, - ); - if (credits.availableCount === 0 && status === null) return null; + const { canManageProviders, confirming, setConfirming, confirm, creditId, busy, status, redeem } = + useResetCredit(environmentId, input); + const { grants, waiting } = resetCreditsForWindow(credits, windows); + if (grants.length === 0 && !waiting && status === null) return null; return ( -
- {resetCreditsSummary(credits, now)} - {credits.availableCount > 0 ? ( - - ) : null} +
+ {grants.map((grant) => ( +
+ {resetCreditsSummary(grant, now)} + +
+ ))} + {waiting ? {waiting} : null} {status ? {status} : null} void redeem()} + effect={grants.find((grant) => grant.id === creditId)?.effect} disabled={!canManageProviders} />
diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 00f437246584..627f66996338 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -13,6 +13,7 @@ import { type LimitPoolWindow, paceRemainingPercent, remainingPercent, + 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"; @@ -146,7 +147,7 @@ function SegmentPopover({ readonly reset: LimitPoolWindow["resets"][number] | undefined; readonly now: number; readonly redeem: ReturnType | null; - readonly onRedeem: () => void; + readonly onRedeem: (creditId: string | undefined) => void; }) { const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); const remaining = remainingPercent(window); @@ -156,8 +157,11 @@ function SegmentPopover({ account.environments.length > 0 ? account.environments.map((environment) => environment.label).join(", ") : account.sourceLabel; - const credits = - redeem && account.limits.resetCredits?.availableCount ? account.limits.resetCredits : null; + const credits = resetCreditsForWindow( + account.limits.resetCredits, + account.limits.windows, + window.id, + ); return (
@@ -196,20 +200,27 @@ function SegmentPopover({ +{reset.restoresPercent}% of pool ) : null}
- {credits && redeem ? ( -
- - {resetCreditsSummary(credits, now, true)} - - + {(credits.count > 0 && redeem) || credits.waiting ? ( +
+ {redeem + ? credits.grants.map((grant) => ( +
+ + {resetCreditsSummary(grant, now, true)} + + +
+ )) + : null} + {credits.waiting ? {credits.waiting} : null}
) : null}
@@ -264,9 +275,9 @@ function ActiveSegmentPopover({ { + onRedeem={(creditId) => { handle.close(); - payload.redeem?.setConfirming(true); + payload.redeem?.confirm(creditId); }} /> @@ -304,6 +315,11 @@ function RedeemableSegment({ readonly redeemAt: NonNullable; }) { const redeem = useResetCredit(redeemAt.environmentId, redeemAt.input); + const effect = resetCreditsForWindow( + props.account.limits.resetCredits, + props.account.limits.windows, + props.window.id, + ).grants.find((grant) => grant.id === redeem.creditId)?.effect; return ( <> @@ -311,6 +327,7 @@ function RedeemableSegment({ open={redeem.confirming} onOpenChange={redeem.setConfirming} onConfirm={() => void redeem.redeem()} + effect={effect} disabled={!redeem.canManageProviders} /> {/* The popover closed before the confirm, so progress and the outcome need a home outside it. */} @@ -347,7 +364,12 @@ 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, + ); + const creditsLabel = `${credits} ${scope ? `${scope} ` : ""}reset ${credits === 1 ? "credit" : "credits"} banked`; return ( <> } @@ -423,6 +445,9 @@ function SegmentTriggers({ {credits} + {scope ? ( + {scope} + ) : null} ) : null} @@ -450,7 +475,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/docs/user/usage.md b/docs/user/usage.md index e6b3a46d5a14..d65be47a12d3 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -121,8 +121,10 @@ when the next reset lands and how much it hands back. The hatched part of a segment is what that reset restores. Tap a segment or account row for the account's plan, where it is signed in, and its reset time. On web, you can hover too. Codex and Claude accounts with banked reset credits show a ticket count and the **Use reset** action in the account details. +A Claude account at its weekly limit can also have a free weekly reset; it counts as one more +credit and **Use reset** spends it before any banked credit. Claude resets are not available when the server runs on macOS, where Claude keeps its login in the -Keychain. On narrow screens, numbered rows below +Keychain; a hub reports them on any platform. On narrow screens, numbered rows below the bar show each account's quota, countdown, and credits. Tap a row to open its details. The same account signed in on more than one environment, or reported by a hub as well, counts once. @@ -177,8 +179,9 @@ To see pooled accounts, open **Settings → Providers → Usage providers → Ad environment that will connect to the hub and enter its URL and management key. The hub's Codex, Claude, xAI (shown as Grok) and Antigravity accounts appear under **Usage → Limits**; -accounts for other providers are not read. Codex accounts show banked reset credits; select an -account and choose **Use reset** to redeem one. No hub plugin is required. +accounts for other providers are not read. Codex and Claude accounts show banked reset credits, and +Claude accounts also show their weekly reset; select an account and choose **Use reset** to redeem +one. No hub plugin is required. This connection supplies usage information; configure the provider separately to send agent requests through the hub. Remove the hub from the same diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 5c4927d4a164..6c7084a4b332 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -20,11 +20,25 @@ export const ServerProviderUsageWindow = Schema.Struct({ }); export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type; +export const ServerProviderResetCreditGrant = Schema.Struct({ + id: TrimmedNonEmptyString, + availableCount: NonNegativeInt, + expiresAt: Schema.optional(IsoDateTime), + /** Usage window ids this grant clears. Absent means it clears every window. */ + clearsWindowIds: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + /** Also removes the cleared windows' usage from the weekly window it does not clear. */ + givesBackWeekly: Schema.optional(Schema.Boolean), + /** Set when the grant can't be used now; it is left out of `availableCount`. */ + waiting: Schema.optional(Schema.Literals(["afterLimit", "paused", "unavailable"])), +}); +export type ServerProviderResetCreditGrant = typeof ServerProviderResetCreditGrant.Type; + export const ServerProviderResetCredits = Schema.Struct({ availableCount: NonNegativeInt, nextExpiresAt: Schema.optional(IsoDateTime), /** Pins hub redemption to the displayed credit, including retries from another client. */ nextCreditId: Schema.optional(TrimmedNonEmptyString), + grants: Schema.optional(ForwardCompatibleArray(ServerProviderResetCreditGrant)), }); export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type; @@ -87,7 +101,10 @@ export type UsageLimitSourceConsumeResetCreditInput = typeof UsageLimitSourceConsumeResetCreditInput.Type; export const ProviderConsumeResetCreditInput = Schema.Union([ - Schema.Struct({ instanceId: ProviderInstanceId }), + Schema.Struct({ + instanceId: ProviderInstanceId, + creditId: Schema.optional(TrimmedNonEmptyString), + }), UsageLimitSourceConsumeResetCreditInput, ]); export type ProviderConsumeResetCreditInput = typeof ProviderConsumeResetCreditInput.Type; diff --git a/packages/provider-core/src/server/driver.ts b/packages/provider-core/src/server/driver.ts index a1771e2e748e..3fee55c6fbca 100644 --- a/packages/provider-core/src/server/driver.ts +++ b/packages/provider-core/src/server/driver.ts @@ -45,10 +45,9 @@ export interface ProviderInstance { ) => Effect.Effect; readonly refreshModels?: () => Effect.Effect; readonly invalidateCaches?: Effect.Effect; - readonly consumeResetCredit?: () => Effect.Effect< - ProviderConsumeResetCreditOutcome, - ProviderDriverError - >; + readonly consumeResetCredit?: ( + creditId: string | undefined, + ) => Effect.Effect; readonly orchestrationAdapter: ProviderAdapter.ProviderAdapterV2["Service"]; readonly textGeneration: ProviderTextGeneration; readonly auth?: ProviderAuthController; diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index e9d5f6e604f3..8753fd378be0 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -23,6 +23,7 @@ import { paceRemainingPercent, usesChatGptSharing, resolveNewThreadModelSelection, + resetCreditsForWindow, } from "./usageLimits.ts"; const now = Date.parse("2026-09-03T12:00:00.000Z"); @@ -1556,3 +1557,104 @@ 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 fable = { + id: "launch-fable", + availableCount: 1, + expiresAt: "2026-09-10T00:00:00.000Z", + clearsWindowIds: ["five_hour", "seven_day_fable"], + givesBackWeekly: true, + }; + const standard = { + id: "standard", + availableCount: 2, + clearsWindowIds: ["five_hour", "seven_day"], + }; + const credits = { availableCount: 3, nextCreditId: "launch-fable", grants: [fable, standard] }; + + it("counts each grant only on the windows it clears", () => { + expect(resetCreditsForWindow(credits, windows, "seven_day")).toMatchObject({ + count: 2, + scope: undefined, + }); + expect(resetCreditsForWindow(credits, windows, "seven_day_fable")).toMatchObject({ + count: 1, + scope: "Fable", + grants: [ + { + scope: "Fable", + effect: "Clears Weekly · Fable and gives back Fable's share of Weekly.", + expiresAt: "2026-09-10T00:00:00.000Z", + }, + ], + }); + expect(resetCreditsForWindow(credits, windows, "five_hour")).toMatchObject({ + count: 2, + scope: undefined, + grants: [{ id: "standard" }], + }); + expect(resetCreditsForWindow(credits, windows).count).toBe(3); + const everything = { + availableCount: 1, + grants: [ + { id: "full", availableCount: 1, clearsWindowIds: windows.map((window) => window.id) }, + ], + }; + expect(resetCreditsForWindow(everything, windows, "five_hour")).toMatchObject({ + count: 1, + scope: undefined, + grants: [{ effect: "Clears Session, Weekly and Weekly · Fable." }], + }); + }); + + it("shows one row per kind, spends the one expiring first and sums waiting resets", () => { + const fableLate = { + ...fable, + id: "fable-late", + availableCount: 2, + expiresAt: "2026-10-01T00:00:00.000Z", + }; + const weekly = { id: "weekly:juniper_tide", availableCount: 1, clearsWindowIds: ["seven_day"] }; + const account = { + availableCount: 5, + nextCreditId: "weekly:juniper_tide", + grants: [ + weekly, + fableLate, + fable, + { ...standard, availableCount: 1 }, + { id: "later", availableCount: 2, waiting: "afterLimit" as const }, + { id: "held", availableCount: 1, waiting: "paused" as const }, + ], + }; + const summary = resetCreditsForWindow(account, windows); + expect(summary.grants.map(({ id, count, scope }) => ({ id, count, scope }))).toEqual([ + { id: "weekly:juniper_tide", count: 1, scope: "Weekly" }, + { id: "launch-fable", count: 3, scope: "Fable" }, + { id: "standard", count: 1, scope: undefined }, + ]); + expect(summary.count).toBe(5); + expect(summary.waiting).toBe("+3 not usable yet: 2 once you hit a limit they refill, 1 paused"); + expect(resetCreditsForWindow(account, windows, "seven_day").count).toBe(2); + }); + + it("treats credits without grant details as clearing every window", () => { + expect( + resetCreditsForWindow({ availableCount: 2, nextCreditId: "grant_a" }, windows, "seven_day"), + ).toMatchObject({ count: 2, scope: undefined, grants: [{ id: undefined }] }); + expect( + resetCreditsForWindow( + { availableCount: 1, grants: [{ id: "grant_a", availableCount: 1 }] }, + windows, + "seven_day_fable", + ), + ).toMatchObject({ count: 1, scope: undefined, grants: [{ id: "grant_a" }] }); + }); +}); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 1707deb735a4..234c22c8dd37 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -11,6 +11,7 @@ import { isProviderAvailable, PROVIDER_DISPLAY_NAMES, type ServerProvider, + type ServerProviderResetCredits, type ServerProviderUsageLimits, type ServerProviderUsageWindow, type UsageLimitSourceSnapshots, @@ -884,3 +885,145 @@ export function collectProviderUsageLimits( } return { createdAt: DateTime.formatIso(DateTime.makeUnsafe(now)), accounts, notices }; } + +export interface ResetCreditGrantSummary { + readonly id: string | undefined; + readonly count: number; + /** Set when the grant only clears part of the account, e.g. "Fable". */ + readonly scope: string | undefined; + readonly effect: string | undefined; + readonly expiresAt: string | undefined; +} + +export interface WindowResetCredits { + readonly count: number; + /** The scope every counted grant shares, for a compact badge. */ + readonly scope: string | undefined; + /** One row per kind of reset; `id` is the one of that kind that expires first. */ + readonly grants: ReadonlyArray; + /** The account's resets that can't be used now, e.g. "+3 not usable yet: 1 paused". */ + readonly waiting: string | undefined; +} + +const WAITING_TEXT = { + afterLimit: "once you hit a limit they refill", + paused: "paused", + unavailable: "not usable right now", +} as const; + +function waitingSummary(credits: ServerProviderResetCredits): string | undefined { + const counts = new Map(); + for (const grant of credits.grants ?? []) { + if (grant.waiting && grant.availableCount > 0) { + counts.set(grant.waiting, (counts.get(grant.waiting) ?? 0) + grant.availableCount); + } + } + const total = [...counts.values()].reduce((sum, count) => sum + count, 0); + if (total === 0) return undefined; + const parts = [...counts].map(([reason, count]) => `${count} ${WAITING_TEXT[reason]}`); + return `+${total} not usable yet: ${parts.join(", ")}`; +} + +const expiresFirst = (a: string | undefined, b: string | undefined) => + b === undefined || (a !== undefined && Date.parse(a) < Date.parse(b)); + +const joinLabels = (labels: ReadonlyArray) => + labels.length < 2 ? (labels[0] ?? "") : `${labels.slice(0, -1).join(", ")} and ${labels.at(-1)}`; + +/** Grants without a clears list reset every window. Omit `windowId` to describe the whole account. */ +export function resetCreditsForWindow( + credits: ServerProviderResetCredits | undefined, + windows: ReadonlyArray, + windowId?: string, +): WindowResetCredits { + if (!credits) return { count: 0, scope: undefined, grants: [], waiting: undefined }; + if (!credits.grants) { + return { + count: credits.availableCount, + scope: undefined, + waiting: undefined, + grants: + credits.availableCount === 0 + ? [] + : [ + { + id: undefined, + count: credits.availableCount, + scope: undefined, + effect: undefined, + expiresAt: credits.nextExpiresAt, + }, + ], + }; + } + const resettable = windows.filter((window) => window.kind !== "monthly"); + const kinds = new Map(); + credits.grants + .filter((grant) => grant.availableCount > 0 && !grant.waiting) + .flatMap((grant): ResetCreditGrantSummary[] => { + const clears = grant.clearsWindowIds; + const listed = clears ? resettable.filter((window) => clears.includes(window.id)) : []; + const modelScope = + listed.length < resettable.length + ? listed.map((window) => window.label.split(" · ")[1]).find(Boolean) + : undefined; + // Claude lists the shared Session in a model-scoped grant's clears, but redeeming leaves it. + const cleared = modelScope + ? listed.filter((window) => window.label.split(" · ")[1] === modelScope) + : listed; + // A grant that clears one account-wide window (Claude's weekly reset) is named after it. + const scope = + modelScope ?? + (cleared.length === 1 && cleared.length < resettable.length + ? cleared[0]!.label + : undefined); + if (windowId !== undefined && clears && !cleared.some((window) => window.id === windowId)) { + return []; + } + const keptWeekly = clears + ? resettable.find((window) => window.kind === "weekly" && !cleared.includes(window)) + : undefined; + const givesBack = + grant.givesBackWeekly && keptWeekly + ? ` and gives back ${modelScope ? `${modelScope}'s` : "its"} share of ${keptWeekly.label}` + : ""; + return [ + { + id: grant.id, + count: grant.availableCount, + scope, + effect: + cleared.length > 0 + ? `Clears ${joinLabels(cleared.map((window) => window.label))}${givesBack}.` + : undefined, + expiresAt: grant.expiresAt, + }, + ]; + }) + .forEach((grant) => { + const key = `${grant.scope ?? ""}|${grant.effect ?? ""}`; + const kind = kinds.get(key); + kinds.set( + key, + !kind + ? grant + : { + ...(expiresFirst(grant.expiresAt, kind.expiresAt) ? grant : kind), + count: kind.count + grant.count, + }, + ); + }); + const grants = [...kinds.values()]; + const scopes = new Set(grants.map((grant) => grant.scope)); + return { + count: grants.reduce((sum, grant) => sum + grant.count, 0), + scope: scopes.size === 1 ? [...scopes][0] : undefined, + grants, + waiting: waitingSummary(credits), + }; +} + +export const withResetCreditId = ( + input: ProviderConsumeResetCreditInput, + creditId: string | undefined, +): ProviderConsumeResetCreditInput => (creditId ? { ...input, creditId } : input);