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 */
10import type {
11 ProviderConsumeResetCreditOutcome,
12 ServerProviderResetCredits,
13} from "@t3tools/contracts";
14import * as HostProcess from "@t3tools/shared/HostProcess";
15import * as DateTime from "effect/DateTime";
16import * as Effect from "effect/Effect";
17import * as FileSystem from "effect/FileSystem";
18import * as Option from "effect/Option";
19import * as Path from "effect/Path";
20import * as Schema from "effect/Schema";
21import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
22
23const API_BASE = "https://api.anthropic.com";
24const PROGRAM = "cedar_ember";
25const GRANT_ID = /^[a-z0-9_-]{1,40}$/;
26const REQUEST_ID = /^[A-Za-z0-9_-]{1,64}$/;
27const COMPLETE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
28
29const Credentials = Schema.Struct({
30 claudeAiOauth: Schema.optional(Schema.Struct({ accessToken: Schema.optional(Schema.String) })),
31});
32const Config = Schema.Struct({
33 oauthAccount: Schema.optional(
34 Schema.Struct({ organizationUuid: Schema.optional(Schema.String) }),
35 ),
36});
37const 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});
44const decodeGrant = Schema.decodeUnknownOption(Grant);
45const 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});
50const UsageResponse = Schema.Struct({
51 cedar_ember: Schema.optional(Schema.NullOr(Schema.Unknown)),
52});
53const decodeCedarEmber = Schema.decodeUnknownOption(CedarEmber);
54const ClaimResponse = Schema.Struct({
55 result: Schema.Literals([
56 "reset",
57 "already_used",
58 "not_limited",
59 "cooldown",
60 "ineligible",
61 "unavailable",
62 ]),
63});
64
65const 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
77class 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
91const 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 */
98export 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. */
104const 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. */
113export 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
138const 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
150const 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
158const 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 */
169export 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. */
193export const readClaudeOrganizationId = (accountConfigPath: string) =>
194 readJson(Config, accountConfigPath).pipe(
195 Effect.map((config) => config.oauthAccount?.organizationUuid?.trim() || undefined),
196 Effect.orElseSucceed(() => undefined),
197 );
198
199export 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
208const 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 */
219export 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});