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