From dec16ab8f7a2a2f6b141fb6135d4b8ae65885ade Mon Sep 17 00:00:00 2001 From: Ehsanul Haque Siam Date: Wed, 28 Oct 2026 06:47:26 +0600 Subject: [PATCH 1/7] feat(server): hub usage source reports Claude banked resets A CLIProxyAPI hub account for Claude never showed banked reset credits in Usage > Limits. The hub source only decoded the 5-hour, weekly and model windows, and Anthropic reports the reset-grant block only to requests that identify as a Claude CLI of version 2.1.280 or newer; the source sent an older, differently shaped user agent. Request the usage endpoint with the grant flag and a current CLI user agent, map the grant block with the existing direct-login reader, and let Use reset claim a grant through the hub the same way the CLI does, then clear the account's hub cooldown. --- .../server/src/provider/claudeResetCredits.ts | 8 +- apps/server/src/usage/cliproxyApi.test.ts | 93 +++++++++-- apps/server/src/usage/cliproxyApi.ts | 150 ++++++++++++++---- docs/user/usage.md | 6 +- 4 files changed, 204 insertions(+), 53 deletions(-) diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 35973378edad..6ec3e1120058 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -12,8 +12,8 @@ import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; const API_BASE = "https://api.anthropic.com"; -const PROGRAM = "cedar_ember"; -const GRANT_ID = /^[a-z0-9_-]{1,40}$/; +export const PROGRAM = "cedar_ember"; +export const GRANT_ID = /^[a-z0-9_-]{1,40}$/; 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})$/; @@ -42,7 +42,7 @@ const UsageResponse = Schema.Struct({ cedar_ember: Schema.optional(Schema.NullOr(Schema.Unknown)), }); const decodeCedarEmber = Schema.decodeUnknownOption(CedarEmber); -const ClaimResponse = Schema.Struct({ +export const ClaimResponse = Schema.Struct({ result: Schema.Literals([ "reset", "already_used", @@ -187,7 +187,7 @@ 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", diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 0119adfd940f..a9a1831e8a4f 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -312,25 +312,42 @@ 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, + }, + ], + 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; @@ -342,6 +359,52 @@ 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", + }); + expect(test.requests.find((request) => request.body?.url)?.body?.url).toContain( + "cedar_ember=1", + ); + }), + ); + + it.effect("claims a Claude grant through the hub and clears only that account's cooldown", () => + Effect.gen(function* () { + let result = "reset"; + const test = fixture({ + accounts: [{ ...accounts[0]!, provider: "claude" }], + upstream: (request) => ({ + status: 200, + body: request.url?.endsWith("/api/oauth/profile") + ? { organization: { uuid: "org-1" } } + : request.url?.endsWith("/reset_rate_limits") + ? { result } + : claudeUsage, + }), + }); + 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", + }); + const claims = test.requests.filter((request) => + request.body?.url?.endsWith("/api/organizations/org-1/reset_rate_limits"), + ); + expect(claims).toHaveLength(2); + expect(claims[0]?.body?.data).toBe(claims[1]?.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"]); }), ); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 91504d800229..d19ab5cfb6a3 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -16,6 +16,13 @@ import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } fr import { codexPlanLabel } from "../provider/CodexProvider.ts"; import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts"; import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts"; +import { + CLAIM_OUTCOMES as CLAUDE_CLAIM_OUTCOMES, + ClaimResponse as ClaudeClaimResponse, + GRANT_ID as CLAUDE_GRANT_ID, + PROGRAM as CLAUDE_RESET_PROGRAM, + claudeResetCreditsToContract, +} from "../provider/claudeResetCredits.ts"; import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; const AuthFile = Schema.Struct({ @@ -71,6 +78,10 @@ const ClaudeUsage = Schema.Struct({ }), ), ), + cedar_ember: 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( @@ -88,6 +99,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 isUsageLimitSourceError = Schema.is(UsageLimitSourceError); @@ -133,6 +146,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`; @@ -214,9 +228,11 @@ export const makeCliproxyApi = Effect.gen(function* () { } : { Authorization: "Bearer $TOKEN$", + "Content-Type": "application/json", "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", + // 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)", }; const raw = yield* management(config, "api-call", { auth_index: account.auth_index, @@ -266,8 +282,16 @@ export const makeCliproxyApi = Effect.gen(function* () { }; const read = Effect.gen(function* () { if (account.provider === "claude") { - const body = yield* apiCall(config, account, "https://api.anthropic.com/api/oauth/usage"); + const body = yield* apiCall( + config, + account, + `${CLAUDE_BASE}/api/oauth/usage?cedar_ember=1&skip_spend=1`, + ); const usage = yield* decodeClaudeUsage(body); + const resetCredits = claudeResetCreditsToContract( + usage.cedar_ember, + DateTime.toEpochMillis(yield* DateTime.now), + ); const model_scoped = (usage.limits ?? []).flatMap((limit) => limit.kind === "weekly_scoped" && limit.scope?.model && typeof limit.percent === "number" ? [ @@ -282,17 +306,20 @@ export const makeCliproxyApi = Effect.gen(function* () { return { ...base, plan: "Claude Subscription", - usageLimits: claudeUsageResponseToLimits({ - checkedAt, - response: { - rate_limits_available: true, - rate_limits: { - five_hour: usage.five_hour ?? null, - seven_day: usage.seven_day ?? null, - model_scoped, + usageLimits: { + ...claudeUsageResponseToLimits({ + checkedAt, + response: { + rate_limits_available: true, + rate_limits: { + five_hour: usage.five_hour ?? null, + seven_day: usage.seven_day ?? null, + model_scoped, + }, }, - }, - }).limits, + }).limits, + ...(resetCredits ? { resetCredits } : {}), + }, }; } const body = yield* apiCall(config, account, `${CODEX_BASE}/usage`); @@ -371,6 +398,75 @@ export const makeCliproxyApi = Effect.gen(function* () { ); }); + const redeemCodexCredit = Effect.fn("CliproxyApi.redeemCodexCredit")(function* ( + config: UsageLimitSourceConfig, + account: typeof AuthFile.Type, + creditId: string, + ) { + const body = yield* apiCall(config, account, `${CREDIT_URL}/consume`, { + redeem_request_id: yield* creditRedeemRequestId( + account.id_token?.chatgpt_account_id ?? account.id, + creditId, + ).pipe(Effect.provideService(Crypto.Crypto, crypto)), + 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; request_id is the idempotency key, so a retry + // addresses the claim already made. + const claimClaudeGrant = Effect.fn("CliproxyApi.claimClaudeGrant")(function* ( + config: UsageLimitSourceConfig, + account: typeof AuthFile.Type, + grantId: string, + ) { + if (!CLAUDE_GRANT_ID.test(grantId)) { + return yield* new UsageLimitSourceError({ detail: "The Claude reset grant is not valid." }); + } + const profile = yield* decodeClaudeProfile( + yield* apiCall(config, account, `${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 body = yield* apiCall( + config, + account, + `${CLAUDE_BASE}/api/organizations/${encodeURIComponent(organization)}/reset_rate_limits`, + { + program: CLAUDE_RESET_PROGRAM, + grant_id: grantId, + request_id: yield* creditRedeemRequestId(account.id, grantId).pipe( + Effect.provideService(Crypto.Crypto, crypto), + ), + }, + ); + const claim = yield* decodeClaudeClaim(body); + if (claim.result === "cooldown") { + return yield* new UsageLimitSourceError({ + detail: "Claude resets are cooling down. Try again later.", + }); + } + 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]; + }); + const consume = Effect.fn("CliproxyApi.consume")(function* ( config: UsageLimitSourceConfig, accountId: string, @@ -378,27 +474,19 @@ export const makeCliproxyApi = Effect.gen(function* () { ): Effect.fn.Return { const operation = Effect.gen(function* () { const account = (yield* authFiles(config)).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(config, account, `${CREDIT_URL}/consume`, { - redeem_request_id: yield* creditRedeemRequestId( - account.id_token?.chatgpt_account_id ?? account.id, - creditId, - ).pipe(Effect.provideService(Crypto.Crypto, crypto)), - 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(config, account, creditId) + : yield* redeemCodexCredit(config, account, creditId); if (outcome !== "reset" && outcome !== "alreadyRedeemed") return { outcome }; const cleared = yield* management(config, "reset-quota", { auth_index: account.auth_index, diff --git a/docs/user/usage.md b/docs/user/usage.md index e41e60ca73d7..b82ee4845a00 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -95,7 +95,7 @@ part of a segment is what that reset restores. Tap a segment or account row for 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. 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. @@ -139,8 +139,8 @@ using a proxy through `ANTHROPIC_AUTH_TOKEN`. To see pooled accounts, open **Settings → Providers → Usage providers → Add hub**. Choose the environment that will connect to the hub and enter its URL and management key. -The accounts appear under **Usage → Limits**. Codex accounts show banked reset credits; select an -account and choose **Use reset** to redeem one. No hub plugin is required. +The accounts appear under **Usage → Limits**. Codex and Claude accounts show banked reset credits; +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 From 74575b07a2dac1eee686ce6ef413b40814446643 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 07:58:28 +0000 Subject: [PATCH 2/7] fix(server): renew settled claude hub reset claims --- apps/server/src/usage/cliproxyApi.test.ts | 41 ++++++++++++++----- apps/server/src/usage/cliproxyApi.ts | 50 ++++++++++++----------- 2 files changed, 56 insertions(+), 35 deletions(-) diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 090367561106..a90a96ac8f90 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -373,16 +373,19 @@ describe("CLIProxyAPI built-in management API", () => { 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) => ({ - status: 200, - body: request.url?.endsWith("/api/oauth/profile") - ? { organization: { uuid: "org-1" } } - : request.url?.endsWith("/reset_rate_limits") - ? { result } - : claudeUsage, - }), + 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" }); @@ -390,11 +393,27 @@ describe("CLIProxyAPI built-in management API", () => { 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" }); const claims = test.requests.filter((request) => request.body?.url?.endsWith("/api/organizations/org-1/reset_rate_limits"), ); - expect(claims).toHaveLength(2); - expect(claims[0]?.body?.data).toBe(claims[1]?.body?.data); + expect(claims).toHaveLength(6); + 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", @@ -404,7 +423,7 @@ describe("CLIProxyAPI built-in management API", () => { test.requests .filter((request) => request.path.endsWith("/reset-quota")) .map((request) => request.body?.auth_index), - ).toEqual(["a"]); + ).toEqual(["a", "a", "a"]); }), ); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 8536e7ceee8f..bafab381ba10 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -16,6 +16,7 @@ import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } fr 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, @@ -185,6 +186,7 @@ const creditRedeemRequestId = Effect.fn("CliproxyApi.creditRedeemRequestId")(fun export const makeCliproxyApi = Effect.gen(function* () { const client = yield* HttpClient.HttpClient; const crypto = yield* Crypto.Crypto; + const resetCreditCoordinator = yield* ResetCreditCoordinator.make; const management = Effect.fn("CliproxyApi.management")(function* ( config: UsageLimitSourceConfig, @@ -454,31 +456,31 @@ export const makeCliproxyApi = Effect.gen(function* () { detail: "The hub could not read the Claude account's organization.", }); } - const body = yield* apiCall( - config, - account, - `${CLAUDE_BASE}/api/organizations/${encodeURIComponent(organization)}/reset_rate_limits`, - { - program: CLAUDE_RESET_PROGRAM, - grant_id: grantId, - request_id: yield* creditRedeemRequestId(account.id, grantId).pipe( - Effect.provideService(Crypto.Crypto, crypto), - ), - }, + const coolingDown = new UsageLimitSourceError({ + detail: "Claude resets are cooling down. Try again later.", + }); + return yield* resetCreditCoordinator.redeem( + yield* encodeJson([config.url, organization, grantId]), + (requestId) => + Effect.gen(function* () { + const body = yield* apiCall( + config, + account, + `${CLAUDE_BASE}/api/organizations/${encodeURIComponent(organization)}/reset_rate_limits`, + { program: CLAUDE_RESET_PROGRAM, grant_id: grantId, request_id: requestId }, + ); + 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, ); - const claim = yield* decodeClaudeClaim(body); - if (claim.result === "cooldown") { - return yield* new UsageLimitSourceError({ - detail: "Claude resets are cooling down. Try again later.", - }); - } - 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]; }); const consume = Effect.fn("CliproxyApi.consume")(function* ( From 1b1eb35b9d4fce4faa76998fe74a1ce9b1500fab Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 3 Nov 2026 01:58:01 +0000 Subject: [PATCH 3/7] fix(server): release settled claude hub claim ids --- apps/server/src/usage/cliproxyApi.test.ts | 17 +++++++++++++++-- apps/server/src/usage/cliproxyApi.ts | 21 +++++++++++++++++---- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index a90a96ac8f90..5735faf530c2 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -406,10 +406,23 @@ describe("CLIProxyAPI built-in management API", () => { ).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(6); + 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); @@ -423,7 +436,7 @@ describe("CLIProxyAPI built-in management API", () => { test.requests .filter((request) => request.path.endsWith("/reset-quota")) .map((request) => request.body?.auth_index), - ).toEqual(["a", "a", "a"]); + ).toEqual(["a", "a", "a", "a", "a", "a"]); }), ); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 2aa6596bb44b..ada303550cf0 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -227,6 +227,7 @@ export const makeCliproxyApi = Effect.gen(function* () { account: typeof AuthFile.Type, url: string, data?: unknown, + onRejected?: (status: number) => UsageLimitSourceError | undefined, ) { const header = account.provider === "codex" @@ -256,9 +257,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; }); @@ -459,6 +463,9 @@ export const makeCliproxyApi = Effect.gen(function* () { 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([config.url, organization, grantId]), (requestId) => @@ -468,6 +475,12 @@ export const makeCliproxyApi = Effect.gen(function* () { account, `${CLAUDE_BASE}/api/organizations/${encodeURIComponent(organization)}/reset_rate_limits`, { program: CLAUDE_RESET_PROGRAM, grant_id: grantId, request_id: requestId }, + (status) => + status === 429 + ? coolingDown + : status === 401 || status === 403 + ? signedOut + : undefined, ); const claim = yield* decodeClaudeClaim(body); if (claim.result === "cooldown") return yield* coolingDown; @@ -479,7 +492,7 @@ export const makeCliproxyApi = Effect.gen(function* () { } return CLAUDE_CLAIM_OUTCOMES[claim.result]; }), - (error) => error === coolingDown, + (error) => error === coolingDown || error === signedOut, ); }); From 022d50e3d92bfa059dd04a5aa967a80ae25e91ef Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:52:36 +0100 Subject: [PATCH 4/7] fix(usage): Claude reset badges only show on the limits they clear Claude's reset credits come as grants that each list the usage windows they clear. The Limits UI treated every credit as clearing every window, so a Fable-only reset was badged on Session and Weekly too. The server now forwards each grant's cleared windows and gives_back_weekly, clients count credits per window, and redeeming spends the chosen grant. Fixes #33509 Co-Authored-By: Claude Opus 5.5 --- .../features/threads/ComposerUsageLimits.tsx | 1 + .../src/features/usage/UsageLimitsPooled.tsx | 20 +++- .../src/features/usage/UsageLimitsSection.tsx | 100 ++++++++++-------- apps/server/src/provider/ClaudeProvider.ts | 10 +- .../src/provider/Drivers/ClaudeDriver.ts | 12 ++- .../provider/ProviderInstanceRegistry.test.ts | 25 ++++- .../src/provider/claudeResetCredits.test.ts | 64 ++++++++++- .../server/src/provider/claudeResetCredits.ts | 35 ++++-- apps/server/src/provider/claudeUsageLimits.ts | 7 ++ apps/server/src/ws.ts | 2 +- .../components/chat/ComposerUsageLimits.tsx | 1 + apps/web/src/components/usage/UsageLimits.tsx | 78 ++++++++------ .../components/usage/UsageLimitsPooled.tsx | 74 +++++++++---- packages/contracts/src/providerUsageLimits.ts | 17 ++- packages/provider-core/src/server/driver.ts | 7 +- packages/shared/src/usageLimits.test.ts | 71 +++++++++++++ packages/shared/src/usageLimits.ts | 95 +++++++++++++++++ 17 files changed, 488 insertions(+), 131 deletions(-) diff --git a/apps/mobile/src/features/threads/ComposerUsageLimits.tsx b/apps/mobile/src/features/threads/ComposerUsageLimits.tsx index ef39ef51ff4c..a77ce1ed952c 100644 --- a/apps/mobile/src/features/threads/ComposerUsageLimits.tsx +++ b/apps/mobile/src/features/threads/ComposerUsageLimits.tsx @@ -65,6 +65,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 a9780d9954ae..98880a1f5995 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -14,6 +14,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"; @@ -238,7 +239,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} @@ -326,13 +334,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" @@ -376,6 +388,7 @@ function PoolWindowCard({ {credits} + {scope ? {scope} : null} ) : null} @@ -627,6 +640,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..0a50a7f26f00 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 } = resetCreditsForWindow(credits, windows); + if (dense && grants.length === 0 && 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,59 @@ 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 ? ( + 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 ? ` · 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"} + + + + ); + })} {!canManageProviders ? ( This connection cannot manage provider accounts. diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 5ea745622bb3..1bf2aa281fc5 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -520,7 +520,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, @@ -716,7 +719,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..61ed0d0440d7 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)?.id; if (!grantId || !current.version) return "noCredit" as const; const version = current.version; return yield* resetCreditCoordinator.redeem( diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index e998332d3e92..2675eea43e6e 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -603,8 +603,13 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { }).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..7d53d956960b 100644 --- a/apps/server/src/provider/claudeResetCredits.test.ts +++ b/apps/server/src/provider/claudeResetCredits.test.ts @@ -62,6 +62,46 @@ 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" }], + }); + }); + + 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 }, + ], }); }); @@ -107,22 +147,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, {})), ); diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 35973378edad..b0bf91db443b 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -11,6 +11,8 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import { claudeUsageWindowId } from "./claudeUsageLimits.ts"; + const API_BASE = "https://api.anthropic.com"; const PROGRAM = "cedar_ember"; const GRANT_ID = /^[a-z0-9_-]{1,40}$/; @@ -31,6 +33,8 @@ const Grant = Schema.Struct({ ends_at: Schema.optional(Schema.NullOr(Schema.String)), paused: Schema.optional(Schema.Boolean), usable_now: Schema.optional(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({ @@ -98,6 +102,7 @@ const isFutureTimestamp = (value: string, nowMs: number) => { export function claudeResetCreditsToContract( block: unknown, nowMs: number, + overageIncluded?: string, ): ServerProviderResetCredits | undefined { const parsed = decodeCedarEmber(block); if (Option.isNone(parsed) || !parsed.value.eligible) return undefined; @@ -110,13 +115,28 @@ export function claudeResetCreditsToContract( (grant.ends_at == null || isFutureTimestamp(grant.ends_at, nowMs)), ); const next = live.find((grant) => grant.id === parsed.value.next_grant_id); - const nextExpiresAt = next?.ends_at ? DateTime.make(next.ends_at) : Option.none(); + if (!next) return { availableCount: 0 }; + const expiresAt = (grant: typeof next) => { + const date = grant.ends_at ? DateTime.make(grant.ends_at) : Option.none(); + return Option.isSome(date) ? DateTime.formatIso(date.value) : undefined; + }; + const nextExpiresAt = expiresAt(next); + const grants = live.map((grant) => { + const grantExpiresAt = expiresAt(grant); + const clears = grant.clears?.map((bucket) => claudeUsageWindowId(bucket, overageIncluded)); + return { + id: grant.id, + availableCount: grant.resets_left, + ...(grantExpiresAt ? { expiresAt: grantExpiresAt } : {}), + ...(clears?.length ? { clearsWindowIds: clears } : {}), + ...(grant.gives_back_weekly ? { givesBackWeekly: true } : {}), + }; + }); 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,7 +171,7 @@ const withClaudeHeaders = (token: string, version: string) => }); export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( - function* (configDir: string, version: string) { + function* (configDir: string, version: string, overageIncluded: string | undefined) { const token = yield* readAccessToken(configDir); if (!token) return undefined; const client = yield* HttpClient.HttpClient; @@ -166,6 +186,7 @@ export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( return claudeResetCreditsToContract( body.cedar_ember, DateTime.toEpochMillis(yield* DateTime.now), + overageIncluded, ); }, Effect.timeout("10 seconds"), diff --git a/apps/server/src/provider/claudeUsageLimits.ts b/apps/server/src/provider/claudeUsageLimits.ts index 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/ws.ts b/apps/server/src/ws.ts index 1c204481350d..2a4fcce3125d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2648,7 +2648,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..60b0ce88758f 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,18 +314,19 @@ 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"; + 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} ` : ""; 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}` : "" + return `${grant.count} ${scope}${grant.count === 1 ? "reset" : "resets"}${ + expiresIn ? ` · expires in ${expiresIn}` : "" + }`; + return `${grant.count} ${scope}${grant.count === 1 ? "reset credit" : "reset credits"} banked${ + expiresIn ? ` · expires in ${expiresIn}` : "" }`; } @@ -322,38 +334,40 @@ 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 } = resetCreditsForWindow(credits, windows); + if (grants.length === 0 && status === null) return null; return ( -
- {resetCreditsSummary(credits, now)} - {credits.availableCount > 0 ? ( - - ) : null} +
+ {grants.map((grant) => ( +
+ {resetCreditsSummary(grant, now)} + +
+ ))} {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..2ac6fd984eff 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,24 @@ function SegmentPopover({ +{reset.restoresPercent}% of pool ) : null}
- {credits && redeem ? ( -
- - {resetCreditsSummary(credits, now, true)} - - + {credits.count > 0 && redeem ? ( +
+ {credits.grants.map((grant) => ( +
+ + {resetCreditsSummary(grant, now, true)} + + +
+ ))}
) : null}
@@ -264,9 +272,9 @@ function ActiveSegmentPopover({ { + onRedeem={(creditId) => { handle.close(); - payload.redeem?.setConfirming(true); + payload.redeem?.confirm(creditId); }} /> @@ -304,6 +312,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 +324,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 +361,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 +442,9 @@ function SegmentTriggers({ {credits} + {scope ? ( + {scope} + ) : null} ) : null} @@ -450,7 +472,11 @@ function LegendRow({ const { account, window } = payload; const remaining = remainingPercent(window); const resetsIn = formatResetsIn(window, now); - const credits = account.limits.resetCredits?.availableCount ?? 0; + const { count: credits, scope } = resetCreditsForWindow( + account.limits.resetCredits, + account.limits.windows, + window.id, + ); return ( {credits} + {scope ? {scope} : null} - {credits} reset {credits === 1 ? "credit" : "credits"} banked + {credits} {scope ? `${scope} ` : ""}reset {credits === 1 ? "credit" : "credits"}{" "} + banked ) : null} diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 5c4927d4a164..0f43bae318af 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -20,11 +20,23 @@ 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), +}); +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 +99,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 236a8baa13a2..7ea883f119fd 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"); @@ -1517,3 +1518,73 @@ 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("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..1ee8881bad5a 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,97 @@ 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; + readonly grants: ReadonlyArray; +} + +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: [] }; + if (!credits.grants) { + return { + count: credits.availableCount, + scope: 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 grants = credits.grants + .filter((grant) => grant.availableCount > 0) + .flatMap((grant): ResetCreditGrantSummary[] => { + const clears = grant.clearsWindowIds; + const listed = clears ? resettable.filter((window) => clears.includes(window.id)) : []; + const scope = + 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 = scope + ? listed.filter((window) => window.label.split(" · ")[1] === scope) + : listed; + 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 ${scope ? `${scope}'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, + }, + ]; + }); + 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, + }; +} + +export const withResetCreditId = ( + input: ProviderConsumeResetCreditInput, + creditId: string | undefined, +): ProviderConsumeResetCreditInput => (creditId ? { ...input, creditId } : input); From 718d27de8c62f36ae8b8d18dc5a3ad7f4647862c Mon Sep 17 00:00:00 2001 From: Ehsanul Haque Siam Date: Tue, 10 Nov 2026 19:05:29 +0600 Subject: [PATCH 5/7] feat(server): Claude accounts offer their weekly reset beside banked resets Reads juniper_tide with at_wall=1 on native and hub usage reads, counts an available weekly reset as a credit that Use reset spends first, and claims it with {program: "juniper_tide"} the way the Claude CLI does. --- .../src/provider/Drivers/ClaudeDriver.ts | 6 +- .../src/provider/claudeResetCredits.test.ts | 53 ++++++++++++++++-- .../server/src/provider/claudeResetCredits.ts | 56 +++++++++++++++---- apps/server/src/usage/cliproxyApi.test.ts | 33 ++++++++++- apps/server/src/usage/cliproxyApi.ts | 25 ++++++--- docs/user/usage.md | 7 ++- 6 files changed, 151 insertions(+), 29 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index c2d4b6386107..9c547a87aa4b 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -311,8 +311,8 @@ export const ClaudeDriver: ProviderDriver { ).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, { available: true }), + ).toEqual({ + availableCount: 3, + nextCreditId: ClaudeResetCredits.WEEKLY_RESET_ID, + nextExpiresAt: "2026-10-01T00:00:00.000Z", + }); + expect( + ClaudeResetCredits.claudeResetCreditsToContract({ eligible: false }, NOW, { + eligible: true, + available: true, + }), + ).toEqual({ availableCount: 1, nextCreditId: ClaudeResetCredits.WEEKLY_RESET_ID }); + for (const weekly of [{ available: false }, { available: "true" }, null]) { + expect(ClaudeResetCredits.claudeResetCreditsToContract(banked, NOW, weekly)).toMatchObject({ + nextCreditId: "grant_a", + }); + } + }); }); effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { @@ -94,7 +120,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)"); @@ -136,7 +162,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 +200,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 +254,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 6ec3e1120058..43274d0c2137 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -12,8 +12,10 @@ import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; const API_BASE = "https://api.anthropic.com"; -export const PROGRAM = "cedar_ember"; -export const GRANT_ID = /^[a-z0-9_-]{1,40}$/; +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})$/; @@ -40,8 +42,12 @@ 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 decodeJuniperTide = Schema.decodeUnknownOption( + Schema.Struct({ available: Schema.optional(Schema.Boolean) }), +); export const ClaimResponse = Schema.Struct({ result: Schema.Literals([ "reset", @@ -95,10 +101,28 @@ 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, + weeklyBlock?: unknown, ): ServerProviderResetCredits | undefined { + const banked = bankedCredits(block, nowMs); + if (!Option.exists(decodeJuniperTide(weeklyBlock), (weekly) => weekly.available === true)) { + return banked; + } + return { + ...banked, + availableCount: (banked?.availableCount ?? 0) + 1, + nextCreditId: WEEKLY_RESET_ID, + }; +} + +function bankedCredits(block: unknown, nowMs: number): ServerProviderResetCredits | undefined { const parsed = decodeCedarEmber(block); if (Option.isNone(parsed) || !parsed.value.eligible) return undefined; const live = (parsed.value.grants ?? []) @@ -157,7 +181,7 @@ export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( 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 +190,7 @@ export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( return claudeResetCreditsToContract( body.cedar_ember, DateTime.toEpochMillis(yield* DateTime.now), + body.juniper_tide, ); }, Effect.timeout("10 seconds"), @@ -194,15 +219,30 @@ export const CLAIM_OUTCOMES = { 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 +265,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/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 03c5727e5b1a..186494af952c 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -389,7 +389,38 @@ describe("CLIProxyAPI built-in management API", () => { nextExpiresAt: "2099-01-01T00:00:00.000Z", }); expect(test.requests.find((request) => request.body?.url)?.body?.url).toContain( - "cedar_ember=1", + "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).toEqual({ + availableCount: 2, + nextCreditId: "weekly:juniper_tide", + nextExpiresAt: "2099-01-01T00:00:00.000Z", + }); + 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, ); }), ); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 80b7f29779f8..267815191dd1 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -20,8 +20,7 @@ import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts"; import { CLAIM_OUTCOMES as CLAUDE_CLAIM_OUTCOMES, ClaimResponse as ClaudeClaimResponse, - GRANT_ID as CLAUDE_GRANT_ID, - PROGRAM as CLAUDE_RESET_PROGRAM, + claudeClaim, claudeResetCreditsToContract, } from "../provider/claudeResetCredits.ts"; import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; @@ -97,6 +96,7 @@ 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 }))), @@ -306,11 +306,17 @@ export const makeCliproxyApi = Effect.gen(function* () { }; const read = Effect.gen(function* () { if (account.provider === "claude") { - const body = yield* apiCall(hub, account, `${CLAUDE_BASE}/api/oauth/usage?cedar_ember=1`); + // at_wall=1 adds the weekly at-limit reset (juniper_tide) beside the banked grants. + const body = yield* apiCall( + hub, + account, + `${CLAUDE_BASE}/api/oauth/usage?cedar_ember=1&at_wall=1`, + ); const usage = yield* decodeClaudeUsage(body); const resetCredits = claudeResetCreditsToContract( usage.cedar_ember, DateTime.toEpochMillis(yield* DateTime.now), + usage.juniper_tide, ); const model_scoped = (usage.limits ?? []).flatMap((limit) => limit.kind === "weekly_scoped" && limit.scope?.model && typeof limit.percent === "number" @@ -444,14 +450,15 @@ export const makeCliproxyApi = Effect.gen(function* () { )[response.code]; }); - // Same claim the Claude CLI sends; request_id is the idempotency key, so a retry - // addresses the claim already made. + // 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: typeof AuthFile.Type, - grantId: string, + creditId: string, ) { - if (!CLAUDE_GRANT_ID.test(grantId)) { + const claimBody = claudeClaim(creditId); + if (!claimBody) { return yield* new UsageLimitSourceError({ detail: "The Claude reset grant is not valid." }); } const profile = yield* decodeClaudeProfile( @@ -470,14 +477,14 @@ export const makeCliproxyApi = Effect.gen(function* () { detail: "The Claude hub account needs to sign in again.", }); return yield* resetCreditCoordinator.redeem( - yield* encodeJson([hub.url, organization, grantId]), + yield* encodeJson([hub.url, organization, creditId]), (requestId) => Effect.gen(function* () { const body = yield* apiCall( hub, account, `${CLAUDE_BASE}/api/organizations/${encodeURIComponent(organization)}/reset_rate_limits`, - { program: CLAUDE_RESET_PROGRAM, grant_id: grantId, request_id: requestId }, + claimBody(requestId), (status) => status === 429 ? coolingDown diff --git a/docs/user/usage.md b/docs/user/usage.md index fef104680831..d1b3e238a8dc 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -121,6 +121,8 @@ 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; 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. @@ -173,8 +175,9 @@ using a proxy through `ANTHROPIC_AUTH_TOKEN`. To see pooled accounts, open **Settings → Providers → Usage providers → Add hub**. Choose the environment that will connect to the hub and enter its URL and management key. -The accounts appear under **Usage → Limits**. Codex and Claude accounts show banked reset credits; -select an account and choose **Use reset** to redeem one. No hub plugin is required. +The accounts appear under **Usage → Limits**. 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 From 31a2c1cbd245d79e6492b2daf813bbadc3a2fd5b Mon Sep 17 00:00:00 2001 From: Ehsanul Haque Siam Date: Tue, 10 Nov 2026 19:36:17 +0600 Subject: [PATCH 6/7] feat(usage): group Claude resets by kind and show the ones not usable yet Each kind of reset (what it clears) is one row with its count summed; Use reset spends the one of that kind that expires first. Paused grants and grants that only work once a limit is hit are listed with a waiting reason and shown as one muted line, left out of the usable count and bar badges. --- .../src/features/usage/UsageLimitsSection.tsx | 7 ++- .../src/provider/Drivers/ClaudeDriver.ts | 2 +- .../src/provider/claudeResetCredits.test.ts | 18 ++++-- .../server/src/provider/claudeResetCredits.ts | 27 +++++--- apps/web/src/components/usage/UsageLimits.tsx | 5 +- .../components/usage/UsageLimitsPooled.tsx | 37 ++++++----- packages/contracts/src/providerUsageLimits.ts | 2 + packages/shared/src/usageLimits.test.ts | 31 ++++++++++ packages/shared/src/usageLimits.ts | 62 ++++++++++++++++--- 9 files changed, 149 insertions(+), 42 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index 0a50a7f26f00..577744f6e0f1 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -244,8 +244,8 @@ export function ResetCredits(props: { }); const [busyCreditId, setBusyCreditId] = useState(null); const [status, setStatus] = useState(null); - const { grants } = resetCreditsForWindow(credits, windows); - if (dense && grants.length === 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 redeem = async (creditId: string | undefined) => { @@ -279,7 +279,7 @@ export function ResetCredits(props: { return ( - {grants.length === 0 ? ( + {grants.length === 0 && !waiting ? ( No reset credits banked ) : null} {grants.map((grant) => { @@ -318,6 +318,7 @@ export function ResetCredits(props: { ); })} + {waiting ? {waiting} : null} {!canManageProviders ? ( This connection cannot manage provider accounts. diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 253b655338c6..02abff29e6df 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -315,7 +315,7 @@ export const ClaudeDriver: ProviderDriver grant.id === creditId)?.id; + : 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( diff --git a/apps/server/src/provider/claudeResetCredits.test.ts b/apps/server/src/provider/claudeResetCredits.test.ts index cc5756a90bf3..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,7 +64,12 @@ 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" }], + 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" }, + ], }); }); @@ -108,10 +115,13 @@ describe("claudeResetCreditsToContract", () => { 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({ diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 34e55979cd44..7640a3599a71 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -35,6 +35,7 @@ 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)), }); @@ -138,32 +139,42 @@ function bankedCredits( ): 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); - if (!next) return { availableCount: 0 }; - const expiresAt = (grant: typeof next) => { + 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; }; - const nextExpiresAt = expiresAt(next); - const grants = live.map((grant) => { + // 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: live.reduce((sum, grant) => sum + grant.resets_left, 0), ...(nextExpiresAt ? { nextExpiresAt } : {}), diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 60b0ce88758f..71ccf2f5c8fb 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -345,8 +345,8 @@ export function ResetCredits({ }) { const { canManageProviders, confirming, setConfirming, confirm, creditId, busy, status, redeem } = useResetCredit(environmentId, input); - const { grants } = resetCreditsForWindow(credits, windows); - if (grants.length === 0 && status === null) return null; + const { grants, waiting } = resetCreditsForWindow(credits, windows); + if (grants.length === 0 && !waiting && status === null) return null; return (
{grants.map((grant) => ( @@ -362,6 +362,7 @@ export function ResetCredits({
))} + {waiting ? {waiting} : null} {status ? {status} : null} +{reset.restoresPercent}% of pool ) : null}
- {credits.count > 0 && redeem ? ( + {(credits.count > 0 && redeem) || credits.waiting ? (
- {credits.grants.map((grant) => ( -
- - {resetCreditsSummary(grant, now, true)} - - -
- ))} + {redeem + ? credits.grants.map((grant) => ( +
+ + {resetCreditsSummary(grant, now, true)} + + +
+ )) + : null} + {credits.waiting ? {credits.waiting} : null}
) : null}
diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 0f43bae318af..6c7084a4b332 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -28,6 +28,8 @@ export const ServerProviderResetCreditGrant = Schema.Struct({ 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; diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 7ea883f119fd..e3b5a77d5203 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1575,6 +1575,37 @@ describe("reset credits per window", () => { }); }); + 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"), diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 1ee8881bad5a..234c22c8dd37 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -899,9 +899,34 @@ 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)}`; @@ -911,11 +936,12 @@ export function resetCreditsForWindow( windows: ReadonlyArray, windowId?: string, ): WindowResetCredits { - if (!credits) return { count: 0, scope: undefined, grants: [] }; + 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 ? [] @@ -931,19 +957,26 @@ export function resetCreditsForWindow( }; } const resettable = windows.filter((window) => window.kind !== "monthly"); - const grants = credits.grants - .filter((grant) => grant.availableCount > 0) + 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 scope = + 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 = scope - ? listed.filter((window) => window.label.split(" · ")[1] === scope) + 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 []; } @@ -952,7 +985,7 @@ export function resetCreditsForWindow( : undefined; const givesBack = grant.givesBackWeekly && keptWeekly - ? ` and gives back ${scope ? `${scope}'s` : "its"} share of ${keptWeekly.label}` + ? ` and gives back ${modelScope ? `${modelScope}'s` : "its"} share of ${keptWeekly.label}` : ""; return [ { @@ -966,12 +999,27 @@ export function resetCreditsForWindow( 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), }; } From c83be184200203f6bf49ce726e1736d9def31f20 Mon Sep 17 00:00:00 2001 From: Ehsanul Haque Siam Date: Tue, 10 Nov 2026 19:41:58 +0600 Subject: [PATCH 7/7] fix(usage): a merged reset row says when its next reset expires --- .../src/features/usage/UsageLimitsSection.tsx | 4 +++- apps/web/src/components/usage/UsageLimits.tsx | 13 ++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index 577744f6e0f1..e7e679e02fe8 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -291,7 +291,9 @@ export function ResetCredits(props: { {`${grant.count} ${scope}${grant.count === 1 ? "reset credit" : "reset credits"} banked${ - expiresIn ? ` · expires in ${expiresIn}` : "" + expiresIn + ? ` · ${grant.count > 1 ? "next expires" : "expires"} in ${expiresIn}` + : "" }`} 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({