diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 48f96b7fe433d2e481616a8d2664fbc5bccc3749..b0d7ec6d3ba6481121fa84db25112c3161f62a34 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -31,6 +31,8 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; import { makeClaudeScopedLimitNames } from "../Layers/claudeUsageLimits.ts"; +import * as ClaudeResetCredits from "../Layers/claudeResetCredits.ts"; +import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts"; import { checkClaudeProviderStatus, makePendingClaudeProvider, @@ -59,7 +61,11 @@ import { makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, } from "../providerUpdateSettings.ts"; -import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; +import { + makeClaudeCapabilitiesCacheKey, + makeClaudeContinuationGroupKey, + resolveClaudeHomePath, +} from "./ClaudeHome.ts"; import { discoverClaudeSkills } from "./ClaudeSkills.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); @@ -87,6 +93,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ export type ClaudeDriverEnv = | BackgroundPolicy.BackgroundPolicy | ChildProcessSpawner.ChildProcessSpawner + | ResetCreditCoordinator.ResetCreditCoordinator | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient @@ -111,6 +118,7 @@ export const ClaudeDriver: ProviderDriver = { const path = yield* Path.Path; const { cwd } = yield* ServerConfig; const httpClient = yield* HttpClient.HttpClient; + const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; const modelManifest = yield* ModelManifest.ModelManifest; @@ -139,6 +147,12 @@ export const ClaudeDriver: ProviderDriver = { effectiveConfig, processEnv, ); + const configDir = yield* resolveClaudeHomePath(effectiveConfig, processEnv); + const accountConfigPath = yield* ClaudeResetCredits.claudeAccountConfigPath( + effectiveConfig.homePath.trim() || processEnv.CLAUDE_CONFIG_DIR?.trim() + ? configDir + : undefined, + ); const stampIdentity = withInstanceIdentity({ instanceId, driverKind: DRIVER_KIND, @@ -193,6 +207,12 @@ export const ClaudeDriver: ProviderDriver = { cwd, resolveClaudeModelCatalog(manifest), scopedLimitNames, + (version) => + ClaudeResetCredits.readClaudeResetCredits(configDir, version).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), ), ), Effect.map(stampIdentity), @@ -250,6 +270,68 @@ export const ClaudeDriver: ProviderDriver = { Effect.provideService(Path.Path, path), ); + // Same rules as Codex: serialised on the config directory that holds the + // login, one request id kept until Claude answers (a cooldown or rate + // limit is an answer), then a re-probe. + const consumeResetCredit: NonNullable = () => + Effect.gen(function* () { + const current = yield* snapshot.getSnapshot; + const grantId = current.usageLimits?.resetCredits?.nextCreditId; + if (!grantId || !current.version) return "noCredit" as const; + const version = current.version; + return yield* resetCreditCoordinator.redeem( + configDir, + (requestId) => + ClaudeResetCredits.consumeClaudeResetCredit({ + configDir, + accountConfigPath, + version, + grantId, + requestId, + }), + ClaudeResetCredits.isSettledClaudeResetCreditFailure, + ); + }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: + cause._tag === "ClaudeResetCreditError" + ? cause.message + : "Claude could not redeem the reset.", + cause, + }), + ), + // Re-probe after any answer, but only a reset claims the limits + // changed, so only a reset reports an unconfirmed refresh. + Effect.tap((outcome) => + Effect.gen(function* () { + const before = (yield* snapshot.getSnapshot).usageLimits?.checkedAt; + yield* Cache.invalidateAll(capabilitiesProbeCache); + const refreshed = yield* snapshot.refresh; + const after = refreshed.usageLimits?.checkedAt; + if ( + outcome === "reset" && + (after === undefined || + after === before || + refreshed.usageLimits?.unavailable?.reason === "probeFailed") + ) { + return yield* new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: + "The reset was applied, but Claude could not confirm the new limits. Refresh to check.", + }); + } + }), + ), + ); + return { instanceId, driverKind: DRIVER_KIND, @@ -265,6 +347,7 @@ export const ClaudeDriver: ProviderDriver = { snapshotForCwd, adapter, textGeneration, + consumeResetCredit, } satisfies ProviderInstance; }), }; diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index bac34db452fde28729dee5cf2c4e037fdd3cca2c..246ef79515d30874833eb690e0294e81a6531d67 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -17,7 +17,7 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { layerTest as codexResetCreditLayerTest } from "../Layers/codexResetCredit.ts"; +import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import * as ModelManifest from "../ModelManifest.ts"; import { @@ -33,7 +33,7 @@ const testLayer = ServerConfig.layerTest(process.cwd(), { Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(codexResetCreditLayerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), Layer.provideMerge( Layer.mock(BackgroundPolicy.BackgroundPolicy)({ shouldRunScopeWork: () => Effect.succeed(false), diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 9f1bbc2dfe5b4d7211aa56b31d66e63bde98cbad..52f7b47e1e1d01041bd61546b7978c16fc66e749 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -37,10 +37,7 @@ import { expandHomePath } from "../../pathExpansion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; -import { - CODEX_RESET_CREDIT_TIMEOUT, - CodexResetCreditCoordinator, -} from "../Layers/codexResetCredit.ts"; +import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts"; import { checkCodexProviderStatus, makePendingCodexProvider, @@ -106,7 +103,7 @@ function makeCodexMaintenanceResolver(sharedHomePath: string) { export type CodexDriverEnv = | BackgroundPolicy.BackgroundPolicy | ChildProcessSpawner.ChildProcessSpawner - | CodexResetCreditCoordinator + | ResetCreditCoordinator.ResetCreditCoordinator | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient @@ -127,7 +124,7 @@ export const CodexDriver: ProviderDriver = { create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const resetCreditCoordinator = yield* CodexResetCreditCoordinator; + const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator; const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; @@ -299,7 +296,7 @@ export const CodexDriver: ProviderDriver = { idempotencyKey, }); return response.outcome; - }).pipe(Effect.scoped, Effect.timeout(CODEX_RESET_CREDIT_TIMEOUT)), + }).pipe(Effect.scoped, Effect.timeout("20 seconds")), ) .pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 62d7444c696885d5f58f22425deee0f3ae02aa44..db06557c7c8e7c40dc969a603249e6a5561ea27f 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -2,6 +2,7 @@ import { type ClaudeSettings, type ModelCapabilities, type ServerProviderSlashCommand, + type ServerProviderResetCredits, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -426,6 +427,8 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, /** Shared with the adapter so turn events reuse the scoped-bucket names this probe saw. */ scopedLimitNames?: Ref.Ref, + /** Banked resets for a subscription login, given the CLI version for the user agent. */ + resolveResetCredits?: (version: string) => Effect.Effect, ): Effect.fn.Return< ServerProviderDraft, never, @@ -568,6 +571,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( checkedAt, }) : claudeUsageResponseToLimits({ response: capabilities.usage, checkedAt }).limits; + const resetCredits = + resolveResetCredits && + capabilities.subscriptionType && + !usageLimits.unavailable && + parsedVersion + ? yield* resolveResetCredits(parsedVersion) + : undefined; return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, @@ -585,7 +595,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ...(authMetadata ? authMetadata : {}), }, ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), - usageLimits, + usageLimits: resetCredits ? { ...usageLimits, resetCredits } : usageLimits, }, }); }); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 059e8c0e2cdff757d06b2ffe4aa5400570a2fdfe..c33b1dfc690a6b88e0d2e437c7a16c504d49a9b2 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -34,7 +34,7 @@ import { type ProviderInstanceConfigMap, ProviderInstanceId, } from "@t3tools/contracts"; -import { isHostWindows } from "@t3tools/shared/hostProcess"; +import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -56,7 +56,7 @@ import { GrokDriver } from "../Drivers/GrokDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; import * as ModelManifest from "../ModelManifest.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; -import * as CodexResetCredit from "./codexResetCredit.ts"; +import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; @@ -178,6 +178,7 @@ const makeTildeProviderFixtures = Effect.fn( claudePath, [ "#!/usr/bin/env node", + 'import { existsSync } from "node:fs";', 'import * as NodeReadline from "node:readline";', 'if (process.argv.includes("--version")) {', ' process.stdout.write("claude 2.1.219\\n");', @@ -186,7 +187,26 @@ const makeTildeProviderFixtures = Effect.fn( "const lines = NodeReadline.createInterface({ input: process.stdin });", 'lines.on("line", (line) => {', " const message = JSON.parse(line);", - ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', + ' if (message.type !== "control_request") return;', + ' if (message.request?.subtype === "get_usage") {', + " const marker = process.env.T3_CLAUDE_RESET_MARKER;", + " if (process.env.T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM && marker && existsSync(marker)) {", + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + ' response: { subtype: "error", request_id: message.request_id, error: "usage failed" },', + ' }) + "\\n");', + " return;", + " }", + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + ' response: { subtype: "success", request_id: message.request_id, response: {', + ' session: {}, subscription_type: "pro", rate_limits_available: true,', + " rate_limits: { five_hour: { utilization: marker && existsSync(marker) ? 0 : 100, resets_at: null } },", + " } },", + ' }) + "\\n");', + " return;", + " }", + ' if (message.request?.subtype !== "initialize") return;', " process.stdout.write(JSON.stringify({", ' type: "control_response",', " response: {", @@ -231,7 +251,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(CodexResetCredit.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), ); it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => @@ -430,6 +450,96 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }).pipe(Effect.provide(testLayer)), ); + const redeemClaudeReset = (claim: { result: string; usageFailsAfterClaim: boolean }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixtures = yield* makeTildeProviderFixtures(); + const marker = path.join(fixtures.claudeHomePath, "redeemed"); + yield* fs.writeFileString( + path.join(fixtures.claudeHomePath, ".credentials.json"), + '{"claudeAiOauth":{"accessToken":"fake-token"}}', + ); + yield* fs.writeFileString( + path.join(fixtures.claudeHomePath, ".claude.json"), + '{"oauthAccount":{"organizationUuid":"fake-org"}}', + ); + const client = HttpClient.make((request) => + Effect.gen(function* () { + if (request.url.endsWith("/api/oauth/usage")) { + return HttpClientResponse.fromWeb( + request, + Response.json({ + cedar_ember: { + eligible: true, + next_grant_id: "grant_a", + grants: [{ id: "grant_a", resets_left: 1, usable_now: true }], + }, + }), + ); + } + if (request.url.endsWith("/reset_rate_limits")) { + yield* fs.writeFileString(marker, "redeemed").pipe(Effect.orDie); + return HttpClientResponse.fromWeb(request, Response.json({ result: claim.result })); + } + return HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" })); + }), + ); + const instanceId = ProviderInstanceId.make("claude_reset"); + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [ClaudeDriver], + configMap: { + [instanceId]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + environment: [ + { name: "T3_CLAUDE_RESET_MARKER", value: marker, sensitive: false }, + ...(claim.usageFailsAfterClaim + ? [{ name: "T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM", value: "1", sensitive: false }] + : []), + ], + config: makeClaudeConfig({ + enabled: true, + binaryPath: fixtures.claudeBinaryPath, + homePath: fixtures.claudeHomePath, + }), + }, + }, + }).pipe(Effect.provideService(HttpClient.HttpClient, client)); + const instance = yield* registry.getInstance(instanceId); + expect(instance).toBeDefined(); + const before = yield* instance!.snapshot.refresh; + expect(before.usageLimits?.windows[0]?.usedPercent).toBe(100); + expect(before.usageLimits?.resetCredits?.nextCreditId).toBe("grant_a"); + const outcome = yield* instance!.consumeResetCredit!().pipe(Effect.result); + return { outcome, after: yield* instance!.snapshot.getSnapshot }; + }).pipe( + // macOS logins live in the Keychain, where resets are never read. + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provide(testLayer), + ); + + it.live("refreshes Claude usage after redeeming a reset", () => + Effect.gen(function* () { + const { outcome, after } = yield* redeemClaudeReset({ + result: "reset", + usageFailsAfterClaim: false, + }); + expect(outcome).toMatchObject({ _tag: "Success", success: "reset" }); + expect(after.usageLimits?.windows[0]?.usedPercent).toBe(0); + }), + ); + + it.live("reports Claude's answer when a claim changed nothing and the re-probe fails", () => + Effect.gen(function* () { + const { outcome } = yield* redeemClaudeReset({ + result: "already_used", + usageFailsAfterClaim: true, + }); + expect(outcome).toMatchObject({ _tag: "Success", success: "alreadyRedeemed" }); + }), + ); + it.live( "shadows instances whose driver is not registered in this build without failing boot", () => @@ -497,7 +607,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(CodexResetCredit.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), ); it.live("boots one instance of every shipped driver from a single config map", () => diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 9ddf54deb3afd685551b01e3e271818dd4f1aa47..ee1a23573277ca5481c1524fa24cbe178d99afbb 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -39,7 +39,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { AntigravityInstallation } from "../AntigravityInstallation.ts"; import * as ModelManifest from "../ModelManifest.ts"; import { applyProviderCompatibility } from "../providerCompatibility.ts"; -import * as CodexResetCredit from "./codexResetCredit.ts"; +import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; @@ -2321,7 +2321,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ), Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(CodexResetCredit.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), // NO spawner mock — `ChildProcessSpawner` is supplied by the @@ -2420,7 +2420,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ), Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(CodexResetCredit.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => ChildProcessSpawner.make((command) => { @@ -2536,7 +2536,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ), Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(CodexResetCredit.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), @@ -2598,8 +2598,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ), Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(CodexResetCredit.layerTest), - Layer.provideMerge(CodexResetCredit.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge( @@ -2752,6 +2751,42 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + it.effect("reads banked resets only for subscription logins", () => + Effect.gen(function* () { + const check = (overrides: Partial) => + checkClaudeProviderStatus( + defaultClaudeSettings, + () => + Effect.succeed({ + email: undefined, + subscriptionType: undefined, + tokenSource: undefined, + apiProvider: undefined, + slashCommands: [], + usage: { rate_limits_available: true, rate_limits: {} }, + ...overrides, + }), + undefined, + undefined, + undefined, + undefined, + () => Effect.succeed({ availableCount: 2 }), + ); + const subscription = yield* check({ subscriptionType: "max" }); + const bedrock = yield* check({ apiProvider: "bedrock" }); + assert.deepStrictEqual(subscription.usageLimits?.resetCredits, { availableCount: 2 }); + assert.strictEqual(bedrock.usageLimits?.resetCredits, undefined); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("does not duplicate Claude in full subscription labels", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/apps/server/src/provider/Layers/claudeResetCredits.test.ts b/apps/server/src/provider/Layers/claudeResetCredits.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..17f9a03c48bdf88088fd28582f965986dff3066e --- /dev/null +++ b/apps/server/src/provider/Layers/claudeResetCredits.test.ts @@ -0,0 +1,259 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it as effectIt } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse, UrlParams } from "effect/unstable/http"; +import { describe, expect, it } from "vite-plus/test"; + +import * as ClaudeResetCredits from "./claudeResetCredits.ts"; + +const NOW = Date.parse("2026-09-22T12:00:00.000Z"); +const grant = (overrides: Record) => ({ + id: "grant_a", + resets_left: 1, + usable_now: true, + ...overrides, +}); + +const writeLogin = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + yield* fs.writeFileString( + path.join(directory, ".credentials.json"), + '{"claudeAiOauth":{"accessToken":"oauth-token"}}', + ); + const accountConfigPath = path.join(directory, ".claude.json"); + yield* fs.writeFileString(accountConfigPath, '{"oauthAccount":{"organizationUuid":"org-1"}}'); + return { configDir: directory, accountConfigPath }; +}); + +const respond = (status: number, body: unknown) => + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(body, { status }))), + ); +const refuseRequests = HttpClient.make(() => Effect.die("must not send a request")); + +describe("claudeResetCreditsToContract", () => { + it("counts live grants and pins the next usable one", () => { + expect( + ClaudeResetCredits.claudeResetCreditsToContract( + { + eligible: true, + next_grant_id: "grant_a", + grants: [ + grant({ resets_left: 2, ends_at: "2026-10-01T00:00:00Z" }), + grant({ id: "paused", paused: true }), + grant({ id: "expired", ends_at: "2026-09-01T00:00:00Z" }), + grant({ id: "garbled", ends_at: "not a date" }), + grant({ id: "date_only", ends_at: "2026-10-01" }), + grant({ id: "impossible", ends_at: "2027-02-30T00:00:00Z" }), + grant({ id: "empty", ends_at: "" }), + grant({ id: "Not Valid" }), + grant({ id: "grant_b", resets_left: 3, usable_now: false }), + ], + }, + NOW, + ), + ).toEqual({ + availableCount: 2, + nextCreditId: "grant_a", + nextExpiresAt: "2026-10-01T00:00:00.000Z", + }); + }); + + it("offers nothing to redeem without a usable next grant or an eligible account", () => { + expect( + ClaudeResetCredits.claudeResetCreditsToContract( + { eligible: true, next_grant_id: "grant_a", grants: [grant({ usable_now: false })] }, + NOW, + ), + ).toEqual({ availableCount: 0 }); + expect( + ClaudeResetCredits.claudeResetCreditsToContract({ eligible: true, grants: [grant({})] }, NOW), + ).toEqual({ + availableCount: 0, + }); + expect( + ClaudeResetCredits.claudeResetCreditsToContract( + { eligible: false, grants: [grant({})] }, + NOW, + ), + ).toBeUndefined(); + expect(ClaudeResetCredits.claudeResetCreditsToContract(undefined, NOW)).toBeUndefined(); + }); +}); + +effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { + it.effect("reads the grants with the CLI's request", () => + Effect.gen(function* () { + const { configDir } = yield* writeLogin; + const client = HttpClient.make((request) => { + expect(request.method).toBe("GET"); + expect(request.url).toBe("https://api.anthropic.com/api/oauth/usage"); + expect(UrlParams.toString(request.urlParams)).toBe("cedar_ember=1&skip_spend=1"); + expect(request.headers.authorization).toBe("Bearer oauth-token"); + expect(request.headers["anthropic-beta"]).toBe("oauth-2025-04-20"); + expect(request.headers["user-agent"]).toBe("claude-cli/2.1.0 (external, cli)"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + cedar_ember: { eligible: true, next_grant_id: "grant_a", grants: [grant({})] }, + }), + ), + ); + }); + const credits = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HttpClient.HttpClient, client), + ); + expect(credits).toEqual({ availableCount: 1, nextCreditId: "grant_a" }); + }), + ); + + it.effect("reads nothing from keychain logins or failed requests", () => + Effect.gen(function* () { + const { configDir } = yield* writeLogin; + const darwin = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(HttpClient.HttpClient, refuseRequests), + ); + const limited = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HttpClient.HttpClient, respond(429, {})), + ); + expect([darwin, limited]).toEqual([undefined, undefined]); + }), + ); +}); + +const ClaimBody = Schema.fromJsonString( + Schema.Struct({ program: Schema.String, grant_id: Schema.String, request_id: Schema.String }), +); +const decodeClaimBody = Schema.decodeEffect(ClaimBody); + +const consume = (client: HttpClient.HttpClient, ids = { grantId: "grant_a", requestId: "r-1" }) => + Effect.gen(function* () { + const login = yield* writeLogin; + return yield* ClaudeResetCredits.consumeClaudeResetCredit({ + ...login, + version: "2.1.0", + ...ids, + }).pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HttpClient.HttpClient, client), + Effect.result, + ); + }); + +effectIt.layer(NodeServices.layer)("consumeClaudeResetCredit", (it) => { + it.effect("claims the grant for the organization", () => + Effect.gen(function* () { + const client = HttpClient.make((request) => + Effect.gen(function* () { + expect(request.method).toBe("POST"); + expect(request.url).toBe( + "https://api.anthropic.com/api/organizations/org-1/reset_rate_limits", + ); + expect(request.headers.authorization).toBe("Bearer oauth-token"); + const body = + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""; + expect(yield* decodeClaimBody(body)).toEqual({ + program: "cedar_ember", + grant_id: "grant_a", + request_id: "r-1", + }); + return HttpClientResponse.fromWeb(request, Response.json({ result: "reset" })); + }).pipe(Effect.orDie), + ); + expect(yield* consume(client)).toMatchObject({ _tag: "Success", success: "reset" }); + }), + ); + + it.effect("maps each answer to an outcome or a failure", () => + Effect.gen(function* () { + for (const [result, outcome] of [ + ["not_limited", "nothingToReset"], + ["already_used", "alreadyRedeemed"], + ["ineligible", "noCredit"], + ] as const) { + expect(yield* consume(respond(200, { result }))).toMatchObject({ success: outcome }); + } + for (const client of [ + respond(200, { result: "cooldown" }), + respond(429, {}), + respond(401, {}), + ]) { + const result = yield* consume(client); + expect(result).toMatchObject({ _tag: "Failure" }); + // Claude answered, so a retry must be a new claim. + if (result._tag === "Failure") { + expect(ClaudeResetCredits.isSettledClaudeResetCreditFailure(result.failure)).toBe(true); + } + } + // No answer, or Claude could not confirm the claim: a retry is the same claim. + for (const client of [respond(500, {}), respond(200, { result: "unavailable" })]) { + const unanswered = yield* consume(client); + expect(unanswered).toMatchObject({ _tag: "Failure" }); + if (unanswered._tag === "Failure") { + expect(ClaudeResetCredits.isSettledClaudeResetCreditFailure(unanswered.failure)).toBe( + false, + ); + } + } + }), + ); + + it.effect("times out a stalled claim body", () => + Effect.gen(function* () { + const login = yield* writeLogin; + const readingBody = yield* Deferred.make(); + const client = HttpClient.make((request) => { + const response = HttpClientResponse.fromWeb(request, Response.json({ result: "reset" })); + Object.defineProperty(response, "json", { + value: Deferred.succeed(readingBody, undefined).pipe(Effect.andThen(Effect.never)), + }); + return Effect.succeed(response); + }); + const claim = yield* ClaudeResetCredits.consumeClaudeResetCredit({ + ...login, + version: "2.1.0", + grantId: "grant_a", + requestId: "r-1", + }).pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HttpClient.HttpClient, client), + Effect.result, + Effect.forkChild, + ); + yield* Deferred.await(readingBody); + yield* TestClock.adjust("26 seconds"); + expect(yield* Fiber.join(claim)).toMatchObject({ + _tag: "Failure", + failure: { + _tag: "ClaudeResetCreditError", + reason: "requestFailed", + cause: { _tag: "TimeoutError" }, + }, + }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("refuses malformed ids without sending anything", () => + Effect.gen(function* () { + for (const ids of [ + { grantId: "Bad Grant", requestId: "r-1" }, + { grantId: "grant_a", requestId: "has space" }, + ]) { + expect(yield* consume(refuseRequests, ids)).toMatchObject({ _tag: "Failure" }); + } + }), + ); +}); diff --git a/apps/server/src/provider/Layers/claudeResetCredits.ts b/apps/server/src/provider/Layers/claudeResetCredits.ts new file mode 100644 index 0000000000000000000000000000000000000000..925a25585491ecc9940bf8004362fcae03d76722 --- /dev/null +++ b/apps/server/src/provider/Layers/claudeResetCredits.ts @@ -0,0 +1,270 @@ +/** + * Claude banked resets (the CLI's `cedar_ember` program). The CLI reads the + * grants from the OAuth usage endpoint and claims one against the + * organization; this module does the same with the credentials the CLI keeps + * in its config directory. macOS keeps them in the keychain, so there the + * feature is not offered. + * + * @module provider/Layers/claudeResetCredits + */ +import * as NodeOS from "node:os"; +import type { + ProviderConsumeResetCreditOutcome, + ServerProviderResetCredits, +} from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +const API_BASE = "https://api.anthropic.com"; +const PROGRAM = "cedar_ember"; +const GRANT_ID = /^[a-z0-9_-]{1,40}$/; +const REQUEST_ID = /^[A-Za-z0-9_-]{1,64}$/; +const COMPLETE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; + +const Credentials = Schema.Struct({ + claudeAiOauth: Schema.optional(Schema.Struct({ accessToken: Schema.optional(Schema.String) })), +}); +const Config = Schema.Struct({ + oauthAccount: Schema.optional( + Schema.Struct({ organizationUuid: Schema.optional(Schema.String) }), + ), +}); +const Grant = Schema.Struct({ + id: Schema.String.check(Schema.isPattern(GRANT_ID)), + resets_left: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + ends_at: Schema.optional(Schema.NullOr(Schema.String)), + paused: Schema.optional(Schema.Boolean), + usable_now: Schema.optional(Schema.Boolean), +}); +const decodeGrant = Schema.decodeUnknownOption(Grant); +const CedarEmber = Schema.Struct({ + eligible: Schema.Boolean, + grants: Schema.optional(Schema.Array(Schema.Unknown)), + next_grant_id: Schema.optional(Schema.NullOr(Schema.String)), +}); +const UsageResponse = Schema.Struct({ + cedar_ember: Schema.optional(Schema.NullOr(Schema.Unknown)), +}); +const decodeCedarEmber = Schema.decodeUnknownOption(CedarEmber); +const ClaimResponse = Schema.Struct({ + result: Schema.Literals([ + "reset", + "already_used", + "not_limited", + "cooldown", + "ineligible", + "unavailable", + ]), +}); + +const RESET_CREDIT_FAILURES = { + malformedCredit: "Claude returned a malformed reset credit.", + loginUnreadable: "Claude could not read its login.", + accountUnreadable: "Claude could not read its account.", + signedOut: "Sign in to Claude again to redeem resets.", + rateLimited: "Claude is rate limiting resets. Try again soon.", + coolingDown: "Claude resets are cooling down. Try again later.", + unconfirmed: + "Claude could not confirm the reset. If you are still limited in a moment, try again.", + requestFailed: "Claude could not redeem the reset.", +} as const; + +class ClaudeResetCreditError extends Schema.TaggedError()( + "ClaudeResetCreditError", + { + reason: Schema.Literals( + Object.keys(RESET_CREDIT_FAILURES) as Array, + ), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return RESET_CREDIT_FAILURES[this.reason]; + } +} + +const isClaudeResetCreditError = Schema.is(ClaudeResetCreditError); + +/** + * Every reset failure except `requestFailed` and `unconfirmed` is final: + * Claude answered, or nothing was sent. An unanswered or unconfirmed claim + * retries with the same request id. + */ +export const isSettledClaudeResetCreditFailure = (error: unknown) => + isClaudeResetCreditError(error) && + error.reason !== "requestFailed" && + error.reason !== "unconfirmed"; + +/** Rejects unparseable and calendar-invalid timestamps such as February 30. */ +const isFutureTimestamp = (value: string, nowMs: number) => { + if (!COMPLETE_TIMESTAMP.test(value)) return false; + const [year, month, day] = value.slice(0, 10).split("-").map(Number); + return ( + Date.parse(value) > nowMs && Date.UTC(year!, month! - 1, day!) <= Date.UTC(year!, month!, 0) + ); +}; + +/** Grants that are paused or past `ends_at` cannot be claimed and do not count. */ +export function claudeResetCreditsToContract( + block: unknown, + nowMs: number, +): ServerProviderResetCredits | undefined { + const parsed = decodeCedarEmber(block); + if (Option.isNone(parsed) || !parsed.value.eligible) return undefined; + const live = (parsed.value.grants ?? []) + .flatMap((raw) => Option.toArray(decodeGrant(raw))) + .filter( + (grant) => + !grant.paused && + grant.usable_now && + (grant.ends_at == null || isFutureTimestamp(grant.ends_at, nowMs)), + ); + const next = live.find((grant) => grant.id === parsed.value.next_grant_id); + const nextExpiresAt = next?.ends_at ? DateTime.make(next.ends_at) : Option.none(); + return { + availableCount: next ? live.reduce((sum, grant) => sum + grant.resets_left, 0) : 0, + ...(Option.isSome(nextExpiresAt) + ? { nextExpiresAt: DateTime.formatIso(nextExpiresAt.value) } + : {}), + ...(next ? { nextCreditId: next.id } : {}), + }; +} + +const readJson = (schema: S, file: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(file).pipe( + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), + }), + Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(schema))), + ); + }); + +const readAccessToken = (configDir: string) => + Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "darwin") return undefined; + const path = yield* Path.Path; + const credentials = yield* readJson(Credentials, path.join(configDir, ".credentials.json")); + return credentials.claudeAiOauth?.accessToken?.trim() || undefined; + }); + +const withClaudeHeaders = (token: string, version: string) => + HttpClientRequest.setHeaders({ + authorization: `Bearer ${token}`, + "anthropic-beta": "oauth-2025-04-20", + "user-agent": `claude-cli/${version} (external, cli)`, + }); + +/** + * Reads the banked resets for the login in `configDir`. Any failure reads as + * "no resets" so the usage bars never break on this optional extra. + */ +export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( + function* (configDir: string, version: string) { + const token = yield* readAccessToken(configDir); + if (!token) return undefined; + const client = yield* HttpClient.HttpClient; + const response = yield* client.execute( + HttpClientRequest.get(`${API_BASE}/api/oauth/usage`, { + urlParams: { cedar_ember: "1", skip_spend: "1" }, + }).pipe(withClaudeHeaders(token, version)), + ); + const body = yield* HttpClientResponse.schemaBodyJson(UsageResponse)( + yield* HttpClientResponse.filterStatusOk(response), + ); + return claudeResetCreditsToContract( + body.cedar_ember, + DateTime.toEpochMillis(yield* DateTime.now), + ); + }, + Effect.timeout("10 seconds"), + Effect.catch(() => Effect.succeed(undefined)), +); + +/** The CLI keeps the account record beside its settings, or in the home directory by default. */ +export const claudeAccountConfigPath = (configDir: string | undefined) => + Effect.map(Path.Path, (path) => + configDir ? path.join(configDir, ".claude.json") : path.join(NodeOS.homedir(), ".claude.json"), + ); + +const CLAIM_OUTCOMES = { + reset: "reset", + not_limited: "nothingToReset", + already_used: "alreadyRedeemed", + ineligible: "noCredit", +} as const satisfies Record; + +/** + * Claims `grantId`. `requestId` is the idempotency key: a retry with the same + * id is the same claim. Ids are checked before anything is sent. + */ +export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(function* (input: { + readonly configDir: string; + readonly accountConfigPath: string; + readonly version: string; + readonly grantId: string; + readonly requestId: string; +}) { + if (!GRANT_ID.test(input.grantId) || !REQUEST_ID.test(input.requestId)) { + return yield* new ClaudeResetCreditError({ reason: "malformedCredit" }); + } + const token = yield* readAccessToken(input.configDir).pipe( + Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "loginUnreadable", cause })), + ); + const config = yield* readJson(Config, input.accountConfigPath).pipe( + Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "accountUnreadable", cause })), + ); + const organization = config.oauthAccount?.organizationUuid?.trim(); + if (!token || !organization) { + return yield* new ClaudeResetCreditError({ reason: "signedOut" }); + } + const client = yield* HttpClient.HttpClient; + const response = yield* client + .execute( + HttpClientRequest.post( + new URL( + `/api/organizations/${encodeURIComponent(organization)}/reset_rate_limits`, + API_BASE, + ), + ).pipe( + withClaudeHeaders(token, input.version), + HttpClientRequest.bodyJsonUnsafe({ + program: PROGRAM, + grant_id: input.grantId, + request_id: input.requestId, + }), + ), + ) + .pipe( + Effect.timeout("25 seconds"), + Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })), + ); + if (response.status === 429) { + return yield* new ClaudeResetCreditError({ reason: "rateLimited" }); + } + if (response.status === 401 || response.status === 403) { + return yield* new ClaudeResetCreditError({ reason: "signedOut" }); + } + const body = yield* HttpClientResponse.filterStatusOk(response).pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(ClaimResponse)), + Effect.timeout("25 seconds"), + Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })), + ); + if (body.result === "cooldown") { + return yield* new ClaudeResetCreditError({ reason: "coolingDown" }); + } + // Claude could not say whether the claim landed, so, like the CLI, keep the + // request id and let the retry ask about the same claim. + if (body.result === "unavailable") { + return yield* new ClaudeResetCreditError({ reason: "unconfirmed" }); + } + return CLAIM_OUTCOMES[body.result]; +}); diff --git a/apps/server/src/provider/Layers/codexResetCredit.test.ts b/apps/server/src/provider/Layers/codexResetCredit.test.ts deleted file mode 100644 index f03b29737209e5f0b519e92fa82150cf10d46596..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/codexResetCredit.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Ref from "effect/Ref"; - -import { CodexResetCreditCoordinator, layerTest } from "./codexResetCredit.ts"; - -describe("CodexResetCreditCoordinator", () => { - it.effect("re-sends the same idempotency key after a failed attempt, then clears it", () => - Effect.gen(function* () { - const { redeem } = yield* CodexResetCreditCoordinator; - const keys = yield* Ref.make>([]); - const attempts = yield* Ref.make(0); - const consume = (key: string) => - Effect.gen(function* () { - yield* Ref.update(keys, (seen) => [...seen, key]); - const attempt = yield* Ref.updateAndGet(attempts, (n) => n + 1); - if (attempt === 1) return yield* Effect.fail("timed out" as const); - return "reset" as const; - }); - - const first = yield* redeem("acct", consume).pipe(Effect.result); - assert.isTrue(first._tag === "Failure"); - const second = yield* redeem("acct", consume); - assert.strictEqual(second, "reset"); - // A fresh redemption after success must be a fresh attempt. - yield* redeem("acct", consume); - - const seen = yield* Ref.get(keys); - assert.strictEqual(seen.length, 3); - assert.strictEqual(seen[0], seen[1]); - assert.notStrictEqual(seen[1], seen[2]); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("serialises concurrent redemptions on the same account, not per caller", () => - Effect.gen(function* () { - const { redeem } = yield* CodexResetCreditCoordinator; - const release = yield* Deferred.make(); - const inFlight = yield* Ref.make(0); - const peak = yield* Ref.make(0); - const consume = () => - Effect.gen(function* () { - const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1); - yield* Ref.update(peak, (p) => Math.max(p, now)); - yield* Deferred.await(release); - yield* Ref.update(inFlight, (n) => n - 1); - return "reset" as const; - }); - - // Two instances of the same account redeem at once. - const a = yield* redeem("acct", consume).pipe(Effect.forkChild); - const b = yield* redeem("acct", consume).pipe(Effect.forkChild); - yield* Effect.yieldNow; - yield* Deferred.succeed(release, undefined); - yield* Fiber.join(a); - yield* Fiber.join(b); - - assert.strictEqual(yield* Ref.get(peak), 1); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("keeps different accounts independent", () => - Effect.gen(function* () { - const { redeem } = yield* CodexResetCreditCoordinator; - const release = yield* Deferred.make(); - const peak = yield* Ref.make(0); - const inFlight = yield* Ref.make(0); - const consume = () => - Effect.gen(function* () { - const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1); - yield* Ref.update(peak, (p) => Math.max(p, now)); - yield* Deferred.await(release); - return "reset" as const; - }); - const a = yield* redeem("acct-a", consume).pipe(Effect.forkChild); - const b = yield* redeem("acct-b", consume).pipe(Effect.forkChild); - yield* Effect.yieldNow; - yield* Deferred.succeed(release, undefined); - yield* Fiber.join(a); - yield* Fiber.join(b); - assert.strictEqual(yield* Ref.get(peak), 2); - }).pipe(Effect.provide(layerTest)), - ); -}); diff --git a/apps/server/src/provider/Layers/codexResetCredit.ts b/apps/server/src/provider/Layers/codexResetCredit.ts deleted file mode 100644 index 34bd0a77fe27228f9922471a62a63d7416cada28..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/codexResetCredit.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Redeeming a Codex reset credit is an account-level action: instances that - * share the directory holding `auth.json` share the credit, so their - * redemptions must serialise on that directory, not the instance. This - * service keeps one lock and one pending idempotency key per account key so - * overlapping confirmations from any instance queue rather than spending two - * credits, and a retry after a timeout re-sends the same attempt. - * - * @module provider/Layers/codexResetCredit - */ -import type { ProviderConsumeResetCreditOutcome } from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import type * as PlatformError from "effect/PlatformError"; -import * as Ref from "effect/Ref"; -import * as Semaphore from "effect/Semaphore"; - -/** - * Bounded so a hung app-server cannot hold the account lock forever; the - * timeout interrupts the scoped request, which kills the process, and the - * kept idempotency key makes the user's retry safe. - */ -export const CODEX_RESET_CREDIT_TIMEOUT = Duration.seconds(20); - -interface AccountRedemptionState { - readonly lock: Semaphore.Semaphore; - readonly pendingKey: Ref.Ref; -} - -export class CodexResetCreditCoordinator extends Context.Service< - CodexResetCreditCoordinator, - { - /** - * Run `consume` under the account's lock with a stable idempotency key. - * The key is cleared only when Codex reports an outcome; a failure - * (timeout included) keeps it so the next attempt is the same attempt. - */ - readonly redeem: ( - accountKey: string, - consume: (idempotencyKey: string) => Effect.Effect, - ) => Effect.Effect; - } ->()("t3/provider/Layers/codexResetCredit/CodexResetCreditCoordinator") {} - -/** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; - const statesRef = yield* Ref.make>(new Map()); - - // Get-or-create through one Ref.modify so two first redemptions for the - // same account cannot each install their own lock. - const stateFor = Effect.fn("CodexResetCreditCoordinator.stateFor")(function* ( - accountKey: string, - ) { - const existing = (yield* Ref.get(statesRef)).get(accountKey); - if (existing) return existing; - const candidate = { - lock: yield* Semaphore.make(1), - pendingKey: yield* Ref.make(null), - }; - return yield* Ref.modify(statesRef, (states) => { - const current = states.get(accountKey); - if (current) return [current, states] as const; - const next = new Map(states); - next.set(accountKey, candidate); - return [candidate, next] as const; - }); - }); - - const redeem: CodexResetCreditCoordinator["Service"]["redeem"] = (accountKey, consume) => - Effect.gen(function* () { - const state = yield* stateFor(accountKey); - return yield* state.lock.withPermits(1)( - Effect.gen(function* () { - const existing = yield* Ref.get(state.pendingKey); - const idempotencyKey = existing ?? (yield* crypto.randomUUIDv4); - yield* Ref.set(state.pendingKey, idempotencyKey); - const outcome = yield* consume(idempotencyKey); - yield* Ref.set(state.pendingKey, null); - return outcome; - }), - ); - }); - - return { redeem } satisfies CodexResetCreditCoordinator["Service"]; -}); - -export const layer = Layer.effect(CodexResetCreditCoordinator, make); - -/** - * Self-contained for tests: a counter-backed Crypto so keys are deterministic - * and distinct without the platform layer. - */ -export const layerTest = Layer.effect( - CodexResetCreditCoordinator, - Effect.gen(function* () { - let counter = 0; - return yield* make.pipe( - Effect.provideService( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => { - counter += 1; - return new Uint8Array(size).fill(counter); - }, - digest: (_algorithm, data) => Effect.succeed(data), - }), - ), - ); - }), -); diff --git a/apps/server/src/provider/Layers/resetCreditCoordinator.test.ts b/apps/server/src/provider/Layers/resetCreditCoordinator.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..88de498d43dc3578e4fdc8d71b7d5ac673072dcb --- /dev/null +++ b/apps/server/src/provider/Layers/resetCreditCoordinator.test.ts @@ -0,0 +1,105 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Ref from "effect/Ref"; + +import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; + +describe("ResetCreditCoordinator", () => { + it.effect("re-sends the same idempotency key after a failed attempt, then clears it", () => + Effect.gen(function* () { + const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; + const keys = yield* Ref.make>([]); + const attempts = yield* Ref.make(0); + const consume = (key: string) => + Effect.gen(function* () { + yield* Ref.update(keys, (seen) => [...seen, key]); + const attempt = yield* Ref.updateAndGet(attempts, (n) => n + 1); + if (attempt === 1) return yield* Effect.fail("timed out" as const); + return "reset" as const; + }); + + const first = yield* redeem("acct", consume).pipe(Effect.result); + assert.isTrue(first._tag === "Failure"); + const second = yield* redeem("acct", consume); + assert.strictEqual(second, "reset"); + // A fresh redemption after success must be a fresh attempt. + yield* redeem("acct", consume); + + const seen = yield* Ref.get(keys); + assert.strictEqual(seen.length, 3); + assert.strictEqual(seen[0], seen[1]); + assert.notStrictEqual(seen[1], seen[2]); + }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), + ); + + it.effect("starts a fresh attempt after a settled failure", () => + Effect.gen(function* () { + const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; + const keys = yield* Ref.make>([]); + const consume = (key: string) => + Ref.update(keys, (seen) => [...seen, key]).pipe( + Effect.andThen(Effect.fail("cooldown" as const)), + ); + const isSettled = (error: "cooldown") => error === "cooldown"; + + yield* redeem("acct", consume, isSettled).pipe(Effect.result); + yield* redeem("acct", consume, isSettled).pipe(Effect.result); + + const seen = yield* Ref.get(keys); + assert.strictEqual(seen.length, 2); + assert.notStrictEqual(seen[0], seen[1]); + }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), + ); + + it.effect("serialises concurrent redemptions on the same account, not per caller", () => + Effect.gen(function* () { + const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; + const release = yield* Deferred.make(); + const inFlight = yield* Ref.make(0); + const peak = yield* Ref.make(0); + const consume = () => + Effect.gen(function* () { + const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1); + yield* Ref.update(peak, (p) => Math.max(p, now)); + yield* Deferred.await(release); + yield* Ref.update(inFlight, (n) => n - 1); + return "reset" as const; + }); + + // Two instances of the same account redeem at once. + const a = yield* redeem("acct", consume).pipe(Effect.forkChild); + const b = yield* redeem("acct", consume).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(a); + yield* Fiber.join(b); + + assert.strictEqual(yield* Ref.get(peak), 1); + }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), + ); + + it.effect("keeps different accounts independent", () => + Effect.gen(function* () { + const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; + const release = yield* Deferred.make(); + const peak = yield* Ref.make(0); + const inFlight = yield* Ref.make(0); + const consume = () => + Effect.gen(function* () { + const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1); + yield* Ref.update(peak, (p) => Math.max(p, now)); + yield* Deferred.await(release); + return "reset" as const; + }); + const a = yield* redeem("acct-a", consume).pipe(Effect.forkChild); + const b = yield* redeem("acct-b", consume).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(a); + yield* Fiber.join(b); + assert.strictEqual(yield* Ref.get(peak), 2); + }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), + ); +}); diff --git a/apps/server/src/provider/Layers/resetCreditCoordinator.ts b/apps/server/src/provider/Layers/resetCreditCoordinator.ts new file mode 100644 index 0000000000000000000000000000000000000000..c481624e7f40913557ec6f9116ad8d342146093b --- /dev/null +++ b/apps/server/src/provider/Layers/resetCreditCoordinator.ts @@ -0,0 +1,111 @@ +/** + * Redeeming a reset credit is an account-level action: instances that share + * the directory holding a provider's login share the credit, so their + * redemptions must serialise on that directory, not the instance. This + * service keeps one lock and one pending idempotency key per account key so + * overlapping confirmations from any instance queue rather than spending two + * credits, and a retry after a timeout re-sends the same attempt. + * + * @module provider/Layers/resetCreditCoordinator + */ +import type { ProviderConsumeResetCreditOutcome } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; + +interface AccountRedemptionState { + readonly lock: Semaphore.Semaphore; + readonly pendingKey: Ref.Ref; +} + +export class ResetCreditCoordinator extends Context.Service< + ResetCreditCoordinator, + { + /** + * Run `consume` under the account's lock with a stable idempotency key. + * The key is cleared when the provider reports an outcome, or when + * `isSettled` says a failure was a final answer (such as a cooldown). + * Any other failure (timeout included) keeps it so the next attempt is + * the same attempt. + */ + readonly redeem: ( + accountKey: string, + consume: (idempotencyKey: string) => Effect.Effect, + isSettled?: (error: E) => boolean, + ) => Effect.Effect; + } +>()("t3/provider/Layers/resetCreditCoordinator") {} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const statesRef = yield* Ref.make>(new Map()); + + // Get-or-create through one Ref.modify so two first redemptions for the + // same account cannot each install their own lock. + const stateFor = Effect.fn("ResetCreditCoordinator.stateFor")(function* (accountKey: string) { + const existing = (yield* Ref.get(statesRef)).get(accountKey); + if (existing) return existing; + const candidate = { + lock: yield* Semaphore.make(1), + pendingKey: yield* Ref.make(null), + }; + return yield* Ref.modify(statesRef, (states) => { + const current = states.get(accountKey); + if (current) return [current, states] as const; + const next = new Map(states); + next.set(accountKey, candidate); + return [candidate, next] as const; + }); + }); + + const redeem: ResetCreditCoordinator["Service"]["redeem"] = (accountKey, consume, isSettled) => + Effect.gen(function* () { + const state = yield* stateFor(accountKey); + return yield* state.lock.withPermits(1)( + Effect.gen(function* () { + const existing = yield* Ref.get(state.pendingKey); + const idempotencyKey = existing ?? (yield* crypto.randomUUIDv4); + yield* Ref.set(state.pendingKey, idempotencyKey); + const outcome = yield* consume(idempotencyKey).pipe( + Effect.tapError((error) => + isSettled?.(error) ? Ref.set(state.pendingKey, null) : Effect.void, + ), + ); + yield* Ref.set(state.pendingKey, null); + return outcome; + }), + ); + }); + + return { redeem } satisfies ResetCreditCoordinator["Service"]; +}); + +export const layer = Layer.effect(ResetCreditCoordinator, make); + +/** + * Self-contained for tests: a counter-backed Crypto so keys are deterministic + * and distinct without the platform layer. + */ +export const layerTest = Layer.effect( + ResetCreditCoordinator, + Effect.gen(function* () { + let counter = 0; + return yield* make.pipe( + Effect.provideService( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => { + counter += 1; + return new Uint8Array(size).fill(counter); + }, + digest: (_algorithm, data) => Effect.succeed(data), + }), + ), + ); + }), +); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3fd0bb7274a02bd682fdfdd3b538b0fe5202a27d..e648b3b50aec242b52b800d7a69a661ecb30ec93 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -46,7 +46,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; import * as ModelManifest from "./provider/ModelManifest.ts"; -import * as CodexResetCredit from "./provider/Layers/codexResetCredit.ts"; +import * as ResetCreditCoordinator from "./provider/Layers/resetCreditCoordinator.ts"; import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.ts"; @@ -523,7 +523,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // from the repo's `model-manifest.json` on `main` and applied by the // Codex/Claude drivers. Layer.provideMerge( - Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer, CodexResetCredit.layer), + Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer, ResetCreditCoordinator.layer), ), // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but diff --git a/docs/user/usage.md b/docs/user/usage.md index 2e6b4acd9e4c45b5e52ac17a27892942063b803d..cb6cb1a761a514f73cdcd3671844475f020bf71c 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -53,8 +53,10 @@ first, or by the first available window when no account reports a 5-hour limit. account does not report that window. When the provider reports reset times, the card also says when the next reset lands and how much it hands back. The hatched part of a segment is what that reset restores. Tap a segment or account row for the account's plan, -where it is signed in, and its reset time. On web, you can hover too. Codex accounts with banked -reset credits show a ticket count and the **Use reset** action in the account details. On narrow screens, numbered rows below +where it is signed in, and its reset time. On web, you can hover too. Codex and Claude accounts +with banked reset credits show a ticket count and the **Use reset** action in the account details. +Claude resets are not available when the server runs on macOS, where Claude keeps its login in the +Keychain. On narrow screens, numbered rows below the bar show each account's quota, countdown, and credits. Tap a row to open its details. The same account signed in on more than one environment, or reported by a hub as well, counts once.