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";...@@ -31,6 +31,8 @@ import { ServerSettingsService } from "../../serverSettings.ts";
31import { ProviderDriverError } from "../Errors.ts";31import { ProviderDriverError } from "../Errors.ts";
32import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts";32import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts";
33import { makeClaudeScopedLimitNames } from "../Layers/claudeUsageLimits.ts";33import { makeClaudeScopedLimitNames } from "../Layers/claudeUsageLimits.ts";
34import * as ClaudeResetCredits from "../Layers/claudeResetCredits.ts";
35import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts";
34import {36import {
35 checkClaudeProviderStatus,37 checkClaudeProviderStatus,
36 makePendingClaudeProvider,38 makePendingClaudeProvider,
...@@ -59,7 +61,11 @@ import {...@@ -59,7 +61,11 @@ import {
59 makeProviderSnapshotSettingsSource,61 makeProviderSnapshotSettingsSource,
60 type ProviderSnapshotSettings,62 type ProviderSnapshotSettings,
61} from "../providerUpdateSettings.ts";63} from "../providerUpdateSettings.ts";
62import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts";64import {
65 makeClaudeCapabilitiesCacheKey,
66 makeClaudeContinuationGroupKey,
67 resolveClaudeHomePath,
68} from "./ClaudeHome.ts";
63import { discoverClaudeSkills } from "./ClaudeSkills.ts";69import { discoverClaudeSkills } from "./ClaudeSkills.ts";
64const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);70const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
6571
...@@ -87,6 +93,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({...@@ -87,6 +93,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({
87export type ClaudeDriverEnv =93export type ClaudeDriverEnv =
88 | BackgroundPolicy.BackgroundPolicy94 | BackgroundPolicy.BackgroundPolicy
89 | ChildProcessSpawner.ChildProcessSpawner95 | ChildProcessSpawner.ChildProcessSpawner
96 | ResetCreditCoordinator.ResetCreditCoordinator
90 | Crypto.Crypto97 | Crypto.Crypto
91 | FileSystem.FileSystem98 | FileSystem.FileSystem
92 | HttpClient.HttpClient99 | HttpClient.HttpClient
...@@ -111,6 +118,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {...@@ -111,6 +118,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
111 const path = yield* Path.Path;118 const path = yield* Path.Path;
112 const { cwd } = yield* ServerConfig;119 const { cwd } = yield* ServerConfig;
113 const httpClient = yield* HttpClient.HttpClient;120 const httpClient = yield* HttpClient.HttpClient;
121 const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator;
114 const serverSettings = yield* ServerSettingsService;122 const serverSettings = yield* ServerSettingsService;
115 const eventLoggers = yield* ProviderEventLoggers;123 const eventLoggers = yield* ProviderEventLoggers;
116 const modelManifest = yield* ModelManifest.ModelManifest;124 const modelManifest = yield* ModelManifest.ModelManifest;
...@@ -139,6 +147,12 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {...@@ -139,6 +147,12 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
139 effectiveConfig,147 effectiveConfig,
140 processEnv,148 processEnv,
141 );149 );
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 );
142 const stampIdentity = withInstanceIdentity({156 const stampIdentity = withInstanceIdentity({
143 instanceId,157 instanceId,
144 driverKind: DRIVER_KIND,158 driverKind: DRIVER_KIND,
...@@ -193,6 +207,12 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {...@@ -193,6 +207,12 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
193 cwd,207 cwd,
194 resolveClaudeModelCatalog(manifest),208 resolveClaudeModelCatalog(manifest),
195 scopedLimitNames,209 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 ),
196 ),216 ),
197 ),217 ),
198 Effect.map(stampIdentity),218 Effect.map(stampIdentity),
...@@ -250,6 +270,68 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {...@@ -250,6 +270,68 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
250 Effect.provideService(Path.Path, path),270 Effect.provideService(Path.Path, path),
251 );271 );
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
253 return {335 return {
254 instanceId,336 instanceId,
255 driverKind: DRIVER_KIND,337 driverKind: DRIVER_KIND,
...@@ -265,6 +347,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {...@@ -265,6 +347,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
265 snapshotForCwd,347 snapshotForCwd,
266 adapter,348 adapter,
267 textGeneration,349 textGeneration,
350 consumeResetCredit,
268 } satisfies ProviderInstance;351 } satisfies ProviderInstance;
269 }),352 }),
270};353};
apps/server/src/provider/Drivers/CodexDriver.test.ts+2-2
...@@ -17,7 +17,7 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne...@@ -17,7 +17,7 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne
17import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";17import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
18import { ServerConfig } from "../../config.ts";18import { ServerConfig } from "../../config.ts";
19import { ServerSettingsService } from "../../serverSettings.ts";19import { ServerSettingsService } from "../../serverSettings.ts";
20import { layerTest as codexResetCreditLayerTest } from "../Layers/codexResetCredit.ts";20import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts";
21import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";21import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
22import * as ModelManifest from "../ModelManifest.ts";22import * as ModelManifest from "../ModelManifest.ts";
23import {23import {
...@@ -33,7 +33,7 @@ const testLayer = ServerConfig.layerTest(process.cwd(), {...@@ -33,7 +33,7 @@ const testLayer = ServerConfig.layerTest(process.cwd(), {
33 Layer.provideMerge(NodeServices.layer),33 Layer.provideMerge(NodeServices.layer),
34 Layer.provideMerge(ServerSettingsService.layerTest()),34 Layer.provideMerge(ServerSettingsService.layerTest()),
35 Layer.provideMerge(ModelManifest.layerTest),35 Layer.provideMerge(ModelManifest.layerTest),
36 Layer.provideMerge(codexResetCreditLayerTest),36 Layer.provideMerge(ResetCreditCoordinator.layerTest),
37 Layer.provideMerge(37 Layer.provideMerge(
38 Layer.mock(BackgroundPolicy.BackgroundPolicy)({38 Layer.mock(BackgroundPolicy.BackgroundPolicy)({
39 shouldRunScopeWork: () => Effect.succeed(false),39 shouldRunScopeWork: () => Effect.succeed(false),
apps/server/src/provider/Drivers/CodexDriver.ts+4-7
...@@ -37,10 +37,7 @@ import { expandHomePath } from "../../pathExpansion.ts";...@@ -37,10 +37,7 @@ import { expandHomePath } from "../../pathExpansion.ts";
37import { ServerSettingsService } from "../../serverSettings.ts";37import { ServerSettingsService } from "../../serverSettings.ts";
38import { ProviderDriverError } from "../Errors.ts";38import { ProviderDriverError } from "../Errors.ts";
39import { makeCodexAdapter } from "../Layers/CodexAdapter.ts";39import { makeCodexAdapter } from "../Layers/CodexAdapter.ts";
40import {40import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts";
41 CODEX_RESET_CREDIT_TIMEOUT,
42 CodexResetCreditCoordinator,
43} from "../Layers/codexResetCredit.ts";
44import {41import {
45 checkCodexProviderStatus,42 checkCodexProviderStatus,
46 makePendingCodexProvider,43 makePendingCodexProvider,
...@@ -106,7 +103,7 @@ function makeCodexMaintenanceResolver(sharedHomePath: string) {...@@ -106,7 +103,7 @@ function makeCodexMaintenanceResolver(sharedHomePath: string) {
106export type CodexDriverEnv =103export type CodexDriverEnv =
107 | BackgroundPolicy.BackgroundPolicy104 | BackgroundPolicy.BackgroundPolicy
108 | ChildProcessSpawner.ChildProcessSpawner105 | ChildProcessSpawner.ChildProcessSpawner
109 | CodexResetCreditCoordinator106 | ResetCreditCoordinator.ResetCreditCoordinator
110 | Crypto.Crypto107 | Crypto.Crypto
111 | FileSystem.FileSystem108 | FileSystem.FileSystem
112 | HttpClient.HttpClient109 | HttpClient.HttpClient
...@@ -127,7 +124,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {...@@ -127,7 +124,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
127 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>124 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
128 Effect.gen(function* () {125 Effect.gen(function* () {
129 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;126 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
130 const resetCreditCoordinator = yield* CodexResetCreditCoordinator;127 const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator;
131 const fileSystem = yield* FileSystem.FileSystem;128 const fileSystem = yield* FileSystem.FileSystem;
132 const pathService = yield* Path.Path;129 const pathService = yield* Path.Path;
133 const httpClient = yield* HttpClient.HttpClient;130 const httpClient = yield* HttpClient.HttpClient;
...@@ -299,7 +296,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {...@@ -299,7 +296,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
299 idempotencyKey,296 idempotencyKey,
300 });297 });
301 return response.outcome;298 return response.outcome;
302 }).pipe(Effect.scoped, Effect.timeout(CODEX_RESET_CREDIT_TIMEOUT)),299 }).pipe(Effect.scoped, Effect.timeout("20 seconds")),
303 )300 )
304 .pipe(301 .pipe(
305 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),302 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
apps/server/src/provider/Layers/ClaudeProvider.ts+11-1
...@@ -2,6 +2,7 @@ import {...@@ -2,6 +2,7 @@ import {
2 type ClaudeSettings,2 type ClaudeSettings,
3 type ModelCapabilities,3 type ModelCapabilities,
4 type ServerProviderSlashCommand,4 type ServerProviderSlashCommand,
5 type ServerProviderResetCredits,
5} from "@t3tools/contracts";6} from "@t3tools/contracts";
6import * as DateTime from "effect/DateTime";7import * as DateTime from "effect/DateTime";
7import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
...@@ -426,6 +427,8 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(...@@ -426,6 +427,8 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
426 modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG,427 modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG,
427 /** Shared with the adapter so turn events reuse the scoped-bucket names this probe saw. */428 /** Shared with the adapter so turn events reuse the scoped-bucket names this probe saw. */
428 scopedLimitNames?: Ref.Ref<ClaudeScopedLimitNames>,429 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>,
429): Effect.fn.Return<432): Effect.fn.Return<
430 ServerProviderDraft,433 ServerProviderDraft,
431 never,434 never,
...@@ -568,6 +571,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(...@@ -568,6 +571,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
568 checkedAt,571 checkedAt,
569 })572 })
570 : claudeUsageResponseToLimits({ response: capabilities.usage, checkedAt }).limits;573 : 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;
571 return buildServerProvider({581 return buildServerProvider({
572 presentation: CLAUDE_PRESENTATION,582 presentation: CLAUDE_PRESENTATION,
573 enabled: claudeSettings.enabled,583 enabled: claudeSettings.enabled,
...@@ -585,7 +595,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(...@@ -585,7 +595,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
585 ...(authMetadata ? authMetadata : {}),595 ...(authMetadata ? authMetadata : {}),
586 },596 },
587 ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}),597 ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}),
588 usageLimits,598 usageLimits: resetCredits ? { ...usageLimits, resetCredits } : usageLimits,
589 },599 },
590 });600 });
591});601});
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+115-5
...@@ -34,7 +34,7 @@ import {...@@ -34,7 +34,7 @@ import {
34 type ProviderInstanceConfigMap,34 type ProviderInstanceConfigMap,
35 ProviderInstanceId,35 ProviderInstanceId,
36} from "@t3tools/contracts";36} from "@t3tools/contracts";
37import { isHostWindows } from "@t3tools/shared/hostProcess";37import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess";
38import * as DateTime from "effect/DateTime";38import * as DateTime from "effect/DateTime";
39import * as Effect from "effect/Effect";39import * as Effect from "effect/Effect";
40import * as FileSystem from "effect/FileSystem";40import * as FileSystem from "effect/FileSystem";
...@@ -56,7 +56,7 @@ import { GrokDriver } from "../Drivers/GrokDriver.ts";...@@ -56,7 +56,7 @@ import { GrokDriver } from "../Drivers/GrokDriver.ts";
56import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts";56import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts";
57import * as ModelManifest from "../ModelManifest.ts";57import * as ModelManifest from "../ModelManifest.ts";
58import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts";58import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts";
59import * as CodexResetCredit from "./codexResetCredit.ts";59import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts";
60import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts";60import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts";
61import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts";61import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts";
6262
...@@ -178,6 +178,7 @@ const makeTildeProviderFixtures = Effect.fn(...@@ -178,6 +178,7 @@ const makeTildeProviderFixtures = Effect.fn(
178 claudePath,178 claudePath,
179 [179 [
180 "#!/usr/bin/env node",180 "#!/usr/bin/env node",
181 'import { existsSync } from "node:fs";',
181 'import * as NodeReadline from "node:readline";',182 'import * as NodeReadline from "node:readline";',
182 'if (process.argv.includes("--version")) {',183 'if (process.argv.includes("--version")) {',
183 ' process.stdout.write("claude 2.1.219\\n");',184 ' process.stdout.write("claude 2.1.219\\n");',
...@@ -186,7 +187,26 @@ const makeTildeProviderFixtures = Effect.fn(...@@ -186,7 +187,26 @@ const makeTildeProviderFixtures = Effect.fn(
186 "const lines = NodeReadline.createInterface({ input: process.stdin });",187 "const lines = NodeReadline.createInterface({ input: process.stdin });",
187 'lines.on("line", (line) => {',188 'lines.on("line", (line) => {',
188 " const message = JSON.parse(line);",189 " 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;',
190 " process.stdout.write(JSON.stringify({",210 " process.stdout.write(JSON.stringify({",
191 ' type: "control_response",',211 ' type: "control_response",',
192 " response: {",212 " response: {",
...@@ -231,7 +251,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {...@@ -231,7 +251,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
231 Layer.provideMerge(TestHttpClientLive),251 Layer.provideMerge(TestHttpClientLive),
232 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),252 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
233 Layer.provideMerge(ModelManifest.layerTest),253 Layer.provideMerge(ModelManifest.layerTest),
234 Layer.provideMerge(CodexResetCredit.layerTest),254 Layer.provideMerge(ResetCreditCoordinator.layerTest),
235 );255 );
236256
237 it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () =>257 it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () =>
...@@ -430,6 +450,96 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {...@@ -430,6 +450,96 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
430 }).pipe(Effect.provide(testLayer)),450 }).pipe(Effect.provide(testLayer)),
431 );451 );
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
433 it.live(543 it.live(
434 "shadows instances whose driver is not registered in this build without failing boot",544 "shadows instances whose driver is not registered in this build without failing boot",
435 () =>545 () =>
...@@ -497,7 +607,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {...@@ -497,7 +607,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
497 Layer.provideMerge(TestHttpClientLive),607 Layer.provideMerge(TestHttpClientLive),
498 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),608 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
499 Layer.provideMerge(ModelManifest.layerTest),609 Layer.provideMerge(ModelManifest.layerTest),
500 Layer.provideMerge(CodexResetCredit.layerTest),610 Layer.provideMerge(ResetCreditCoordinator.layerTest),
501 );611 );
502612
503 it.live("boots one instance of every shipped driver from a single config map", () =>613 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";...@@ -39,7 +39,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
39import { AntigravityInstallation } from "../AntigravityInstallation.ts";39import { AntigravityInstallation } from "../AntigravityInstallation.ts";
40import * as ModelManifest from "../ModelManifest.ts";40import * as ModelManifest from "../ModelManifest.ts";
41import { applyProviderCompatibility } from "../providerCompatibility.ts";41import { applyProviderCompatibility } from "../providerCompatibility.ts";
42import * as CodexResetCredit from "./codexResetCredit.ts";42import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts";
43import * as OpenCodeRuntime from "../opencodeRuntime.ts";43import * as OpenCodeRuntime from "../opencodeRuntime.ts";
44import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";44import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";
45import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts";45import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts";
...@@ -2321,7 +2321,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -2321,7 +2321,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
2321 ),2321 ),
2322 ),2322 ),
2323 Layer.provideMerge(ModelManifest.layerTest),2323 Layer.provideMerge(ModelManifest.layerTest),
2324 Layer.provideMerge(CodexResetCredit.layerTest),2324 Layer.provideMerge(ResetCreditCoordinator.layerTest),
2325 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),2325 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
2326 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),2326 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
2327 // NO spawner mock — `ChildProcessSpawner` is supplied by the2327 // NO spawner mock — `ChildProcessSpawner` is supplied by the
...@@ -2420,7 +2420,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -2420,7 +2420,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
2420 ),2420 ),
2421 ),2421 ),
2422 Layer.provideMerge(ModelManifest.layerTest),2422 Layer.provideMerge(ModelManifest.layerTest),
2423 Layer.provideMerge(CodexResetCredit.layerTest),2423 Layer.provideMerge(ResetCreditCoordinator.layerTest),
2424 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),2424 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
2425 Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) =>2425 Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) =>
2426 ChildProcessSpawner.make((command) => {2426 ChildProcessSpawner.make((command) => {
...@@ -2536,7 +2536,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -2536,7 +2536,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
2536 ),2536 ),
2537 ),2537 ),
2538 Layer.provideMerge(ModelManifest.layerTest),2538 Layer.provideMerge(ModelManifest.layerTest),
2539 Layer.provideMerge(CodexResetCredit.layerTest),2539 Layer.provideMerge(ResetCreditCoordinator.layerTest),
2540 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),2540 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
2541 Layer.provideMerge(NodeServices.layer),2541 Layer.provideMerge(NodeServices.layer),
2542 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),2542 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
...@@ -2598,8 +2598,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -2598,8 +2598,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
2598 ),2598 ),
2599 ),2599 ),
2600 Layer.provideMerge(ModelManifest.layerTest),2600 Layer.provideMerge(ModelManifest.layerTest),
2601 Layer.provideMerge(CodexResetCredit.layerTest),2601 Layer.provideMerge(ResetCreditCoordinator.layerTest),
2602 Layer.provideMerge(CodexResetCredit.layerTest),
2603 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),2602 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
2604 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),2603 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
2605 Layer.provideMerge(2604 Layer.provideMerge(
...@@ -2752,6 +2751,42 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -2752,6 +2751,42 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
2752 ),2751 ),
2753 );2752 );
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
2755 it.effect("does not duplicate Claude in full subscription labels", () =>2790 it.effect("does not duplicate Claude in full subscription labels", () =>
2756 Effect.gen(function* () {2791 Effect.gen(function* () {
2757 const status = yield* checkClaudeProviderStatus(2792 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...@@ -46,7 +46,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD
46import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts";46import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts";
47import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts";47import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts";
48import * as ModelManifest from "./provider/ModelManifest.ts";48import * as ModelManifest from "./provider/ModelManifest.ts";
49import * as CodexResetCredit from "./provider/Layers/codexResetCredit.ts";49import * as ResetCreditCoordinator from "./provider/Layers/resetCreditCoordinator.ts";
50import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts";50import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts";
51import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts";51import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts";
52import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.ts";52import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.ts";
...@@ -523,7 +523,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(...@@ -523,7 +523,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
523 // from the repo's `model-manifest.json` on `main` and applied by the523 // from the repo's `model-manifest.json` on `main` and applied by the
524 // Codex/Claude drivers.524 // Codex/Claude drivers.
525 Layer.provideMerge(525 Layer.provideMerge(
526 Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer, CodexResetCredit.layer),526 Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer, ResetCreditCoordinator.layer),
527 ),527 ),
528 // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old528 // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old
529 // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but529 // `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....@@ -53,8 +53,10 @@ first, or by the first available window when no account reports a 5-hour limit.
53account does not report that window. When the provider reports reset times, the card also says53account does not report that window. When the provider reports reset times, the card also says
54when the next reset lands and how much it hands back. The hatched54when the next reset lands and how much it hands back. The hatched
55part of a segment is what that reset restores. Tap a segment or account row for the account's plan,55part 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 banked56where it is signed in, and its reset time. On web, you can hover too. Codex and Claude accounts
57reset credits show a ticket count and the **Use reset** action in the account details. On narrow screens, numbered rows below57with 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
58the bar show each account's quota, countdown, and credits. Tap a row to open its details.60the bar show each account's quota, countdown, and credits. Tap a row to open its details.
5961
60The same account signed in on more than one environment, or reported by a hub as well, counts once.62The same account signed in on more than one environment, or reported by a hub as well, counts once.