From cd40a51684e04cef3c376976204a85d90a8a946f Mon Sep 17 00:00:00 2001 From: Max schwenk Date: Wed, 11 Nov 2026 07:53:22 -0500 Subject: [PATCH] feat(usage): show and redeem Claude reset credits for hub accounts Claude only returns reset grants (cedar_ember) to ?cedar_ember=1 with a current claude-cli user agent, so the hub read never saw them. Send the installed Claude Code version, map the grants, and redeem them through the account's organization. --- .../server/src/provider/claudeResetCredits.ts | 17 ++- .../src/usage/UsageLimitSources.test.ts | 3 + apps/server/src/usage/UsageLimitSources.ts | 26 +++- apps/server/src/usage/cliproxyApi.test.ts | 103 +++++++++++++ apps/server/src/usage/cliproxyApi.ts | 137 ++++++++++++++---- 5 files changed, 248 insertions(+), 38 deletions(-) diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 35973378edada..ba3da48a54e83 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -12,7 +12,7 @@ import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; const API_BASE = "https://api.anthropic.com"; -const PROGRAM = "cedar_ember"; +export const CLAUDE_RESET_PROGRAM = "cedar_ember"; 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 ClaudeResetClaimResponse = Schema.Struct({ result: Schema.Literals([ "reset", "already_used", @@ -143,11 +143,14 @@ const readAccessToken = Effect.fn("claudeResetCredits.readAccessToken")(function return credentials.claudeAiOauth?.accessToken?.trim() || undefined; }); +/** Claude only reveals reset credits to a current Claude Code CLI's user agent. */ +export const claudeCliUserAgent = (version: string) => `claude-cli/${version} (external, cli)`; + const withClaudeHeaders = (token: string, version: string) => HttpClientRequest.setHeaders({ authorization: `Bearer ${token}`, "anthropic-beta": "oauth-2025-04-20", - "user-agent": `claude-cli/${version} (external, cli)`, + "user-agent": claudeCliUserAgent(version), }); export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( @@ -187,7 +190,7 @@ export const claudeAccountConfigPath = Effect.fn("claudeAccountConfigPath")(func : path.join(yield* HostProcess.HomeDirectory, ".claude.json"); }); -const CLAIM_OUTCOMES = { +export const CLAUDE_RESET_CLAIM_OUTCOMES = { reset: "reset", not_limited: "nothingToReset", already_used: "alreadyRedeemed", @@ -226,7 +229,7 @@ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(fu ).pipe( withClaudeHeaders(token, input.version), HttpClientRequest.bodyJsonUnsafe({ - program: PROGRAM, + program: CLAUDE_RESET_PROGRAM, grant_id: input.grantId, request_id: input.requestId, }), @@ -243,7 +246,7 @@ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(fu return yield* new ClaudeResetCreditError({ reason: "signedOut" }); } const body = yield* HttpClientResponse.filterStatusOk(response).pipe( - Effect.flatMap(HttpClientResponse.schemaBodyJson(ClaimResponse)), + Effect.flatMap(HttpClientResponse.schemaBodyJson(ClaudeResetClaimResponse)), Effect.timeout("25 seconds"), Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })), ); @@ -254,5 +257,5 @@ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(fu if (body.result === "unavailable") { return yield* new ClaudeResetCreditError({ reason: "unconfirmed" }); } - return CLAIM_OUTCOMES[body.result]; + return CLAUDE_RESET_CLAIM_OUTCOMES[body.result]; }); diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index 274e63b46c906..00f0b1fb60a12 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -9,6 +9,7 @@ import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/http"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as UsageLimitSources from "./UsageLimitSources.ts"; @@ -78,6 +79,7 @@ it.effect("keeps malformed source URLs encodable for config subscribers", () => }, }, }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(BackgroundPolicy.BackgroundPolicy)({ shouldRunScopeWork: () => Effect.succeed(false), }), @@ -130,6 +132,7 @@ it.effect("reads a management key from its secret provider", () => { }, }, }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(BackgroundPolicy.BackgroundPolicy)({ shouldRunScopeWork: () => Effect.succeed(false), }), diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 7d40b0fb9ecea..c3813aacff7f6 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -10,6 +10,7 @@ import { type UsageLimitSourceSnapshot, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; +import { compareSemverVersions } from "@t3tools/shared/semver"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -22,6 +23,7 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as SecretResolverLayer from "../secrets/SecretResolver.ts"; import { resolveSettingsSecrets } from "../secrets/settingsSecretValue.ts"; @@ -62,6 +64,24 @@ export const make = Effect.gen(function* () { const settingsService = yield* Settings.ServerSettingsService; const secrets = yield* SecretResolver.SecretResolver; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; + const providerRegistry = yield* ProviderRegistry.ProviderRegistry; + const claudeCliVersion = providerRegistry.getProviders.pipe( + Effect.map((providers) => + providers + .flatMap((provider) => { + const version = provider.installedVersion ?? provider.version; + return provider.driver === "claudeAgent" && provider.enabled && version ? [version] : []; + }) + .toSorted(compareSemverVersions) + .at(-1), + ), + ); + const hub = Effect.fn("UsageLimitSources.hub")(function* ( + config: UsageLimitSourceConfig, + managementKey: string, + ) { + return { url: config.url, managementKey, claudeCliVersion: yield* claudeCliVersion }; + }); const readManagementKey = Effect.fn("UsageLimitSources.readManagementKey")(function* ( config: UsageLimitSourceConfig, ) { @@ -84,9 +104,7 @@ export const make = Effect.gen(function* () { if (managementKey.length === 0) { return { ...base, accounts: [], error: missingManagementKey(config) }; } - const accounts = yield* api - .readAccounts({ url: config.url, managementKey }) - .pipe(Effect.result); + const accounts = yield* api.readAccounts(yield* hub(config, managementKey)).pipe(Effect.result); if (accounts._tag === "Failure") { yield* Effect.logDebug("usage limit source read failed", { id, cause: accounts.failure }); return { ...base, accounts: [], error: accounts.failure.detail }; @@ -138,7 +156,7 @@ export const make = Effect.gen(function* () { return yield* new UsageLimitSourceError({ detail: missingManagementKey(config) }); } const result = yield* api.consume( - { url: config.url, managementKey }, + yield* hub(config, managementKey), input.accountId, input.creditId, ); diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index b9f357522bb7b..1a2611debc90e 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -433,6 +433,109 @@ describe("CLIProxyAPI built-in management API", () => { }), ); + it.effect( + "shows Claude reset credits only when the hub knows the current Claude Code version", + () => + Effect.gen(function* () { + const grant = (id: string, ends_at: string) => ({ + id, + resets_left: 1, + ends_at, + paused: false, + usable_now: true, + }); + const test = fixture({ + accounts: [{ ...accounts[0]!, provider: "claude" }], + // Claude hides grants from any user agent other than a current Claude Code CLI. + upstream: (request) => ({ + status: 200, + body: { + seven_day: { utilization: 100, resets_at: "2099-01-01T00:00:00Z" }, + cedar_ember: request.header?.["User-Agent"]?.startsWith("claude-cli/2.1.319 ") + ? { + eligible: true, + next_grant_id: "full-reset-2611", + grants: [ + grant("full-reset-2611", "2099-12-10T22:00:00+00:00"), + grant("launch-2610-fable", "2099-03-09T07:59:00+00:00"), + ], + } + : { eligible: false, ineligible_reason: "surface", grants: [] }, + }, + }), + }); + const api = yield* test.api; + const current = yield* api.readAccounts({ ...config, claudeCliVersion: "2.1.319" }); + expect(current[0]?.usageLimits.resetCredits).toEqual({ + availableCount: 2, + nextCreditId: "full-reset-2611", + nextExpiresAt: "2099-12-10T22:00:00.000Z", + }); + const unknown = yield* api.readAccounts(config); + expect(unknown[0]?.usageLimits.resetCredits).toBeUndefined(); + expect(unknown[0]?.usageLimits.windows.map((window) => window.id)).toEqual(["seven_day"]); + }), + ); + + it.effect("redeems a Claude reset in the account's organization and clears its cooldown", () => + Effect.gen(function* () { + const test = fixture({ + accounts: [{ ...accounts[0]!, provider: "claude" }], + upstream: (request) => ({ + status: 200, + body: request.url?.endsWith("/api/oauth/profile") + ? { organization: { uuid: "org-1" } } + : { result: "reset" }, + }), + }); + const api = yield* test.api; + const hub = { ...config, claudeCliVersion: "2.1.319" }; + expect(yield* api.consume(hub, "first.json", "full-reset-2611")).toEqual({ + outcome: "reset", + }); + expect(yield* api.consume(hub, "first.json", "full-reset-2611")).toEqual({ + outcome: "reset", + }); + const claims = test.requests.filter((request) => + request.body?.url?.endsWith("/reset_rate_limits"), + ); + expect(claims.map((request) => request.body?.url)).toEqual([ + "https://api.anthropic.com/api/organizations/org-1/reset_rate_limits", + "https://api.anthropic.com/api/organizations/org-1/reset_rate_limits", + ]); + // A retried click reuses the request id, so Claude spends the grant at most once. + expect(claims[0]?.body?.data).toBe(claims[1]?.body?.data); + expect(JSON.parse(claims[0]!.body!.data!)).toMatchObject({ + program: "cedar_ember", + grant_id: "full-reset-2611", + }); + expect( + test.requests + .filter((request) => request.path.endsWith("/reset-quota")) + .map((request) => request.body?.auth_index), + ).toEqual(["a", "a"]); + }), + ); + + it.effect("reports a Claude reset cooldown without clearing the hub cooldown", () => + Effect.gen(function* () { + const test = fixture({ + accounts: [{ ...accounts[0]!, provider: "claude" }], + upstream: (request) => ({ + status: 200, + body: request.url?.endsWith("/api/oauth/profile") + ? { organization: { uuid: "org-1" } } + : { result: "cooldown" }, + }), + }); + const result = yield* (yield* test.api) + .consume({ ...config, claudeCliVersion: "2.1.319" }, "first.json", "full-reset-2611") + .pipe(Effect.flip); + expect(result.detail).toContain("cooling down"); + expect(test.requests.some((request) => request.path.endsWith("/reset-quota"))).toBe(false); + }), + ); + it.effect("pins redemption to the displayed credit and clears only that account's cooldown", () => Effect.gen(function* () { const test = fixture(); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 87ec1e05b8f77..9b965494f9ef8 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -15,6 +15,13 @@ import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } fr import { codexPlanLabel } from "../provider/CodexProvider.ts"; import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts"; +import { + CLAUDE_RESET_CLAIM_OUTCOMES, + CLAUDE_RESET_PROGRAM, + ClaudeResetClaimResponse, + claudeCliUserAgent, + claudeResetCreditsToContract, +} from "../provider/claudeResetCredits.ts"; import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts"; import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; @@ -22,6 +29,8 @@ import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageL export interface CliproxyHub { readonly url: UsageLimitSourceConfig["url"]; readonly managementKey: string; + /** Installed Claude Code version; without it Claude accounts report no reset credits. */ + readonly claudeCliVersion?: string | undefined; } const AuthFile = Schema.Struct({ @@ -62,6 +71,7 @@ const ClaudeUsage = Schema.Struct({ five_hour: Schema.optional(Schema.NullOr(ClaudeWindow)), seven_day: Schema.optional(Schema.NullOr(ClaudeWindow)), spend: Schema.optional(Schema.Unknown), + cedar_ember: Schema.optional(Schema.Unknown), extra_usage: Schema.optional( Schema.NullOr( Schema.Struct({ @@ -89,6 +99,9 @@ const ClaudeUsage = Schema.Struct({ ), ), }); +const ClaudeProfile = Schema.Struct({ + organization: Schema.Struct({ uuid: Schema.String }), +}); const CreditList = Schema.Struct({ credits: Schema.Array( Schema.Struct({ @@ -105,6 +118,10 @@ 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 decodeClaudeResetClaim = Schema.decodeUnknownEffect( + Schema.fromJsonString(ClaudeResetClaimResponse), +); const decodeCodexUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(CodexUsage)); const isUsageLimitSourceError = Schema.is(UsageLimitSourceError); @@ -152,6 +169,7 @@ const decodeConsumeResponse = Schema.decodeUnknownEffect( const CODEX_BASE = "https://chatgpt.com/backend-api/wham"; const CREDIT_URL = `${CODEX_BASE}/rate-limit-reset-credits`; +const ANTHROPIC_BASE = "https://api.anthropic.com"; const CREDIT_REDEEM_NAMESPACE = new Uint8Array([ 0x6f, 0x1c, 0x2a, 0x9e, 0x2d, 0x4b, 0x4c, 0x1e, 0x9a, 0x7f, 0x3b, 0x8d, 0x5e, 0x0c, 0x1a, 0x42, @@ -232,9 +250,12 @@ 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", + "User-Agent": hub.claudeCliVersion + ? claudeCliUserAgent(hub.claudeCliVersion) + : "claude-code/2.1.273", }; const raw = yield* management(hub, "api-call", { auth_index: account.auth_index, @@ -284,8 +305,16 @@ export const makeCliproxyApi = Effect.gen(function* () { }; const read = Effect.gen(function* () { if (account.provider === "claude") { - const body = yield* apiCall(hub, account, "https://api.anthropic.com/api/oauth/usage"); + const body = yield* apiCall( + hub, + account, + `${ANTHROPIC_BASE}/api/oauth/usage?cedar_ember=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" ? [ @@ -307,13 +336,16 @@ export const makeCliproxyApi = Effect.gen(function* () { return { ...base, plan: "Claude Subscription", - usageLimits: claudeUsageResponseToLimits({ - checkedAt, - response: { - rate_limits_available: true, - rate_limits: rateLimits, - }, - }).limits, + usageLimits: { + ...claudeUsageResponseToLimits({ + checkedAt, + response: { + rate_limits_available: true, + rate_limits: rateLimits, + }, + }).limits, + ...(resetCredits ? { resetCredits } : {}), + }, }; } const body = yield* apiCall(hub, account, `${CODEX_BASE}/usage`); @@ -392,6 +424,65 @@ export const makeCliproxyApi = Effect.gen(function* () { ); }); + const redeemCodex = Effect.fn("CliproxyApi.redeemCodex")(function* ( + hub: CliproxyHub, + account: typeof AuthFile.Type, + creditId: string, + ) { + const body = yield* apiCall(hub, account, `${CREDIT_URL}/consume`, { + redeem_request_id: yield* creditRedeemRequestId( + account.id_token?.chatgpt_account_id ?? account.id, + creditId, + ).pipe(Effect.provideService(Digest.Digest, digest)), + credit_id: creditId, + }); + const response = yield* decodeConsumeResponse(body); + return ( + { + reset: "reset", + nothing_to_reset: "nothingToReset", + no_credit: "noCredit", + already_redeemed: "alreadyRedeemed", + } as const + )[response.code]; + }); + + const redeemClaude = Effect.fn("CliproxyApi.redeemClaude")(function* ( + hub: CliproxyHub, + account: typeof AuthFile.Type, + grantId: string, + ) { + const profile = yield* decodeClaudeProfile( + yield* apiCall(hub, account, `${ANTHROPIC_BASE}/api/oauth/profile`), + ); + const organization = encodeURIComponent(profile.organization.uuid); + const body = yield* apiCall( + hub, + account, + `${ANTHROPIC_BASE}/api/organizations/${organization}/reset_rate_limits`, + { + program: CLAUDE_RESET_PROGRAM, + grant_id: grantId, + request_id: yield* creditRedeemRequestId(account.email ?? account.id, grantId).pipe( + Effect.provideService(Digest.Digest, digest), + ), + }, + ); + const { result } = yield* decodeClaudeResetClaim(body); + if (result === "cooldown") { + return yield* new UsageLimitSourceError({ + detail: "Claude resets are cooling down. Try again later.", + }); + } + if (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_RESET_CLAIM_OUTCOMES[result]; + }); + const consume = Effect.fn("CliproxyApi.consume")(function* ( hub: CliproxyHub, accountId: string, @@ -399,27 +490,19 @@ export const makeCliproxyApi = Effect.gen(function* () { ): Effect.fn.Return { const operation = Effect.gen(function* () { const account = (yield* authFiles(hub)).find((account) => account.id === accountId); - if (!account || account.disabled || account.provider !== "codex") { + if ( + !account || + account.disabled || + (account.provider !== "codex" && account.provider !== "claude") + ) { return yield* new UsageLimitSourceError({ - detail: "The Codex hub account is missing or disabled.", + detail: "The hub account is missing or disabled.", }); } - const body = yield* apiCall(hub, account, `${CREDIT_URL}/consume`, { - redeem_request_id: yield* creditRedeemRequestId( - account.id_token?.chatgpt_account_id ?? account.id, - creditId, - ).pipe(Effect.provideService(Digest.Digest, digest)), - credit_id: creditId, - }); - const response = yield* decodeConsumeResponse(body); - const outcome = ( - { - reset: "reset", - nothing_to_reset: "nothingToReset", - no_credit: "noCredit", - already_redeemed: "alreadyRedeemed", - } as const - )[response.code]; + const outcome = + account.provider === "claude" + ? yield* redeemClaude(hub, account, creditId) + : yield* redeemCodex(hub, account, creditId); if (outcome !== "reset" && outcome !== "alreadyRedeemed") return { outcome }; const cleared = yield* management(hub, "reset-quota", { auth_index: account.auth_index,