authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-24 22:14:04+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-24 12:14:04-07:00
log3412097bff7ffa62ef79e735c65d1e5cff71c356
tree105644d7bdcf127cbb308026d2d94f659199b534
parent7cfb4987fe27da3e1d4e86abacae3a1fc12af5bb
signature Signed by PGP key B5690EEEBB952194

feat(server): show and redeem Claude banked resets (#13118)

Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

14 files changed, 1008 insertions(+), 226 deletions(-)

apps/server/src/provider/Drivers/ClaudeDriver.ts+84-1
......@@ -31,6 +31,8 @@ import { ServerSettingsService } from "../../serverSettings.ts";
3131import { ProviderDriverError } from "../Errors.ts";
3232import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts";
3333import { makeClaudeScopedLimitNames } from "../Layers/claudeUsageLimits.ts";
34import * as ClaudeResetCredits from "../Layers/claudeResetCredits.ts";
35import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts";
3436import {
3537 checkClaudeProviderStatus,
3638 makePendingClaudeProvider,
......@@ -59,7 +61,11 @@ import {
5961 makeProviderSnapshotSettingsSource,
6062 type ProviderSnapshotSettings,
6163} from "../providerUpdateSettings.ts";
62import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts";
64import {
65 makeClaudeCapabilitiesCacheKey,
66 makeClaudeContinuationGroupKey,
67 resolveClaudeHomePath,
68} from "./ClaudeHome.ts";
6369import { discoverClaudeSkills } from "./ClaudeSkills.ts";
6470const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
6571
......@@ -87,6 +93,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({
8793export type ClaudeDriverEnv =
8894 | BackgroundPolicy.BackgroundPolicy
8995 | ChildProcessSpawner.ChildProcessSpawner
96 | ResetCreditCoordinator.ResetCreditCoordinator
9097 | Crypto.Crypto
9198 | FileSystem.FileSystem
9299 | HttpClient.HttpClient
......@@ -111,6 +118,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
111118 const path = yield* Path.Path;
112119 const { cwd } = yield* ServerConfig;
113120 const httpClient = yield* HttpClient.HttpClient;
121 const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator;
114122 const serverSettings = yield* ServerSettingsService;
115123 const eventLoggers = yield* ProviderEventLoggers;
116124 const modelManifest = yield* ModelManifest.ModelManifest;
......@@ -139,6 +147,12 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
139147 effectiveConfig,
140148 processEnv,
141149 );
150 const configDir = yield* resolveClaudeHomePath(effectiveConfig, processEnv);
151 const accountConfigPath = yield* ClaudeResetCredits.claudeAccountConfigPath(
152 effectiveConfig.homePath.trim() || processEnv.CLAUDE_CONFIG_DIR?.trim()
153 ? configDir
154 : undefined,
155 );
142156 const stampIdentity = withInstanceIdentity({
143157 instanceId,
144158 driverKind: DRIVER_KIND,
......@@ -193,6 +207,12 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
193207 cwd,
194208 resolveClaudeModelCatalog(manifest),
195209 scopedLimitNames,
210 (version) =>
211 ClaudeResetCredits.readClaudeResetCredits(configDir, version).pipe(
212 Effect.provideService(HttpClient.HttpClient, httpClient),
213 Effect.provideService(FileSystem.FileSystem, fileSystem),
214 Effect.provideService(Path.Path, path),
215 ),
196216 ),
197217 ),
198218 Effect.map(stampIdentity),
......@@ -250,6 +270,68 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
250270 Effect.provideService(Path.Path, path),
251271 );
252272
273 // Same rules as Codex: serialised on the config directory that holds the
274 // login, one request id kept until Claude answers (a cooldown or rate
275 // limit is an answer), then a re-probe.
276 const consumeResetCredit: NonNullable<ProviderInstance["consumeResetCredit"]> = () =>
277 Effect.gen(function* () {
278 const current = yield* snapshot.getSnapshot;
279 const grantId = current.usageLimits?.resetCredits?.nextCreditId;
280 if (!grantId || !current.version) return "noCredit" as const;
281 const version = current.version;
282 return yield* resetCreditCoordinator.redeem(
283 configDir,
284 (requestId) =>
285 ClaudeResetCredits.consumeClaudeResetCredit({
286 configDir,
287 accountConfigPath,
288 version,
289 grantId,
290 requestId,
291 }),
292 ClaudeResetCredits.isSettledClaudeResetCreditFailure,
293 );
294 }).pipe(
295 Effect.provideService(HttpClient.HttpClient, httpClient),
296 Effect.provideService(FileSystem.FileSystem, fileSystem),
297 Effect.provideService(Path.Path, path),
298 Effect.mapError(
299 (cause) =>
300 new ProviderDriverError({
301 driver: DRIVER_KIND,
302 instanceId,
303 detail:
304 cause._tag === "ClaudeResetCreditError"
305 ? cause.message
306 : "Claude could not redeem the reset.",
307 cause,
308 }),
309 ),
310 // Re-probe after any answer, but only a reset claims the limits
311 // changed, so only a reset reports an unconfirmed refresh.
312 Effect.tap((outcome) =>
313 Effect.gen(function* () {
314 const before = (yield* snapshot.getSnapshot).usageLimits?.checkedAt;
315 yield* Cache.invalidateAll(capabilitiesProbeCache);
316 const refreshed = yield* snapshot.refresh;
317 const after = refreshed.usageLimits?.checkedAt;
318 if (
319 outcome === "reset" &&
320 (after === undefined ||
321 after === before ||
322 refreshed.usageLimits?.unavailable?.reason === "probeFailed")
323 ) {
324 return yield* new ProviderDriverError({
325 driver: DRIVER_KIND,
326 instanceId,
327 detail:
328 "The reset was applied, but Claude could not confirm the new limits. Refresh to check.",
329 });
330 }
331 }),
332 ),
333 );
334
253335 return {
254336 instanceId,
255337 driverKind: DRIVER_KIND,
......@@ -265,6 +347,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
265347 snapshotForCwd,
266348 adapter,
267349 textGeneration,
350 consumeResetCredit,
268351 } satisfies ProviderInstance;
269352 }),
270353};
apps/server/src/provider/Drivers/CodexDriver.test.ts+2-2
......@@ -17,7 +17,7 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne
1717import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
1818import { ServerConfig } from "../../config.ts";
1919import { ServerSettingsService } from "../../serverSettings.ts";
20import { layerTest as codexResetCreditLayerTest } from "../Layers/codexResetCredit.ts";
20import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts";
2121import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
2222import * as ModelManifest from "../ModelManifest.ts";
2323import {
......@@ -33,7 +33,7 @@ const testLayer = ServerConfig.layerTest(process.cwd(), {
3333 Layer.provideMerge(NodeServices.layer),
3434 Layer.provideMerge(ServerSettingsService.layerTest()),
3535 Layer.provideMerge(ModelManifest.layerTest),
36 Layer.provideMerge(codexResetCreditLayerTest),
36 Layer.provideMerge(ResetCreditCoordinator.layerTest),
3737 Layer.provideMerge(
3838 Layer.mock(BackgroundPolicy.BackgroundPolicy)({
3939 shouldRunScopeWork: () => Effect.succeed(false),
apps/server/src/provider/Drivers/CodexDriver.ts+4-7
......@@ -37,10 +37,7 @@ import { expandHomePath } from "../../pathExpansion.ts";
3737import { ServerSettingsService } from "../../serverSettings.ts";
3838import { ProviderDriverError } from "../Errors.ts";
3939import { makeCodexAdapter } from "../Layers/CodexAdapter.ts";
40import {
41 CODEX_RESET_CREDIT_TIMEOUT,
42 CodexResetCreditCoordinator,
43} from "../Layers/codexResetCredit.ts";
40import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts";
4441import {
4542 checkCodexProviderStatus,
4643 makePendingCodexProvider,
......@@ -106,7 +103,7 @@ function makeCodexMaintenanceResolver(sharedHomePath: string) {
106103export type CodexDriverEnv =
107104 | BackgroundPolicy.BackgroundPolicy
108105 | ChildProcessSpawner.ChildProcessSpawner
109 | CodexResetCreditCoordinator
106 | ResetCreditCoordinator.ResetCreditCoordinator
110107 | Crypto.Crypto
111108 | FileSystem.FileSystem
112109 | HttpClient.HttpClient
......@@ -127,7 +124,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
127124 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
128125 Effect.gen(function* () {
129126 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
130 const resetCreditCoordinator = yield* CodexResetCreditCoordinator;
127 const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator;
131128 const fileSystem = yield* FileSystem.FileSystem;
132129 const pathService = yield* Path.Path;
133130 const httpClient = yield* HttpClient.HttpClient;
......@@ -299,7 +296,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
299296 idempotencyKey,
300297 });
301298 return response.outcome;
302 }).pipe(Effect.scoped, Effect.timeout(CODEX_RESET_CREDIT_TIMEOUT)),
299 }).pipe(Effect.scoped, Effect.timeout("20 seconds")),
303300 )
304301 .pipe(
305302 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
apps/server/src/provider/Layers/ClaudeProvider.ts+11-1
......@@ -2,6 +2,7 @@ import {
22 type ClaudeSettings,
33 type ModelCapabilities,
44 type ServerProviderSlashCommand,
5 type ServerProviderResetCredits,
56} from "@t3tools/contracts";
67import * as DateTime from "effect/DateTime";
78import * as Effect from "effect/Effect";
......@@ -426,6 +427,8 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
426427 modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG,
427428 /** Shared with the adapter so turn events reuse the scoped-bucket names this probe saw. */
428429 scopedLimitNames?: Ref.Ref<ClaudeScopedLimitNames>,
430 /** Banked resets for a subscription login, given the CLI version for the user agent. */
431 resolveResetCredits?: (version: string) => Effect.Effect<ServerProviderResetCredits | undefined>,
429432): Effect.fn.Return<
430433 ServerProviderDraft,
431434 never,
......@@ -568,6 +571,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
568571 checkedAt,
569572 })
570573 : claudeUsageResponseToLimits({ response: capabilities.usage, checkedAt }).limits;
574 const resetCredits =
575 resolveResetCredits &&
576 capabilities.subscriptionType &&
577 !usageLimits.unavailable &&
578 parsedVersion
579 ? yield* resolveResetCredits(parsedVersion)
580 : undefined;
571581 return buildServerProvider({
572582 presentation: CLAUDE_PRESENTATION,
573583 enabled: claudeSettings.enabled,
......@@ -585,7 +595,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
585595 ...(authMetadata ? authMetadata : {}),
586596 },
587597 ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}),
588 usageLimits,
598 usageLimits: resetCredits ? { ...usageLimits, resetCredits } : usageLimits,
589599 },
590600 });
591601});
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+115-5
......@@ -34,7 +34,7 @@ import {
3434 type ProviderInstanceConfigMap,
3535 ProviderInstanceId,
3636} from "@t3tools/contracts";
37import { isHostWindows } from "@t3tools/shared/hostProcess";
37import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess";
3838import * as DateTime from "effect/DateTime";
3939import * as Effect from "effect/Effect";
4040import * as FileSystem from "effect/FileSystem";
......@@ -56,7 +56,7 @@ import { GrokDriver } from "../Drivers/GrokDriver.ts";
5656import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts";
5757import * as ModelManifest from "../ModelManifest.ts";
5858import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts";
59import * as CodexResetCredit from "./codexResetCredit.ts";
59import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts";
6060import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts";
6161import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts";
6262
......@@ -178,6 +178,7 @@ const makeTildeProviderFixtures = Effect.fn(
178178 claudePath,
179179 [
180180 "#!/usr/bin/env node",
181 'import { existsSync } from "node:fs";',
181182 'import * as NodeReadline from "node:readline";',
182183 'if (process.argv.includes("--version")) {',
183184 ' process.stdout.write("claude 2.1.219\\n");',
......@@ -186,7 +187,26 @@ const makeTildeProviderFixtures = Effect.fn(
186187 "const lines = NodeReadline.createInterface({ input: process.stdin });",
187188 'lines.on("line", (line) => {',
188189 " const message = JSON.parse(line);",
189 ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;',
190 ' if (message.type !== "control_request") return;',
191 ' if (message.request?.subtype === "get_usage") {',
192 " const marker = process.env.T3_CLAUDE_RESET_MARKER;",
193 " if (process.env.T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM && marker && existsSync(marker)) {",
194 " process.stdout.write(JSON.stringify({",
195 ' type: "control_response",',
196 ' response: { subtype: "error", request_id: message.request_id, error: "usage failed" },',
197 ' }) + "\\n");',
198 " return;",
199 " }",
200 " process.stdout.write(JSON.stringify({",
201 ' type: "control_response",',
202 ' response: { subtype: "success", request_id: message.request_id, response: {',
203 ' session: {}, subscription_type: "pro", rate_limits_available: true,',
204 " rate_limits: { five_hour: { utilization: marker && existsSync(marker) ? 0 : 100, resets_at: null } },",
205 " } },",
206 ' }) + "\\n");',
207 " return;",
208 " }",
209 ' if (message.request?.subtype !== "initialize") return;',
190210 " process.stdout.write(JSON.stringify({",
191211 ' type: "control_response",',
192212 " response: {",
......@@ -231,7 +251,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
231251 Layer.provideMerge(TestHttpClientLive),
232252 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
233253 Layer.provideMerge(ModelManifest.layerTest),
234 Layer.provideMerge(CodexResetCredit.layerTest),
254 Layer.provideMerge(ResetCreditCoordinator.layerTest),
235255 );
236256
237257 it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () =>
......@@ -430,6 +450,96 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
430450 }).pipe(Effect.provide(testLayer)),
431451 );
432452
453 const redeemClaudeReset = (claim: { result: string; usageFailsAfterClaim: boolean }) =>
454 Effect.gen(function* () {
455 const fs = yield* FileSystem.FileSystem;
456 const path = yield* Path.Path;
457 const fixtures = yield* makeTildeProviderFixtures();
458 const marker = path.join(fixtures.claudeHomePath, "redeemed");
459 yield* fs.writeFileString(
460 path.join(fixtures.claudeHomePath, ".credentials.json"),
461 '{"claudeAiOauth":{"accessToken":"fake-token"}}',
462 );
463 yield* fs.writeFileString(
464 path.join(fixtures.claudeHomePath, ".claude.json"),
465 '{"oauthAccount":{"organizationUuid":"fake-org"}}',
466 );
467 const client = HttpClient.make((request) =>
468 Effect.gen(function* () {
469 if (request.url.endsWith("/api/oauth/usage")) {
470 return HttpClientResponse.fromWeb(
471 request,
472 Response.json({
473 cedar_ember: {
474 eligible: true,
475 next_grant_id: "grant_a",
476 grants: [{ id: "grant_a", resets_left: 1, usable_now: true }],
477 },
478 }),
479 );
480 }
481 if (request.url.endsWith("/reset_rate_limits")) {
482 yield* fs.writeFileString(marker, "redeemed").pipe(Effect.orDie);
483 return HttpClientResponse.fromWeb(request, Response.json({ result: claim.result }));
484 }
485 return HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }));
486 }),
487 );
488 const instanceId = ProviderInstanceId.make("claude_reset");
489 const { registry } = yield* makeProviderInstanceRegistry({
490 drivers: [ClaudeDriver],
491 configMap: {
492 [instanceId]: {
493 driver: ProviderDriverKind.make("claudeAgent"),
494 enabled: true,
495 environment: [
496 { name: "T3_CLAUDE_RESET_MARKER", value: marker, sensitive: false },
497 ...(claim.usageFailsAfterClaim
498 ? [{ name: "T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM", value: "1", sensitive: false }]
499 : []),
500 ],
501 config: makeClaudeConfig({
502 enabled: true,
503 binaryPath: fixtures.claudeBinaryPath,
504 homePath: fixtures.claudeHomePath,
505 }),
506 },
507 },
508 }).pipe(Effect.provideService(HttpClient.HttpClient, client));
509 const instance = yield* registry.getInstance(instanceId);
510 expect(instance).toBeDefined();
511 const before = yield* instance!.snapshot.refresh;
512 expect(before.usageLimits?.windows[0]?.usedPercent).toBe(100);
513 expect(before.usageLimits?.resetCredits?.nextCreditId).toBe("grant_a");
514 const outcome = yield* instance!.consumeResetCredit!().pipe(Effect.result);
515 return { outcome, after: yield* instance!.snapshot.getSnapshot };
516 }).pipe(
517 // macOS logins live in the Keychain, where resets are never read.
518 Effect.provideService(HostProcessPlatform, "linux"),
519 Effect.provide(testLayer),
520 );
521
522 it.live("refreshes Claude usage after redeeming a reset", () =>
523 Effect.gen(function* () {
524 const { outcome, after } = yield* redeemClaudeReset({
525 result: "reset",
526 usageFailsAfterClaim: false,
527 });
528 expect(outcome).toMatchObject({ _tag: "Success", success: "reset" });
529 expect(after.usageLimits?.windows[0]?.usedPercent).toBe(0);
530 }),
531 );
532
533 it.live("reports Claude's answer when a claim changed nothing and the re-probe fails", () =>
534 Effect.gen(function* () {
535 const { outcome } = yield* redeemClaudeReset({
536 result: "already_used",
537 usageFailsAfterClaim: true,
538 });
539 expect(outcome).toMatchObject({ _tag: "Success", success: "alreadyRedeemed" });
540 }),
541 );
542
433543 it.live(
434544 "shadows instances whose driver is not registered in this build without failing boot",
435545 () =>
......@@ -497,7 +607,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
497607 Layer.provideMerge(TestHttpClientLive),
498608 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
499609 Layer.provideMerge(ModelManifest.layerTest),
500 Layer.provideMerge(CodexResetCredit.layerTest),
610 Layer.provideMerge(ResetCreditCoordinator.layerTest),
501611 );
502612
503613 it.live("boots one instance of every shipped driver from a single config map", () =>
apps/server/src/provider/Layers/ProviderRegistry.test.ts+41-6
......@@ -39,7 +39,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
3939import { AntigravityInstallation } from "../AntigravityInstallation.ts";
4040import * as ModelManifest from "../ModelManifest.ts";
4141import { applyProviderCompatibility } from "../providerCompatibility.ts";
42import * as CodexResetCredit from "./codexResetCredit.ts";
42import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts";
4343import * as OpenCodeRuntime from "../opencodeRuntime.ts";
4444import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";
4545import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts";
......@@ -2321,7 +2321,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
23212321 ),
23222322 ),
23232323 Layer.provideMerge(ModelManifest.layerTest),
2324 Layer.provideMerge(CodexResetCredit.layerTest),
2324 Layer.provideMerge(ResetCreditCoordinator.layerTest),
23252325 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
23262326 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
23272327 // NO spawner mock — `ChildProcessSpawner` is supplied by the
......@@ -2420,7 +2420,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
24202420 ),
24212421 ),
24222422 Layer.provideMerge(ModelManifest.layerTest),
2423 Layer.provideMerge(CodexResetCredit.layerTest),
2423 Layer.provideMerge(ResetCreditCoordinator.layerTest),
24242424 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
24252425 Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) =>
24262426 ChildProcessSpawner.make((command) => {
......@@ -2536,7 +2536,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
25362536 ),
25372537 ),
25382538 Layer.provideMerge(ModelManifest.layerTest),
2539 Layer.provideMerge(CodexResetCredit.layerTest),
2539 Layer.provideMerge(ResetCreditCoordinator.layerTest),
25402540 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
25412541 Layer.provideMerge(NodeServices.layer),
25422542 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
......@@ -2598,8 +2598,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
25982598 ),
25992599 ),
26002600 Layer.provideMerge(ModelManifest.layerTest),
2601 Layer.provideMerge(CodexResetCredit.layerTest),
2602 Layer.provideMerge(CodexResetCredit.layerTest),
2601 Layer.provideMerge(ResetCreditCoordinator.layerTest),
26032602 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
26042603 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
26052604 Layer.provideMerge(
......@@ -2752,6 +2751,42 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
27522751 ),
27532752 );
27542753
2754 it.effect("reads banked resets only for subscription logins", () =>
2755 Effect.gen(function* () {
2756 const check = (overrides: Partial<TestClaudeCapabilities>) =>
2757 checkClaudeProviderStatus(
2758 defaultClaudeSettings,
2759 () =>
2760 Effect.succeed({
2761 email: undefined,
2762 subscriptionType: undefined,
2763 tokenSource: undefined,
2764 apiProvider: undefined,
2765 slashCommands: [],
2766 usage: { rate_limits_available: true, rate_limits: {} },
2767 ...overrides,
2768 }),
2769 undefined,
2770 undefined,
2771 undefined,
2772 undefined,
2773 () => Effect.succeed({ availableCount: 2 }),
2774 );
2775 const subscription = yield* check({ subscriptionType: "max" });
2776 const bedrock = yield* check({ apiProvider: "bedrock" });
2777 assert.deepStrictEqual(subscription.usageLimits?.resetCredits, { availableCount: 2 });
2778 assert.strictEqual(bedrock.usageLimits?.resetCredits, undefined);
2779 }).pipe(
2780 Effect.provide(
2781 mockSpawnerLayer((args) => {
2782 const joined = args.join(" ");
2783 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
2784 throw new Error(`Unexpected args: ${joined}`);
2785 }),
2786 ),
2787 ),
2788 );
2789
27552790 it.effect("does not duplicate Claude in full subscription labels", () =>
27562791 Effect.gen(function* () {
27572792 const status = yield* checkClaudeProviderStatus(
apps/server/src/provider/Layers/claudeResetCredits.test.ts created+259
......@@ -0,0 +1,259 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it as effectIt } from "@effect/vitest";
3import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Fiber from "effect/Fiber";
8import * as Path from "effect/Path";
9import * as Schema from "effect/Schema";
10import * as TestClock from "effect/testing/TestClock";
11import { HttpClient, HttpClientResponse, UrlParams } from "effect/unstable/http";
12import { describe, expect, it } from "vite-plus/test";
13
14import * as ClaudeResetCredits from "./claudeResetCredits.ts";
15
16const NOW = Date.parse("2026-09-22T12:00:00.000Z");
17const grant = (overrides: Record<string, unknown>) => ({
18 id: "grant_a",
19 resets_left: 1,
20 usable_now: true,
21 ...overrides,
22});
23
24const writeLogin = Effect.gen(function* () {
25 const fs = yield* FileSystem.FileSystem;
26 const path = yield* Path.Path;
27 const directory = yield* fs.makeTempDirectoryScoped();
28 yield* fs.writeFileString(
29 path.join(directory, ".credentials.json"),
30 '{"claudeAiOauth":{"accessToken":"oauth-token"}}',
31 );
32 const accountConfigPath = path.join(directory, ".claude.json");
33 yield* fs.writeFileString(accountConfigPath, '{"oauthAccount":{"organizationUuid":"org-1"}}');
34 return { configDir: directory, accountConfigPath };
35});
36
37const respond = (status: number, body: unknown) =>
38 HttpClient.make((request) =>
39 Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(body, { status }))),
40 );
41const refuseRequests = HttpClient.make(() => Effect.die("must not send a request"));
42
43describe("claudeResetCreditsToContract", () => {
44 it("counts live grants and pins the next usable one", () => {
45 expect(
46 ClaudeResetCredits.claudeResetCreditsToContract(
47 {
48 eligible: true,
49 next_grant_id: "grant_a",
50 grants: [
51 grant({ resets_left: 2, ends_at: "2026-10-01T00:00:00Z" }),
52 grant({ id: "paused", paused: true }),
53 grant({ id: "expired", ends_at: "2026-09-01T00:00:00Z" }),
54 grant({ id: "garbled", ends_at: "not a date" }),
55 grant({ id: "date_only", ends_at: "2026-10-01" }),
56 grant({ id: "impossible", ends_at: "2027-02-30T00:00:00Z" }),
57 grant({ id: "empty", ends_at: "" }),
58 grant({ id: "Not Valid" }),
59 grant({ id: "grant_b", resets_left: 3, usable_now: false }),
60 ],
61 },
62 NOW,
63 ),
64 ).toEqual({
65 availableCount: 2,
66 nextCreditId: "grant_a",
67 nextExpiresAt: "2026-10-01T00:00:00.000Z",
68 });
69 });
70
71 it("offers nothing to redeem without a usable next grant or an eligible account", () => {
72 expect(
73 ClaudeResetCredits.claudeResetCreditsToContract(
74 { eligible: true, next_grant_id: "grant_a", grants: [grant({ usable_now: false })] },
75 NOW,
76 ),
77 ).toEqual({ availableCount: 0 });
78 expect(
79 ClaudeResetCredits.claudeResetCreditsToContract({ eligible: true, grants: [grant({})] }, NOW),
80 ).toEqual({
81 availableCount: 0,
82 });
83 expect(
84 ClaudeResetCredits.claudeResetCreditsToContract(
85 { eligible: false, grants: [grant({})] },
86 NOW,
87 ),
88 ).toBeUndefined();
89 expect(ClaudeResetCredits.claudeResetCreditsToContract(undefined, NOW)).toBeUndefined();
90 });
91});
92
93effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => {
94 it.effect("reads the grants with the CLI's request", () =>
95 Effect.gen(function* () {
96 const { configDir } = yield* writeLogin;
97 const client = HttpClient.make((request) => {
98 expect(request.method).toBe("GET");
99 expect(request.url).toBe("https://api.anthropic.com/api/oauth/usage");
100 expect(UrlParams.toString(request.urlParams)).toBe("cedar_ember=1&skip_spend=1");
101 expect(request.headers.authorization).toBe("Bearer oauth-token");
102 expect(request.headers["anthropic-beta"]).toBe("oauth-2025-04-20");
103 expect(request.headers["user-agent"]).toBe("claude-cli/2.1.0 (external, cli)");
104 return Effect.succeed(
105 HttpClientResponse.fromWeb(
106 request,
107 Response.json({
108 cedar_ember: { eligible: true, next_grant_id: "grant_a", grants: [grant({})] },
109 }),
110 ),
111 );
112 });
113 const credits = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe(
114 Effect.provideService(HostProcessPlatform, "linux"),
115 Effect.provideService(HttpClient.HttpClient, client),
116 );
117 expect(credits).toEqual({ availableCount: 1, nextCreditId: "grant_a" });
118 }),
119 );
120
121 it.effect("reads nothing from keychain logins or failed requests", () =>
122 Effect.gen(function* () {
123 const { configDir } = yield* writeLogin;
124 const darwin = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe(
125 Effect.provideService(HostProcessPlatform, "darwin"),
126 Effect.provideService(HttpClient.HttpClient, refuseRequests),
127 );
128 const limited = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe(
129 Effect.provideService(HostProcessPlatform, "linux"),
130 Effect.provideService(HttpClient.HttpClient, respond(429, {})),
131 );
132 expect([darwin, limited]).toEqual([undefined, undefined]);
133 }),
134 );
135});
136
137const ClaimBody = Schema.fromJsonString(
138 Schema.Struct({ program: Schema.String, grant_id: Schema.String, request_id: Schema.String }),
139);
140const decodeClaimBody = Schema.decodeEffect(ClaimBody);
141
142const consume = (client: HttpClient.HttpClient, ids = { grantId: "grant_a", requestId: "r-1" }) =>
143 Effect.gen(function* () {
144 const login = yield* writeLogin;
145 return yield* ClaudeResetCredits.consumeClaudeResetCredit({
146 ...login,
147 version: "2.1.0",
148 ...ids,
149 }).pipe(
150 Effect.provideService(HostProcessPlatform, "linux"),
151 Effect.provideService(HttpClient.HttpClient, client),
152 Effect.result,
153 );
154 });
155
156effectIt.layer(NodeServices.layer)("consumeClaudeResetCredit", (it) => {
157 it.effect("claims the grant for the organization", () =>
158 Effect.gen(function* () {
159 const client = HttpClient.make((request) =>
160 Effect.gen(function* () {
161 expect(request.method).toBe("POST");
162 expect(request.url).toBe(
163 "https://api.anthropic.com/api/organizations/org-1/reset_rate_limits",
164 );
165 expect(request.headers.authorization).toBe("Bearer oauth-token");
166 const body =
167 request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "";
168 expect(yield* decodeClaimBody(body)).toEqual({
169 program: "cedar_ember",
170 grant_id: "grant_a",
171 request_id: "r-1",
172 });
173 return HttpClientResponse.fromWeb(request, Response.json({ result: "reset" }));
174 }).pipe(Effect.orDie),
175 );
176 expect(yield* consume(client)).toMatchObject({ _tag: "Success", success: "reset" });
177 }),
178 );
179
180 it.effect("maps each answer to an outcome or a failure", () =>
181 Effect.gen(function* () {
182 for (const [result, outcome] of [
183 ["not_limited", "nothingToReset"],
184 ["already_used", "alreadyRedeemed"],
185 ["ineligible", "noCredit"],
186 ] as const) {
187 expect(yield* consume(respond(200, { result }))).toMatchObject({ success: outcome });
188 }
189 for (const client of [
190 respond(200, { result: "cooldown" }),
191 respond(429, {}),
192 respond(401, {}),
193 ]) {
194 const result = yield* consume(client);
195 expect(result).toMatchObject({ _tag: "Failure" });
196 // Claude answered, so a retry must be a new claim.
197 if (result._tag === "Failure") {
198 expect(ClaudeResetCredits.isSettledClaudeResetCreditFailure(result.failure)).toBe(true);
199 }
200 }
201 // No answer, or Claude could not confirm the claim: a retry is the same claim.
202 for (const client of [respond(500, {}), respond(200, { result: "unavailable" })]) {
203 const unanswered = yield* consume(client);
204 expect(unanswered).toMatchObject({ _tag: "Failure" });
205 if (unanswered._tag === "Failure") {
206 expect(ClaudeResetCredits.isSettledClaudeResetCreditFailure(unanswered.failure)).toBe(
207 false,
208 );
209 }
210 }
211 }),
212 );
213
214 it.effect("times out a stalled claim body", () =>
215 Effect.gen(function* () {
216 const login = yield* writeLogin;
217 const readingBody = yield* Deferred.make<void>();
218 const client = HttpClient.make((request) => {
219 const response = HttpClientResponse.fromWeb(request, Response.json({ result: "reset" }));
220 Object.defineProperty(response, "json", {
221 value: Deferred.succeed(readingBody, undefined).pipe(Effect.andThen(Effect.never)),
222 });
223 return Effect.succeed(response);
224 });
225 const claim = yield* ClaudeResetCredits.consumeClaudeResetCredit({
226 ...login,
227 version: "2.1.0",
228 grantId: "grant_a",
229 requestId: "r-1",
230 }).pipe(
231 Effect.provideService(HostProcessPlatform, "linux"),
232 Effect.provideService(HttpClient.HttpClient, client),
233 Effect.result,
234 Effect.forkChild,
235 );
236 yield* Deferred.await(readingBody);
237 yield* TestClock.adjust("26 seconds");
238 expect(yield* Fiber.join(claim)).toMatchObject({
239 _tag: "Failure",
240 failure: {
241 _tag: "ClaudeResetCreditError",
242 reason: "requestFailed",
243 cause: { _tag: "TimeoutError" },
244 },
245 });
246 }).pipe(Effect.provide(TestClock.layer())),
247 );
248
249 it.effect("refuses malformed ids without sending anything", () =>
250 Effect.gen(function* () {
251 for (const ids of [
252 { grantId: "Bad Grant", requestId: "r-1" },
253 { grantId: "grant_a", requestId: "has space" },
254 ]) {
255 expect(yield* consume(refuseRequests, ids)).toMatchObject({ _tag: "Failure" });
256 }
257 }),
258 );
259});
apps/server/src/provider/Layers/claudeResetCredits.ts created+270
......@@ -0,0 +1,270 @@
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/Layers/claudeResetCredits
9 */
10import * as NodeOS from "node:os";
11import type {
12 ProviderConsumeResetCreditOutcome,
13 ServerProviderResetCredits,
14} from "@t3tools/contracts";
15import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
16import * as DateTime from "effect/DateTime";
17import * as Effect from "effect/Effect";
18import * as FileSystem from "effect/FileSystem";
19import * as Option from "effect/Option";
20import * as Path from "effect/Path";
21import * as Schema from "effect/Schema";
22import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
23
24const API_BASE = "https://api.anthropic.com";
25const PROGRAM = "cedar_ember";
26const GRANT_ID = /^[a-z0-9_-]{1,40}$/;
27const REQUEST_ID = /^[A-Za-z0-9_-]{1,64}$/;
28const COMPLETE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
29
30const Credentials = Schema.Struct({
31 claudeAiOauth: Schema.optional(Schema.Struct({ accessToken: Schema.optional(Schema.String) })),
32});
33const Config = Schema.Struct({
34 oauthAccount: Schema.optional(
35 Schema.Struct({ organizationUuid: Schema.optional(Schema.String) }),
36 ),
37});
38const Grant = Schema.Struct({
39 id: Schema.String.check(Schema.isPattern(GRANT_ID)),
40 resets_left: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
41 ends_at: Schema.optional(Schema.NullOr(Schema.String)),
42 paused: Schema.optional(Schema.Boolean),
43 usable_now: Schema.optional(Schema.Boolean),
44});
45const decodeGrant = Schema.decodeUnknownOption(Grant);
46const CedarEmber = Schema.Struct({
47 eligible: Schema.Boolean,
48 grants: Schema.optional(Schema.Array(Schema.Unknown)),
49 next_grant_id: Schema.optional(Schema.NullOr(Schema.String)),
50});
51const UsageResponse = Schema.Struct({
52 cedar_ember: Schema.optional(Schema.NullOr(Schema.Unknown)),
53});
54const decodeCedarEmber = Schema.decodeUnknownOption(CedarEmber);
55const ClaimResponse = Schema.Struct({
56 result: Schema.Literals([
57 "reset",
58 "already_used",
59 "not_limited",
60 "cooldown",
61 "ineligible",
62 "unavailable",
63 ]),
64});
65
66const RESET_CREDIT_FAILURES = {
67 malformedCredit: "Claude returned a malformed reset credit.",
68 loginUnreadable: "Claude could not read its login.",
69 accountUnreadable: "Claude could not read its account.",
70 signedOut: "Sign in to Claude again to redeem resets.",
71 rateLimited: "Claude is rate limiting resets. Try again soon.",
72 coolingDown: "Claude resets are cooling down. Try again later.",
73 unconfirmed:
74 "Claude could not confirm the reset. If you are still limited in a moment, try again.",
75 requestFailed: "Claude could not redeem the reset.",
76} as const;
77
78class ClaudeResetCreditError extends Schema.TaggedError<ClaudeResetCreditError>()(
79 "ClaudeResetCreditError",
80 {
81 reason: Schema.Literals(
82 Object.keys(RESET_CREDIT_FAILURES) as Array<keyof typeof RESET_CREDIT_FAILURES>,
83 ),
84 cause: Schema.optional(Schema.Defect()),
85 },
86) {
87 override get message(): string {
88 return RESET_CREDIT_FAILURES[this.reason];
89 }
90}
91
92const isClaudeResetCreditError = Schema.is(ClaudeResetCreditError);
93
94/**
95 * Every reset failure except `requestFailed` and `unconfirmed` is final:
96 * Claude answered, or nothing was sent. An unanswered or unconfirmed claim
97 * retries with the same request id.
98 */
99export const isSettledClaudeResetCreditFailure = (error: unknown) =>
100 isClaudeResetCreditError(error) &&
101 error.reason !== "requestFailed" &&
102 error.reason !== "unconfirmed";
103
104/** Rejects unparseable and calendar-invalid timestamps such as February 30. */
105const isFutureTimestamp = (value: string, nowMs: number) => {
106 if (!COMPLETE_TIMESTAMP.test(value)) return false;
107 const [year, month, day] = value.slice(0, 10).split("-").map(Number);
108 return (
109 Date.parse(value) > nowMs && Date.UTC(year!, month! - 1, day!) <= Date.UTC(year!, month!, 0)
110 );
111};
112
113/** Grants that are paused or past `ends_at` cannot be claimed and do not count. */
114export function claudeResetCreditsToContract(
115 block: unknown,
116 nowMs: number,
117): ServerProviderResetCredits | undefined {
118 const parsed = decodeCedarEmber(block);
119 if (Option.isNone(parsed) || !parsed.value.eligible) return undefined;
120 const live = (parsed.value.grants ?? [])
121 .flatMap((raw) => Option.toArray(decodeGrant(raw)))
122 .filter(
123 (grant) =>
124 !grant.paused &&
125 grant.usable_now &&
126 (grant.ends_at == null || isFutureTimestamp(grant.ends_at, nowMs)),
127 );
128 const next = live.find((grant) => grant.id === parsed.value.next_grant_id);
129 const nextExpiresAt = next?.ends_at ? DateTime.make(next.ends_at) : Option.none();
130 return {
131 availableCount: next ? live.reduce((sum, grant) => sum + grant.resets_left, 0) : 0,
132 ...(Option.isSome(nextExpiresAt)
133 ? { nextExpiresAt: DateTime.formatIso(nextExpiresAt.value) }
134 : {}),
135 ...(next ? { nextCreditId: next.id } : {}),
136 };
137}
138
139const readJson = <S extends Schema.Top>(schema: S, file: string) =>
140 Effect.gen(function* () {
141 const fs = yield* FileSystem.FileSystem;
142 return yield* fs.readFileString(file).pipe(
143 Effect.catchTags({
144 PlatformError: (error) =>
145 error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error),
146 }),
147 Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(schema))),
148 );
149 });
150
151const readAccessToken = (configDir: string) =>
152 Effect.gen(function* () {
153 if ((yield* HostProcessPlatform) === "darwin") return undefined;
154 const path = yield* Path.Path;
155 const credentials = yield* readJson(Credentials, path.join(configDir, ".credentials.json"));
156 return credentials.claudeAiOauth?.accessToken?.trim() || undefined;
157 });
158
159const withClaudeHeaders = (token: string, version: string) =>
160 HttpClientRequest.setHeaders({
161 authorization: `Bearer ${token}`,
162 "anthropic-beta": "oauth-2025-04-20",
163 "user-agent": `claude-cli/${version} (external, cli)`,
164 });
165
166/**
167 * Reads the banked resets for the login in `configDir`. Any failure reads as
168 * "no resets" so the usage bars never break on this optional extra.
169 */
170export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")(
171 function* (configDir: string, version: string) {
172 const token = yield* readAccessToken(configDir);
173 if (!token) return undefined;
174 const client = yield* HttpClient.HttpClient;
175 const response = yield* client.execute(
176 HttpClientRequest.get(`${API_BASE}/api/oauth/usage`, {
177 urlParams: { cedar_ember: "1", skip_spend: "1" },
178 }).pipe(withClaudeHeaders(token, version)),
179 );
180 const body = yield* HttpClientResponse.schemaBodyJson(UsageResponse)(
181 yield* HttpClientResponse.filterStatusOk(response),
182 );
183 return claudeResetCreditsToContract(
184 body.cedar_ember,
185 DateTime.toEpochMillis(yield* DateTime.now),
186 );
187 },
188 Effect.timeout("10 seconds"),
189 Effect.catch(() => Effect.succeed(undefined)),
190);
191
192/** The CLI keeps the account record beside its settings, or in the home directory by default. */
193export const claudeAccountConfigPath = (configDir: string | undefined) =>
194 Effect.map(Path.Path, (path) =>
195 configDir ? path.join(configDir, ".claude.json") : path.join(NodeOS.homedir(), ".claude.json"),
196 );
197
198const CLAIM_OUTCOMES = {
199 reset: "reset",
200 not_limited: "nothingToReset",
201 already_used: "alreadyRedeemed",
202 ineligible: "noCredit",
203} as const satisfies Record<string, ProviderConsumeResetCreditOutcome>;
204
205/**
206 * Claims `grantId`. `requestId` is the idempotency key: a retry with the same
207 * id is the same claim. Ids are checked before anything is sent.
208 */
209export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(function* (input: {
210 readonly configDir: string;
211 readonly accountConfigPath: string;
212 readonly version: string;
213 readonly grantId: string;
214 readonly requestId: string;
215}) {
216 if (!GRANT_ID.test(input.grantId) || !REQUEST_ID.test(input.requestId)) {
217 return yield* new ClaudeResetCreditError({ reason: "malformedCredit" });
218 }
219 const token = yield* readAccessToken(input.configDir).pipe(
220 Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "loginUnreadable", cause })),
221 );
222 const config = yield* readJson(Config, input.accountConfigPath).pipe(
223 Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "accountUnreadable", cause })),
224 );
225 const organization = config.oauthAccount?.organizationUuid?.trim();
226 if (!token || !organization) {
227 return yield* new ClaudeResetCreditError({ reason: "signedOut" });
228 }
229 const client = yield* HttpClient.HttpClient;
230 const response = yield* client
231 .execute(
232 HttpClientRequest.post(
233 new URL(
234 `/api/organizations/${encodeURIComponent(organization)}/reset_rate_limits`,
235 API_BASE,
236 ),
237 ).pipe(
238 withClaudeHeaders(token, input.version),
239 HttpClientRequest.bodyJsonUnsafe({
240 program: PROGRAM,
241 grant_id: input.grantId,
242 request_id: input.requestId,
243 }),
244 ),
245 )
246 .pipe(
247 Effect.timeout("25 seconds"),
248 Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })),
249 );
250 if (response.status === 429) {
251 return yield* new ClaudeResetCreditError({ reason: "rateLimited" });
252 }
253 if (response.status === 401 || response.status === 403) {
254 return yield* new ClaudeResetCreditError({ reason: "signedOut" });
255 }
256 const body = yield* HttpClientResponse.filterStatusOk(response).pipe(
257 Effect.flatMap(HttpClientResponse.schemaBodyJson(ClaimResponse)),
258 Effect.timeout("25 seconds"),
259 Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })),
260 );
261 if (body.result === "cooldown") {
262 return yield* new ClaudeResetCreditError({ reason: "coolingDown" });
263 }
264 // Claude could not say whether the claim landed, so, like the CLI, keep the
265 // request id and let the retry ask about the same claim.
266 if (body.result === "unavailable") {
267 return yield* new ClaudeResetCreditError({ reason: "unconfirmed" });
268 }
269 return CLAIM_OUTCOMES[body.result];
270});
apps/server/src/provider/Layers/codexResetCredit.test.ts deleted-86
......@@ -1,86 +0,0 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as Ref from "effect/Ref";
6
7import { CodexResetCreditCoordinator, layerTest } from "./codexResetCredit.ts";
8
9describe("CodexResetCreditCoordinator", () => {
10 it.effect("re-sends the same idempotency key after a failed attempt, then clears it", () =>
11 Effect.gen(function* () {
12 const { redeem } = yield* CodexResetCreditCoordinator;
13 const keys = yield* Ref.make<ReadonlyArray<string>>([]);
14 const attempts = yield* Ref.make(0);
15 const consume = (key: string) =>
16 Effect.gen(function* () {
17 yield* Ref.update(keys, (seen) => [...seen, key]);
18 const attempt = yield* Ref.updateAndGet(attempts, (n) => n + 1);
19 if (attempt === 1) return yield* Effect.fail("timed out" as const);
20 return "reset" as const;
21 });
22
23 const first = yield* redeem("acct", consume).pipe(Effect.result);
24 assert.isTrue(first._tag === "Failure");
25 const second = yield* redeem("acct", consume);
26 assert.strictEqual(second, "reset");
27 // A fresh redemption after success must be a fresh attempt.
28 yield* redeem("acct", consume);
29
30 const seen = yield* Ref.get(keys);
31 assert.strictEqual(seen.length, 3);
32 assert.strictEqual(seen[0], seen[1]);
33 assert.notStrictEqual(seen[1], seen[2]);
34 }).pipe(Effect.provide(layerTest)),
35 );
36
37 it.effect("serialises concurrent redemptions on the same account, not per caller", () =>
38 Effect.gen(function* () {
39 const { redeem } = yield* CodexResetCreditCoordinator;
40 const release = yield* Deferred.make<void>();
41 const inFlight = yield* Ref.make(0);
42 const peak = yield* Ref.make(0);
43 const consume = () =>
44 Effect.gen(function* () {
45 const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1);
46 yield* Ref.update(peak, (p) => Math.max(p, now));
47 yield* Deferred.await(release);
48 yield* Ref.update(inFlight, (n) => n - 1);
49 return "reset" as const;
50 });
51
52 // Two instances of the same account redeem at once.
53 const a = yield* redeem("acct", consume).pipe(Effect.forkChild);
54 const b = yield* redeem("acct", consume).pipe(Effect.forkChild);
55 yield* Effect.yieldNow;
56 yield* Deferred.succeed(release, undefined);
57 yield* Fiber.join(a);
58 yield* Fiber.join(b);
59
60 assert.strictEqual(yield* Ref.get(peak), 1);
61 }).pipe(Effect.provide(layerTest)),
62 );
63
64 it.effect("keeps different accounts independent", () =>
65 Effect.gen(function* () {
66 const { redeem } = yield* CodexResetCreditCoordinator;
67 const release = yield* Deferred.make<void>();
68 const peak = yield* Ref.make(0);
69 const inFlight = yield* Ref.make(0);
70 const consume = () =>
71 Effect.gen(function* () {
72 const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1);
73 yield* Ref.update(peak, (p) => Math.max(p, now));
74 yield* Deferred.await(release);
75 return "reset" as const;
76 });
77 const a = yield* redeem("acct-a", consume).pipe(Effect.forkChild);
78 const b = yield* redeem("acct-b", consume).pipe(Effect.forkChild);
79 yield* Effect.yieldNow;
80 yield* Deferred.succeed(release, undefined);
81 yield* Fiber.join(a);
82 yield* Fiber.join(b);
83 assert.strictEqual(yield* Ref.get(peak), 2);
84 }).pipe(Effect.provide(layerTest)),
85 );
86});
apps/server/src/provider/Layers/codexResetCredit.ts deleted-114
......@@ -1,114 +0,0 @@
1/**
2 * Redeeming a Codex reset credit is an account-level action: instances that
3 * share the directory holding `auth.json` share the credit, so their
4 * redemptions must serialise on that directory, not the instance. This
5 * service keeps one lock and one pending idempotency key per account key so
6 * overlapping confirmations from any instance queue rather than spending two
7 * credits, and a retry after a timeout re-sends the same attempt.
8 *
9 * @module provider/Layers/codexResetCredit
10 */
11import type { ProviderConsumeResetCreditOutcome } from "@t3tools/contracts";
12import * as Context from "effect/Context";
13import * as Crypto from "effect/Crypto";
14import * as Duration from "effect/Duration";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17import type * as PlatformError from "effect/PlatformError";
18import * as Ref from "effect/Ref";
19import * as Semaphore from "effect/Semaphore";
20
21/**
22 * Bounded so a hung app-server cannot hold the account lock forever; the
23 * timeout interrupts the scoped request, which kills the process, and the
24 * kept idempotency key makes the user's retry safe.
25 */
26export const CODEX_RESET_CREDIT_TIMEOUT = Duration.seconds(20);
27
28interface AccountRedemptionState {
29 readonly lock: Semaphore.Semaphore;
30 readonly pendingKey: Ref.Ref<string | null>;
31}
32
33export class CodexResetCreditCoordinator extends Context.Service<
34 CodexResetCreditCoordinator,
35 {
36 /**
37 * Run `consume` under the account's lock with a stable idempotency key.
38 * The key is cleared only when Codex reports an outcome; a failure
39 * (timeout included) keeps it so the next attempt is the same attempt.
40 */
41 readonly redeem: <E, R>(
42 accountKey: string,
43 consume: (idempotencyKey: string) => Effect.Effect<ProviderConsumeResetCreditOutcome, E, R>,
44 ) => Effect.Effect<ProviderConsumeResetCreditOutcome, E | PlatformError.PlatformError, R>;
45 }
46>()("t3/provider/Layers/codexResetCredit/CodexResetCreditCoordinator") {}
47
48/** @public Service construction is part of the canonical Effect module API. */
49export const make = Effect.gen(function* () {
50 const crypto = yield* Crypto.Crypto;
51 const statesRef = yield* Ref.make<ReadonlyMap<string, AccountRedemptionState>>(new Map());
52
53 // Get-or-create through one Ref.modify so two first redemptions for the
54 // same account cannot each install their own lock.
55 const stateFor = Effect.fn("CodexResetCreditCoordinator.stateFor")(function* (
56 accountKey: string,
57 ) {
58 const existing = (yield* Ref.get(statesRef)).get(accountKey);
59 if (existing) return existing;
60 const candidate = {
61 lock: yield* Semaphore.make(1),
62 pendingKey: yield* Ref.make<string | null>(null),
63 };
64 return yield* Ref.modify(statesRef, (states) => {
65 const current = states.get(accountKey);
66 if (current) return [current, states] as const;
67 const next = new Map(states);
68 next.set(accountKey, candidate);
69 return [candidate, next] as const;
70 });
71 });
72
73 const redeem: CodexResetCreditCoordinator["Service"]["redeem"] = (accountKey, consume) =>
74 Effect.gen(function* () {
75 const state = yield* stateFor(accountKey);
76 return yield* state.lock.withPermits(1)(
77 Effect.gen(function* () {
78 const existing = yield* Ref.get(state.pendingKey);
79 const idempotencyKey = existing ?? (yield* crypto.randomUUIDv4);
80 yield* Ref.set(state.pendingKey, idempotencyKey);
81 const outcome = yield* consume(idempotencyKey);
82 yield* Ref.set(state.pendingKey, null);
83 return outcome;
84 }),
85 );
86 });
87
88 return { redeem } satisfies CodexResetCreditCoordinator["Service"];
89});
90
91export const layer = Layer.effect(CodexResetCreditCoordinator, make);
92
93/**
94 * Self-contained for tests: a counter-backed Crypto so keys are deterministic
95 * and distinct without the platform layer.
96 */
97export const layerTest = Layer.effect(
98 CodexResetCreditCoordinator,
99 Effect.gen(function* () {
100 let counter = 0;
101 return yield* make.pipe(
102 Effect.provideService(
103 Crypto.Crypto,
104 Crypto.make({
105 randomBytes: (size) => {
106 counter += 1;
107 return new Uint8Array(size).fill(counter);
108 },
109 digest: (_algorithm, data) => Effect.succeed(data),
110 }),
111 ),
112 );
113 }),
114);
apps/server/src/provider/Layers/resetCreditCoordinator.test.ts created+105
......@@ -0,0 +1,105 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as Ref from "effect/Ref";
6
7import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts";
8
9describe("ResetCreditCoordinator", () => {
10 it.effect("re-sends the same idempotency key after a failed attempt, then clears it", () =>
11 Effect.gen(function* () {
12 const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator;
13 const keys = yield* Ref.make<ReadonlyArray<string>>([]);
14 const attempts = yield* Ref.make(0);
15 const consume = (key: string) =>
16 Effect.gen(function* () {
17 yield* Ref.update(keys, (seen) => [...seen, key]);
18 const attempt = yield* Ref.updateAndGet(attempts, (n) => n + 1);
19 if (attempt === 1) return yield* Effect.fail("timed out" as const);
20 return "reset" as const;
21 });
22
23 const first = yield* redeem("acct", consume).pipe(Effect.result);
24 assert.isTrue(first._tag === "Failure");
25 const second = yield* redeem("acct", consume);
26 assert.strictEqual(second, "reset");
27 // A fresh redemption after success must be a fresh attempt.
28 yield* redeem("acct", consume);
29
30 const seen = yield* Ref.get(keys);
31 assert.strictEqual(seen.length, 3);
32 assert.strictEqual(seen[0], seen[1]);
33 assert.notStrictEqual(seen[1], seen[2]);
34 }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)),
35 );
36
37 it.effect("starts a fresh attempt after a settled failure", () =>
38 Effect.gen(function* () {
39 const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator;
40 const keys = yield* Ref.make<ReadonlyArray<string>>([]);
41 const consume = (key: string) =>
42 Ref.update(keys, (seen) => [...seen, key]).pipe(
43 Effect.andThen(Effect.fail("cooldown" as const)),
44 );
45 const isSettled = (error: "cooldown") => error === "cooldown";
46
47 yield* redeem("acct", consume, isSettled).pipe(Effect.result);
48 yield* redeem("acct", consume, isSettled).pipe(Effect.result);
49
50 const seen = yield* Ref.get(keys);
51 assert.strictEqual(seen.length, 2);
52 assert.notStrictEqual(seen[0], seen[1]);
53 }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)),
54 );
55
56 it.effect("serialises concurrent redemptions on the same account, not per caller", () =>
57 Effect.gen(function* () {
58 const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator;
59 const release = yield* Deferred.make<void>();
60 const inFlight = yield* Ref.make(0);
61 const peak = yield* Ref.make(0);
62 const consume = () =>
63 Effect.gen(function* () {
64 const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1);
65 yield* Ref.update(peak, (p) => Math.max(p, now));
66 yield* Deferred.await(release);
67 yield* Ref.update(inFlight, (n) => n - 1);
68 return "reset" as const;
69 });
70
71 // Two instances of the same account redeem at once.
72 const a = yield* redeem("acct", consume).pipe(Effect.forkChild);
73 const b = yield* redeem("acct", consume).pipe(Effect.forkChild);
74 yield* Effect.yieldNow;
75 yield* Deferred.succeed(release, undefined);
76 yield* Fiber.join(a);
77 yield* Fiber.join(b);
78
79 assert.strictEqual(yield* Ref.get(peak), 1);
80 }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)),
81 );
82
83 it.effect("keeps different accounts independent", () =>
84 Effect.gen(function* () {
85 const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator;
86 const release = yield* Deferred.make<void>();
87 const peak = yield* Ref.make(0);
88 const inFlight = yield* Ref.make(0);
89 const consume = () =>
90 Effect.gen(function* () {
91 const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1);
92 yield* Ref.update(peak, (p) => Math.max(p, now));
93 yield* Deferred.await(release);
94 return "reset" as const;
95 });
96 const a = yield* redeem("acct-a", consume).pipe(Effect.forkChild);
97 const b = yield* redeem("acct-b", consume).pipe(Effect.forkChild);
98 yield* Effect.yieldNow;
99 yield* Deferred.succeed(release, undefined);
100 yield* Fiber.join(a);
101 yield* Fiber.join(b);
102 assert.strictEqual(yield* Ref.get(peak), 2);
103 }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)),
104 );
105});
apps/server/src/provider/Layers/resetCreditCoordinator.ts created+111
......@@ -0,0 +1,111 @@
1/**
2 * Redeeming a reset credit is an account-level action: instances that share
3 * the directory holding a provider's login share the credit, so their
4 * redemptions must serialise on that directory, not the instance. This
5 * service keeps one lock and one pending idempotency key per account key so
6 * overlapping confirmations from any instance queue rather than spending two
7 * credits, and a retry after a timeout re-sends the same attempt.
8 *
9 * @module provider/Layers/resetCreditCoordinator
10 */
11import type { ProviderConsumeResetCreditOutcome } from "@t3tools/contracts";
12import * as Context from "effect/Context";
13import * as Crypto from "effect/Crypto";
14import * as Effect from "effect/Effect";
15import * as Layer from "effect/Layer";
16import type * as PlatformError from "effect/PlatformError";
17import * as Ref from "effect/Ref";
18import * as Semaphore from "effect/Semaphore";
19
20interface AccountRedemptionState {
21 readonly lock: Semaphore.Semaphore;
22 readonly pendingKey: Ref.Ref<string | null>;
23}
24
25export class ResetCreditCoordinator extends Context.Service<
26 ResetCreditCoordinator,
27 {
28 /**
29 * Run `consume` under the account's lock with a stable idempotency key.
30 * The key is cleared when the provider reports an outcome, or when
31 * `isSettled` says a failure was a final answer (such as a cooldown).
32 * Any other failure (timeout included) keeps it so the next attempt is
33 * the same attempt.
34 */
35 readonly redeem: <E, R>(
36 accountKey: string,
37 consume: (idempotencyKey: string) => Effect.Effect<ProviderConsumeResetCreditOutcome, E, R>,
38 isSettled?: (error: E) => boolean,
39 ) => Effect.Effect<ProviderConsumeResetCreditOutcome, E | PlatformError.PlatformError, R>;
40 }
41>()("t3/provider/Layers/resetCreditCoordinator") {}
42
43/** @public Service construction is part of the canonical Effect module API. */
44export const make = Effect.gen(function* () {
45 const crypto = yield* Crypto.Crypto;
46 const statesRef = yield* Ref.make<ReadonlyMap<string, AccountRedemptionState>>(new Map());
47
48 // Get-or-create through one Ref.modify so two first redemptions for the
49 // same account cannot each install their own lock.
50 const stateFor = Effect.fn("ResetCreditCoordinator.stateFor")(function* (accountKey: string) {
51 const existing = (yield* Ref.get(statesRef)).get(accountKey);
52 if (existing) return existing;
53 const candidate = {
54 lock: yield* Semaphore.make(1),
55 pendingKey: yield* Ref.make<string | null>(null),
56 };
57 return yield* Ref.modify(statesRef, (states) => {
58 const current = states.get(accountKey);
59 if (current) return [current, states] as const;
60 const next = new Map(states);
61 next.set(accountKey, candidate);
62 return [candidate, next] as const;
63 });
64 });
65
66 const redeem: ResetCreditCoordinator["Service"]["redeem"] = (accountKey, consume, isSettled) =>
67 Effect.gen(function* () {
68 const state = yield* stateFor(accountKey);
69 return yield* state.lock.withPermits(1)(
70 Effect.gen(function* () {
71 const existing = yield* Ref.get(state.pendingKey);
72 const idempotencyKey = existing ?? (yield* crypto.randomUUIDv4);
73 yield* Ref.set(state.pendingKey, idempotencyKey);
74 const outcome = yield* consume(idempotencyKey).pipe(
75 Effect.tapError((error) =>
76 isSettled?.(error) ? Ref.set(state.pendingKey, null) : Effect.void,
77 ),
78 );
79 yield* Ref.set(state.pendingKey, null);
80 return outcome;
81 }),
82 );
83 });
84
85 return { redeem } satisfies ResetCreditCoordinator["Service"];
86});
87
88export const layer = Layer.effect(ResetCreditCoordinator, make);
89
90/**
91 * Self-contained for tests: a counter-backed Crypto so keys are deterministic
92 * and distinct without the platform layer.
93 */
94export const layerTest = Layer.effect(
95 ResetCreditCoordinator,
96 Effect.gen(function* () {
97 let counter = 0;
98 return yield* make.pipe(
99 Effect.provideService(
100 Crypto.Crypto,
101 Crypto.make({
102 randomBytes: (size) => {
103 counter += 1;
104 return new Uint8Array(size).fill(counter);
105 },
106 digest: (_algorithm, data) => Effect.succeed(data),
107 }),
108 ),
109 );
110 }),
111);
apps/server/src/server.ts+2-2
......@@ -46,7 +46,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD
4646import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts";
4747import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts";
4848import * as ModelManifest from "./provider/ModelManifest.ts";
49import * as CodexResetCredit from "./provider/Layers/codexResetCredit.ts";
49import * as ResetCreditCoordinator from "./provider/Layers/resetCreditCoordinator.ts";
5050import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts";
5151import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts";
5252import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.ts";
......@@ -523,7 +523,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
523523 // from the repo's `model-manifest.json` on `main` and applied by the
524524 // Codex/Claude drivers.
525525 Layer.provideMerge(
526 Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer, CodexResetCredit.layer),
526 Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer, ResetCreditCoordinator.layer),
527527 ),
528528 // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old
529529 // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but
docs/user/usage.md+4-2
......@@ -53,8 +53,10 @@ first, or by the first available window when no account reports a 5-hour limit.
5353account does not report that window. When the provider reports reset times, the card also says
5454when the next reset lands and how much it hands back. The hatched
5555part of a segment is what that reset restores. Tap a segment or account row for the account's plan,
56where it is signed in, and its reset time. On web, you can hover too. Codex accounts with banked
57reset credits show a ticket count and the **Use reset** action in the account details. On narrow screens, numbered rows below
56where it is signed in, and its reset time. On web, you can hover too. Codex and Claude accounts
57with banked reset credits show a ticket count and the **Use reset** action in the account details.
58Claude resets are not available when the server runs on macOS, where Claude keeps its login in the
59Keychain. On narrow screens, numbered rows below
5860the bar show each account's quota, countdown, and credits. Tap a row to open its details.
5961
6062The same account signed in on more than one environment, or reported by a hub as well, counts once.