diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 35973378edad..6ec3e1120058 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -12,8 +12,8 @@ import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; const API_BASE = "https://api.anthropic.com"; -const PROGRAM = "cedar_ember"; -const GRANT_ID = /^[a-z0-9_-]{1,40}$/; +export const PROGRAM = "cedar_ember"; +export const GRANT_ID = /^[a-z0-9_-]{1,40}$/; const REQUEST_ID = /^[A-Za-z0-9_-]{1,64}$/; const COMPLETE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; @@ -42,7 +42,7 @@ const UsageResponse = Schema.Struct({ cedar_ember: Schema.optional(Schema.NullOr(Schema.Unknown)), }); const decodeCedarEmber = Schema.decodeUnknownOption(CedarEmber); -const ClaimResponse = Schema.Struct({ +export const ClaimResponse = Schema.Struct({ result: Schema.Literals([ "reset", "already_used", @@ -187,7 +187,7 @@ export const claudeAccountConfigPath = Effect.fn("claudeAccountConfigPath")(func : path.join(yield* HostProcess.HomeDirectory, ".claude.json"); }); -const CLAIM_OUTCOMES = { +export const CLAIM_OUTCOMES = { reset: "reset", not_limited: "nothingToReset", already_used: "alreadyRedeemed", diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 0119adfd940f..a9a1831e8a4f 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -312,25 +312,42 @@ describe("CLIProxyAPI built-in management API", () => { }), ); - it.effect("maps Claude scoped windows without a scheduler plugin", () => + const claudeUsage = { + five_hour: { utilization: 10, resets_at: null }, + seven_day: { utilization: 50, resets_at: "2099-01-01T00:00:00Z" }, + limits: [ + { + kind: "weekly_scoped", + percent: 80, + resets_at: null, + scope: { model: { display_name: "Fable" } }, + }, + ], + cedar_ember: { + eligible: true, + grants: [ + { + id: "free_reset", + resets_total: 1, + resets_left: 1, + ends_at: "2099-01-01T00:00:00Z", + paused: false, + usable_now: true, + }, + ], + next_grant_id: "free_reset", + }, + }; + + it.effect("maps Claude scoped windows and banked resets without a scheduler plugin", () => Effect.gen(function* () { const test = fixture({ accounts: [{ ...accounts[0]!, provider: "claude" }], - // The usage endpoint answers 429 to requests without a Claude Code user agent. + // The usage endpoint answers 429, and reports no reset grants, to requests + // without a Claude CLI user agent. upstream: (request) => ({ - status: request.header?.["User-Agent"]?.startsWith("claude-code/") ? 200 : 429, - body: { - five_hour: { utilization: 10, resets_at: null }, - seven_day: { utilization: 50, resets_at: "2099-01-01T00:00:00Z" }, - limits: [ - { - kind: "weekly_scoped", - percent: 80, - resets_at: null, - scope: { model: { display_name: "Fable" } }, - }, - ], - }, + status: request.header?.["User-Agent"]?.startsWith("claude-cli/") ? 200 : 429, + body: claudeUsage, }), }); const api = yield* test.api; @@ -342,6 +359,52 @@ describe("CLIProxyAPI built-in management API", () => { ["seven_day", 50], ["seven_day_fable", 80], ]); + expect(result[0]?.usageLimits.resetCredits).toEqual({ + availableCount: 1, + nextCreditId: "free_reset", + nextExpiresAt: "2099-01-01T00:00:00.000Z", + }); + expect(test.requests.find((request) => request.body?.url)?.body?.url).toContain( + "cedar_ember=1", + ); + }), + ); + + it.effect("claims a Claude grant through the hub and clears only that account's cooldown", () => + Effect.gen(function* () { + let result = "reset"; + const test = fixture({ + accounts: [{ ...accounts[0]!, provider: "claude" }], + upstream: (request) => ({ + status: 200, + body: request.url?.endsWith("/api/oauth/profile") + ? { organization: { uuid: "org-1" } } + : request.url?.endsWith("/reset_rate_limits") + ? { result } + : claudeUsage, + }), + }); + const api = yield* test.api; + expect(yield* api.consume(config, "first.json", "free_reset")).toEqual({ outcome: "reset" }); + result = "not_limited"; + expect(yield* api.consume(config, "first.json", "free_reset")).toEqual({ + outcome: "nothingToReset", + }); + const claims = test.requests.filter((request) => + request.body?.url?.endsWith("/api/organizations/org-1/reset_rate_limits"), + ); + expect(claims).toHaveLength(2); + expect(claims[0]?.body?.data).toBe(claims[1]?.body?.data); + expect(JSON.parse(claims[0]?.body?.data ?? "{}")).toMatchObject({ + program: "cedar_ember", + grant_id: "free_reset", + request_id: expect.stringMatching(/^[0-9a-f-]{36}$/), + }); + expect( + test.requests + .filter((request) => request.path.endsWith("/reset-quota")) + .map((request) => request.body?.auth_index), + ).toEqual(["a"]); }), ); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 91504d800229..d19ab5cfb6a3 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -16,6 +16,13 @@ import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } fr import { codexPlanLabel } from "../provider/CodexProvider.ts"; import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts"; import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts"; +import { + CLAIM_OUTCOMES as CLAUDE_CLAIM_OUTCOMES, + ClaimResponse as ClaudeClaimResponse, + GRANT_ID as CLAUDE_GRANT_ID, + PROGRAM as CLAUDE_RESET_PROGRAM, + claudeResetCreditsToContract, +} from "../provider/claudeResetCredits.ts"; import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; const AuthFile = Schema.Struct({ @@ -71,6 +78,10 @@ const ClaudeUsage = Schema.Struct({ }), ), ), + cedar_ember: Schema.optional(Schema.NullOr(Schema.Unknown)), +}); +const ClaudeProfile = Schema.Struct({ + organization: Schema.optional(Schema.NullOr(Schema.Struct({ uuid: Schema.String }))), }); const CreditList = Schema.Struct({ credits: Schema.Array( @@ -88,6 +99,8 @@ const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const decodeApiResponse = Schema.decodeUnknownEffect(ApiResponse); const decodeCreditList = Schema.decodeUnknownEffect(Schema.fromJsonString(CreditList)); const decodeClaudeUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(ClaudeUsage)); +const decodeClaudeProfile = Schema.decodeUnknownEffect(Schema.fromJsonString(ClaudeProfile)); +const decodeClaudeClaim = Schema.decodeUnknownEffect(Schema.fromJsonString(ClaudeClaimResponse)); const decodeCodexUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(CodexUsage)); const isUsageLimitSourceError = Schema.is(UsageLimitSourceError); @@ -133,6 +146,7 @@ const decodeConsumeResponse = Schema.decodeUnknownEffect( ), ); +const CLAUDE_BASE = "https://api.anthropic.com"; const CODEX_BASE = "https://chatgpt.com/backend-api/wham"; const CREDIT_URL = `${CODEX_BASE}/rate-limit-reset-credits`; @@ -214,9 +228,11 @@ export const makeCliproxyApi = Effect.gen(function* () { } : { Authorization: "Bearer $TOKEN$", + "Content-Type": "application/json", "anthropic-beta": "oauth-2025-04-20", - // The OAuth usage endpoint can return 429 without a Claude Code user agent. - "User-Agent": "claude-code/2.1.273", + // The OAuth usage endpoint answers 429 without a Claude CLI user agent and + // reports banked reset grants only to CLI versions from 2.1.280 on. + "User-Agent": "claude-cli/2.1.312 (external, cli)", }; const raw = yield* management(config, "api-call", { auth_index: account.auth_index, @@ -266,8 +282,16 @@ export const makeCliproxyApi = Effect.gen(function* () { }; const read = Effect.gen(function* () { if (account.provider === "claude") { - const body = yield* apiCall(config, account, "https://api.anthropic.com/api/oauth/usage"); + const body = yield* apiCall( + config, + account, + `${CLAUDE_BASE}/api/oauth/usage?cedar_ember=1&skip_spend=1`, + ); const usage = yield* decodeClaudeUsage(body); + const resetCredits = claudeResetCreditsToContract( + usage.cedar_ember, + DateTime.toEpochMillis(yield* DateTime.now), + ); const model_scoped = (usage.limits ?? []).flatMap((limit) => limit.kind === "weekly_scoped" && limit.scope?.model && typeof limit.percent === "number" ? [ @@ -282,17 +306,20 @@ export const makeCliproxyApi = Effect.gen(function* () { return { ...base, plan: "Claude Subscription", - usageLimits: claudeUsageResponseToLimits({ - checkedAt, - response: { - rate_limits_available: true, - rate_limits: { - five_hour: usage.five_hour ?? null, - seven_day: usage.seven_day ?? null, - model_scoped, + usageLimits: { + ...claudeUsageResponseToLimits({ + checkedAt, + response: { + rate_limits_available: true, + rate_limits: { + five_hour: usage.five_hour ?? null, + seven_day: usage.seven_day ?? null, + model_scoped, + }, }, - }, - }).limits, + }).limits, + ...(resetCredits ? { resetCredits } : {}), + }, }; } const body = yield* apiCall(config, account, `${CODEX_BASE}/usage`); @@ -371,6 +398,75 @@ export const makeCliproxyApi = Effect.gen(function* () { ); }); + const redeemCodexCredit = Effect.fn("CliproxyApi.redeemCodexCredit")(function* ( + config: UsageLimitSourceConfig, + account: typeof AuthFile.Type, + creditId: string, + ) { + const body = yield* apiCall(config, account, `${CREDIT_URL}/consume`, { + redeem_request_id: yield* creditRedeemRequestId( + account.id_token?.chatgpt_account_id ?? account.id, + creditId, + ).pipe(Effect.provideService(Crypto.Crypto, crypto)), + 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]; + }); + + // Same claim the Claude CLI sends; request_id is the idempotency key, so a retry + // addresses the claim already made. + const claimClaudeGrant = Effect.fn("CliproxyApi.claimClaudeGrant")(function* ( + config: UsageLimitSourceConfig, + account: typeof AuthFile.Type, + grantId: string, + ) { + if (!CLAUDE_GRANT_ID.test(grantId)) { + return yield* new UsageLimitSourceError({ detail: "The Claude reset grant is not valid." }); + } + const profile = yield* decodeClaudeProfile( + yield* apiCall(config, account, `${CLAUDE_BASE}/api/oauth/profile`), + ); + const organization = profile.organization?.uuid.trim(); + if (!organization) { + return yield* new UsageLimitSourceError({ + detail: "The hub could not read the Claude account's organization.", + }); + } + const body = yield* apiCall( + config, + account, + `${CLAUDE_BASE}/api/organizations/${encodeURIComponent(organization)}/reset_rate_limits`, + { + program: CLAUDE_RESET_PROGRAM, + grant_id: grantId, + request_id: yield* creditRedeemRequestId(account.id, grantId).pipe( + Effect.provideService(Crypto.Crypto, crypto), + ), + }, + ); + const claim = yield* decodeClaudeClaim(body); + if (claim.result === "cooldown") { + return yield* new UsageLimitSourceError({ + detail: "Claude resets are cooling down. Try again later.", + }); + } + if (claim.result === "unavailable") { + return yield* new UsageLimitSourceError({ + detail: + "Claude could not confirm the reset. If you are still limited in a moment, try again.", + }); + } + return CLAUDE_CLAIM_OUTCOMES[claim.result]; + }); + const consume = Effect.fn("CliproxyApi.consume")(function* ( config: UsageLimitSourceConfig, accountId: string, @@ -378,27 +474,19 @@ export const makeCliproxyApi = Effect.gen(function* () { ): Effect.fn.Return { const operation = Effect.gen(function* () { const account = (yield* authFiles(config)).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(config, account, `${CREDIT_URL}/consume`, { - redeem_request_id: yield* creditRedeemRequestId( - account.id_token?.chatgpt_account_id ?? account.id, - creditId, - ).pipe(Effect.provideService(Crypto.Crypto, crypto)), - 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(config, account, creditId) + : yield* redeemCodexCredit(config, account, creditId); if (outcome !== "reset" && outcome !== "alreadyRedeemed") return { outcome }; const cleared = yield* management(config, "reset-quota", { auth_index: account.auth_index, diff --git a/docs/user/usage.md b/docs/user/usage.md index e41e60ca73d7..b82ee4845a00 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -95,7 +95,7 @@ part of a segment is what that reset restores. Tap a segment or account row for where it is signed in, and its reset time. On web, you can hover too. Codex and Claude accounts with banked reset credits show a ticket count and the **Use reset** action in the account details. Claude resets are not available when the server runs on macOS, where Claude keeps its login in the -Keychain. On narrow screens, numbered rows below +Keychain; a hub reports them on any platform. On narrow screens, numbered rows below the bar show each account's quota, countdown, and credits. Tap a row to open its details. The same account signed in on more than one environment, or reported by a hub as well, counts once. @@ -139,8 +139,8 @@ using a proxy through `ANTHROPIC_AUTH_TOKEN`. To see pooled accounts, open **Settings → Providers → Usage providers → Add hub**. Choose the environment that will connect to the hub and enter its URL and management key. -The accounts appear under **Usage → Limits**. Codex accounts show banked reset credits; select an -account and choose **Use reset** to redeem one. No hub plugin is required. +The accounts appear under **Usage → Limits**. 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