diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 87c81f08c8..d6da82dfb3 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -32,6 +32,7 @@ import { OrchestrationEventStoreLive } from "../src/persistence/Layers/Orchestra import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts"; import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts"; import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; +import { ProviderUsageLimitsRepositoryLive } from "../src/persistence/Layers/ProviderUsageLimits.ts"; import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts"; import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts"; import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts"; @@ -287,6 +288,9 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provide(fakeRegistry!), Layer.provide(AnalyticsService.layerTest), ); + const usageLimitsRepositoryLayer = ProviderUsageLimitsRepositoryLive.pipe( + Layer.provide(persistenceLayer), + ); const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive; @@ -296,7 +300,7 @@ export const makeOrchestrationIntegrationHarness = ( ProjectionCheckpointRepositoryLive, ProjectionPendingApprovalRepositoryLive, checkpointStoreLayer, - providerLayer, + providerLayer.pipe(Layer.provide(usageLimitsRepositoryLayer)), RuntimeReceiptBusTest, ); const serverSettingsLayer = ServerSettingsService.layerTest(); diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 1ca6fa1b83..d21e4ddda9 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -17,6 +17,7 @@ import { ServerSettingsService } from "../src/serverSettings.ts"; import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; +import { ProviderUsageLimitsRepositoryLive } from "../src/persistence/Layers/ProviderUsageLimits.ts"; import { makeTestProviderAdapterHarness, @@ -58,9 +59,13 @@ const makeIntegrationFixture = Effect.gen(function* () { const directoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(ProviderSessionRuntimeRepositoryLive), ); + const usageLimitsRepositoryLayer = ProviderUsageLimitsRepositoryLive.pipe( + Layer.provide(SqlitePersistenceMemory), + ); const shared = Layer.mergeAll( directoryLayer, + usageLimitsRepositoryLayer, Layer.succeed(ProviderAdapterRegistry, registry), ServerSettingsService.layerTest(DEFAULT_SERVER_SETTINGS), AnalyticsService.layerTest, diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index cb1cb2f3f8..e1f6e0d238 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -101,5 +101,6 @@ export type OrchestrationCommandReceiptRepositoryError = | PersistenceDecodeError; export type ProviderSessionRuntimeRepositoryError = PersistenceSqlError | PersistenceDecodeError; +export type ProviderUsageLimitsRepositoryError = PersistenceSqlError | PersistenceDecodeError; export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError; diff --git a/apps/server/src/persistence/Layers/ProviderUsageLimits.test.ts b/apps/server/src/persistence/Layers/ProviderUsageLimits.test.ts new file mode 100644 index 0000000000..71f3da4fd6 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProviderUsageLimits.test.ts @@ -0,0 +1,60 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; + +import type { ServerProviderUsageLimits } from "@t3tools/contracts"; +import { ProviderUsageLimitsRepository } from "../Services/ProviderUsageLimits.ts"; +import { ProviderUsageLimitsRepositoryLive } from "./ProviderUsageLimits.ts"; +import { SqlitePersistenceMemory } from "./Sqlite.ts"; + +const layer = it.layer( + ProviderUsageLimitsRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), +); + +layer("ProviderUsageLimitsRepository", (it) => { + it.effect("keeps the newest usage limits snapshot when a stale update arrives", () => + Effect.gen(function* () { + const repository = yield* ProviderUsageLimitsRepository; + const newerUsageLimits = { + updatedAt: "2026-04-04T01:00:00.000Z", + windows: [ + { + kind: "weekly" as const, + label: "Weekly limit", + usedPercentage: 31, + resetsAt: "2026-04-08T00:00:00.000Z", + windowDurationMins: 10_080, + }, + ], + } satisfies ServerProviderUsageLimits; + const staleUsageLimits = { + updatedAt: "2026-04-04T00:00:00.000Z", + windows: [ + { + kind: "weekly" as const, + label: "Weekly limit", + usedPercentage: 12, + resetsAt: "2026-04-09T00:00:00.000Z", + windowDurationMins: 10_080, + }, + ], + } satisfies ServerProviderUsageLimits; + + yield* repository.upsert({ + provider: "codex", + usageLimits: newerUsageLimits, + }); + + yield* repository.upsert({ + provider: "codex", + usageLimits: staleUsageLimits, + }); + + const stored = yield* repository.getByProvider({ provider: "codex" }); + assert.equal(Option.isSome(stored), true); + if (Option.isSome(stored)) { + assert.deepStrictEqual(stored.value.usageLimits, newerUsageLimits); + assert.strictEqual(stored.value.updatedAt, newerUsageLimits.updatedAt); + } + }), + ); +}); diff --git a/apps/server/src/persistence/Layers/ProviderUsageLimits.ts b/apps/server/src/persistence/Layers/ProviderUsageLimits.ts new file mode 100644 index 0000000000..5509be08e1 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProviderUsageLimits.ts @@ -0,0 +1,139 @@ +import { IsoDateTime, ProviderKind, ServerProviderUsageLimits } from "@t3tools/contracts"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Option, PubSub, Schema, Stream } from "effect"; + +import { + toPersistenceDecodeError, + toPersistenceSqlError, + type ProviderUsageLimitsRepositoryError, +} from "../Errors.ts"; +import { + ProviderUsageLimitsRepository, + type ProviderUsageLimitsRepositoryShape, + StoredProviderUsageLimits, +} from "../Services/ProviderUsageLimits.ts"; + +const ProviderUsageLimitsDbRowSchema = Schema.Struct({ + provider: ProviderKind, + updatedAt: IsoDateTime, + usageLimits: Schema.fromJsonString(ServerProviderUsageLimits), +}); + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown): ProviderUsageLimitsRepositoryError => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +const makeProviderUsageLimitsRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const changesPubSub = yield* Effect.acquireRelease( + PubSub.unbounded(), + PubSub.shutdown, + ); + + const upsertUsageLimitsRow = SqlSchema.findOneOption({ + Request: ProviderUsageLimitsDbRowSchema, + Result: ProviderUsageLimitsDbRowSchema, + execute: (row) => + sql` + INSERT INTO provider_usage_limits ( + provider_name, + updated_at, + payload_json + ) + VALUES ( + ${row.provider}, + ${row.updatedAt}, + ${row.usageLimits} + ) + ON CONFLICT (provider_name) + DO UPDATE SET + updated_at = excluded.updated_at, + payload_json = excluded.payload_json + WHERE excluded.updated_at > provider_usage_limits.updated_at + RETURNING + provider_name AS "provider", + updated_at AS "updatedAt", + payload_json AS "usageLimits" + `, + }); + + const getUsageLimitsByProvider = SqlSchema.findOneOption({ + Request: Schema.Struct({ + provider: ProviderKind, + }), + Result: ProviderUsageLimitsDbRowSchema, + execute: ({ provider }) => + sql` + SELECT + provider_name AS "provider", + updated_at AS "updatedAt", + payload_json AS "usageLimits" + FROM provider_usage_limits + WHERE provider_name = ${provider} + `, + }); + + const getByProvider: ProviderUsageLimitsRepositoryShape["getByProvider"] = (input) => + getUsageLimitsByProvider(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProviderUsageLimitsRepository.getByProvider:query", + "ProviderUsageLimitsRepository.getByProvider:decodeRow", + ), + ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + Schema.decodeUnknownEffect(StoredProviderUsageLimits)(row).pipe( + Effect.mapError( + toPersistenceDecodeError( + "ProviderUsageLimitsRepository.getByProvider:rowToUsageLimits", + ), + ), + Effect.map(Option.some), + ), + }), + ), + ); + + const upsert: ProviderUsageLimitsRepositoryShape["upsert"] = (input) => { + const row: StoredProviderUsageLimits = { + provider: input.provider, + updatedAt: input.usageLimits.updatedAt, + usageLimits: input.usageLimits, + }; + + return upsertUsageLimitsRow(row).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProviderUsageLimitsRepository.upsert:query", + "ProviderUsageLimitsRepository.upsert:requestOrRow", + ), + ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.void, + onSome: (updatedRow) => PubSub.publish(changesPubSub, updatedRow).pipe(Effect.asVoid), + }), + ), + ); + }; + + return { + getByProvider, + upsert, + get streamChanges() { + return Stream.fromPubSub(changesPubSub); + }, + } satisfies ProviderUsageLimitsRepositoryShape; +}); + +export const ProviderUsageLimitsRepositoryLive = Layer.effect( + ProviderUsageLimitsRepository, + makeProviderUsageLimitsRepository, +); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index a03c3c2d18..5d4a7db02e 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -32,6 +32,7 @@ import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts"; import Migration0017 from "./Migrations/017_ProjectionThreadsArchivedAt.ts"; import Migration0018 from "./Migrations/018_ProjectionThreadsArchivedAtIndex.ts"; import Migration0019 from "./Migrations/019_ProjectionSnapshotLookupIndexes.ts"; +import Migration0020 from "./Migrations/020_ProviderUsageLimits.ts"; /** * Migration loader with all migrations defined inline. @@ -63,6 +64,7 @@ export const migrationEntries = [ [17, "ProjectionThreadsArchivedAt", Migration0017], [18, "ProjectionThreadsArchivedAtIndex", Migration0018], [19, "ProjectionSnapshotLookupIndexes", Migration0019], + [20, "ProviderUsageLimits", Migration0020], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/020_ProviderUsageLimits.ts b/apps/server/src/persistence/Migrations/020_ProviderUsageLimits.ts new file mode 100644 index 0000000000..1423d471b4 --- /dev/null +++ b/apps/server/src/persistence/Migrations/020_ProviderUsageLimits.ts @@ -0,0 +1,14 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS provider_usage_limits ( + provider_name TEXT PRIMARY KEY, + updated_at TEXT NOT NULL, + payload_json TEXT NOT NULL + ) + `; +}); diff --git a/apps/server/src/persistence/Services/ProviderUsageLimits.ts b/apps/server/src/persistence/Services/ProviderUsageLimits.ts new file mode 100644 index 0000000000..f6d324d33c --- /dev/null +++ b/apps/server/src/persistence/Services/ProviderUsageLimits.ts @@ -0,0 +1,38 @@ +import { + IsoDateTime, + type ProviderKind, + ProviderKind as ProviderKindSchema, +} from "@t3tools/contracts"; +import { ServerProviderUsageLimits } from "@t3tools/contracts"; +import { Option, Schema, ServiceMap, type Stream } from "effect"; +import type { Effect } from "effect"; + +import type { ProviderUsageLimitsRepositoryError } from "../Errors.ts"; + +export const StoredProviderUsageLimits = Schema.Struct({ + provider: ProviderKindSchema, + updatedAt: IsoDateTime, + usageLimits: ServerProviderUsageLimits, +}); +export type StoredProviderUsageLimits = typeof StoredProviderUsageLimits.Type; + +export const GetProviderUsageLimitsInput = Schema.Struct({ + provider: ProviderKindSchema, +}); +export type GetProviderUsageLimitsInput = typeof GetProviderUsageLimitsInput.Type; + +export interface ProviderUsageLimitsRepositoryShape { + readonly getByProvider: ( + input: GetProviderUsageLimitsInput, + ) => Effect.Effect, ProviderUsageLimitsRepositoryError>; + readonly upsert: (input: { + readonly provider: ProviderKind; + readonly usageLimits: ServerProviderUsageLimits; + }) => Effect.Effect; + readonly streamChanges: Stream.Stream; +} + +export class ProviderUsageLimitsRepository extends ServiceMap.Service< + ProviderUsageLimitsRepository, + ProviderUsageLimitsRepositoryShape +>()("t3/persistence/Services/ProviderUsageLimits/ProviderUsageLimitsRepository") {} diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 761b795fe5..c2f2e27f52 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -5,6 +5,7 @@ import type { ServerProviderModel, ServerProviderAuth, ServerProviderState, + ServerProviderUsageLimits, } from "@t3tools/contracts"; import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -26,6 +27,8 @@ import { makeManagedServerProvider } from "../makeManagedServerProvider"; import { ClaudeProvider } from "../Services/ClaudeProvider"; import { ServerSettingsService } from "../../serverSettings"; import { ServerSettingsError } from "@t3tools/contracts"; +import { readPersistedProviderUsageLimits } from "../providerUsageLimits"; +import { ProviderUsageLimitsRepository } from "../../persistence/Services/ProviderUsageLimits.ts"; const PROVIDER = "claudeAgent" as const; const BUILT_IN_MODELS: ReadonlyArray = [ @@ -440,6 +443,7 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (args: Readonly export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* ( resolveSubscriptionType?: (binaryPath: string) => Effect.Effect, + resolveCachedUsageLimits?: () => Effect.Effect, ): Effect.fn.Return< ServerProvider, ServerSettingsError, @@ -451,13 +455,32 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ); const checkedAt = new Date().toISOString(); const models = providerModelsFromSettings(BUILT_IN_MODELS, PROVIDER, claudeSettings.customModels); - - if (!claudeSettings.enabled) { - return buildServerProvider({ + const cachedUsageLimits = resolveCachedUsageLimits + ? yield* resolveCachedUsageLimits() + : undefined; + + const buildProviderSnapshot = (input: { + readonly probe: { + installed: boolean; + version: string | null; + status: Exclude; + auth: ServerProviderAuth; + message?: string; + }; + readonly models?: ReadonlyArray; + readonly usageLimits?: ServerProviderUsageLimits; + }) => + buildServerProvider({ provider: PROVIDER, - enabled: false, + enabled: claudeSettings.enabled, checkedAt, - models, + models: input.models ?? models, + probe: input.probe, + ...(input.usageLimits ? { usageLimits: input.usageLimits } : {}), + }); + + if (!claudeSettings.enabled) { + return buildProviderSnapshot({ probe: { installed: false, version: null, @@ -465,6 +488,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( auth: { status: "unknown" }, message: "Claude is disabled in T3 Code settings.", }, + ...(cachedUsageLimits ? { usageLimits: cachedUsageLimits } : {}), }); } @@ -475,11 +499,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( if (Result.isFailure(versionProbe)) { const error = versionProbe.failure; - return buildServerProvider({ - provider: PROVIDER, - enabled: claudeSettings.enabled, - checkedAt, - models, + return buildProviderSnapshot({ probe: { installed: !isCommandMissingCause(error), version: null, @@ -489,15 +509,12 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ? "Claude Agent CLI (`claude`) is not installed or not on PATH." : `Failed to execute Claude Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, }, + ...(cachedUsageLimits ? { usageLimits: cachedUsageLimits } : {}), }); } if (Option.isNone(versionProbe.success)) { - return buildServerProvider({ - provider: PROVIDER, - enabled: claudeSettings.enabled, - checkedAt, - models, + return buildProviderSnapshot({ probe: { installed: true, version: null, @@ -506,6 +523,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( message: "Claude Agent CLI is installed but failed to run. Timed out while running command.", }, + ...(cachedUsageLimits ? { usageLimits: cachedUsageLimits } : {}), }); } @@ -513,11 +531,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); if (version.code !== 0) { const detail = detailFromResult(version); - return buildServerProvider({ - provider: PROVIDER, - enabled: claudeSettings.enabled, - checkedAt, - models, + return buildProviderSnapshot({ probe: { installed: true, version: parsedVersion, @@ -527,6 +541,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ? `Claude Agent CLI is installed but failed to run. ${detail}` : "Claude Agent CLI is installed but failed to run.", }, + ...(cachedUsageLimits ? { usageLimits: cachedUsageLimits } : {}), }); } @@ -561,10 +576,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( if (Result.isFailure(authProbe)) { const error = authProbe.failure; - return buildServerProvider({ - provider: PROVIDER, - enabled: claudeSettings.enabled, - checkedAt, + return buildProviderSnapshot({ models: resolvedModels, probe: { installed: true, @@ -576,14 +588,12 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ? `Could not verify Claude authentication status: ${error.message}.` : "Could not verify Claude authentication status.", }, + ...(cachedUsageLimits ? { usageLimits: cachedUsageLimits } : {}), }); } if (Option.isNone(authProbe.success)) { - return buildServerProvider({ - provider: PROVIDER, - enabled: claudeSettings.enabled, - checkedAt, + return buildProviderSnapshot({ models: resolvedModels, probe: { installed: true, @@ -592,15 +602,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( auth: { status: "unknown" }, message: "Could not verify Claude authentication status. Timed out while running command.", }, + ...(cachedUsageLimits ? { usageLimits: cachedUsageLimits } : {}), }); } const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value); const authMetadata = claudeAuthMetadata({ subscriptionType, authMethod }); - return buildServerProvider({ - provider: PROVIDER, - enabled: claudeSettings.enabled, - checkedAt, + return buildProviderSnapshot({ models: resolvedModels, probe: { installed: true, @@ -612,6 +620,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }, ...(parsed.message ? { message: parsed.message } : {}), }, + ...(cachedUsageLimits ? { usageLimits: cachedUsageLimits } : {}), }); }); @@ -620,6 +629,7 @@ export const ClaudeProviderLive = Layer.effect( Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const usageLimitsRepository = yield* ProviderUsageLimitsRepository; const subscriptionProbeCache = yield* Cache.make({ capacity: 1, @@ -628,11 +638,16 @@ export const ClaudeProviderLive = Layer.effect( probeClaudeCapabilities(binaryPath).pipe(Effect.map((r) => r?.subscriptionType)), }); - const checkProvider = checkClaudeProviderStatus((binaryPath) => - Cache.get(subscriptionProbeCache, binaryPath), + const checkProvider = checkClaudeProviderStatus( + (binaryPath) => Cache.get(subscriptionProbeCache, binaryPath), + () => + readPersistedProviderUsageLimits(PROVIDER, usageLimitsRepository).pipe( + Effect.orElseSucceed(() => undefined), + ), ).pipe( Effect.provideService(ServerSettingsService, serverSettings), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(ProviderUsageLimitsRepository, usageLimitsRepository), ); return yield* makeManagedServerProvider({ diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 667bdf048b..bbec093940 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -6,6 +6,7 @@ import type { ServerProviderModel, ServerProviderAuth, ServerProviderState, + ServerProviderUsageLimits, } from "@t3tools/contracts"; import { Cache, @@ -44,10 +45,15 @@ import { codexAuthSubType, type CodexAccountSnapshot, } from "../codexAccount"; -import { probeCodexAccount } from "../codexAppServer"; +import { probeCodexAccountState, type CodexAccountState } from "../codexAppServer"; import { CodexProvider } from "../Services/CodexProvider"; import { ServerSettingsService } from "../../serverSettings"; import { ServerSettingsError } from "@t3tools/contracts"; +import { + normalizeCodexUsageLimits, + readPersistedProviderUsageLimits, +} from "../providerUsageLimits"; +import { ProviderUsageLimitsRepository } from "../../persistence/Services/ProviderUsageLimits.ts"; const PROVIDER = "codex" as const; const OPENAI_AUTH_PROVIDERS = new Set(["openai"]); @@ -297,15 +303,30 @@ const probeCodexCapabilities = (input: { readonly binaryPath: string; readonly homePath?: string; }) => - Effect.tryPromise((signal) => probeCodexAccount({ ...input, signal })).pipe( + Effect.tryPromise((signal) => probeCodexAccountState({ ...input, signal })).pipe( Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS), Effect.result, Effect.map((result) => { if (Result.isFailure(result)) return undefined; - return Option.isSome(result.success) ? result.success.value : undefined; + if (Option.isNone(result.success)) return undefined; + const state = result.success.value; + return { + ...state, + usageLimits: normalizeCodexUsageLimits(state.rateLimits, new Date().toISOString()), + }; }), ); +type ResolvedCodexAccount = + | CodexAccountSnapshot + | (CodexAccountState & { readonly usageLimits: ServerProviderUsageLimits | undefined }); + +function isResolvedCodexAccountState( + value: ResolvedCodexAccount | undefined, +): value is CodexAccountState & { readonly usageLimits: ServerProviderUsageLimits | undefined } { + return typeof value === "object" && value !== null && "snapshot" in value; +} + const runCodexCommand = Effect.fn("runCodexCommand")(function* (args: ReadonlyArray) { const settingsService = yield* ServerSettingsService; const codexSettings = yield* settingsService.getSettings.pipe( @@ -325,7 +346,8 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu resolveAccount?: (input: { readonly binaryPath: string; readonly homePath?: string; - }) => Effect.Effect, + }) => Effect.Effect, + resolveCachedUsageLimits?: () => Effect.Effect, ): Effect.fn.Return< ServerProvider, ServerSettingsError, @@ -340,13 +362,32 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu ); const checkedAt = new Date().toISOString(); const models = providerModelsFromSettings(BUILT_IN_MODELS, PROVIDER, codexSettings.customModels); + const cachedUsageLimits = resolveCachedUsageLimits + ? yield* resolveCachedUsageLimits() + : undefined; - if (!codexSettings.enabled) { - return buildServerProvider({ + const buildProviderSnapshot = (input: { + readonly probe: { + installed: boolean; + version: string | null; + status: Exclude; + auth: ServerProviderAuth; + message?: string; + }; + readonly models?: ReadonlyArray; + readonly usageLimits?: ServerProviderUsageLimits; + }) => + buildServerProvider({ provider: PROVIDER, - enabled: false, + enabled: codexSettings.enabled, checkedAt, - models, + models: input.models ?? models, + probe: input.probe, + ...(input.usageLimits ? { usageLimits: input.usageLimits } : {}), + }); + + if (!codexSettings.enabled) { + return buildProviderSnapshot({ probe: { installed: false, version: null, @@ -354,6 +395,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu auth: { status: "unknown" }, message: "Codex is disabled in T3 Code settings.", }, + ...(cachedUsageLimits ? { usageLimits: cachedUsageLimits } : {}), }); } @@ -364,11 +406,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu if (Result.isFailure(versionProbe)) { const error = versionProbe.failure; - return buildServerProvider({ - provider: PROVIDER, - enabled: codexSettings.enabled, - checkedAt, - models, + return buildProviderSnapshot({ probe: { installed: !isCommandMissingCause(error), version: null, @@ -378,15 +416,12 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu ? "Codex CLI (`codex`) is not installed or not on PATH." : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`, }, + ...(cachedUsageLimits ? { usageLimits: cachedUsageLimits } : {}), }); } if (Option.isNone(versionProbe.success)) { - return buildServerProvider({ - provider: PROVIDER, - enabled: codexSettings.enabled, - checkedAt, - models, + return buildProviderSnapshot({ probe: { installed: true, version: null, @@ -394,6 +429,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu auth: { status: "unknown" }, message: "Codex CLI is installed but failed to run. Timed out while running command.", }, + ...(cachedUsageLimits ? { usageLimits: cachedUsageLimits } : {}), }); } @@ -403,11 +439,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); if (version.code !== 0) { const detail = detailFromResult(version); - return buildServerProvider({ - provider: PROVIDER, - enabled: codexSettings.enabled, - checkedAt, - models, + return buildProviderSnapshot({ probe: { installed: true, version: parsedVersion, @@ -417,15 +449,12 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu ? `Codex CLI is installed but failed to run. ${detail}` : "Codex CLI is installed but failed to run.", }, + ...(cachedUsageLimits ? { usageLimits: cachedUsageLimits } : {}), }); } if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) { - return buildServerProvider({ - provider: PROVIDER, - enabled: codexSettings.enabled, - checkedAt, - models, + return buildProviderSnapshot({ probe: { installed: true, version: parsedVersion, @@ -433,15 +462,12 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu auth: { status: "unknown" }, message: formatCodexCliUpgradeMessage(parsedVersion), }, + ...(cachedUsageLimits ? { usageLimits: cachedUsageLimits } : {}), }); } if (yield* hasCustomModelProvider) { - return buildServerProvider({ - provider: PROVIDER, - enabled: codexSettings.enabled, - checkedAt, - models, + return buildProviderSnapshot({ probe: { installed: true, version: parsedVersion, @@ -449,6 +475,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu auth: { status: "unknown" }, message: "Using a custom Codex model provider; OpenAI login check skipped.", }, + ...(cachedUsageLimits ? { usageLimits: cachedUsageLimits } : {}), }); } @@ -462,14 +489,14 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu homePath: codexSettings.homePath, }) : undefined; - const resolvedModels = adjustCodexModelsForAccount(models, account); + const accountSnapshot = isResolvedCodexAccountState(account) ? account.snapshot : account; + const resolvedModels = adjustCodexModelsForAccount(models, accountSnapshot); + const accountUsageLimits = isResolvedCodexAccountState(account) ? account.usageLimits : undefined; + const usageLimits = cachedUsageLimits ?? accountUsageLimits; if (Result.isFailure(authProbe)) { const error = authProbe.failure; - return buildServerProvider({ - provider: PROVIDER, - enabled: codexSettings.enabled, - checkedAt, + return buildProviderSnapshot({ models: resolvedModels, probe: { installed: true, @@ -481,14 +508,12 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu ? `Could not verify Codex authentication status: ${error.message}.` : "Could not verify Codex authentication status.", }, + ...(usageLimits ? { usageLimits } : {}), }); } if (Option.isNone(authProbe.success)) { - return buildServerProvider({ - provider: PROVIDER, - enabled: codexSettings.enabled, - checkedAt, + return buildProviderSnapshot({ models: resolvedModels, probe: { installed: true, @@ -497,16 +522,14 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu auth: { status: "unknown" }, message: "Could not verify Codex authentication status. Timed out while running command.", }, + ...(usageLimits ? { usageLimits } : {}), }); } const parsed = parseAuthStatusFromOutput(authProbe.success.value); - const authType = codexAuthSubType(account); - const authLabel = codexAuthSubLabel(account); - return buildServerProvider({ - provider: PROVIDER, - enabled: codexSettings.enabled, - checkedAt, + const authType = codexAuthSubType(accountSnapshot); + const authLabel = codexAuthSubLabel(accountSnapshot); + return buildProviderSnapshot({ models: resolvedModels, probe: { installed: true, @@ -519,6 +542,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu }, ...(parsed.message ? { message: parsed.message } : {}), }, + ...(usageLimits ? { usageLimits } : {}), }); }); @@ -529,6 +553,7 @@ export const CodexProviderLive = Layer.effect( const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const usageLimitsRepository = yield* ProviderUsageLimitsRepository; const accountProbeCache = yield* Cache.make({ capacity: 4, timeToLive: Duration.minutes(5), @@ -541,13 +566,30 @@ export const CodexProviderLive = Layer.effect( }, }); - const checkProvider = checkCodexProviderStatus((input) => - Cache.get(accountProbeCache, JSON.stringify([input.binaryPath, input.homePath])), + const checkProvider = checkCodexProviderStatus( + (input) => + Cache.get(accountProbeCache, JSON.stringify([input.binaryPath, input.homePath])).pipe( + Effect.tap((state) => + state?.usageLimits + ? usageLimitsRepository + .upsert({ + provider: PROVIDER, + usageLimits: state.usageLimits, + }) + .pipe(Effect.catchCause(() => Effect.void)) + : Effect.void, + ), + ), + () => + readPersistedProviderUsageLimits(PROVIDER, usageLimitsRepository).pipe( + Effect.orElseSucceed(() => undefined), + ), ).pipe( Effect.provideService(ServerSettingsService, serverSettings), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(ProviderUsageLimitsRepository, usageLimitsRepository), ); return yield* makeManagedServerProvider({ diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index ca27371b61..98998243d4 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1,5 +1,10 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, it, assert } from "@effect/vitest"; +import { expect } from "vitest"; import { Effect, Exit, @@ -33,10 +38,19 @@ import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./Cl import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry"; import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings"; import { ProviderRegistry } from "../Services/ProviderRegistry"; +import { ProviderUsageLimitsRepositoryLive } from "../../persistence/Layers/ProviderUsageLimits.ts"; +import { ProviderUsageLimitsRepository } from "../../persistence/Services/ProviderUsageLimits.ts"; +import { + makeSqlitePersistenceLive, + SqlitePersistenceMemory, +} from "../../persistence/Layers/Sqlite.ts"; // ── Test helpers ──────────────────────────────────────────────────── const encoder = new TextEncoder(); +const usageLimitsRepositoryLayer = ProviderUsageLimitsRepositoryLive.pipe( + Layer.provide(SqlitePersistenceMemory), +); function mockHandle(result: { stdout: string; stderr: string; code: number }) { return ChildProcessSpawner.makeHandle({ @@ -220,6 +234,172 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); + it.effect("includes weekly-only codex usage limits in the provider snapshot", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus(() => + Effect.succeed({ + snapshot: { + type: "chatgpt" as const, + planType: "free" as const, + sparkEnabled: false, + }, + account: null, + rateLimits: null, + usageLimits: { + updatedAt: "2026-04-04T00:00:00.000Z", + windows: [ + { + kind: "weekly", + label: "Weekly limit", + usedPercentage: 23, + resetsAt: "2026-04-08T00:00:00.000Z", + windowDurationMins: 10_080, + }, + ], + }, + }), + ); + + assert.deepStrictEqual(status.usageLimits, { + updatedAt: "2026-04-04T00:00:00.000Z", + windows: [ + { + kind: "weekly", + label: "Weekly limit", + usedPercentage: 23, + resetsAt: "2026-04-08T00:00:00.000Z", + windowDurationMins: 10_080, + }, + ], + }); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("includes session and weekly codex usage limits in the provider snapshot", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus(() => + Effect.succeed({ + snapshot: { + type: "chatgpt" as const, + planType: "pro" as const, + sparkEnabled: true, + }, + account: null, + rateLimits: null, + usageLimits: { + updatedAt: "2026-04-04T00:00:00.000Z", + windows: [ + { + kind: "session", + label: "Session limit", + usedPercentage: 68, + resetsAt: "2026-04-04T05:00:00.000Z", + windowDurationMins: 300, + }, + { + kind: "weekly", + label: "Weekly limit", + usedPercentage: 29, + resetsAt: "2026-04-08T00:00:00.000Z", + windowDurationMins: 10_080, + }, + ], + }, + }), + ); + + assert.deepStrictEqual( + status.usageLimits?.windows.map((window) => window.kind), + ["session", "weekly"], + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("prefers persisted codex usage limits over cached probe limits", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus( + () => + Effect.succeed({ + snapshot: { + type: "chatgpt" as const, + planType: "pro" as const, + sparkEnabled: true, + }, + account: null, + rateLimits: null, + usageLimits: { + updatedAt: "2026-04-04T00:00:00.000Z", + windows: [ + { + kind: "weekly", + label: "Weekly limit", + usedPercentage: 91, + resetsAt: "2026-04-08T00:00:00.000Z", + windowDurationMins: 10_080, + }, + ], + }, + }), + () => + Effect.succeed({ + updatedAt: "2026-04-04T02:00:00.000Z", + windows: [ + { + kind: "weekly" as const, + label: "Weekly limit", + usedPercentage: 27, + resetsAt: "2026-04-09T00:00:00.000Z", + windowDurationMins: 10_080, + }, + ], + }), + ); + + assert.deepStrictEqual(status.usageLimits, { + updatedAt: "2026-04-04T02:00:00.000Z", + windows: [ + { + kind: "weekly", + label: "Weekly limit", + usedPercentage: 27, + resetsAt: "2026-04-09T00:00:00.000Z", + windowDurationMins: 10_080, + }, + ], + }); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("hides spark from codex models for unsupported chatgpt plans", () => Effect.gen(function* () { yield* withTempCodexHome(); @@ -536,10 +716,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( throw new Error(`Unexpected args: ${joined}`); }), ), + Layer.provideMerge(usageLimitsRepositoryLayer), ); const runtimeServices = yield* Layer.build( Layer.mergeAll( Layer.succeed(ServerSettingsService, serverSettings), + usageLimitsRepositoryLayer, providerRegistryLayer, ), ).pipe(Scope.provide(scope)); @@ -578,6 +760,251 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( }), ); + it.effect("hydrates cached Claude usage limits into the first provider snapshot", () => + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-registry-cache-")); + const dbPath = path.join(tempDir, "state.sqlite"); + const persistenceLayer = makeSqlitePersistenceLive(dbPath); + const persistedUsageLimitsLayer = ProviderUsageLimitsRepositoryLive.pipe( + Layer.provide(persistenceLayer), + ); + + yield* Effect.gen(function* () { + const repository = yield* ProviderUsageLimitsRepository; + yield* repository.upsert({ + provider: "claudeAgent", + usageLimits: { + updatedAt: "2026-04-04T00:00:00.000Z", + windows: [ + { + kind: "weekly", + label: "Weekly limit", + usedPercentage: 28, + resetsAt: "2026-04-05T00:00:00.000Z", + windowDurationMins: 10_080, + }, + ], + }, + }); + }).pipe(Effect.provide(persistedUsageLimitsLayer)); + + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge( + mockCommandSpawnerLayer((command, args) => { + const joined = args.join(" "); + if (joined === "--version") { + return { + stdout: command === "codex" ? "codex 1.0.0\n" : "claude 1.0.0\n", + stderr: "", + code: 0, + }; + } + if (joined === "login status" || joined === "auth status") { + return { stdout: "Logged in\n", stderr: "", code: 0 }; + } + throw new Error(`Unexpected args: ${command} ${joined}`); + }), + ), + Layer.provideMerge(persistedUsageLimitsLayer), + ); + + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const serverSettings = yield* makeMutableServerSettingsService(); + const runtimeServices = yield* Layer.build( + providerRegistryLayer.pipe( + Layer.provide(Layer.succeed(ServerSettingsService, serverSettings)), + ), + ).pipe(Scope.provide(scope)); + + const providers = yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + return yield* registry.getProviders; + }).pipe(Effect.provide(runtimeServices)); + + expect( + providers.find((provider) => provider.provider === "claudeAgent")?.usageLimits, + ).toEqual({ + updatedAt: "2026-04-04T00:00:00.000Z", + windows: [ + { + kind: "weekly", + label: "Weekly limit", + usedPercentage: 28, + resetsAt: "2026-04-05T00:00:00.000Z", + windowDurationMins: 10_080, + }, + ], + }); + + fs.rmSync(tempDir, { recursive: true, force: true }); + }), + ); + + it.effect( + "restores persisted Claude usage limits after restart into the first provider snapshot", + () => + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-registry-")); + const dbPath = path.join(tempDir, "state.sqlite"); + const persistenceLayer = makeSqlitePersistenceLive(dbPath); + const persistedUsageLimitsLayer = ProviderUsageLimitsRepositoryLive.pipe( + Layer.provide(persistenceLayer), + ); + + yield* Effect.gen(function* () { + const repository = yield* ProviderUsageLimitsRepository; + yield* repository.upsert({ + provider: "claudeAgent", + usageLimits: { + updatedAt: "2026-04-04T00:00:00.000Z", + windows: [ + { + kind: "weekly", + label: "Weekly limit", + usedPercentage: 19, + resetsAt: "2026-04-06T00:00:00.000Z", + windowDurationMins: 10_080, + }, + ], + }, + }); + }).pipe(Effect.provide(persistedUsageLimitsLayer)); + + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge( + mockCommandSpawnerLayer((command, args) => { + const joined = args.join(" "); + if (joined === "--version") { + return { + stdout: command === "codex" ? "codex 1.0.0\n" : "claude 1.0.0\n", + stderr: "", + code: 0, + }; + } + if (joined === "login status" || joined === "auth status") { + return { stdout: "Logged in\n", stderr: "", code: 0 }; + } + throw new Error(`Unexpected args: ${command} ${joined}`); + }), + ), + Layer.provideMerge(persistedUsageLimitsLayer), + ); + + const providers = yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + return yield* registry.getProviders; + }).pipe(Effect.provide(providerRegistryLayer)); + + expect( + providers.find((provider) => provider.provider === "claudeAgent")?.usageLimits, + ).toEqual({ + updatedAt: "2026-04-04T00:00:00.000Z", + windows: [ + { + kind: "weekly", + label: "Weekly limit", + usedPercentage: 19, + resetsAt: "2026-04-06T00:00:00.000Z", + windowDurationMins: 10_080, + }, + ], + }); + + fs.rmSync(tempDir, { recursive: true, force: true }); + }), + ); + + it.effect("publishes provider-status updates when usage-limit cache changes", () => + Effect.gen(function* () { + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge( + mockCommandSpawnerLayer((command, args) => { + const joined = args.join(" "); + if (joined === "--version") { + return { + stdout: command === "codex" ? "codex 1.0.0\n" : "claude 1.0.0\n", + stderr: "", + code: 0, + }; + } + if (joined === "login status" || joined === "auth status") { + return { stdout: "Logged in\n", stderr: "", code: 0 }; + } + throw new Error(`Unexpected args: ${command} ${joined}`); + }), + ), + ); + + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const serverSettings = yield* makeMutableServerSettingsService(); + const runtimeServices = yield* Layer.build( + Layer.mergeAll( + providerRegistryLayer.pipe( + Layer.provide(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge(usageLimitsRepositoryLayer), + ), + usageLimitsRepositoryLayer, + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + const repository = yield* ProviderUsageLimitsRepository; + const publishedRef = yield* Ref.make | null>(null); + + yield* registry.getProviders; + yield* Stream.take(registry.streamChanges, 1).pipe( + Stream.runForEach((providers) => Ref.set(publishedRef, providers)), + Effect.forkScoped, + ); + + yield* Effect.yieldNow; + + yield* repository.upsert({ + provider: "claudeAgent", + usageLimits: { + updatedAt: "2026-04-04T00:00:00.000Z", + windows: [ + { + kind: "weekly", + label: "Weekly limit", + usedPercentage: 11, + resetsAt: "2026-04-07T00:00:00.000Z", + windowDurationMins: 10_080, + }, + ], + }, + }); + + for (let attempt = 0; attempt < 50; attempt += 1) { + const published = yield* Ref.get(publishedRef); + if (published) { + expect( + published.find((provider) => provider.provider === "claudeAgent")?.usageLimits, + ).toEqual({ + updatedAt: "2026-04-04T00:00:00.000Z", + windows: [ + { + kind: "weekly", + label: "Weekly limit", + usedPercentage: 11, + resetsAt: "2026-04-07T00:00:00.000Z", + windowDurationMins: 10_080, + }, + ], + }); + return; + } + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 10))); + } + + assert.fail("Timed out waiting for provider-status update after usage-limit change"); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("skips codex probes entirely when the provider is disabled", () => Effect.gen(function* () { const serverSettingsLayer = ServerSettingsService.layerTest({ @@ -886,6 +1313,53 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); + it.effect("includes cached Claude usage limits in the provider snapshot", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + () => Effect.succeed("maxplan"), + () => + Effect.succeed({ + updatedAt: "2026-04-04T00:00:00.000Z", + windows: [ + { + kind: "weekly", + label: "Weekly limit", + usedPercentage: 31, + resetsAt: "2026-04-05T00:00:00.000Z", + windowDurationMins: 10_080, + }, + ], + }), + ); + assert.deepStrictEqual(status.usageLimits, { + updatedAt: "2026-04-04T00:00:00.000Z", + windows: [ + { + kind: "weekly", + label: "Weekly limit", + usedPercentage: 31, + resetsAt: "2026-04-05T00:00:00.000Z", + windowDurationMins: 10_080, + }, + ], + }); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns an api key label for claude api key auth", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus(); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index fb2f33c293..c45fe0a0b7 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -13,6 +13,7 @@ import { ClaudeProvider } from "../Services/ClaudeProvider"; import type { CodexProviderShape } from "../Services/CodexProvider"; import { CodexProvider } from "../Services/CodexProvider"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry"; +import { ProviderUsageLimitsRepository } from "../../persistence/Services/ProviderUsageLimits.ts"; const loadProviders = ( codexProvider: CodexProviderShape, @@ -32,6 +33,7 @@ export const ProviderRegistryLive = Layer.effect( Effect.gen(function* () { const codexProvider = yield* CodexProvider; const claudeProvider = yield* ClaudeProvider; + const usageLimitsRepository = yield* ProviderUsageLimitsRepository; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded>(), PubSub.shutdown, @@ -54,13 +56,6 @@ export const ProviderRegistryLive = Layer.effect( return providers; }); - yield* Stream.runForEach(codexProvider.streamChanges, () => syncProviders()).pipe( - Effect.forkScoped, - ); - yield* Stream.runForEach(claudeProvider.streamChanges, () => syncProviders()).pipe( - Effect.forkScoped, - ); - const refresh = Effect.fn("refresh")(function* (provider?: ProviderKind) { switch (provider) { case "codex": @@ -78,6 +73,16 @@ export const ProviderRegistryLive = Layer.effect( return yield* syncProviders(); }); + yield* Stream.runForEach(codexProvider.streamChanges, () => syncProviders()).pipe( + Effect.forkScoped, + ); + yield* Stream.runForEach(claudeProvider.streamChanges, () => syncProviders()).pipe( + Effect.forkScoped, + ); + yield* Stream.runForEach(usageLimitsRepository.streamChanges, (change) => + refresh(change.provider).pipe(Effect.ignoreCause({ log: true })), + ).pipe(Effect.forkScoped); + return { getProviders: syncProviders({ publish: false }).pipe( Effect.tapError(Effect.logError), diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index fc3c9cf25c..2f69da0f66 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -38,6 +38,8 @@ import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; +import { ProviderUsageLimitsRepositoryLive } from "../../persistence/Layers/ProviderUsageLimits.ts"; +import { ProviderUsageLimitsRepository } from "../../persistence/Services/ProviderUsageLimits.ts"; import { makeSqlitePersistenceLive, SqlitePersistenceMemory, @@ -243,6 +245,12 @@ const hasMetricSnapshot = ( Object.entries(attributes).every(([key, value]) => snapshot.attributes?.[key] === value), ); +const makeUsageLimitsRepositoryLayer = ( + persistenceLayer: + | typeof SqlitePersistenceMemory + | ReturnType = SqlitePersistenceMemory, +) => ProviderUsageLimitsRepositoryLive.pipe(Layer.provide(persistenceLayer)); + function makeProviderServiceLayer() { const codex = makeFakeCodexAdapter(); const claude = makeFakeCodexAdapter("claudeAgent"); @@ -260,6 +268,7 @@ function makeProviderServiceLayer() { const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( Layer.provide(SqlitePersistenceMemory), ); + const usageLimitsRepositoryLayer = makeUsageLimitsRepositoryLayer(); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); const layer = it.layer( @@ -269,12 +278,11 @@ function makeProviderServiceLayer() { Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provideMerge(AnalyticsService.layerTest), + Layer.provideMerge(usageLimitsRepositoryLayer), ), directoryLayer, - runtimeRepositoryLayer, - NodeServices.layer, - ), + ).pipe(Layer.provideMerge(NodeServices.layer)), ); return { @@ -308,12 +316,14 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( Layer.provide(SqlitePersistenceMemory), ); + const usageLimitsRepositoryLayer = makeUsageLimitsRepositoryLayer(); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); const providerLayer = makeProviderServiceLive().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(serverSettingsLayer), Layer.provide(AnalyticsService.layerTest), + Layer.provideMerge(usageLimitsRepositoryLayer), ); const failure = yield* Effect.flip( @@ -352,6 +362,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( Layer.provide(persistenceLayer), ); + const usageLimitsRepositoryLayer = makeUsageLimitsRepositoryLayer(persistenceLayer); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); yield* Effect.gen(function* () { @@ -367,6 +378,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), + Layer.provideMerge(usageLimitsRepositoryLayer), ); yield* Effect.gen(function* () { @@ -409,6 +421,7 @@ it.effect( const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( Layer.provide(persistenceLayer), ); + const usageLimitsRepositoryLayer = makeUsageLimitsRepositoryLayer(persistenceLayer); const firstCodex = makeFakeCodexAdapter(); const firstRegistry: typeof ProviderAdapterRegistry.Service = { @@ -427,6 +440,7 @@ it.effect( Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), + Layer.provideMerge(usageLimitsRepositoryLayer), ); const updatedResumeCursor = { threadId: asThreadId("thread-1"), @@ -479,6 +493,7 @@ it.effect( Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), + Layer.provideMerge(usageLimitsRepositoryLayer), ); secondCodex.startSession.mockClear(); @@ -823,6 +838,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( Layer.provide(persistenceLayer), ); + const usageLimitsRepositoryLayer = makeUsageLimitsRepositoryLayer(persistenceLayer); const firstClaude = makeFakeCodexAdapter("claudeAgent"); const firstRegistry: typeof ProviderAdapterRegistry.Service = { @@ -840,6 +856,7 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), + Layer.provideMerge(usageLimitsRepositoryLayer), ); const initial = yield* Effect.gen(function* () { @@ -873,6 +890,7 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), + Layer.provideMerge(usageLimitsRepositoryLayer), ); secondClaude.startSession.mockClear(); @@ -948,6 +966,71 @@ fanout.layer("ProviderServiceLive fanout", (it) => { }), ); + it.effect( + "persists normalized usage limits from runtime account.rate-limits.updated events", + () => + Effect.gen(function* () { + const provider = yield* ProviderService; + const repository = yield* ProviderUsageLimitsRepository; + const session = yield* provider.startSession(asThreadId("thread-usage-limits"), { + provider: "codex", + threadId: asThreadId("thread-usage-limits"), + runtimeMode: "full-access", + }); + + fanout.codex.emit({ + type: "account.rate-limits.updated", + eventId: asEventId("evt-rate-limits-1"), + provider: "codex", + createdAt: "2026-04-04T00:00:00.000Z", + threadId: session.threadId, + payload: { + rateLimits: { + primary: { + usedPercent: 42.4, + windowDurationMins: 300, + resetsAt: 1_775_318_400, + }, + secondary: { + usedPercent: 15, + windowDurationMins: 10_080, + resetsAt: 1_775_836_800, + }, + }, + }, + }); + + for (let attempt = 0; attempt < 50; attempt += 1) { + const stored = yield* repository.getByProvider({ provider: "codex" }); + if (Option.isSome(stored)) { + assert.deepEqual(stored.value.usageLimits, { + updatedAt: "2026-04-04T00:00:00.000Z", + windows: [ + { + kind: "session", + label: "Session limit", + usedPercentage: 42, + resetsAt: "2026-04-04T16:00:00.000Z", + windowDurationMins: 300, + }, + { + kind: "weekly", + label: "Weekly limit", + usedPercentage: 15, + resetsAt: "2026-04-10T16:00:00.000Z", + windowDurationMins: 10_080, + }, + ], + }); + return; + } + yield* sleep(10); + } + + assert.fail("Timed out waiting for persisted usage limits"); + }), + ); + it.effect("fans out canonical runtime events in emission order", () => Effect.gen(function* () { const provider = yield* ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 85fe9fbc32..88ee59aaa8 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -44,6 +44,8 @@ import { import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { ProviderUsageLimitsRepository } from "../../persistence/Services/ProviderUsageLimits.ts"; +import { normalizeClaudeUsageLimits, normalizeCodexUsageLimits } from "../providerUsageLimits.ts"; export interface ProviderServiceLiveOptions { readonly canonicalEventLogPath?: string; @@ -156,6 +158,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const registry = yield* ProviderAdapterRegistry; const directory = yield* ProviderSessionDirectory; + const usageLimitsRepository = yield* ProviderUsageLimitsRepository; const runtimeEventPubSub = yield* PubSub.unbounded(); const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => @@ -187,11 +190,44 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const providers = yield* registry.listProviders(); const adapters = yield* Effect.forEach(providers, (provider) => registry.getByProvider(provider)); + const persistUsageLimitEvent = (event: ProviderRuntimeEvent): Effect.Effect => { + if (event.type !== "account.rate-limits.updated") { + return Effect.void; + } + + const usageLimits = + event.provider === "codex" + ? normalizeCodexUsageLimits(event.payload.rateLimits, event.createdAt) + : event.provider === "claudeAgent" + ? normalizeClaudeUsageLimits(event.payload.rateLimits, event.createdAt) + : undefined; + + if (!usageLimits) { + return Effect.void; + } + + return usageLimitsRepository + .upsert({ + provider: event.provider, + usageLimits, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logDebug("failed to persist provider usage limits", { + cause, + provider: event.provider, + }), + ), + ); + }; const processRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => increment(providerRuntimeEventsTotal, { provider: event.provider, eventType: event.type, - }).pipe(Effect.andThen(publishRuntimeEvent(event))); + }).pipe( + Effect.andThen(persistUsageLimitEvent(event)), + Effect.andThen(publishRuntimeEvent(event)), + ); yield* Effect.forEach(adapters, (adapter) => Stream.runForEach(adapter.streamEvents, processRuntimeEvent).pipe(Effect.forkScoped), diff --git a/apps/server/src/provider/codexAppServer.test.ts b/apps/server/src/provider/codexAppServer.test.ts new file mode 100644 index 0000000000..40a19b6e04 --- /dev/null +++ b/apps/server/src/provider/codexAppServer.test.ts @@ -0,0 +1,227 @@ +import { EventEmitter } from "node:events"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { PassThrough } from "node:stream"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { spawnMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), +})); + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + spawn: spawnMock, + spawnSync: vi.fn(), + }; +}); + +import { probeCodexAccountState } from "./codexAppServer"; + +type RateLimitsBehavior = "ignore" | "respond" | "respondWithLimitId"; + +function createMockCodexProbeChild( + rateLimitsBehavior: RateLimitsBehavior, +): ChildProcessWithoutNullStreams { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const emitter = new EventEmitter() as ChildProcessWithoutNullStreams; + + let killed = false; + let stdinBuffer = ""; + + const writeResponse = (message: unknown) => { + stdout.write(`${JSON.stringify(message)}\n`); + }; + + const finish = () => { + stdout.end(); + stderr.end(); + emitter.emit("close", 0, null); + }; + + Object.assign(emitter, { + stdin, + stdout, + stderr, + pid: 1234, + get killed() { + return killed; + }, + kill: () => { + killed = true; + finish(); + return true; + }, + }); + + stdin.setEncoding("utf8"); + stdin.on("data", (chunk: string) => { + stdinBuffer += chunk; + + let newlineIndex = stdinBuffer.indexOf("\n"); + while (newlineIndex !== -1) { + const line = stdinBuffer.slice(0, newlineIndex).trim(); + stdinBuffer = stdinBuffer.slice(newlineIndex + 1); + newlineIndex = stdinBuffer.indexOf("\n"); + if (!line) { + continue; + } + + const message = JSON.parse(line) as { id?: number; method?: string }; + + if (message.method === "initialize") { + writeResponse({ id: 1, result: {} }); + continue; + } + + if (message.id === 2 && message.method === "account/read") { + writeResponse({ + id: 2, + result: { + account: { + type: "chatgpt", + planType: "pro", + }, + }, + }); + continue; + } + + if (message.id === 3 && message.method === "account/rateLimits/read") { + if (rateLimitsBehavior === "respond") { + writeResponse({ + id: 3, + result: { + rateLimits: { + primary: { + usedPercent: 72.5, + windowDurationMins: 300, + resetsAt: 1730947200, + }, + secondary: { + usedPercent: 35, + windowDurationMins: 10080, + resetsAt: 1731000000, + }, + }, + }, + }); + } else if (rateLimitsBehavior === "respondWithLimitId") { + writeResponse({ + id: 3, + result: { + rateLimits: { + primary: { + usedPercent: 11, + windowDurationMins: 300, + resetsAt: 1730947200, + }, + }, + rateLimitsByLimitId: { + codex: { + primary: { + usedPercent: 27, + windowDurationMins: 10080, + resetsAt: 1731000000, + }, + }, + }, + }, + }); + } + + queueMicrotask(finish); + } + } + }); + + return emitter; +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("probeCodexAccountState", () => { + it("resolves when account/rateLimits/read is ignored", async () => { + spawnMock.mockImplementation(() => createMockCodexProbeChild("ignore")); + + const state = await probeCodexAccountState({ + binaryPath: "codex", + signal: AbortSignal.timeout(2_500), + }); + + expect(state.snapshot).toEqual({ + type: "chatgpt", + planType: "pro", + sparkEnabled: true, + }); + expect(state.account).toEqual({ + account: { + type: "chatgpt", + planType: "pro", + }, + }); + expect(state.rateLimits).toBeNull(); + }); + + it("includes rate limits when account/rateLimits/read responds", async () => { + spawnMock.mockImplementation(() => createMockCodexProbeChild("respond")); + + const state = await probeCodexAccountState({ + binaryPath: "codex", + signal: AbortSignal.timeout(1_000), + }); + + expect(state.snapshot).toEqual({ + type: "chatgpt", + planType: "pro", + sparkEnabled: true, + }); + expect(state.rateLimits).toEqual({ + rateLimits: { + primary: { + usedPercent: 72.5, + windowDurationMins: 300, + resetsAt: 1730947200, + }, + secondary: { + usedPercent: 35, + windowDurationMins: 10080, + resetsAt: 1731000000, + }, + }, + }); + }); + + it("preserves top-level rateLimitsByLimitId payloads from account/rateLimits/read", async () => { + spawnMock.mockImplementation(() => createMockCodexProbeChild("respondWithLimitId")); + + const state = await probeCodexAccountState({ + binaryPath: "codex", + signal: AbortSignal.timeout(1_000), + }); + + expect(state.rateLimits).toEqual({ + rateLimits: { + primary: { + usedPercent: 11, + windowDurationMins: 300, + resetsAt: 1730947200, + }, + }, + rateLimitsByLimitId: { + codex: { + primary: { + usedPercent: 27, + windowDurationMins: 10080, + resetsAt: 1731000000, + }, + }, + }, + }); + }); +}); diff --git a/apps/server/src/provider/codexAppServer.ts b/apps/server/src/provider/codexAppServer.ts index d25fc3533e..73c9719b67 100644 --- a/apps/server/src/provider/codexAppServer.ts +++ b/apps/server/src/provider/codexAppServer.ts @@ -1,5 +1,4 @@ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; -import readline from "node:readline"; import { readCodexAccountSnapshot, type CodexAccountSnapshot } from "./codexAccount"; interface JsonRpcProbeResponse { @@ -14,6 +13,10 @@ function readErrorMessage(response: JsonRpcProbeResponse): string | undefined { return typeof response.error?.message === "string" ? response.error.message : undefined; } +function readCodexRateLimitsSnapshot(response: unknown): unknown | null { + return response ?? null; +} + export function buildCodexInitializeParams() { return { clientInfo: { @@ -40,11 +43,17 @@ export function killCodexChildProcess(child: ChildProcessWithoutNullStreams): vo child.kill(); } -export async function probeCodexAccount(input: { +export interface CodexAccountState { + readonly snapshot: CodexAccountSnapshot; + readonly account: unknown | null; + readonly rateLimits: unknown | null; +} + +export async function probeCodexAccountState(input: { readonly binaryPath: string; readonly homePath?: string; readonly signal?: AbortSignal; -}): Promise { +}): Promise { return await new Promise((resolve, reject) => { const child = spawn(input.binaryPath, ["app-server"], { env: { @@ -54,13 +63,21 @@ export async function probeCodexAccount(input: { stdio: ["pipe", "pipe", "pipe"], shell: process.platform === "win32", }); - const output = readline.createInterface({ input: child.stdout }); - let completed = false; + let settleTimer: ReturnType | undefined; + let accountResult: unknown | undefined; + let rateLimitsResult: unknown | null | undefined; + let handleAbort: (() => void) | undefined; + let stdoutBuffer = ""; const cleanup = () => { - output.removeAllListeners(); - output.close(); + if (settleTimer !== undefined) { + clearTimeout(settleTimer); + } + if (handleAbort) { + input.signal?.removeEventListener("abort", handleAbort); + } + child.stdout.removeAllListeners(); child.removeAllListeners(); if (!child.killed) { killCodexChildProcess(child); @@ -87,7 +104,34 @@ export async function probeCodexAccount(input: { fail(new Error("Codex account probe aborted.")); return; } - input.signal?.addEventListener("abort", () => fail(new Error("Codex account probe aborted."))); + handleAbort = () => fail(new Error("Codex account probe aborted.")); + input.signal?.addEventListener("abort", handleAbort); + + const maybeResolve = () => { + if (accountResult === undefined) { + return; + } + + if (rateLimitsResult !== undefined) { + finish(() => + resolve({ + snapshot: readCodexAccountSnapshot(accountResult), + account: accountResult ?? null, + rateLimits: rateLimitsResult, + }), + ); + return; + } + + if (settleTimer !== undefined) { + return; + } + + settleTimer = setTimeout(() => { + rateLimitsResult = null; + maybeResolve(); + }, 150); + }; const writeMessage = (message: unknown) => { if (!child.stdin.writable) { @@ -98,7 +142,7 @@ export async function probeCodexAccount(input: { child.stdin.write(`${JSON.stringify(message)}\n`); }; - output.on("line", (line) => { + const processOutputLine = (line: string) => { let parsed: unknown; try { parsed = JSON.parse(line); @@ -121,6 +165,7 @@ export async function probeCodexAccount(input: { writeMessage({ method: "initialized" }); writeMessage({ id: 2, method: "account/read", params: {} }); + writeMessage({ id: 3, method: "account/rateLimits/read", params: {} }); return; } @@ -131,13 +176,53 @@ export async function probeCodexAccount(input: { return; } - finish(() => resolve(readCodexAccountSnapshot(response.result))); + accountResult = response.result ?? null; + maybeResolve(); + return; + } + + if (response.id === 3) { + const errorMessage = readErrorMessage(response); + if (errorMessage) { + rateLimitsResult = null; + maybeResolve(); + return; + } + + rateLimitsResult = readCodexRateLimitsSnapshot(response.result); + maybeResolve(); + } + }; + + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdoutBuffer += chunk; + + let newlineIndex = stdoutBuffer.indexOf("\n"); + while (newlineIndex !== -1) { + const line = stdoutBuffer.slice(0, newlineIndex).trim(); + stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1); + if (line.length > 0) { + processOutputLine(line); + } + newlineIndex = stdoutBuffer.indexOf("\n"); } }); child.once("error", fail); - child.once("exit", (code, signal) => { + child.once("close", (code, signal) => { if (completed) return; + const trailingLine = stdoutBuffer.trim(); + if (trailingLine.length > 0) { + stdoutBuffer = ""; + processOutputLine(trailingLine); + if (completed) return; + } + if (accountResult !== undefined) { + rateLimitsResult = rateLimitsResult ?? null; + maybeResolve(); + return; + } fail( new Error( `codex app-server exited before probe completed (code=${code ?? "null"}, signal=${signal ?? "null"}).`, diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index e1243c4bd0..a4a38427c6 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -1,6 +1,7 @@ import type { ServerProvider, ServerProviderAuth, + ServerProviderUsageLimits, ServerProviderModel, ServerProviderState, } from "@t3tools/contracts"; @@ -130,6 +131,7 @@ export function buildServerProvider(input: { checkedAt: string; models: ReadonlyArray; probe: ProviderProbeResult; + usageLimits?: ServerProviderUsageLimits; }): ServerProvider { return { provider: input.provider, @@ -141,6 +143,7 @@ export function buildServerProvider(input: { checkedAt: input.checkedAt, ...(input.probe.message ? { message: input.probe.message } : {}), models: input.models, + ...(input.usageLimits ? { usageLimits: input.usageLimits } : {}), }; } diff --git a/apps/server/src/provider/providerUsageLimits.test.ts b/apps/server/src/provider/providerUsageLimits.test.ts new file mode 100644 index 0000000000..ee33db6f9f --- /dev/null +++ b/apps/server/src/provider/providerUsageLimits.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeClaudeUsageLimits, normalizeCodexUsageLimits } from "./providerUsageLimits"; + +describe("normalizeCodexUsageLimits", () => { + it("returns weekly-only limits for free-tier style payloads", () => { + const usageLimits = normalizeCodexUsageLimits( + { + rateLimits: { + primary: { + usedPercent: 42, + windowDurationMins: 10_080, + resetsAt: 1_762_050_000, + }, + secondary: null, + }, + }, + "2026-04-04T00:00:00.000Z", + ); + + expect(usageLimits).toEqual({ + updatedAt: "2026-04-04T00:00:00.000Z", + windows: [ + { + kind: "weekly", + label: "Weekly limit", + usedPercentage: 42, + resetsAt: new Date(1_762_050_000 * 1000).toISOString(), + windowDurationMins: 10_080, + }, + ], + }); + }); + + it("returns session and weekly limits for dual-window payloads", () => { + const usageLimits = normalizeCodexUsageLimits( + { + rateLimits: { + primary: { + usedPercent: 73.6, + windowDurationMins: 300, + resetsAt: 1_762_040_000, + }, + secondary: { + usedPercent: 12, + windowDurationMins: 10_080, + resetsAt: 1_762_090_000, + }, + }, + }, + "2026-04-04T00:00:00.000Z", + ); + + expect(usageLimits?.windows).toEqual([ + { + kind: "session", + label: "Session limit", + usedPercentage: 74, + resetsAt: new Date(1_762_040_000 * 1000).toISOString(), + windowDurationMins: 300, + }, + { + kind: "weekly", + label: "Weekly limit", + usedPercentage: 12, + resetsAt: new Date(1_762_090_000 * 1000).toISOString(), + windowDurationMins: 10_080, + }, + ]); + }); + + it("prefers rateLimitsByLimitId.codex over fallback rateLimits", () => { + const usageLimits = normalizeCodexUsageLimits( + { + rateLimits: { + primary: { + usedPercent: 10, + windowDurationMins: 300, + resetsAt: 1_762_010_000, + }, + }, + rateLimitsByLimitId: { + codex: { + primary: { + usedPercent: 66, + windowDurationMins: 300, + resetsAt: 1_762_020_000, + }, + }, + }, + }, + "2026-04-04T00:00:00.000Z", + ); + + expect(usageLimits?.windows[0]).toEqual({ + kind: "session", + label: "Session limit", + usedPercentage: 66, + resetsAt: new Date(1_762_020_000 * 1000).toISOString(), + windowDurationMins: 300, + }); + }); +}); + +describe("normalizeClaudeUsageLimits", () => { + it("normalizes statusline-shaped Claude runtime payloads", () => { + const usageLimits = normalizeClaudeUsageLimits( + { + type: "rate_limit_event", + rate_limits: { + five_hour: { + used_percentage: 91.2, + resets_at: 1_762_030_000, + }, + seven_day: { + used_percentage: 17, + resets_at: 1_762_100_000, + }, + }, + }, + "2026-04-04T00:00:00.000Z", + ); + + expect(usageLimits?.windows).toEqual([ + { + kind: "session", + label: "Session limit", + usedPercentage: 91, + resetsAt: new Date(1_762_030_000 * 1000).toISOString(), + windowDurationMins: 300, + }, + { + kind: "weekly", + label: "Weekly limit", + usedPercentage: 17, + resetsAt: new Date(1_762_100_000 * 1000).toISOString(), + windowDurationMins: 10_080, + }, + ]); + }); +}); diff --git a/apps/server/src/provider/providerUsageLimits.ts b/apps/server/src/provider/providerUsageLimits.ts new file mode 100644 index 0000000000..0310732b4c --- /dev/null +++ b/apps/server/src/provider/providerUsageLimits.ts @@ -0,0 +1,238 @@ +import type { + ProviderKind, + ServerProviderUsageLimits, + ServerProviderUsageWindow, + ServerProviderUsageWindowKind, +} from "@t3tools/contracts"; +import { Effect, Option } from "effect"; + +import { ProviderUsageLimitsRepository } from "../persistence/Services/ProviderUsageLimits.ts"; + +const SESSION_WINDOW_DURATION_MINS = 300; +const WEEKLY_WINDOW_DURATION_MINS = 10_080; + +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + return value as Record; +} + +function asFiniteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function clampUsedPercentage(value: unknown): number | undefined { + const number = asFiniteNumber(value); + if (number === undefined) return undefined; + return Math.max(0, Math.min(100, Math.round(number))); +} + +function readUnixSecondsAsIso(value: unknown): string | undefined { + const unixSeconds = asFiniteNumber(value); + if (unixSeconds === undefined) return undefined; + return new Date(unixSeconds * 1000).toISOString(); +} + +function readIsoDate(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? undefined : new Date(timestamp).toISOString(); +} + +function readIsoDateTime(value: unknown): string | undefined { + return readUnixSecondsAsIso(value) ?? readIsoDate(value); +} + +function readWindowDurationMins( + record: Record, + fallback: number | null, +): number | null { + const duration = + asFiniteNumber(record.windowDurationMins) ?? + asFiniteNumber(record.window_duration_mins) ?? + asFiniteNumber(record.windowMins) ?? + asFiniteNumber(record.window_minutes); + if (duration === undefined) { + return fallback; + } + return Math.max(0, Math.round(duration)); +} + +function createUsageWindow(input: { + readonly kind: ServerProviderUsageWindowKind; + readonly usedPercentage: unknown; + readonly resetsAt: unknown; + readonly windowDurationMins: number | null; +}): ServerProviderUsageWindow | undefined { + const usedPercentage = clampUsedPercentage(input.usedPercentage); + const resetsAt = readIsoDateTime(input.resetsAt); + + if (usedPercentage === undefined || resetsAt === undefined) { + return undefined; + } + + if (input.kind === "session") { + return { + kind: "session", + label: "Session limit", + usedPercentage, + resetsAt, + windowDurationMins: input.windowDurationMins, + }; + } + + return { + kind: "weekly", + label: "Weekly limit", + usedPercentage, + resetsAt, + windowDurationMins: input.windowDurationMins, + }; +} + +function buildUsageLimits( + windows: ReadonlyArray, + updatedAt: string, +): ServerProviderUsageLimits | undefined { + const normalizedUpdatedAt = readIsoDateTime(updatedAt); + if (!normalizedUpdatedAt) { + return undefined; + } + + const resolvedWindows = windows.flatMap((window) => (window ? [window] : [])); + if (resolvedWindows.length === 0) { + return undefined; + } + + return { + updatedAt: normalizedUpdatedAt, + windows: resolvedWindows.toSorted((left, right) => + left.kind === right.kind ? 0 : left.kind === "session" ? -1 : 1, + ), + }; +} + +function inferCodexWindowKind( + name: string, + record: Record, +): ServerProviderUsageWindowKind | undefined { + const normalizedName = name.toLowerCase(); + const windowDurationMins = readWindowDurationMins(record, null); + + if (windowDurationMins === SESSION_WINDOW_DURATION_MINS) return "session"; + if (windowDurationMins === WEEKLY_WINDOW_DURATION_MINS) return "weekly"; + + if (normalizedName.includes("session") || normalizedName.includes("primary")) { + return "session"; + } + if ( + normalizedName.includes("week") || + normalizedName.includes("secondary") || + normalizedName.includes("seven") + ) { + return "weekly"; + } + + return undefined; +} + +function normalizeCodexWindow(name: string, value: unknown): ServerProviderUsageWindow | undefined { + const record = asRecord(value); + if (!record) return undefined; + + const kind = inferCodexWindowKind(name, record); + if (!kind) return undefined; + + return createUsageWindow({ + kind, + usedPercentage: record.usedPercent ?? record.used_percentage ?? record.percentage, + resetsAt: record.resetsAt ?? record.resets_at ?? record.resetAt ?? record.reset_at, + windowDurationMins: readWindowDurationMins( + record, + kind === "session" ? SESSION_WINDOW_DURATION_MINS : WEEKLY_WINDOW_DURATION_MINS, + ), + }); +} + +function getPreferredCodexRateLimitsRoot(rateLimits: unknown): Record | undefined { + const root = asRecord(rateLimits); + if (!root) return undefined; + + const rateLimitsByLimitId = asRecord(root.rateLimitsByLimitId); + const preferred = asRecord(rateLimitsByLimitId?.codex); + if (preferred) { + return preferred; + } + + return asRecord(root.rateLimits) ?? root; +} + +export function normalizeCodexUsageLimits( + rateLimits: unknown, + updatedAt: string, +): ServerProviderUsageLimits | undefined { + const root = getPreferredCodexRateLimitsRoot(rateLimits); + if (!root) return undefined; + + return buildUsageLimits( + [ + normalizeCodexWindow("primary", root.primary), + normalizeCodexWindow("secondary", root.secondary), + ], + updatedAt, + ); +} + +function getClaudeRateLimitsRoot(rateLimits: unknown): Record | undefined { + const root = asRecord(rateLimits); + if (!root) return undefined; + return asRecord(root.rate_limits) ?? asRecord(root.rateLimits) ?? root; +} + +function normalizeClaudeWindow( + value: unknown, + input: { + readonly kind: ServerProviderUsageWindowKind; + readonly defaultDurationMins: number; + }, +): ServerProviderUsageWindow | undefined { + const record = asRecord(value); + if (!record) return undefined; + + return createUsageWindow({ + kind: input.kind, + usedPercentage: record.used_percentage ?? record.usedPercent ?? record.percentage, + resetsAt: record.resets_at ?? record.resetsAt ?? record.reset_at ?? record.resetAt, + windowDurationMins: readWindowDurationMins(record, input.defaultDurationMins), + }); +} + +export function normalizeClaudeUsageLimits( + rateLimits: unknown, + updatedAt: string, +): ServerProviderUsageLimits | undefined { + const root = getClaudeRateLimitsRoot(rateLimits); + if (!root) return undefined; + + return buildUsageLimits( + [ + normalizeClaudeWindow(root.five_hour ?? root.fiveHour, { + kind: "session", + defaultDurationMins: SESSION_WINDOW_DURATION_MINS, + }), + normalizeClaudeWindow(root.seven_day ?? root.sevenDay, { + kind: "weekly", + defaultDurationMins: WEEKLY_WINDOW_DURATION_MINS, + }), + ], + updatedAt, + ); +} + +export const readPersistedProviderUsageLimits = Effect.fn("readPersistedProviderUsageLimits")( + function* (provider: ProviderKind, repository: typeof ProviderUsageLimitsRepository.Service) { + const stored = yield* repository.getByProvider({ provider }); + return Option.getOrUndefined(stored)?.usageLimits; + }, +); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f56edde6fa..9ac54a6899 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -17,6 +17,7 @@ import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; +import { ProviderUsageLimitsRepositoryLive } from "./persistence/Layers/ProviderUsageLimits"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; @@ -159,7 +160,10 @@ const ProviderLayerLive = Layer.unwrap( }), ); -const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); +const PersistenceLayerLive = Layer.mergeAll( + SqlitePersistenceLayerLive, + ProviderUsageLimitsRepositoryLive.pipe(Layer.provide(SqlitePersistenceLayerLive)), +); const GitLayerLive = Layer.empty.pipe( Layer.provideMerge( @@ -188,12 +192,12 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), Layer.provideMerge(GitLayerLive), - Layer.provideMerge(OrchestrationLayerLive), - Layer.provideMerge(ProviderLayerLive), - Layer.provideMerge(TerminalLayerLive), + Layer.provideMerge(OrchestrationLayerLive.pipe(Layer.provide(PersistenceLayerLive))), Layer.provideMerge(PersistenceLayerLive), + Layer.provideMerge(ProviderLayerLive.pipe(Layer.provide(PersistenceLayerLive))), + Layer.provideMerge(TerminalLayerLive), Layer.provideMerge(KeybindingsLive), - Layer.provideMerge(ProviderRegistryLive), + Layer.provideMerge(ProviderRegistryLive.pipe(Layer.provide(PersistenceLayerLive))), Layer.provideMerge(ServerSettingsLive), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLive), diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 090f6f12ad..35bd65e266 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -8,6 +8,7 @@ import { render } from "vitest-browser-react"; import { __resetNativeApiForTests } from "../../nativeApi"; import { AppAtomRegistryProvider } from "../../rpc/atomRegistry"; import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; +import { getTimestampFormatOptions } from "../../timestampFormat"; import { GeneralSettingsPanel } from "./SettingsPanels"; function createBaseServerConfig(): ServerConfig { @@ -29,6 +30,15 @@ function createBaseServerConfig(): ServerConfig { }; } +function formatResetLabel(isoDate: string, timestampFormat: "locale" | "12-hour" | "24-hour") { + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + ...getTimestampFormatOptions(timestampFormat, false), + }).format(new Date(isoDate)); +} + describe("GeneralSettingsPanel observability", () => { beforeEach(() => { resetServerStateForTests(); @@ -88,4 +98,126 @@ describe("GeneralSettingsPanel observability", () => { expect(openInEditor).toHaveBeenCalledWith("/repo/project/.t3/logs", "cursor"); }); + + it("renders both provider usage-limit rows when both windows exist", async () => { + setServerConfigSnapshot({ + ...createBaseServerConfig(), + providers: [ + { + provider: "codex", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated", label: "ChatGPT Pro Subscription" }, + checkedAt: "2026-04-04T00:00:00.000Z", + models: [], + usageLimits: { + updatedAt: "2026-04-04T00:00:00.000Z", + windows: [ + { + kind: "session", + label: "Session limit", + usedPercentage: 61, + resetsAt: "2026-04-04T05:00:00.000Z", + windowDurationMins: 300, + }, + { + kind: "weekly", + label: "Weekly limit", + usedPercentage: 22, + resetsAt: "2026-04-08T00:00:00.000Z", + windowDurationMins: 10_080, + }, + ], + }, + }, + ], + }); + + await render( + + + , + ); + + await expect.element(page.getByText("Session limit")).toBeInTheDocument(); + await expect.element(page.getByText("Weekly limit")).toBeInTheDocument(); + await expect.element(page.getByText("39% remaining")).toBeInTheDocument(); + await expect.element(page.getByText("78% remaining")).toBeInTheDocument(); + await expect + .element(page.getByText(`Resets ${formatResetLabel("2026-04-04T05:00:00.000Z", "locale")}`)) + .toBeInTheDocument(); + await expect + .element(page.getByText(`Resets ${formatResetLabel("2026-04-08T00:00:00.000Z", "locale")}`)) + .toBeInTheDocument(); + }); + + it("renders only weekly provider usage when the session window is absent", async () => { + setServerConfigSnapshot({ + ...createBaseServerConfig(), + providers: [ + { + provider: "claudeAgent", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated", label: "Claude Pro Subscription" }, + checkedAt: "2026-04-04T00:00:00.000Z", + models: [], + usageLimits: { + updatedAt: "2026-04-04T00:00:00.000Z", + windows: [ + { + kind: "weekly", + label: "Weekly limit", + usedPercentage: 40, + resetsAt: "2026-04-10T00:00:00.000Z", + windowDurationMins: 10_080, + }, + ], + }, + }, + ], + }); + + await render( + + + , + ); + + await expect.element(page.getByText("Weekly limit")).toBeInTheDocument(); + await expect.element(page.getByText("60% remaining")).toBeInTheDocument(); + await expect.element(page.getByText("Session limit")).not.toBeInTheDocument(); + }); + + it("keeps provider cards unchanged when usage limits are absent", async () => { + setServerConfigSnapshot({ + ...createBaseServerConfig(), + providers: [ + { + provider: "codex", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-04-04T00:00:00.000Z", + models: [], + }, + ], + }); + + await render( + + + , + ); + + await expect.element(page.getByText("Authenticated")).toBeInTheDocument(); + await expect.element(page.getByText("Session limit")).not.toBeInTheDocument(); + await expect.element(page.getByText("Weekly limit")).not.toBeInTheDocument(); + }); }); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d534eefaa4..68c82e1a65 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -47,7 +47,11 @@ import { } from "../../modelSelection"; import { ensureNativeApi, readNativeApi } from "../../nativeApi"; import { useStore } from "../../store"; -import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; +import { + formatRelativeTime, + formatRelativeTimeLabel, + getTimestampFormatOptions, +} from "../../timestampFormat"; import { cn } from "../../lib/utils"; import { Button } from "../ui/button"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; @@ -217,6 +221,69 @@ function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null } ); } +function formatUsageLimitResetLabel( + resetsAt: string, + timestampFormat: (typeof DEFAULT_UNIFIED_SETTINGS)["timestampFormat"], +) { + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + ...getTimestampFormatOptions(timestampFormat, false), + }).format(new Date(resetsAt)); +} + +function ProviderUsageLimitsSection({ + provider, + timestampFormat, +}: { + provider: ServerProvider; + timestampFormat: (typeof DEFAULT_UNIFIED_SETTINGS)["timestampFormat"]; +}) { + const windows = provider.usageLimits?.windows ?? []; + + if (windows.length === 0) { + return null; + } + + return ( +
+
+ {windows.map((window) => { + const remainingPercentage = Math.max(0, 100 - window.usedPercentage); + + return ( +
+
+ {window.label} + + {remainingPercentage}% remaining + +
+
+
+
+
+ Resets {formatUsageLimitResetLabel(window.resetsAt, timestampFormat)} +
+
+ ); + })} +
+
+ ); +} + function SettingsSection({ title, icon, @@ -1194,6 +1261,13 @@ export function GeneralSettingsPanel() {
+ {providerCard.liveProvider ? ( + + ) : null} + diff --git a/apps/web/src/rpc/serverState.test.ts b/apps/web/src/rpc/serverState.test.ts index 721ce25fb5..555bfd5685 100644 --- a/apps/web/src/rpc/serverState.test.ts +++ b/apps/web/src/rpc/serverState.test.ts @@ -47,6 +47,18 @@ const defaultProviders: ReadonlyArray = [ auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", models: [], + usageLimits: { + updatedAt: "2026-01-01T00:00:00.000Z", + windows: [ + { + kind: "weekly", + label: "Weekly limit", + usedPercentage: 12, + resetsAt: "2026-01-08T00:00:00.000Z", + windowDurationMins: 10_080, + }, + ], + }, }, ]; @@ -239,6 +251,25 @@ describe("serverState", () => { status: "warning", checkedAt: "2026-01-02T00:00:00.000Z", message: "rate limited", + usageLimits: { + updatedAt: "2026-01-02T00:00:00.000Z", + windows: [ + { + kind: "session", + label: "Session limit", + usedPercentage: 66, + resetsAt: "2026-01-02T05:00:00.000Z", + windowDurationMins: 300, + }, + { + kind: "weekly", + label: "Weekly limit", + usedPercentage: 24, + resetsAt: "2026-01-09T00:00:00.000Z", + windowDurationMins: 10_080, + }, + ], + }, }, ]; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c60856bbe5..4b9d2a3711 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -3,6 +3,7 @@ export * from "./ipc"; export * from "./terminal"; export * from "./provider"; export * from "./providerRuntime"; +export * from "./providerUsageLimits"; export * from "./model"; export * from "./keybindings"; export * from "./server"; diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts new file mode 100644 index 0000000000..e7003986af --- /dev/null +++ b/packages/contracts/src/providerUsageLimits.ts @@ -0,0 +1,32 @@ +import { Schema } from "effect"; +import { IsoDateTime, NonNegativeInt } from "./baseSchemas"; + +export const ServerProviderUsageWindowKind = Schema.Literals(["session", "weekly"]); +export type ServerProviderUsageWindowKind = typeof ServerProviderUsageWindowKind.Type; + +const UsedPercentage = NonNegativeInt.check(Schema.isLessThanOrEqualTo(100)); + +const SessionUsageWindow = Schema.Struct({ + kind: Schema.Literal("session"), + label: Schema.Literal("Session limit"), + usedPercentage: UsedPercentage, + resetsAt: IsoDateTime, + windowDurationMins: Schema.NullOr(NonNegativeInt), +}); + +const WeeklyUsageWindow = Schema.Struct({ + kind: Schema.Literal("weekly"), + label: Schema.Literal("Weekly limit"), + usedPercentage: UsedPercentage, + resetsAt: IsoDateTime, + windowDurationMins: Schema.NullOr(NonNegativeInt), +}); + +export const ServerProviderUsageWindow = Schema.Union([SessionUsageWindow, WeeklyUsageWindow]); +export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type; + +export const ServerProviderUsageLimits = Schema.Struct({ + windows: Schema.Array(ServerProviderUsageWindow), + updatedAt: IsoDateTime, +}); +export type ServerProviderUsageLimits = typeof ServerProviderUsageLimits.Type; diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts new file mode 100644 index 0000000000..91121cb04f --- /dev/null +++ b/packages/contracts/src/server.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { Schema } from "effect"; + +import { ServerProvider } from "./server"; + +const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); + +describe("ServerProvider", () => { + it("accepts providers without usage limits", () => { + const parsed = decodeServerProvider({ + provider: "codex", + enabled: true, + installed: true, + version: "1.2.3", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-03-31T10:00:00.000Z", + models: [], + }); + + expect(parsed.usageLimits).toBeUndefined(); + }); + + it("accepts normalized usage limits", () => { + const parsed = decodeServerProvider({ + provider: "claudeAgent", + enabled: true, + installed: true, + version: "0.9.0", + status: "ready", + auth: { status: "authenticated", type: "max", label: "Claude Max Subscription" }, + checkedAt: "2026-03-31T10:00:00.000Z", + models: [], + usageLimits: { + updatedAt: "2026-03-31T10:02:00.000Z", + windows: [ + { + kind: "session", + label: "Session limit", + usedPercentage: 72, + resetsAt: "2026-03-31T15:00:00.000Z", + windowDurationMins: 300, + }, + { + kind: "weekly", + label: "Weekly limit", + usedPercentage: 35, + resetsAt: "2026-04-01T00:00:00.000Z", + windowDurationMins: 10080, + }, + ], + }, + }); + + expect(parsed.usageLimits?.windows).toHaveLength(2); + expect(parsed.usageLimits?.windows[0]?.kind).toBe("session"); + expect(parsed.usageLimits?.windows[1]?.kind).toBe("weekly"); + }); + + it("rejects malformed usage-limit percentages", () => { + expect(() => + decodeServerProvider({ + provider: "codex", + enabled: true, + installed: true, + version: "1.2.3", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-03-31T10:00:00.000Z", + models: [], + usageLimits: { + updatedAt: "2026-03-31T10:02:00.000Z", + windows: [ + { + kind: "weekly", + label: "Weekly limit", + usedPercentage: 101, + resetsAt: "2026-04-01T00:00:00.000Z", + windowDurationMins: 10080, + }, + ], + }, + }), + ).toThrow(); + }); +}); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 776a0a89e9..21ba90f296 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -11,6 +11,7 @@ import { EditorId } from "./editor"; import { ModelCapabilities } from "./model"; import { ProviderKind } from "./orchestration"; import { ServerSettings } from "./settings"; +import { ServerProviderUsageLimits } from "./providerUsageLimits"; const KeybindingsMalformedConfigIssue = Schema.Struct({ kind: Schema.Literal("keybindings.malformed-config"), @@ -66,6 +67,7 @@ export const ServerProvider = Schema.Struct({ checkedAt: IsoDateTime, message: Schema.optional(TrimmedNonEmptyString), models: Schema.Array(ServerProviderModel), + usageLimits: Schema.optional(ServerProviderUsageLimits), }); export type ServerProvider = typeof ServerProvider.Type;