From b570489084708c479913f852856671d2d1c461cb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 19:35:47 +0000 Subject: [PATCH 1/4] test(server): hub-routed Claude accounts read their reset grants Reproduces #34226: the CLIProxyAPI Claude reader asks for usage without cedar_ember=1 and with an old claude-code user agent, so no reset credits are returned. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/usage/cliproxyApi.test.ts | 41 +++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 431af7b5971ff..3b49f4adb3254 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -373,6 +373,47 @@ describe("CLIProxyAPI built-in management API", () => { }), ); + it.effect("reads Claude reset grants through the hub", () => + Effect.gen(function* () { + const test = fixture({ + accounts: [{ ...accounts[0]!, provider: "claude" }], + // Claude only reports grants for cedar_ember=1 from a current Claude CLI user agent. + upstream: (request) => { + const url = new URL(request.url!); + const eligible = + url.searchParams.get("cedar_ember") === "1" && + /^claude-cli\/\S+ \(external, cli\)$/.test(request.header?.["User-Agent"] ?? ""); + return { + status: 200, + body: { + five_hour: { utilization: 10, resets_at: null }, + cedar_ember: eligible + ? { + eligible: true, + next_grant_id: "grant_a", + grants: [ + { + id: "grant_a", + resets_left: 1, + usable_now: true, + ends_at: "2099-01-01T00:00:00Z", + clears: ["five_hour", "seven_day"], + }, + ], + } + : null, + }, + }; + }, + }); + const result = yield* (yield* test.api).readAccounts(config); + expect(result[0]?.usageLimits.resetCredits).toMatchObject({ + availableCount: 1, + nextCreditId: "grant_a", + }); + }), + ); + it.effect.each([ { name: "spend budget", From 5b6aafc8a21a83dbcde2d59b827740a9c01fe27c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 19:46:32 +0000 Subject: [PATCH 2/4] fix(server): Claude accounts in a CLIProxyAPI hub show and use their limit resets The hub read Claude usage without cedar_ember=1 and with an old claude-code user agent, so Claude answered with no reset grants, and the reader would not have mapped them. It now sends the request the direct login sends and maps the grants with the same function. Redeeming went only to Codex accounts. A Claude grant is now claimed through the hub in the organization of the hub's login, with the request and answer mapping the direct login uses and one request id kept per account until Claude answers. Each kind of reset is offered under its own grant for hub accounts too, as it is for a direct login. Fixes pingdotgg/t3code#34226 Co-Authored-By: Claude Fable 5.2 --- .../server/src/provider/claudeResetCredits.ts | 86 ++++--- apps/server/src/usage/cliproxyApi.test.ts | 43 +++- apps/server/src/usage/cliproxyApi.ts | 212 ++++++++++++------ docs/user/usage.md | 4 +- packages/shared/src/usageLimits.test.ts | 37 +++ packages/shared/src/usageLimits.ts | 2 +- 6 files changed, 277 insertions(+), 107 deletions(-) diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index d2000804cf4f6..3033d0b2ca149 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -256,7 +256,52 @@ const CLAIM_OUTCOMES = { ineligible: "noCredit", } as const satisfies Record; -/** requestId is the claim's idempotency key. */ +const decodeClaimResponse = Schema.decodeUnknownEffect(Schema.fromJsonString(ClaimResponse)); + +/** The claim request for an organization; requestId is its idempotency key. */ +export const claudeResetClaim = Effect.fn("claudeResetClaim")(function* (input: { + readonly grantId: string; + readonly requestId: string; +}) { + if (!GRANT_ID.test(input.grantId) || !REQUEST_ID.test(input.requestId)) { + return yield* new ClaudeResetCreditError({ reason: "malformedCredit" }); + } + return (organization: string) => ({ + url: new URL( + `/api/organizations/${encodeURIComponent(organization)}/reset_rate_limits`, + API_BASE, + ).toString(), + body: { program: PROGRAM, grant_id: input.grantId, request_id: input.requestId }, + }); +}); + +export const claudeResetClaimOutcome = Effect.fn("claudeResetClaimOutcome")(function* ( + status: number, + body: Effect.Effect, +) { + if (status === 429) { + return yield* new ClaudeResetCreditError({ reason: "rateLimited" }); + } + if (status === 401 || status === 403) { + return yield* new ClaudeResetCreditError({ reason: "signedOut" }); + } + const requestFailed = (cause?: unknown) => + new ClaudeResetCreditError({ reason: "requestFailed", cause }); + if (status < 200 || status >= 300) return yield* requestFailed(); + const claim = yield* body.pipe( + Effect.flatMap(decodeClaimResponse), + Effect.mapError(requestFailed), + ); + if (claim.result === "cooldown") { + return yield* new ClaudeResetCreditError({ reason: "coolingDown" }); + } + // Unknown outcomes retain the request ID so retries address the same claim. + if (claim.result === "unavailable") { + return yield* new ClaudeResetCreditError({ reason: "unconfirmed" }); + } + return CLAIM_OUTCOMES[claim.result]; +}); + export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(function* (input: { readonly configDir: string; readonly accountConfigPath: string; @@ -264,9 +309,7 @@ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(fu readonly grantId: string; readonly requestId: string; }) { - if (!GRANT_ID.test(input.grantId) || !REQUEST_ID.test(input.requestId)) { - return yield* new ClaudeResetCreditError({ reason: "malformedCredit" }); - } + const claim = yield* claudeResetClaim(input); const token = yield* readAccessToken(input.configDir).pipe( Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "loginUnreadable", cause })), ); @@ -277,44 +320,21 @@ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(fu if (!token || !organization) { return yield* new ClaudeResetCreditError({ reason: "signedOut" }); } + const { url, body } = claim(organization); const client = yield* HttpClient.HttpClient; const response = yield* client .execute( - HttpClientRequest.post( - new URL( - `/api/organizations/${encodeURIComponent(organization)}/reset_rate_limits`, - API_BASE, - ), - ).pipe( + HttpClientRequest.post(url).pipe( withClaudeHeaders(token, input.version), - HttpClientRequest.bodyJsonUnsafe({ - program: PROGRAM, - grant_id: input.grantId, - request_id: input.requestId, - }), + HttpClientRequest.bodyJsonUnsafe(body), ), ) .pipe( Effect.timeout("25 seconds"), Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })), ); - if (response.status === 429) { - return yield* new ClaudeResetCreditError({ reason: "rateLimited" }); - } - if (response.status === 401 || response.status === 403) { - return yield* new ClaudeResetCreditError({ reason: "signedOut" }); - } - const body = yield* HttpClientResponse.filterStatusOk(response).pipe( - Effect.flatMap(HttpClientResponse.schemaBodyJson(ClaimResponse)), - Effect.timeout("25 seconds"), - Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })), + return yield* claudeResetClaimOutcome( + response.status, + response.text.pipe(Effect.timeout("25 seconds")), ); - if (body.result === "cooldown") { - return yield* new ClaudeResetCreditError({ reason: "coolingDown" }); - } - // Unknown outcomes retain the request ID so retries address the same claim. - if (body.result === "unavailable") { - return yield* new ClaudeResetCreditError({ reason: "unconfirmed" }); - } - return CLAIM_OUTCOMES[body.result]; }); diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 3b49f4adb3254..d1db6ef331215 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -346,7 +346,7 @@ describe("CLIProxyAPI built-in management API", () => { accounts: [{ ...accounts[0]!, provider: "claude" }], // The usage endpoint answers 429 to requests without a Claude Code user agent. upstream: (request) => ({ - status: request.header?.["User-Agent"]?.startsWith("claude-code/") ? 200 : 429, + status: request.header?.["User-Agent"]?.startsWith("claude-cli/") ? 200 : 429, body: { five_hour: { utilization: 10, resets_at: null }, seven_day: { utilization: 50, resets_at: "2099-01-01T00:00:00Z" }, @@ -732,6 +732,47 @@ describe("CLIProxyAPI built-in management API", () => { }), ); + it.effect("claims a Claude grant in the account's organization, keeping an unanswered id", () => + Effect.gen(function* () { + let claims = 0; + const test = fixture({ + accounts: [{ ...accounts[0]!, provider: "claude" }], + upstream: (request) => + request.url?.endsWith("/api/oauth/profile") + ? { status: 200, body: { organization: { uuid: "org a" } } } + : ++claims === 1 + ? { status: 503, body: {} } + : { status: 200, body: { result: "reset" } }, + }); + const api = yield* test.api; + expect((yield* api.consume(config, "first.json", "grant_a").pipe(Effect.result))._tag).toBe( + "Failure", + ); + expect(yield* api.consume(config, "first.json", "grant_a")).toEqual({ outcome: "reset" }); + const claimed = () => + test.requests.flatMap(({ body }) => + body?.method === "POST" ? [{ url: body.url, data: body.data }] : [], + ); + const sent = claimed(); + expect(sent).toHaveLength(2); + expect(sent[0]).toEqual(sent[1]); + expect(sent[0]?.url).toBe( + "https://api.anthropic.com/api/organizations/org%20a/reset_rate_limits", + ); + expect(sent[0]?.data).toMatch( + /^\{"program":"cedar_ember","grant_id":"grant_a","request_id":"[0-9a-f-]{36}"\}$/, + ); + expect( + test.requests + .filter((request) => request.path.endsWith("/reset-quota")) + .map((request) => request.body?.auth_index), + ).toEqual(["a"]); + const refused = yield* api.consume(config, "first.json", "Not A Grant").pipe(Effect.flip); + expect(refused.detail).toBe("Claude returned a malformed reset credit."); + expect(claimed()).toHaveLength(2); + }), + ); + it.effect("rejects unknown accounts without forwarding a redemption", () => Effect.gen(function* () { const test = fixture(); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 030983f50e33d..b0e96c43f3d9e 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -22,7 +22,14 @@ import { } from "@t3tools/provider-grok/server"; import { codexPlanLabel } from "../provider/CodexProvider.ts"; import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts"; +import { + claudeResetClaim, + claudeResetClaimOutcome, + claudeResetCreditsToContract, + isSettledClaudeResetCreditFailure, +} from "../provider/claudeResetCredits.ts"; import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts"; +import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts"; import { antigravityAccountFingerprint, antigravitySummaryWindows, @@ -77,6 +84,7 @@ const ClaudeWindow = Schema.Struct({ const ClaudeUsage = Schema.Struct({ five_hour: Schema.optional(Schema.NullOr(ClaudeWindow)), seven_day: Schema.optional(Schema.NullOr(ClaudeWindow)), + cedar_ember: Schema.optional(Schema.Unknown), spend: Schema.optional(Schema.Unknown), extra_usage: Schema.optional( Schema.NullOr( @@ -121,6 +129,9 @@ 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(Schema.Struct({ organization: Schema.Struct({ uuid: Schema.String }) })), +); const decodeCodexUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(CodexUsage)); const decodeGrokUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(GrokUsageResponse)); const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); @@ -290,6 +301,56 @@ const CODEX_HUB: HubProvider = { }), }; +const CLAUDE_API = "https://api.anthropic.com"; +const CLAUDE_HUB: HubProvider = { + driver: ProviderDriverKind.make("claudeAgent"), + header: () => ({ + Authorization: "Bearer $TOKEN$", + "Content-Type": "application/json", + "anthropic-beta": "oauth-2025-04-20", + // Usage can answer 429 to other user agents, and older Claude Code versions get no reset grants. + "User-Agent": "claude-cli/2.1.321 (external, cli)", + }), + read: Effect.fnUntraced(function* ({ account, call, checkedAt }) { + const usage = yield* decodeClaudeUsage( + yield* call(`${CLAUDE_API}/api/oauth/usage?cedar_ember=1`), + ); + const model_scoped = (usage.limits ?? []).flatMap((limit) => + limit.kind === "weekly_scoped" && limit.scope?.model && typeof limit.percent === "number" + ? [ + { + display_name: limit.scope.model.display_name, + utilization: limit.percent, + resets_at: limit.resets_at ?? null, + }, + ] + : [], + ); + const rateLimits = { + five_hour: usage.five_hour ?? null, + seven_day: usage.seven_day ?? null, + model_scoped, + spend: usage.spend, + extra_usage: usage.extra_usage ?? null, + }; + const resetCredits = claudeResetCreditsToContract( + usage.cedar_ember, + DateTime.toEpochMillis(yield* DateTime.now), + ); + return { + ...listedIdentity(account), + plan: "Claude Subscription", + usageLimits: { + ...claudeUsageResponseToLimits({ + checkedAt, + response: { rate_limits_available: true, rate_limits: rateLimits }, + }).limits, + ...(resetCredits ? { resetCredits } : {}), + }, + }; + }), +}; + const GROK_CLI_VERSION = "1.0.77"; const ANTIGRAVITY_SUMMARY_URL = "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"; @@ -297,49 +358,7 @@ const ANTIGRAVITY_SUMMARY_URL = // Keyed by the auth-files `provider`; accounts of any other provider are skipped. const HUB_PROVIDERS = new Map([ ["codex", CODEX_HUB], - [ - "claude", - { - driver: ProviderDriverKind.make("claudeAgent"), - header: () => ({ - Authorization: "Bearer $TOKEN$", - "anthropic-beta": "oauth-2025-04-20", - // The OAuth usage endpoint can return 429 without a Claude Code user agent. - "User-Agent": "claude-code/2.1.273", - }), - read: Effect.fnUntraced(function* ({ account, call, checkedAt }) { - const usage = yield* decodeClaudeUsage( - yield* call("https://api.anthropic.com/api/oauth/usage"), - ); - const model_scoped = (usage.limits ?? []).flatMap((limit) => - limit.kind === "weekly_scoped" && limit.scope?.model && typeof limit.percent === "number" - ? [ - { - display_name: limit.scope.model.display_name, - utilization: limit.percent, - resets_at: limit.resets_at ?? null, - }, - ] - : [], - ); - const rateLimits = { - five_hour: usage.five_hour ?? null, - seven_day: usage.seven_day ?? null, - model_scoped, - spend: usage.spend, - extra_usage: usage.extra_usage ?? null, - }; - return { - ...listedIdentity(account), - plan: "Claude Subscription", - usageLimits: claudeUsageResponseToLimits({ - checkedAt, - response: { rate_limits_available: true, rate_limits: rateLimits }, - }).limits, - }; - }), - }, - ], + ["claude", CLAUDE_HUB], [ "xai", { @@ -402,6 +421,7 @@ export const makeCliproxyApi = Effect.gen(function* () { const digest = yield* Digest.Digest; const platform = yield* HostProcess.Platform; const architecture = yield* HostProcess.Architecture; + const coordinator = yield* ResetCreditCoordinator.make; const management = Effect.fn("CliproxyApi.management")(function* ( hub: CliproxyHub, @@ -438,7 +458,8 @@ export const makeCliproxyApi = Effect.gen(function* () { return (yield* decodeAuthFiles(response)).files; }); - const apiCall = Effect.fn("CliproxyApi.apiCall")(function* ( + /** The provider's answer whatever its status; `apiCall` accepts only a 2xx. */ + const upstreamCall = Effect.fn("CliproxyApi.upstreamCall")(function* ( hub: CliproxyHub, account: AuthFile, header: Record, @@ -452,7 +473,17 @@ export const makeCliproxyApi = Effect.gen(function* () { header, ...(data === undefined ? {} : { data: yield* encodeJson(data) }), }); - const response = yield* decodeApiResponse(raw); + return yield* decodeApiResponse(raw); + }); + + const apiCall = Effect.fn("CliproxyApi.apiCall")(function* ( + hub: CliproxyHub, + account: AuthFile, + header: Record, + url: string, + data?: unknown, + ) { + const response = yield* upstreamCall(hub, account, header, url, data); if (response.status_code < 200 || response.status_code >= 300) { return yield* new UsageLimitSourceError({ detail: `The provider refused the hub request (HTTP ${response.status_code}).`, @@ -503,6 +534,58 @@ export const makeCliproxyApi = Effect.gen(function* () { ); }); + const redeemCodexCredit = Effect.fn("CliproxyApi.redeemCodexCredit")(function* ( + hub: CliproxyHub, + account: AuthFile, + creditId: string, + ) { + const body = yield* apiCall( + hub, + account, + CODEX_HUB.header(account, platform, architecture), + `${CREDIT_URL}/consume`, + { + redeem_request_id: yield* creditRedeemRequestId( + account.id_token?.chatgpt_account_id ?? account.id, + creditId, + ).pipe(Effect.provideService(Digest.Digest, digest)), + credit_id: creditId, + }, + ); + const response = yield* decodeConsumeResponse(body); + return ( + { + reset: "reset", + nothing_to_reset: "nothingToReset", + no_credit: "noCredit", + already_redeemed: "alreadyRedeemed", + } as const + )[response.code]; + }); + + // A grant can hold several resets, so its id cannot key the claim as a Codex credit's does: + // one request id per account is kept until Claude answers, as for a direct login. + const claimClaudeGrant = Effect.fn("CliproxyApi.claimClaudeGrant")(function* ( + hub: CliproxyHub, + account: AuthFile, + grantId: string, + ) { + const header = CLAUDE_HUB.header(account, platform, architecture); + return yield* coordinator.redeem( + `${hub.url}\n${account.id}`, + Effect.fnUntraced(function* (requestId) { + const claim = yield* claudeResetClaim({ grantId, requestId }); + const profile = yield* decodeClaudeProfile( + yield* apiCall(hub, account, header, `${CLAUDE_API}/api/oauth/profile`), + ); + const { url, body } = claim(profile.organization.uuid); + const response = yield* upstreamCall(hub, account, header, url, body); + return yield* claudeResetClaimOutcome(response.status_code, Effect.succeed(response.body)); + }), + isSettledClaudeResetCreditFailure, + ); + }); + const consume = Effect.fn("CliproxyApi.consume")(function* ( hub: CliproxyHub, accountId: string, @@ -510,33 +593,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, - CODEX_HUB.header(account, platform, architecture), - `${CREDIT_URL}/consume`, - { - redeem_request_id: yield* creditRedeemRequestId( - account.id_token?.chatgpt_account_id ?? account.id, - creditId, - ).pipe(Effect.provideService(Digest.Digest, digest)), - credit_id: creditId, - }, - ); - const response = yield* decodeConsumeResponse(body); - const outcome = ( - { - reset: "reset", - nothing_to_reset: "nothingToReset", - no_credit: "noCredit", - already_redeemed: "alreadyRedeemed", - } as const - )[response.code]; + const outcome = + account.provider === "claude" + ? yield* claimClaudeGrant(hub, account, creditId) + : yield* redeemCodexCredit(hub, account, creditId); if (outcome !== "reset" && outcome !== "alreadyRedeemed") return { outcome }; const cleared = yield* management(hub, "reset-quota", { auth_index: account.auth_index, @@ -556,7 +625,10 @@ export const makeCliproxyApi = Effect.gen(function* () { isUsageLimitSourceError(error) ? error : new UsageLimitSourceError({ - detail: "The hub returned an unexpected reset-credit response.", + detail: + error._tag === "ClaudeResetCreditError" + ? error.message + : "The hub returned an unexpected reset-credit response.", }), ), ); diff --git a/docs/user/usage.md b/docs/user/usage.md index e6b3a46d5a14e..3fee3d60c0f65 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -177,8 +177,8 @@ To see pooled accounts, open **Settings → Providers → Usage providers → Ad environment that will connect to the hub and enter its URL and management key. The hub's Codex, Claude, xAI (shown as Grok) and Antigravity accounts appear under **Usage → Limits**; -accounts for other providers are not read. Codex accounts show banked reset credits; select an -account and choose **Use reset** to redeem one. No hub plugin is required. +accounts for other providers are not read. Codex and Claude accounts show banked reset credits; +select an account and choose **Use reset** to redeem one. No hub plugin is required. This connection supplies usage information; configure the provider separately to send agent requests through the hub. Remove the hub from the same diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 9d2bf395a9d72..05bf0b4affd17 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -25,6 +25,7 @@ import { paceRemainingPercent, usesChatGptSharing, resolveNewThreadModelSelection, + resetCreditChoices, } from "./usageLimits.ts"; const now = Date.parse("2026-09-03T12:00:00.000Z"); @@ -1609,6 +1610,42 @@ describe("/usage-limits", () => { }); }); +describe("resetCreditChoices", () => { + it("offers each kind of reset to a hub account under its own grant", () => { + const choices = resetCreditChoices( + { + availableCount: 2, + nextCreditId: "grant_full", + options: [ + { creditId: "grant_full", label: "Full reset", description: "Full.", availableCount: 1 }, + { + creditId: "grant_fable", + label: "Fable reset", + description: "Fable.", + availableCount: 1, + }, + ], + }, + { + sourceId: UsageLimitSourceId.make("hub"), + accountId: "claude.json", + creditId: "grant_full", + }, + now, + ); + expect(choices.map((choice) => [choice.summary, choice.input])).toEqual([ + [ + "Full reset · 1 left", + { sourceId: "hub", accountId: "claude.json", creditId: "grant_full" }, + ], + [ + "Fable reset · 1 left", + { sourceId: "hub", accountId: "claude.json", creditId: "grant_fable" }, + ], + ]); + }); +}); + describe("sameUsageLimitCommandCoverage", () => { const codexAccount = { id: "a", diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index b7902f11d6733..f0a216a13d404 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -763,7 +763,7 @@ export function resetCreditChoices( compact = false, ): ReadonlyArray { const expiresIn = (at: string | undefined) => (at ? formatDuration(Date.parse(at) - now) : null); - if (credits.options && "instanceId" in input) { + if (credits.options) { return credits.options.map((option) => { const expires = expiresIn(option.expiresAt); return { From 439972f69b90256a6c72835c8b8c4b1e25516bb9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 20:03:33 +0000 Subject: [PATCH 3/4] fix(server): a hub Claude grant never takes another grant's unanswered request id The reset coordinator kept one pending request id per account. After an unanswered claim, choosing a different Claude grant on the same hub account sent that grant with the first grant's id, and the first grant's retry then got a new one. The coordinator now keeps the pending id per credit when the caller names one, and the hub names the grant. Claims on one account are still serialized. Co-Authored-By: Claude Fable 5.2 --- .../src/provider/resetCreditCoordinator.ts | 29 ++++++++++++------- apps/server/src/usage/cliproxyApi.test.ts | 12 +++++--- apps/server/src/usage/cliproxyApi.ts | 3 +- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/apps/server/src/provider/resetCreditCoordinator.ts b/apps/server/src/provider/resetCreditCoordinator.ts index c311dad7c5ab5..c7d9991350c7d 100644 --- a/apps/server/src/provider/resetCreditCoordinator.ts +++ b/apps/server/src/provider/resetCreditCoordinator.ts @@ -11,17 +11,21 @@ import * as Semaphore from "effect/Semaphore"; interface AccountRedemptionState { readonly lock: Semaphore.Semaphore; - readonly pendingKey: Ref.Ref; + readonly pendingKeys: Ref.Ref>; } export class ResetCreditCoordinator extends Context.Service< ResetCreditCoordinator, { - /** Reuse the idempotency key until an outcome or settled failure; timeouts keep it for retry. */ + /** + * Reuse the idempotency key until an outcome or settled failure; timeouts keep it for retry. + * `credit` names what is redeemed when an account holds several, so each keeps its own key. + */ readonly redeem: ( accountKey: string, consume: (idempotencyKey: string) => Effect.Effect, isSettled?: (error: E) => boolean, + credit?: string, ) => Effect.Effect; } >()("t3/provider/resetCreditCoordinator") {} @@ -37,7 +41,7 @@ export const make = Effect.gen(function* () { if (existing) return existing; const candidate = { lock: yield* Semaphore.make(1), - pendingKey: yield* Ref.make(null), + pendingKeys: yield* Ref.make>(new Map()), }; return yield* Ref.modify(statesRef, (states) => { const current = states.get(accountKey); @@ -50,19 +54,24 @@ export const make = Effect.gen(function* () { const redeem: ResetCreditCoordinator["Service"]["redeem"] = Effect.fn( "resetCreditCoordinator.redeem", - )(function* (accountKey, consume, isSettled) { + )(function* (accountKey, consume, isSettled, credit = "") { const state = yield* stateFor(accountKey); + const settle = Ref.update(state.pendingKeys, (pending) => { + const next = new Map(pending); + next.delete(credit); + return next; + }); return yield* state.lock.withPermits(1)( Effect.gen(function* () { - const existing = yield* Ref.get(state.pendingKey); + const existing = (yield* Ref.get(state.pendingKeys)).get(credit); const idempotencyKey = existing ?? (yield* random.uuidV4()); - yield* Ref.set(state.pendingKey, idempotencyKey); + yield* Ref.update(state.pendingKeys, (pending) => + new Map(pending).set(credit, idempotencyKey), + ); const outcome = yield* consume(idempotencyKey).pipe( - Effect.tapError((error) => - isSettled?.(error) ? Ref.set(state.pendingKey, null) : Effect.void, - ), + Effect.tapError((error) => (isSettled?.(error) ? settle : Effect.void)), ); - yield* Ref.set(state.pendingKey, null); + yield* settle; return outcome; }), ); diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index d1db6ef331215..81031f7c63389 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -748,14 +748,18 @@ describe("CLIProxyAPI built-in management API", () => { expect((yield* api.consume(config, "first.json", "grant_a").pipe(Effect.result))._tag).toBe( "Failure", ); + // Another grant claimed while grant_a is unanswered must not take grant_a's request id. + expect(yield* api.consume(config, "first.json", "grant_b")).toEqual({ outcome: "reset" }); expect(yield* api.consume(config, "first.json", "grant_a")).toEqual({ outcome: "reset" }); const claimed = () => test.requests.flatMap(({ body }) => body?.method === "POST" ? [{ url: body.url, data: body.data }] : [], ); const sent = claimed(); - expect(sent).toHaveLength(2); - expect(sent[0]).toEqual(sent[1]); + expect(sent).toHaveLength(3); + expect(sent[0]).toEqual(sent[2]); + expect(sent[1]?.data).toContain('"grant_id":"grant_b"'); + expect(sent[1]?.data).not.toBe(sent[0]?.data?.replace("grant_a", "grant_b")); expect(sent[0]?.url).toBe( "https://api.anthropic.com/api/organizations/org%20a/reset_rate_limits", ); @@ -766,10 +770,10 @@ 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"]); const refused = yield* api.consume(config, "first.json", "Not A Grant").pipe(Effect.flip); expect(refused.detail).toBe("Claude returned a malformed reset credit."); - expect(claimed()).toHaveLength(2); + expect(claimed()).toHaveLength(3); }), ); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index b0e96c43f3d9e..e8bdff96ffdc7 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -564,7 +564,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }); // A grant can hold several resets, so its id cannot key the claim as a Codex credit's does: - // one request id per account is kept until Claude answers, as for a direct login. + // one request id per grant is kept until Claude answers. const claimClaudeGrant = Effect.fn("CliproxyApi.claimClaudeGrant")(function* ( hub: CliproxyHub, account: AuthFile, @@ -583,6 +583,7 @@ export const makeCliproxyApi = Effect.gen(function* () { return yield* claudeResetClaimOutcome(response.status_code, Effect.succeed(response.body)); }), isSettledClaudeResetCreditFailure, + grantId, ); }); From c3c9dac0ac7c14cbb9fe9af1c3e09cbc674022ac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 20:09:59 +0000 Subject: [PATCH 4/4] fix(server): a direct Claude login keeps each grant's unanswered request id too The direct Claude login shared one pending request id across the grants of a login, as the hub did. It now names the grant to the coordinator, so a different grant chosen after an unanswered claim gets its own id and the first grant's retry keeps its own. Co-Authored-By: Claude Fable 5.2 --- .../src/provider/Drivers/ClaudeDriver.ts | 5 +++-- .../provider/resetCreditCoordinator.test.ts | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index af20b76754943..54ae84a501165 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -304,8 +304,8 @@ export const ClaudeDriver: ProviderDriver = Effect.fn( "ClaudeDriver.consumeResetCredit", )( @@ -331,6 +331,7 @@ export const ClaudeDriver: ProviderDriver { }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), ); + it.effect("keeps an unanswered key for its own credit when another is redeemed", () => + Effect.gen(function* () { + const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; + const keys = yield* Ref.make>([]); + const consume = (answer: "timed out" | "reset") => (key: string) => + Ref.update(keys, (seen) => [...seen, key]).pipe( + Effect.andThen(answer === "reset" ? Effect.succeed(answer) : Effect.fail(answer)), + ); + + yield* redeem("acct", consume("timed out"), undefined, "weekly").pipe(Effect.result); + yield* redeem("acct", consume("reset"), undefined, "fable"); + yield* redeem("acct", consume("reset"), undefined, "weekly"); + + const seen = yield* Ref.get(keys); + assert.notStrictEqual(seen[1], seen[0]); + assert.strictEqual(seen[2], seen[0]); + }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), + ); + it.effect("starts a fresh attempt after a settled failure", () => Effect.gen(function* () { const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator;