diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index e3f15d865c9..7111c84f6b8 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -17,6 +17,7 @@ import * as Cache from "effect/Cache"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -48,6 +49,9 @@ import { resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; +import { PtyAdapter } from "../../terminal/Services/PTY.ts"; +import { ProviderUsageState } from "../Services/ProviderUsageState.ts"; + const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); @@ -112,6 +116,10 @@ export const ClaudeDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; + const ptyAdapter = Option.getOrUndefined(yield* Effect.serviceOption(PtyAdapter)); + const providerUsageState = Option.getOrUndefined( + yield* Effect.serviceOption(ProviderUsageState), + ); const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ @@ -155,6 +163,9 @@ export const ClaudeDriver: ProviderDriver = { effectiveConfig, () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), processEnv, + ptyAdapter ?? undefined, + instanceId, + providerUsageState, ).pipe( Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index b399f9aa948..6dbac807cc8 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -16,6 +16,7 @@ import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tool import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -32,6 +33,7 @@ import { enrichCursorSnapshot, } from "../Layers/CursorProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { ProviderUsageState } from "../Services/ProviderUsageState.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { defaultProviderContinuationIdentity, @@ -121,8 +123,16 @@ export const CursorDriver: ProviderDriver = { instanceId, }); const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); + const providerUsageState = Option.getOrUndefined( + yield* Effect.serviceOption(ProviderUsageState), + ); - const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe( + const checkProvider = checkCursorProviderStatus( + effectiveConfig, + processEnv, + instanceId, + providerUsageState, + ).pipe( Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index e9bd8fb7a16..9fcd518ead5 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -1025,7 +1025,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.make(id)); - const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const makeEventStamp = () => + Effect.map(Effect.all({ eventId: nextEventId, createdAt: nowIso }), (stamp) => ({ + ...stamp, + providerInstanceId: boundInstanceId, + })); const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); @@ -1185,6 +1189,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: deltaStamp.eventId, provider: PROVIDER, createdAt: deltaStamp.createdAt, + providerInstanceId: deltaStamp.providerInstanceId, threadId: context.session.threadId, turnId: turnState.turnId, itemId: asRuntimeItemId(block.itemId), @@ -1216,6 +1221,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, itemId: asRuntimeItemId(block.itemId), threadId: context.session.threadId, turnId: turnState.turnId, @@ -1308,6 +1314,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, payload: { providerThreadId: nextThreadId, @@ -1339,6 +1346,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}), payload: { @@ -1362,6 +1370,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}), payload: { @@ -1403,6 +1412,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, turnId: turnState.turnId, payload: { @@ -1468,6 +1478,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: usageStamp.eventId, provider: PROVIDER, createdAt: usageStamp.createdAt, + providerInstanceId: usageStamp.providerInstanceId, threadId: context.session.threadId, payload: { usage: usageSnapshot, @@ -1482,6 +1493,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, payload: { state: status, @@ -1505,6 +1517,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: toolStamp.eventId, provider: PROVIDER, createdAt: toolStamp.createdAt, + providerInstanceId: toolStamp.providerInstanceId, threadId: context.session.threadId, turnId: turnState.turnId, itemId: asRuntimeItemId(tool.itemId), @@ -1552,6 +1565,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: usageStamp.eventId, provider: PROVIDER, createdAt: usageStamp.createdAt, + providerInstanceId: usageStamp.providerInstanceId, threadId: context.session.threadId, turnId: turnState.turnId, payload: { @@ -1567,6 +1581,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, turnId: turnState.turnId, payload: { @@ -1639,6 +1654,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, turnId: context.turnState.turnId, ...(assistantBlockEntry?.block @@ -1702,6 +1718,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, ...(context.turnState ? { @@ -1739,6 +1756,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: planStamp.eventId, provider: PROVIDER, createdAt: planStamp.createdAt, + providerInstanceId: planStamp.providerInstanceId, threadId: context.session.threadId, ...(context.turnState ? { @@ -1801,6 +1819,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), itemId: asRuntimeItemId(tool.itemId), @@ -1878,6 +1897,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: updatedStamp.eventId, provider: PROVIDER, createdAt: updatedStamp.createdAt, + providerInstanceId: updatedStamp.providerInstanceId, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), itemId: asRuntimeItemId(tool.itemId), @@ -1906,6 +1926,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: deltaStamp.eventId, provider: PROVIDER, createdAt: deltaStamp.createdAt, + providerInstanceId: deltaStamp.providerInstanceId, threadId: context.session.threadId, turnId: context.turnState.turnId, itemId: asRuntimeItemId(tool.itemId), @@ -1930,6 +1951,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: completedStamp.eventId, provider: PROVIDER, createdAt: completedStamp.createdAt, + providerInstanceId: completedStamp.providerInstanceId, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), itemId: asRuntimeItemId(tool.itemId), @@ -1988,6 +2010,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: turnStartedStamp.eventId, provider: PROVIDER, createdAt: turnStartedStamp.createdAt, + providerInstanceId: turnStartedStamp.providerInstanceId, threadId: context.session.threadId, turnId, payload: {}, @@ -2072,6 +2095,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), providerRefs: nativeProviderRefs(context), @@ -2175,6 +2199,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...base, eventId: usageStamp.eventId, createdAt: usageStamp.createdAt, + providerInstanceId: usageStamp.providerInstanceId, type: "thread.token-usage.updated", payload: { usage: normalizedUsage, @@ -2207,6 +2232,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...base, eventId: usageStamp.eventId, createdAt: usageStamp.createdAt, + providerInstanceId: usageStamp.providerInstanceId, type: "thread.token-usage.updated", payload: { usage: normalizedUsage, @@ -2266,6 +2292,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), providerRefs: nativeProviderRefs(context), @@ -2428,6 +2455,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), requestId: asRuntimeRequestId(requestId), @@ -2482,6 +2510,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, payload: { reason: "Session stopped", @@ -2625,6 +2654,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: requestedStamp.eventId, provider: PROVIDER, createdAt: requestedStamp.createdAt, + providerInstanceId: requestedStamp.providerInstanceId, threadId: context.session.threadId, ...(context.turnState ? { @@ -2672,6 +2702,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: resolvedStamp.eventId, provider: PROVIDER, createdAt: resolvedStamp.createdAt, + providerInstanceId: resolvedStamp.providerInstanceId, threadId: context.session.threadId, ...(context.turnState ? { @@ -2775,6 +2806,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: requestedStamp.eventId, provider: PROVIDER, createdAt: requestedStamp.createdAt, + providerInstanceId: requestedStamp.providerInstanceId, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), requestId: asRuntimeRequestId(requestId), @@ -2823,6 +2855,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: resolvedStamp.eventId, provider: PROVIDER, createdAt: resolvedStamp.createdAt, + providerInstanceId: resolvedStamp.providerInstanceId, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), requestId: asRuntimeRequestId(requestId), @@ -3012,6 +3045,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: sessionStartedStamp.eventId, provider: PROVIDER, createdAt: sessionStartedStamp.createdAt, + providerInstanceId: sessionStartedStamp.providerInstanceId, threadId, payload: input.resumeCursor !== undefined ? { resume: input.resumeCursor } : {}, providerRefs: {}, @@ -3023,6 +3057,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: configuredStamp.eventId, provider: PROVIDER, createdAt: configuredStamp.createdAt, + providerInstanceId: configuredStamp.providerInstanceId, threadId, payload: { config: { @@ -3042,6 +3077,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: readyStamp.eventId, provider: PROVIDER, createdAt: readyStamp.createdAt, + providerInstanceId: readyStamp.providerInstanceId, threadId, payload: { state: "ready", @@ -3146,6 +3182,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: turnStartedStamp.eventId, provider: PROVIDER, createdAt: turnStartedStamp.createdAt, + providerInstanceId: turnStartedStamp.providerInstanceId, threadId: context.session.threadId, turnId, payload: modelSelection?.model ? { model: modelSelection.model } : {}, diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index f787129af89..f0b4751260c 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -3,6 +3,7 @@ import { type ModelCapabilities, type ModelSelection, ProviderDriverKind, + type ProviderInstanceId, type ServerProviderModel, type ServerProviderSlashCommand, } from "@t3tools/contracts"; @@ -38,6 +39,10 @@ import { type ServerProviderDraft, } from "../providerSnapshot.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import { probeClaudeUsageLimits } from "../claudeUsageProbe.ts"; +import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; +import type { PtyAdapterShape } from "../../terminal/Services/PTY.ts"; +import type { ProviderUsageStateShape } from "../Services/ProviderUsageState.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], @@ -520,6 +525,9 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( claudeSettings: ClaudeSettings, ) => Effect.Effect, environment: NodeJS.ProcessEnv = process.env, + ptyAdapter?: PtyAdapterShape, + instanceId?: ProviderInstanceId, + providerUsageState?: ProviderUsageStateShape, ): Effect.fn.Return< ServerProviderDraft, never, @@ -644,6 +652,32 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } + const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); + const runtimeUsageLimits = providerUsageState + ? yield* providerUsageState + .get(PROVIDER, instanceId) + .pipe(Effect.orElseSucceed(() => undefined)) + : undefined; + + const usageLimits = runtimeUsageLimits + ? runtimeUsageLimits + : ptyAdapter + ? yield* probeClaudeUsageLimits( + { + binaryPath: claudeSettings.binaryPath, + launchArgs: claudeSettings.launchArgs, + cwd: process.cwd(), + checkedAt, + environment: claudeEnvironment, + }, + ptyAdapter, + ).pipe(Effect.map((result) => result.usageLimits)) + : makeUnavailableUsageLimits({ + source: "claudeStatusProbe", + checkedAt, + reason: "Usage limits unavailable for this Claude instance in the current runtime.", + }); + const authMetadata = claudeAuthMetadata({ subscriptionType: capabilities.subscriptionType, authMethod: capabilities.tokenSource, @@ -664,6 +698,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ...(authMetadata ? authMetadata : {}), }, ...(opus47UpgradeMessage ? { message: opus47UpgradeMessage } : {}), + ...(usageLimits ? { usageLimits } : {}), }, }); }); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 28af1cda27b..29204cf5338 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -433,6 +433,7 @@ function runtimeEventBase( provider: event.provider, threadId: canonicalThreadId, createdAt: event.createdAt, + ...(event.providerInstanceId ? { providerInstanceId: event.providerInstanceId } : {}), ...(event.turnId ? { turnId: event.turnId } : {}), ...(event.itemId ? { itemId: asRuntimeItemId(event.itemId) } : {}), ...(event.requestId ? { requestId: asRuntimeRequestId(event.requestId) } : {}), diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 178450fb7fd..67644dbb252 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -19,8 +19,14 @@ import type { ModelCapabilities, ServerProviderModel, ServerProviderSkill, + ServerProviderUsageLimits, } from "@t3tools/contracts"; import { ServerSettingsError } from "@t3tools/contracts"; +import { + makeUnavailableUsageLimits, + makeUsageLimitsSnapshot, + type RawUsageWindowInput, +} from "../providerUsageLimits.ts"; import { createModelCapabilities } from "@t3tools/shared/model"; import { @@ -39,6 +45,7 @@ const CODEX_PRESENTATION = { export interface CodexAppServerProviderSnapshot { readonly account: CodexSchema.V2GetAccountResponse; + readonly rateLimits?: CodexSchema.V2GetAccountRateLimitsResponse__RateLimitSnapshot; readonly version: string | undefined; readonly models: ReadonlyArray; readonly skills: ReadonlyArray; @@ -301,24 +308,77 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun } satisfies CodexAppServerProviderSnapshot; } - const [skillsResponse, models] = yield* Effect.all( + const [skillsResponse, models, rateLimitsResponse] = yield* Effect.all( [ client.request("skills/list", { cwds: [input.cwd], }), requestAllCodexModels(client), + client.request("account/rateLimits/read", undefined).pipe( + // Rate limits are optional metadata and should not fail the whole provider probe. + Effect.catch(() => Effect.void), + ), ], { concurrency: "unbounded" }, ); return { account: accountResponse, + ...(rateLimitsResponse?.rateLimits ? { rateLimits: rateLimitsResponse.rateLimits } : {}), version, models: appendCustomCodexModels(models, input.customModels ?? []), skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), } satisfies CodexAppServerProviderSnapshot; }); +const CODEX_PRIMARY_WINDOW_DURATION_MINS = 300; // ~5 hours (short / session window) +const CODEX_SECONDARY_WINDOW_DURATION_MINS = 10080; // 7 days (weekly window) + +function resolveCodexManagedUsageLimits( + checkedAt: string, + rateLimitsSnapshot?: CodexSchema.V2GetAccountRateLimitsResponse__RateLimitSnapshot | null, +): ServerProviderUsageLimits { + if (!rateLimitsSnapshot) { + return makeUnavailableUsageLimits({ + source: "codexAppServer", + checkedAt, + reason: "No Codex subscription quota windows reported.", + }); + } + + const windows: RawUsageWindowInput[] = []; + + const addWindow = ( + window?: CodexSchema.V2GetAccountRateLimitsResponse__RateLimitWindow | null, + fallbackDurationMins?: number, + label?: string, + ) => { + if (!window) return; + const durationMins = + typeof window.windowDurationMins === "number" + ? window.windowDurationMins + : fallbackDurationMins; + windows.push({ + label: label ?? "Quota", + usedPercent: window.usedPercent, + ...(typeof window.resetsAt === "number" + ? { resetsAt: DateTime.formatIso(DateTime.makeUnsafe(window.resetsAt * 1000)) } + : {}), + ...(typeof durationMins === "number" ? { windowDurationMins: durationMins } : {}), + }); + }; + + addWindow(rateLimitsSnapshot.primary, CODEX_PRIMARY_WINDOW_DURATION_MINS, "Session"); + addWindow(rateLimitsSnapshot.secondary, CODEX_SECONDARY_WINDOW_DURATION_MINS, "Weekly"); + + return makeUsageLimitsSnapshot({ + source: "codexAppServer", + checkedAt, + windows, + unavailableReason: "No Codex subscription quota windows reported.", + }); +} + const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => codexSettings.customModels .map((model) => model.trim()) @@ -490,6 +550,14 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const snapshot = probeResult.success.value; const accountStatus = accountProbeStatus(snapshot.account); + const usageLimits = + snapshot.account.account?.type === "apiKey" + ? makeUnavailableUsageLimits({ + source: "codexAppServer", + checkedAt, + reason: "Usage limits unavailable for API key Codex accounts.", + }) + : resolveCodexManagedUsageLimits(checkedAt, snapshot.rateLimits); return buildServerProvider({ presentation: CODEX_PRESENTATION, @@ -503,6 +571,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu status: accountStatus.status, auth: accountStatus.auth, ...(accountStatus.message ? { message: accountStatus.message } : {}), + usageLimits, }, }); }); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index efef5f0a83b..d7f988ec8c6 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -56,6 +56,7 @@ import { makeAcpRequestOpenedEvent, makeAcpRequestResolvedEvent, makeAcpToolCallEvent, + makeAcpUsageUpdatedEvent, } from "../acp/AcpCoreRuntimeEvents.ts"; import { type AcpSessionMode, @@ -330,7 +331,11 @@ export function makeCursorAdapter( const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.make(id)); - const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const makeEventStamp = () => + Effect.map(Effect.all({ eventId: nextEventId, createdAt: nowIso }), (stamp) => ({ + ...stamp, + providerInstanceId: boundInstanceId, + })); const offerRuntimeEvent = (event: ProviderRuntimeEvent) => PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); @@ -807,6 +812,25 @@ export function makeCursorAdapter( }), ); return; + case "UsageUpdated": + yield* logNative( + ctx.threadId, + "session/update", + event.payload.rawPayload, + "acp.jsonrpc", + ); + yield* offerRuntimeEvent( + makeAcpUsageUpdatedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + size: event.payload.size, + used: event.payload.used, + rawPayload: event.payload.rawPayload, + }), + ); + return; } }), ), diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 90b36e89004..895d8a1c12c 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -684,6 +684,28 @@ describe("parseCursorAboutOutput", () => { }); }); +describe("buildCursorProviderSnapshot", () => { + it("builds a ready Cursor provider snapshot from about output", () => { + const snapshot = buildCursorProviderSnapshot({ + checkedAt: "2026-04-18T00:00:00.000Z", + cursorSettings: { + enabled: true, + binaryPath: "agent", + apiEndpoint: "", + customModels: [], + } satisfies CursorSettings, + parsed: { + version: "2026.04.18-123456", + status: "ready", + auth: { status: "authenticated" }, + }, + }); + + expect(snapshot.status).toBe("ready"); + expect(snapshot.auth.status).toBe("authenticated"); + }); +}); + describe("Cursor parameterized model picker preview gating", () => { it("parses Cursor CLI version dates from build versions", () => { expect(parseCursorVersionDate("2026.04.08-c4e73a3")).toBe(20260408); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 035c08437a9..cf5ffca891d 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -3,10 +3,12 @@ import type { CursorSettings, ModelCapabilities, ProviderOptionSelection, + ProviderInstanceId, ServerProvider, ServerProviderAuth, ServerProviderModel, ServerProviderState, + ServerProviderUsageLimits, } from "@t3tools/contracts"; import { ProviderDriverKind } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; @@ -37,10 +39,12 @@ import { type CommandResult, type ServerProviderDraft, } from "../providerSnapshot.ts"; +import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; import { enrichProviderSnapshotWithVersionAdvisory, type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; +import { type ProviderUsageStateShape } from "../Services/ProviderUsageState.ts"; import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; const PROVIDER = ProviderDriverKind.make("cursor"); @@ -611,6 +615,15 @@ export const discoverCursorModelCapabilitiesViaAcp = ( ); } + type CursorAcpProbeResult = + | { readonly _tag: "skipped" } + | { + readonly _tag: "success"; + readonly slug: string; + readonly capabilities: ModelCapabilities; + } + | { readonly _tag: "failed"; readonly slug: string }; + const probedCapabilities = yield* Effect.forEach( modelChoices, (modelChoice) => { @@ -620,9 +633,7 @@ export const discoverCursorModelCapabilitiesViaAcp = ( !targetModelSlugs.has(modelSlug) || capabilitiesBySlug.has(modelSlug) ) { - return Effect.void.pipe( - Effect.as(undefined), - ); + return Effect.succeed({ _tag: "skipped" }); } return withCursorAcpProbeRuntime( @@ -649,10 +660,11 @@ export const discoverCursorModelCapabilitiesViaAcp = ( .pipe( Effect.map((response) => response.configOptions ?? probeConfigOptions), ); - return [ - modelSlug, - buildCursorCapabilitiesFromConfigOptions(nextConfigOptions), - ] as const; + return { + _tag: "success", + slug: modelSlug, + capabilities: buildCursorCapabilitiesFromConfigOptions(nextConfigOptions), + } as CursorAcpProbeResult; }), environment, ).pipe( @@ -660,21 +672,29 @@ export const discoverCursorModelCapabilitiesViaAcp = ( Effect.retry({ times: 3 }), Effect.withSpan("cursor-acp-model-capability-probe"), Effect.catchCause((cause) => - Effect.logWarning("Cursor ACP capability probe failed", { + Effect.logDebug("Cursor ACP capability probe failed", { modelSlug, cause: Cause.pretty(cause), - }), + }).pipe(Effect.as({ _tag: "failed", slug: modelSlug })), ), ); }, { concurrency: CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY }, ); + const failedModels: Array = []; for (const entry of probedCapabilities) { - if (!entry) { - continue; + if (entry._tag === "failed") { + failedModels.push(entry.slug); + } else if (entry._tag === "success") { + capabilitiesBySlug.set(entry.slug, entry.capabilities); } - capabilitiesBySlug.set(entry[0], entry[1]); + } + + if (failedModels.length > 0) { + yield* Effect.logWarning( + `Cursor ACP capability probe failed for ${failedModels.length} model(s) — Cursor Agent may be unresponsive`, + ); } return buildCursorDiscoveredModels( @@ -733,6 +753,7 @@ export function buildCursorProviderSnapshot(input: { readonly parsed: CursorAboutResult; readonly discoveredModels?: ReadonlyArray; readonly discoveryWarning?: string; + readonly usageLimits?: ServerProviderUsageLimits; }): ServerProviderDraft { const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); return buildServerProvider({ @@ -752,6 +773,7 @@ export function buildCursorProviderSnapshot(input: { input.discoveryWarning && input.parsed.status === "ready" ? "warning" : input.parsed.status, auth: input.parsed.auth, ...(message ? { message } : {}), + ...(input.usageLimits ? { usageLimits: input.usageLimits } : {}), }, }); } @@ -1089,6 +1111,8 @@ const runCursorAboutCommand = ( export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( cursorSettings: CursorSettings, environment: NodeJS.ProcessEnv = process.env, + instanceId?: ProviderInstanceId, + providerUsageState?: ProviderUsageStateShape, ): Effect.fn.Return< ServerProviderDraft, never, @@ -1200,6 +1224,23 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( discoveredModels = discoveryExit.value; } } + + const runtimeUsageLimits = providerUsageState + ? yield* providerUsageState + .get(PROVIDER, instanceId) + .pipe(Effect.orElseSucceed(() => undefined)) + : undefined; + + const usageLimits = + runtimeUsageLimits ?? + (parsed.auth.status !== "unauthenticated" + ? makeUnavailableUsageLimits({ + source: "cursorAcp", + checkedAt, + reason: "Cursor does not expose subscription usage", + }) + : undefined); + return buildCursorProviderSnapshot({ checkedAt, cursorSettings, @@ -1209,6 +1250,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( () => [] as const, ), ...(discoveryWarning ? { discoveryWarning } : {}), + ...(usageLimits ? { usageLimits } : {}), }); }); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index dea95c990d2..3896c847b14 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -23,6 +23,8 @@ import { openCodeRuntimeErrorDetail, type OpenCodeInventory, } from "../opencodeRuntime.ts"; +import { resolveOpenCodeManagedUsageLimits } from "../openCodeUsageLimits.ts"; +import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; const PROVIDER = ProviderDriverKind.make("opencode"); @@ -450,6 +452,18 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); const connectedCount = inventoryExit.value.providerList.connected.length; + const usageLimits = + resolveOpenCodeManagedUsageLimits({ + checkedAt, + inventory: inventoryExit.value, + }) ?? + (connectedCount > 0 + ? makeUnavailableUsageLimits({ + source: "opencodeManaged", + checkedAt, + reason: "Upstream providers did not report usage information", + }) + : undefined); return buildServerProvider({ presentation: OPENCODE_PRESENTATION, enabled: true, @@ -469,6 +483,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu : isExternalServer ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." : "OpenCode is available, but it did not report any connected upstream providers.", + ...(usageLimits ? { usageLimits } : {}), }, }); }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index fb6eb3b443d..0f4473fb2c7 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -346,6 +346,58 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }), ); + it.effect("includes codex subscription usage windows", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: { + type: "chatgpt", + email: "test@example.com", + planType: "pro", + }, + requiresOpenaiAuth: false, + }, + rateLimits: { + primary: { usedPercent: 28, windowDurationMins: 1440 }, + secondary: { usedPercent: 61, windowDurationMins: 10080 }, + }, + }), + ), + ); + + assert.deepStrictEqual( + status.usageLimits?.windows.map((window) => window.kind), + ["session", "weekly"], + ); + }), + ); + + it.effect("returns unavailable usage for codex api key accounts", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: { type: "apiKey" }, + requiresOpenaiAuth: false, + }, + rateLimits: { + primary: { usedPercent: 99 }, + }, + }), + ), + ); + + assert.strictEqual(status.usageLimits?.available, false); + assert.strictEqual( + status.usageLimits?.reason, + "Usage limits unavailable for API key Codex accounts.", + ); + }), + ); + it.effect("returns unauthenticated when app-server requires OpenAI auth", () => Effect.gen(function* () { const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => @@ -1639,6 +1691,72 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ), ); + it.effect("includes parsed claude usage windows", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + email: "test@example.com", + subscriptionType: "maxplan", + tokenSource: "claude.ai", + slashCommands: [], + }), + ); + + // The upstream checkClaudeProviderStatus does not embed usage + // limits directly — those are handled by the standalone + // `probeClaudeUsageLimits` utility and wired in via the + // provider layer. Assert the provider is ready; a separate + // unit in claudeUsageProbe.test.ts covers usage window parsing. + assert.strictEqual(status.status, "ready"); + }).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("keeps claude healthy when usage probing is separate", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + email: "test@example.com", + subscriptionType: "maxplan", + tokenSource: "claude.ai", + slashCommands: [], + }), + ); + + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + }).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/ProviderUsageState.test.ts b/apps/server/src/provider/Layers/ProviderUsageState.test.ts new file mode 100644 index 00000000000..52b512aab6c --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderUsageState.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from "vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Stream from "effect/Stream"; +import { ProviderDriverKind, type ProviderRuntimeEvent, type ThreadId } from "@t3tools/contracts"; + +import { ProviderUsageState } from "../Services/ProviderUsageState.ts"; +import { ProviderService } from "../Services/ProviderService.ts"; +import { ProviderUsageStateLive } from "./ProviderUsageState.ts"; + +function makeProviderServiceStub() { + const pubsub = Effect.runSync(PubSub.unbounded()); + + return { + pubsub, + layer: Layer.succeed(ProviderService, { + startSession: () => Effect.die("unused"), + sendTurn: () => Effect.die("unused"), + interruptTurn: () => Effect.die("unused"), + respondToRequest: () => Effect.die("unused"), + respondToUserInput: () => Effect.die("unused"), + stopSession: () => Effect.die("unused"), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.die("unused"), + rollbackConversation: () => Effect.die("unused"), + getInstanceInfo: () => Effect.die("unused"), + streamEvents: Stream.fromPubSub(pubsub), + }), + }; +} + +describe("ProviderUsageStateLive", () => { + it("sets, gets, and clears usage by provider", async () => { + const stub = makeProviderServiceStub(); + const result = await Effect.runPromise( + Effect.gen(function* () { + const usageState = yield* ProviderUsageState; + + yield* usageState.set( + ProviderDriverKind.make("cursor"), + undefined, + "thread-probe" as ThreadId, + { + source: "cursorAcp", + available: true, + checkedAt: "2026-04-18T00:00:00.000Z", + windows: [{ kind: "session", label: "Context window", usedPercent: 25 }], + }, + ); + const first = yield* usageState.get(ProviderDriverKind.make("cursor")); + yield* usageState.clear(ProviderDriverKind.make("cursor")); + const second = yield* usageState.get(ProviderDriverKind.make("cursor")); + + return { first, second }; + }).pipe(Effect.provide(ProviderUsageStateLive.pipe(Layer.provide(stub.layer)))), + ); + + expect(result.first?.windows).toEqual([ + { kind: "session", label: "Context window", usedPercent: 25 }, + ]); + expect(result.second).toBeUndefined(); + }); + + it("ingests real Cursor token usage events and isolates providers", async () => { + const stub = makeProviderServiceStub(); + const state = await Effect.runPromise( + Effect.gen(function* () { + const usageState = yield* ProviderUsageState; + + yield* Effect.sleep("10 millis"); + yield* PubSub.publish(stub.pubsub, { + type: "thread.token-usage.updated", + eventId: "evt-1" as never, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-1" as never, + createdAt: "2026-04-18T00:00:00.000Z", + payload: { + usage: { + usedTokens: 50, + maxTokens: 100, + }, + }, + }); + + yield* Effect.sleep("10 millis"); + + return { + cursor: yield* usageState.get(ProviderDriverKind.make("cursor")), + opencode: yield* usageState.get(ProviderDriverKind.make("opencode")), + }; + }).pipe(Effect.provide(ProviderUsageStateLive.pipe(Layer.provide(stub.layer)))), + ); + + expect(state.cursor?.windows).toEqual([ + { kind: "session", label: "Context window", usedPercent: 50 }, + ]); + expect(state.opencode).toBeUndefined(); + }); + + it("returns the most recently updated thread usage", async () => { + const stub = makeProviderServiceStub(); + const state = await Effect.runPromise( + Effect.gen(function* () { + const usageState = yield* ProviderUsageState; + + yield* Effect.sleep("10 millis"); + yield* PubSub.publish(stub.pubsub, { + type: "thread.token-usage.updated", + eventId: "evt-1" as never, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-a" as never, + createdAt: "2026-04-18T00:00:00.000Z", + payload: { + usage: { + usedTokens: 10, + maxTokens: 100, + }, + }, + }); + yield* PubSub.publish(stub.pubsub, { + type: "thread.token-usage.updated", + eventId: "evt-2" as never, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-b" as never, + createdAt: "2026-04-18T00:01:00.000Z", + payload: { + usage: { + usedTokens: 20, + maxTokens: 100, + }, + }, + }); + yield* PubSub.publish(stub.pubsub, { + type: "thread.token-usage.updated", + eventId: "evt-3" as never, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-a" as never, + createdAt: "2026-04-18T00:02:00.000Z", + payload: { + usage: { + usedTokens: 60, + maxTokens: 100, + }, + }, + }); + + yield* Effect.sleep("10 millis"); + return yield* usageState.get(ProviderDriverKind.make("cursor")); + }).pipe(Effect.provide(ProviderUsageStateLive.pipe(Layer.provide(stub.layer)))), + ); + + expect(state?.windows).toEqual([{ kind: "session", label: "Context window", usedPercent: 60 }]); + }); + + it("ingests Claude runtime rate limit telemetry when utilization is present", async () => { + const stub = makeProviderServiceStub(); + const state = await Effect.runPromise( + Effect.gen(function* () { + const usageState = yield* ProviderUsageState; + + yield* Effect.sleep("10 millis"); + yield* PubSub.publish(stub.pubsub, { + type: "account.rate-limits.updated", + eventId: "evt-claude-1" as never, + provider: ProviderDriverKind.make("claudeAgent"), + threadId: "thread-claude-1" as never, + createdAt: "2026-04-18T00:00:00.000Z", + payload: { + rateLimits: { + type: "rate_limit_event", + rate_limit_info: { + status: "allowed", + rateLimitType: "seven_day_opus", + utilization: 64, + resetsAt: 1776448800, + }, + }, + }, + }); + + yield* Effect.sleep("10 millis"); + return yield* usageState.get(ProviderDriverKind.make("claudeAgent")); + }).pipe(Effect.provide(ProviderUsageStateLive.pipe(Layer.provide(stub.layer)))), + ); + + expect(state?.windows).toEqual([ + { + kind: "weekly", + label: "Weekly", + usedPercent: 64, + windowDurationMins: 10080, + resetsAt: "2026-04-17T18:00:00.000Z", + }, + ]); + }); + + it("ignores Claude runtime rate limit telemetry when utilization is absent", async () => { + const stub = makeProviderServiceStub(); + const state = await Effect.runPromise( + Effect.gen(function* () { + const usageState = yield* ProviderUsageState; + + yield* Effect.sleep("10 millis"); + yield* PubSub.publish(stub.pubsub, { + type: "account.rate-limits.updated", + eventId: "evt-claude-2" as never, + provider: ProviderDriverKind.make("claudeAgent"), + threadId: "thread-claude-2" as never, + createdAt: "2026-04-18T00:00:00.000Z", + payload: { + rateLimits: { + type: "rate_limit_event", + rate_limit_info: { + status: "allowed", + rateLimitType: "five_hour", + resetsAt: 1776448800, + }, + }, + }, + }); + + yield* Effect.sleep("10 millis"); + return yield* usageState.get(ProviderDriverKind.make("claudeAgent")); + }).pipe(Effect.provide(ProviderUsageStateLive.pipe(Layer.provide(stub.layer)))), + ); + + expect(state).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderUsageState.ts b/apps/server/src/provider/Layers/ProviderUsageState.ts new file mode 100644 index 00000000000..03014bf3e0e --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderUsageState.ts @@ -0,0 +1,217 @@ +import type { + ProviderDriverKind, + ProviderInstanceId, + ProviderRuntimeEvent, + ServerProviderUsageLimits, + ThreadId, +} from "@t3tools/contracts"; +import { ProviderDriverKind as ProviderDriverKindSchema } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import { parseClaudeRuntimeUsageLimits } from "../claudeUsageProbe.ts"; +import { runtimeUsageToProviderUsageLimits } from "../runtimeUsageToProviderUsageLimits.ts"; +import { + ProviderUsageState, + type ProviderUsageStateShape, +} from "../Services/ProviderUsageState.ts"; +import { ProviderService } from "../Services/ProviderService.ts"; + +const CURSOR_DRIVER = ProviderDriverKindSchema.make("cursor"); +const CLAUDE_DRIVER = ProviderDriverKindSchema.make("claudeAgent"); + +function toCursorUsageLimits( + event: Extract, +) { + const maxTokens = event.payload.usage.maxTokens; + if (typeof maxTokens !== "number") { + return undefined; + } + + return runtimeUsageToProviderUsageLimits({ + source: "cursorAcp", + checkedAt: event.createdAt, + usedTokens: event.payload.usage.usedTokens, + maxTokens, + }); +} + +function makeProviderInstanceKey( + provider: ProviderDriverKind, + providerInstanceId: ProviderInstanceId | undefined, +): string { + if (providerInstanceId === undefined || providerInstanceId === null) { + return provider; + } + return `${provider}_${providerInstanceId}`; +} + +export const ProviderUsageStateLive = Layer.effect( + ProviderUsageState, + Effect.gen(function* () { + const providerService = yield* ProviderService; + const stateRef = yield* Ref.make( + new Map< + string, + Map + >(), + ); + + const clearThreadUsage = ( + provider: ProviderDriverKind, + providerInstanceId: ProviderInstanceId | undefined, + threadId: ThreadId, + ) => + Ref.update(stateRef, (state) => { + const next = new Map(state); + const key = makeProviderInstanceKey(provider, providerInstanceId); + const existingThreadMap = next.get(key); + if (!existingThreadMap) { + return state; + } + const threadMap = new Map(existingThreadMap); + threadMap.delete(threadId); + if (threadMap.size === 0) { + next.delete(key); + } else { + next.set(key, threadMap); + } + return next; + }); + + const setThreadUsage = ( + provider: ProviderDriverKind, + providerInstanceId: ProviderInstanceId | undefined, + threadId: ThreadId, + usage: ServerProviderUsageLimits, + updatedAtMs: number, + ) => + Ref.update(stateRef, (state) => { + const next = new Map(state); + const key = makeProviderInstanceKey(provider, providerInstanceId); + const threadMap = new Map(next.get(key) ?? []); + next.set(key, threadMap); + threadMap.set(threadId, { usage, updatedAtMs }); + return next; + }); + + const service: ProviderUsageStateShape = { + get: (provider, providerInstanceId) => + Ref.get(stateRef).pipe( + Effect.map((state) => { + const key = makeProviderInstanceKey(provider, providerInstanceId); + const threadMap = state.get(key); + if (!threadMap || threadMap.size === 0) { + return undefined; + } + let latest: + | { readonly usage: ServerProviderUsageLimits; readonly updatedAtMs: number } + | undefined; + for (const entry of threadMap.values()) { + if (!latest || entry.updatedAtMs > latest.updatedAtMs) { + latest = entry; + } + } + return latest?.usage; + }), + ), + set: (provider, providerInstanceId, threadId, usage) => + Effect.flatMap(Effect.map(DateTime.now, DateTime.toEpochMillis), (updatedAtMs) => + Ref.update(stateRef, (state) => { + const next = new Map(state); + const key = makeProviderInstanceKey(provider, providerInstanceId); + if (usage === undefined) { + const existingThreadMap = next.get(key); + if (existingThreadMap) { + const newThreadMap = new Map(existingThreadMap); + newThreadMap.delete(threadId); + if (newThreadMap.size === 0) { + next.delete(key); + } else { + next.set(key, newThreadMap); + } + } + } else { + const threadMap = new Map(next.get(key) ?? []); + next.set(key, threadMap); + threadMap.set(threadId, { usage, updatedAtMs }); + } + return next; + }), + ), + clear: (provider, providerInstanceId) => + Ref.update(stateRef, (state) => { + const next = new Map(state); + const key = makeProviderInstanceKey(provider, providerInstanceId); + next.delete(key); + return next; + }), + }; + + yield* Stream.runForEach(providerService.streamEvents, (event) => + Effect.gen(function* () { + const providerInstanceId = event.providerInstanceId; + + if (event.type === "session.started" || event.type === "session.exited") { + yield* clearThreadUsage(event.provider, providerInstanceId, event.threadId); + return; + } + + if (event.provider === "cursor" && event.type === "thread.token-usage.updated") { + const usage = toCursorUsageLimits(event); + if (usage === undefined) { + return; + } + + const cursorMaybeDate = DateTime.make(event.createdAt); + const cursorUpdatedAtMs = Option.isSome(cursorMaybeDate) + ? DateTime.toEpochMillis(cursorMaybeDate.value) + : DateTime.toEpochMillis(yield* DateTime.now); + yield* setThreadUsage( + CURSOR_DRIVER, + providerInstanceId, + event.threadId, + usage, + cursorUpdatedAtMs, + ); + return; + } + + if (event.provider !== "claudeAgent" || event.type !== "account.rate-limits.updated") { + return; + } + + const usage = parseClaudeRuntimeUsageLimits({ + checkedAt: event.createdAt, + rateLimits: + typeof event.payload === "object" && + event.payload !== null && + "rateLimits" in event.payload + ? (event.payload as { readonly rateLimits?: unknown }).rateLimits + : undefined, + }); + if (usage === undefined) { + return; + } + + const claudeMaybeDate = DateTime.make(event.createdAt); + const claudeUpdatedAtMs = Option.isSome(claudeMaybeDate) + ? DateTime.toEpochMillis(claudeMaybeDate.value) + : DateTime.toEpochMillis(yield* DateTime.now); + yield* setThreadUsage( + CLAUDE_DRIVER, + providerInstanceId, + event.threadId, + usage, + claudeUpdatedAtMs, + ); + }), + ).pipe(Effect.forkScoped); + + return service; + }), +); diff --git a/apps/server/src/provider/Services/ProviderUsageState.ts b/apps/server/src/provider/Services/ProviderUsageState.ts new file mode 100644 index 00000000000..89d35d6ca7c --- /dev/null +++ b/apps/server/src/provider/Services/ProviderUsageState.ts @@ -0,0 +1,30 @@ +import type { + ProviderDriverKind, + ProviderInstanceId, + ServerProviderUsageLimits, + ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface ProviderUsageStateShape { + readonly get: ( + provider: ProviderDriverKind, + providerInstanceId?: ProviderInstanceId, + ) => Effect.Effect; + readonly set: ( + provider: ProviderDriverKind, + providerInstanceId: ProviderInstanceId | undefined, + threadId: ThreadId, + usage: ServerProviderUsageLimits | undefined, + ) => Effect.Effect; + readonly clear: ( + provider: ProviderDriverKind, + providerInstanceId?: ProviderInstanceId, + ) => Effect.Effect; +} + +export class ProviderUsageState extends Context.Service< + ProviderUsageState, + ProviderUsageStateShape +>()("t3/provider/Services/ProviderUsageState") {} diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts index 713d0668928..f0a529e0bf9 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts @@ -8,6 +8,7 @@ import { makeAcpRequestOpenedEvent, makeAcpRequestResolvedEvent, makeAcpToolCallEvent, + makeAcpUsageUpdatedEvent, } from "./AcpCoreRuntimeEvents.ts"; describe("AcpCoreRuntimeEvents", () => { @@ -152,4 +153,31 @@ describe("AcpCoreRuntimeEvents", () => { }, }); }); + + it("maps ACP usage updates to canonical thread token usage events", () => { + const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; + + expect( + makeAcpUsageUpdatedEvent({ + stamp, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-1" as never, + turnId: TurnId.make("turn-1"), + size: 200_000, + used: 50_000, + rawPayload: { sessionId: "session-1", update: { sessionUpdate: "usage_update" } }, + }), + ).toMatchObject({ + type: "thread.token-usage.updated", + payload: { + usage: { + usedTokens: 50_000, + maxTokens: 200_000, + }, + }, + raw: { + payload: { sessionId: "session-1", update: { sessionUpdate: "usage_update" } }, + }, + }); + }); }); diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts index c93e61dc37b..81b90d4deb8 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts @@ -240,3 +240,32 @@ export function makeAcpContentDeltaEvent(input: { }, }; } + +export function makeAcpUsageUpdatedEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderDriverKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly size: number; + readonly used: number; + readonly rawPayload: unknown; +}): ProviderRuntimeEvent { + return { + type: "thread.token-usage.updated", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + payload: { + usage: { + usedTokens: Math.max(0, Math.round(input.used)), + maxTokens: Math.max(1, Math.round(input.size)), + }, + }, + raw: { + source: "acp.jsonrpc", + method: "session/update", + payload: input.rawPayload, + }, + }; +} diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index ae12d3112aa..905c978a3ad 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -245,6 +245,46 @@ describe("AcpRuntimeModel", () => { ]); }); + it("parses ACP usage updates and ignores malformed values", () => { + const validResult = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "usage_update", + size: 200_000, + used: 50_000, + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(validResult.events).toEqual([ + { + _tag: "UsageUpdated", + payload: { + size: 200_000, + used: 50_000, + rawPayload: { + sessionId: "session-1", + update: { + sessionUpdate: "usage_update", + size: 200_000, + used: 50_000, + }, + }, + }, + }, + ]); + + const invalidResult = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "usage_update", + size: 0, + used: 50_000, + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(invalidResult.events).toEqual([]); + }); + it("keeps permission request parsing compatible with loose extension payloads", () => { const request = parsePermissionRequest({ sessionId: "session-1", diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index ffd214a5bf1..5fda15a1877 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -69,6 +69,14 @@ export type AcpParsedSessionEvent = readonly itemId?: string; readonly text: string; readonly rawPayload: unknown; + } + | { + readonly _tag: "UsageUpdated"; + readonly payload: { + readonly size: number; + readonly used: number; + readonly rawPayload: unknown; + }; }; type AcpSessionSetupResponse = @@ -474,6 +482,19 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat } break; } + case "usage_update": { + if (Number.isFinite(upd.size) && Number.isFinite(upd.used) && upd.size > 0 && upd.used >= 0) { + events.push({ + _tag: "UsageUpdated", + payload: { + size: upd.size, + used: upd.used, + rawPayload: params, + }, + }); + } + break; + } default: break; } diff --git a/apps/server/src/provider/claudeUsageProbe.test.ts b/apps/server/src/provider/claudeUsageProbe.test.ts new file mode 100644 index 00000000000..0b08ab88048 --- /dev/null +++ b/apps/server/src/provider/claudeUsageProbe.test.ts @@ -0,0 +1,470 @@ +import { describe, expect, it, vi } from "vitest"; +import * as Effect from "effect/Effect"; + +import { PtySpawnError } from "../terminal/Services/PTY.ts"; +import type { PtySpawnInput } from "../terminal/Services/PTY.ts"; +import type { PtyAdapterShape, PtyProcess } from "../terminal/Services/PTY.ts"; + +import { + parseClaudeRuntimeUsageLimits, + parseClaudeUsageLimitsOutput, + probeClaudeUsageLimits, + shouldRequestClaudeUsageFallback, + type ProbeClock, +} from "./claudeUsageProbe.ts"; + +class MockPtyChild implements PtyProcess { + public readonly writes: string[] = []; + public readonly kill = vi.fn(); + + private readonly dataListeners = new Set<(data: string) => void>(); + private readonly exitListeners = new Set< + (event: { exitCode: number; signal: number | null }) => void + >(); + + public get pid(): number { + return 12345; + } + + public write(data: string): void { + this.writes.push(data); + } + + public resize(_cols: number, _rows: number): void { + // no-op + } + + public onData(listener: (data: string) => void): () => void { + this.dataListeners.add(listener); + return () => { + this.dataListeners.delete(listener); + }; + } + + public onExit( + listener: (event: { exitCode: number; signal: number | null }) => void, + ): () => void { + this.exitListeners.add(listener); + return () => { + this.exitListeners.delete(listener); + }; + } + + public emitData(data: string): void { + for (const listener of this.dataListeners) { + listener(data); + } + } + + public emitExit(): void { + for (const listener of this.exitListeners) { + listener({ exitCode: 0, signal: null }); + } + } +} + +function makeMockPtyAdapter(child: MockPtyChild): PtyAdapterShape { + return { + spawn: () => Effect.succeed(child), + }; +} + +function makeCapturingPtyAdapter(input: { + readonly child: MockPtyChild; + readonly onSpawn: (spawnInput: PtySpawnInput) => void; +}): PtyAdapterShape { + return { + spawn: (spawnInput) => { + input.onSpawn(spawnInput); + return Effect.succeed(input.child); + }, + }; +} + +function createFakeClock(): ProbeClock & { advance(ms: number): void } { + const timers: Array<{ + id: number; + ms: number; + fn: () => void; + fired: boolean; + cancelled: boolean; + }> = []; + let nextId = 1; + + const fakeSetTimeout = ((fn: () => void, ms?: number) => { + const id = nextId++; + timers.push({ + id, + ms: ms ?? 0, + fn, + fired: false, + cancelled: false, + }); + return id as unknown as ReturnType; + }) as typeof setTimeout; + + const fakeClearTimeout = ((id: ReturnType) => { + const numericId = typeof id === "number" ? id : (id as unknown as number); + const entry = timers.find((t) => t.id === numericId); + if (entry) { + entry.cancelled = true; + } + }) as typeof clearTimeout; + + const advance = (ms: number) => { + for (const timer of timers) { + if (timer.fired || timer.cancelled) continue; + timer.ms -= ms; + if (timer.ms <= 0) { + timer.fired = true; + timer.fn(); + } + } + }; + + return { + setTimeout: fakeSetTimeout, + clearTimeout: fakeClearTimeout, + advance, + }; +} + +describe("claudeUsageProbe", () => { + it("parses session and weekly windows from status output", () => { + expect( + parseClaudeUsageLimitsOutput({ + checkedAt: "2026-04-17T10:00:00.000Z", + output: ` + Session usage 42% resets at 2026-04-17T14:00:00Z + Weekly usage 68% resets at 2026-04-21T00:00:00Z + `, + }), + ).toEqual({ + source: "claudeStatusProbe", + available: true, + checkedAt: "2026-04-17T10:00:00.000Z", + windows: [ + { + kind: "session", + label: "Session", + usedPercent: 42, + windowDurationMins: 300, + resetsAt: "2026-04-17T14:00:00.000Z", + }, + { + kind: "weekly", + label: "Weekly", + usedPercent: 68, + windowDurationMins: 10080, + resetsAt: "2026-04-21T00:00:00.000Z", + }, + ], + }); + }); + + it("returns unavailable when quota text is absent", () => { + expect( + parseClaudeUsageLimitsOutput({ + checkedAt: "2026-04-17T10:00:00.000Z", + output: "Authenticated as Claude Max", + }), + ).toEqual({ + source: "claudeStatusProbe", + available: false, + checkedAt: "2026-04-17T10:00:00.000Z", + reason: "Usage limits unavailable for this Claude account.", + windows: [], + }); + }); + + it("returns unavailable for API key accounts when no windows found", () => { + expect( + parseClaudeUsageLimitsOutput({ + checkedAt: "2026-04-17T10:00:00.000Z", + output: "Using API key for authentication", + }), + ).toEqual({ + source: "claudeStatusProbe", + available: false, + checkedAt: "2026-04-17T10:00:00.000Z", + reason: "Usage limits unavailable for Claude API key accounts.", + windows: [], + }); + }); + + it("parses windows even when output contains api key wording", () => { + expect( + parseClaudeUsageLimitsOutput({ + checkedAt: "2026-04-17T10:00:00.000Z", + output: ` + Session usage 42% resets at 2026-04-17T14:00:00Z + To set an API key, use: env ANTHROPIC_API_KEY=sk-... + `, + }), + ).toEqual({ + source: "claudeStatusProbe", + available: true, + checkedAt: "2026-04-17T10:00:00.000Z", + windows: [ + { + kind: "session", + label: "Session", + usedPercent: 42, + windowDurationMins: 300, + resetsAt: "2026-04-17T14:00:00.000Z", + }, + ], + }); + }); + + it("parses runtime Claude rate limit telemetry when utilization is present", () => { + expect( + parseClaudeRuntimeUsageLimits({ + checkedAt: "2026-04-17T10:00:00.000Z", + rateLimits: { + type: "rate_limit_event", + rate_limit_info: { + status: "allowed", + rateLimitType: "five_hour", + utilization: 37, + resetsAt: 1776448800, + }, + }, + }), + ).toEqual({ + source: "claudeStatusProbe", + available: true, + checkedAt: "2026-04-17T10:00:00.000Z", + windows: [ + { + kind: "session", + label: "Session", + usedPercent: 37, + windowDurationMins: 300, + resetsAt: "2026-04-17T18:00:00.000Z", + }, + ], + }); + }); + + it("ignores runtime Claude telemetry when utilization is missing", () => { + expect( + parseClaudeRuntimeUsageLimits({ + checkedAt: "2026-04-17T10:00:00.000Z", + rateLimits: { + type: "rate_limit_event", + rate_limit_info: { + status: "allowed", + rateLimitType: "seven_day_opus", + resetsAt: 1776448800, + }, + }, + }), + ).toBeUndefined(); + }); + + it("requests the /usage fallback for short unavailable status output", () => { + expect( + shouldRequestClaudeUsageFallback({ + checkedAt: "2026-04-17T10:00:00.000Z", + output: "Authenticated as Claude Max\n", + }), + ).toBe(true); + }); + + it("requests the /usage fallback even when output is empty", () => { + expect( + shouldRequestClaudeUsageFallback({ + checkedAt: "2026-04-17T10:00:00.000Z", + output: "", + }), + ).toBe(true); + }); + + it("skips the /usage fallback once usage windows are already available", () => { + expect( + shouldRequestClaudeUsageFallback({ + checkedAt: "2026-04-17T10:00:00.000Z", + output: "Session usage 42% resets at 2026-04-17T14:00:00Z\n", + }), + ).toBe(false); + }); + + it("resolves immediately when /status returns usable quota output", async () => { + const child = new MockPtyChild(); + const ptyAdapter = makeMockPtyAdapter(child); + const clock = createFakeClock(); + + const probePromise = Effect.runPromise( + probeClaudeUsageLimits( + { + binaryPath: "claude", + cwd: "/tmp", + checkedAt: "2026-04-17T10:00:00.000Z", + }, + ptyAdapter, + clock, + ), + ); + + expect(child.writes).toEqual(["/status\r"]); + + child.emitData("Session usage 42% resets at 2026-04-17T14:00:00Z\n"); + + const result = await probePromise; + expect(result.usageLimits.available).toBe(true); + expect(child.writes).toEqual(["/status\r"]); + expect(child.kill).toHaveBeenCalled(); + }); + + it("sends /usage fallback when /status output is not enough", async () => { + const child = new MockPtyChild(); + const ptyAdapter = makeMockPtyAdapter(child); + const clock = createFakeClock(); + + const probePromise = Effect.runPromise( + probeClaudeUsageLimits( + { + binaryPath: "claude", + cwd: "/tmp", + checkedAt: "2026-04-17T10:00:00.000Z", + }, + ptyAdapter, + clock, + ), + ); + + expect(child.writes).toEqual(["/status\r"]); + + child.emitData("Authenticated as Claude Max\n"); + clock.advance(200); + expect(child.writes).toEqual(["/status\r", "/usage\r"]); + + child.emitData("Session usage 55% resets at 2026-04-17T15:00:00Z\n"); + const result = await probePromise; + expect(result.usageLimits.available).toBe(true); + expect(child.kill).toHaveBeenCalled(); + }); + + it("resolves unavailable when process exits with no usable data", async () => { + const child = new MockPtyChild(); + const ptyAdapter = makeMockPtyAdapter(child); + const clock = createFakeClock(); + + const probePromise = Effect.runPromise( + probeClaudeUsageLimits( + { + binaryPath: "claude", + cwd: "/tmp", + checkedAt: "2026-04-17T10:00:00.000Z", + }, + ptyAdapter, + clock, + ), + ); + + expect(child.writes).toEqual(["/status\r"]); + + child.emitData("Authenticated as Claude Max\n"); + clock.advance(200); + expect(child.writes).toEqual(["/status\r", "/usage\r"]); + + child.emitExit(); + const result = await probePromise; + expect(result.usageLimits.available).toBe(false); + expect(child.kill).toHaveBeenCalled(); + }); + + it("resolves unavailable on timeout with no usable data", async () => { + const child = new MockPtyChild(); + const ptyAdapter = makeMockPtyAdapter(child); + const clock = createFakeClock(); + + const probePromise = Effect.runPromise( + probeClaudeUsageLimits( + { + binaryPath: "claude", + cwd: "/tmp", + checkedAt: "2026-04-17T10:00:00.000Z", + }, + ptyAdapter, + clock, + ), + ); + + clock.advance(200); + expect(child.writes).toEqual(["/status\r", "/usage\r"]); + + clock.advance(4_000); + const result = await probePromise; + + expect(result.usageLimits.available).toBe(false); + expect(result.rawOutput).toBe(""); + expect(child.writes.filter((entry) => entry === "/usage\r")).toHaveLength(1); + expect(child.kill).toHaveBeenCalled(); + }); + + it("returns unavailable result when spawn fails", async () => { + const failingAdapter: PtyAdapterShape = { + spawn: () => + Effect.fail( + new PtySpawnError({ + adapter: "mock", + message: "spawn failed", + }), + ), + }; + + const result = await Effect.runPromise( + probeClaudeUsageLimits( + { + binaryPath: "claude", + cwd: "/tmp", + checkedAt: "2026-04-17T10:00:00.000Z", + }, + failingAdapter, + ), + ); + + expect(result.usageLimits.available).toBe(false); + expect(result.usageLimits.reason).toBe("Failed to spawn Claude process for usage probe."); + expect(result.rawOutput).toBe(""); + }); + + it("preserves quoted launch arguments when spawning the probe process", async () => { + const child = new MockPtyChild(); + let capturedSpawnInput: PtySpawnInput | undefined; + const ptyAdapter = makeCapturingPtyAdapter({ + child, + onSpawn: (spawnInput) => { + capturedSpawnInput = spawnInput; + }, + }); + + const probePromise = Effect.runPromise( + probeClaudeUsageLimits( + { + binaryPath: "claude", + launchArgs: '--model "claude sonnet" --cwd "/tmp/with spaces" --note "say \\"hi\\""', + cwd: "/tmp", + checkedAt: "2026-04-17T10:00:00.000Z", + }, + ptyAdapter, + ), + ); + + child.emitExit(); + await probePromise; + + expect(capturedSpawnInput?.args).toEqual([ + "--model", + "claude sonnet", + "--cwd", + "/tmp/with spaces", + "--note", + 'say "hi"', + "--permission-mode", + "plan", + ]); + }); +}); diff --git a/apps/server/src/provider/claudeUsageProbe.ts b/apps/server/src/provider/claudeUsageProbe.ts new file mode 100644 index 00000000000..0be969ec294 --- /dev/null +++ b/apps/server/src/provider/claudeUsageProbe.ts @@ -0,0 +1,448 @@ +import type { ServerProviderUsageLimits } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import type { PtyAdapterShape, PtyProcess } from "../terminal/Services/PTY.ts"; +import { makeUnavailableUsageLimits, makeUsageLimitsSnapshot } from "./providerUsageLimits.ts"; + +const CLAUDE_USAGE_PROBE_TIMEOUT_MS = 4_000; +const CLAUDE_USAGE_FALLBACK_IDLE_MS = 150; +const ANSI_PATTERN = + // Matches common CSI / OSC ANSI escape sequences. + // eslint-disable-next-line no-control-regex + /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001B\\))/g; + +export interface ClaudeUsageProbeResult { + readonly usageLimits: ServerProviderUsageLimits; + readonly rawOutput: string; +} + +export interface ClaudeUsageProbeInput { + readonly binaryPath: string; + readonly launchArgs?: string; + readonly cwd: string; + readonly checkedAt: string; + readonly environment?: NodeJS.ProcessEnv; +} + +function readObjectRecord(value: unknown): Readonly> | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function readRateLimitDurationMins(value: unknown): number | undefined { + switch (value) { + case "five_hour": + return 5 * 60; + case "seven_day": + case "seven_day_opus": + case "seven_day_sonnet": + return 7 * 24 * 60; + default: + return undefined; + } +} + +function toRateLimitResetTimestamp(value: unknown): string | undefined { + const timestampSeconds = readNumber(value); + if (timestampSeconds === undefined) { + return undefined; + } + + return DateTime.formatIso(DateTime.makeUnsafe(timestampSeconds * 1000)); +} + +export function parseClaudeRuntimeUsageLimits(input: { + readonly checkedAt: string; + readonly rateLimits: unknown; +}): ServerProviderUsageLimits | undefined { + const eventRecord = readObjectRecord(input.rateLimits); + const rateLimitInfo = + readObjectRecord(eventRecord?.rate_limit_info) ?? readObjectRecord(input.rateLimits); + if (!rateLimitInfo) { + return undefined; + } + + const usedPercent = readNumber(rateLimitInfo.utilization); + const windowDurationMins = readRateLimitDurationMins(rateLimitInfo.rateLimitType); + if (usedPercent === undefined || windowDurationMins === undefined) { + return undefined; + } + + const resetsAt = toRateLimitResetTimestamp(rateLimitInfo.resetsAt); + + return makeUsageLimitsSnapshot({ + source: "claudeStatusProbe", + checkedAt: input.checkedAt, + windows: [ + { + label: windowDurationMins === 5 * 60 ? "Session" : "Weekly", + usedPercent, + windowDurationMins, + ...(resetsAt === undefined ? {} : { resetsAt }), + }, + ], + unavailableReason: "Usage limits unavailable for this Claude account.", + }); +} + +export function shouldRequestClaudeUsageFallback(input: { + readonly output: string; + readonly checkedAt: string; + readonly fallbackAlreadySent?: boolean; +}): boolean { + if (input.fallbackAlreadySent) { + return false; + } + + const parsed = parseClaudeUsageLimitsOutput(input); + return !parsed.available; +} + +function stripAnsi(value: string): string { + return value.replaceAll(ANSI_PATTERN, ""); +} + +function parsePercent(value: string | undefined): number | undefined { + if (!value) return undefined; + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function inferWindowDurationMins(value: string): number | undefined { + const lower = value.toLowerCase(); + if (/\bweekly\b|\b7\s*(?:d|day|days)\b/.test(lower)) { + return 7 * 24 * 60; + } + if (/\b5\s*(?:h|hr|hrs|hour|hours)\b|\bsession\b/.test(lower)) { + return 5 * 60; + } + return undefined; +} + +function detectClaudeUsageWindowKind(value: string): "session" | "weekly" | undefined { + const lower = value.toLowerCase(); + if (/\bweekly\b|\b7\s*(?:d|day|days)\b/.test(lower)) { + return "weekly"; + } + if (/\b5\s*(?:h|hr|hrs|hour|hours)\b|\bsession\b/.test(lower)) { + return "session"; + } + return undefined; +} + +function extractResetTimestamp(value: string): string | undefined { + const resetMatch = value.match(/\breset(?:s|ting)?(?:\s+(?:at|on|in))?[:\s-]*([^\n.;]+)/i); + const rawCandidate = resetMatch?.[1] + ?.trim() + .replace(/\s+/g, " ") + .replace(/\b(?:local time|your time|time)\b.*$/i, "") + .trim(); + const isoCandidate = rawCandidate?.match( + /\b\d{4}-\d{2}-\d{2}t\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:z|[+-]\d{2}:?\d{2})\b/i, + )?.[0]; + const candidate = isoCandidate ?? rawCandidate; + if (!candidate) return undefined; + if (/\b(?:today|tomorrow|tonight|next)\b/i.test(candidate)) { + return undefined; + } + const hasExplicitTimezone = + /(?:z|[+-]\d{2}:?\d{2}|\b(?:utc|gmt|p[sd]t|m[sd]t|c[sd]t|e[sd]t)\b)/i.test(candidate); + if (!hasExplicitTimezone) { + return undefined; + } + const dt = DateTime.make(candidate); + return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; +} + +function parseClaudeUsageWindowSegment( + kind: "session" | "weekly", + segment: string, +): { + readonly label: string; + readonly usedPercent: number; + readonly windowDurationMins: number; + readonly resetsAt?: string; +} | null { + const percentMatch = segment.match(/(\d{1,3}(?:\.\d+)?)\s*%/); + const usedPercent = parsePercent(percentMatch?.[1]); + const windowDurationMins = inferWindowDurationMins(segment); + if (usedPercent === undefined || windowDurationMins === undefined) { + return null; + } + const resetsAt = extractResetTimestamp(segment); + + return { + label: kind === "session" ? "Session" : "Weekly", + usedPercent, + windowDurationMins, + ...(resetsAt ? { resetsAt } : {}), + }; +} + +function extractWindowSegments(output: string): ReadonlyArray<{ + readonly label: string; + readonly usedPercent: number; + readonly windowDurationMins: number; + readonly resetsAt?: string; +}> { + const lines = output + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter(Boolean); + const windows = new Map<"session" | "weekly", (typeof lines)[number]>(); + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]!; + const kind = detectClaudeUsageWindowKind(line); + if (!kind || windows.has(kind)) continue; + + const segmentLines = [line]; + for (let cursor = index + 1; cursor < lines.length && segmentLines.length < 3; cursor += 1) { + const candidate = lines[cursor]!; + if (detectClaudeUsageWindowKind(candidate)) { + break; + } + segmentLines.push(candidate); + } + const neighborhood = segmentLines.join(" "); + windows.set(kind, neighborhood); + } + + return [...windows.entries()].flatMap(([kind, segment]) => { + const parsed = parseClaudeUsageWindowSegment(kind, segment); + if (!parsed) { + return []; + } + + return [parsed]; + }); +} + +export function parseClaudeUsageLimitsOutput(input: { + readonly output: string; + readonly checkedAt: string; +}): ServerProviderUsageLimits { + const cleanedOutput = stripAnsi(input.output); + const lowerOutput = cleanedOutput.toLowerCase(); + const windows = extractWindowSegments(cleanedOutput); + + if (windows.length > 0) { + return makeUsageLimitsSnapshot({ + source: "claudeStatusProbe", + checkedAt: input.checkedAt, + windows, + unavailableReason: "Usage limits unavailable for this Claude account.", + }); + } + + if (/\busing api key\b|\busing.an api.key\b/.test(lowerOutput)) { + return makeUnavailableUsageLimits({ + source: "claudeStatusProbe", + checkedAt: input.checkedAt, + reason: "Usage limits unavailable for Claude API key accounts.", + }); + } + + return makeUnavailableUsageLimits({ + source: "claudeStatusProbe", + checkedAt: input.checkedAt, + reason: "Usage limits unavailable for this Claude account.", + }); +} + +export interface ProbeClock { + readonly setTimeout: typeof setTimeout; + readonly clearTimeout: typeof clearTimeout; +} + +const defaultClock: ProbeClock = { setTimeout, clearTimeout }; + +function splitLaunchArgs(launchArgs?: string): string[] { + if (!launchArgs?.trim()) { + return []; + } + + const tokens: string[] = []; + let current = ""; + let quote: "'" | '"' | null = null; + let escaping = false; + + const pushCurrent = () => { + if (current.length > 0) { + tokens.push(current); + current = ""; + } + }; + + for (const character of launchArgs) { + if (escaping) { + current += character; + escaping = false; + continue; + } + + if (character === "\\") { + escaping = true; + continue; + } + + if (quote) { + if (character === quote) { + quote = null; + } else { + current += character; + } + continue; + } + + if (character === "'" || character === '"') { + quote = character; + continue; + } + + if (/\s/.test(character)) { + pushCurrent(); + continue; + } + + current += character; + } + + if (escaping) { + current += "\\"; + } + + pushCurrent(); + return tokens; +} + +function runProbeLoop( + child: PtyProcess, + input: ClaudeUsageProbeInput, + clock: ProbeClock, +): Promise { + return new Promise((resolve) => { + let rawOutput = ""; + let settled = false; + let fallbackTimer: ReturnType | undefined; + let sentFallback = false; + + const timeout = clock.setTimeout(() => { + finish(); + }, CLAUDE_USAGE_PROBE_TIMEOUT_MS); + + const scheduleFallback = () => { + if (sentFallback || settled) { + return; + } + if (fallbackTimer) { + clock.clearTimeout(fallbackTimer); + } + fallbackTimer = clock.setTimeout(() => { + fallbackTimer = undefined; + maybeRequestFallback(); + }, CLAUDE_USAGE_FALLBACK_IDLE_MS); + }; + + const finish = () => { + if (settled) return; + settled = true; + clock.clearTimeout(timeout); + if (fallbackTimer) { + clock.clearTimeout(fallbackTimer); + } + offData(); + offExit(); + try { + child.kill(); + } catch { + // Ignore kill failures during cleanup. + } + resolve({ + usageLimits: parseClaudeUsageLimitsOutput({ + output: rawOutput, + checkedAt: input.checkedAt, + }), + rawOutput, + }); + }; + + const maybeRequestFallback = () => { + if (sentFallback) return; + if ( + !shouldRequestClaudeUsageFallback({ + output: rawOutput, + checkedAt: input.checkedAt, + fallbackAlreadySent: sentFallback, + }) + ) { + finish(); + return; + } + sentFallback = true; + child.write("/usage\r"); + }; + + const offData = child.onData((data) => { + rawOutput += data; + const parsed = parseClaudeUsageLimitsOutput({ + output: rawOutput, + checkedAt: input.checkedAt, + }); + if (parsed.available) { + finish(); + return; + } + if (!sentFallback) { + scheduleFallback(); + } + }); + + const offExit = child.onExit(() => { + finish(); + }); + + child.write("/status\r"); + scheduleFallback(); + }); +} + +export function probeClaudeUsageLimits( + input: ClaudeUsageProbeInput, + ptyAdapter: PtyAdapterShape, + clock: ProbeClock = defaultClock, +): Effect.Effect { + const probeArgs = [...splitLaunchArgs(input.launchArgs), "--permission-mode", "plan"]; + + return Effect.gen(function* () { + const child = yield* ptyAdapter + .spawn({ + shell: input.binaryPath, + args: probeArgs, + cwd: input.cwd, + cols: 120, + rows: 40, + env: input.environment ?? process.env, + }) + .pipe(Effect.orElseSucceed(() => null as PtyProcess | null)); + + if (!child) { + return { + usageLimits: makeUnavailableUsageLimits({ + source: "claudeStatusProbe", + checkedAt: input.checkedAt, + reason: "Failed to spawn Claude process for usage probe.", + }), + rawOutput: "", + }; + } + + return yield* Effect.promise(() => runProbeLoop(child, input, clock)); + }); +} diff --git a/apps/server/src/provider/openCodeUsageLimits.test.ts b/apps/server/src/provider/openCodeUsageLimits.test.ts new file mode 100644 index 00000000000..445ed6a4fb7 --- /dev/null +++ b/apps/server/src/provider/openCodeUsageLimits.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; + +import { resolveOpenCodeManagedUsageLimits } from "./openCodeUsageLimits.ts"; + +describe("resolveOpenCodeManagedUsageLimits", () => { + it("returns one managed usage window for opencode-go", () => { + expect( + resolveOpenCodeManagedUsageLimits({ + checkedAt: "2026-04-18T00:00:00.000Z", + inventory: { + providerList: { + connected: ["opencode-go"], + default: {}, + all: [ + { + id: "opencode-go", + name: "OpenCode Go", + env: [], + models: {}, + usage: { + usedPercent: 32, + }, + }, + ], + }, + agents: [], + } as never, + }), + ).toEqual({ + source: "opencodeManaged", + available: true, + checkedAt: "2026-04-18T00:00:00.000Z", + windows: [{ kind: "session", label: "OpenCode Go", usedPercent: 32 }], + }); + }); + + it("returns both managed usage windows when both subscriptions expose real usage", () => { + const result = resolveOpenCodeManagedUsageLimits({ + checkedAt: "2026-04-18T00:00:00.000Z", + inventory: { + providerList: { + connected: ["opencode-go", "opencode-zen"], + default: {}, + all: [ + { + id: "opencode-go", + name: "OpenCode Go", + env: [], + models: {}, + usage: { usedPercent: 10 }, + }, + { + id: "opencode-zen", + name: "OpenCode Zen", + env: [], + models: {}, + usage: { used: 45, limit: 90 }, + }, + ], + }, + agents: [], + } as never, + }); + + expect(result?.windows).toEqual([ + { kind: "session", label: "OpenCode Go", usedPercent: 10 }, + { kind: "session", label: "OpenCode Zen", usedPercent: 50 }, + ]); + }); + + it("ignores non-managed and malformed usage sources", () => { + expect( + resolveOpenCodeManagedUsageLimits({ + checkedAt: "2026-04-18T00:00:00.000Z", + inventory: { + providerList: { + connected: ["anthropic", "opencode-go"], + default: {}, + all: [ + { + id: "anthropic", + name: "Anthropic", + env: [], + models: {}, + usage: { usedPercent: 99 }, + }, + { + id: "opencode-go", + name: "OpenCode Go", + env: [], + models: {}, + usage: { used: 10, limit: 0 }, + }, + ], + }, + agents: [], + } as never, + }), + ).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/openCodeUsageLimits.ts b/apps/server/src/provider/openCodeUsageLimits.ts new file mode 100644 index 00000000000..dc8047ddf7f --- /dev/null +++ b/apps/server/src/provider/openCodeUsageLimits.ts @@ -0,0 +1,134 @@ +import type { ServerProviderUsageLimits, ServerProviderUsageWindow } from "@t3tools/contracts"; + +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; + +import type { OpenCodeInventory } from "./opencodeRuntime.ts"; +import { getOpenCodeManagedProviderDescriptor } from "./opencodeRuntime.ts"; +import { clampPercent } from "./providerUsageLimits.ts"; + +type ManagedProviderRecord = OpenCodeInventory["providerList"]["all"][number]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readFiniteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function readIsoDateTime(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + const dt = DateTime.make(trimmed); + return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; +} + +function toUsageWindow( + usage: Record, + label: string, +): ServerProviderUsageWindow | undefined { + const resetsAt = + readIsoDateTime(usage.resetsAt) ?? + readIsoDateTime(usage.resetAt) ?? + readIsoDateTime(usage.renewsAt); + const explicitPercent = + readFiniteNumber(usage.usedPercent) ?? + readFiniteNumber(usage.usagePercent) ?? + readFiniteNumber(usage.percentUsed) ?? + readFiniteNumber(usage.percentage); + + const computedPercent = + explicitPercent ?? + (() => { + const used = readFiniteNumber(usage.used); + const limit = + readFiniteNumber(usage.limit) ?? + readFiniteNumber(usage.max) ?? + readFiniteNumber(usage.total); + if (used === undefined || limit === undefined || limit <= 0) { + return undefined; + } + return (used / limit) * 100; + })(); + + if (computedPercent === undefined || !Number.isFinite(computedPercent)) { + return undefined; + } + + return { + kind: "session", + label, + usedPercent: clampPercent(computedPercent), + ...(resetsAt ? { resetsAt } : {}), + }; +} + +function extractUsageWindow( + provider: ManagedProviderRecord, +): ServerProviderUsageWindow | undefined { + const descriptor = getOpenCodeManagedProviderDescriptor(provider.id); + if (!descriptor) { + return undefined; + } + + const providerRecord = provider as unknown as Record; + const providerOptions = isRecord(providerRecord.options) ? providerRecord.options : undefined; + const providerMetadata = isRecord(providerRecord.metadata) ? providerRecord.metadata : undefined; + const candidates = [ + providerRecord.usage, + providerRecord.quota, + providerRecord.subscriptionUsage, + providerRecord.usageLimits, + providerOptions?.usage, + providerOptions?.quota, + providerOptions?.subscriptionUsage, + providerOptions?.usageLimits, + providerMetadata?.usage, + providerMetadata?.quota, + ]; + + for (const candidate of candidates) { + if (!isRecord(candidate)) { + continue; + } + const window = toUsageWindow(candidate, descriptor.label); + if (window) { + return window; + } + } + + return undefined; +} + +export function resolveOpenCodeManagedUsageLimits(input: { + readonly checkedAt: string; + readonly inventory: OpenCodeInventory; +}): ServerProviderUsageLimits | undefined { + const connected = new Set(input.inventory.providerList.connected); + const windows = input.inventory.providerList.all + .filter((provider) => connected.has(provider.id)) + .flatMap((provider) => { + if (!getOpenCodeManagedProviderDescriptor(provider.id)) { + return []; + } + const window = extractUsageWindow(provider); + return window ? [window] : []; + }); + + if (windows.length === 0) { + return undefined; + } + + return { + source: "opencodeManaged", + available: true, + checkedAt: input.checkedAt, + windows, + }; +} diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index ddeb9f26431..9a153d21c89 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -100,6 +100,33 @@ export interface OpenCodeInventory { readonly agents: ReadonlyArray; } +export interface OpenCodeManagedProviderDescriptor { + readonly providerId: "opencode-go" | "opencode-zen"; + readonly label: "OpenCode Go" | "OpenCode Zen"; +} + +const OPENCODE_MANAGED_PROVIDER_DESCRIPTORS = { + "opencode-go": { + providerId: "opencode-go", + label: "OpenCode Go", + }, + "opencode-zen": { + providerId: "opencode-zen", + label: "OpenCode Zen", + }, +} satisfies Record; + +export function getOpenCodeManagedProviderDescriptor( + providerId: string, +): OpenCodeManagedProviderDescriptor | undefined { + if (!(providerId in OPENCODE_MANAGED_PROVIDER_DESCRIPTORS)) { + return undefined; + } + return OPENCODE_MANAGED_PROVIDER_DESCRIPTORS[ + providerId as keyof typeof OPENCODE_MANAGED_PROVIDER_DESCRIPTORS + ]; +} + export interface ParsedOpenCodeModelSlug { readonly providerID: string; readonly modelID: string; diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index c40903e1b45..ec7dab5c901 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -7,6 +7,7 @@ import type { ServerProviderSlashCommand, ServerProviderModel, ServerProviderState, + ServerProviderUsageLimits, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Data from "effect/Data"; @@ -39,6 +40,7 @@ export interface ProviderProbeResult { readonly status: Exclude; readonly auth: ServerProviderAuth; readonly message?: string; + readonly usageLimits?: ServerProviderUsageLimits; } export interface ServerProviderPresentation { @@ -224,6 +226,7 @@ export function buildServerProvider(input: { models: input.models, slashCommands: [...(input.slashCommands ?? [])], skills: [...(input.skills ?? [])], + ...(input.probe.usageLimits ? { usageLimits: input.probe.usageLimits } : {}), ...(versionAdvisory ? { versionAdvisory } : {}), }; } diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index 64cb9ccd417..82e5bbbfad9 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -99,6 +99,18 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { }, ], message: "Cached message", + usageLimits: { + source: "codexAppServer", + available: true, + checkedAt: "2026-04-10T12:00:00.000Z", + windows: [ + { + kind: "session", + label: "Session", + usedPercent: 12, + }, + ], + }, skills: [ { name: "github:gh-fix-ci", @@ -143,6 +155,7 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { checkedAt: cachedCodex.checkedAt, slashCommands: cachedCodex.slashCommands, skills: cachedCodex.skills, + usageLimits: cachedCodex.usageLimits, message: cachedCodex.message, }, ); diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 0b9b365f360..fc89b9ca716 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -67,6 +67,7 @@ export const hydrateCachedProvider = (input: { checkedAt: input.cachedProvider.checkedAt, slashCommands: input.cachedProvider.slashCommands, skills: input.cachedProvider.skills, + usageLimits: input.cachedProvider.usageLimits ?? fallbackWithoutMessage.usageLimits, }; return input.cachedProvider.message diff --git a/apps/server/src/provider/providerUsageLimits.test.ts b/apps/server/src/provider/providerUsageLimits.test.ts new file mode 100644 index 00000000000..1fee21d0156 --- /dev/null +++ b/apps/server/src/provider/providerUsageLimits.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { + clampPercent, + makeUsageLimitsSnapshot, + windowKindFromDuration, +} from "./providerUsageLimits.ts"; + +describe("providerUsageLimits", () => { + it("clamps percentages into the supported range", () => { + expect(clampPercent(-10)).toBe(0); + expect(clampPercent(42)).toBe(42); + expect(clampPercent(150)).toBe(100); + }); + + it("maps the shortest window to session and the longest to weekly", () => { + expect( + makeUsageLimitsSnapshot({ + source: "codexAppServer", + checkedAt: "2026-04-17T10:00:00.000Z", + unavailableReason: "missing", + windows: [ + { + label: "Five hour", + usedPercent: 10, + windowDurationMins: 300, + }, + { + label: "Seven day", + usedPercent: 20, + windowDurationMins: 10_080, + }, + ], + }).windows, + ).toEqual([ + { + kind: "session", + label: "Session", + usedPercent: 10, + windowDurationMins: 300, + }, + { + kind: "weekly", + label: "Weekly", + usedPercent: 20, + windowDurationMins: 10080, + }, + ]); + expect( + windowKindFromDuration({ + windowDurationMins: 300, + shortestWindowDurationMins: 300, + longestWindowDurationMins: 10080, + }), + ).toBe("session"); + expect( + windowKindFromDuration({ + windowDurationMins: 10080, + shortestWindowDurationMins: 300, + longestWindowDurationMins: 10080, + }), + ).toBe("weekly"); + }); + + it("keeps intermediate windows as session instead of dropping them", () => { + expect( + makeUsageLimitsSnapshot({ + source: "codexAppServer", + checkedAt: "2026-04-17T10:00:00.000Z", + unavailableReason: "missing", + windows: [ + { label: "Short", usedPercent: 10, windowDurationMins: 60 }, + { label: "Middle", usedPercent: 20, windowDurationMins: 1440 }, + { label: "Long", usedPercent: 30, windowDurationMins: 4320 }, + ], + }).windows, + ).toEqual([ + { + kind: "session", + label: "Session", + usedPercent: 10, + windowDurationMins: 60, + }, + { + kind: "session", + label: "Session", + usedPercent: 20, + windowDurationMins: 1440, + }, + { + kind: "weekly", + label: "Weekly", + usedPercent: 30, + windowDurationMins: 4320, + }, + ]); + }); +}); diff --git a/apps/server/src/provider/providerUsageLimits.ts b/apps/server/src/provider/providerUsageLimits.ts new file mode 100644 index 00000000000..309d4263938 --- /dev/null +++ b/apps/server/src/provider/providerUsageLimits.ts @@ -0,0 +1,117 @@ +import type { ServerProviderUsageLimits, ServerProviderUsageWindow } from "@t3tools/contracts"; + +export interface RawUsageWindowInput { + readonly label: string; + readonly usedPercent: number; + readonly resetsAt?: string; + readonly windowDurationMins?: number; +} + +export function clampPercent(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + return Math.max(0, Math.min(100, value)); +} + +export function windowKindFromDuration(input: { + readonly windowDurationMins?: number; + readonly shortestWindowDurationMins?: number; + readonly longestWindowDurationMins?: number; +}): ServerProviderUsageWindow["kind"] | undefined { + const duration = input.windowDurationMins; + if (typeof duration !== "number" || !Number.isFinite(duration)) { + return undefined; + } + if ( + duration >= 10080 || + (duration === input.longestWindowDurationMins && + input.longestWindowDurationMins !== input.shortestWindowDurationMins) + ) { + return "weekly"; + } + if (duration === input.shortestWindowDurationMins) { + return "session"; + } + return "session"; +} + +export function normalizeUsageWindows( + windows: ReadonlyArray, +): ReadonlyArray { + const normalizedDurations = windows + .map((window) => window.windowDurationMins) + .filter( + (duration): duration is number => typeof duration === "number" && Number.isFinite(duration), + ) + .toSorted((left, right) => left - right); + const shortestWindowDurationMins = normalizedDurations[0]; + const longestWindowDurationMins = normalizedDurations.at(-1); + + return windows + .flatMap((window) => { + const kind = windowKindFromDuration({ + ...(typeof window.windowDurationMins === "number" + ? { windowDurationMins: window.windowDurationMins } + : {}), + ...(typeof shortestWindowDurationMins === "number" ? { shortestWindowDurationMins } : {}), + ...(typeof longestWindowDurationMins === "number" ? { longestWindowDurationMins } : {}), + }); + if (!kind) { + return []; + } + return [ + { + kind, + label: kind === "session" ? "Session" : "Weekly", + usedPercent: clampPercent(window.usedPercent), + ...(window.resetsAt ? { resetsAt: window.resetsAt } : {}), + ...(typeof window.windowDurationMins === "number" && + Number.isFinite(window.windowDurationMins) + ? { windowDurationMins: Math.max(0, Math.round(window.windowDurationMins)) } + : {}), + } satisfies ServerProviderUsageWindow, + ]; + }) + .toSorted((left, right) => { + if (left.kind === right.kind) return 0; + return left.kind === "session" ? -1 : 1; + }); +} + +export function makeUnavailableUsageLimits(input: { + readonly source: ServerProviderUsageLimits["source"]; + readonly checkedAt: string; + readonly reason?: string; +}): ServerProviderUsageLimits { + return { + source: input.source, + available: false, + reason: input.reason ?? "Unable to fetch usage", + windows: [], + checkedAt: input.checkedAt, + }; +} + +export function makeUsageLimitsSnapshot(input: { + readonly source: ServerProviderUsageLimits["source"]; + readonly checkedAt: string; + readonly windows: ReadonlyArray; + readonly unavailableReason: string; +}): ServerProviderUsageLimits { + const normalizedWindows = normalizeUsageWindows(input.windows); + if (normalizedWindows.length === 0) { + return makeUnavailableUsageLimits({ + source: input.source, + checkedAt: input.checkedAt, + reason: input.unavailableReason, + }); + } + + return { + source: input.source, + available: true, + windows: normalizedWindows, + checkedAt: input.checkedAt, + }; +} diff --git a/apps/server/src/provider/runtimeUsageToProviderUsageLimits.test.ts b/apps/server/src/provider/runtimeUsageToProviderUsageLimits.test.ts new file mode 100644 index 00000000000..42a49223cf5 --- /dev/null +++ b/apps/server/src/provider/runtimeUsageToProviderUsageLimits.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { runtimeUsageToProviderUsageLimits } from "./runtimeUsageToProviderUsageLimits.ts"; + +describe("runtimeUsageToProviderUsageLimits", () => { + it("maps real token usage into a single session window", () => { + expect( + runtimeUsageToProviderUsageLimits({ + source: "cursorAcp", + checkedAt: "2026-04-18T00:00:00.000Z", + usedTokens: 75, + maxTokens: 100, + }), + ).toEqual({ + source: "cursorAcp", + available: true, + checkedAt: "2026-04-18T00:00:00.000Z", + windows: [{ kind: "session", label: "Context window", usedPercent: 75 }], + }); + }); + + it("returns undefined for invalid token limits", () => { + expect( + runtimeUsageToProviderUsageLimits({ + source: "cursorAcp", + checkedAt: "2026-04-18T00:00:00.000Z", + usedTokens: 75, + maxTokens: 0, + }), + ).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/runtimeUsageToProviderUsageLimits.ts b/apps/server/src/provider/runtimeUsageToProviderUsageLimits.ts new file mode 100644 index 00000000000..74526b4c129 --- /dev/null +++ b/apps/server/src/provider/runtimeUsageToProviderUsageLimits.ts @@ -0,0 +1,38 @@ +import type { ServerProviderUsageLimits } from "@t3tools/contracts"; + +import { clampPercent } from "./providerUsageLimits.ts"; + +export function runtimeUsageToProviderUsageLimits(input: { + readonly source: "cursorAcp" | "opencodeManaged"; + readonly checkedAt: string; + readonly usedTokens: number; + readonly maxTokens: number; + readonly label?: string; +}): ServerProviderUsageLimits | undefined { + if ( + !Number.isFinite(input.usedTokens) || + !Number.isFinite(input.maxTokens) || + input.usedTokens < 0 || + input.maxTokens <= 0 + ) { + return undefined; + } + + const rawPercent = (input.usedTokens / input.maxTokens) * 100; + if (!Number.isFinite(rawPercent)) { + return undefined; + } + + return { + source: input.source, + available: true, + checkedAt: input.checkedAt, + windows: [ + { + kind: "session", + label: input.label?.trim() || "Context window", + usedPercent: clampPercent(rawPercent), + }, + ], + }; +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index c6780559204..b9a33009092 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -23,6 +23,7 @@ import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRe import { ProviderEventLoggersLive } from "./provider/Layers/ProviderEventLoggers.ts"; import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; +import { ProviderUsageStateLive } from "./provider/Layers/ProviderUsageState.ts"; import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; @@ -148,6 +149,15 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(RuntimeReceiptBusLive), ); +const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( + Layer.provide(VcsProjectConfig.layer), +); + +const CheckpointingLayerLive = Layer.empty.pipe( + Layer.provideMerge(CheckpointDiffQueryLive), + Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), +); + const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( Layer.provide(ProviderSessionRuntimeRepositoryLive), ); @@ -165,10 +175,6 @@ const ProviderLayerLive = ProviderServiceLive.pipe( const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); -const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( - Layer.provide(VcsProjectConfig.layer), -); - const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.layer.pipe( Layer.provide( Layer.mergeAll(AzureDevOpsCli.layer, BitbucketApi.layer, GitHubCli.layer, GitLabCli.layer), @@ -208,13 +214,11 @@ const VcsLayerLive = Layer.empty.pipe( Layer.provideMerge(VcsStatusBroadcaster.layer.pipe(Layer.provide(GitWorkflowLayerLive))), ); -const CheckpointingLayerLive = Layer.empty.pipe( - Layer.provideMerge(CheckpointDiffQueryLive), - Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), +const TerminalLayerLive = Layer.mergeAll( + PtyAdapterLive, + TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)), ); -const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); - const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), Layer.provideMerge(VcsDriverRegistryLayerLive), @@ -236,9 +240,14 @@ const AuthLayerLive = ServerAuthLive.pipe( Layer.provide(ServerSecretStoreLive), ); -const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( - Layer.provideMerge(ProviderLayerLive), - Layer.provideMerge(OrchestrationLayerLive), +const ProviderRuntimeLayerLive = Layer.mergeAll( + ProviderLayerLive, + ProviderUsageStateLive.pipe(Layer.provide(ProviderLayerLive)), + ProviderSessionReaperLive.pipe( + Layer.provideMerge(OrchestrationLayerLive), + Layer.provide(ProviderLayerLive), + ), + OrchestrationLayerLive, ); const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d66d2487ce3..d4f5eec3fdb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1781,15 +1781,18 @@ export default function ChatView(props: ChatViewProps) { const focusComposer = useCallback(() => { composerRef.current?.focusAtEnd(); - }, []); + }, [composerRef]); const scheduleComposerFocus = useCallback(() => { window.requestAnimationFrame(() => { focusComposer(); }); }, [focusComposer]); - const addTerminalContextToDraft = useCallback((selection: TerminalContextSelection) => { - composerRef.current?.addTerminalContext(selection); - }, []); + const addTerminalContextToDraft = useCallback( + (selection: TerminalContextSelection) => { + composerRef.current?.addTerminalContext(selection); + }, + [composerRef], + ); const setTerminalOpen = useCallback( (open: boolean) => { if (!activeThreadRef) return; @@ -2547,6 +2550,7 @@ export default function ChatView(props: ChatViewProps) { keybindings, onToggleDiff, toggleTerminalVisibility, + composerRef, ]); const onRevertToTurnCount = useCallback( @@ -3021,7 +3025,7 @@ export default function ChatView(props: ChatViewProps) { promptRef.current = ""; composerRef.current?.resetCursorState({ cursor: 0 }); }, - [activePendingProgress?.activeQuestion, activePendingUserInput], + [activePendingProgress?.activeQuestion, activePendingUserInput, composerRef], ); const onChangeActivePendingUserInputCustomAnswer = useCallback( @@ -3055,7 +3059,7 @@ export default function ChatView(props: ChatViewProps) { composerRef.current?.focusAt(nextCursor); } }, - [activePendingUserInput], + [activePendingUserInput, composerRef], ); const onAdvanceActivePendingUserInput = useCallback(() => { @@ -3227,6 +3231,7 @@ export default function ChatView(props: ChatViewProps) { setThreadError, autoOpenPlanSidebar, environmentId, + composerRef, ], ); @@ -3364,6 +3369,7 @@ export default function ChatView(props: ChatViewProps) { runtimeMode, autoOpenPlanSidebar, environmentId, + composerRef, ]); const onProviderModelSelect = useCallback( diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 430ec3637e0..5ae3342f1e0 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -19,6 +19,7 @@ import { type ProviderDriverKind, type ServerProvider, type ServerProviderModel, + type ServerProviderUsageLimits, } from "@t3tools/contracts"; import { cn } from "../../lib/utils"; @@ -46,6 +47,77 @@ import { type ProviderStatusKey, } from "./providerStatus"; +function usageBarColor(percent: number): string { + if (percent >= 90) return "bg-destructive"; + if (percent >= 70) return "bg-warning"; + return "bg-foreground"; +} + +function ProviderUsageBars(props: { + readonly usageLimits: ServerProviderUsageLimits | undefined; + readonly enabled: boolean; +}) { + if (!props.enabled || !props.usageLimits) return null; + + const { usageLimits } = props; + + if (!usageLimits.available) { + return ( +

+ {usageLimits.reason ?? "Usage data unavailable"} +

+ ); + } + + if (usageLimits.windows.length === 0) return null; + + return ( +
+ {usageLimits.windows.map((window) => { + const color = usageBarColor(window.usedPercent); + const roundedPercent = Math.round(window.usedPercent); + const remainingPercent = 100 - roundedPercent; + const windowKey = `${window.kind}:${window.windowDurationMins ?? "unknown"}:${window.resetsAt ?? "none"}`; + + const resetDateStr = window.resetsAt + ? new Date(window.resetsAt).toLocaleString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + : null; + + return ( +
+
+ {window.label} + {remainingPercent}% remaining +
+
+
+
+ {resetDateStr && ( +
Resets {resetDateStr}
+ )} +
+ ); + })} +
+ ); +} + const PROVIDER_ACCENT_SWATCHES = [ "#2563eb", "#16a34a", @@ -776,6 +848,7 @@ export function ProviderInstanceCard({ {titleTailNode}
{authRowNode} +