From a906d5d7448217ab623864028997af11d0b6a1a9 Mon Sep 17 00:00:00 2001 From: Krarilotus Date: Thu, 12 Nov 2026 16:20:38 +0100 Subject: [PATCH 1/7] fix(server): Claude accounts in a CLIProxyAPI hub show and use their reset grants The hub read Claude usage without asking for reset grants, and Use reset redeemed only Codex credits, so hub Claude accounts never showed the resets Claude Code offers them. The hub read now asks for the grants and maps them with the direct reader's parser; a chosen grant is claimed through the hub for the organization its OAuth profile names, with the coordinator keeping one request id per claim, and the hub cooldown is cleared. --- .../server/src/provider/claudeResetCredits.ts | 92 +++++--- apps/server/src/usage/cliproxyApi.test.ts | 112 +++++++++- apps/server/src/usage/cliproxyApi.ts | 210 ++++++++++++------ docs/user/usage.md | 4 +- packages/shared/src/usageLimits.test.ts | 33 +++ packages/shared/src/usageLimits.ts | 2 +- 6 files changed, 344 insertions(+), 109 deletions(-) diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index d2000804cf4f6..4d0cc3ffcc24b 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -59,6 +59,7 @@ const ClaimResponse = Schema.Struct({ "unavailable", ]), }); +const decodeClaimResponse = Schema.decodeUnknownEffect(Schema.fromJsonString(ClaimResponse)); const RESET_CREDIT_FAILURES = { malformedCredit: "Claude returned a malformed reset credit.", @@ -256,6 +257,51 @@ const CLAIM_OUTCOMES = { ineligible: "noCredit", } as const satisfies Record; +/** Claude Code's claim of one grant for an organization; requestId is its idempotency key. */ +export const claudeResetClaim = Effect.fn("claudeResetClaim")(function* (input: { + readonly organization: string | undefined; + readonly grantId: string; + readonly requestId: string; +}) { + if (!GRANT_ID.test(input.grantId) || !REQUEST_ID.test(input.requestId)) { + return yield* new ClaudeResetCreditError({ reason: "malformedCredit" }); + } + const organization = input.organization?.trim(); + if (!organization) return yield* new ClaudeResetCreditError({ reason: "signedOut" }); + return { + url: new URL( + `/api/organizations/${encodeURIComponent(organization)}/reset_rate_limits`, + API_BASE, + ).toString(), + body: { program: PROGRAM, grant_id: input.grantId, request_id: input.requestId }, + }; +}); + +/** Claude's answer to a claim, given its HTTP status and body text. */ +export const claudeResetClaimOutcome = Effect.fn("claudeResetClaimOutcome")(function* ( + status: number, + body: string, +) { + if (status === 429) return yield* new ClaudeResetCreditError({ reason: "rateLimited" }); + if (status === 401 || status === 403) { + return yield* new ClaudeResetCreditError({ reason: "signedOut" }); + } + if (status < 200 || status >= 300) { + return yield* new ClaudeResetCreditError({ reason: "requestFailed" }); + } + const answer = yield* decodeClaimResponse(body).pipe( + Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })), + ); + if (answer.result === "cooldown") { + return yield* new ClaudeResetCreditError({ reason: "coolingDown" }); + } + // Unknown outcomes retain the request ID so retries address the same claim. + if (answer.result === "unavailable") { + return yield* new ClaudeResetCreditError({ reason: "unconfirmed" }); + } + return CLAIM_OUTCOMES[answer.result]; +}); + /** requestId is the claim's idempotency key. */ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(function* (input: { readonly configDir: string; @@ -264,57 +310,33 @@ 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 token = yield* readAccessToken(input.configDir).pipe( Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "loginUnreadable", cause })), ); const config = yield* readJson(Config, input.accountConfigPath).pipe( Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "accountUnreadable", cause })), ); - const organization = config.oauthAccount?.organizationUuid?.trim(); - if (!token || !organization) { - return yield* new ClaudeResetCreditError({ reason: "signedOut" }); - } + const claim = yield* claudeResetClaim({ + organization: config.oauthAccount?.organizationUuid, + grantId: input.grantId, + requestId: input.requestId, + }); + if (!token) return yield* new ClaudeResetCreditError({ reason: "signedOut" }); 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(claim.url).pipe( withClaudeHeaders(token, input.version), - HttpClientRequest.bodyJsonUnsafe({ - program: PROGRAM, - grant_id: input.grantId, - request_id: input.requestId, - }), + HttpClientRequest.bodyJsonUnsafe(claim.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)), + const body = yield* response.text.pipe( Effect.timeout("25 seconds"), - Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })), + Effect.orElseSucceed(() => ""), ); - 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]; + return yield* claudeResetClaimOutcome(response.status, body); }); diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 431af7b5971ff..abf30aba20e29 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -46,6 +46,11 @@ const RequestBody = Schema.Struct({ type RequestBody = typeof RequestBody.Type; const decodeRequest = Schema.decodeUnknownSync(Schema.fromJsonString(RequestBody)); const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeClaim = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ program: Schema.String, grant_id: Schema.String, request_id: Schema.String }), + ), +); function fixture( options: { @@ -346,7 +351,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" }, @@ -373,6 +378,111 @@ describe("CLIProxyAPI built-in management API", () => { }), ); + it.effect( + "reads Claude reset grants per account and keeps windows beside a malformed block", + () => + Effect.gen(function* () { + // Shaped like a hub answer of 12 Nov 2026: a weekly and a Fable grant, plus one not usable yet. + const grants = { + eligible: true, + next_grant_id: "full-reset-2611", + grants: [ + { + id: "full-reset-2611", + resets_left: 1, + usable_now: true, + clears: ["seven_day", "seven_day_overage_included"], + type: "weekly", + gives_back_weekly: false, + ends_at: "2099-12-10T22:00:00+00:00", + }, + { + id: "launch-2610-fable", + resets_left: 1, + usable_now: true, + clears: ["seven_day_overage_included"], + type: "full", + gives_back_weekly: true, + ends_at: "2099-03-09T07:59:00+00:00", + }, + { id: "blocked-grant", resets_left: 1, usable_now: false, blocking: ["seven_day"] }, + ], + }; + const test = fixture({ + accounts: [ + { ...accounts[0]!, provider: "claude" }, + { ...accounts[1]!, provider: "claude" }, + ], + upstream: (request) => ({ + status: 200, + body: { + seven_day: { utilization: 90, resets_at: null }, + cedar_ember: request.auth_index === "a" ? grants : { eligible: true, grants: "?" }, + }, + }), + }); + const [first, second] = yield* (yield* test.api).readAccounts(config); + expect(first?.usageLimits.resetCredits).toMatchObject({ + availableCount: 2, + nextCreditId: "full-reset-2611", + }); + expect( + first?.usageLimits.resetCredits?.options?.map(({ creditId, label, availableCount }) => [ + creditId, + label, + availableCount, + ]), + ).toEqual([ + ["full-reset-2611", "Weekly reset", 1], + ["launch-2610-fable", "Fable reset", 1], + ]); + expect(second?.usageLimits.resetCredits).toBeUndefined(); + expect(second?.usageLimits.windows.map((window) => window.usedPercent)).toEqual([90]); + expect(test.requests[1]?.body?.url).toBe( + "https://api.anthropic.com/api/oauth/usage?cedar_ember=1", + ); + }), + ); + + it.effect( + "claims the chosen Claude grant for that account's organization, one request id per claim", + () => + Effect.gen(function* () { + let claims = 0; + const test = fixture({ + accounts: [ + { ...accounts[0]!, provider: "claude" }, + { ...accounts[1]!, provider: "claude" }, + ], + upstream: (request) => + request.url?.endsWith("/api/oauth/profile") + ? { status: 200, body: { organization: { uuid: `org-${request.auth_index}` } } } + : // The first claim goes unanswered, so its retry must address the same claim. + { status: ++claims === 1 ? 503 : 200, body: { result: "reset" } }, + }); + const api = yield* test.api; + const claim = api.consume(config, "second.json", "launch-2610-fable"); + expect((yield* claim.pipe(Effect.result))._tag).toBe("Failure"); + expect(yield* claim).toEqual({ outcome: "reset" }); + expect(yield* claim).toEqual({ outcome: "reset" }); + const sent = test.requests.filter((request) => request.body?.url?.includes("/reset_rate")); + expect(sent.map((request) => request.body?.url)).toEqual( + Array(3).fill("https://api.anthropic.com/api/organizations/org-b/reset_rate_limits"), + ); + const bodies = sent.map((request) => decodeClaim(request.body?.data)); + expect(bodies.map(({ program, grant_id }) => [program, grant_id])).toEqual( + Array(3).fill(["cedar_ember", "launch-2610-fable"]), + ); + expect(bodies[1]?.request_id).toBe(bodies[0]?.request_id); + expect(bodies[2]?.request_id).not.toBe(bodies[1]?.request_id); + expect( + test.requests + .filter((request) => request.path.endsWith("/reset-quota")) + .map((request) => request.body?.auth_index), + ).toEqual(["b", "b"]); + }), + ); + it.effect.each([ { name: "spend budget", diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 030983f50e33d..df35c3f17cf9b 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -23,6 +23,13 @@ import { import { codexPlanLabel } from "../provider/CodexProvider.ts"; import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts"; import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts"; +import { + claudeResetClaim, + claudeResetClaimOutcome, + claudeResetCreditsToContract, + isSettledClaudeResetCreditFailure, +} from "../provider/claudeResetCredits.ts"; +import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts"; import { antigravityAccountFingerprint, antigravitySummaryWindows, @@ -78,6 +85,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({ @@ -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,51 @@ 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 return 429 without a Claude Code user agent; older CLI versions get no reset grants. + "User-Agent": "claude-cli/2.1.322 (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 limits = claudeUsageResponseToLimits({ + checkedAt, + response: { rate_limits_available: true, rate_limits: rateLimits }, + }).limits; + const resetCredits = claudeResetCreditsToContract(usage.cedar_ember, Date.parse(checkedAt)); + return { + ...listedIdentity(account), + plan: "Claude Subscription", + usageLimits: resetCredits ? { ...limits, resetCredits } : limits, + }; + }), +}; + const GROK_CLI_VERSION = "1.0.77"; const ANTIGRAVITY_SUMMARY_URL = "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"; @@ -297,49 +353,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 +416,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 +453,8 @@ export const makeCliproxyApi = Effect.gen(function* () { return (yield* decodeAuthFiles(response)).files; }); - const apiCall = Effect.fn("CliproxyApi.apiCall")(function* ( + /** The upstream answer whatever its status; `apiCall` accepts only a 2xx. */ + const upstreamCall = Effect.fn("CliproxyApi.upstreamCall")(function* ( hub: CliproxyHub, account: AuthFile, header: Record, @@ -452,7 +468,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 +529,61 @@ 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. + 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}#${account.id}`, + (requestId) => + Effect.gen(function* () { + const profile = yield* decodeClaudeProfile( + yield* apiCall(hub, account, header, `${CLAUDE_API}/api/oauth/profile`), + ); + const claim = yield* claudeResetClaim({ + organization: profile.organization.uuid, + grantId, + requestId, + }); + const response = yield* upstreamCall(hub, account, header, claim.url, claim.body); + return yield* claudeResetClaimOutcome(response.status_code, response.body); + }), + isSettledClaudeResetCreditFailure, + ); + }); + const consume = Effect.fn("CliproxyApi.consume")(function* ( hub: CliproxyHub, accountId: string, @@ -510,33 +591,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 +623,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..db1dd3f8accc0 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, on any server platform. 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 bed48428a93c4..49074739d2d35 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -23,6 +23,7 @@ import { exhaustedUsageWindow, paceOf, paceRemainingPercent, + resetCreditChoices, usesChatGptSharing, resolveNewThreadModelSelection, } from "./usageLimits.ts"; @@ -1509,6 +1510,38 @@ describe("/usage-limits", () => { }); }); +describe("resetCreditChoices", () => { + it("offers each kind of hub reset under its own grant through the same hub account", () => { + const hub = { + sourceId: UsageLimitSourceId.make("hub"), + accountId: "claude.json", + creditId: "full-reset-2611", + }; + const option = (creditId: string, label: string) => ({ + creditId, + label, + description: `${label}.`, + availableCount: 1, + }); + const choices = resetCreditChoices( + { + availableCount: 2, + nextCreditId: "full-reset-2611", + options: [ + option("full-reset-2611", "Weekly reset"), + option("launch-2610-fable", "Fable reset"), + ], + }, + hub, + now, + ); + expect(choices.map((choice) => choice.input)).toEqual([ + hub, + { ...hub, creditId: "launch-2610-fable" }, + ]); + }); +}); + describe("sameUsageLimitCommandCoverage", () => { const codexAccount = { id: "a", diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 716103ac2fc9b..26daf88e30d5b 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -725,7 +725,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 4c9de48bf8233619b652330ad9ad0891989e878b Mon Sep 17 00:00:00 2001 From: Krarilotus Date: Thu, 12 Nov 2026 17:23:42 +0100 Subject: [PATCH 2/7] fix(server): a Claude reset claim keeps its own request id until answered, and hub grants need a verified organization A pending claim's request id was kept per account, so choosing another grant (or an account that moved organization) reused it, and a refused retry dropped it although the first attempt may have applied. Pending ids are now kept per action (organization and grant), and only a first attempt's refusal or an answer clears one. Hub Claude grants are offered only with the organization read from the account's profile, published as its workspace, so they never merge onto another organization's row; without it the windows stay and the grants are left out. --- .../src/provider/Drivers/ClaudeDriver.ts | 2 + .../provider/resetCreditCoordinator.test.ts | 50 +++++++++++ .../src/provider/resetCreditCoordinator.ts | 29 +++++-- apps/server/src/usage/cliproxyApi.test.ts | 87 ++++++++++++++----- apps/server/src/usage/cliproxyApi.ts | 24 +++-- 5 files changed, 152 insertions(+), 40 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index af20b76754943..5ac2d81125d73 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -320,6 +320,7 @@ export const ClaudeDriver: ProviderDriver @@ -331,6 +332,7 @@ export const ClaudeDriver: ProviderDriver { }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), ); + it.effect("keeps an unanswered claim's key when its retry is refused, until an answer", () => + Effect.gen(function* () { + const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; + const keys = yield* Ref.make>([]); + const answers = yield* Ref.make>([ + "lost", + "rateLimited", + "reset", + "reset", + ]); + const consume = (key: string) => + Effect.gen(function* () { + yield* Ref.update(keys, (seen) => [...seen, key]); + const [answer] = yield* Ref.getAndUpdate(answers, ([, ...rest]) => rest); + return answer === "reset" ? answer : yield* Effect.fail(answer!); + }); + const isSettled = (error: "lost" | "rateLimited") => error === "rateLimited"; + + for (let attempt = 0; attempt < 4; attempt += 1) { + yield* redeem("acct", consume, isSettled).pipe(Effect.result); + } + + const [lost, refused, answered, next] = yield* Ref.get(keys); + assert.strictEqual(refused, lost); + assert.strictEqual(answered, lost); + assert.notStrictEqual(next, lost); + }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), + ); + + it.effect("never gives one action's unanswered key to another action on the account", () => + Effect.gen(function* () { + const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; + const keys = yield* Ref.make>([]); + const consume = (outcome: "lost" | "reset") => (key: string) => + Effect.gen(function* () { + yield* Ref.update(keys, (seen) => [...seen, key]); + if (outcome === "lost") return yield* Effect.fail(outcome); + return outcome; + }); + + yield* redeem("acct", consume("lost"), undefined, "weekly").pipe(Effect.result); + yield* redeem("acct", consume("reset"), undefined, "fable"); + yield* redeem("acct", consume("reset"), undefined, "weekly"); + + const [weekly, fable, weeklyRetry] = yield* Ref.get(keys); + assert.notStrictEqual(fable, weekly); + assert.strictEqual(weeklyRetry, weekly); + }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), + ); + it.effect("serialises concurrent redemptions on the same account, not per caller", () => Effect.gen(function* () { const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; diff --git a/apps/server/src/provider/resetCreditCoordinator.ts b/apps/server/src/provider/resetCreditCoordinator.ts index c311dad7c5ab5..53d20b8705656 100644 --- a/apps/server/src/provider/resetCreditCoordinator.ts +++ b/apps/server/src/provider/resetCreditCoordinator.ts @@ -11,17 +11,22 @@ import * as Semaphore from "effect/Semaphore"; interface AccountRedemptionState { readonly lock: Semaphore.Semaphore; - readonly pendingKey: Ref.Ref; + /** Unanswered claims' idempotency keys, by the action each one performs. */ + 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 an action's idempotency key until an outcome, or a settled failure of its first attempt; + * timeouts keep it for retry. Another action on the account never gets that key. + */ readonly redeem: ( accountKey: string, consume: (idempotencyKey: string) => Effect.Effect, isSettled?: (error: E) => boolean, + action?: string, ) => Effect.Effect; } >()("t3/provider/resetCreditCoordinator") {} @@ -37,7 +42,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 +55,25 @@ export const make = Effect.gen(function* () { const redeem: ResetCreditCoordinator["Service"]["redeem"] = Effect.fn( "resetCreditCoordinator.redeem", - )(function* (accountKey, consume, isSettled) { + )(function* (accountKey, consume, isSettled, action = "") { const state = yield* stateFor(accountKey); + const forget = Ref.update(state.pendingKeys, (keys) => { + const next = new Map(keys); + next.delete(action); + return next; + }); return yield* state.lock.withPermits(1)( Effect.gen(function* () { - const existing = yield* Ref.get(state.pendingKey); - const idempotencyKey = existing ?? (yield* random.uuidV4()); - yield* Ref.set(state.pendingKey, idempotencyKey); + const retried = (yield* Ref.get(state.pendingKeys)).get(action); + const idempotencyKey = retried ?? (yield* random.uuidV4()); + yield* Ref.update(state.pendingKeys, (keys) => new Map(keys).set(action, idempotencyKey)); const outcome = yield* consume(idempotencyKey).pipe( + // A retry's refusal says nothing about the earlier attempt it repeats, which may have applied. Effect.tapError((error) => - isSettled?.(error) ? Ref.set(state.pendingKey, null) : Effect.void, + retried === undefined && isSettled?.(error) ? forget : Effect.void, ), ); - yield* Ref.set(state.pendingKey, null); + yield* forget; return outcome; }), ); diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index abf30aba20e29..ed45b531d34f6 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -379,7 +379,7 @@ describe("CLIProxyAPI built-in management API", () => { ); it.effect( - "reads Claude reset grants per account and keeps windows beside a malformed block", + "offers Claude reset grants only under the verified organization, keeping windows without them", () => Effect.gen(function* () { // Shaped like a hub answer of 12 Nov 2026: a weekly and a Fable grant, plus one not usable yet. @@ -409,19 +409,28 @@ describe("CLIProxyAPI built-in management API", () => { ], }; const test = fixture({ - accounts: [ - { ...accounts[0]!, provider: "claude" }, - { ...accounts[1]!, provider: "claude" }, - ], - upstream: (request) => ({ - status: 200, - body: { - seven_day: { utilization: 90, resets_at: null }, - cedar_ember: request.auth_index === "a" ? grants : { eligible: true, grants: "?" }, - }, - }), + accounts: ["a", "b", "c"].map((auth_index) => ({ + id: `${auth_index}.json`, + auth_index, + provider: "claude", + email: `${auth_index}@example.com`, + })), + upstream: (request) => + request.url?.endsWith("/api/oauth/profile") + ? request.auth_index === "a" + ? { status: 200, body: { organization: { uuid: "org-a" } } } + : { status: 500, body: {} } + : { + status: 200, + body: { + seven_day: { utilization: 90, resets_at: null }, + cedar_ember: + request.auth_index === "c" ? { eligible: true, grants: "?" } : grants, + }, + }, }); - const [first, second] = yield* (yield* test.api).readAccounts(config); + const [first, unverified, malformed] = yield* (yield* test.api).readAccounts(config); + expect(first?.workspaceId).toBe("org-a"); expect(first?.usageLimits.resetCredits).toMatchObject({ availableCount: 2, nextCreditId: "full-reset-2611", @@ -436,8 +445,11 @@ describe("CLIProxyAPI built-in management API", () => { ["full-reset-2611", "Weekly reset", 1], ["launch-2610-fable", "Fable reset", 1], ]); - expect(second?.usageLimits.resetCredits).toBeUndefined(); - expect(second?.usageLimits.windows.map((window) => window.usedPercent)).toEqual([90]); + for (const account of [unverified, malformed]) { + expect(account?.workspaceId).toBeUndefined(); + expect(account?.usageLimits.resetCredits).toBeUndefined(); + expect(account?.usageLimits.windows.map((window) => window.usedPercent)).toEqual([90]); + } expect(test.requests[1]?.body?.url).toBe( "https://api.anthropic.com/api/oauth/usage?cedar_ember=1", ); @@ -448,7 +460,9 @@ describe("CLIProxyAPI built-in management API", () => { "claims the chosen Claude grant for that account's organization, one request id per claim", () => Effect.gen(function* () { - let claims = 0; + // The first claim may have applied though its answer was lost, and refusing its retry + // doesn't settle it: only an answer frees the account for its next use. + const statuses = [503, 429, 200, 200]; const test = fixture({ accounts: [ { ...accounts[0]!, provider: "claude" }, @@ -457,24 +471,25 @@ describe("CLIProxyAPI built-in management API", () => { upstream: (request) => request.url?.endsWith("/api/oauth/profile") ? { status: 200, body: { organization: { uuid: `org-${request.auth_index}` } } } - : // The first claim goes unanswered, so its retry must address the same claim. - { status: ++claims === 1 ? 503 : 200, body: { result: "reset" } }, + : { status: statuses.shift() ?? 200, body: { result: "reset" } }, }); const api = yield* test.api; const claim = api.consume(config, "second.json", "launch-2610-fable"); expect((yield* claim.pipe(Effect.result))._tag).toBe("Failure"); + expect((yield* claim.pipe(Effect.result))._tag).toBe("Failure"); expect(yield* claim).toEqual({ outcome: "reset" }); expect(yield* claim).toEqual({ outcome: "reset" }); const sent = test.requests.filter((request) => request.body?.url?.includes("/reset_rate")); expect(sent.map((request) => request.body?.url)).toEqual( - Array(3).fill("https://api.anthropic.com/api/organizations/org-b/reset_rate_limits"), + Array(4).fill("https://api.anthropic.com/api/organizations/org-b/reset_rate_limits"), ); const bodies = sent.map((request) => decodeClaim(request.body?.data)); expect(bodies.map(({ program, grant_id }) => [program, grant_id])).toEqual( - Array(3).fill(["cedar_ember", "launch-2610-fable"]), + Array(4).fill(["cedar_ember", "launch-2610-fable"]), ); - expect(bodies[1]?.request_id).toBe(bodies[0]?.request_id); - expect(bodies[2]?.request_id).not.toBe(bodies[1]?.request_id); + const [lost, refused, answered, next] = bodies.map((body) => body.request_id); + expect([refused, answered]).toEqual([lost, lost]); + expect(next).not.toBe(lost); expect( test.requests .filter((request) => request.path.endsWith("/reset-quota")) @@ -483,6 +498,34 @@ describe("CLIProxyAPI built-in management API", () => { }), ); + it.effect("never reuses an unanswered Claude claim's id for another grant or organization", () => + Effect.gen(function* () { + let organization = "org-1"; + const statuses = [503]; + const test = fixture({ + accounts: [{ ...accounts[0]!, provider: "claude" }], + upstream: (request) => + request.url?.endsWith("/api/oauth/profile") + ? { status: 200, body: { organization: { uuid: organization } } } + : { status: statuses.shift() ?? 200, body: { result: "reset" } }, + }); + const api = yield* test.api; + const claim = (grant: string) => api.consume(config, "first.json", grant); + expect((yield* claim("full-reset-2611").pipe(Effect.result))._tag).toBe("Failure"); + yield* claim("launch-2610-fable"); + organization = "org-2"; + yield* claim("full-reset-2611"); + organization = "org-1"; + yield* claim("full-reset-2611"); + const [weekly, fable, otherOrganization, weeklyRetry] = test.requests + .filter((request) => request.body?.url?.includes("/reset_rate")) + .map((request) => decodeClaim(request.body?.data).request_id); + expect(fable).not.toBe(weekly); + expect(otherOrganization).not.toBe(weekly); + expect(weeklyRetry).toBe(weekly); + }), + ); + it.effect.each([ { name: "spend budget", diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index df35c3f17cf9b..44ed4312f0016 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -302,6 +302,11 @@ const CODEX_HUB: HubProvider = { }; const CLAUDE_API = "https://api.anthropic.com"; +const readClaudeOrganization = (call: HubCall) => + call(`${CLAUDE_API}/api/oauth/profile`).pipe( + Effect.flatMap(decodeClaudeProfile), + Effect.map((profile) => profile.organization.uuid), + ); const CLAUDE_HUB: HubProvider = { driver: ProviderDriverKind.make("claudeAgent"), header: () => ({ @@ -338,10 +343,16 @@ const CLAUDE_HUB: HubProvider = { response: { rate_limits_available: true, rate_limits: rateLimits }, }).limits; const resetCredits = claudeResetCreditsToContract(usage.cedar_ember, Date.parse(checkedAt)); + // Grants are spent in the token's organization; offer them only under that verified workspace. + const organization = resetCredits + ? yield* readClaudeOrganization(call).pipe(Effect.option) + : Option.none(); return { ...listedIdentity(account), + ...(Option.isSome(organization) ? { workspaceId: organization.value } : {}), plan: "Claude Subscription", - usageLimits: resetCredits ? { ...limits, resetCredits } : limits, + usageLimits: + resetCredits && Option.isSome(organization) ? { ...limits, resetCredits } : limits, }; }), }; @@ -565,22 +576,17 @@ export const makeCliproxyApi = Effect.gen(function* () { grantId: string, ) { const header = CLAUDE_HUB.header(account, platform, architecture); + const organization = yield* readClaudeOrganization((url) => apiCall(hub, account, header, url)); return yield* coordinator.redeem( `${hub.url}#${account.id}`, (requestId) => Effect.gen(function* () { - const profile = yield* decodeClaudeProfile( - yield* apiCall(hub, account, header, `${CLAUDE_API}/api/oauth/profile`), - ); - const claim = yield* claudeResetClaim({ - organization: profile.organization.uuid, - grantId, - requestId, - }); + const claim = yield* claudeResetClaim({ organization, grantId, requestId }); const response = yield* upstreamCall(hub, account, header, claim.url, claim.body); return yield* claudeResetClaimOutcome(response.status_code, response.body); }), isSettledClaudeResetCreditFailure, + `${organization}\n${grantId}`, ); }); From 8854005509b9ac8546ee2850e27143a9de08b172 Mon Sep 17 00:00:00 2001 From: Krarilotus Date: Thu, 12 Nov 2026 17:55:22 +0100 Subject: [PATCH 3/7] docs(usage): hub reset credits are redeemed from the account's details --- docs/user/usage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/usage.md b/docs/user/usage.md index db1dd3f8accc0..7d7b459afea26 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -178,7 +178,7 @@ 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 and Claude accounts show banked reset credits; select -an account and choose **Use reset** to redeem one, on any server platform. No hub plugin is required. +an account to redeem one from its details, on any server platform. 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 6dd20fc54ac5a696bb3224a698264bbab02acac8 Mon Sep 17 00:00:00 2001 From: Krarilotus Date: Thu, 12 Nov 2026 18:13:17 +0100 Subject: [PATCH 4/7] fix(server): a Claude reset is claimed only in the organization it was shown under A claim read the login's organization before waiting for the account's lock and again while claiming, so a login that moved meanwhile could be sent another organization's grant or request id. A hub Claude account without a readable organization also joined the only known workspace for its email. The hub redemption input now carries the workspace the grant was shown under, and the direct path uses its snapshot's workspace. Both compare it with the organization read under the claim lock and refuse a mismatch before sending. Every hub Claude account reads its organization, and a Claude report without one keeps its own row. --- .../src/provider/Drivers/ClaudeDriver.ts | 4 +- .../provider/ProviderInstanceRegistry.test.ts | 84 +++++++++++++++++++ .../src/provider/claudeResetCredits.test.ts | 1 + .../server/src/provider/claudeResetCredits.ts | 8 ++ apps/server/src/usage/UsageLimitSources.ts | 1 + apps/server/src/usage/cliproxyApi.test.ts | 83 +++++++++++++++--- apps/server/src/usage/cliproxyApi.ts | 32 ++++--- docs/user/usage.md | 3 + packages/contracts/src/providerUsageLimits.ts | 2 + packages/shared/src/usageLimits.test.ts | 71 +++++++++------- packages/shared/src/usageLimits.ts | 8 +- 11 files changed, 244 insertions(+), 53 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 5ac2d81125d73..d5ea996b29366 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -320,7 +320,8 @@ export const ClaudeDriver: ProviderDriver @@ -328,6 +329,7 @@ export const ClaudeDriver: ProviderDriver { }), ); + it.live.skipIf(windowsHost)( + "claims a Claude reset only in the organization its grant was read under, even queued", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixtures = yield* makeTildeProviderFixtures(); + const signIn = (organizationUuid: string) => + fs.writeFileString( + path.join(fixtures.claudeHomePath, ".claude.json"), + JSON.stringify({ oauthAccount: { organizationUuid } }), + ); + yield* fs.writeFileString( + path.join(fixtures.claudeHomePath, ".credentials.json"), + '{"claudeAiOauth":{"accessToken":"fake-token"}}', + ); + yield* signIn("org-a"); + const posted = yield* Deferred.make(); + const answer = yield* Deferred.make(); + const claims: Array<{ url: string; requestId: string }> = []; + const client = HttpClient.make((request) => + Effect.gen(function* () { + if (request.url.endsWith("/api/oauth/usage")) { + return HttpClientResponse.fromWeb( + request, + Response.json({ + cedar_ember: { + eligible: true, + next_grant_id: "grant_a", + grants: [{ id: "grant_a", resets_left: 2, usable_now: true }], + }, + }), + ); + } + if (request.url.endsWith("/reset_rate_limits")) { + const body = + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""; + claims.push({ url: request.url, requestId: decodeClaimRequest(body).request_id }); + if (claims.length === 1) yield* Deferred.succeed(posted, undefined); + const status = claims.length === 1 ? yield* Deferred.await(answer) : 200; + return HttpClientResponse.fromWeb(request, Response.json({ result: "reset" }, { status })); + } + return HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" })); + }), + ); + const instanceId = ProviderInstanceId.make("claude_moved"); + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [ClaudeDriver], + configMap: { + [instanceId]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + config: makeClaudeConfig({ + enabled: true, + binaryPath: fixtures.claudeBinaryPath, + homePath: fixtures.claudeHomePath, + }), + }, + }, + }).pipe(Effect.provideService(HttpClient.HttpClient, client)); + const instance = (yield* registry.getInstance(instanceId))!; + expect((yield* instance.snapshot.refresh).auth.workspaceId).toBe("org-a"); + const consume = instance.consumeResetCredit!(undefined).pipe(Effect.result); + const unanswered = yield* consume.pipe(Effect.forkChild); + yield* Deferred.await(posted); + // Claude signs in to another organization while the shown grant's claim waits. + yield* signIn("org-b"); + const queued = yield* consume.pipe(Effect.forkChild); + yield* Deferred.succeed(answer, 503); + const results = [yield* Fiber.join(unanswered), yield* Fiber.join(queued)]; + yield* signIn("org-a"); + const retry = yield* consume; + + const sentTo = "https://api.anthropic.com/api/organizations/org-a/reset_rate_limits"; + const first = { url: sentTo, requestId: claims[0]?.requestId }; + expect(claims).toEqual([first, first]); + expect(results.map((result) => result._tag)).toEqual(["Failure", "Failure"]); + expect(retry).toMatchObject({ _tag: "Success", success: "reset" }); + }).pipe(Effect.provideService(HostProcess.Platform, "linux"), Effect.provide(layerTest)), + ); + const makeClaudeCacheFixture = Effect.fnUntraced(function* ( state: "ready" | "signed-out" | "usage-failed", ) { diff --git a/apps/server/src/provider/claudeResetCredits.test.ts b/apps/server/src/provider/claudeResetCredits.test.ts index a3a1110d87d34..8e04990996109 100644 --- a/apps/server/src/provider/claudeResetCredits.test.ts +++ b/apps/server/src/provider/claudeResetCredits.test.ts @@ -184,6 +184,7 @@ const consume = (client: HttpClient.HttpClient, ids = { grantId: "grant_a", requ return yield* ClaudeResetCredits.consumeClaudeResetCredit({ ...login, version: "2.1.0", + expectedOrganization: "org-1", ...ids, }).pipe( Effect.provideService(HostProcess.Platform, "linux"), diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 4d0cc3ffcc24b..c33d5fac72f7c 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -66,6 +66,7 @@ const RESET_CREDIT_FAILURES = { loginUnreadable: "Claude could not read its login.", accountUnreadable: "Claude could not read its account.", signedOut: "Sign in to Claude again to redeem resets.", + accountChanged: "The Claude login changed since this reset was shown. Refresh Limits and try again.", rateLimited: "Claude is rate limiting resets. Try again soon.", coolingDown: "Claude resets are cooling down. Try again later.", unconfirmed: @@ -259,7 +260,9 @@ const CLAIM_OUTCOMES = { /** Claude Code's claim of one grant for an organization; requestId is its idempotency key. */ export const claudeResetClaim = Effect.fn("claudeResetClaim")(function* (input: { + /** The login's organization, read under the account's claim lock. */ readonly organization: string | undefined; + readonly expectedOrganization: string | undefined; readonly grantId: string; readonly requestId: string; }) { @@ -268,6 +271,9 @@ export const claudeResetClaim = Effect.fn("claudeResetClaim")(function* (input: } const organization = input.organization?.trim(); if (!organization) return yield* new ClaudeResetCreditError({ reason: "signedOut" }); + if (organization !== input.expectedOrganization) { + return yield* new ClaudeResetCreditError({ reason: "accountChanged" }); + } return { url: new URL( `/api/organizations/${encodeURIComponent(organization)}/reset_rate_limits`, @@ -307,6 +313,7 @@ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(fu readonly configDir: string; readonly accountConfigPath: string; readonly version: string; + readonly expectedOrganization: string | undefined; readonly grantId: string; readonly requestId: string; }) { @@ -318,6 +325,7 @@ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(fu ); const claim = yield* claudeResetClaim({ organization: config.oauthAccount?.organizationUuid, + expectedOrganization: input.expectedOrganization, grantId: input.grantId, requestId: input.requestId, }); diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 7d40b0fb9ecea..e42baf40d93ad 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -141,6 +141,7 @@ export const make = Effect.gen(function* () { { url: config.url, managementKey }, input.accountId, input.creditId, + input.workspaceId, ); const snapshot = yield* readSource(input.sourceId, config); const previous = yield* Ref.get(stateRef); diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index ed45b531d34f6..2929cd28d967e 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -1,7 +1,9 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientError, HttpClientResponse } from "effect/http"; @@ -52,6 +54,8 @@ const decodeClaim = Schema.decodeUnknownSync( ), ); +type Upstream = { status: number; body: unknown }; + function fixture( options: { accounts?: Array<{ @@ -63,13 +67,15 @@ function fixture( project_id?: string; id_token?: { chatgpt_account_id: string; plan_type?: string; chatgpt_plan_type?: string }; }>; - upstream?: (request: RequestBody) => { status: number; body: unknown }; + upstream?: ( + request: RequestBody, + ) => Upstream | Effect.Effect; cooldownStatus?: number; } = {}, ) { const requests: Array<{ path: string; body?: RequestBody }> = []; const http = HttpClient.make((request) => - Effect.sync(() => { + Effect.gen(function* () { expect(request.headers.authorization).toBe("Bearer management-secret"); const path = new URL(request.url).pathname; const body = @@ -89,7 +95,8 @@ function fixture( ); expect(path).toMatch(/\/v0\/management\/api-call$/); expect(body?.header?.Authorization).toBe("Bearer $TOKEN$"); - const upstream = options.upstream?.(body!) ?? { + const answer = options.upstream?.(body!); + const upstream = (Effect.isEffect(answer) ? yield* answer : answer) ?? { status: 200, body: body?.url?.endsWith("/consume") ? { code: "reset" } @@ -379,7 +386,7 @@ describe("CLIProxyAPI built-in management API", () => { ); it.effect( - "offers Claude reset grants only under the verified organization, keeping windows without them", + "names every Claude account's verified organization, offering grants only under it, keeping windows", () => Effect.gen(function* () { // Shaped like a hub answer of 12 Nov 2026: a weekly and a Fable grant, plus one not usable yet. @@ -409,7 +416,7 @@ describe("CLIProxyAPI built-in management API", () => { ], }; const test = fixture({ - accounts: ["a", "b", "c"].map((auth_index) => ({ + accounts: ["a", "b", "c", "d"].map((auth_index) => ({ id: `${auth_index}.json`, auth_index, provider: "claude", @@ -417,19 +424,28 @@ describe("CLIProxyAPI built-in management API", () => { })), upstream: (request) => request.url?.endsWith("/api/oauth/profile") - ? request.auth_index === "a" - ? { status: 200, body: { organization: { uuid: "org-a" } } } + ? request.auth_index === "a" || request.auth_index === "d" + ? { status: 200, body: { organization: { uuid: `org-${request.auth_index}` } } } : { status: 500, body: {} } : { status: 200, body: { seven_day: { utilization: 90, resets_at: null }, - cedar_ember: - request.auth_index === "c" ? { eligible: true, grants: "?" } : grants, + ...(request.auth_index === "d" + ? {} + : { + cedar_ember: + request.auth_index === "c" ? { eligible: true, grants: "?" } : grants, + }), }, }, }); - const [first, unverified, malformed] = yield* (yield* test.api).readAccounts(config); + const [first, unverified, malformed, withoutGrants] = yield* (yield* test.api).readAccounts( + config, + ); + // Its organization is what joins an account to the same login elsewhere, grants or not. + expect(withoutGrants?.workspaceId).toBe("org-d"); + expect(withoutGrants?.usageLimits.resetCredits).toBeUndefined(); expect(first?.workspaceId).toBe("org-a"); expect(first?.usageLimits.resetCredits).toMatchObject({ availableCount: 2, @@ -474,7 +490,7 @@ describe("CLIProxyAPI built-in management API", () => { : { status: statuses.shift() ?? 200, body: { result: "reset" } }, }); const api = yield* test.api; - const claim = api.consume(config, "second.json", "launch-2610-fable"); + const claim = api.consume(config, "second.json", "launch-2610-fable", "org-b"); expect((yield* claim.pipe(Effect.result))._tag).toBe("Failure"); expect((yield* claim.pipe(Effect.result))._tag).toBe("Failure"); expect(yield* claim).toEqual({ outcome: "reset" }); @@ -510,7 +526,7 @@ describe("CLIProxyAPI built-in management API", () => { : { status: statuses.shift() ?? 200, body: { result: "reset" } }, }); const api = yield* test.api; - const claim = (grant: string) => api.consume(config, "first.json", grant); + const claim = (grant: string) => api.consume(config, "first.json", grant, organization); expect((yield* claim("full-reset-2611").pipe(Effect.result))._tag).toBe("Failure"); yield* claim("launch-2610-fable"); organization = "org-2"; @@ -526,6 +542,49 @@ describe("CLIProxyAPI built-in management API", () => { }), ); + it.effect("claims a Claude grant only in the organization it was shown under, even queued", () => + Effect.gen(function* () { + let organization = "org-a"; + const posted = yield* Deferred.make(); + const answer = yield* Deferred.make(); + const claims: Array<{ signedIn: string; url?: string; requestId: string }> = []; + const test = fixture({ + accounts: [{ ...accounts[0]!, provider: "claude" }], + upstream: (request) => { + if (request.url?.endsWith("/api/oauth/profile")) { + return { status: 200, body: { organization: { uuid: organization } } }; + } + const { request_id } = decodeClaim(request.data); + claims.push({ signedIn: organization, url: request.url, requestId: request_id }); + return claims.length === 1 + ? Deferred.succeed(posted, undefined).pipe(Effect.andThen(Deferred.await(answer))) + : { status: 200, body: { result: "reset" } }; + }, + }); + const api = yield* test.api; + const claim = (workspaceId?: string) => + api.consume(config, "first.json", "full-reset-2611", workspaceId).pipe(Effect.result); + const unanswered = yield* claim("org-a").pipe(Effect.forkChild); + yield* Deferred.await(posted); + // The hub's login moves to another organization while the shown grant's claim waits. + organization = "org-b"; + const queued = yield* claim("org-a").pipe(Effect.forkChild); + yield* Deferred.succeed(answer, { status: 503, body: {} }); + const results = [yield* Fiber.join(unanswered), yield* Fiber.join(queued), yield* claim()]; + organization = "org-a"; + const retry = yield* claim("org-a"); + + const sentTo = "https://api.anthropic.com/api/organizations/org-a/reset_rate_limits"; + const first = { signedIn: "org-a", url: sentTo, requestId: claims[0]?.requestId }; + expect(claims).toEqual([first, first]); + expect(results.map((result) => result._tag)).toEqual(["Failure", "Failure", "Failure"]); + expect(results[1]).toMatchObject({ + failure: { detail: expect.stringContaining("Claude login changed") }, + }); + expect(retry).toMatchObject({ _tag: "Success", success: { outcome: "reset" } }); + }), + ); + it.effect.each([ { name: "spend budget", diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 44ed4312f0016..a18316ebeda0f 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -342,17 +342,17 @@ const CLAUDE_HUB: HubProvider = { checkedAt, response: { rate_limits_available: true, rate_limits: rateLimits }, }).limits; - const resetCredits = claudeResetCreditsToContract(usage.cedar_ember, Date.parse(checkedAt)); - // Grants are spent in the token's organization; offer them only under that verified workspace. - const organization = resetCredits - ? yield* readClaudeOrganization(call).pipe(Effect.option) - : Option.none(); + // The token's organization is the account's identity and where its grants are spent, so an + // account without a verified one gets no workspace and no grants. + const organization = yield* readClaudeOrganization(call).pipe(Effect.option); + const resetCredits = Option.isSome(organization) + ? claudeResetCreditsToContract(usage.cedar_ember, Date.parse(checkedAt)) + : undefined; return { ...listedIdentity(account), ...(Option.isSome(organization) ? { workspaceId: organization.value } : {}), plan: "Claude Subscription", - usageLimits: - resetCredits && Option.isSome(organization) ? { ...limits, resetCredits } : limits, + usageLimits: resetCredits ? { ...limits, resetCredits } : limits, }; }), }; @@ -574,19 +574,28 @@ export const makeCliproxyApi = Effect.gen(function* () { hub: CliproxyHub, account: AuthFile, grantId: string, + expectedOrganization: string | undefined, ) { const header = CLAUDE_HUB.header(account, platform, architecture); - const organization = yield* readClaudeOrganization((url) => apiCall(hub, account, header, url)); return yield* coordinator.redeem( `${hub.url}#${account.id}`, (requestId) => Effect.gen(function* () { - const claim = yield* claudeResetClaim({ organization, grantId, requestId }); + // Read under the account's lock, right before the claim the same token sends. + const organization = yield* readClaudeOrganization((url) => + apiCall(hub, account, header, url), + ); + const claim = yield* claudeResetClaim({ + organization, + expectedOrganization, + grantId, + requestId, + }); const response = yield* upstreamCall(hub, account, header, claim.url, claim.body); return yield* claudeResetClaimOutcome(response.status_code, response.body); }), isSettledClaudeResetCreditFailure, - `${organization}\n${grantId}`, + `${expectedOrganization ?? ""}\n${grantId}`, ); }); @@ -594,6 +603,7 @@ export const makeCliproxyApi = Effect.gen(function* () { hub: CliproxyHub, accountId: string, creditId: string, + workspaceId?: string, ): Effect.fn.Return { const operation = Effect.gen(function* () { const account = (yield* authFiles(hub)).find((account) => account.id === accountId); @@ -608,7 +618,7 @@ export const makeCliproxyApi = Effect.gen(function* () { } const outcome = account.provider === "claude" - ? yield* claimClaudeGrant(hub, account, creditId) + ? yield* claimClaudeGrant(hub, account, creditId, workspaceId) : yield* redeemCodexCredit(hub, account, creditId); if (outcome !== "reset" && outcome !== "alreadyRedeemed") return { outcome }; const cleared = yield* management(hub, "reset-quota", { diff --git a/docs/user/usage.md b/docs/user/usage.md index 7d7b459afea26..44458b5a1956d 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -179,6 +179,9 @@ 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 and Claude accounts show banked reset credits; select an account to redeem one from its details, on any server platform. No hub plugin is required. +When the hub cannot read a Claude account's organization, the account gets its own row and offers +no resets. A Claude reset is refused if the hub account has moved to another organization since +Limits showed it; refresh Limits to see the current account. This connection supplies usage information; configure the provider separately to send agent requests through the hub. Remove the hub from the same diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 67acbe0d35b05..fd38254e6dad1 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -94,6 +94,8 @@ export const UsageLimitSourceConsumeResetCreditInput = Schema.Struct({ sourceId: UsageLimitSourceId, accountId: TrimmedNonEmptyString, creditId: TrimmedNonEmptyString, + /** The workspace the credit was shown under; a Claude claim refuses any other organization. */ + workspaceId: Schema.optional(TrimmedNonEmptyString), }); export type UsageLimitSourceConsumeResetCreditInput = typeof UsageLimitSourceConsumeResetCreditInput.Type; diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 49074739d2d35..0ae5acae21b36 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -649,44 +649,59 @@ describe("pools", () => { ]); }); - it("joins a report without a workspace to the only workspace signed in with its email", () => { + it("joins a Claude hub report to a login only in the organization both verified", () => { const native = provider({ driver: claude, auth: { status: "authenticated", email: "same@example.com", workspaceId: "org-a" }, - usageLimits: { checkedAt, windows: [window] }, + usageLimits: { + checkedAt, + windows: [window], + resetCredits: { availableCount: 1, nextCreditId: "grant-a" }, + }, }); - const hubAccount = { + // The hub read is newer; without a verified organization it may be another workspace's login. + const hubAccount = (workspaceId?: string) => ({ id: "claude.json", driver: claude, email: "same@example.com", - usageLimits: { checkedAt, windows: [window] }, - }; - const withOther = (other: ServerProvider) => - new Map([ - [ - EnvironmentId.make("env-a"), - { - ...laptop, - serverConfig: { - providers: [native, other], - usageLimitSources: [{ ...source, accounts: [hubAccount] }], + ...(workspaceId ? { workspaceId } : {}), + usageLimits: { + checkedAt: "2026-09-03T11:30:00.000Z", + windows: [{ ...window, usedPercent: 90 }], + ...(workspaceId + ? { resetCredits: { availableCount: 1, nextCreditId: "grant-hub" } } + : {}), + }, + }); + const rows = (account: ReturnType) => + collectLimitAccounts( + new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [native], + usageLimitSources: [{ ...source, accounts: [account] }], + }, }, - }, - ], + ], + ]), + ).map((account) => [ + account.key, + account.limits.windows[0]?.usedPercent, + account.redeem?.input ?? null, ]); - const unrelated = provider({ instanceId: ProviderInstanceId.make("codex-2") }); - expect(collectLimitAccounts(withOther(unrelated)).map((account) => account.key)).toEqual([ - "env-a:codex", + expect(rows(hubAccount())).toEqual([ + ["env-a:codex", 40, { instanceId: native.instanceId }], + ["hub:claude.json", 90, null], ]); - const unreadable = provider({ - ...native, - instanceId: ProviderInstanceId.make("team"), - auth: { ...native.auth, workspaceId: "org-b" }, - usageLimits: { checkedAt, windows: [], unavailable: { reason: "probeFailed" } }, - }); - expect(collectLimitAccounts(withOther(unreadable)).map((account) => account.key)).toEqual([ - "env-a:codex", - "hub:claude.json", + expect(rows(hubAccount("org-a"))).toEqual([ + [ + "env-a:codex", + 90, + { sourceId: source.id, accountId: "claude.json", creditId: "grant-hub", workspaceId: "org-a" }, + ], ]); }); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 26daf88e30d5b..5a3e0e4d83dd0 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -242,7 +242,8 @@ function accountKeyResolver(reports: Iterable) { if (report.workspaceId) return `${key}:${report.workspaceId}`; const known = workspaces.get(key); if (!known) return key; - return known.size === 1 ? `${key}:${[...known][0]}` : null; + // Claude spends grants in an organization, so an unverified one never borrows a known one. + return known.size === 1 && report.driver !== "claudeAgent" ? `${key}:${[...known][0]}` : null; }; } @@ -391,6 +392,7 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl sourceId: source.id, accountId: account.id, creditId: account.usageLimits.resetCredits.nextCreditId, + ...(account.workspaceId ? { workspaceId: account.workspaceId } : {}), }, } : null, @@ -929,6 +931,9 @@ export function collectProviderUsageLimits( sourceId: hubCredits.source.id, accountId: hubCredits.account.id, creditId: hubCreditId, + ...(hubCredits.account.workspaceId + ? { workspaceId: hubCredits.account.workspaceId } + : {}), } : { instanceId: credits?.provider.instanceId ?? provider.instanceId }, ...(provider.displayName ? { displayName: provider.displayName } : {}), @@ -953,6 +958,7 @@ export function collectProviderUsageLimits( sourceId: source.id, accountId: account.id, creditId: account.usageLimits.resetCredits.nextCreditId, + ...(account.workspaceId ? { workspaceId: account.workspaceId } : {}), }, } : {}), From 89d3c9b78b0fdce81f892c2d1a4d4867568f8c7d Mon Sep 17 00:00:00 2001 From: Krarilotus Date: Thu, 12 Nov 2026 18:31:28 +0100 Subject: [PATCH 5/7] test(server): format the new claim tests and type their recorded url --- .../src/provider/ProviderInstanceRegistry.test.ts | 9 +++++++-- apps/server/src/provider/claudeResetCredits.ts | 3 ++- apps/server/src/usage/cliproxyApi.test.ts | 6 ++---- packages/shared/src/usageLimits.test.ts | 11 +++++++---- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index ea17c1d2d124c..56f59a6119bc2 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -828,11 +828,16 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { } if (request.url.endsWith("/reset_rate_limits")) { const body = - request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""; + request.body._tag === "Uint8Array" + ? new TextDecoder().decode(request.body.body) + : ""; claims.push({ url: request.url, requestId: decodeClaimRequest(body).request_id }); if (claims.length === 1) yield* Deferred.succeed(posted, undefined); const status = claims.length === 1 ? yield* Deferred.await(answer) : 200; - return HttpClientResponse.fromWeb(request, Response.json({ result: "reset" }, { status })); + return HttpClientResponse.fromWeb( + request, + Response.json({ result: "reset" }, { status }), + ); } return HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" })); }), diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index c33d5fac72f7c..961d2027e5d25 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -66,7 +66,8 @@ const RESET_CREDIT_FAILURES = { loginUnreadable: "Claude could not read its login.", accountUnreadable: "Claude could not read its account.", signedOut: "Sign in to Claude again to redeem resets.", - accountChanged: "The Claude login changed since this reset was shown. Refresh Limits and try again.", + accountChanged: + "The Claude login changed since this reset was shown. Refresh Limits and try again.", rateLimited: "Claude is rate limiting resets. Try again soon.", coolingDown: "Claude resets are cooling down. Try again later.", unconfirmed: diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 2929cd28d967e..6b533068bca0e 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -67,9 +67,7 @@ function fixture( project_id?: string; id_token?: { chatgpt_account_id: string; plan_type?: string; chatgpt_plan_type?: string }; }>; - upstream?: ( - request: RequestBody, - ) => Upstream | Effect.Effect; + upstream?: (request: RequestBody) => Upstream | Effect.Effect; cooldownStatus?: number; } = {}, ) { @@ -547,7 +545,7 @@ describe("CLIProxyAPI built-in management API", () => { let organization = "org-a"; const posted = yield* Deferred.make(); const answer = yield* Deferred.make(); - const claims: Array<{ signedIn: string; url?: string; requestId: string }> = []; + const claims: Array<{ signedIn: string; url: string | undefined; requestId: string }> = []; const test = fixture({ accounts: [{ ...accounts[0]!, provider: "claude" }], upstream: (request) => { diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 0ae5acae21b36..64a25d905c705 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -668,9 +668,7 @@ describe("pools", () => { usageLimits: { checkedAt: "2026-09-03T11:30:00.000Z", windows: [{ ...window, usedPercent: 90 }], - ...(workspaceId - ? { resetCredits: { availableCount: 1, nextCreditId: "grant-hub" } } - : {}), + ...(workspaceId ? { resetCredits: { availableCount: 1, nextCreditId: "grant-hub" } } : {}), }, }); const rows = (account: ReturnType) => @@ -700,7 +698,12 @@ describe("pools", () => { [ "env-a:codex", 90, - { sourceId: source.id, accountId: "claude.json", creditId: "grant-hub", workspaceId: "org-a" }, + { + sourceId: source.id, + accountId: "claude.json", + creditId: "grant-hub", + workspaceId: "org-a", + }, ], ]); }); From 4b9d4046121015897de6c846e9ccd153336f0033 Mon Sep 17 00:00:00 2001 From: Krarilotus Date: Thu, 12 Nov 2026 19:04:49 +0100 Subject: [PATCH 6/7] fix(server): a hub Claude reading names the organization Claude answered for The hub may swap the login behind an auth index between calls, so a profile read after the usage read could name another organization than the one whose grants were read. The hub now takes the organization from the Anthropic-Organization-Id header of the usage answer itself; without a readable one the windows stay and no reset is offered. A claim answer naming another organization than the claim's is unconfirmed, so its request id stays for a retry and the hub cooldown is left alone. Reset choices for a Claude hub account name the organization in their summary and confirmation. --- .../server/src/provider/claudeResetCredits.ts | 30 +++++++- apps/server/src/usage/cliproxyApi.test.ts | 69 ++++++++++++++++--- apps/server/src/usage/cliproxyApi.ts | 54 ++++++++++----- packages/shared/src/usageLimits.test.ts | 14 +++- packages/shared/src/usageLimits.ts | 29 +++++--- 5 files changed, 156 insertions(+), 40 deletions(-) diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 961d2027e5d25..1fa86d35b63ff 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -284,10 +284,35 @@ export const claudeResetClaim = Effect.fn("claudeResetClaim")(function* (input: }; }); -/** Claude's answer to a claim, given its HTTP status and body text. */ +const ORGANIZATION_HEADER = "anthropic-organization-id"; +const ORGANIZATION_ID = /^[A-Za-z0-9-]{1,128}$/; + +/** + * The organization a Claude response names in its headers, matched by any case and as a string + * or an array of values: undefined when it names none, null when it names one unreadably. + */ +export function claudeOrganizationHeader(header: unknown): string | null | undefined { + if (typeof header !== "object" || header === null) return undefined; + const values = Object.entries(header) + .filter(([name]) => name.toLowerCase() === ORGANIZATION_HEADER) + .flatMap(([, value]) => (Array.isArray(value) ? value : [value])); + if (values.length === 0) return undefined; + const [first] = values; + return typeof first === "string" && + ORGANIZATION_ID.test(first) && + values.every((value) => value === first) + ? first + : null; +} + +/** + * Claude's answer to a claim, given its HTTP status and body text. An answer naming another + * organization than the claim's confirms nothing, so its request ID stays for a retry. + */ export const claudeResetClaimOutcome = Effect.fn("claudeResetClaimOutcome")(function* ( status: number, body: string, + receipt?: { readonly expected: string | undefined; readonly answeredFor: string | null | undefined }, ) { if (status === 429) return yield* new ClaudeResetCreditError({ reason: "rateLimited" }); if (status === 401 || status === 403) { @@ -296,6 +321,9 @@ export const claudeResetClaimOutcome = Effect.fn("claudeResetClaimOutcome")(func if (status < 200 || status >= 300) { return yield* new ClaudeResetCreditError({ reason: "requestFailed" }); } + if (receipt && receipt.answeredFor !== undefined && receipt.answeredFor !== receipt.expected) { + return yield* new ClaudeResetCreditError({ reason: "unconfirmed" }); + } const answer = yield* decodeClaimResponse(body).pipe( Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })), ); diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 6b533068bca0e..0c20ef2ff3810 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -54,7 +54,7 @@ const decodeClaim = Schema.decodeUnknownSync( ), ); -type Upstream = { status: number; body: unknown }; +type Upstream = { status: number; body: unknown; header?: unknown }; function fixture( options: { @@ -120,7 +120,11 @@ function fixture( }; return HttpClientResponse.fromWeb( request, - Response.json({ status_code: upstream.status, body: encodeJson(upstream.body) }), + Response.json({ + status_code: upstream.status, + body: encodeJson(upstream.body), + ...("header" in upstream ? { header: upstream.header } : {}), + }), ); }), ); @@ -384,7 +388,7 @@ describe("CLIProxyAPI built-in management API", () => { ); it.effect( - "names every Claude account's verified organization, offering grants only under it, keeping windows", + "names each Claude account by the organization its usage answer names, offering grants only there", () => Effect.gen(function* () { // Shaped like a hub answer of 12 Nov 2026: a weekly and a Fable grant, plus one not usable yet. @@ -413,20 +417,27 @@ describe("CLIProxyAPI built-in management API", () => { { id: "blocked-grant", resets_left: 1, usable_now: false, blocking: ["seven_day"] }, ], }; + // As the hub relays Go's http.Header: canonical names and arrays of values. + const organizationHeaders: Record = { + a: { "Anthropic-Organization-Id": ["org-a"], "Content-Type": ["application/json"] }, + c: { "anthropic-organization-id": "org-c" }, + d: { "Anthropic-Organization-Id": ["org-d"] }, + e: { "Anthropic-Organization-Id": ["org-a", "org-e"] }, + }; const test = fixture({ - accounts: ["a", "b", "c", "d"].map((auth_index) => ({ + accounts: ["a", "b", "c", "d", "e"].map((auth_index) => ({ id: `${auth_index}.json`, auth_index, provider: "claude", email: `${auth_index}@example.com`, })), upstream: (request) => + // The hub swapped every login to org-b after the usage answers. request.url?.endsWith("/api/oauth/profile") - ? request.auth_index === "a" || request.auth_index === "d" - ? { status: 200, body: { organization: { uuid: `org-${request.auth_index}` } } } - : { status: 500, body: {} } + ? { status: 200, body: { organization: { uuid: "org-b" } } } : { status: 200, + header: organizationHeaders[request.auth_index], body: { seven_day: { utilization: 90, resets_at: null }, ...(request.auth_index === "d" @@ -438,12 +449,12 @@ describe("CLIProxyAPI built-in management API", () => { }, }, }); - const [first, unverified, malformed, withoutGrants] = yield* (yield* test.api).readAccounts( - config, - ); + const [first, unnamed, malformed, withoutGrants, ambiguous] = yield* (yield* test.api) + .readAccounts(config); // Its organization is what joins an account to the same login elsewhere, grants or not. expect(withoutGrants?.workspaceId).toBe("org-d"); expect(withoutGrants?.usageLimits.resetCredits).toBeUndefined(); + expect(malformed?.workspaceId).toBe("org-c"); expect(first?.workspaceId).toBe("org-a"); expect(first?.usageLimits.resetCredits).toMatchObject({ availableCount: 2, @@ -459,11 +470,14 @@ describe("CLIProxyAPI built-in management API", () => { ["full-reset-2611", "Weekly reset", 1], ["launch-2610-fable", "Fable reset", 1], ]); - for (const account of [unverified, malformed]) { + for (const account of [unnamed, ambiguous]) { expect(account?.workspaceId).toBeUndefined(); + } + for (const account of [unnamed, malformed, ambiguous]) { expect(account?.usageLimits.resetCredits).toBeUndefined(); expect(account?.usageLimits.windows.map((window) => window.usedPercent)).toEqual([90]); } + expect(test.requests.some((request) => request.body?.url?.includes("/profile"))).toBe(false); expect(test.requests[1]?.body?.url).toBe( "https://api.anthropic.com/api/oauth/usage?cedar_ember=1", ); @@ -583,6 +597,39 @@ describe("CLIProxyAPI built-in management API", () => { }), ); + it.effect("never takes a Claude answer for another organization as the shown grant's reset", () => + Effect.gen(function* () { + const answeredFor = ["org-b", "org-a"]; + const test = fixture({ + accounts: [{ ...accounts[0]!, provider: "claude" }], + upstream: (request) => + request.url?.endsWith("/api/oauth/profile") + ? { status: 200, body: { organization: { uuid: "org-a" } } } + : { + status: 200, + header: { "Anthropic-Organization-Id": [answeredFor.shift()] }, + body: { result: "reset" }, + }, + }); + const api = yield* test.api; + const claim = api + .consume(config, "first.json", "full-reset-2611", "org-a") + .pipe(Effect.result); + const answers = [yield* claim, yield* claim]; + + const sent = test.requests + .filter((request) => request.body?.url?.includes("/reset_rate")) + .map((request) => [request.body?.url, decodeClaim(request.body?.data).request_id]); + const toOrgA = [ + "https://api.anthropic.com/api/organizations/org-a/reset_rate_limits", + sent[0]?.[1], + ]; + expect(sent).toEqual([toOrgA, toOrgA]); + expect(answers.map((answer) => answer._tag)).toEqual(["Failure", "Success"]); + expect(test.requests.filter((request) => request.path.endsWith("/reset-quota"))).toHaveLength(1); + }), + ); + it.effect.each([ { name: "spend budget", diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index a18316ebeda0f..1f4c6e2884449 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -25,6 +25,7 @@ import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts"; import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts"; import { claudeResetClaim, + claudeOrganizationHeader, claudeResetClaimOutcome, claudeResetCreditsToContract, isSettledClaudeResetCreditFailure, @@ -62,7 +63,12 @@ const AuthFile = Schema.Struct({ ), }); const AuthFiles = Schema.Struct({ files: Schema.Array(AuthFile) }); -const ApiResponse = Schema.Struct({ status_code: Schema.Number, body: Schema.String }); +// `header` is the upstream response's, as Go's http.Header: canonical names, arrays of values. +const ApiResponse = Schema.Struct({ + status_code: Schema.Number, + body: Schema.String, + header: Schema.optional(Schema.Unknown), +}); const CodexWindow = Schema.Struct({ used_percent: Schema.Number, reset_at: Schema.optional(Schema.NullOr(Schema.Number)), @@ -209,6 +215,7 @@ type HubCall = ( url: string, data?: unknown, ) => Effect.Effect; +type HubResponse = typeof ApiResponse.Type; type HubReading = Omit; interface HubProvider { readonly driver: ProviderDriverKind; @@ -220,6 +227,10 @@ interface HubProvider { readonly read: (input: { readonly account: AuthFile; readonly call: HubCall; + /** As `call` for a GET, keeping the upstream response's headers. */ + readonly respond: ( + url: string, + ) => Effect.Effect; readonly checkedAt: string; }) => Effect.Effect; } @@ -316,10 +327,9 @@ const CLAUDE_HUB: HubProvider = { // Usage can return 429 without a Claude Code user agent; older CLI versions get no reset grants. "User-Agent": "claude-cli/2.1.322 (external, cli)", }), - read: Effect.fnUntraced(function* ({ account, call, checkedAt }) { - const usage = yield* decodeClaudeUsage( - yield* call(`${CLAUDE_API}/api/oauth/usage?cedar_ember=1`), - ); + read: Effect.fnUntraced(function* ({ account, respond, checkedAt }) { + const response = yield* respond(`${CLAUDE_API}/api/oauth/usage?cedar_ember=1`); + const usage = yield* decodeClaudeUsage(response.body); const model_scoped = (usage.limits ?? []).flatMap((limit) => limit.kind === "weekly_scoped" && limit.scope?.model && typeof limit.percent === "number" ? [ @@ -342,15 +352,15 @@ const CLAUDE_HUB: HubProvider = { checkedAt, response: { rate_limits_available: true, rate_limits: rateLimits }, }).limits; - // The token's organization is the account's identity and where its grants are spent, so an - // account without a verified one gets no workspace and no grants. - const organization = yield* readClaudeOrganization(call).pipe(Effect.option); - const resetCredits = Option.isSome(organization) + // The hub may swap the login behind an auth index between calls, so only the organization + // Claude names in this answer identifies these grants; without one there are none to offer. + const organization = claudeOrganizationHeader(response.header); + const resetCredits = organization ? claudeResetCreditsToContract(usage.cedar_ember, Date.parse(checkedAt)) : undefined; return { ...listedIdentity(account), - ...(Option.isSome(organization) ? { workspaceId: organization.value } : {}), + ...(organization ? { workspaceId: organization } : {}), plan: "Claude Subscription", usageLimits: resetCredits ? { ...limits, resetCredits } : limits, }; @@ -482,7 +492,7 @@ export const makeCliproxyApi = Effect.gen(function* () { return yield* decodeApiResponse(raw); }); - const apiCall = Effect.fn("CliproxyApi.apiCall")(function* ( + const apiResponse = Effect.fn("CliproxyApi.apiResponse")(function* ( hub: CliproxyHub, account: AuthFile, header: Record, @@ -495,18 +505,27 @@ export const makeCliproxyApi = Effect.gen(function* () { detail: `The provider refused the hub request (HTTP ${response.status_code}).`, }); } - return response.body; + return response; }); + const apiCall = ( + hub: CliproxyHub, + account: AuthFile, + header: Record, + url: string, + data?: unknown, + ) => apiResponse(hub, account, header, url, data).pipe(Effect.map((response) => response.body)); + const readAccount = Effect.fn("CliproxyApi.readAccount")(function* ( hub: CliproxyHub, account: AuthFile, provider: HubProvider, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); - const call: HubCall = (url, data) => - apiCall(hub, account, provider.header(account, platform, architecture), url, data); - const reading = yield* provider.read({ account, call, checkedAt }).pipe( + const header = provider.header(account, platform, architecture); + const call: HubCall = (url, data) => apiCall(hub, account, header, url, data); + const respond = (url: string) => apiResponse(hub, account, header, url); + const reading = yield* provider.read({ account, call, respond, checkedAt }).pipe( Effect.provideService(Digest.Digest, digest), Effect.orElseSucceed(() => ({ ...listedIdentity(account), @@ -592,7 +611,10 @@ export const makeCliproxyApi = Effect.gen(function* () { requestId, }); const response = yield* upstreamCall(hub, account, header, claim.url, claim.body); - return yield* claudeResetClaimOutcome(response.status_code, response.body); + return yield* claudeResetClaimOutcome(response.status_code, response.body, { + expected: expectedOrganization, + answeredFor: claudeOrganizationHeader(response.header), + }); }), isSettledClaudeResetCreditFailure, `${expectedOrganization ?? ""}\n${grantId}`, diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 64a25d905c705..b5b7017217cd9 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1529,11 +1529,12 @@ describe("/usage-limits", () => { }); describe("resetCreditChoices", () => { - it("offers each kind of hub reset under its own grant through the same hub account", () => { + it("offers each kind of hub reset under its own grant, naming the organization it is spent in", () => { const hub = { sourceId: UsageLimitSourceId.make("hub"), accountId: "claude.json", creditId: "full-reset-2611", + workspaceId: "org-a", }; const option = (creditId: string, label: string) => ({ creditId, @@ -1557,6 +1558,17 @@ describe("resetCreditChoices", () => { hub, { ...hub, creditId: "launch-2610-fable" }, ]); + // The same email can hold the same grant in another organization. + expect(choices.map(({ summary, confirmDescription }) => [summary, confirmDescription])).toEqual([ + [ + "Weekly reset · Organization org-a · 1 left", + "Weekly reset in organization org-a: Weekly reset. This cannot be undone.", + ], + [ + "Fable reset · Organization org-a · 1 left", + "Fable reset in organization org-a: Fable reset. This cannot be undone.", + ], + ]); }); }); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 5a3e0e4d83dd0..86e8d64b9638b 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -249,6 +249,12 @@ function accountKeyResolver(reports: Iterable) { const hubAccountName = (account: { readonly id: string }) => account.id.replace(/\.json$/i, ""); +// A Claude grant is claimed only in the organization it was shown under. +const claudeHubOrganization = (account: AccountReport) => + account.driver === "claudeAgent" && account.workspaceId + ? { workspaceId: account.workspaceId } + : {}; + export interface LimitAccount { readonly key: string; readonly driver: ServerProvider["driver"]; @@ -392,7 +398,7 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl sourceId: source.id, accountId: account.id, creditId: account.usageLimits.resetCredits.nextCreditId, - ...(account.workspaceId ? { workspaceId: account.workspaceId } : {}), + ...claudeHubOrganization(account), }, } : null, @@ -727,14 +733,18 @@ export function resetCreditChoices( compact = false, ): ReadonlyArray { const expiresIn = (at: string | undefined) => (at ? formatDuration(Date.parse(at) - now) : null); + // One email can hold the same grant in several organizations. + const organization = "workspaceId" in input ? input.workspaceId : undefined; + const scope = organization ? ` · Organization ${organization}` : ""; + const inOrganization = organization ? ` in organization ${organization}` : ""; if (credits.options) { return credits.options.map((option) => { const expires = expiresIn(option.expiresAt); return { key: option.creditId, - summary: `${option.label} · ${option.availableCount} left${expires ? ` · expires in ${expires}` : ""}`, + summary: `${option.label}${scope} · ${option.availableCount} left${expires ? ` · expires in ${expires}` : ""}`, actionLabel: "Use", - confirmDescription: `${option.label}: ${option.description} This cannot be undone.`, + confirmDescription: `${option.label}${inOrganization}: ${option.description} This cannot be undone.`, availableCount: option.availableCount, input: { ...input, creditId: option.creditId }, }; @@ -749,11 +759,10 @@ export function resetCreditChoices( count === 0 ? "No reset credits banked" : compact - ? `${count} banked${expires ? ` · expires in ${expires}` : ""}` - : `${count} ${count === 1 ? "reset credit" : "reset credits"} banked${expires ? ` · next expires in ${expires}` : ""}`, + ? `${count} banked${scope}${expires ? ` · expires in ${expires}` : ""}` + : `${count} ${count === 1 ? "reset credit" : "reset credits"} banked${scope}${expires ? ` · next expires in ${expires}` : ""}`, actionLabel: "Use reset", - confirmDescription: - "This redeems one credit on your account and clears the current rate-limit windows. It cannot be undone.", + confirmDescription: `This redeems one credit on your account${inOrganization} and clears the current rate-limit windows. It cannot be undone.`, availableCount: count, input, }, @@ -931,9 +940,7 @@ export function collectProviderUsageLimits( sourceId: hubCredits.source.id, accountId: hubCredits.account.id, creditId: hubCreditId, - ...(hubCredits.account.workspaceId - ? { workspaceId: hubCredits.account.workspaceId } - : {}), + ...claudeHubOrganization(hubCredits.account), } : { instanceId: credits?.provider.instanceId ?? provider.instanceId }, ...(provider.displayName ? { displayName: provider.displayName } : {}), @@ -958,7 +965,7 @@ export function collectProviderUsageLimits( sourceId: source.id, accountId: account.id, creditId: account.usageLimits.resetCredits.nextCreditId, - ...(account.workspaceId ? { workspaceId: account.workspaceId } : {}), + ...claudeHubOrganization(account), }, } : {}), From b8fb56a4f4b4642b82279e9f9452be2d7132a4ab Mon Sep 17 00:00:00 2001 From: Krarilotus Date: Thu, 12 Nov 2026 19:19:29 +0100 Subject: [PATCH 7/7] style(server): format the organization checks and say what the claim lock can promise --- apps/server/src/provider/claudeResetCredits.ts | 5 ++++- apps/server/src/usage/cliproxyApi.test.ts | 12 ++++++++---- apps/server/src/usage/cliproxyApi.ts | 3 ++- packages/shared/src/usageLimits.test.ts | 18 ++++++++++-------- 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 1fa86d35b63ff..b2f0aa51e2384 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -312,7 +312,10 @@ export function claudeOrganizationHeader(header: unknown): string | null | undef export const claudeResetClaimOutcome = Effect.fn("claudeResetClaimOutcome")(function* ( status: number, body: string, - receipt?: { readonly expected: string | undefined; readonly answeredFor: string | null | undefined }, + receipt?: { + readonly expected: string | undefined; + readonly answeredFor: string | null | undefined; + }, ) { if (status === 429) return yield* new ClaudeResetCreditError({ reason: "rateLimited" }); if (status === 401 || status === 403) { diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 0c20ef2ff3810..209074b8d38d9 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -449,8 +449,8 @@ describe("CLIProxyAPI built-in management API", () => { }, }, }); - const [first, unnamed, malformed, withoutGrants, ambiguous] = yield* (yield* test.api) - .readAccounts(config); + const [first, unnamed, malformed, withoutGrants, ambiguous] = + yield* (yield* test.api).readAccounts(config); // Its organization is what joins an account to the same login elsewhere, grants or not. expect(withoutGrants?.workspaceId).toBe("org-d"); expect(withoutGrants?.usageLimits.resetCredits).toBeUndefined(); @@ -477,7 +477,9 @@ describe("CLIProxyAPI built-in management API", () => { expect(account?.usageLimits.resetCredits).toBeUndefined(); expect(account?.usageLimits.windows.map((window) => window.usedPercent)).toEqual([90]); } - expect(test.requests.some((request) => request.body?.url?.includes("/profile"))).toBe(false); + expect(test.requests.some((request) => request.body?.url?.includes("/profile"))).toBe( + false, + ); expect(test.requests[1]?.body?.url).toBe( "https://api.anthropic.com/api/oauth/usage?cedar_ember=1", ); @@ -626,7 +628,9 @@ describe("CLIProxyAPI built-in management API", () => { ]; expect(sent).toEqual([toOrgA, toOrgA]); expect(answers.map((answer) => answer._tag)).toEqual(["Failure", "Success"]); - expect(test.requests.filter((request) => request.path.endsWith("/reset-quota"))).toHaveLength(1); + 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 1f4c6e2884449..846494ddcd281 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -600,7 +600,8 @@ export const makeCliproxyApi = Effect.gen(function* () { `${hub.url}#${account.id}`, (requestId) => Effect.gen(function* () { - // Read under the account's lock, right before the claim the same token sends. + // Read under the account's lock, right before the claim. The hub cannot pin the login + // between calls, so the claim's answer is checked against the shown organization too. const organization = yield* readClaudeOrganization((url) => apiCall(hub, account, header, url), ); diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index b5b7017217cd9..0ec8733f9d6f1 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1559,16 +1559,18 @@ describe("resetCreditChoices", () => { { ...hub, creditId: "launch-2610-fable" }, ]); // The same email can hold the same grant in another organization. - expect(choices.map(({ summary, confirmDescription }) => [summary, confirmDescription])).toEqual([ + expect(choices.map(({ summary, confirmDescription }) => [summary, confirmDescription])).toEqual( [ - "Weekly reset · Organization org-a · 1 left", - "Weekly reset in organization org-a: Weekly reset. This cannot be undone.", - ], - [ - "Fable reset · Organization org-a · 1 left", - "Fable reset in organization org-a: Fable reset. This cannot be undone.", + [ + "Weekly reset · Organization org-a · 1 left", + "Weekly reset in organization org-a: Weekly reset. This cannot be undone.", + ], + [ + "Fable reset · Organization org-a · 1 left", + "Fable reset in organization org-a: Fable reset. This cannot be undone.", + ], ], - ]); + ); }); });