| author | |
| committer | |
| log | 3412097bff7ffa62ef79e735c65d1e5cff71c356 |
| tree | 105644d7bdcf127cbb308026d2d94f659199b534 |
| parent | 7cfb4987fe27da3e1d4e86abacae3a1fc12af5bb |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 31 | import { ProviderDriverError } from "../Errors.ts"; | 31 | import { ProviderDriverError } from "../Errors.ts"; |
| 32 | import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; | 32 | import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; |
| 33 | import { makeClaudeScopedLimitNames } from "../Layers/claudeUsageLimits.ts"; | 33 | import { makeClaudeScopedLimitNames } from "../Layers/claudeUsageLimits.ts"; |
| 34 | import * as ClaudeResetCredits from "../Layers/claudeResetCredits.ts"; | ||
| 35 | import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts"; | ||
| 34 | import { | 36 | import { |
| 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"; |
| 62 | import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; | 64 | import { |
| 65 | makeClaudeCapabilitiesCacheKey, | ||
| 66 | makeClaudeContinuationGroupKey, | ||
| 67 | resolveClaudeHomePath, | ||
| 68 | } from "./ClaudeHome.ts"; | ||
| 63 | import { discoverClaudeSkills } from "./ClaudeSkills.ts"; | 69 | import { discoverClaudeSkills } from "./ClaudeSkills.ts"; |
| 64 | const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); | 70 | const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); |
| 65 | 71 | ||
| ... | @@ -87,6 +93,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ | ... | @@ -87,6 +93,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ |
| 87 | export type ClaudeDriverEnv = | 93 | export type ClaudeDriverEnv = |
| 88 | | BackgroundPolicy.BackgroundPolicy | 94 | | BackgroundPolicy.BackgroundPolicy |
| 89 | | ChildProcessSpawner.ChildProcessSpawner | 95 | | ChildProcessSpawner.ChildProcessSpawner |
| 96 | | ResetCreditCoordinator.ResetCreditCoordinator | ||
| 90 | | Crypto.Crypto | 97 | | Crypto.Crypto |
| 91 | | FileSystem.FileSystem | 98 | | FileSystem.FileSystem |
| 92 | | HttpClient.HttpClient | 99 | | 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 | ); |
| 252 | 272 | ||
| 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 |
| 17 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | 17 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; |
| 18 | import { ServerConfig } from "../../config.ts"; | 18 | import { ServerConfig } from "../../config.ts"; |
| 19 | import { ServerSettingsService } from "../../serverSettings.ts"; | 19 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 20 | import { layerTest as codexResetCreditLayerTest } from "../Layers/codexResetCredit.ts"; | 20 | import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts"; |
| 21 | import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; | 21 | import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; |
| 22 | import * as ModelManifest from "../ModelManifest.ts"; | 22 | import * as ModelManifest from "../ModelManifest.ts"; |
| 23 | import { | 23 | import { |
| ... | @@ -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"; |
| 37 | import { ServerSettingsService } from "../../serverSettings.ts"; | 37 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 38 | import { ProviderDriverError } from "../Errors.ts"; | 38 | import { ProviderDriverError } from "../Errors.ts"; |
| 39 | import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; | 39 | import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; |
| 40 | import { | 40 | import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts"; |
| 41 | CODEX_RESET_CREDIT_TIMEOUT, | ||
| 42 | CodexResetCreditCoordinator, | ||
| 43 | } from "../Layers/codexResetCredit.ts"; | ||
| 44 | import { | 41 | import { |
| 45 | checkCodexProviderStatus, | 42 | checkCodexProviderStatus, |
| 46 | makePendingCodexProvider, | 43 | makePendingCodexProvider, |
| ... | @@ -106,7 +103,7 @@ function makeCodexMaintenanceResolver(sharedHomePath: string) { | ... | @@ -106,7 +103,7 @@ function makeCodexMaintenanceResolver(sharedHomePath: string) { |
| 106 | export type CodexDriverEnv = | 103 | export type CodexDriverEnv = |
| 107 | | BackgroundPolicy.BackgroundPolicy | 104 | | BackgroundPolicy.BackgroundPolicy |
| 108 | | ChildProcessSpawner.ChildProcessSpawner | 105 | | ChildProcessSpawner.ChildProcessSpawner |
| 109 | | CodexResetCreditCoordinator | 106 | | ResetCreditCoordinator.ResetCreditCoordinator |
| 110 | | Crypto.Crypto | 107 | | Crypto.Crypto |
| 111 | | FileSystem.FileSystem | 108 | | FileSystem.FileSystem |
| 112 | | HttpClient.HttpClient | 109 | | 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"; |
| 6 | import * as DateTime from "effect/DateTime"; | 7 | import * as DateTime from "effect/DateTime"; |
| 7 | import * as Effect from "effect/Effect"; | 8 | import * 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"; |
| 37 | import { isHostWindows } from "@t3tools/shared/hostProcess"; | 37 | import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess"; |
| 38 | import * as DateTime from "effect/DateTime"; | 38 | import * as DateTime from "effect/DateTime"; |
| 39 | import * as Effect from "effect/Effect"; | 39 | import * as Effect from "effect/Effect"; |
| 40 | import * as FileSystem from "effect/FileSystem"; | 40 | import * 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"; |
| 56 | import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; | 56 | import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; |
| 57 | import * as ModelManifest from "../ModelManifest.ts"; | 57 | import * as ModelManifest from "../ModelManifest.ts"; |
| 58 | import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; | 58 | import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; |
| 59 | import * as CodexResetCredit from "./codexResetCredit.ts"; | 59 | import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; |
| 60 | import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; | 60 | import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; |
| 61 | import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; | 61 | import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; |
| 62 | 62 | ||
| ... | @@ -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 | ); |
| 236 | 256 | ||
| 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 | ); |
| 432 | 452 | ||
| 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 | ); |
| 502 | 612 | ||
| 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"; |
| 39 | import { AntigravityInstallation } from "../AntigravityInstallation.ts"; | 39 | import { AntigravityInstallation } from "../AntigravityInstallation.ts"; |
| 40 | import * as ModelManifest from "../ModelManifest.ts"; | 40 | import * as ModelManifest from "../ModelManifest.ts"; |
| 41 | import { applyProviderCompatibility } from "../providerCompatibility.ts"; | 41 | import { applyProviderCompatibility } from "../providerCompatibility.ts"; |
| 42 | import * as CodexResetCredit from "./codexResetCredit.ts"; | 42 | import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; |
| 43 | import * as OpenCodeRuntime from "../opencodeRuntime.ts"; | 43 | import * as OpenCodeRuntime from "../opencodeRuntime.ts"; |
| 44 | import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; | 44 | import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; |
| 45 | import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; | 45 | import { 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 the | 2327 | // 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 | ); |
| 2754 | 2753 | ||
| 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 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { it as effectIt } from "@effect/vitest"; | ||
| 3 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 4 | import * as Deferred from "effect/Deferred"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as FileSystem from "effect/FileSystem"; | ||
| 7 | import * as Fiber from "effect/Fiber"; | ||
| 8 | import * as Path from "effect/Path"; | ||
| 9 | import * as Schema from "effect/Schema"; | ||
| 10 | import * as TestClock from "effect/testing/TestClock"; | ||
| 11 | import { HttpClient, HttpClientResponse, UrlParams } from "effect/unstable/http"; | ||
| 12 | import { describe, expect, it } from "vite-plus/test"; | ||
| 13 | |||
| 14 | import * as ClaudeResetCredits from "./claudeResetCredits.ts"; | ||
| 15 | |||
| 16 | const NOW = Date.parse("2026-09-22T12:00:00.000Z"); | ||
| 17 | const grant = (overrides: Record<string, unknown>) => ({ | ||
| 18 | id: "grant_a", | ||
| 19 | resets_left: 1, | ||
| 20 | usable_now: true, | ||
| 21 | ...overrides, | ||
| 22 | }); | ||
| 23 | |||
| 24 | const 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 | |||
| 37 | const respond = (status: number, body: unknown) => | ||
| 38 | HttpClient.make((request) => | ||
| 39 | Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(body, { status }))), | ||
| 40 | ); | ||
| 41 | const refuseRequests = HttpClient.make(() => Effect.die("must not send a request")); | ||
| 42 | |||
| 43 | describe("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 | |||
| 93 | effectIt.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 | |||
| 137 | const ClaimBody = Schema.fromJsonString( | ||
| 138 | Schema.Struct({ program: Schema.String, grant_id: Schema.String, request_id: Schema.String }), | ||
| 139 | ); | ||
| 140 | const decodeClaimBody = Schema.decodeEffect(ClaimBody); | ||
| 141 | |||
| 142 | const 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 | |||
| 156 | effectIt.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 | */ | ||
| 10 | import * as NodeOS from "node:os"; | ||
| 11 | import type { | ||
| 12 | ProviderConsumeResetCreditOutcome, | ||
| 13 | ServerProviderResetCredits, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 16 | import * as DateTime from "effect/DateTime"; | ||
| 17 | import * as Effect from "effect/Effect"; | ||
| 18 | import * as FileSystem from "effect/FileSystem"; | ||
| 19 | import * as Option from "effect/Option"; | ||
| 20 | import * as Path from "effect/Path"; | ||
| 21 | import * as Schema from "effect/Schema"; | ||
| 22 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 23 | |||
| 24 | const API_BASE = "https://api.anthropic.com"; | ||
| 25 | const PROGRAM = "cedar_ember"; | ||
| 26 | const GRANT_ID = /^[a-z0-9_-]{1,40}$/; | ||
| 27 | const REQUEST_ID = /^[A-Za-z0-9_-]{1,64}$/; | ||
| 28 | const COMPLETE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; | ||
| 29 | |||
| 30 | const Credentials = Schema.Struct({ | ||
| 31 | claudeAiOauth: Schema.optional(Schema.Struct({ accessToken: Schema.optional(Schema.String) })), | ||
| 32 | }); | ||
| 33 | const Config = Schema.Struct({ | ||
| 34 | oauthAccount: Schema.optional( | ||
| 35 | Schema.Struct({ organizationUuid: Schema.optional(Schema.String) }), | ||
| 36 | ), | ||
| 37 | }); | ||
| 38 | const 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 | }); | ||
| 45 | const decodeGrant = Schema.decodeUnknownOption(Grant); | ||
| 46 | const 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 | }); | ||
| 51 | const UsageResponse = Schema.Struct({ | ||
| 52 | cedar_ember: Schema.optional(Schema.NullOr(Schema.Unknown)), | ||
| 53 | }); | ||
| 54 | const decodeCedarEmber = Schema.decodeUnknownOption(CedarEmber); | ||
| 55 | const ClaimResponse = Schema.Struct({ | ||
| 56 | result: Schema.Literals([ | ||
| 57 | "reset", | ||
| 58 | "already_used", | ||
| 59 | "not_limited", | ||
| 60 | "cooldown", | ||
| 61 | "ineligible", | ||
| 62 | "unavailable", | ||
| 63 | ]), | ||
| 64 | }); | ||
| 65 | |||
| 66 | const 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 | |||
| 78 | class 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 | |||
| 92 | const 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 | */ | ||
| 99 | export 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. */ | ||
| 105 | const 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. */ | ||
| 114 | export 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 | |||
| 139 | const 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 | |||
| 151 | const 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 | |||
| 159 | const 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 | */ | ||
| 170 | export 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. */ | ||
| 193 | export 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 | |||
| 198 | const 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 | */ | ||
| 209 | export 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 @@ | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | import * as Deferred from "effect/Deferred"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Fiber from "effect/Fiber"; | ||
| 5 | import * as Ref from "effect/Ref"; | ||
| 6 | |||
| 7 | import { CodexResetCreditCoordinator, layerTest } from "./codexResetCredit.ts"; | ||
| 8 | |||
| 9 | describe("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 | */ | ||
| 11 | import type { ProviderConsumeResetCreditOutcome } from "@t3tools/contracts"; | ||
| 12 | import * as Context from "effect/Context"; | ||
| 13 | import * as Crypto from "effect/Crypto"; | ||
| 14 | import * as Duration from "effect/Duration"; | ||
| 15 | import * as Effect from "effect/Effect"; | ||
| 16 | import * as Layer from "effect/Layer"; | ||
| 17 | import type * as PlatformError from "effect/PlatformError"; | ||
| 18 | import * as Ref from "effect/Ref"; | ||
| 19 | import * 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 | */ | ||
| 26 | export const CODEX_RESET_CREDIT_TIMEOUT = Duration.seconds(20); | ||
| 27 | |||
| 28 | interface AccountRedemptionState { | ||
| 29 | readonly lock: Semaphore.Semaphore; | ||
| 30 | readonly pendingKey: Ref.Ref<string | null>; | ||
| 31 | } | ||
| 32 | |||
| 33 | export 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. */ | ||
| 49 | export 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 | |||
| 91 | export 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 | */ | ||
| 97 | export 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 @@ | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | import * as Deferred from "effect/Deferred"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Fiber from "effect/Fiber"; | ||
| 5 | import * as Ref from "effect/Ref"; | ||
| 6 | |||
| 7 | import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; | ||
| 8 | |||
| 9 | describe("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 | */ | ||
| 11 | import type { ProviderConsumeResetCreditOutcome } from "@t3tools/contracts"; | ||
| 12 | import * as Context from "effect/Context"; | ||
| 13 | import * as Crypto from "effect/Crypto"; | ||
| 14 | import * as Effect from "effect/Effect"; | ||
| 15 | import * as Layer from "effect/Layer"; | ||
| 16 | import type * as PlatformError from "effect/PlatformError"; | ||
| 17 | import * as Ref from "effect/Ref"; | ||
| 18 | import * as Semaphore from "effect/Semaphore"; | ||
| 19 | |||
| 20 | interface AccountRedemptionState { | ||
| 21 | readonly lock: Semaphore.Semaphore; | ||
| 22 | readonly pendingKey: Ref.Ref<string | null>; | ||
| 23 | } | ||
| 24 | |||
| 25 | export 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. */ | ||
| 44 | export 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 | |||
| 88 | export 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 | */ | ||
| 94 | export 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 |
| 46 | import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts"; | 46 | import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts"; |
| 47 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; | 47 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; |
| 48 | import * as ModelManifest from "./provider/ModelManifest.ts"; | 48 | import * as ModelManifest from "./provider/ModelManifest.ts"; |
| 49 | import * as CodexResetCredit from "./provider/Layers/codexResetCredit.ts"; | 49 | import * as ResetCreditCoordinator from "./provider/Layers/resetCreditCoordinator.ts"; |
| 50 | import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; | 50 | import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; |
| 51 | import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; | 51 | import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; |
| 52 | import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.ts"; | 52 | import { 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 the | 523 | // 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 old | 528 | // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old |
| 529 | // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but | 529 | // `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. |
| 53 | account does not report that window. When the provider reports reset times, the card also says | 53 | account does not report that window. When the provider reports reset times, the card also says |
| 54 | when the next reset lands and how much it hands back. The hatched | 54 | when the next reset lands and how much it hands back. The hatched |
| 55 | part of a segment is what that reset restores. Tap a segment or account row for the account's plan, | 55 | part of a segment is what that reset restores. Tap a segment or account row for the account's plan, |
| 56 | where it is signed in, and its reset time. On web, you can hover too. Codex accounts with banked | 56 | where it is signed in, and its reset time. On web, you can hover too. Codex and Claude accounts |
| 57 | reset credits show a ticket count and the **Use reset** action in the account details. On narrow screens, numbered rows below | 57 | with banked reset credits show a ticket count and the **Use reset** action in the account details. |
| 58 | Claude resets are not available when the server runs on macOS, where Claude keeps its login in the | ||
| 59 | Keychain. On narrow screens, numbered rows below | ||
| 58 | the bar show each account's quota, countdown, and credits. Tap a row to open its details. | 60 | the bar show each account's quota, countdown, and credits. Tap a row to open its details. |
| 59 | 61 | ||
| 60 | The same account signed in on more than one environment, or reported by a hub as well, counts once. | 62 | The same account signed in on more than one environment, or reported by a hub as well, counts once. |