diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index af20b76754943..d5ea996b29366 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -320,6 +320,8 @@ export const ClaudeDriver: ProviderDriver @@ -327,10 +329,12 @@ 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 d2000804cf4f6..b2f0aa51e2384 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -59,12 +59,15 @@ const ClaimResponse = Schema.Struct({ "unavailable", ]), }); +const decodeClaimResponse = Schema.decodeUnknownEffect(Schema.fromJsonString(ClaimResponse)); const RESET_CREDIT_FAILURES = { malformedCredit: "Claude returned a malformed reset credit.", 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: @@ -256,65 +259,124 @@ 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: { + /** 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; +}) { + 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" }); + if (organization !== input.expectedOrganization) { + return yield* new ClaudeResetCreditError({ reason: "accountChanged" }); + } + 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 }, + }; +}); + +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) { + return yield* new ClaudeResetCreditError({ reason: "signedOut" }); + } + 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 })), + ); + 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; readonly accountConfigPath: string; readonly version: string; + readonly expectedOrganization: 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 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, + expectedOrganization: input.expectedOrganization, + 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/provider/resetCreditCoordinator.test.ts b/apps/server/src/provider/resetCreditCoordinator.test.ts index b863569b06cf3..bdcbc07ef9b14 100644 --- a/apps/server/src/provider/resetCreditCoordinator.test.ts +++ b/apps/server/src/provider/resetCreditCoordinator.test.ts @@ -53,6 +53,56 @@ describe("ResetCreditCoordinator", () => { }).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/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 431af7b5971ff..209074b8d38d9 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"; @@ -46,6 +48,13 @@ 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 }), + ), +); + +type Upstream = { status: number; body: unknown; header?: unknown }; function fixture( options: { @@ -58,13 +67,13 @@ 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 = @@ -84,7 +93,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" } @@ -110,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 } : {}), + }), ); }), ); @@ -346,7 +360,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 +387,253 @@ describe("CLIProxyAPI built-in management API", () => { }), ); + it.effect( + "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. + 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"] }, + ], + }; + // 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", "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") + ? { 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" + ? {} + : { + cedar_ember: + request.auth_index === "c" ? { eligible: true, grants: "?" } : grants, + }), + }, + }, + }); + 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, + 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], + ]); + 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", + ); + }), + ); + + it.effect( + "claims the chosen Claude grant for that account's organization, one request id per claim", + () => + Effect.gen(function* () { + // 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" }, + { ...accounts[1]!, provider: "claude" }, + ], + upstream: (request) => + request.url?.endsWith("/api/oauth/profile") + ? { status: 200, body: { organization: { uuid: `org-${request.auth_index}` } } } + : { status: statuses.shift() ?? 200, body: { result: "reset" } }, + }); + const api = yield* test.api; + 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" }); + 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(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(4).fill(["cedar_ember", "launch-2610-fable"]), + ); + 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")) + .map((request) => request.body?.auth_index), + ).toEqual(["b", "b"]); + }), + ); + + 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, organization); + 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("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 | undefined; 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("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 030983f50e33d..846494ddcd281 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -23,6 +23,14 @@ import { import { codexPlanLabel } from "../provider/CodexProvider.ts"; import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts"; import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts"; +import { + claudeResetClaim, + claudeOrganizationHeader, + claudeResetClaimOutcome, + claudeResetCreditsToContract, + isSettledClaudeResetCreditFailure, +} from "../provider/claudeResetCredits.ts"; +import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts"; import { antigravityAccountFingerprint, antigravitySummaryWindows, @@ -55,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)), @@ -78,6 +91,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 +135,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)); @@ -198,6 +215,7 @@ type HubCall = ( url: string, data?: unknown, ) => Effect.Effect; +type HubResponse = typeof ApiResponse.Type; type HubReading = Omit; interface HubProvider { readonly driver: ProviderDriverKind; @@ -209,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; } @@ -290,6 +312,61 @@ 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: () => ({ + 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, 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" + ? [ + { + 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; + // 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), + ...(organization ? { workspaceId: organization } : {}), + 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 +374,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 +437,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 +474,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,24 +489,43 @@ export const makeCliproxyApi = Effect.gen(function* () { header, ...(data === undefined ? {} : { data: yield* encodeJson(data) }), }); - const response = yield* decodeApiResponse(raw); + return yield* decodeApiResponse(raw); + }); + + const apiResponse = Effect.fn("CliproxyApi.apiResponse")(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}).`, }); } - 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), @@ -503,40 +559,90 @@ 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, + expectedOrganization: string | undefined, + ) { + const header = CLAUDE_HUB.header(account, platform, architecture); + return yield* coordinator.redeem( + `${hub.url}#${account.id}`, + (requestId) => + Effect.gen(function* () { + // 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), + ); + 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, { + expected: expectedOrganization, + answeredFor: claudeOrganizationHeader(response.header), + }); + }), + isSettledClaudeResetCreditFailure, + `${expectedOrganization ?? ""}\n${grantId}`, + ); + }); + const consume = Effect.fn("CliproxyApi.consume")(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); - 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, workspaceId) + : 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 +662,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..44458b5a1956d 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -177,8 +177,11 @@ 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 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 bed48428a93c4..0ec8733f9d6f1 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"; @@ -648,44 +649,62 @@ 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", + }, + ], ]); }); @@ -1509,6 +1528,52 @@ describe("/usage-limits", () => { }); }); +describe("resetCreditChoices", () => { + 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, + 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" }, + ]); + // 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.", + ], + ], + ); + }); +}); + describe("sameUsageLimitCommandCoverage", () => { const codexAccount = { id: "a", diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 716103ac2fc9b..86e8d64b9638b 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -242,12 +242,19 @@ 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; }; } 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"]; @@ -391,6 +398,7 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl sourceId: source.id, accountId: account.id, creditId: account.usageLimits.resetCredits.nextCreditId, + ...claudeHubOrganization(account), }, } : null, @@ -725,14 +733,18 @@ export function resetCreditChoices( compact = false, ): ReadonlyArray { const expiresIn = (at: string | undefined) => (at ? formatDuration(Date.parse(at) - now) : null); - if (credits.options && "instanceId" in input) { + // 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 }, }; @@ -747,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, }, @@ -929,6 +940,7 @@ export function collectProviderUsageLimits( sourceId: hubCredits.source.id, accountId: hubCredits.account.id, creditId: hubCreditId, + ...claudeHubOrganization(hubCredits.account), } : { instanceId: credits?.provider.instanceId ?? provider.instanceId }, ...(provider.displayName ? { displayName: provider.displayName } : {}), @@ -953,6 +965,7 @@ export function collectProviderUsageLimits( sourceId: source.id, accountId: account.id, creditId: account.usageLimits.resetCredits.nextCreditId, + ...claudeHubOrganization(account), }, } : {}),