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