| 1 | /** |
| 2 | * Claude banked resets (the CLI's `cedar_ember` program). The CLI reads the |
| 3 | * grants from the OAuth usage endpoint and claims one against the |
| 4 | * organization; this module does the same with the credentials the CLI keeps |
| 5 | * in its config directory. macOS keeps them in the keychain, so there the |
| 6 | * feature is not offered. |
| 7 | * |
| 8 | * @module provider/claudeResetCredits |
| 9 | */ |
| 10 | import type { |
| 11 | ProviderConsumeResetCreditOutcome, |
| 12 | ServerProviderResetCredits, |
| 13 | } from "@t3tools/contracts"; |
| 14 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 15 | import * as DateTime from "effect/DateTime"; |
| 16 | import * as Effect from "effect/Effect"; |
| 17 | import * as FileSystem from "effect/FileSystem"; |
| 18 | import * as Option from "effect/Option"; |
| 19 | import * as Path from "effect/Path"; |
| 20 | import * as Schema from "effect/Schema"; |
| 21 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; |
| 22 | |
| 23 | const API_BASE = "https://api.anthropic.com"; |
| 24 | const PROGRAM = "cedar_ember"; |
| 25 | const GRANT_ID = /^[a-z0-9_-]{1,40}$/; |
| 26 | const REQUEST_ID = /^[A-Za-z0-9_-]{1,64}$/; |
| 27 | const COMPLETE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; |
| 28 | |
| 29 | const Credentials = Schema.Struct({ |
| 30 | claudeAiOauth: Schema.optional(Schema.Struct({ accessToken: Schema.optional(Schema.String) })), |
| 31 | }); |
| 32 | const Config = Schema.Struct({ |
| 33 | oauthAccount: Schema.optional( |
| 34 | Schema.Struct({ organizationUuid: Schema.optional(Schema.String) }), |
| 35 | ), |
| 36 | }); |
| 37 | const Grant = Schema.Struct({ |
| 38 | id: Schema.String.check(Schema.isPattern(GRANT_ID)), |
| 39 | resets_left: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), |
| 40 | ends_at: Schema.optional(Schema.NullOr(Schema.String)), |
| 41 | paused: Schema.optional(Schema.Boolean), |
| 42 | usable_now: Schema.optional(Schema.Boolean), |
| 43 | }); |
| 44 | const decodeGrant = Schema.decodeUnknownOption(Grant); |
| 45 | const CedarEmber = Schema.Struct({ |
| 46 | eligible: Schema.Boolean, |
| 47 | grants: Schema.optional(Schema.Array(Schema.Unknown)), |
| 48 | next_grant_id: Schema.optional(Schema.NullOr(Schema.String)), |
| 49 | }); |
| 50 | const UsageResponse = Schema.Struct({ |
| 51 | cedar_ember: Schema.optional(Schema.NullOr(Schema.Unknown)), |
| 52 | }); |
| 53 | const decodeCedarEmber = Schema.decodeUnknownOption(CedarEmber); |
| 54 | const ClaimResponse = Schema.Struct({ |
| 55 | result: Schema.Literals([ |
| 56 | "reset", |
| 57 | "already_used", |
| 58 | "not_limited", |
| 59 | "cooldown", |
| 60 | "ineligible", |
| 61 | "unavailable", |
| 62 | ]), |
| 63 | }); |
| 64 | |
| 65 | const RESET_CREDIT_FAILURES = { |
| 66 | malformedCredit: "Claude returned a malformed reset credit.", |
| 67 | loginUnreadable: "Claude could not read its login.", |
| 68 | accountUnreadable: "Claude could not read its account.", |
| 69 | signedOut: "Sign in to Claude again to redeem resets.", |
| 70 | rateLimited: "Claude is rate limiting resets. Try again soon.", |
| 71 | coolingDown: "Claude resets are cooling down. Try again later.", |
| 72 | unconfirmed: |
| 73 | "Claude could not confirm the reset. If you are still limited in a moment, try again.", |
| 74 | requestFailed: "Claude could not redeem the reset.", |
| 75 | } as const; |
| 76 | |
| 77 | class ClaudeResetCreditError extends Schema.TaggedError<ClaudeResetCreditError>()( |
| 78 | "ClaudeResetCreditError", |
| 79 | { |
| 80 | reason: Schema.Literals( |
| 81 | Object.keys(RESET_CREDIT_FAILURES) as Array<keyof typeof RESET_CREDIT_FAILURES>, |
| 82 | ), |
| 83 | cause: Schema.optional(Schema.Defect()), |
| 84 | }, |
| 85 | ) { |
| 86 | override get message(): string { |
| 87 | return RESET_CREDIT_FAILURES[this.reason]; |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | const isClaudeResetCreditError = Schema.is(ClaudeResetCreditError); |
| 92 | |
| 93 | /** |
| 94 | * Every reset failure except `requestFailed` and `unconfirmed` is final: |
| 95 | * Claude answered, or nothing was sent. An unanswered or unconfirmed claim |
| 96 | * retries with the same request id. |
| 97 | */ |
| 98 | export const isSettledClaudeResetCreditFailure = (error: unknown) => |
| 99 | isClaudeResetCreditError(error) && |
| 100 | error.reason !== "requestFailed" && |
| 101 | error.reason !== "unconfirmed"; |
| 102 | |
| 103 | /** Rejects unparseable and calendar-invalid timestamps such as February 30. */ |
| 104 | const isFutureTimestamp = (value: string, nowMs: number) => { |
| 105 | if (!COMPLETE_TIMESTAMP.test(value)) return false; |
| 106 | const [year, month, day] = value.slice(0, 10).split("-").map(Number); |
| 107 | return ( |
| 108 | Date.parse(value) > nowMs && Date.UTC(year!, month! - 1, day!) <= Date.UTC(year!, month!, 0) |
| 109 | ); |
| 110 | }; |
| 111 | |
| 112 | /** Grants that are paused or past `ends_at` cannot be claimed and do not count. */ |
| 113 | export function claudeResetCreditsToContract( |
| 114 | block: unknown, |
| 115 | nowMs: number, |
| 116 | ): ServerProviderResetCredits | undefined { |
| 117 | const parsed = decodeCedarEmber(block); |
| 118 | if (Option.isNone(parsed) || !parsed.value.eligible) return undefined; |
| 119 | const live = (parsed.value.grants ?? []) |
| 120 | .flatMap((raw) => Option.toArray(decodeGrant(raw))) |
| 121 | .filter( |
| 122 | (grant) => |
| 123 | !grant.paused && |
| 124 | grant.usable_now && |
| 125 | (grant.ends_at == null || isFutureTimestamp(grant.ends_at, nowMs)), |
| 126 | ); |
| 127 | const next = live.find((grant) => grant.id === parsed.value.next_grant_id); |
| 128 | const nextExpiresAt = next?.ends_at ? DateTime.make(next.ends_at) : Option.none(); |
| 129 | return { |
| 130 | availableCount: next ? live.reduce((sum, grant) => sum + grant.resets_left, 0) : 0, |
| 131 | ...(Option.isSome(nextExpiresAt) |
| 132 | ? { nextExpiresAt: DateTime.formatIso(nextExpiresAt.value) } |
| 133 | : {}), |
| 134 | ...(next ? { nextCreditId: next.id } : {}), |
| 135 | }; |
| 136 | } |
| 137 | |
| 138 | const readJson = <S extends Schema.Top>(schema: S, file: string) => |
| 139 | Effect.gen(function* () { |
| 140 | const fs = yield* FileSystem.FileSystem; |
| 141 | return yield* fs.readFileString(file).pipe( |
| 142 | Effect.catchTags({ |
| 143 | PlatformError: (error) => |
| 144 | error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), |
| 145 | }), |
| 146 | Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(schema))), |
| 147 | ); |
| 148 | }); |
| 149 | |
| 150 | const readAccessToken = (configDir: string) => |
| 151 | Effect.gen(function* () { |
| 152 | if ((yield* HostProcess.Platform) === "darwin") return undefined; |
| 153 | const path = yield* Path.Path; |
| 154 | const credentials = yield* readJson(Credentials, path.join(configDir, ".credentials.json")); |
| 155 | return credentials.claudeAiOauth?.accessToken?.trim() || undefined; |
| 156 | }); |
| 157 | |
| 158 | const withClaudeHeaders = (token: string, version: string) => |
| 159 | HttpClientRequest.setHeaders({ |
| 160 | authorization: `Bearer ${token}`, |
| 161 | "anthropic-beta": "oauth-2025-04-20", |
| 162 | "user-agent": `claude-cli/${version} (external, cli)`, |
| 163 | }); |
| 164 | |
| 165 | /** |
| 166 | * Reads the banked resets for the login in `configDir`. Any failure reads as |
| 167 | * "no resets" so the usage bars never break on this optional extra. |
| 168 | */ |
| 169 | export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( |
| 170 | function* (configDir: string, version: string) { |
| 171 | const token = yield* readAccessToken(configDir); |
| 172 | if (!token) return undefined; |
| 173 | const client = yield* HttpClient.HttpClient; |
| 174 | const response = yield* client.execute( |
| 175 | HttpClientRequest.get(`${API_BASE}/api/oauth/usage`, { |
| 176 | urlParams: { cedar_ember: "1", skip_spend: "1" }, |
| 177 | }).pipe(withClaudeHeaders(token, version)), |
| 178 | ); |
| 179 | const body = yield* HttpClientResponse.schemaBodyJson(UsageResponse)( |
| 180 | yield* HttpClientResponse.filterStatusOk(response), |
| 181 | ); |
| 182 | return claudeResetCreditsToContract( |
| 183 | body.cedar_ember, |
| 184 | DateTime.toEpochMillis(yield* DateTime.now), |
| 185 | ); |
| 186 | }, |
| 187 | Effect.timeout("10 seconds"), |
| 188 | Effect.orElseSucceed(() => undefined), |
| 189 | ); |
| 190 | |
| 191 | /** The CLI keeps the account record beside its settings, or in the home directory by default. */ |
| 192 | /** The organization the login is signed in to; one email can belong to several. */ |
| 193 | export const readClaudeOrganizationId = (accountConfigPath: string) => |
| 194 | readJson(Config, accountConfigPath).pipe( |
| 195 | Effect.map((config) => config.oauthAccount?.organizationUuid?.trim() || undefined), |
| 196 | Effect.orElseSucceed(() => undefined), |
| 197 | ); |
| 198 | |
| 199 | export const claudeAccountConfigPath = Effect.fn("claudeAccountConfigPath")(function* ( |
| 200 | configDir: string | undefined, |
| 201 | ) { |
| 202 | const path = yield* Path.Path; |
| 203 | return configDir |
| 204 | ? path.join(configDir, ".claude.json") |
| 205 | : path.join(yield* HostProcess.HomeDirectory, ".claude.json"); |
| 206 | }); |
| 207 | |
| 208 | const CLAIM_OUTCOMES = { |
| 209 | reset: "reset", |
| 210 | not_limited: "nothingToReset", |
| 211 | already_used: "alreadyRedeemed", |
| 212 | ineligible: "noCredit", |
| 213 | } as const satisfies Record<string, ProviderConsumeResetCreditOutcome>; |
| 214 | |
| 215 | /** |
| 216 | * Claims `grantId`. `requestId` is the idempotency key: a retry with the same |
| 217 | * id is the same claim. Ids are checked before anything is sent. |
| 218 | */ |
| 219 | export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(function* (input: { |
| 220 | readonly configDir: string; |
| 221 | readonly accountConfigPath: string; |
| 222 | readonly version: string; |
| 223 | readonly grantId: string; |
| 224 | readonly requestId: string; |
| 225 | }) { |
| 226 | if (!GRANT_ID.test(input.grantId) || !REQUEST_ID.test(input.requestId)) { |
| 227 | return yield* new ClaudeResetCreditError({ reason: "malformedCredit" }); |
| 228 | } |
| 229 | const token = yield* readAccessToken(input.configDir).pipe( |
| 230 | Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "loginUnreadable", cause })), |
| 231 | ); |
| 232 | const config = yield* readJson(Config, input.accountConfigPath).pipe( |
| 233 | Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "accountUnreadable", cause })), |
| 234 | ); |
| 235 | const organization = config.oauthAccount?.organizationUuid?.trim(); |
| 236 | if (!token || !organization) { |
| 237 | return yield* new ClaudeResetCreditError({ reason: "signedOut" }); |
| 238 | } |
| 239 | const client = yield* HttpClient.HttpClient; |
| 240 | const response = yield* client |
| 241 | .execute( |
| 242 | HttpClientRequest.post( |
| 243 | new URL( |
| 244 | `/api/organizations/${encodeURIComponent(organization)}/reset_rate_limits`, |
| 245 | API_BASE, |
| 246 | ), |
| 247 | ).pipe( |
| 248 | withClaudeHeaders(token, input.version), |
| 249 | HttpClientRequest.bodyJsonUnsafe({ |
| 250 | program: PROGRAM, |
| 251 | grant_id: input.grantId, |
| 252 | request_id: input.requestId, |
| 253 | }), |
| 254 | ), |
| 255 | ) |
| 256 | .pipe( |
| 257 | Effect.timeout("25 seconds"), |
| 258 | Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })), |
| 259 | ); |
| 260 | if (response.status === 429) { |
| 261 | return yield* new ClaudeResetCreditError({ reason: "rateLimited" }); |
| 262 | } |
| 263 | if (response.status === 401 || response.status === 403) { |
| 264 | return yield* new ClaudeResetCreditError({ reason: "signedOut" }); |
| 265 | } |
| 266 | const body = yield* HttpClientResponse.filterStatusOk(response).pipe( |
| 267 | Effect.flatMap(HttpClientResponse.schemaBodyJson(ClaimResponse)), |
| 268 | Effect.timeout("25 seconds"), |
| 269 | Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })), |
| 270 | ); |
| 271 | if (body.result === "cooldown") { |
| 272 | return yield* new ClaudeResetCreditError({ reason: "coolingDown" }); |
| 273 | } |
| 274 | // Claude could not say whether the claim landed, so, like the CLI, keep the |
| 275 | // request id and let the retry ask about the same claim. |
| 276 | if (body.result === "unavailable") { |
| 277 | return yield* new ClaudeResetCreditError({ reason: "unconfirmed" }); |
| 278 | } |
| 279 | return CLAIM_OUTCOMES[body.result]; |
| 280 | }); |