| author | |
| committer | |
| log | 19d8ab2ae9fc562ee7b216a0d72903fbfafa9572 |
| tree | 6b48da114bc1823cab0f0c80a6fd85b9a88681b4 |
| parent | 0a0b6be96833adae68b36540c00d347eda278736 |
| signature | Signed by PGP key B5690EEEBB952194 |
Users on Codex or Claude Code subscriptions could not see how much quota was left or when it resets without leaving T3 Code. A user whose CLIs route through a CLIProxyAPI hub could not see it at all.
Each driver now returns `usageLimits` on its own snapshot (Codex from `account/rateLimits/read`, Claude from the SDK's `get_usage`), adapters normalise turn-driven rate-limit events at the boundary, and a driver-blind ingestion layer folds them onto the owning instance. The Usage page gains a Limits tab (mobile a card) with a bar per window, elapsed marker, pace, and reset countdown. CLIProxyAPI hubs can be added as read-only usage-limit sources; their accounts show badged "via CLIProxyAPI" with emails blurred.
Distilled from #1732 (server model, provider rows) and #9421 (Limits tab, window bars, pace maths). Closes #228.
Co-authored-by: Aditya Mer <101453576+Aditya190803@users.noreply.github.com>
Co-authored-by: Wout Stiens <71498452+StiensWout@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>56 files changed, 3458 insertions(+), 86 deletions(-)
apps/mobile/src/features/usage/UsageLimitsSection.tsx created+183| ... | ... | @@ -0,0 +1,183 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import type { | |
| 3 | ServerProvider, | |
| 4 | ServerProviderUsageWindow, | |
| 5 | UsageLimitSourceAccount, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { | |
| 8 | collectLimitSources, | |
| 9 | collectLimitsGroups, | |
| 10 | elapsedShare, | |
| 11 | formatResetsIn, | |
| 12 | limitsNotice, | |
| 13 | paceOf, | |
| 14 | providerLimitsLabel, | |
| 15 | } from "@t3tools/shared/usageLimits"; | |
| 16 | import { useState } from "react"; | |
| 17 | import { View } from "react-native"; | |
| 18 | ||
| 19 | import { AppText as Text } from "../../components/AppText"; | |
| 20 | import { environmentPresentations } from "../../state/presentation"; | |
| 21 | import { SettingsSection } from "../settings/components/SettingsSection"; | |
| 22 | ||
| 23 | const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const; | |
| 24 | ||
| 25 | /** | |
| 26 | * One window as a bar spanning its whole duration: the fill is quota spent, | |
| 27 | * the hairline is how far into the window the clock is. | |
| 28 | */ | |
| 29 | function WindowBar(props: { readonly window: ServerProviderUsageWindow; readonly now: number }) { | |
| 30 | const { window, now } = props; | |
| 31 | const used = Math.round(Math.max(0, Math.min(100, window.usedPercent))); | |
| 32 | const elapsed = elapsedShare(window, now); | |
| 33 | const pace = paceOf(window, now); | |
| 34 | const resetsIn = formatResetsIn(window, now); | |
| 35 | const detail = [pace ? PACE_LABEL[pace] : null, resetsIn].filter(Boolean).join(" · "); | |
| 36 | return ( | |
| 37 | <View className="gap-1.5"> | |
| 38 | <View className="flex-row items-baseline justify-between gap-3"> | |
| 39 | <Text className="text-base text-foreground">{window.label}</Text> | |
| 40 | <Text className="text-base tabular-nums text-foreground">{used}% used</Text> | |
| 41 | </View> | |
| 42 | <View className="h-3 justify-center"> | |
| 43 | <View className="h-1.5 flex-row overflow-hidden rounded-full bg-subtle"> | |
| 44 | <View | |
| 45 | className={ | |
| 46 | used >= 90 | |
| 47 | ? "h-full rounded-full bg-destructive" | |
| 48 | : used >= 70 | |
| 49 | ? "h-full rounded-full bg-warning" | |
| 50 | : "h-full rounded-full bg-foreground" | |
| 51 | } | |
| 52 | style={{ flex: used }} | |
| 53 | /> | |
| 54 | <View style={{ flex: 100 - used }} /> | |
| 55 | </View> | |
| 56 | {elapsed !== null ? ( | |
| 57 | <View | |
| 58 | className="absolute top-0 bottom-0 w-px bg-foreground" | |
| 59 | style={{ left: `${elapsed * 100}%`, opacity: 0.6 }} | |
| 60 | /> | |
| 61 | ) : null} | |
| 62 | </View> | |
| 63 | {detail ? <Text className="text-xs text-foreground-tertiary">{detail}</Text> : null} | |
| 64 | </View> | |
| 65 | ); | |
| 66 | } | |
| 67 | ||
| 68 | function AccountLimits(props: { | |
| 69 | readonly label: string; | |
| 70 | readonly detail: string | undefined; | |
| 71 | readonly limits: ServerProvider["usageLimits"]; | |
| 72 | readonly now: number; | |
| 73 | readonly first: boolean; | |
| 74 | }) { | |
| 75 | const { limits, now } = props; | |
| 76 | if (!limits) return null; | |
| 77 | const notice = limitsNotice(limits); | |
| 78 | return ( | |
| 79 | <View className={props.first ? "gap-3 p-4" : "gap-3 border-t border-border-subtle p-4"}> | |
| 80 | <View className="flex-row items-baseline gap-2"> | |
| 81 | <Text className="text-lg text-foreground">{props.label}</Text> | |
| 82 | {props.detail ? ( | |
| 83 | <Text className="text-sm text-foreground-muted">{props.detail}</Text> | |
| 84 | ) : null} | |
| 85 | </View> | |
| 86 | {notice ? ( | |
| 87 | <Text className="text-sm text-foreground-muted">{notice}</Text> | |
| 88 | ) : ( | |
| 89 | limits.windows.map((window) => <WindowBar key={window.id} window={window} now={now} />) | |
| 90 | )} | |
| 91 | </View> | |
| 92 | ); | |
| 93 | } | |
| 94 | ||
| 95 | function ProviderLimits(props: { | |
| 96 | readonly provider: ServerProvider; | |
| 97 | readonly now: number; | |
| 98 | readonly first: boolean; | |
| 99 | }) { | |
| 100 | const { provider } = props; | |
| 101 | return ( | |
| 102 | <AccountLimits | |
| 103 | label={providerLimitsLabel(provider, () => undefined)} | |
| 104 | detail={provider.auth.label} | |
| 105 | limits={provider.usageLimits} | |
| 106 | now={props.now} | |
| 107 | first={props.first} | |
| 108 | /> | |
| 109 | ); | |
| 110 | } | |
| 111 | ||
| 112 | const DRIVER_LABEL: Partial<Record<string, string>> = { codex: "Codex", claudeAgent: "Claude" }; | |
| 113 | ||
| 114 | /** Emails stay off the phone screen; the plan and driver identify the row. */ | |
| 115 | function SourceAccountLimits(props: { | |
| 116 | readonly account: UsageLimitSourceAccount; | |
| 117 | readonly now: number; | |
| 118 | readonly first: boolean; | |
| 119 | }) { | |
| 120 | const { account } = props; | |
| 121 | return ( | |
| 122 | <AccountLimits | |
| 123 | label={DRIVER_LABEL[account.driver] ?? String(account.driver)} | |
| 124 | detail={account.plan} | |
| 125 | limits={account.usageLimits} | |
| 126 | now={props.now} | |
| 127 | first={props.first} | |
| 128 | /> | |
| 129 | ); | |
| 130 | } | |
| 131 | ||
| 132 | /** | |
| 133 | * Subscription quota windows from every connected environment's providers, | |
| 134 | * read from the config each environment already streams. Countdowns anchor to | |
| 135 | * render time rather than ticking. | |
| 136 | */ | |
| 137 | export function UsageLimitsSection() { | |
| 138 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); | |
| 139 | const groups = collectLimitsGroups(presentations); | |
| 140 | const sources = collectLimitSources(presentations); | |
| 141 | // Anchored once per mount on purpose: countdowns must not tick. | |
| 142 | const [now] = useState(() => Date.now()); | |
| 143 | if (groups.length === 0 && sources.length === 0) return null; | |
| 144 | ||
| 145 | return ( | |
| 146 | <> | |
| 147 | {sources.map((source) => ( | |
| 148 | <SettingsSection key={source.key} title={`${source.label} · CLIProxyAPI`} card> | |
| 149 | {source.error ? ( | |
| 150 | <Text className="p-4 text-sm text-foreground-muted">{source.error}</Text> | |
| 151 | ) : source.accounts.length === 0 ? ( | |
| 152 | <Text className="p-4 text-sm text-foreground-muted">No accounts reported.</Text> | |
| 153 | ) : ( | |
| 154 | source.accounts.map((account, index) => ( | |
| 155 | <SourceAccountLimits | |
| 156 | key={account.id} | |
| 157 | account={account} | |
| 158 | now={now} | |
| 159 | first={index === 0} | |
| 160 | /> | |
| 161 | )) | |
| 162 | )} | |
| 163 | </SettingsSection> | |
| 164 | ))} | |
| 165 | {groups.map((group) => ( | |
| 166 | <SettingsSection | |
| 167 | key={group.environmentId} | |
| 168 | title={group.environmentLabel ? `Limits · ${group.environmentLabel}` : "Limits"} | |
| 169 | card | |
| 170 | > | |
| 171 | {group.providers.map((provider, index) => ( | |
| 172 | <ProviderLimits | |
| 173 | key={provider.instanceId} | |
| 174 | provider={provider} | |
| 175 | now={now} | |
| 176 | first={index === 0} | |
| 177 | /> | |
| 178 | ))} | |
| 179 | </SettingsSection> | |
| 180 | ))} | |
| 181 | </> | |
| 182 | ); | |
| 183 | } |
apps/mobile/src/features/usage/UsageRouteScreen.tsx+2| ... | ... | @@ -21,6 +21,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 21 | 21 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; |
| 22 | 22 | import { SettingsSection } from "../settings/components/SettingsSection"; |
| 23 | 23 | import { UsageDailyChart } from "./UsageDailyChart"; |
| 24 | import { UsageLimitsSection } from "./UsageLimitsSection"; | |
| 24 | 25 | import type { UsageChartMetric } from "./usageChartData"; |
| 25 | 26 | import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; |
| 26 | 27 | |
| ... | ... | @@ -139,6 +140,7 @@ export function UsageRouteScreen() { |
| 139 | 140 | timeZone={window.timeZone} |
| 140 | 141 | /> |
| 141 | 142 | <ProviderSection merged={merged} metric={metric} /> |
| 143 | <UsageLimitsSection /> | |
| 142 | 144 | <TotalsSection merged={merged} isPast24Hours={isPast24Hours} /> |
| 143 | 145 | <ModelsSection merged={merged} /> |
| 144 | 146 | </> |
apps/mobile/src/state/server.ts+1| ... | ... | @@ -7,6 +7,7 @@ import { environmentSession } from "./session"; |
| 7 | 7 | |
| 8 | 8 | export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { |
| 9 | 9 | initialConfigValueAtom: environmentSession.initialConfigValueAtom, |
| 10 | usageLimitSources: true, | |
| 10 | 11 | }); |
| 11 | 12 | export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ |
| 12 | 13 | catalogValueAtom: environmentCatalog.catalogValueAtom, |
apps/server/src/environment/ServerEnvironment.ts+1| ... | ... | @@ -221,6 +221,7 @@ export const make = Effect.gen(function* () { |
| 221 | 221 | threadAutoSettlement: true, |
| 222 | 222 | threadSnooze: true, |
| 223 | 223 | environmentThemes: true, |
| 224 | usageLimitSources: true, | |
| 224 | 225 | threadPinning: true, |
| 225 | 226 | threadPinReorder: true, |
| 226 | 227 | threadTitleRegeneration: true, |
apps/server/src/provider/Drivers/ClaudeDriver.ts+6| ... | ... | @@ -29,6 +29,7 @@ import { ServerConfig } from "../../config.ts"; |
| 29 | 29 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 30 | 30 | import { ProviderDriverError } from "../Errors.ts"; |
| 31 | 31 | import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; |
| 32 | import { makeClaudeScopedLimitNames } from "../Layers/claudeUsageLimits.ts"; | |
| 32 | 33 | import { |
| 33 | 34 | checkClaudeProviderStatus, |
| 34 | 35 | makePendingClaudeProvider, |
| ... | ... | @@ -134,10 +135,14 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { |
| 134 | 135 | continuationGroupKey, |
| 135 | 136 | }); |
| 136 | 137 | |
| 138 | // One per instance: the status probe writes the model-scoped bucket | |
| 139 | // names it saw, the adapter reads them to place turn-driven events. | |
| 140 | const scopedLimitNames = yield* makeClaudeScopedLimitNames; | |
| 137 | 141 | const adapterOptions = { |
| 138 | 142 | instanceId, |
| 139 | 143 | environment: processEnv, |
| 140 | 144 | modelCatalog, |
| 145 | scopedLimitNames, | |
| 141 | 146 | ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), |
| 142 | 147 | }; |
| 143 | 148 | const adapter = yield* makeClaudeAdapter(effectiveConfig, adapterOptions); |
| ... | ... | @@ -171,6 +176,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { |
| 171 | 176 | processEnv, |
| 172 | 177 | cwd, |
| 173 | 178 | resolveClaudeModelCatalog(manifest), |
| 179 | scopedLimitNames, | |
| 174 | 180 | ), |
| 175 | 181 | ), |
| 176 | 182 | Effect.map(stampIdentity), |
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+82| ... | ... | @@ -28,6 +28,7 @@ import * as Effect from "effect/Effect"; |
| 28 | 28 | import * as Fiber from "effect/Fiber"; |
| 29 | 29 | import * as Layer from "effect/Layer"; |
| 30 | 30 | import * as Random from "effect/Random"; |
| 31 | import * as Ref from "effect/Ref"; | |
| 31 | 32 | import * as Schema from "effect/Schema"; |
| 32 | 33 | import * as Stream from "effect/Stream"; |
| 33 | 34 | import * as TestClock from "effect/testing/TestClock"; |
| ... | ... | @@ -44,6 +45,7 @@ import { |
| 44 | 45 | } from "../ClaudeModelCatalog.testFixtures.ts"; |
| 45 | 46 | import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts"; |
| 46 | 47 | import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; |
| 48 | import type { ClaudeScopedLimitNames } from "./claudeUsageLimits.ts"; | |
| 47 | 49 | import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; |
| 48 | 50 | const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); |
| 49 | 51 | const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| ... | ... | @@ -163,6 +165,7 @@ function makeHarness(config?: { |
| 163 | 165 | readonly baseDir?: string; |
| 164 | 166 | readonly claudeConfig?: Partial<ClaudeSettings>; |
| 165 | 167 | readonly instanceId?: ProviderInstanceId; |
| 168 | readonly scopedLimitNames?: ClaudeAdapterLiveOptions["scopedLimitNames"]; | |
| 166 | 169 | }) { |
| 167 | 170 | const query = new FakeClaudeQuery(); |
| 168 | 171 | let createInput: |
| ... | ... | @@ -174,6 +177,7 @@ function makeHarness(config?: { |
| 174 | 177 | |
| 175 | 178 | const adapterOptions: ClaudeAdapterLiveOptions = { |
| 176 | 179 | ...(config?.instanceId ? { instanceId: config.instanceId } : {}), |
| 180 | ...(config?.scopedLimitNames ? { scopedLimitNames: config.scopedLimitNames } : {}), | |
| 177 | 181 | modelCatalog: Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG), |
| 178 | 182 | createQuery: (input) => { |
| 179 | 183 | createInput = input; |
| ... | ... | @@ -1239,6 +1243,84 @@ describe("ClaudeAdapterLive", () => { |
| 1239 | 1243 | ); |
| 1240 | 1244 | }); |
| 1241 | 1245 | |
| 1246 | it.effect("places overage-included rate-limit events on the bucket the probe named", () => { | |
| 1247 | const scopedLimitNames = Ref.makeUnsafe<ClaudeScopedLimitNames>({ overageIncluded: undefined }); | |
| 1248 | const harness = makeHarness({ scopedLimitNames }); | |
| 1249 | const rateLimitEvent = (utilization: number): SDKMessage => | |
| 1250 | ({ | |
| 1251 | type: "rate_limit_event", | |
| 1252 | rate_limit_info: { | |
| 1253 | status: "allowed", | |
| 1254 | rateLimitType: "seven_day_overage_included", | |
| 1255 | utilization, | |
| 1256 | }, | |
| 1257 | uuid: `rate-limit-${utilization}`, | |
| 1258 | session_id: "sdk-session-1", | |
| 1259 | }) as unknown as SDKMessage; | |
| 1260 | const resultMessage = (uuid: string): SDKMessage => | |
| 1261 | ({ | |
| 1262 | type: "result", | |
| 1263 | subtype: "success", | |
| 1264 | is_error: false, | |
| 1265 | errors: [], | |
| 1266 | num_turns: 1, | |
| 1267 | session_id: "sdk-session-1", | |
| 1268 | uuid, | |
| 1269 | }) as unknown as SDKMessage; | |
| 1270 | const limitsUpdates = (events: Iterable<ProviderRuntimeEvent>) => | |
| 1271 | Array.from(events).flatMap((event) => | |
| 1272 | event.type === "account.rate-limits.updated" ? [event.payload.limits] : [], | |
| 1273 | ); | |
| 1274 | return Effect.gen(function* () { | |
| 1275 | const adapter = yield* ClaudeAdapter; | |
| 1276 | const session = yield* adapter.startSession({ | |
| 1277 | threadId: THREAD_ID, | |
| 1278 | provider: ProviderDriverKind.make("claudeAgent"), | |
| 1279 | runtimeMode: "full-access", | |
| 1280 | }); | |
| 1281 | ||
| 1282 | // Before any probe names the bucket the event has nowhere to land. | |
| 1283 | // Collecting through the turn's completion proves the SDK message was | |
| 1284 | // handled, not merely still queued. | |
| 1285 | const firstTurnFiber = yield* adapter.streamEvents.pipe( | |
| 1286 | Stream.takeUntil((event) => event.type === "turn.completed"), | |
| 1287 | Stream.runCollect, | |
| 1288 | Effect.forkChild, | |
| 1289 | ); | |
| 1290 | yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", attachments: [] }); | |
| 1291 | harness.query.emit(rateLimitEvent(0.2)); | |
| 1292 | harness.query.emit(resultMessage("result-1")); | |
| 1293 | assert.deepStrictEqual(limitsUpdates(yield* Fiber.join(firstTurnFiber)), []); | |
| 1294 | ||
| 1295 | // The status probe reads `get_usage` and records the model it saw. | |
| 1296 | yield* Ref.set(scopedLimitNames, { overageIncluded: "Fable" }); | |
| 1297 | const secondTurnFiber = yield* adapter.streamEvents.pipe( | |
| 1298 | Stream.takeUntil((event) => event.type === "turn.completed"), | |
| 1299 | Stream.runCollect, | |
| 1300 | Effect.forkChild, | |
| 1301 | ); | |
| 1302 | yield* adapter.sendTurn({ threadId: session.threadId, input: "again", attachments: [] }); | |
| 1303 | harness.query.emit(rateLimitEvent(0.4)); | |
| 1304 | harness.query.emit(resultMessage("result-2")); | |
| 1305 | assert.deepStrictEqual(limitsUpdates(yield* Fiber.join(secondTurnFiber)), [ | |
| 1306 | { | |
| 1307 | windows: [ | |
| 1308 | { | |
| 1309 | id: "seven_day_fable", | |
| 1310 | kind: "weekly", | |
| 1311 | label: "Weekly · Fable", | |
| 1312 | usedPercent: 40, | |
| 1313 | windowDurationMins: 10_080, | |
| 1314 | }, | |
| 1315 | ], | |
| 1316 | }, | |
| 1317 | ]); | |
| 1318 | }).pipe( | |
| 1319 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | |
| 1320 | Effect.provide(harness.layer), | |
| 1321 | ); | |
| 1322 | }); | |
| 1323 | ||
| 1242 | 1324 | it.effect("does not emit turn.completed for a result with no active turn", () => { |
| 1243 | 1325 | const harness = makeHarness(); |
| 1244 | 1326 | return Effect.gen(function* () { |
apps/server/src/provider/Layers/ClaudeAdapter.ts+9-3| ... | ... | @@ -21,6 +21,7 @@ import { |
| 21 | 21 | } from "@anthropic-ai/claude-agent-sdk"; |
| 22 | 22 | import { parseCliArgs } from "@t3tools/shared/cliArgs"; |
| 23 | 23 | import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; |
| 24 | import { type ClaudeScopedLimitNames, claudeRateLimitEventToUpdate } from "./claudeUsageLimits.ts"; | |
| 24 | 25 | import { |
| 25 | 26 | ApprovalRequestId, |
| 26 | 27 | type CanonicalItemType, |
| ... | ... | @@ -342,6 +343,8 @@ export interface ClaudeAdapterLiveOptions { |
| 342 | 343 | readonly nativeEventLogPath?: string; |
| 343 | 344 | readonly nativeEventLogger?: EventNdjsonLogger; |
| 344 | 345 | readonly modelCatalog?: Effect.Effect<ClaudeModelCatalog>; |
| 346 | /** Scoped-bucket names the driver's status probe last saw; see `claudeUsageLimits`. */ | |
| 347 | readonly scopedLimitNames?: Ref.Ref<ClaudeScopedLimitNames>; | |
| 345 | 348 | } |
| 346 | 349 | |
| 347 | 350 | function isUuid(value: string): boolean { |
| ... | ... | @@ -3595,12 +3598,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3595 | 3598 | } |
| 3596 | 3599 | |
| 3597 | 3600 | if (message.type === "rate_limit_event") { |
| 3601 | const names = options?.scopedLimitNames | |
| 3602 | ? yield* Ref.get(options.scopedLimitNames) | |
| 3603 | : { overageIncluded: undefined }; | |
| 3604 | const limits = claudeRateLimitEventToUpdate(message.rate_limit_info, names); | |
| 3605 | if (!limits) return; | |
| 3598 | 3606 | yield* offerRuntimeEvent({ |
| 3599 | 3607 | ...base, |
| 3600 | 3608 | type: "account.rate-limits.updated", |
| 3601 | payload: { | |
| 3602 | rateLimits: message, | |
| 3603 | }, | |
| 3609 | payload: { limits }, | |
| 3604 | 3610 | }); |
| 3605 | 3611 | return; |
| 3606 | 3612 | } |
apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts+27-14| ... | ... | @@ -77,22 +77,31 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { |
| 77 | 77 | "const lines = createInterface({ input: process.stdin });", |
| 78 | 78 | 'lines.on("line", (line) => {', |
| 79 | 79 | " const message = JSON.parse(line);", |
| 80 | ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', | |
| 81 | " process.stdout.write(JSON.stringify({", | |
| 80 | ' if (message.type !== "control_request") return;', | |
| 81 | " const reply = (response) => process.stdout.write(JSON.stringify({", | |
| 82 | 82 | ' type: "control_response",', |
| 83 | " response: {", | |
| 84 | ' subtype: "success",', | |
| 85 | " request_id: message.request_id,", | |
| 86 | " response: {", | |
| 87 | ' commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],', | |
| 88 | " agents: [],", | |
| 89 | ' output_style: "default",', | |
| 90 | ' available_output_styles: ["default"],', | |
| 91 | " models: [],", | |
| 92 | ' account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },', | |
| 93 | " },", | |
| 94 | " },", | |
| 83 | ' response: { subtype: "success", request_id: message.request_id, response },', | |
| 95 | 84 | ' }) + "\\n");', |
| 85 | ' if (message.request?.subtype === "initialize") {', | |
| 86 | " reply({", | |
| 87 | ' commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],', | |
| 88 | " agents: [],", | |
| 89 | ' output_style: "default",', | |
| 90 | ' available_output_styles: ["default"],', | |
| 91 | " models: [],", | |
| 92 | ' account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },', | |
| 93 | " });", | |
| 94 | " }", | |
| 95 | " // The probe follows initialize with get_usage on the same process.", | |
| 96 | ' if (message.request?.subtype === "get_usage") {', | |
| 97 | " reply({", | |
| 98 | " session: {},", | |
| 99 | ' subscription_type: "pro",', | |
| 100 | " rate_limits_available: true,", | |
| 101 | ' rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } },', | |
| 102 | " behaviors: null,", | |
| 103 | " });", | |
| 104 | " }", | |
| 96 | 105 | "});", |
| 97 | 106 | "setInterval(() => {}, 1_000);", |
| 98 | 107 | "", |
| ... | ... | @@ -122,6 +131,10 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { |
| 122 | 131 | input: { hint: "[path]" }, |
| 123 | 132 | }, |
| 124 | 133 | ], |
| 134 | usage: { | |
| 135 | rate_limits_available: true, | |
| 136 | rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } }, | |
| 137 | }, | |
| 125 | 138 | }); |
| 126 | 139 | |
| 127 | 140 | // @effect-diagnostics-next-line preferSchemaOverJson:off |
apps/server/src/provider/Layers/ClaudeProvider.ts+35| ... | ... | @@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"; |
| 8 | 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | 9 | import * as Option from "effect/Option"; |
| 10 | 10 | import * as Path from "effect/Path"; |
| 11 | import * as Ref from "effect/Ref"; | |
| 11 | 12 | import * as Result from "effect/Result"; |
| 12 | 13 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 13 | 14 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| ... | ... | @@ -16,6 +17,7 @@ import { |
| 16 | 17 | query as claudeQuery, |
| 17 | 18 | type Options as ClaudeQueryOptions, |
| 18 | 19 | type SlashCommand as ClaudeSlashCommand, |
| 20 | type SDKControlGetUsageResponse, | |
| 19 | 21 | type SDKUserMessage, |
| 20 | 22 | type SettingSource, |
| 21 | 23 | } from "@anthropic-ai/claude-agent-sdk"; |
| ... | ... | @@ -32,6 +34,12 @@ import { |
| 32 | 34 | import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; |
| 33 | 35 | import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; |
| 34 | 36 | import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; |
| 37 | import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; | |
| 38 | import { | |
| 39 | type ClaudeScopedLimitNames, | |
| 40 | claudeUsageResponseToLimits, | |
| 41 | recordClaudeUsageResponse, | |
| 42 | } from "./claudeUsageLimits.ts"; | |
| 35 | 43 | import { |
| 36 | 44 | BUNDLED_CLAUDE_MODEL_CATALOG, |
| 37 | 45 | type ClaudeModelCatalog, |
| ... | ... | @@ -225,6 +233,12 @@ type ClaudeCapabilitiesProbe = { |
| 225 | 233 | */ |
| 226 | 234 | readonly apiProvider: string | undefined; |
| 227 | 235 | readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>; |
| 236 | /** | |
| 237 | * Subscription windows from the SDK's `get_usage` control request, or | |
| 238 | * `undefined` when the request itself failed. Absent windows on an | |
| 239 | * otherwise successful response mean the account has none (API key). | |
| 240 | */ | |
| 241 | readonly usage?: Pick<SDKControlGetUsageResponse, "rate_limits_available" | "rate_limits">; | |
| 228 | 242 | }; |
| 229 | 243 | |
| 230 | 244 | function parseClaudeInitializationCommands( |
| ... | ... | @@ -340,6 +354,15 @@ const probeClaudeCapabilities = ( |
| 340 | 354 | }), |
| 341 | 355 | }); |
| 342 | 356 | const init = await q.initializationResult(); |
| 357 | // Usage is a second control round trip on the same process; a failure | |
| 358 | // there must not cost the slash commands and account we already have. | |
| 359 | const usage = await q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET().then( | |
| 360 | (response) => ({ | |
| 361 | rate_limits_available: response.rate_limits_available, | |
| 362 | rate_limits: response.rate_limits, | |
| 363 | }), | |
| 364 | () => undefined, | |
| 365 | ); | |
| 343 | 366 | const account = init.account as |
| 344 | 367 | | { |
| 345 | 368 | readonly email?: string; |
| ... | ... | @@ -354,6 +377,7 @@ const probeClaudeCapabilities = ( |
| 354 | 377 | tokenSource: account?.tokenSource, |
| 355 | 378 | apiProvider: account?.apiProvider, |
| 356 | 379 | slashCommands: parseClaudeInitializationCommands(init.commands), |
| 380 | ...(usage ? { usage } : {}), | |
| 357 | 381 | } satisfies ClaudeCapabilitiesProbe; |
| 358 | 382 | }); |
| 359 | 383 | }).pipe( |
| ... | ... | @@ -395,6 +419,8 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 395 | 419 | environment?: NodeJS.ProcessEnv, |
| 396 | 420 | cwd?: string, |
| 397 | 421 | modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, |
| 422 | /** Shared with the adapter so turn events reuse the scoped-bucket names this probe saw. */ | |
| 423 | scopedLimitNames?: Ref.Ref<ClaudeScopedLimitNames>, | |
| 398 | 424 | ): Effect.fn.Return< |
| 399 | 425 | ServerProviderDraft, |
| 400 | 426 | never, |
| ... | ... | @@ -535,6 +561,14 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 535 | 561 | subscriptionType: capabilities.subscriptionType, |
| 536 | 562 | authMethod: capabilities.tokenSource, |
| 537 | 563 | }) ?? apiProviderAuthMetadata(capabilities.apiProvider); |
| 564 | const usageLimits = !capabilities.usage | |
| 565 | ? makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed" }) | |
| 566 | : scopedLimitNames | |
| 567 | ? yield* recordClaudeUsageResponse(scopedLimitNames, { | |
| 568 | response: capabilities.usage, | |
| 569 | checkedAt, | |
| 570 | }) | |
| 571 | : claudeUsageResponseToLimits({ response: capabilities.usage, checkedAt }).limits; | |
| 538 | 572 | return buildServerProvider({ |
| 539 | 573 | presentation: CLAUDE_PRESENTATION, |
| 540 | 574 | enabled: claudeSettings.enabled, |
| ... | ... | @@ -552,6 +586,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 552 | 586 | ...(authMetadata ? authMetadata : {}), |
| 553 | 587 | }, |
| 554 | 588 | ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), |
| 589 | usageLimits, | |
| 555 | 590 | }, |
| 556 | 591 | }); |
| 557 | 592 | }); |
apps/server/src/provider/Layers/CodexAdapter.ts+8-4| ... | ... | @@ -70,6 +70,7 @@ import { |
| 70 | 70 | } from "./CodexSessionRuntime.ts"; |
| 71 | 71 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 72 | 72 | import { resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; |
| 73 | import { codexRateLimitsToUpdate } from "./codexUsageLimits.ts"; | |
| 73 | 74 | const isCodexAppServerProcessExitedError = Schema.is(CodexErrors.CodexAppServerProcessExitedError); |
| 74 | 75 | const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTransportError); |
| 75 | 76 | const isCodexSessionRuntimeThreadIdMissingError = Schema.is( |
| ... | ... | @@ -1747,16 +1748,19 @@ function mapToRuntimeEvents( |
| 1747 | 1748 | } |
| 1748 | 1749 | |
| 1749 | 1750 | if (event.method === "account/rateLimits/updated") { |
| 1750 | if (!readPayload(EffectCodexSchema.V2AccountRateLimitsUpdatedNotification, event.payload)) { | |
| 1751 | const payload = readPayload( | |
| 1752 | EffectCodexSchema.V2AccountRateLimitsUpdatedNotification, | |
| 1753 | event.payload, | |
| 1754 | ); | |
| 1755 | const limits = payload ? codexRateLimitsToUpdate(payload.rateLimits) : undefined; | |
| 1756 | if (!limits) { | |
| 1751 | 1757 | return []; |
| 1752 | 1758 | } |
| 1753 | 1759 | return [ |
| 1754 | 1760 | { |
| 1755 | 1761 | type: "account.rate-limits.updated", |
| 1756 | 1762 | ...runtimeEventBase(event, canonicalThreadId), |
| 1757 | payload: { | |
| 1758 | rateLimits: event.payload ?? {}, | |
| 1759 | }, | |
| 1763 | payload: { limits }, | |
| 1760 | 1764 | }, |
| 1761 | 1765 | ]; |
| 1762 | 1766 | } |
apps/server/src/provider/Layers/CodexProvider.ts+46-3| ... | ... | @@ -33,8 +33,19 @@ import { |
| 33 | 33 | type ServerProviderDraft, |
| 34 | 34 | } from "../providerSnapshot.ts"; |
| 35 | 35 | import { expandHomePath } from "../../pathExpansion.ts"; |
| 36 | import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; | |
| 37 | import { | |
| 38 | codexRateLimitsFailureMessage, | |
| 39 | codexRateLimitsToLimits, | |
| 40 | type CodexRateLimitSnapshot, | |
| 41 | } from "./codexUsageLimits.ts"; | |
| 36 | 42 | import packageJson from "../../../package.json" with { type: "json" }; |
| 37 | 43 | const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); |
| 44 | const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000; | |
| 45 | ||
| 46 | type CodexRateLimitsProbe = | |
| 47 | | { readonly snapshot: CodexRateLimitSnapshot } | |
| 48 | | { readonly failure: string }; | |
| 38 | 49 | |
| 39 | 50 | const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; |
| 40 | 51 | |
| ... | ... | @@ -45,6 +56,7 @@ const CODEX_PRESENTATION = { |
| 45 | 56 | |
| 46 | 57 | export interface CodexAppServerProviderSnapshot { |
| 47 | 58 | readonly account: CodexSchema.V2GetAccountResponse; |
| 59 | readonly rateLimits?: CodexRateLimitsProbe; | |
| 48 | 60 | readonly version: string | undefined; |
| 49 | 61 | readonly models: ReadonlyArray<ServerProviderModel>; |
| 50 | 62 | readonly skills: ReadonlyArray<ServerProviderSkill>; |
| ... | ... | @@ -72,8 +84,12 @@ function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["accoun |
| 72 | 84 | if (account.type === "apiKey") return "OpenAI API Key"; |
| 73 | 85 | if (account.type === "amazonBedrock") return "Amazon Bedrock"; |
| 74 | 86 | if (account.type !== "chatgpt") return undefined; |
| 87 | return codexPlanLabel(account.planType); | |
| 88 | } | |
| 75 | 89 | |
| 76 | switch (account.planType) { | |
| 90 | /** Shared with usage-limit sources, which report the same `planType` slugs. */ | |
| 91 | export function codexPlanLabel(planType: string | null | undefined): string | undefined { | |
| 92 | switch (planType) { | |
| 77 | 93 | case "free": |
| 78 | 94 | return "ChatGPT Free Subscription"; |
| 79 | 95 | case "go": |
| ... | ... | @@ -102,7 +118,6 @@ function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["accoun |
| 102 | 118 | case "unknown": |
| 103 | 119 | return "ChatGPT Subscription"; |
| 104 | 120 | default: |
| 105 | account.planType satisfies never; | |
| 106 | 121 | return undefined; |
| 107 | 122 | } |
| 108 | 123 | } |
| ... | ... | @@ -394,18 +409,35 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun |
| 394 | 409 | } satisfies CodexAppServerProviderSnapshot; |
| 395 | 410 | } |
| 396 | 411 | |
| 397 | const [skillsResponse, models] = yield* Effect.all( | |
| 412 | const [skillsResponse, models, rateLimits] = yield* Effect.all( | |
| 398 | 413 | [ |
| 399 | 414 | client.request("skills/list", { |
| 400 | 415 | cwds: [input.cwd], |
| 401 | 416 | }), |
| 402 | 417 | requestAllCodexModels(client), |
| 418 | // Usage is an enrichment: a failure or a slow answer degrades to "no | |
| 419 | // usage this probe" rather than costing the account and models. | |
| 420 | client.request("account/rateLimits/read", undefined).pipe( | |
| 421 | Effect.map((response): CodexRateLimitsProbe => ({ snapshot: response.rateLimits })), | |
| 422 | Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)), | |
| 423 | Effect.map( | |
| 424 | Option.getOrElse((): CodexRateLimitsProbe => ({ | |
| 425 | failure: "Codex did not answer the usage request.", | |
| 426 | })), | |
| 427 | ), | |
| 428 | Effect.catch((error) => | |
| 429 | Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe( | |
| 430 | Effect.as<CodexRateLimitsProbe>({ failure: codexRateLimitsFailureMessage(error) }), | |
| 431 | ), | |
| 432 | ), | |
| 433 | ), | |
| 403 | 434 | ], |
| 404 | 435 | { concurrency: "unbounded" }, |
| 405 | 436 | ); |
| 406 | 437 | |
| 407 | 438 | return { |
| 408 | 439 | account: accountResponse, |
| 440 | rateLimits, | |
| 409 | 441 | version, |
| 410 | 442 | models: applyPreferredCodexDefaultModel( |
| 411 | 443 | appendCustomCodexModels(models, input.customModels ?? []), |
| ... | ... | @@ -640,6 +672,16 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu |
| 640 | 672 | |
| 641 | 673 | const snapshot = probeResult.success.value; |
| 642 | 674 | const accountStatus = accountProbeStatus(snapshot.account); |
| 675 | const usageLimits = | |
| 676 | snapshot.account.account?.type === "apiKey" | |
| 677 | ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) | |
| 678 | : snapshot.rateLimits === undefined || "failure" in snapshot.rateLimits | |
| 679 | ? makeUnavailableUsageLimits({ | |
| 680 | checkedAt, | |
| 681 | reason: "probeFailed", | |
| 682 | ...(snapshot.rateLimits ? { message: snapshot.rateLimits.failure } : {}), | |
| 683 | }) | |
| 684 | : codexRateLimitsToLimits({ snapshot: snapshot.rateLimits.snapshot, checkedAt }); | |
| 643 | 685 | |
| 644 | 686 | return buildServerProvider({ |
| 645 | 687 | presentation: CODEX_PRESENTATION, |
| ... | ... | @@ -660,6 +702,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu |
| 660 | 702 | status: accountStatus.status, |
| 661 | 703 | auth: accountStatus.auth, |
| 662 | 704 | ...(accountStatus.message ? { message: accountStatus.message } : {}), |
| 705 | usageLimits, | |
| 663 | 706 | }, |
| 664 | 707 | }); |
| 665 | 708 | }); |
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+1| ... | ... | @@ -118,6 +118,7 @@ const makeFakeInstance = ( |
| 118 | 118 | getSnapshot: Effect.succeed({} as unknown as ServerProvider), |
| 119 | 119 | refresh: Effect.succeed({} as unknown as ServerProvider), |
| 120 | 120 | streamChanges: Stream.empty, |
| 121 | applyUsageLimits: () => Effect.void, | |
| 121 | 122 | }, |
| 122 | 123 | adapter, |
| 123 | 124 | textGeneration: {} as unknown as TextGeneration.TextGeneration["Service"], |
apps/server/src/provider/Layers/ProviderRegistry.test.ts+8| ... | ... | @@ -1098,6 +1098,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1098 | 1098 | Effect.andThen(Effect.never), |
| 1099 | 1099 | ), |
| 1100 | 1100 | streamChanges: Stream.empty, |
| 1101 | applyUsageLimits: () => Effect.void, | |
| 1101 | 1102 | }, |
| 1102 | 1103 | adapter: {} as ProviderInstance["adapter"], |
| 1103 | 1104 | textGeneration: {} as ProviderInstance["textGeneration"], |
| ... | ... | @@ -1187,6 +1188,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1187 | 1188 | getSnapshot: Effect.succeed(provider), |
| 1188 | 1189 | refresh: Effect.succeed(provider), |
| 1189 | 1190 | streamChanges: Stream.empty, |
| 1191 | applyUsageLimits: () => Effect.void, | |
| 1190 | 1192 | }, |
| 1191 | 1193 | snapshotForCwd, |
| 1192 | 1194 | adapter: {} as ProviderInstance["adapter"], |
| ... | ... | @@ -1377,6 +1379,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1377 | 1379 | Effect.as(codexProvider), |
| 1378 | 1380 | ), |
| 1379 | 1381 | streamChanges: Stream.empty, |
| 1382 | applyUsageLimits: () => Effect.void, | |
| 1380 | 1383 | }, |
| 1381 | 1384 | adapter: {} as ProviderInstance["adapter"], |
| 1382 | 1385 | textGeneration: {} as ProviderInstance["textGeneration"], |
| ... | ... | @@ -1400,6 +1403,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1400 | 1403 | Effect.andThen(Ref.get(catalogSnapshot)), |
| 1401 | 1404 | ), |
| 1402 | 1405 | streamChanges: Stream.empty, |
| 1406 | applyUsageLimits: () => Effect.void, | |
| 1403 | 1407 | }, |
| 1404 | 1408 | adapter: {} as ProviderInstance["adapter"], |
| 1405 | 1409 | textGeneration: {} as ProviderInstance["textGeneration"], |
| ... | ... | @@ -1521,6 +1525,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1521 | 1525 | getSnapshot: Effect.succeed(initialProvider), |
| 1522 | 1526 | refresh: Effect.succeed(refreshedProvider), |
| 1523 | 1527 | streamChanges: Stream.fromPubSub(changes), |
| 1528 | applyUsageLimits: () => Effect.void, | |
| 1524 | 1529 | }, |
| 1525 | 1530 | adapter: {} as ProviderInstance["adapter"], |
| 1526 | 1531 | textGeneration: {} as ProviderInstance["textGeneration"], |
| ... | ... | @@ -1650,6 +1655,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1650 | 1655 | getSnapshot: Effect.succeed(initialProvider), |
| 1651 | 1656 | refresh: Effect.succeed(authoritativeProvider), |
| 1652 | 1657 | streamChanges: Stream.fromPubSub(changes), |
| 1658 | applyUsageLimits: () => Effect.void, | |
| 1653 | 1659 | }, |
| 1654 | 1660 | adapter: {} as ProviderInstance["adapter"], |
| 1655 | 1661 | textGeneration: {} as ProviderInstance["textGeneration"], |
| ... | ... | @@ -1757,6 +1763,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1757 | 1763 | getSnapshot: Effect.succeed(cachedProvider), |
| 1758 | 1764 | refresh: Effect.die(new Error("simulated refresh failure")), |
| 1759 | 1765 | streamChanges: Stream.empty, |
| 1766 | applyUsageLimits: () => Effect.void, | |
| 1760 | 1767 | }, |
| 1761 | 1768 | adapter: {} as ProviderInstance["adapter"], |
| 1762 | 1769 | textGeneration: {} as ProviderInstance["textGeneration"], |
| ... | ... | @@ -1850,6 +1857,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1850 | 1857 | getSnapshot: Effect.succeed(provider), |
| 1851 | 1858 | refresh: Effect.succeed(provider), |
| 1852 | 1859 | streamChanges: Stream.empty, |
| 1860 | applyUsageLimits: () => Effect.void, | |
| 1853 | 1861 | }, |
| 1854 | 1862 | adapter: {} as ProviderInstance["adapter"], |
| 1855 | 1863 | textGeneration: {} as ProviderInstance["textGeneration"], |
apps/server/src/provider/Layers/ProviderUsageLimitsIngestion.ts created+44| ... | ... | @@ -0,0 +1,44 @@ |
| 1 | /** | |
| 2 | * ProviderUsageLimitsIngestionLive — folds `account.rate-limits.updated` | |
| 3 | * runtime events into the owning instance's published snapshot. | |
| 4 | * | |
| 5 | * Adapters normalise their native payloads before emitting, so this layer | |
| 6 | * never sees a driver shape: it routes the typed update to the instance and | |
| 7 | * lets `ServerProviderShape.applyUsageLimits` merge and republish on the | |
| 8 | * instance's own change stream, which `ProviderRegistry` already aggregates. | |
| 9 | * | |
| 10 | * @module provider/Layers/ProviderUsageLimitsIngestion | |
| 11 | */ | |
| 12 | import * as DateTime from "effect/DateTime"; | |
| 13 | import * as Effect from "effect/Effect"; | |
| 14 | import * as Layer from "effect/Layer"; | |
| 15 | import * as Stream from "effect/Stream"; | |
| 16 | ||
| 17 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; | |
| 18 | import { ProviderService } from "../Services/ProviderService.ts"; | |
| 19 | ||
| 20 | export const ProviderUsageLimitsIngestionLive = Layer.effectDiscard( | |
| 21 | Effect.gen(function* () { | |
| 22 | const providerService = yield* ProviderService; | |
| 23 | const instanceRegistry = yield* ProviderInstanceRegistry; | |
| 24 | ||
| 25 | yield* providerService.streamEvents.pipe( | |
| 26 | Stream.filter((event) => event.type === "account.rate-limits.updated"), | |
| 27 | Stream.runForEach((event) => | |
| 28 | Effect.gen(function* () { | |
| 29 | if (!event.providerInstanceId) { | |
| 30 | return; | |
| 31 | } | |
| 32 | const instance = yield* instanceRegistry.getInstance(event.providerInstanceId); | |
| 33 | if (!instance) { | |
| 34 | return; | |
| 35 | } | |
| 36 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | |
| 37 | yield* instance.snapshot.applyUsageLimits({ ...event.payload.limits, checkedAt }); | |
| 38 | // One bad event must not end the subscriber for every later one. | |
| 39 | }).pipe(Effect.ignoreCause({ log: true })), | |
| 40 | ), | |
| 41 | Effect.forkScoped, | |
| 42 | ); | |
| 43 | }), | |
| 44 | ); |
apps/server/src/provider/Layers/claudeUsageLimits.test.ts created+180| ... | ... | @@ -0,0 +1,180 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { claudeRateLimitEventToUpdate, claudeUsageResponseToLimits } from "./claudeUsageLimits.ts"; | |
| 4 | ||
| 5 | const checkedAt = "2026-07-18T10:00:00.000Z"; | |
| 6 | const noNames = { overageIncluded: undefined } as const; | |
| 7 | ||
| 8 | describe("claudeUsageResponseToLimits", () => { | |
| 9 | it("maps the session, weekly, and model-scoped weekly windows", () => { | |
| 10 | expect( | |
| 11 | claudeUsageResponseToLimits({ | |
| 12 | checkedAt, | |
| 13 | response: { | |
| 14 | rate_limits_available: true, | |
| 15 | rate_limits: { | |
| 16 | five_hour: { utilization: 54, resets_at: "2026-07-18T14:39:00Z" }, | |
| 17 | seven_day: { utilization: 18.4, resets_at: "2026-07-24T08:59:00+00:00" }, | |
| 18 | seven_day_opus: { utilization: 3, resets_at: null }, | |
| 19 | // Newer CLIs add this on top of the typed keys; the pinned SDK | |
| 20 | // typings do not know it yet. | |
| 21 | ...({ | |
| 22 | model_scoped: [ | |
| 23 | { display_name: "Fable", utilization: 73, resets_at: "2026-07-24T08:59:00Z" }, | |
| 24 | { display_name: "Ghost", utilization: null, resets_at: null }, | |
| 25 | ], | |
| 26 | } as object), | |
| 27 | extra_usage: { | |
| 28 | is_enabled: false, | |
| 29 | monthly_limit: null, | |
| 30 | used_credits: null, | |
| 31 | utilization: null, | |
| 32 | }, | |
| 33 | }, | |
| 34 | }, | |
| 35 | }), | |
| 36 | ).toEqual({ | |
| 37 | names: { overageIncluded: "Fable" }, | |
| 38 | limits: { | |
| 39 | checkedAt, | |
| 40 | windows: [ | |
| 41 | { | |
| 42 | id: "five_hour", | |
| 43 | kind: "session", | |
| 44 | label: "Session", | |
| 45 | usedPercent: 54, | |
| 46 | windowDurationMins: 300, | |
| 47 | resetsAt: "2026-07-18T14:39:00.000Z", | |
| 48 | }, | |
| 49 | { | |
| 50 | id: "seven_day", | |
| 51 | kind: "weekly", | |
| 52 | label: "Weekly", | |
| 53 | usedPercent: 18.4, | |
| 54 | windowDurationMins: 10080, | |
| 55 | resetsAt: "2026-07-24T08:59:00.000Z", | |
| 56 | }, | |
| 57 | { | |
| 58 | id: "seven_day_fable", | |
| 59 | kind: "weekly", | |
| 60 | label: "Weekly · Fable", | |
| 61 | usedPercent: 73, | |
| 62 | windowDurationMins: 10080, | |
| 63 | resetsAt: "2026-07-24T08:59:00.000Z", | |
| 64 | }, | |
| 65 | ], | |
| 66 | }, | |
| 67 | }); | |
| 68 | }); | |
| 69 | ||
| 70 | it("names the overage-included bucket only from a scoped entry that drew a row", () => { | |
| 71 | expect( | |
| 72 | claudeUsageResponseToLimits({ | |
| 73 | checkedAt, | |
| 74 | response: { | |
| 75 | rate_limits_available: true, | |
| 76 | rate_limits: { | |
| 77 | ...({ | |
| 78 | model_scoped: [ | |
| 79 | { display_name: "Ghost", utilization: null, resets_at: null }, | |
| 80 | { display_name: "Fable", utilization: 5, resets_at: null }, | |
| 81 | ], | |
| 82 | } as object), | |
| 83 | }, | |
| 84 | }, | |
| 85 | }).names, | |
| 86 | ).toEqual({ overageIncluded: "Fable" }); | |
| 87 | }); | |
| 88 | ||
| 89 | it("reports API key and Bedrock accounts as unsupported", () => { | |
| 90 | expect( | |
| 91 | claudeUsageResponseToLimits({ | |
| 92 | checkedAt, | |
| 93 | response: { rate_limits_available: false, rate_limits: null }, | |
| 94 | }).limits, | |
| 95 | ).toEqual({ checkedAt, windows: [], unavailable: { reason: "unsupported" } }); | |
| 96 | }); | |
| 97 | ||
| 98 | it("skips a window the endpoint reports without a utilization", () => { | |
| 99 | expect( | |
| 100 | claudeUsageResponseToLimits({ | |
| 101 | checkedAt, | |
| 102 | response: { | |
| 103 | rate_limits_available: true, | |
| 104 | rate_limits: { | |
| 105 | five_hour: { utilization: null, resets_at: null }, | |
| 106 | seven_day: { utilization: 250, resets_at: null }, | |
| 107 | }, | |
| 108 | }, | |
| 109 | }).limits.windows, | |
| 110 | ).toEqual([ | |
| 111 | { | |
| 112 | id: "seven_day", | |
| 113 | kind: "weekly", | |
| 114 | label: "Weekly", | |
| 115 | usedPercent: 100, | |
| 116 | windowDurationMins: 10080, | |
| 117 | }, | |
| 118 | ]); | |
| 119 | }); | |
| 120 | }); | |
| 121 | ||
| 122 | describe("claudeRateLimitEventToUpdate", () => { | |
| 123 | it("scales the 0–1 utilization and epoch-second reset onto the probe's window id", () => { | |
| 124 | expect( | |
| 125 | claudeRateLimitEventToUpdate( | |
| 126 | { | |
| 127 | status: "allowed_warning", | |
| 128 | rateLimitType: "seven_day", | |
| 129 | utilization: 0.85, | |
| 130 | resetsAt: 1_784_000_000, | |
| 131 | }, | |
| 132 | noNames, | |
| 133 | ), | |
| 134 | ).toEqual({ | |
| 135 | windows: [ | |
| 136 | { | |
| 137 | id: "seven_day", | |
| 138 | kind: "weekly", | |
| 139 | label: "Weekly", | |
| 140 | usedPercent: 85, | |
| 141 | windowDurationMins: 10080, | |
| 142 | resetsAt: "2026-07-14T03:33:20.000Z", | |
| 143 | }, | |
| 144 | ], | |
| 145 | }); | |
| 146 | }); | |
| 147 | ||
| 148 | it("lands the streamed overage-included bucket on the row the probe named", () => { | |
| 149 | const event = { | |
| 150 | status: "allowed", | |
| 151 | rateLimitType: "seven_day_overage_included" as never, | |
| 152 | utilization: 0.4, | |
| 153 | } as const; | |
| 154 | // No probe has named the bucket yet: guessing would open a stray row. | |
| 155 | expect(claudeRateLimitEventToUpdate(event, noNames)).toBeUndefined(); | |
| 156 | expect(claudeRateLimitEventToUpdate(event, { overageIncluded: "Fable" })).toEqual({ | |
| 157 | windows: [ | |
| 158 | { | |
| 159 | id: "seven_day_fable", | |
| 160 | kind: "weekly", | |
| 161 | label: "Weekly · Fable", | |
| 162 | usedPercent: 40, | |
| 163 | windowDurationMins: 10080, | |
| 164 | }, | |
| 165 | ], | |
| 166 | }); | |
| 167 | }); | |
| 168 | ||
| 169 | it("ignores windows the page does not render and events without a utilization", () => { | |
| 170 | expect( | |
| 171 | claudeRateLimitEventToUpdate( | |
| 172 | { status: "allowed", rateLimitType: "seven_day_opus", utilization: 0.1 }, | |
| 173 | noNames, | |
| 174 | ), | |
| 175 | ).toBeUndefined(); | |
| 176 | expect( | |
| 177 | claudeRateLimitEventToUpdate({ status: "rejected", rateLimitType: "five_hour" }, noNames), | |
| 178 | ).toBeUndefined(); | |
| 179 | }); | |
| 180 | }); |
apps/server/src/provider/Layers/claudeUsageLimits.ts created+199| ... | ... | @@ -0,0 +1,199 @@ |
| 1 | /** | |
| 2 | * Claude Code subscription usage. Both sources produce windows with the same | |
| 3 | * ids so a turn-driven `rate_limit_event` lands on the row the SDK's | |
| 4 | * `get_usage` read established: | |
| 5 | * | |
| 6 | * - `get_usage` (on demand, during the capabilities probe) reports every | |
| 7 | * window at once as 0–100 percentages with ISO reset times. | |
| 8 | * - `rate_limit_event` (streamed during a turn) names one window at a time | |
| 9 | * with a 0–1 utilization fraction and an epoch-seconds reset. | |
| 10 | * | |
| 11 | * @module provider/Layers/claudeUsageLimits | |
| 12 | */ | |
| 13 | import type { SDKControlGetUsageResponse, SDKRateLimitInfo } from "@anthropic-ai/claude-agent-sdk"; | |
| 14 | import type { | |
| 15 | ProviderUsageLimitsUpdate, | |
| 16 | ServerProviderUsageLimits, | |
| 17 | ServerProviderUsageWindow, | |
| 18 | } from "@t3tools/contracts"; | |
| 19 | import * as DateTime from "effect/DateTime"; | |
| 20 | import * as Effect from "effect/Effect"; | |
| 21 | import * as Option from "effect/Option"; | |
| 22 | import * as Ref from "effect/Ref"; | |
| 23 | ||
| 24 | import { | |
| 25 | clampPercent, | |
| 26 | makeUnavailableUsageLimits, | |
| 27 | makeUsageLimits, | |
| 28 | } from "../providerUsageLimits.ts"; | |
| 29 | ||
| 30 | const SESSION_MINS = 5 * 60; | |
| 31 | const WEEK_MINS = 7 * 24 * 60; | |
| 32 | ||
| 33 | /** | |
| 34 | * The account-wide windows, keyed by the SDK's `rateLimitType`. Model-scoped | |
| 35 | * weeklies are additive on top of these: the CLI reports them under | |
| 36 | * `rate_limits.model_scoped[]` on `get_usage` and streams the overage-included | |
| 37 | * model bucket (Fable today) as `seven_day_overage_included`. | |
| 38 | */ | |
| 39 | const WINDOWS: Readonly< | |
| 40 | Record<string, Pick<ServerProviderUsageWindow, "kind" | "label" | "windowDurationMins">> | |
| 41 | > = { | |
| 42 | five_hour: { kind: "session", label: "Session", windowDurationMins: SESSION_MINS }, | |
| 43 | seven_day: { kind: "weekly", label: "Weekly", windowDurationMins: WEEK_MINS }, | |
| 44 | }; | |
| 45 | ||
| 46 | /** | |
| 47 | * The streamed event names the overage-included bucket by type | |
| 48 | * (`seven_day_overage_included`), while `get_usage` names it by the model's | |
| 49 | * `display_name`. Which model that is changes over time, so the probe records | |
| 50 | * the name it saw and the event mapper reuses it; the mid-turn update then | |
| 51 | * lands on the row the probe drew instead of opening a second one. | |
| 52 | */ | |
| 53 | const OVERAGE_INCLUDED_EVENT_TYPE = "seven_day_overage_included"; | |
| 54 | ||
| 55 | export interface ClaudeScopedLimitNames { | |
| 56 | readonly overageIncluded: string | undefined; | |
| 57 | } | |
| 58 | ||
| 59 | export const makeClaudeScopedLimitNames = Ref.make<ClaudeScopedLimitNames>({ | |
| 60 | overageIncluded: undefined, | |
| 61 | }); | |
| 62 | ||
| 63 | function scopedWindowId(displayName: string): string { | |
| 64 | return `seven_day_${displayName.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`; | |
| 65 | } | |
| 66 | ||
| 67 | function scopedWindow( | |
| 68 | displayName: string, | |
| 69 | usedPercent: number, | |
| 70 | resetsAt: string | undefined, | |
| 71 | ): ServerProviderUsageWindow { | |
| 72 | return { | |
| 73 | id: scopedWindowId(displayName), | |
| 74 | kind: "weekly", | |
| 75 | label: `Weekly · ${displayName}`, | |
| 76 | windowDurationMins: WEEK_MINS, | |
| 77 | usedPercent: clampPercent(usedPercent), | |
| 78 | ...(resetsAt ? { resetsAt } : {}), | |
| 79 | }; | |
| 80 | } | |
| 81 | ||
| 82 | /** | |
| 83 | * `model_scoped` shipped in the CLI after the SDK typings we pin, so it is | |
| 84 | * read structurally until the `.d.ts` catches up. | |
| 85 | */ | |
| 86 | interface ModelScopedWindow { | |
| 87 | readonly display_name: string; | |
| 88 | readonly utilization: number | null; | |
| 89 | readonly resets_at: string | null; | |
| 90 | } | |
| 91 | ||
| 92 | function readModelScoped(rateLimits: object): ReadonlyArray<ModelScopedWindow> { | |
| 93 | const raw = (rateLimits as { readonly model_scoped?: unknown }).model_scoped; | |
| 94 | if (!Array.isArray(raw)) return []; | |
| 95 | return raw.filter( | |
| 96 | (entry): entry is ModelScopedWindow => | |
| 97 | typeof entry === "object" && | |
| 98 | entry !== null && | |
| 99 | typeof (entry as ModelScopedWindow).display_name === "string", | |
| 100 | ); | |
| 101 | } | |
| 102 | ||
| 103 | function isoFromEpochSeconds(value: number | undefined): string | undefined { | |
| 104 | if (value === undefined || !Number.isFinite(value) || value <= 0) return undefined; | |
| 105 | const dt = DateTime.make(value * 1000); | |
| 106 | return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; | |
| 107 | } | |
| 108 | ||
| 109 | function isoFromString(value: string | null | undefined): string | undefined { | |
| 110 | if (!value) return undefined; | |
| 111 | const dt = DateTime.make(value); | |
| 112 | return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; | |
| 113 | } | |
| 114 | ||
| 115 | function makeWindow( | |
| 116 | id: keyof typeof WINDOWS & string, | |
| 117 | usedPercent: number, | |
| 118 | resetsAt: string | undefined, | |
| 119 | ): ServerProviderUsageWindow { | |
| 120 | const window = WINDOWS[id]!; | |
| 121 | return { | |
| 122 | id, | |
| 123 | ...window, | |
| 124 | usedPercent: clampPercent(usedPercent), | |
| 125 | ...(resetsAt ? { resetsAt } : {}), | |
| 126 | }; | |
| 127 | } | |
| 128 | ||
| 129 | /** | |
| 130 | * Utilization is a 0–1 fraction on the streamed event. An overage-included | |
| 131 | * event before any probe has named the bucket is dropped: guessing a name | |
| 132 | * would draw a row the next probe cannot reconcile. | |
| 133 | */ | |
| 134 | export function claudeRateLimitEventToUpdate( | |
| 135 | info: SDKRateLimitInfo, | |
| 136 | names: ClaudeScopedLimitNames, | |
| 137 | ): ProviderUsageLimitsUpdate | undefined { | |
| 138 | const type: string | undefined = info.rateLimitType; | |
| 139 | if (!type || typeof info.utilization !== "number") { | |
| 140 | return undefined; | |
| 141 | } | |
| 142 | const usedPercent = info.utilization * 100; | |
| 143 | const resetsAt = isoFromEpochSeconds(info.resetsAt); | |
| 144 | if (type in WINDOWS) { | |
| 145 | return { windows: [makeWindow(type, usedPercent, resetsAt)] }; | |
| 146 | } | |
| 147 | if (type === OVERAGE_INCLUDED_EVENT_TYPE && names.overageIncluded) { | |
| 148 | return { windows: [scopedWindow(names.overageIncluded, usedPercent, resetsAt)] }; | |
| 149 | } | |
| 150 | return undefined; | |
| 151 | } | |
| 152 | ||
| 153 | /** | |
| 154 | * Percentages on the `get_usage` response are already 0–100. Also yields the | |
| 155 | * scoped-bucket names the response carried, for the event mapper to reuse. | |
| 156 | */ | |
| 157 | export function claudeUsageResponseToLimits(input: { | |
| 158 | readonly response: Pick<SDKControlGetUsageResponse, "rate_limits_available" | "rate_limits">; | |
| 159 | readonly checkedAt: string; | |
| 160 | }): { readonly limits: ServerProviderUsageLimits; readonly names: ClaudeScopedLimitNames } { | |
| 161 | const { response, checkedAt } = input; | |
| 162 | if (!response.rate_limits_available || !response.rate_limits) { | |
| 163 | return { | |
| 164 | limits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }), | |
| 165 | names: { overageIncluded: undefined }, | |
| 166 | }; | |
| 167 | } | |
| 168 | const windows: ServerProviderUsageWindow[] = []; | |
| 169 | for (const id of Object.keys(WINDOWS)) { | |
| 170 | const window = response.rate_limits[id as "five_hour" | "seven_day"]; | |
| 171 | if (!window || typeof window.utilization !== "number") continue; | |
| 172 | windows.push(makeWindow(id, window.utilization, isoFromString(window.resets_at))); | |
| 173 | } | |
| 174 | // The CLI filters `model_scoped` to the overage-included allowlist, which | |
| 175 | // today holds one model; the first entry is the one the event refers to. | |
| 176 | let overageIncluded: string | undefined; | |
| 177 | for (const entry of readModelScoped(response.rate_limits)) { | |
| 178 | if (typeof entry.utilization !== "number") continue; | |
| 179 | windows.push( | |
| 180 | scopedWindow(entry.display_name, entry.utilization, isoFromString(entry.resets_at)), | |
| 181 | ); | |
| 182 | // Only a bucket that drew a row may receive events; naming one that was | |
| 183 | // skipped would let a mid-turn event open a row the probe never showed. | |
| 184 | overageIncluded ??= entry.display_name; | |
| 185 | } | |
| 186 | return { | |
| 187 | limits: makeUsageLimits({ checkedAt, windows }), | |
| 188 | names: { overageIncluded }, | |
| 189 | }; | |
| 190 | } | |
| 191 | ||
| 192 | /** Probe-side helper: map the response and remember the scoped names for events. */ | |
| 193 | export const recordClaudeUsageResponse = ( | |
| 194 | namesRef: Ref.Ref<ClaudeScopedLimitNames>, | |
| 195 | input: Parameters<typeof claudeUsageResponseToLimits>[0], | |
| 196 | ): Effect.Effect<ServerProviderUsageLimits> => { | |
| 197 | const { limits, names } = claudeUsageResponseToLimits(input); | |
| 198 | return Ref.set(namesRef, names).pipe(Effect.as(limits)); | |
| 199 | }; |
apps/server/src/provider/Layers/codexUsageLimits.test.ts created+103| ... | ... | @@ -0,0 +1,103 @@ |
| 1 | import * as CodexErrors from "effect-codex-app-server/errors"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | codexRateLimitsFailureMessage, | |
| 6 | codexRateLimitsToLimits, | |
| 7 | codexRateLimitsToUpdate, | |
| 8 | } from "./codexUsageLimits.ts"; | |
| 9 | ||
| 10 | const checkedAt = "2026-07-18T10:00:00.000Z"; | |
| 11 | ||
| 12 | describe("codexRateLimitsToLimits", () => { | |
| 13 | it("maps primary and secondary onto the session and weekly windows", () => { | |
| 14 | expect( | |
| 15 | codexRateLimitsToLimits({ | |
| 16 | checkedAt, | |
| 17 | snapshot: { | |
| 18 | planType: "plus", | |
| 19 | primary: { usedPercent: 12, resetsAt: 1_784_000_000, windowDurationMins: 300 }, | |
| 20 | secondary: { usedPercent: 47, resetsAt: 1_784_500_000, windowDurationMins: 10080 }, | |
| 21 | }, | |
| 22 | }), | |
| 23 | ).toEqual({ | |
| 24 | checkedAt, | |
| 25 | windows: [ | |
| 26 | { | |
| 27 | id: "primary", | |
| 28 | kind: "session", | |
| 29 | label: "Session", | |
| 30 | usedPercent: 12, | |
| 31 | windowDurationMins: 300, | |
| 32 | resetsAt: "2026-07-14T03:33:20.000Z", | |
| 33 | }, | |
| 34 | { | |
| 35 | id: "secondary", | |
| 36 | kind: "weekly", | |
| 37 | label: "Weekly", | |
| 38 | usedPercent: 47, | |
| 39 | windowDurationMins: 10080, | |
| 40 | resetsAt: "2026-07-19T22:26:40.000Z", | |
| 41 | }, | |
| 42 | ], | |
| 43 | }); | |
| 44 | }); | |
| 45 | ||
| 46 | it("treats a lone duration-less primary as monthly on Free and Go", () => { | |
| 47 | expect( | |
| 48 | codexRateLimitsToLimits({ | |
| 49 | checkedAt, | |
| 50 | snapshot: { planType: "free", primary: { usedPercent: 80, resetsAt: null } }, | |
| 51 | }).windows, | |
| 52 | ).toEqual([ | |
| 53 | { | |
| 54 | id: "primary", | |
| 55 | kind: "monthly", | |
| 56 | label: "Monthly", | |
| 57 | usedPercent: 80, | |
| 58 | windowDurationMins: 43_200, | |
| 59 | }, | |
| 60 | ]); | |
| 61 | }); | |
| 62 | }); | |
| 63 | ||
| 64 | describe("codexRateLimitsToUpdate", () => { | |
| 65 | it("carries only the windows the notification names", () => { | |
| 66 | expect( | |
| 67 | codexRateLimitsToUpdate({ | |
| 68 | secondary: { usedPercent: 51, windowDurationMins: 10080 }, | |
| 69 | }), | |
| 70 | ).toEqual({ | |
| 71 | windows: [ | |
| 72 | { | |
| 73 | id: "secondary", | |
| 74 | kind: "weekly", | |
| 75 | label: "Weekly", | |
| 76 | usedPercent: 51, | |
| 77 | windowDurationMins: 10080, | |
| 78 | }, | |
| 79 | ], | |
| 80 | }); | |
| 81 | expect(codexRateLimitsToUpdate({ planType: "plus" })).toBeUndefined(); | |
| 82 | }); | |
| 83 | }); | |
| 84 | ||
| 85 | describe("codexRateLimitsFailureMessage", () => { | |
| 86 | it("keeps the JSON-RPC code and nothing else from a request failure", () => { | |
| 87 | expect( | |
| 88 | codexRateLimitsFailureMessage( | |
| 89 | new CodexErrors.CodexAppServerRequestError({ | |
| 90 | code: -32603, | |
| 91 | errorMessage: | |
| 92 | "failed to fetch codex rate limits: GET https://chatgpt.com/backend-api/wham/usage failed: 401 Unauthorized", | |
| 93 | }), | |
| 94 | ), | |
| 95 | ).toBe("Codex could not read usage (JSON-RPC -32603)."); | |
| 96 | }); | |
| 97 | ||
| 98 | it("phrases a dead process differently from a bad answer", () => { | |
| 99 | expect( | |
| 100 | codexRateLimitsFailureMessage(new CodexErrors.CodexAppServerProcessExitedError({ code: 1 })), | |
| 101 | ).toBe("Codex exited before it could report usage."); | |
| 102 | }); | |
| 103 | }); |
apps/server/src/provider/Layers/codexUsageLimits.ts created+118| ... | ... | @@ -0,0 +1,118 @@ |
| 1 | /** | |
| 2 | * Codex subscription usage. The `account/rateLimits/read` response and the | |
| 3 | * `account/rateLimits/updated` notification carry the same snapshot shape, so | |
| 4 | * one mapper serves the status probe and the turn-driven update; both emit | |
| 5 | * windows with the same ids so they merge onto the same rows. | |
| 6 | * | |
| 7 | * @module provider/Layers/codexUsageLimits | |
| 8 | */ | |
| 9 | import type { | |
| 10 | ProviderUsageLimitsUpdate, | |
| 11 | ServerProviderUsageLimits, | |
| 12 | ServerProviderUsageWindow, | |
| 13 | } from "@t3tools/contracts"; | |
| 14 | import * as DateTime from "effect/DateTime"; | |
| 15 | import * as Option from "effect/Option"; | |
| 16 | import type * as CodexErrors from "effect-codex-app-server/errors"; | |
| 17 | ||
| 18 | import { clampPercent, makeUsageLimits } from "../providerUsageLimits.ts"; | |
| 19 | ||
| 20 | interface CodexRateLimitWindow { | |
| 21 | readonly usedPercent: number; | |
| 22 | readonly resetsAt?: number | null; | |
| 23 | readonly windowDurationMins?: number | null; | |
| 24 | } | |
| 25 | ||
| 26 | /** Structural view of the generated `RateLimitSnapshot`; both messages satisfy it. */ | |
| 27 | export interface CodexRateLimitSnapshot { | |
| 28 | readonly planType?: string | null; | |
| 29 | readonly primary?: CodexRateLimitWindow | null; | |
| 30 | readonly secondary?: CodexRateLimitWindow | null; | |
| 31 | } | |
| 32 | ||
| 33 | const SESSION_MINS = 5 * 60; | |
| 34 | const WEEK_MINS = 7 * 24 * 60; | |
| 35 | const MONTH_MINS = 30 * 24 * 60; | |
| 36 | ||
| 37 | function isoFromEpochSeconds(value: number | null | undefined): string | undefined { | |
| 38 | if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined; | |
| 39 | const dt = DateTime.make(value * 1000); | |
| 40 | return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; | |
| 41 | } | |
| 42 | ||
| 43 | function kindForDuration(mins: number): ServerProviderUsageWindow["kind"] { | |
| 44 | if (mins >= MONTH_MINS) return "monthly"; | |
| 45 | if (mins >= WEEK_MINS) return "weekly"; | |
| 46 | return "session"; | |
| 47 | } | |
| 48 | ||
| 49 | function labelForKind(kind: ServerProviderUsageWindow["kind"]): string { | |
| 50 | return kind === "session" ? "Session" : kind === "weekly" ? "Weekly" : "Monthly"; | |
| 51 | } | |
| 52 | ||
| 53 | /** | |
| 54 | * `primary` / `secondary` are positions, not durations. Codex usually sends | |
| 55 | * `windowDurationMins`; when it does not, paid plans expose the 5-hour and | |
| 56 | * weekly pair and Free/Go expose one monthly allowance. | |
| 57 | */ | |
| 58 | export function codexRateLimitsToWindows( | |
| 59 | snapshot: CodexRateLimitSnapshot, | |
| 60 | ): ReadonlyArray<ServerProviderUsageWindow> { | |
| 61 | const isMonthlyPlan = snapshot.planType === "free" || snapshot.planType === "go"; | |
| 62 | const positions = [ | |
| 63 | ["primary", snapshot.primary, isMonthlyPlan ? MONTH_MINS : SESSION_MINS], | |
| 64 | ["secondary", snapshot.secondary, WEEK_MINS], | |
| 65 | ] as const; | |
| 66 | const windows: ServerProviderUsageWindow[] = []; | |
| 67 | for (const [id, window, fallbackMins] of positions) { | |
| 68 | if (!window || !Number.isFinite(window.usedPercent)) continue; | |
| 69 | const windowDurationMins = | |
| 70 | typeof window.windowDurationMins === "number" ? window.windowDurationMins : fallbackMins; | |
| 71 | const kind = kindForDuration(windowDurationMins); | |
| 72 | const resetsAt = isoFromEpochSeconds(window.resetsAt); | |
| 73 | windows.push({ | |
| 74 | id, | |
| 75 | kind, | |
| 76 | label: labelForKind(kind), | |
| 77 | usedPercent: clampPercent(window.usedPercent), | |
| 78 | windowDurationMins, | |
| 79 | ...(resetsAt ? { resetsAt } : {}), | |
| 80 | }); | |
| 81 | } | |
| 82 | return windows; | |
| 83 | } | |
| 84 | ||
| 85 | export function codexRateLimitsToLimits(input: { | |
| 86 | readonly snapshot: CodexRateLimitSnapshot; | |
| 87 | readonly checkedAt: string; | |
| 88 | }): ServerProviderUsageLimits { | |
| 89 | return makeUsageLimits({ | |
| 90 | checkedAt: input.checkedAt, | |
| 91 | windows: codexRateLimitsToWindows(input.snapshot), | |
| 92 | }); | |
| 93 | } | |
| 94 | ||
| 95 | export function codexRateLimitsToUpdate( | |
| 96 | snapshot: CodexRateLimitSnapshot, | |
| 97 | ): ProviderUsageLimitsUpdate | undefined { | |
| 98 | const windows = codexRateLimitsToWindows(snapshot); | |
| 99 | return windows.length > 0 ? { windows } : undefined; | |
| 100 | } | |
| 101 | ||
| 102 | /** | |
| 103 | * A bounded, client-safe reason for a failed `account/rateLimits/read`. The | |
| 104 | * raw error is for the log; only the category and, for a JSON-RPC failure, | |
| 105 | * the code reach the Limits view. | |
| 106 | */ | |
| 107 | export function codexRateLimitsFailureMessage(error: CodexErrors.CodexAppServerError): string { | |
| 108 | switch (error._tag) { | |
| 109 | case "CodexAppServerRequestError": | |
| 110 | return `Codex could not read usage (JSON-RPC ${error.code}).`; | |
| 111 | case "CodexAppServerSpawnError": | |
| 112 | return "Codex could not be started to read usage."; | |
| 113 | case "CodexAppServerProcessExitedError": | |
| 114 | return "Codex exited before it could report usage."; | |
| 115 | default: | |
| 116 | return "Codex did not answer the usage request."; | |
| 117 | } | |
| 118 | } |
apps/server/src/provider/Services/ServerProvider.ts+9-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { ServerProvider } from "@t3tools/contracts"; | |
| 1 | import type { ProviderUsageLimitsUpdate, ServerProvider } from "@t3tools/contracts"; | |
| 2 | 2 | import type * as Effect from "effect/Effect"; |
| 3 | 3 | import type * as Stream from "effect/Stream"; |
| 4 | 4 | import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; |
| ... | ... | @@ -8,4 +8,12 @@ export interface ServerProviderShape { |
| 8 | 8 | readonly getSnapshot: Effect.Effect<ServerProvider>; |
| 9 | 9 | readonly refresh: Effect.Effect<ServerProvider>; |
| 10 | 10 | readonly streamChanges: Stream.Stream<ServerProvider>; |
| 11 | /** | |
| 12 | * Fold a runtime rate-limit update into the published snapshot without | |
| 13 | * waiting for the next status probe. Sparse: windows merge by id and an | |
| 14 | * update with no usable window leaves the snapshot untouched. | |
| 15 | */ | |
| 16 | readonly applyUsageLimits: ( | |
| 17 | update: ProviderUsageLimitsUpdate & { readonly checkedAt: string }, | |
| 18 | ) => Effect.Effect<void>; | |
| 11 | 19 | } |
apps/server/src/provider/makeManagedServerProvider.test.ts+133| ... | ... | @@ -522,4 +522,137 @@ describe("makeManagedServerProvider", () => { |
| 522 | 522 | }), |
| 523 | 523 | ).pipe(Effect.provide(AlwaysRunTestLayer)), |
| 524 | 524 | ); |
| 525 | ||
| 526 | it.effect("applies runtime usage updates onto the published snapshot", () => | |
| 527 | Effect.scoped( | |
| 528 | Effect.gen(function* () { | |
| 529 | const provider = yield* makeManagedServerProvider<TestSettings>({ | |
| 530 | maintenanceCapabilities, | |
| 531 | getSettings: Effect.succeed({ enabled: true }), | |
| 532 | streamSettings: Stream.empty, | |
| 533 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 534 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 535 | checkProvider: Effect.succeed({ | |
| 536 | ...refreshedSnapshot, | |
| 537 | usageLimits: { | |
| 538 | checkedAt: "2026-04-10T00:00:01.000Z", | |
| 539 | windows: [ | |
| 540 | { id: "five_hour", kind: "session", label: "Session", usedPercent: 10 }, | |
| 541 | { | |
| 542 | id: "seven_day", | |
| 543 | kind: "weekly", | |
| 544 | label: "Weekly", | |
| 545 | usedPercent: 20, | |
| 546 | resetsAt: "2026-04-17T00:00:00.000Z", | |
| 547 | }, | |
| 548 | ], | |
| 549 | }, | |
| 550 | } satisfies ServerProvider), | |
| 551 | refreshInterval: "1 hour", | |
| 552 | }); | |
| 553 | yield* Stream.take(provider.streamChanges, 1).pipe(Stream.runDrain); | |
| 554 | ||
| 555 | const updatesFiber = yield* Stream.take(provider.streamChanges, 1).pipe( | |
| 556 | Stream.runCollect, | |
| 557 | Effect.forkChild, | |
| 558 | ); | |
| 559 | yield* Effect.yieldNow; | |
| 560 | ||
| 561 | // Percent-only weekly update: keeps the probe's reset time. | |
| 562 | yield* provider.applyUsageLimits({ | |
| 563 | checkedAt: "2026-04-10T00:05:00.000Z", | |
| 564 | windows: [{ id: "seven_day", kind: "weekly", label: "Weekly", usedPercent: 25 }], | |
| 565 | }); | |
| 566 | // No windows: nothing to merge, nothing published. | |
| 567 | yield* provider.applyUsageLimits({ checkedAt: "2026-04-10T00:06:00.000Z", windows: [] }); | |
| 568 | ||
| 569 | const [update] = Array.from(yield* Fiber.join(updatesFiber)); | |
| 570 | assert.deepStrictEqual(update?.usageLimits, { | |
| 571 | checkedAt: "2026-04-10T00:05:00.000Z", | |
| 572 | windows: [ | |
| 573 | { id: "five_hour", kind: "session", label: "Session", usedPercent: 10 }, | |
| 574 | { | |
| 575 | id: "seven_day", | |
| 576 | kind: "weekly", | |
| 577 | label: "Weekly", | |
| 578 | usedPercent: 25, | |
| 579 | resetsAt: "2026-04-17T00:00:00.000Z", | |
| 580 | }, | |
| 581 | ], | |
| 582 | }); | |
| 583 | assert.deepStrictEqual(yield* provider.getSnapshot, update); | |
| 584 | }), | |
| 585 | ).pipe(Effect.provide(AlwaysRunTestLayer)), | |
| 586 | ); | |
| 587 | ||
| 588 | it.effect("keeps live usage windows across a failed probe and a stale enrichment", () => | |
| 589 | Effect.scoped( | |
| 590 | Effect.gen(function* () { | |
| 591 | const releaseEnrichment = yield* Deferred.make<void>(); | |
| 592 | const refreshCount = yield* Ref.make(0); | |
| 593 | const probedLimits = { | |
| 594 | checkedAt: "2026-04-10T00:00:01.000Z", | |
| 595 | windows: [{ id: "primary", kind: "session", label: "Session", usedPercent: 10 }], | |
| 596 | } as const; | |
| 597 | const provider = yield* makeManagedServerProvider<TestSettings>({ | |
| 598 | maintenanceCapabilities, | |
| 599 | getSettings: Effect.succeed({ enabled: true }), | |
| 600 | streamSettings: Stream.empty, | |
| 601 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 602 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 603 | checkProvider: Ref.updateAndGet(refreshCount, (count) => count + 1).pipe( | |
| 604 | Effect.map((count) => | |
| 605 | count === 1 | |
| 606 | ? { ...refreshedSnapshot, usageLimits: probedLimits } | |
| 607 | : { | |
| 608 | ...refreshedSnapshotSecond, | |
| 609 | usageLimits: { | |
| 610 | checkedAt: "2026-04-10T00:00:03.000Z", | |
| 611 | windows: [], | |
| 612 | unavailable: { reason: "probeFailed" }, | |
| 613 | }, | |
| 614 | }, | |
| 615 | ), | |
| 616 | ), | |
| 617 | enrichSnapshot: ({ snapshot, publishSnapshot }) => | |
| 618 | Deferred.await(releaseEnrichment).pipe( | |
| 619 | Effect.flatMap(() => | |
| 620 | publishSnapshot({ | |
| 621 | ...enrichedSnapshot, | |
| 622 | ...snapshot, | |
| 623 | models: enrichedSnapshot.models, | |
| 624 | }), | |
| 625 | ), | |
| 626 | ), | |
| 627 | refreshInterval: "1 hour", | |
| 628 | }); | |
| 629 | yield* Stream.take(provider.streamChanges, 1).pipe(Stream.runDrain); | |
| 630 | ||
| 631 | const liveWindow = { | |
| 632 | id: "primary", | |
| 633 | kind: "session", | |
| 634 | label: "Session", | |
| 635 | usedPercent: 60, | |
| 636 | } as const; | |
| 637 | yield* provider.applyUsageLimits({ | |
| 638 | checkedAt: "2026-04-10T00:00:02.000Z", | |
| 639 | windows: [liveWindow], | |
| 640 | }); | |
| 641 | ||
| 642 | // Enrichment computed from the pre-update snapshot lands afterwards. | |
| 643 | yield* Deferred.succeed(releaseEnrichment, undefined); | |
| 644 | const enriched = yield* Stream.take(provider.streamChanges, 1).pipe( | |
| 645 | Stream.runCollect, | |
| 646 | Effect.map((chunk) => Array.from(chunk)[0]!), | |
| 647 | ); | |
| 648 | assert.deepStrictEqual(enriched.models, enrichedSnapshot.models); | |
| 649 | assert.deepStrictEqual(enriched.usageLimits?.windows, [liveWindow]); | |
| 650 | ||
| 651 | // A probe that could not read usage keeps the last good windows. | |
| 652 | const refreshed = yield* provider.refresh; | |
| 653 | assert.strictEqual(refreshed.message, refreshedSnapshotSecond.message); | |
| 654 | assert.deepStrictEqual(refreshed.usageLimits?.windows, [liveWindow]); | |
| 655 | }), | |
| 656 | ).pipe(Effect.provide(AlwaysRunTestLayer)), | |
| 657 | ); | |
| 525 | 658 | }); |
apps/server/src/provider/makeManagedServerProvider.ts+67-21| ... | ... | @@ -17,6 +17,7 @@ import * as Semaphore from "effect/Semaphore"; |
| 17 | 17 | |
| 18 | 18 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; |
| 19 | 19 | import { ServerSettingsService } from "../serverSettings.ts"; |
| 20 | import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "./providerUsageLimits.ts"; | |
| 20 | 21 | import type { ServerProviderShape } from "./Services/ServerProvider.ts"; |
| 21 | 22 | |
| 22 | 23 | interface ProviderSnapshotState { |
| ... | ... | @@ -24,6 +25,17 @@ interface ProviderSnapshotState { |
| 24 | 25 | readonly enrichmentGeneration: number; |
| 25 | 26 | } |
| 26 | 27 | |
| 28 | function withUsageLimits( | |
| 29 | snapshot: ServerProvider, | |
| 30 | usageLimits: ServerProvider["usageLimits"], | |
| 31 | ): ServerProvider { | |
| 32 | if (snapshot.usageLimits === usageLimits) { | |
| 33 | return snapshot; | |
| 34 | } | |
| 35 | const { usageLimits: _previous, ...rest } = snapshot; | |
| 36 | return usageLimits ? { ...rest, usageLimits } : rest; | |
| 37 | } | |
| 38 | ||
| 27 | 39 | export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* < |
| 28 | 40 | Settings, |
| 29 | 41 | >(input: { |
| ... | ... | @@ -69,16 +81,16 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( |
| 69 | 81 | nextSnapshot: ServerProvider, |
| 70 | 82 | ) { |
| 71 | 83 | const snapshotToPublish = yield* Ref.modify(snapshotStateRef, (state) => { |
| 72 | if (state.enrichmentGeneration !== generation || Equal.equals(state.snapshot, nextSnapshot)) { | |
| 84 | if (state.enrichmentGeneration !== generation) { | |
| 73 | 85 | return [null, state] as const; |
| 74 | 86 | } |
| 75 | return [ | |
| 76 | nextSnapshot, | |
| 77 | { | |
| 78 | ...state, | |
| 79 | snapshot: nextSnapshot, | |
| 80 | }, | |
| 81 | ] as const; | |
| 87 | // Enrichment derives from the snapshot it was handed; a runtime usage | |
| 88 | // update that landed since must not be reverted by it. | |
| 89 | const merged = withUsageLimits(nextSnapshot, state.snapshot.usageLimits); | |
| 90 | if (Equal.equals(state.snapshot, merged)) { | |
| 91 | return [null, state] as const; | |
| 92 | } | |
| 93 | return [merged, { ...state, snapshot: merged }] as const; | |
| 82 | 94 | }); |
| 83 | 95 | if (snapshotToPublish === null) { |
| 84 | 96 | return; |
| ... | ... | @@ -138,19 +150,26 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( |
| 138 | 150 | return state.snapshot; |
| 139 | 151 | } |
| 140 | 152 | |
| 141 | const nextSnapshot = yield* input.checkProvider; | |
| 142 | const nextGeneration = yield* Ref.modify(snapshotStateRef, (state) => { | |
| 143 | const generation = input.enrichSnapshot | |
| 144 | ? state.enrichmentGeneration + 1 | |
| 145 | : state.enrichmentGeneration; | |
| 146 | return [ | |
| 147 | generation, | |
| 148 | { | |
| 149 | snapshot: nextSnapshot, | |
| 150 | enrichmentGeneration: generation, | |
| 151 | }, | |
| 152 | ] as const; | |
| 153 | }); | |
| 153 | const probedSnapshot = yield* input.checkProvider; | |
| 154 | const { snapshot: nextSnapshot, generation: nextGeneration } = yield* Ref.modify( | |
| 155 | snapshotStateRef, | |
| 156 | (state) => { | |
| 157 | const generation = input.enrichSnapshot | |
| 158 | ? state.enrichmentGeneration + 1 | |
| 159 | : state.enrichmentGeneration; | |
| 160 | const snapshot = withUsageLimits( | |
| 161 | probedSnapshot, | |
| 162 | resolveUsageLimitsAfterProbe({ | |
| 163 | published: state.snapshot.usageLimits, | |
| 164 | probed: probedSnapshot.usageLimits, | |
| 165 | }), | |
| 166 | ); | |
| 167 | return [ | |
| 168 | { snapshot, generation }, | |
| 169 | { snapshot, enrichmentGeneration: generation }, | |
| 170 | ] as const; | |
| 171 | }, | |
| 172 | ); | |
| 154 | 173 | yield* Ref.set(settingsRef, nextSettings); |
| 155 | 174 | yield* PubSub.publish(changesPubSub, nextSnapshot); |
| 156 | 175 | yield* restartSnapshotEnrichment(nextSettings, nextSnapshot, nextGeneration); |
| ... | ... | @@ -159,6 +178,32 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( |
| 159 | 178 | const applySnapshot = (nextSettings: Settings, options?: { readonly forceRefresh?: boolean }) => |
| 160 | 179 | refreshSemaphore.withPermits(1)(applySnapshotBase(nextSettings, options)); |
| 161 | 180 | |
| 181 | /** | |
| 182 | * Runtime usage updates arrive between probes. They patch only | |
| 183 | * `usageLimits` on whatever snapshot is published and leave the enrichment | |
| 184 | * generation alone, so an in-flight enrichment still lands. | |
| 185 | */ | |
| 186 | const applyUsageLimits: ServerProviderShape["applyUsageLimits"] = (update) => | |
| 187 | Effect.gen(function* () { | |
| 188 | const snapshotToPublish = yield* Ref.modify(snapshotStateRef, (state) => { | |
| 189 | const usageLimits = applyUsageLimitsUpdate({ | |
| 190 | previous: state.snapshot.usageLimits, | |
| 191 | update, | |
| 192 | checkedAt: update.checkedAt, | |
| 193 | }); | |
| 194 | // `applyUsageLimitsUpdate` hands back the same object when nothing | |
| 195 | // moved, which is the common case for Codex's per-tick notification. | |
| 196 | if (usageLimits === state.snapshot.usageLimits) { | |
| 197 | return [null, state] as const; | |
| 198 | } | |
| 199 | const snapshot = withUsageLimits(state.snapshot, usageLimits); | |
| 200 | return [snapshot, { ...state, snapshot }] as const; | |
| 201 | }); | |
| 202 | if (snapshotToPublish !== null) { | |
| 203 | yield* PubSub.publish(changesPubSub, snapshotToPublish); | |
| 204 | } | |
| 205 | }); | |
| 206 | ||
| 162 | 207 | const refreshSnapshot = Effect.fn("refreshSnapshot")(function* () { |
| 163 | 208 | const nextSettings = yield* input.getSettings; |
| 164 | 209 | return yield* applySnapshot(nextSettings, { forceRefresh: true }); |
| ... | ... | @@ -241,6 +286,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( |
| 241 | 286 | maintenanceCapabilities: input.maintenanceCapabilities, |
| 242 | 287 | getSnapshot: Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)), |
| 243 | 288 | refresh: refreshSnapshot().pipe(Effect.tapError(Effect.logError), Effect.orDie), |
| 289 | applyUsageLimits, | |
| 244 | 290 | get streamChanges() { |
| 245 | 291 | return Stream.fromPubSub(changesPubSub); |
| 246 | 292 | }, |
apps/server/src/provider/providerSnapshot.ts+3| ... | ... | @@ -7,6 +7,7 @@ import type { |
| 7 | 7 | ServerProviderSlashCommand, |
| 8 | 8 | ServerProviderModel, |
| 9 | 9 | ServerProviderState, |
| 10 | ServerProviderUsageLimits, | |
| 10 | 11 | } from "@t3tools/contracts"; |
| 11 | 12 | import * as Effect from "effect/Effect"; |
| 12 | 13 | import * as PlatformError from "effect/PlatformError"; |
| ... | ... | @@ -50,6 +51,7 @@ export interface ProviderProbeResult { |
| 50 | 51 | readonly status: Exclude<ServerProviderState, "disabled">; |
| 51 | 52 | readonly auth: ServerProviderAuth; |
| 52 | 53 | readonly message?: string; |
| 54 | readonly usageLimits?: ServerProviderUsageLimits; | |
| 53 | 55 | } |
| 54 | 56 | |
| 55 | 57 | export interface ServerProviderPresentation { |
| ... | ... | @@ -249,6 +251,7 @@ export function buildServerProvider(input: { |
| 249 | 251 | models: input.models, |
| 250 | 252 | slashCommands: [...(input.slashCommands ?? [])], |
| 251 | 253 | skills: [...(input.skills ?? [])], |
| 254 | ...(input.probe.usageLimits ? { usageLimits: input.probe.usageLimits } : {}), | |
| 252 | 255 | ...(versionAdvisory ? { versionAdvisory } : {}), |
| 253 | 256 | }; |
| 254 | 257 | } |
apps/server/src/provider/providerUsageLimits.test.ts created+74| ... | ... | @@ -0,0 +1,74 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "./providerUsageLimits.ts"; | |
| 4 | ||
| 5 | const checkedAt = "2026-09-03T12:00:00.000Z"; | |
| 6 | const session = { | |
| 7 | id: "five_hour", | |
| 8 | kind: "session", | |
| 9 | label: "Session", | |
| 10 | usedPercent: 40, | |
| 11 | windowDurationMins: 300, | |
| 12 | resetsAt: "2026-09-03T14:00:00.000Z", | |
| 13 | } as const; | |
| 14 | const weekly = { | |
| 15 | id: "seven_day", | |
| 16 | kind: "weekly", | |
| 17 | label: "Weekly", | |
| 18 | usedPercent: 20, | |
| 19 | windowDurationMins: 10_080, | |
| 20 | } as const; | |
| 21 | const published = { checkedAt, windows: [session, weekly] }; | |
| 22 | ||
| 23 | describe("applyUsageLimitsUpdate", () => { | |
| 24 | it("returns the published object itself when no window moved", () => { | |
| 25 | // Codex repeats the same numbers beside every token-usage tick; the | |
| 26 | // ingestion path relies on identity to skip the publish. | |
| 27 | const next = applyUsageLimitsUpdate({ | |
| 28 | previous: published, | |
| 29 | checkedAt: "2026-09-03T12:00:05.000Z", | |
| 30 | update: { | |
| 31 | windows: [ | |
| 32 | { ...weekly }, | |
| 33 | { id: "five_hour", kind: "session", label: "Session", usedPercent: 40 }, | |
| 34 | ], | |
| 35 | }, | |
| 36 | }); | |
| 37 | expect(next).toBe(published); | |
| 38 | }); | |
| 39 | ||
| 40 | it("upserts by id and keeps the reset a percent-only update omits", () => { | |
| 41 | const next = applyUsageLimitsUpdate({ | |
| 42 | previous: published, | |
| 43 | checkedAt: "2026-09-03T12:00:05.000Z", | |
| 44 | update: { | |
| 45 | windows: [{ id: "five_hour", kind: "session", label: "Session", usedPercent: 55 }], | |
| 46 | }, | |
| 47 | }); | |
| 48 | expect(next).not.toBe(published); | |
| 49 | expect(next).toEqual({ | |
| 50 | checkedAt: "2026-09-03T12:00:05.000Z", | |
| 51 | windows: [{ ...session, usedPercent: 55 }, weekly], | |
| 52 | }); | |
| 53 | }); | |
| 54 | ||
| 55 | it("leaves an unsupported account and an empty update alone", () => { | |
| 56 | const unsupported = { checkedAt, windows: [], unavailable: { reason: "unsupported" as const } }; | |
| 57 | expect( | |
| 58 | applyUsageLimitsUpdate({ previous: unsupported, checkedAt, update: { windows: [session] } }), | |
| 59 | ).toBe(unsupported); | |
| 60 | expect( | |
| 61 | applyUsageLimitsUpdate({ previous: published, checkedAt, update: { windows: [] } }), | |
| 62 | ).toBe(published); | |
| 63 | }); | |
| 64 | }); | |
| 65 | ||
| 66 | describe("resolveUsageLimitsAfterProbe", () => { | |
| 67 | it("keeps the last good windows through a failed probe but not an unsupported one", () => { | |
| 68 | const failed = { checkedAt, windows: [], unavailable: { reason: "probeFailed" as const } }; | |
| 69 | const unsupported = { checkedAt, windows: [], unavailable: { reason: "unsupported" as const } }; | |
| 70 | expect(resolveUsageLimitsAfterProbe({ published, probed: failed })).toBe(published); | |
| 71 | expect(resolveUsageLimitsAfterProbe({ published, probed: unsupported })).toBe(unsupported); | |
| 72 | expect(resolveUsageLimitsAfterProbe({ published: undefined, probed: failed })).toBe(failed); | |
| 73 | }); | |
| 74 | }); |
apps/server/src/provider/providerUsageLimits.ts created+131| ... | ... | @@ -0,0 +1,131 @@ |
| 1 | import type { | |
| 2 | ProviderUsageLimitsUpdate, | |
| 3 | ServerProviderUsageLimits, | |
| 4 | ServerProviderUsageWindow, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | ||
| 7 | const WINDOW_KIND_ORDER: Record<ServerProviderUsageWindow["kind"], number> = { | |
| 8 | session: 0, | |
| 9 | weekly: 1, | |
| 10 | monthly: 2, | |
| 11 | other: 3, | |
| 12 | }; | |
| 13 | ||
| 14 | export function clampPercent(value: number): number { | |
| 15 | return Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0; | |
| 16 | } | |
| 17 | ||
| 18 | function sortWindows( | |
| 19 | windows: Iterable<ServerProviderUsageWindow>, | |
| 20 | ): ReadonlyArray<ServerProviderUsageWindow> { | |
| 21 | return [...windows].toSorted( | |
| 22 | (left, right) => | |
| 23 | WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind] || | |
| 24 | left.id.localeCompare(right.id), | |
| 25 | ); | |
| 26 | } | |
| 27 | ||
| 28 | export function makeUsageLimits(input: { | |
| 29 | readonly checkedAt: string; | |
| 30 | readonly windows: Iterable<ServerProviderUsageWindow>; | |
| 31 | }): ServerProviderUsageLimits { | |
| 32 | return { checkedAt: input.checkedAt, windows: sortWindows(input.windows) }; | |
| 33 | } | |
| 34 | ||
| 35 | export function makeUnavailableUsageLimits(input: { | |
| 36 | readonly checkedAt: string; | |
| 37 | readonly reason: "unsupported" | "probeFailed"; | |
| 38 | readonly message?: string; | |
| 39 | }): ServerProviderUsageLimits { | |
| 40 | return { | |
| 41 | checkedAt: input.checkedAt, | |
| 42 | windows: [], | |
| 43 | unavailable: { | |
| 44 | reason: input.reason, | |
| 45 | ...(input.message ? { message: input.message } : {}), | |
| 46 | }, | |
| 47 | }; | |
| 48 | } | |
| 49 | ||
| 50 | /** | |
| 51 | * Fold a sparse runtime update into the limits a provider currently | |
| 52 | * publishes. Windows upsert by `id`; a window the update omits keeps its | |
| 53 | * previous values, and a window that arrives without `resetsAt` or | |
| 54 | * `windowDurationMins` keeps whatever the last probe resolved for it. An | |
| 55 | * update with no windows leaves `previous` untouched. | |
| 56 | * | |
| 57 | * An `unsupported` snapshot stays unsupported: an account that cannot have | |
| 58 | * subscription windows will not start reporting them mid-turn. | |
| 59 | */ | |
| 60 | export function applyUsageLimitsUpdate(input: { | |
| 61 | readonly previous: ServerProviderUsageLimits | undefined; | |
| 62 | readonly update: ProviderUsageLimitsUpdate; | |
| 63 | readonly checkedAt: string; | |
| 64 | }): ServerProviderUsageLimits | undefined { | |
| 65 | const { previous, update } = input; | |
| 66 | if (update.windows.length === 0 || previous?.unavailable?.reason === "unsupported") { | |
| 67 | return previous; | |
| 68 | } | |
| 69 | const merged = new Map(previous?.windows.map((window) => [window.id, window] as const)); | |
| 70 | // Codex sends this notification beside every token-usage tick, almost | |
| 71 | // always with unchanged numbers. Decide "nothing changed" per window on | |
| 72 | // the way through so the no-op case never allocates a new snapshot. | |
| 73 | let changed = false; | |
| 74 | for (const window of update.windows) { | |
| 75 | const existing = merged.get(window.id); | |
| 76 | const next: ServerProviderUsageWindow = { | |
| 77 | ...window, | |
| 78 | usedPercent: clampPercent(window.usedPercent), | |
| 79 | ...(window.resetsAt === undefined && existing?.resetsAt !== undefined | |
| 80 | ? { resetsAt: existing.resetsAt } | |
| 81 | : {}), | |
| 82 | ...(window.windowDurationMins === undefined && existing?.windowDurationMins !== undefined | |
| 83 | ? { windowDurationMins: existing.windowDurationMins } | |
| 84 | : {}), | |
| 85 | }; | |
| 86 | if (existing === undefined || !usageWindowEquals(existing, next)) { | |
| 87 | merged.set(window.id, next); | |
| 88 | changed = true; | |
| 89 | } | |
| 90 | } | |
| 91 | if (!changed && previous !== undefined && previous.unavailable === undefined) { | |
| 92 | return previous; | |
| 93 | } | |
| 94 | return makeUsageLimits({ checkedAt: input.checkedAt, windows: merged.values() }); | |
| 95 | } | |
| 96 | ||
| 97 | function usageWindowEquals(a: ServerProviderUsageWindow, b: ServerProviderUsageWindow): boolean { | |
| 98 | return ( | |
| 99 | a.id === b.id && | |
| 100 | a.kind === b.kind && | |
| 101 | a.label === b.label && | |
| 102 | a.usedPercent === b.usedPercent && | |
| 103 | a.resetsAt === b.resetsAt && | |
| 104 | a.windowDurationMins === b.windowDurationMins | |
| 105 | ); | |
| 106 | } | |
| 107 | ||
| 108 | /** | |
| 109 | * Choose what to publish after a status probe finishes. A probe that failed | |
| 110 | * this time must not wipe bars a previous probe or a turn already | |
| 111 | * established, so the last good snapshot stays; `unsupported` is | |
| 112 | * authoritative and replaces them. | |
| 113 | * | |
| 114 | * A successful probe replaces the published windows outright, including any | |
| 115 | * runtime update that landed while it was running. That is a deliberate | |
| 116 | * trade-off: the Codex and Claude reads take a few seconds at most, the | |
| 117 | * probe is the fresher full read in every case except that window, and the | |
| 118 | * per-window epoch bookkeeping needed to reconcile the two was more code | |
| 119 | * than the sub-second regression it prevented. The next runtime event | |
| 120 | * corrects it. | |
| 121 | */ | |
| 122 | export function resolveUsageLimitsAfterProbe(input: { | |
| 123 | readonly published: ServerProviderUsageLimits | undefined; | |
| 124 | readonly probed: ServerProviderUsageLimits | undefined; | |
| 125 | }): ServerProviderUsageLimits | undefined { | |
| 126 | const { published, probed } = input; | |
| 127 | if (probed?.unavailable?.reason === "probeFailed" && published && !published.unavailable) { | |
| 128 | return published; | |
| 129 | } | |
| 130 | return probed; | |
| 131 | } |
apps/server/src/server.test.ts+6| ... | ... | @@ -103,6 +103,7 @@ import { |
| 103 | 103 | import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; |
| 104 | 104 | import * as GitManager from "./git/GitManager.ts"; |
| 105 | 105 | import * as EnvironmentTheme from "./environmentTheme.ts"; |
| 106 | import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; | |
| 106 | 107 | import * as Keybindings from "./keybindings.ts"; |
| 107 | 108 | import * as ExternalLauncher from "./process/externalLauncher.ts"; |
| 108 | 109 | import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; |
| ... | ... | @@ -744,6 +745,11 @@ const buildAppUnderTest = (options?: { |
| 744 | 745 | streamChanges: Stream.empty, |
| 745 | 746 | ...options?.layers?.environmentTheme, |
| 746 | 747 | }), |
| 748 | Layer.mock(UsageLimitSources.UsageLimitSources)({ | |
| 749 | current: Effect.succeed([]), | |
| 750 | streamChanges: Stream.empty, | |
| 751 | refresh: Effect.void, | |
| 752 | }), | |
| 747 | 753 | ), |
| 748 | 754 | ), |
| 749 | 755 | Layer.provide( |
apps/server/src/server.ts+8-1| ... | ... | @@ -41,6 +41,7 @@ import { AntigravityInstallation } from "./provider/AntigravityInstallation.ts"; |
| 41 | 41 | import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; |
| 42 | 42 | import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; |
| 43 | 43 | import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; |
| 44 | import { ProviderUsageLimitsIngestionLive } from "./provider/Layers/ProviderUsageLimitsIngestion.ts"; | |
| 44 | 45 | import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; |
| 45 | 46 | import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; |
| 46 | 47 | import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; |
| ... | ... | @@ -118,6 +119,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien |
| 118 | 119 | import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; |
| 119 | 120 | import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts"; |
| 120 | 121 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; |
| 122 | import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; | |
| 121 | 123 | import * as UsageService from "./usage/UsageService.ts"; |
| 122 | 124 | import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; |
| 123 | 125 | import { |
| ... | ... | @@ -407,6 +409,9 @@ const CloudManagedEndpointRuntimeLive = Layer.mergeAll( |
| 407 | 409 | ); |
| 408 | 410 | |
| 409 | 411 | const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( |
| 412 | // Subscribes to `account.rate-limits.updated` so usage bars track live | |
| 413 | // telemetry instead of waiting for the next status probe. | |
| 414 | Layer.provideMerge(ProviderUsageLimitsIngestionLive), | |
| 410 | 415 | Layer.provideMerge(ProviderLayerLive), |
| 411 | 416 | Layer.provideMerge(OrchestrationLayerLive), |
| 412 | 417 | ); |
| ... | ... | @@ -454,7 +459,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( |
| 454 | 459 | Layer.provideMerge(PersistenceLayerLive), |
| 455 | 460 | // Both read a user-owned file out of the state directory and stream changes |
| 456 | 461 | // to clients; neither depends on the other. |
| 457 | Layer.provideMerge(Layer.mergeAll(Keybindings.layer, EnvironmentTheme.layer)), | |
| 462 | Layer.provideMerge( | |
| 463 | Layer.mergeAll(Keybindings.layer, EnvironmentTheme.layer, UsageLimitSources.layer), | |
| 464 | ), | |
| 458 | 465 | Layer.provideMerge(ProviderRegistryLive), |
| 459 | 466 | // The instance registry is the new routing keystone — text generation, |
| 460 | 467 | // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId` |
apps/server/src/serverSettings.ts+85-1| ... | ... | @@ -18,6 +18,7 @@ import { |
| 18 | 18 | type ModelSelection, |
| 19 | 19 | type ProviderInstanceConfig, |
| 20 | 20 | type ProviderInstanceEnvironmentVariable, |
| 21 | type UsageLimitSourceConfig, | |
| 21 | 22 | ProviderDriverKind, |
| 22 | 23 | ProviderInstanceId, |
| 23 | 24 | ServerSettings, |
| ... | ... | @@ -133,6 +134,17 @@ function providerEnvironmentSecretName(input: { |
| 133 | 134 | return `provider-env-${Buffer.from(input.instanceId, "utf8").toString("base64url")}-${Buffer.from(input.name, "utf8").toString("base64url")}`; |
| 134 | 135 | } |
| 135 | 136 | |
| 137 | /** | |
| 138 | * On disk the hub key is replaced by this marker and the real value lives in | |
| 139 | * the secret store, mirroring provider environment secrets. A client that | |
| 140 | * sends the marker back means "keep what you have". | |
| 141 | */ | |
| 142 | const USAGE_LIMIT_SOURCE_KEY_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022"; | |
| 143 | ||
| 144 | export function usageLimitSourceSecretName(sourceId: string): string { | |
| 145 | return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`; | |
| 146 | } | |
| 147 | ||
| 136 | 148 | function redactProviderEnvironmentVariable( |
| 137 | 149 | variable: ProviderInstanceEnvironmentVariable, |
| 138 | 150 | ): ProviderInstanceEnvironmentVariable { |
| ... | ... | @@ -159,7 +171,17 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS |
| 159 | 171 | : instance, |
| 160 | 172 | ]), |
| 161 | 173 | ); |
| 162 | return { ...settings, providerInstances }; | |
| 174 | // The hub key is a bearer secret; clients only need to know one is set. | |
| 175 | const usageLimitSources = Object.fromEntries( | |
| 176 | Object.entries(settings.usageLimitSources).map(([id, source]) => [ | |
| 177 | id, | |
| 178 | { | |
| 179 | ...source, | |
| 180 | managementKey: source.managementKey.length > 0 ? USAGE_LIMIT_SOURCE_KEY_REDACTED : "", | |
| 181 | }, | |
| 182 | ]), | |
| 183 | ); | |
| 184 | return { ...settings, providerInstances, usageLimitSources }; | |
| 163 | 185 | } |
| 164 | 186 | |
| 165 | 187 | export class ServerSettingsService extends Context.Service< |
| ... | ... | @@ -512,9 +534,28 @@ const make = Effect.gen(function* () { |
| 512 | 534 | environment, |
| 513 | 535 | } satisfies ProviderInstanceConfig; |
| 514 | 536 | } |
| 537 | const usageLimitSources: Record<string, UsageLimitSourceConfig> = {}; | |
| 538 | for (const [sourceId, source] of Object.entries(settings.usageLimitSources)) { | |
| 539 | if (source.managementKey !== USAGE_LIMIT_SOURCE_KEY_REDACTED) { | |
| 540 | usageLimitSources[sourceId] = source; | |
| 541 | continue; | |
| 542 | } | |
| 543 | const secret = yield* secretStore | |
| 544 | .get(usageLimitSourceSecretName(sourceId)) | |
| 545 | .pipe( | |
| 546 | Effect.mapError( | |
| 547 | (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }), | |
| 548 | ), | |
| 549 | ); | |
| 550 | usageLimitSources[sourceId] = { | |
| 551 | ...source, | |
| 552 | managementKey: Option.isSome(secret) ? textDecoder.decode(secret.value) : "", | |
| 553 | }; | |
| 554 | } | |
| 515 | 555 | return { |
| 516 | 556 | ...settings, |
| 517 | 557 | providerInstances: providerInstances as ServerSettings["providerInstances"], |
| 558 | usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], | |
| 518 | 559 | }; |
| 519 | 560 | }); |
| 520 | 561 | |
| ... | ... | @@ -630,9 +671,52 @@ const make = Effect.gen(function* () { |
| 630 | 671 | } |
| 631 | 672 | } |
| 632 | 673 | |
| 674 | const usageLimitSources: Record<string, UsageLimitSourceConfig> = {}; | |
| 675 | for (const [sourceId, source] of Object.entries(next.usageLimitSources)) { | |
| 676 | const secretName = usageLimitSourceSecretName(sourceId); | |
| 677 | if (source.managementKey === USAGE_LIMIT_SOURCE_KEY_REDACTED) { | |
| 678 | // Unchanged from the client's point of view; the store already has it. | |
| 679 | usageLimitSources[sourceId] = source; | |
| 680 | continue; | |
| 681 | } | |
| 682 | if (source.managementKey.length === 0) { | |
| 683 | yield* secretStore | |
| 684 | .remove(secretName) | |
| 685 | .pipe( | |
| 686 | Effect.mapError( | |
| 687 | (cause) => | |
| 688 | new ServerSettingsError({ settingsPath, operation: "remove-secret", cause }), | |
| 689 | ), | |
| 690 | ); | |
| 691 | usageLimitSources[sourceId] = source; | |
| 692 | continue; | |
| 693 | } | |
| 694 | yield* secretStore | |
| 695 | .set(secretName, textEncoder.encode(source.managementKey)) | |
| 696 | .pipe( | |
| 697 | Effect.mapError( | |
| 698 | (cause) => | |
| 699 | new ServerSettingsError({ settingsPath, operation: "write-secret", cause }), | |
| 700 | ), | |
| 701 | ); | |
| 702 | usageLimitSources[sourceId] = { ...source, managementKey: USAGE_LIMIT_SOURCE_KEY_REDACTED }; | |
| 703 | } | |
| 704 | for (const sourceId of Object.keys(current.usageLimitSources)) { | |
| 705 | if (sourceId in next.usageLimitSources) continue; | |
| 706 | yield* secretStore | |
| 707 | .remove(usageLimitSourceSecretName(sourceId)) | |
| 708 | .pipe( | |
| 709 | Effect.mapError( | |
| 710 | (cause) => | |
| 711 | new ServerSettingsError({ settingsPath, operation: "remove-stale-secret", cause }), | |
| 712 | ), | |
| 713 | ); | |
| 714 | } | |
| 715 | ||
| 633 | 716 | return { |
| 634 | 717 | ...next, |
| 635 | 718 | providerInstances: providerInstances as ServerSettings["providerInstances"], |
| 719 | usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], | |
| 636 | 720 | }; |
| 637 | 721 | }); |
| 638 | 722 |
apps/server/src/usage/UsageLimitSources.ts created+202| ... | ... | @@ -0,0 +1,202 @@ |
| 1 | /** | |
| 2 | * UsageLimitSources — quota from places this environment cannot run turns | |
| 3 | * on, today a CLIProxyAPI hub pooling several subscription accounts. | |
| 4 | * | |
| 5 | * Each configured `settings.usageLimitSources` entry is polled on the | |
| 6 | * provider health-check interval and on every settings change, then | |
| 7 | * published as one snapshot per source over `subscribeServerConfig`. A source | |
| 8 | * that fails keeps its row with `error` set so the user can see it is | |
| 9 | * configured but unreachable. Nothing is persisted: like provider status, | |
| 10 | * this is live state that re-derives on boot. | |
| 11 | * | |
| 12 | * @module usage/UsageLimitSources | |
| 13 | */ | |
| 14 | import { | |
| 15 | DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL, | |
| 16 | type ServerSettings, | |
| 17 | type UsageLimitSourceConfig, | |
| 18 | type UsageLimitSourceId, | |
| 19 | type UsageLimitSourceSnapshot, | |
| 20 | } from "@t3tools/contracts"; | |
| 21 | import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; | |
| 22 | import type * as Cause from "effect/Cause"; | |
| 23 | import * as Context from "effect/Context"; | |
| 24 | import * as Data from "effect/Data"; | |
| 25 | import * as DateTime from "effect/DateTime"; | |
| 26 | import * as Duration from "effect/Duration"; | |
| 27 | import * as Effect from "effect/Effect"; | |
| 28 | import * as Equal from "effect/Equal"; | |
| 29 | import * as Layer from "effect/Layer"; | |
| 30 | import * as PubSub from "effect/PubSub"; | |
| 31 | import * as Ref from "effect/Ref"; | |
| 32 | import * as Semaphore from "effect/Semaphore"; | |
| 33 | import type * as Schema from "effect/Schema"; | |
| 34 | import * as Stream from "effect/Stream"; | |
| 35 | import { HttpClient, type HttpClientError, HttpClientResponse } from "effect/unstable/http"; | |
| 36 | ||
| 37 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | |
| 38 | import { ServerSettingsService } from "../serverSettings.ts"; | |
| 39 | import { cliproxyStatusToAccounts, decodeCliproxyQuotaStatus } from "./cliproxyUsageLimits.ts"; | |
| 40 | ||
| 41 | const FETCH_TIMEOUT = "10 seconds"; | |
| 42 | const QUOTA_STATUS_PATH = "/v0/management/quota-scheduler/status"; | |
| 43 | ||
| 44 | export class UsageLimitSources extends Context.Service< | |
| 45 | UsageLimitSources, | |
| 46 | { | |
| 47 | readonly current: Effect.Effect<ReadonlyArray<UsageLimitSourceSnapshot>>; | |
| 48 | /** The current set followed by every change, with repeats dropped. */ | |
| 49 | readonly streamChanges: Stream.Stream<ReadonlyArray<UsageLimitSourceSnapshot>>; | |
| 50 | /** Re-read every source now. Never fails; failures land on the snapshot. */ | |
| 51 | readonly refresh: Effect.Effect<void>; | |
| 52 | } | |
| 53 | >()("t3/usage/UsageLimitSources") {} | |
| 54 | ||
| 55 | /** | |
| 56 | * A bounded, client-safe reason for a failed hub read. The exact failure | |
| 57 | * (which can carry the request URL and response body) goes to the log. | |
| 58 | */ | |
| 59 | function readFailureMessage( | |
| 60 | error: HttpClientError.HttpClientError | Schema.SchemaError | Cause.TimeoutError | InvalidUrl, | |
| 61 | ): string { | |
| 62 | switch (error._tag) { | |
| 63 | case "InvalidUrl": | |
| 64 | return "The hub URL is not valid."; | |
| 65 | case "TimeoutError": | |
| 66 | return "The hub did not answer in time."; | |
| 67 | case "SchemaError": | |
| 68 | return "The hub answered with an unexpected shape."; | |
| 69 | case "HttpClientError": | |
| 70 | return error.reason._tag === "StatusCodeError" | |
| 71 | ? `The hub refused the request (HTTP ${error.reason.response.status}).` | |
| 72 | : "The hub could not be reached."; | |
| 73 | } | |
| 74 | } | |
| 75 | ||
| 76 | class InvalidUrl extends Data.TaggedError("InvalidUrl")<{ | |
| 77 | readonly url: string; | |
| 78 | readonly cause: unknown; | |
| 79 | }> {} | |
| 80 | ||
| 81 | function sourceLabel(id: string, config: UsageLimitSourceConfig): string { | |
| 82 | if (config.label) return config.label; | |
| 83 | try { | |
| 84 | return new URL(config.url).host; | |
| 85 | } catch { | |
| 86 | return id; | |
| 87 | } | |
| 88 | } | |
| 89 | ||
| 90 | export const make = Effect.gen(function* () { | |
| 91 | const httpClient = yield* HttpClient.HttpClient; | |
| 92 | const settingsService = yield* ServerSettingsService; | |
| 93 | const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; | |
| 94 | const stateRef = yield* Ref.make<ReadonlyArray<UsageLimitSourceSnapshot>>([]); | |
| 95 | const changes = yield* Effect.acquireRelease( | |
| 96 | PubSub.unbounded<ReadonlyArray<UsageLimitSourceSnapshot>>(), | |
| 97 | PubSub.shutdown, | |
| 98 | ); | |
| 99 | ||
| 100 | const readSource = Effect.fn("UsageLimitSources.readSource")(function* ( | |
| 101 | id: UsageLimitSourceId, | |
| 102 | config: UsageLimitSourceConfig, | |
| 103 | ) { | |
| 104 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | |
| 105 | const base = { id, kind: config.kind, label: sourceLabel(id, config), checkedAt } as const; | |
| 106 | if (config.managementKey.length === 0) { | |
| 107 | return { ...base, accounts: [], error: "No management key configured." }; | |
| 108 | } | |
| 109 | const accounts = yield* Effect.try({ | |
| 110 | try: () => new URL(QUOTA_STATUS_PATH, config.url).toString(), | |
| 111 | catch: (cause) => new InvalidUrl({ url: config.url, cause }), | |
| 112 | }).pipe( | |
| 113 | Effect.flatMap((url) => | |
| 114 | httpClient.get(url, { headers: { Authorization: `Bearer ${config.managementKey}` } }), | |
| 115 | ), | |
| 116 | Effect.flatMap(HttpClientResponse.filterStatusOk), | |
| 117 | Effect.flatMap((response) => response.json), | |
| 118 | Effect.flatMap(decodeCliproxyQuotaStatus), | |
| 119 | Effect.map((status) => cliproxyStatusToAccounts(status, checkedAt)), | |
| 120 | Effect.timeout(FETCH_TIMEOUT), | |
| 121 | Effect.result, | |
| 122 | ); | |
| 123 | if (accounts._tag === "Failure") { | |
| 124 | yield* Effect.logDebug("usage limit source read failed", { id, cause: accounts.failure }); | |
| 125 | return { ...base, accounts: [], error: readFailureMessage(accounts.failure) }; | |
| 126 | } | |
| 127 | return { ...base, accounts: accounts.success }; | |
| 128 | }); | |
| 129 | ||
| 130 | const publish = (next: ReadonlyArray<UsageLimitSourceSnapshot>) => | |
| 131 | Effect.gen(function* () { | |
| 132 | const changed = yield* Ref.modify(stateRef, (previous) => | |
| 133 | Equal.equals(previous, next) ? [false, previous] : [true, next], | |
| 134 | ); | |
| 135 | if (changed) yield* PubSub.publish(changes, next); | |
| 136 | }); | |
| 137 | ||
| 138 | // One refresh at a time: a slow hub read started before a settings change | |
| 139 | // must not publish after the change's own refresh and resurrect a removed | |
| 140 | // source. Callers queue behind the in-flight run and see current settings. | |
| 141 | const refreshLock = yield* Semaphore.make(1); | |
| 142 | const refresh = Effect.gen(function* () { | |
| 143 | const settings = yield* settingsService.getSettings.pipe( | |
| 144 | Effect.orElseSucceed((): ServerSettings | null => null), | |
| 145 | ); | |
| 146 | const entries = Object.entries(settings?.usageLimitSources ?? {}).filter( | |
| 147 | ([, config]) => config.enabled, | |
| 148 | ); | |
| 149 | const snapshots = yield* Effect.forEach( | |
| 150 | entries, | |
| 151 | ([id, config]) => readSource(id as UsageLimitSourceId, config), | |
| 152 | { concurrency: 4 }, | |
| 153 | ); | |
| 154 | yield* publish(snapshots); | |
| 155 | }).pipe(refreshLock.withPermits(1), Effect.ignoreCause({ log: true })); | |
| 156 | ||
| 157 | // Settings edits re-read straight away so a new hub shows up without | |
| 158 | // waiting for the interval, and a removed one leaves the list. | |
| 159 | yield* settingsService.streamChanges.pipe( | |
| 160 | Stream.map((settings) => settings.usageLimitSources), | |
| 161 | Stream.changes, | |
| 162 | Stream.runForEach(() => refresh), | |
| 163 | Effect.forkScoped, | |
| 164 | ); | |
| 165 | ||
| 166 | const interval = settingsService.getSettings.pipe( | |
| 167 | Effect.map( | |
| 168 | (settings) => resolveServerBackgroundActivitySettings(settings).providerHealthRefreshInterval, | |
| 169 | ), | |
| 170 | Effect.orElseSucceed(() => DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL), | |
| 171 | ); | |
| 172 | yield* Effect.forever( | |
| 173 | interval.pipe( | |
| 174 | Effect.flatMap((wait) => | |
| 175 | Effect.sleep(Duration.toMillis(Duration.fromInputUnsafe(wait)) <= 0 ? "60 seconds" : wait), | |
| 176 | ), | |
| 177 | Effect.andThen(backgroundPolicy.shouldRunScopeWork({ type: "provider-status" })), | |
| 178 | Effect.flatMap((shouldRun) => (shouldRun ? refresh : Effect.void)), | |
| 179 | Effect.ignoreCause({ log: true }), | |
| 180 | ), | |
| 181 | ).pipe(Effect.forkScoped); | |
| 182 | ||
| 183 | yield* refresh.pipe(Effect.forkScoped); | |
| 184 | ||
| 185 | return { | |
| 186 | current: Ref.get(stateRef), | |
| 187 | refresh, | |
| 188 | get streamChanges() { | |
| 189 | return Stream.unwrap( | |
| 190 | Effect.gen(function* () { | |
| 191 | const subscription = yield* PubSub.subscribe(changes); | |
| 192 | const snapshot = yield* Ref.get(stateRef); | |
| 193 | return Stream.concat(Stream.make(snapshot), Stream.fromSubscription(subscription)).pipe( | |
| 194 | Stream.changes, | |
| 195 | ); | |
| 196 | }), | |
| 197 | ); | |
| 198 | }, | |
| 199 | } satisfies UsageLimitSources["Service"]; | |
| 200 | }); | |
| 201 | ||
| 202 | export const layer = Layer.effect(UsageLimitSources, make); |
apps/server/src/usage/cliproxyUsageLimits.test.ts created+118| ... | ... | @@ -0,0 +1,118 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { accountEmailFromAuthFile, cliproxyStatusToAccounts } from "./cliproxyUsageLimits.ts"; | |
| 4 | ||
| 5 | const checkedAt = "2026-09-03T22:00:00.000Z"; | |
| 6 | ||
| 7 | describe("cliproxyStatusToAccounts", () => { | |
| 8 | // Trimmed from a live `quota-scheduler/status`: one Claude account with a | |
| 9 | // hard-limited Fable bucket, one Codex account whose 5h window is unknown. | |
| 10 | it("maps each pooled account onto the windows the provider drivers use", () => { | |
| 11 | const accounts = cliproxyStatusToAccounts( | |
| 12 | { | |
| 13 | accounts: { | |
| 14 | "claude-jmarminge@gmail.com.json": { | |
| 15 | provider: "claude", | |
| 16 | fetched_at: "2026-09-03T15:06:07-07:00", | |
| 17 | five_hour: { hard_limited: false, known: true, used_percent: 0 }, | |
| 18 | seven_day: { | |
| 19 | hard_limited: false, | |
| 20 | known: true, | |
| 21 | reset_at: "2026-09-07T07:59:59Z", | |
| 22 | used_percent: 51, | |
| 23 | }, | |
| 24 | fable: { | |
| 25 | hard_limited: true, | |
| 26 | known: true, | |
| 27 | reset_at: "2026-09-07T07:59:59Z", | |
| 28 | used_percent: 100, | |
| 29 | }, | |
| 30 | }, | |
| 31 | "codex-7f42123a-jmarminge@gmail.com-pro.json": { | |
| 32 | provider: "codex", | |
| 33 | plan: "pro", | |
| 34 | fetched_at: "2026-09-03T15:07:07-07:00", | |
| 35 | five_hour: { hard_limited: false, known: false, used_percent: 0 }, | |
| 36 | weekly: { | |
| 37 | hard_limited: false, | |
| 38 | known: true, | |
| 39 | reset_at: "2026-09-06T19:52:53-07:00", | |
| 40 | used_percent: 12, | |
| 41 | }, | |
| 42 | }, | |
| 43 | "xai-someone@example.com.json": { provider: "xai" }, | |
| 44 | }, | |
| 45 | }, | |
| 46 | checkedAt, | |
| 47 | ); | |
| 48 | ||
| 49 | expect(accounts).toEqual([ | |
| 50 | { | |
| 51 | id: "claude-jmarminge@gmail.com.json", | |
| 52 | driver: "claudeAgent", | |
| 53 | email: "jmarminge@gmail.com", | |
| 54 | plan: "Claude Subscription", | |
| 55 | usageLimits: { | |
| 56 | checkedAt: "2026-09-03T22:06:07.000Z", | |
| 57 | windows: [ | |
| 58 | { | |
| 59 | id: "five_hour", | |
| 60 | kind: "session", | |
| 61 | label: "Session", | |
| 62 | usedPercent: 0, | |
| 63 | windowDurationMins: 300, | |
| 64 | }, | |
| 65 | { | |
| 66 | id: "seven_day", | |
| 67 | kind: "weekly", | |
| 68 | label: "Weekly", | |
| 69 | usedPercent: 51, | |
| 70 | windowDurationMins: 10080, | |
| 71 | resetsAt: "2026-09-07T07:59:59.000Z", | |
| 72 | }, | |
| 73 | { | |
| 74 | id: "seven_day_fable", | |
| 75 | kind: "weekly", | |
| 76 | label: "Weekly · Fable", | |
| 77 | usedPercent: 100, | |
| 78 | windowDurationMins: 10080, | |
| 79 | resetsAt: "2026-09-07T07:59:59.000Z", | |
| 80 | }, | |
| 81 | ], | |
| 82 | }, | |
| 83 | }, | |
| 84 | { | |
| 85 | id: "codex-7f42123a-jmarminge@gmail.com-pro.json", | |
| 86 | driver: "codex", | |
| 87 | email: "jmarminge@gmail.com", | |
| 88 | plan: "ChatGPT Pro 20x Subscription", | |
| 89 | usageLimits: { | |
| 90 | checkedAt: "2026-09-03T22:07:07.000Z", | |
| 91 | windows: [ | |
| 92 | { | |
| 93 | id: "secondary", | |
| 94 | kind: "weekly", | |
| 95 | label: "Weekly", | |
| 96 | usedPercent: 12, | |
| 97 | windowDurationMins: 10080, | |
| 98 | resetsAt: "2026-09-07T02:52:53.000Z", | |
| 99 | }, | |
| 100 | ], | |
| 101 | }, | |
| 102 | }, | |
| 103 | ]); | |
| 104 | }); | |
| 105 | }); | |
| 106 | ||
| 107 | describe("accountEmailFromAuthFile", () => { | |
| 108 | it("pulls the email out of the hub's auth file names", () => { | |
| 109 | expect(accountEmailFromAuthFile("claude-julius@ping.gg.json")).toBe("julius@ping.gg"); | |
| 110 | expect(accountEmailFromAuthFile("codex-e413dce6-julius@ping.gg-pro.json")).toBe( | |
| 111 | "julius@ping.gg", | |
| 112 | ); | |
| 113 | expect(accountEmailFromAuthFile("claude-first-last@example.com.json")).toBe( | |
| 114 | "first-last@example.com", | |
| 115 | ); | |
| 116 | expect(accountEmailFromAuthFile("mystery.json")).toBeUndefined(); | |
| 117 | }); | |
| 118 | }); |
apps/server/src/usage/cliproxyUsageLimits.ts created+160| ... | ... | @@ -0,0 +1,160 @@ |
| 1 | /** | |
| 2 | * Maps a CLIProxyAPI hub's `quota-scheduler/status` response onto the usage | |
| 3 | * limit windows the Limits view renders, one account per pooled auth file. | |
| 4 | * | |
| 5 | * The hub already normalises each upstream: Claude accounts carry | |
| 6 | * `five_hour` / `seven_day` / `fable`, Codex accounts `five_hour` / `weekly`. | |
| 7 | * Every window is `{ used_percent, reset_at?, known, hard_limited }`. | |
| 8 | * | |
| 9 | * @module usage/cliproxyUsageLimits | |
| 10 | */ | |
| 11 | import { | |
| 12 | ProviderDriverKind, | |
| 13 | type ServerProviderUsageLimits, | |
| 14 | type ServerProviderUsageWindow, | |
| 15 | type UsageLimitSourceAccount, | |
| 16 | } from "@t3tools/contracts"; | |
| 17 | import * as DateTime from "effect/DateTime"; | |
| 18 | import * as Option from "effect/Option"; | |
| 19 | import * as Schema from "effect/Schema"; | |
| 20 | ||
| 21 | import { codexPlanLabel } from "../provider/Layers/CodexProvider.ts"; | |
| 22 | import { clampPercent, makeUsageLimits } from "../provider/providerUsageLimits.ts"; | |
| 23 | ||
| 24 | const QuotaWindow = Schema.Struct({ | |
| 25 | used_percent: Schema.Number, | |
| 26 | reset_at: Schema.optional(Schema.String), | |
| 27 | known: Schema.optional(Schema.Boolean), | |
| 28 | hard_limited: Schema.optional(Schema.Boolean), | |
| 29 | }); | |
| 30 | ||
| 31 | const QuotaAccount = Schema.Struct({ | |
| 32 | provider: Schema.String, | |
| 33 | plan: Schema.optional(Schema.String), | |
| 34 | fetched_at: Schema.optional(Schema.String), | |
| 35 | five_hour: Schema.optional(QuotaWindow), | |
| 36 | seven_day: Schema.optional(QuotaWindow), | |
| 37 | weekly: Schema.optional(QuotaWindow), | |
| 38 | fable: Schema.optional(QuotaWindow), | |
| 39 | }); | |
| 40 | ||
| 41 | export const CliproxyQuotaStatus = Schema.Struct({ | |
| 42 | accounts: Schema.Record(Schema.String, QuotaAccount), | |
| 43 | }); | |
| 44 | export type CliproxyQuotaStatus = typeof CliproxyQuotaStatus.Type; | |
| 45 | export const decodeCliproxyQuotaStatus = Schema.decodeUnknownEffect(CliproxyQuotaStatus); | |
| 46 | ||
| 47 | const SESSION_MINS = 5 * 60; | |
| 48 | const WEEK_MINS = 7 * 24 * 60; | |
| 49 | ||
| 50 | /** Window ids match what the provider drivers emit, so rows read the same across sources. */ | |
| 51 | const WINDOWS: ReadonlyArray<{ | |
| 52 | readonly key: keyof Omit<typeof QuotaAccount.Type, "provider" | "plan" | "fetched_at">; | |
| 53 | readonly id: string; | |
| 54 | readonly kind: ServerProviderUsageWindow["kind"]; | |
| 55 | readonly label: string; | |
| 56 | readonly windowDurationMins: number; | |
| 57 | }> = [ | |
| 58 | { | |
| 59 | key: "five_hour", | |
| 60 | id: "five_hour", | |
| 61 | kind: "session", | |
| 62 | label: "Session", | |
| 63 | windowDurationMins: SESSION_MINS, | |
| 64 | }, | |
| 65 | { | |
| 66 | key: "seven_day", | |
| 67 | id: "seven_day", | |
| 68 | kind: "weekly", | |
| 69 | label: "Weekly", | |
| 70 | windowDurationMins: WEEK_MINS, | |
| 71 | }, | |
| 72 | { | |
| 73 | key: "weekly", | |
| 74 | id: "secondary", | |
| 75 | kind: "weekly", | |
| 76 | label: "Weekly", | |
| 77 | windowDurationMins: WEEK_MINS, | |
| 78 | }, | |
| 79 | { | |
| 80 | key: "fable", | |
| 81 | id: "seven_day_fable", | |
| 82 | kind: "weekly", | |
| 83 | label: "Weekly · Fable", | |
| 84 | windowDurationMins: WEEK_MINS, | |
| 85 | }, | |
| 86 | ]; | |
| 87 | ||
| 88 | const DRIVER_BY_HUB_PROVIDER: Readonly<Record<string, ProviderDriverKind>> = { | |
| 89 | claude: ProviderDriverKind.make("claudeAgent"), | |
| 90 | codex: ProviderDriverKind.make("codex"), | |
| 91 | }; | |
| 92 | ||
| 93 | function isoFromHub(value: string | undefined): string | undefined { | |
| 94 | if (!value) return undefined; | |
| 95 | const dt = DateTime.make(value); | |
| 96 | return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; | |
| 97 | } | |
| 98 | ||
| 99 | /** `claude-julius@ping.gg.json` → `julius@ping.gg`; `codex-<hash>-x@y-pro.json` → `x@y`. */ | |
| 100 | export function accountEmailFromAuthFile(fileName: string): string | undefined { | |
| 101 | const stem = fileName.replace(/\.json$/i, ""); | |
| 102 | // Strip the provider prefix (and Codex's hash) rather than splitting on | |
| 103 | // `-`, so a hyphenated local part such as `first-last@` survives. | |
| 104 | return stem.match(/^(?:claude-|codex-[a-z0-9]+-)?([^\s/]+@[^\s/]+?)(?:-[a-z0-9]+)?$/i)?.[1]; | |
| 105 | } | |
| 106 | ||
| 107 | /** | |
| 108 | * The hub only reports a plan slug for Codex. Claude accounts carry no tier | |
| 109 | * in the scheduler status, so the row says what it is: a Claude subscription. | |
| 110 | */ | |
| 111 | function planLabel(account: typeof QuotaAccount.Type): string | undefined { | |
| 112 | if (account.provider === "codex") return codexPlanLabel(account.plan); | |
| 113 | if (account.provider === "claude") return "Claude Subscription"; | |
| 114 | return undefined; | |
| 115 | } | |
| 116 | ||
| 117 | export function cliproxyAccountToUsageLimits( | |
| 118 | account: typeof QuotaAccount.Type, | |
| 119 | checkedAt: string, | |
| 120 | ): ServerProviderUsageLimits { | |
| 121 | const windows: ServerProviderUsageWindow[] = []; | |
| 122 | for (const spec of WINDOWS) { | |
| 123 | const window = account[spec.key]; | |
| 124 | if (!window || window.known === false) continue; | |
| 125 | const resetsAt = isoFromHub(window.reset_at); | |
| 126 | windows.push({ | |
| 127 | id: spec.id, | |
| 128 | kind: spec.kind, | |
| 129 | label: spec.label, | |
| 130 | windowDurationMins: spec.windowDurationMins, | |
| 131 | // The hub flags a window it has seen a 429 on; the percent may lag. | |
| 132 | usedPercent: window.hard_limited ? 100 : clampPercent(window.used_percent), | |
| 133 | ...(resetsAt ? { resetsAt } : {}), | |
| 134 | }); | |
| 135 | } | |
| 136 | return makeUsageLimits({ checkedAt: isoFromHub(account.fetched_at) ?? checkedAt, windows }); | |
| 137 | } | |
| 138 | ||
| 139 | export function cliproxyStatusToAccounts( | |
| 140 | status: CliproxyQuotaStatus, | |
| 141 | checkedAt: string, | |
| 142 | ): ReadonlyArray<UsageLimitSourceAccount> { | |
| 143 | const accounts: UsageLimitSourceAccount[] = []; | |
| 144 | for (const [fileName, account] of Object.entries(status.accounts)) { | |
| 145 | const driver = DRIVER_BY_HUB_PROVIDER[account.provider]; | |
| 146 | if (!driver) continue; | |
| 147 | const email = accountEmailFromAuthFile(fileName); | |
| 148 | const plan = planLabel(account); | |
| 149 | accounts.push({ | |
| 150 | id: fileName, | |
| 151 | driver, | |
| 152 | ...(email ? { email } : {}), | |
| 153 | ...(plan ? { plan } : {}), | |
| 154 | usageLimits: cliproxyAccountToUsageLimits(account, checkedAt), | |
| 155 | }); | |
| 156 | } | |
| 157 | return accounts.toSorted( | |
| 158 | (left, right) => left.driver.localeCompare(right.driver) || left.id.localeCompare(right.id), | |
| 159 | ); | |
| 160 | } |
apps/server/src/ws.ts+24-1| ... | ... | @@ -127,6 +127,7 @@ import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 127 | 127 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 128 | 128 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; |
| 129 | 129 | import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; |
| 130 | import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; | |
| 130 | 131 | import * as UsageService from "./usage/UsageService.ts"; |
| 131 | 132 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| 132 | 133 | import * as PullRequestService from "./pullRequest/PullRequestService.ts"; |
| ... | ... | @@ -501,6 +502,7 @@ const makeWsRpcLayer = ( |
| 501 | 502 | const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; |
| 502 | 503 | const keybindings = yield* Keybindings.Keybindings; |
| 503 | 504 | const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; |
| 505 | const usageLimitSources = yield* UsageLimitSources.UsageLimitSources; | |
| 504 | 506 | const externalLauncher = yield* ExternalLauncher.ExternalLauncher; |
| 505 | 507 | const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets; |
| 506 | 508 | const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; |
| ... | ... | @@ -1676,6 +1678,13 @@ const makeWsRpcLayer = ( |
| 1676 | 1678 | observeRpcEffect( |
| 1677 | 1679 | WS_METHODS.serverRefreshProviders, |
| 1678 | 1680 | Effect.gen(function* () { |
| 1681 | // An untargeted refresh is "re-read everything's status", which | |
| 1682 | // includes quota from configured usage-limit sources. Awaited, | |
| 1683 | // not forked: the RPC scope closes on return and would | |
| 1684 | // interrupt a fork before the hub answered. | |
| 1685 | if (input.instanceId === undefined) { | |
| 1686 | yield* usageLimitSources.refresh; | |
| 1687 | } | |
| 1679 | 1688 | let providers = yield* input.cwd !== undefined && input.instanceId !== undefined |
| 1680 | 1689 | ? providerRegistry.refreshWorkspaceSnapshot({ |
| 1681 | 1690 | instanceId: input.instanceId, |
| ... | ... | @@ -2573,6 +2582,17 @@ const makeWsRpcLayer = ( |
| 2573 | 2582 | })), |
| 2574 | 2583 | ) |
| 2575 | 2584 | : Stream.empty; |
| 2585 | // Same gate as themes: an older client dies on an unknown event. | |
| 2586 | const usageLimitSourceUpdates = | |
| 2587 | input.usageLimitSources === true | |
| 2588 | ? usageLimitSources.streamChanges.pipe( | |
| 2589 | Stream.map((sources) => ({ | |
| 2590 | version: 1 as const, | |
| 2591 | type: "usageLimitSourcesUpdated" as const, | |
| 2592 | payload: { sources }, | |
| 2593 | })), | |
| 2594 | ) | |
| 2595 | : Stream.empty; | |
| 2576 | 2596 | const settingsUpdates = serverSettings.streamChanges.pipe( |
| 2577 | 2597 | Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), |
| 2578 | 2598 | Stream.map((settings) => ({ |
| ... | ... | @@ -2590,7 +2610,10 @@ const makeWsRpcLayer = ( |
| 2590 | 2610 | keybindingsUpdates, |
| 2591 | 2611 | Stream.merge( |
| 2592 | 2612 | providerStatuses, |
| 2593 | Stream.merge(settingsUpdates, environmentThemeUpdates), | |
| 2613 | Stream.merge( | |
| 2614 | settingsUpdates, | |
| 2615 | Stream.merge(environmentThemeUpdates, usageLimitSourceUpdates), | |
| 2616 | ), | |
| 2594 | 2617 | ), |
| 2595 | 2618 | ); |
| 2596 | 2619 |
apps/web/src/components/usage/AddUsageLimitSourceDialog.tsx created+153| ... | ... | @@ -0,0 +1,153 @@ |
| 1 | import { UsageLimitSourceId } from "@t3tools/contracts"; | |
| 2 | import { useState } from "react"; | |
| 3 | ||
| 4 | import { useUpdatePrimarySettings } from "../../hooks/useSettings"; | |
| 5 | import { Button } from "../ui/button"; | |
| 6 | import { | |
| 7 | Dialog, | |
| 8 | DialogDescription, | |
| 9 | DialogFooter, | |
| 10 | DialogHeader, | |
| 11 | DialogPanel, | |
| 12 | DialogPopup, | |
| 13 | DialogTitle, | |
| 14 | } from "../ui/dialog"; | |
| 15 | import { Input } from "../ui/input"; | |
| 16 | import { Label } from "../ui/label"; | |
| 17 | ||
| 18 | /** | |
| 19 | * Stable per hub and readable in settings.json. Dots and dashes in the host | |
| 20 | * are kept so `foo-bar.com` and `foo.bar.com` do not collide; anything else | |
| 21 | * (a port's colon, a path) is folded to a dash. | |
| 22 | */ | |
| 23 | function sourceIdFromUrl(url: string): UsageLimitSourceId { | |
| 24 | let host = url; | |
| 25 | try { | |
| 26 | host = new URL(url).host; | |
| 27 | } catch { | |
| 28 | // Keep the raw text; the server reports the bad URL on its row. | |
| 29 | } | |
| 30 | const slug = host | |
| 31 | .toLowerCase() | |
| 32 | .replace(/[^a-z0-9.-]+/g, "-") | |
| 33 | .replace(/^-+|-+$/g, ""); | |
| 34 | return UsageLimitSourceId.make(`cliproxy-${slug || "hub"}`); | |
| 35 | } | |
| 36 | ||
| 37 | /** | |
| 38 | * Adds a CLIProxyAPI hub as a usage-limit source on the primary environment. | |
| 39 | * The management key is sent once and kept in the server's secret store; | |
| 40 | * settings only ever carry a redaction marker for it afterwards. | |
| 41 | */ | |
| 42 | export function AddUsageLimitSourceDialog({ | |
| 43 | open, | |
| 44 | onOpenChange, | |
| 45 | }: { | |
| 46 | readonly open: boolean; | |
| 47 | readonly onOpenChange: (open: boolean) => void; | |
| 48 | }) { | |
| 49 | const updateSettings = useUpdatePrimarySettings(); | |
| 50 | const [label, setLabel] = useState(""); | |
| 51 | const [url, setUrl] = useState(""); | |
| 52 | const [managementKey, setManagementKey] = useState(""); | |
| 53 | const trimmedUrl = url.trim(); | |
| 54 | const canSave = trimmedUrl.length > 0 && managementKey.trim().length > 0; | |
| 55 | ||
| 56 | const reset = () => { | |
| 57 | setLabel(""); | |
| 58 | setUrl(""); | |
| 59 | setManagementKey(""); | |
| 60 | }; | |
| 61 | ||
| 62 | const save = () => { | |
| 63 | if (!canSave) return; | |
| 64 | const id = sourceIdFromUrl(trimmedUrl); | |
| 65 | // The patch names only this entry; the server merges it into its map. | |
| 66 | updateSettings({ | |
| 67 | usageLimitSources: { | |
| 68 | [id]: { | |
| 69 | kind: "cliproxy", | |
| 70 | ...(label.trim() ? { label: label.trim() } : {}), | |
| 71 | url: trimmedUrl, | |
| 72 | managementKey: managementKey.trim(), | |
| 73 | enabled: true, | |
| 74 | }, | |
| 75 | }, | |
| 76 | }); | |
| 77 | reset(); | |
| 78 | onOpenChange(false); | |
| 79 | }; | |
| 80 | ||
| 81 | return ( | |
| 82 | <Dialog | |
| 83 | open={open} | |
| 84 | onOpenChange={(next) => { | |
| 85 | if (!next) reset(); | |
| 86 | onOpenChange(next); | |
| 87 | }} | |
| 88 | > | |
| 89 | <DialogPopup className="max-w-md"> | |
| 90 | <DialogHeader> | |
| 91 | <DialogTitle>Add a CLIProxyAPI hub</DialogTitle> | |
| 92 | <DialogDescription> | |
| 93 | Show the quota of every account the hub pools, next to the providers on this machine. | |
| 94 | The key stays on the server. | |
| 95 | </DialogDescription> | |
| 96 | </DialogHeader> | |
| 97 | <DialogPanel> | |
| 98 | <form | |
| 99 | className="grid gap-4" | |
| 100 | onSubmit={(event) => { | |
| 101 | event.preventDefault(); | |
| 102 | save(); | |
| 103 | }} | |
| 104 | > | |
| 105 | <div className="grid gap-1.5"> | |
| 106 | <Label htmlFor="usage-source-url">Hub URL</Label> | |
| 107 | <Input | |
| 108 | id="usage-source-url" | |
| 109 | placeholder="https://hub.example.ts.net:8318" | |
| 110 | value={url} | |
| 111 | onChange={(event) => setUrl(event.target.value)} | |
| 112 | autoFocus | |
| 113 | /> | |
| 114 | </div> | |
| 115 | <div className="grid gap-1.5"> | |
| 116 | <Label htmlFor="usage-source-key">Management key</Label> | |
| 117 | <Input | |
| 118 | id="usage-source-key" | |
| 119 | type="password" | |
| 120 | autoComplete="off" | |
| 121 | value={managementKey} | |
| 122 | onChange={(event) => setManagementKey(event.target.value)} | |
| 123 | /> | |
| 124 | </div> | |
| 125 | <div className="grid gap-1.5"> | |
| 126 | <Label htmlFor="usage-source-label">Label (optional)</Label> | |
| 127 | <Input | |
| 128 | id="usage-source-label" | |
| 129 | placeholder="Defaults to the hub's host name" | |
| 130 | value={label} | |
| 131 | onChange={(event) => setLabel(event.target.value)} | |
| 132 | /> | |
| 133 | </div> | |
| 134 | </form> | |
| 135 | </DialogPanel> | |
| 136 | <DialogFooter variant="bare"> | |
| 137 | <Button | |
| 138 | variant="outline" | |
| 139 | onClick={() => { | |
| 140 | reset(); | |
| 141 | onOpenChange(false); | |
| 142 | }} | |
| 143 | > | |
| 144 | Cancel | |
| 145 | </Button> | |
| 146 | <Button onClick={save} disabled={!canSave}> | |
| 147 | Add hub | |
| 148 | </Button> | |
| 149 | </DialogFooter> | |
| 150 | </DialogPopup> | |
| 151 | </Dialog> | |
| 152 | ); | |
| 153 | } |
apps/web/src/components/usage/UsageLimits.tsx created+458| ... | ... | @@ -0,0 +1,458 @@ |
| 1 | import type { | |
| 2 | ServerProvider, | |
| 3 | ServerProviderUsageWindow, | |
| 4 | UsageLimitSourceAccount, | |
| 5 | UsageLimitSourceId, | |
| 6 | UsageLimitSourceSnapshot, | |
| 7 | UsageProviderKind, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { useAtomValue } from "@effect/atom-react"; | |
| 10 | import { | |
| 11 | collectLimitSources, | |
| 12 | collectLimitsGroups, | |
| 13 | elapsedShare, | |
| 14 | formatResetsIn, | |
| 15 | limitsNotice, | |
| 16 | type LimitPace, | |
| 17 | paceOf, | |
| 18 | providerLimitsLabel, | |
| 19 | } from "@t3tools/shared/usageLimits"; | |
| 20 | import { GaugeIcon, PlusIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; | |
| 21 | import { Fragment, useState } from "react"; | |
| 22 | ||
| 23 | import { | |
| 24 | usePrimarySettings, | |
| 25 | usePrimarySettingsAvailable, | |
| 26 | useUpdatePrimarySettings, | |
| 27 | } from "../../hooks/useSettings"; | |
| 28 | import { usePrimaryEnvironmentId } from "../../state/environments"; | |
| 29 | import { environmentPresentations } from "../../state/presentation"; | |
| 30 | import { formatUpcomingTimestamp } from "../../timestampFormat"; | |
| 31 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; | |
| 32 | import { getDriverOption } from "../settings/providerDriverMeta"; | |
| 33 | import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; | |
| 34 | import { | |
| 35 | AlertDialog, | |
| 36 | AlertDialogClose, | |
| 37 | AlertDialogDescription, | |
| 38 | AlertDialogFooter, | |
| 39 | AlertDialogHeader, | |
| 40 | AlertDialogPopup, | |
| 41 | AlertDialogTitle, | |
| 42 | } from "../ui/alert-dialog"; | |
| 43 | import { Badge } from "../ui/badge"; | |
| 44 | import { Button } from "../ui/button"; | |
| 45 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 46 | import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog"; | |
| 47 | import { PROVIDER_PRESENTATION } from "./usageProviders"; | |
| 48 | ||
| 49 | const PACE: Record<LimitPace, { readonly label: string; readonly icon: typeof GaugeIcon }> = { | |
| 50 | ahead: { label: "Ahead of pace: spending faster than the window elapses", icon: TrendingUpIcon }, | |
| 51 | on: { label: "On pace with the window", icon: GaugeIcon }, | |
| 52 | under: { label: "Under pace: headroom left for the rest of the window", icon: TrendingDownIcon }, | |
| 53 | }; | |
| 54 | ||
| 55 | /** The series colour the cost chart uses for this driver, so the two views read as one. */ | |
| 56 | function barColor(driver: ServerProvider["driver"]): string { | |
| 57 | const kind: UsageProviderKind | undefined = | |
| 58 | driver === "codex" ? "codex" : driver === "claudeAgent" ? "claude" : undefined; | |
| 59 | return kind ? PROVIDER_PRESENTATION[kind].color : "var(--foreground)"; | |
| 60 | } | |
| 61 | ||
| 62 | /** Pace as a glyph with the words on hover. */ | |
| 63 | function PaceIcon({ pace }: { readonly pace: LimitPace }) { | |
| 64 | const Icon = PACE[pace].icon; | |
| 65 | return ( | |
| 66 | <Tooltip> | |
| 67 | <TooltipTrigger | |
| 68 | render={ | |
| 69 | <span | |
| 70 | role="img" | |
| 71 | aria-label={PACE[pace].label} | |
| 72 | className="inline-flex text-muted-foreground" | |
| 73 | /> | |
| 74 | } | |
| 75 | > | |
| 76 | <Icon className="size-3.5" aria-hidden /> | |
| 77 | </TooltipTrigger> | |
| 78 | <TooltipPopup side="top">{PACE[pace].label}</TooltipPopup> | |
| 79 | </Tooltip> | |
| 80 | ); | |
| 81 | } | |
| 82 | ||
| 83 | /** | |
| 84 | * One window as a full-width bar from the moment it opened to its reset. | |
| 85 | * The fill is the share of quota spent; the hairline is how far into the | |
| 86 | * window the clock is, which is also where even spending would have put the | |
| 87 | * fill. Hover for the exact figures and reset time. | |
| 88 | */ | |
| 89 | function WindowBar({ | |
| 90 | color, | |
| 91 | window, | |
| 92 | now, | |
| 93 | }: { | |
| 94 | readonly color: string; | |
| 95 | readonly window: ServerProviderUsageWindow; | |
| 96 | readonly now: number; | |
| 97 | }) { | |
| 98 | const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); | |
| 99 | const used = Math.max(0, Math.min(100, window.usedPercent)); | |
| 100 | const elapsed = elapsedShare(window, now); | |
| 101 | const resetsIn = formatResetsIn(window, now); | |
| 102 | const resetsAt = window.resetsAt | |
| 103 | ? formatUpcomingTimestamp(window.resetsAt, timestampFormat, now) | |
| 104 | : null; | |
| 105 | const summary = `${window.label}: ${Math.round(used)}% used${ | |
| 106 | elapsed === null ? "" : `, ${Math.round(elapsed * 100)}% of the window elapsed` | |
| 107 | }${resetsIn ? `, ${resetsIn}` : ""}`; | |
| 108 | ||
| 109 | return ( | |
| 110 | <Tooltip> | |
| 111 | <TooltipTrigger | |
| 112 | render={ | |
| 113 | <div | |
| 114 | role="img" | |
| 115 | aria-label={summary} | |
| 116 | tabIndex={0} | |
| 117 | className="relative h-6 cursor-default rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" | |
| 118 | /> | |
| 119 | } | |
| 120 | > | |
| 121 | <div className="absolute inset-x-0 inset-y-1.5 rounded-full bg-muted" /> | |
| 122 | {used > 0 ? ( | |
| 123 | <div | |
| 124 | className="absolute inset-y-1.5 left-0 rounded-full" | |
| 125 | style={{ width: `${used}%`, backgroundColor: color }} | |
| 126 | /> | |
| 127 | ) : null} | |
| 128 | {elapsed !== null ? ( | |
| 129 | <span | |
| 130 | aria-hidden | |
| 131 | className="absolute inset-y-0.5 w-px -translate-x-1/2 bg-foreground/60" | |
| 132 | style={{ left: `${elapsed * 100}%` }} | |
| 133 | /> | |
| 134 | ) : null} | |
| 135 | </TooltipTrigger> | |
| 136 | <TooltipPopup side="top" className="max-w-72 text-xs"> | |
| 137 | <div className="flex flex-col gap-0.5"> | |
| 138 | <span className="text-foreground"> | |
| 139 | {Math.round(used)}% used | |
| 140 | {elapsed !== null ? ` · ${Math.round(elapsed * 100)}% of the window elapsed` : ""} | |
| 141 | </span> | |
| 142 | {elapsed !== null ? ( | |
| 143 | <span className="text-muted-foreground">The line is where even spending would be.</span> | |
| 144 | ) : null} | |
| 145 | {resetsAt ? ( | |
| 146 | <span className="text-muted-foreground"> | |
| 147 | Resets {resetsAt} | |
| 148 | {resetsIn ? ` · ${resetsIn}` : ""} | |
| 149 | </span> | |
| 150 | ) : null} | |
| 151 | </div> | |
| 152 | </TooltipPopup> | |
| 153 | </Tooltip> | |
| 154 | ); | |
| 155 | } | |
| 156 | ||
| 157 | /** One account's windows as rows: label and percent, bar, pace and countdown. */ | |
| 158 | function LimitWindows({ | |
| 159 | driver, | |
| 160 | windows, | |
| 161 | now, | |
| 162 | }: { | |
| 163 | readonly driver: ServerProvider["driver"]; | |
| 164 | readonly windows: ReadonlyArray<ServerProviderUsageWindow>; | |
| 165 | readonly now: number; | |
| 166 | }) { | |
| 167 | const color = barColor(driver); | |
| 168 | return ( | |
| 169 | <div className="grid grid-cols-[11rem_minmax(0,1fr)_7rem] gap-x-4 gap-y-1"> | |
| 170 | {windows.map((window, index) => { | |
| 171 | // Windows that reset together show the countdown once. | |
| 172 | const previous = windows[index - 1]; | |
| 173 | const sharesReset = | |
| 174 | previous?.resetsAt !== undefined && previous.resetsAt === window.resetsAt; | |
| 175 | const pace = paceOf(window, now); | |
| 176 | const resetsIn = formatResetsIn(window, now); | |
| 177 | return ( | |
| 178 | <Fragment key={window.id}> | |
| 179 | <span className="flex min-w-0 items-center gap-2 text-xs"> | |
| 180 | <span className="truncate text-muted-foreground">{window.label}</span> | |
| 181 | <span className="ms-auto shrink-0 font-medium text-foreground tabular-nums"> | |
| 182 | {Math.round(window.usedPercent)}% | |
| 183 | </span> | |
| 184 | </span> | |
| 185 | <WindowBar color={color} window={window} now={now} /> | |
| 186 | <span className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums"> | |
| 187 | {pace ? <PaceIcon pace={pace} /> : null} | |
| 188 | <span className="ms-auto shrink-0">{sharesReset ? "" : (resetsIn ?? "")}</span> | |
| 189 | </span> | |
| 190 | </Fragment> | |
| 191 | ); | |
| 192 | })} | |
| 193 | </div> | |
| 194 | ); | |
| 195 | } | |
| 196 | ||
| 197 | /** | |
| 198 | * Heading shared by local providers and source accounts: icon, name, plan, | |
| 199 | * and the signed-in email blurred until clicked, as provider settings do. | |
| 200 | */ | |
| 201 | function AccountHeading({ | |
| 202 | driver, | |
| 203 | label, | |
| 204 | plan, | |
| 205 | email, | |
| 206 | accentColor, | |
| 207 | badge, | |
| 208 | }: { | |
| 209 | readonly driver: ServerProvider["driver"]; | |
| 210 | readonly label: string; | |
| 211 | readonly plan: string | undefined; | |
| 212 | readonly email: string | undefined; | |
| 213 | readonly accentColor?: string | undefined; | |
| 214 | readonly badge?: string | undefined; | |
| 215 | }) { | |
| 216 | return ( | |
| 217 | <h2 className="flex min-w-0 items-center gap-2 text-sm font-medium text-foreground"> | |
| 218 | <ProviderInstanceIcon | |
| 219 | driverKind={driver} | |
| 220 | displayName={label} | |
| 221 | accentColor={accentColor} | |
| 222 | showBadge={Boolean(accentColor)} | |
| 223 | indicatorBackground="var(--background)" | |
| 224 | className="size-5" | |
| 225 | iconClassName="size-4 text-foreground/80" | |
| 226 | /> | |
| 227 | <span className="truncate">{label}</span> | |
| 228 | {plan ? <span className="shrink-0 font-normal text-muted-foreground">· {plan}</span> : null} | |
| 229 | {email ? ( | |
| 230 | <RedactedSensitiveText | |
| 231 | value={email} | |
| 232 | ariaLabel="Toggle account email visibility" | |
| 233 | revealTooltip="Click to reveal email" | |
| 234 | hideTooltip="Click to hide email" | |
| 235 | /> | |
| 236 | ) : null} | |
| 237 | {badge ? ( | |
| 238 | <Badge variant="outline" size="sm" className="ms-auto shrink-0 font-normal"> | |
| 239 | {badge} | |
| 240 | </Badge> | |
| 241 | ) : null} | |
| 242 | </h2> | |
| 243 | ); | |
| 244 | } | |
| 245 | ||
| 246 | function ProviderLimits({ | |
| 247 | provider, | |
| 248 | now, | |
| 249 | }: { | |
| 250 | readonly provider: ServerProvider; | |
| 251 | readonly now: number; | |
| 252 | }) { | |
| 253 | const limits = provider.usageLimits; | |
| 254 | if (!limits) return null; | |
| 255 | const notice = limitsNotice(limits); | |
| 256 | return ( | |
| 257 | <section className="flex flex-col gap-3"> | |
| 258 | <AccountHeading | |
| 259 | driver={provider.driver} | |
| 260 | label={providerLimitsLabel(provider, (driver) => getDriverOption(driver)?.label)} | |
| 261 | plan={provider.auth.label} | |
| 262 | email={provider.auth.email} | |
| 263 | accentColor={provider.accentColor} | |
| 264 | /> | |
| 265 | {notice ? ( | |
| 266 | <span className="text-xs text-muted-foreground">{notice}</span> | |
| 267 | ) : ( | |
| 268 | <LimitWindows driver={provider.driver} windows={limits.windows} now={now} /> | |
| 269 | )} | |
| 270 | </section> | |
| 271 | ); | |
| 272 | } | |
| 273 | ||
| 274 | /** One account pooled by a usage-limit source, drawn like a provider row. */ | |
| 275 | function SourceAccountLimits({ | |
| 276 | account, | |
| 277 | sourceKind, | |
| 278 | now, | |
| 279 | }: { | |
| 280 | readonly account: UsageLimitSourceAccount; | |
| 281 | readonly sourceKind: string; | |
| 282 | readonly now: number; | |
| 283 | }) { | |
| 284 | const notice = limitsNotice(account.usageLimits); | |
| 285 | return ( | |
| 286 | <section className="flex flex-col gap-3"> | |
| 287 | <AccountHeading | |
| 288 | driver={account.driver} | |
| 289 | label={getDriverOption(account.driver)?.label ?? String(account.driver)} | |
| 290 | plan={account.plan} | |
| 291 | email={account.email} | |
| 292 | badge={`via ${sourceKind}`} | |
| 293 | /> | |
| 294 | {notice ? ( | |
| 295 | <span className="text-xs text-muted-foreground">{notice}</span> | |
| 296 | ) : ( | |
| 297 | <LimitWindows driver={account.driver} windows={account.usageLimits.windows} now={now} /> | |
| 298 | )} | |
| 299 | </section> | |
| 300 | ); | |
| 301 | } | |
| 302 | ||
| 303 | /** | |
| 304 | * Accounts a configured source (a CLIProxyAPI hub) pools, grouped under the | |
| 305 | * source's name. Unlike provider rows these are read-only: nothing on this | |
| 306 | * environment can run a turn against them. | |
| 307 | */ | |
| 308 | const SOURCE_KIND_LABEL: Record<UsageLimitSourceSnapshot["kind"], string> = { | |
| 309 | cliproxy: "CLIProxyAPI", | |
| 310 | }; | |
| 311 | ||
| 312 | /** | |
| 313 | * Removing a hub also deletes its management key from the server, so it | |
| 314 | * asks first and says so. A bare icon that acted on click was too easy to | |
| 315 | * hit while reaching for the row beside it. | |
| 316 | */ | |
| 317 | function RemoveSourceButton({ | |
| 318 | source, | |
| 319 | onConfirm, | |
| 320 | }: { | |
| 321 | readonly source: UsageLimitSourceSnapshot; | |
| 322 | readonly onConfirm: () => void; | |
| 323 | }) { | |
| 324 | const [open, setOpen] = useState(false); | |
| 325 | return ( | |
| 326 | <> | |
| 327 | <Button size="xs" variant="ghost" onClick={() => setOpen(true)}> | |
| 328 | Remove | |
| 329 | </Button> | |
| 330 | <AlertDialog open={open} onOpenChange={setOpen}> | |
| 331 | <AlertDialogPopup> | |
| 332 | <AlertDialogHeader> | |
| 333 | <AlertDialogTitle>Remove {source.label}?</AlertDialogTitle> | |
| 334 | <AlertDialogDescription> | |
| 335 | The hub's management key is deleted from this server. Its accounts leave the Limits | |
| 336 | view; the hub itself is untouched. Add it again with the URL and key to bring them | |
| 337 | back. | |
| 338 | </AlertDialogDescription> | |
| 339 | </AlertDialogHeader> | |
| 340 | <AlertDialogFooter> | |
| 341 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> | |
| 342 | <Button | |
| 343 | variant="destructive" | |
| 344 | onClick={() => { | |
| 345 | setOpen(false); | |
| 346 | onConfirm(); | |
| 347 | }} | |
| 348 | > | |
| 349 | Remove hub | |
| 350 | </Button> | |
| 351 | </AlertDialogFooter> | |
| 352 | </AlertDialogPopup> | |
| 353 | </AlertDialog> | |
| 354 | </> | |
| 355 | ); | |
| 356 | } | |
| 357 | ||
| 358 | function SourceLimits({ | |
| 359 | source, | |
| 360 | now, | |
| 361 | onRemove, | |
| 362 | }: { | |
| 363 | readonly source: UsageLimitSourceSnapshot; | |
| 364 | readonly now: number; | |
| 365 | readonly onRemove: (() => void) | null; | |
| 366 | }) { | |
| 367 | const kind = SOURCE_KIND_LABEL[source.kind]; | |
| 368 | return ( | |
| 369 | <div className="flex flex-col gap-6"> | |
| 370 | <div className="flex items-center justify-between gap-3"> | |
| 371 | <h2 className="text-xs tracking-wide text-muted-foreground uppercase"> | |
| 372 | {source.label} · {kind} | |
| 373 | </h2> | |
| 374 | {onRemove ? <RemoveSourceButton source={source} onConfirm={onRemove} /> : null} | |
| 375 | </div> | |
| 376 | {source.error ? ( | |
| 377 | <span className="text-xs text-muted-foreground">{source.error}</span> | |
| 378 | ) : source.accounts.length === 0 ? ( | |
| 379 | <span className="text-xs text-muted-foreground">No accounts reported.</span> | |
| 380 | ) : ( | |
| 381 | source.accounts.map((account) => ( | |
| 382 | <SourceAccountLimits key={account.id} account={account} sourceKind={kind} now={now} /> | |
| 383 | )) | |
| 384 | )} | |
| 385 | </div> | |
| 386 | ); | |
| 387 | } | |
| 388 | ||
| 389 | /** | |
| 390 | * Subscription quota windows from every connected environment's providers. | |
| 391 | * Countdowns anchor to render time rather than ticking: a live clock would | |
| 392 | * repaint the page every minute for no decision-changing gain. | |
| 393 | */ | |
| 394 | export function UsageLimitsSection() { | |
| 395 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); | |
| 396 | const groups = collectLimitsGroups(presentations); | |
| 397 | const sources = collectLimitSources(presentations); | |
| 398 | const configuredSources = usePrimarySettings((settings) => settings.usageLimitSources); | |
| 399 | const canEditSources = usePrimarySettingsAvailable(); | |
| 400 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | |
| 401 | const updateSettings = useUpdatePrimarySettings(); | |
| 402 | const [adding, setAdding] = useState(false); | |
| 403 | // Anchored once per mount on purpose: countdowns must not tick (see below). | |
| 404 | const [now] = useState(() => Date.now()); | |
| 405 | ||
| 406 | // The patch names only this entry, so two edits in flight cannot clobber | |
| 407 | // each other's map. | |
| 408 | const removeSource = (id: UsageLimitSourceId) => { | |
| 409 | updateSettings({ usageLimitSources: { [id]: null } }); | |
| 410 | }; | |
| 411 | ||
| 412 | const addHubButton = canEditSources ? ( | |
| 413 | <Button size="xs" variant="outline" onClick={() => setAdding(true)}> | |
| 414 | <PlusIcon className="size-3" aria-hidden /> | |
| 415 | Add CLIProxyAPI hub | |
| 416 | </Button> | |
| 417 | ) : null; | |
| 418 | ||
| 419 | return ( | |
| 420 | <div className="flex flex-col gap-8"> | |
| 421 | {groups.length === 0 && sources.length === 0 ? ( | |
| 422 | <p className="text-sm text-muted-foreground"> | |
| 423 | No provider on a connected environment reports subscription limits. | |
| 424 | </p> | |
| 425 | ) : null} | |
| 426 | {sources.map((source) => ( | |
| 427 | <SourceLimits | |
| 428 | key={source.key} | |
| 429 | source={source} | |
| 430 | now={now} | |
| 431 | // Only the primary environment's own sources can be edited from | |
| 432 | // here; a remote environment's row with the same id is read-only. | |
| 433 | onRemove={ | |
| 434 | canEditSources && | |
| 435 | source.environmentId === primaryEnvironmentId && | |
| 436 | source.id in configuredSources | |
| 437 | ? () => removeSource(source.id) | |
| 438 | : null | |
| 439 | } | |
| 440 | /> | |
| 441 | ))} | |
| 442 | {groups.map((group) => ( | |
| 443 | <div key={group.environmentId} className="flex flex-col gap-6"> | |
| 444 | {group.environmentLabel ? ( | |
| 445 | <h2 className="text-xs tracking-wide text-muted-foreground uppercase"> | |
| 446 | {group.environmentLabel} | |
| 447 | </h2> | |
| 448 | ) : null} | |
| 449 | {group.providers.map((provider) => ( | |
| 450 | <ProviderLimits key={provider.instanceId} provider={provider} now={now} /> | |
| 451 | ))} | |
| 452 | </div> | |
| 453 | ))} | |
| 454 | {addHubButton ? <div>{addHubButton}</div> : null} | |
| 455 | <AddUsageLimitSourceDialog open={adding} onOpenChange={setAdding} /> | |
| 456 | </div> | |
| 457 | ); | |
| 458 | } |
apps/web/src/components/usage/UsagePage.tsx+74-17| ... | ... | @@ -6,7 +6,10 @@ import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; |
| 6 | 6 | |
| 7 | 7 | import { isElectron } from "../../env"; |
| 8 | 8 | import { cn } from "../../lib/utils"; |
| 9 | import { usePrimaryEnvironmentId } from "../../state/environments"; | |
| 10 | import { serverEnvironment } from "../../state/server"; | |
| 9 | 11 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; |
| 12 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 10 | 13 | import { |
| 11 | 14 | enumerateDays, |
| 12 | 15 | enumerateHourStarts, |
| ... | ... | @@ -32,9 +35,21 @@ import { |
| 32 | 35 | } from "../WorkspaceBreadcrumb"; |
| 33 | 36 | import { WorkspacePageContainer } from "../WorkspacePageContainer"; |
| 34 | 37 | import { WorkspacePageHeader } from "../WorkspacePageHeader"; |
| 38 | import { UsageLimitsSection } from "./UsageLimits"; | |
| 35 | 39 | import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; |
| 36 | 40 | import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; |
| 37 | 41 | |
| 42 | type UsageMetric = UsageChartMetric | "limits"; | |
| 43 | const METRIC_OPTIONS = [ | |
| 44 | { value: "cost", label: "Cost" }, | |
| 45 | { value: "tokens", label: "Tokens" }, | |
| 46 | { value: "limits", label: "Limits" }, | |
| 47 | ] as const satisfies readonly { value: UsageMetric; label: string }[]; | |
| 48 | ||
| 49 | function isUsageMetric(value: string | null | undefined): value is UsageMetric { | |
| 50 | return METRIC_OPTIONS.some((option) => option.value === value); | |
| 51 | } | |
| 52 | ||
| 38 | 53 | const WINDOW_OPTIONS = [ |
| 39 | 54 | { days: 1, label: "Past 24h" }, |
| 40 | 55 | { days: 7, label: "7 days" }, |
| ... | ... | @@ -47,11 +62,16 @@ export function UsagePage() { |
| 47 | 62 | days: 30, |
| 48 | 63 | window: makeWindow(30), |
| 49 | 64 | })); |
| 50 | const [metric, setMetric] = useState<UsageChartMetric>("cost"); | |
| 65 | const [metric, setMetric] = useState<UsageMetric>("cost"); | |
| 66 | const showingLimits = metric === "limits"; | |
| 51 | 67 | const [breakdown, setBreakdown] = useState<"model" | "time">("model"); |
| 52 | 68 | const { days: windowDays, window } = windowSelection; |
| 53 | 69 | const isPast24Hours = windowDays === 1; |
| 54 | 70 | const { merged, environments, isPending, isPartial, refresh } = useUsage(window); |
| 71 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | |
| 72 | const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { | |
| 73 | reportFailure: false, | |
| 74 | }); | |
| 55 | 75 | |
| 56 | 76 | // Hold the content until every environment is terminal. Rendering merged |
| 57 | 77 | // totals while devices are still answering makes every number on the page |
| ... | ... | @@ -94,6 +114,15 @@ export function UsagePage() { |
| 94 | 114 | }); |
| 95 | 115 | }; |
| 96 | 116 | const refreshWindow = () => { |
| 117 | // On Limits the button re-probes every provider (and usage-limit source) | |
| 118 | // on the primary environment; the live snapshots then flow in over the | |
| 119 | // config stream, so nothing else needs to move. | |
| 120 | if (showingLimits) { | |
| 121 | if (primaryEnvironmentId) { | |
| 122 | void refreshProviders({ environmentId: primaryEnvironmentId, input: {} }); | |
| 123 | } | |
| 124 | return; | |
| 125 | } | |
| 97 | 126 | const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); |
| 98 | 127 | if ( |
| 99 | 128 | nextWindow.sinceDay === window.sinceDay && |
| ... | ... | @@ -116,10 +145,14 @@ export function UsagePage() { |
| 116 | 145 | <WorkspaceBreadcrumbItem current> |
| 117 | 146 | <h1>Usage</h1> |
| 118 | 147 | </WorkspaceBreadcrumbItem> |
| 119 | <WorkspaceBreadcrumbSeparator className="hidden md:flex" /> | |
| 120 | <WorkspaceBreadcrumbItem className="hidden min-w-0 shrink md:flex"> | |
| 121 | <span className="truncate">{windowLabel}</span> | |
| 122 | </WorkspaceBreadcrumbItem> | |
| 148 | {showingLimits ? null : ( | |
| 149 | <> | |
| 150 | <WorkspaceBreadcrumbSeparator className="hidden md:flex" /> | |
| 151 | <WorkspaceBreadcrumbItem className="hidden min-w-0 shrink md:flex"> | |
| 152 | <span className="truncate">{windowLabel}</span> | |
| 153 | </WorkspaceBreadcrumbItem> | |
| 154 | </> | |
| 155 | )} | |
| 123 | 156 | </WorkspaceBreadcrumb> |
| 124 | 157 | <div className="ms-auto hidden min-w-0 items-center justify-end gap-2 lg:flex"> |
| 125 | 158 | <ToggleGroup |
| ... | ... | @@ -128,19 +161,22 @@ export function UsagePage() { |
| 128 | 161 | value={[metric]} |
| 129 | 162 | onValueChange={(next) => { |
| 130 | 163 | const value = next[0]; |
| 131 | if (value === "cost" || value === "tokens") setMetric(value); | |
| 164 | if (isUsageMetric(value)) setMetric(value); | |
| 132 | 165 | }} |
| 133 | 166 | > |
| 134 | {(["cost", "tokens"] as const).map((option) => ( | |
| 135 | <Toggle key={option} value={option}> | |
| 136 | {option === "cost" ? "Cost" : "Tokens"} | |
| 167 | {METRIC_OPTIONS.map((option) => ( | |
| 168 | <Toggle key={option.value} value={option.value}> | |
| 169 | {option.label} | |
| 137 | 170 | </Toggle> |
| 138 | 171 | ))} |
| 139 | 172 | </ToggleGroup> |
| 173 | {/* The period does not apply to Limits, so it stays in place but | |
| 174 | disabled; unmounting it shifted the metric toggle ~300px. */} | |
| 140 | 175 | <ToggleGroup |
| 141 | 176 | aria-label="Usage period" |
| 142 | 177 | variant="segmented" |
| 143 | 178 | value={[String(windowDays)]} |
| 179 | disabled={showingLimits} | |
| 144 | 180 | onValueChange={(next) => { |
| 145 | 181 | const value = next[0]; |
| 146 | 182 | if (value) selectWindow(Number(value)); |
| ... | ... | @@ -152,7 +188,12 @@ export function UsagePage() { |
| 152 | 188 | </Toggle> |
| 153 | 189 | ))} |
| 154 | 190 | </ToggleGroup> |
| 155 | <Button onClick={refreshWindow} aria-label="Refresh usage" size="icon-sm" variant="ghost"> | |
| 191 | <Button | |
| 192 | onClick={refreshWindow} | |
| 193 | aria-label={showingLimits ? "Refresh limits" : "Refresh usage"} | |
| 194 | size="icon-sm" | |
| 195 | variant="ghost" | |
| 196 | > | |
| 156 | 197 | <RefreshCwIcon className="size-3.5" /> |
| 157 | 198 | </Button> |
| 158 | 199 | </div> |
| ... | ... | @@ -160,7 +201,7 @@ export function UsagePage() { |
| 160 | 201 | <Select |
| 161 | 202 | value={metric} |
| 162 | 203 | onValueChange={(value) => { |
| 163 | if (value === "cost" || value === "tokens") setMetric(value); | |
| 204 | if (isUsageMetric(value)) setMetric(value); | |
| 164 | 205 | }} |
| 165 | 206 | > |
| 166 | 207 | <SelectTrigger |
| ... | ... | @@ -169,14 +210,23 @@ export function UsagePage() { |
| 169 | 210 | variant="ghost" |
| 170 | 211 | className="w-auto min-w-0" |
| 171 | 212 | > |
| 172 | <SelectValue>{metric === "cost" ? "Cost" : "Tokens"}</SelectValue> | |
| 213 | <SelectValue> | |
| 214 | {METRIC_OPTIONS.find((option) => option.value === metric)?.label} | |
| 215 | </SelectValue> | |
| 173 | 216 | </SelectTrigger> |
| 174 | 217 | <SelectPopup align="end" alignItemWithTrigger={false}> |
| 175 | <SelectItem value="cost">Cost</SelectItem> | |
| 176 | <SelectItem value="tokens">Tokens</SelectItem> | |
| 218 | {METRIC_OPTIONS.map((option) => ( | |
| 219 | <SelectItem key={option.value} value={option.value}> | |
| 220 | {option.label} | |
| 221 | </SelectItem> | |
| 222 | ))} | |
| 177 | 223 | </SelectPopup> |
| 178 | 224 | </Select> |
| 179 | <Select value={String(windowDays)} onValueChange={(value) => selectWindow(Number(value))}> | |
| 225 | <Select | |
| 226 | value={String(windowDays)} | |
| 227 | disabled={showingLimits} | |
| 228 | onValueChange={(value) => selectWindow(Number(value))} | |
| 229 | > | |
| 180 | 230 | <SelectTrigger |
| 181 | 231 | aria-label="Usage period" |
| 182 | 232 | size="compact" |
| ... | ... | @@ -195,7 +245,12 @@ export function UsagePage() { |
| 195 | 245 | ))} |
| 196 | 246 | </SelectPopup> |
| 197 | 247 | </Select> |
| 198 | <Button onClick={refreshWindow} aria-label="Refresh usage" size="icon-sm" variant="ghost"> | |
| 248 | <Button | |
| 249 | onClick={refreshWindow} | |
| 250 | aria-label={showingLimits ? "Refresh limits" : "Refresh usage"} | |
| 251 | size="icon-sm" | |
| 252 | variant="ghost" | |
| 253 | > | |
| 199 | 254 | <RefreshCwIcon className="size-3.5" /> |
| 200 | 255 | </Button> |
| 201 | 256 | </div> |
| ... | ... | @@ -209,7 +264,9 @@ export function UsagePage() { |
| 209 | 264 | |
| 210 | 265 | <ScrollArea className="min-h-0 flex-1"> |
| 211 | 266 | <WorkspacePageContainer width="wide"> |
| 212 | {settling ? ( | |
| 267 | {showingLimits ? ( | |
| 268 | <UsageLimitsSection /> | |
| 269 | ) : settling ? ( | |
| 213 | 270 | <> |
| 214 | 271 | {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null} |
| 215 | 272 | <UsageSkeleton /> |
apps/web/src/state/server.ts+1| ... | ... | @@ -28,6 +28,7 @@ import { environmentSession } from "./session"; |
| 28 | 28 | export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { |
| 29 | 29 | initialConfigValueAtom: environmentSession.initialConfigValueAtom, |
| 30 | 30 | environmentThemes: true, |
| 31 | usageLimitSources: true, | |
| 31 | 32 | }); |
| 32 | 33 | export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ |
| 33 | 34 | catalogValueAtom: environmentCatalog.catalogValueAtom, |
apps/web/src/timestampFormat.ts+26| ... | ... | @@ -159,6 +159,32 @@ export function formatDayAwareTimestamp( |
| 159 | 159 | return `${dateFormatter.format(date)} ${time}`; |
| 160 | 160 | } |
| 161 | 161 | |
| 162 | /** | |
| 163 | * The forward-looking counterpart of {@link formatDayAwareTimestamp} for an | |
| 164 | * instant that has not happened yet (a usage-limit reset): today `12:34 PM`, | |
| 165 | * tomorrow `tomorrow at 12:34 PM`, later `8/13 12:34 PM`. | |
| 166 | */ | |
| 167 | export function formatUpcomingTimestamp( | |
| 168 | isoDate: string, | |
| 169 | timestampFormat: TimestampFormat, | |
| 170 | nowMs: number = Date.now(), | |
| 171 | ): string { | |
| 172 | const date = parseTimestampDate(isoDate); | |
| 173 | if (!date) return ""; | |
| 174 | const time = getTimestampFormatter(timestampFormat, false).format(date); | |
| 175 | ||
| 176 | const now = new Date(nowMs); | |
| 177 | const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); | |
| 178 | const startOfTargetDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); | |
| 179 | const dayDiff = Math.round((startOfTargetDay - startOfToday) / 86_400_000); | |
| 180 | ||
| 181 | if (dayDiff <= 0) return time; | |
| 182 | if (dayDiff === 1) return `tomorrow at ${time}`; | |
| 183 | const dateFormatter = | |
| 184 | date.getFullYear() === now.getFullYear() ? numericDateFormatter : numericDateWithYearFormatter; | |
| 185 | return `${dateFormatter.format(date)} ${time}`; | |
| 186 | } | |
| 187 | ||
| 162 | 188 | /** |
| 163 | 189 | * Format a relative time string from an ISO date. |
| 164 | 190 | * Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }` |
docs/internals/glossary.md+8| ... | ... | @@ -117,6 +117,14 @@ Controls how assistant text reaches the thread timeline. In [the contracts][1], |
| 117 | 117 | |
| 118 | 118 | A point-in-time view of state. The word is used in multiple layers, including orchestration, provider, and checkpointing. See [ProjectionSnapshotQuery.ts][10], [ProviderAdapter.ts][15], and [CheckpointStore.ts][19]. |
| 119 | 119 | |
| 120 | #### Usage limits | |
| 121 | ||
| 122 | The rolling subscription quota windows a provider reports for its signed-in account, such as Claude's five-hour and weekly windows or Codex's primary and secondary allowances. Each driver decides in its own `checkProvider` whether it has any and returns them on the snapshot as `usageLimits`; drivers with no notion of subscription usage leave the field absent. Adapters that receive rate-limit telemetry during a turn normalise it into a `ProviderUsageLimitsUpdate` at the boundary, and `ProviderUsageLimitsIngestion` folds it onto the owning instance's snapshot through `ServerProviderShape.applyUsageLimits`, so no central service needs to know a driver kind. See [providerUsageLimits.ts](../../packages/contracts/src/providerUsageLimits.ts) and [makeManagedServerProvider.ts](../../apps/server/src/provider/makeManagedServerProvider.ts). | |
| 123 | ||
| 124 | #### Usage limit source | |
| 125 | ||
| 126 | A read-only quota feed outside this environment's provider CLIs, configured under `settings.usageLimitSources`. The only kind today is a CLIProxyAPI hub, whose `quota-scheduler/status` reports the windows of every pooled account. `UsageLimitSources` polls each source on the provider health interval and publishes `UsageLimitSourceSnapshot`s over the config stream as `usageLimitSourcesUpdated`, gated by a client capability flag the way environment themes are. The management key round-trips through the secret store with a redaction marker on disk. See [UsageLimitSources.ts](../../apps/server/src/usage/UsageLimitSources.ts). | |
| 127 | ||
| 120 | 128 | #### Model manifest |
| 121 | 129 | |
| 122 | 130 | The per-driver list of current model slugs that decides which models land in the model picker's legacy section. Bundled at `apps/server/src/provider/model-manifest.json` and refreshed at runtime from the same file on `main`, so classification updates ship as commits instead of releases. See the [provider architecture][16] model manifest section. |
docs/user/usage.md+15| ... | ... | @@ -8,6 +8,21 @@ separate from the raw token cost shown here. |
| 8 | 8 | Grok Build totals come from persisted session updates. Interactive turns that never wrote a |
| 9 | 9 | completed-turn record will not appear. |
| 10 | 10 | |
| 11 | The **Limits** view shows how much of each subscription window you have used on Codex and Claude | |
| 12 | Code, per connected environment: the session and weekly windows, plus a per-model weekly window | |
| 13 | such as Fable when your plan has one. Each window is a bar from the moment it opened to its reset, | |
| 14 | filled by the share of quota spent; a thin line marks how far into the window you are, which is | |
| 15 | also where even spending would have put the fill, and the icon beside the label says whether you | |
| 16 | are ahead of, on, or under that pace. Hover a bar for the exact reset time. Limits refresh on the | |
| 17 | provider health-check interval and update live while a turn runs. API-key accounts have no | |
| 18 | subscription windows and say so; that includes a Claude Code that reaches Anthropic through a proxy | |
| 19 | via `ANTHROPIC_AUTH_TOKEN`, since the CLI then treats itself as an API-key client. | |
| 20 | ||
| 21 | If you pool accounts behind a CLIProxyAPI hub, **Add CLIProxyAPI hub** on the Limits view shows | |
| 22 | every account the hub manages, each marked _via CLIProxyAPI_ so it is not mistaken for the provider | |
| 23 | signed in on this machine. Enter the hub's URL and management key; the key is stored on the server | |
| 24 | and never sent back to a client. Emails are blurred until clicked, as in provider settings. | |
| 25 | ||
| 11 | 26 | Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**, |
| 12 | 27 | **30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the |
| 13 | 28 | headline and chart. Refreshing rescans every connected environment and refetches model pricing on |
packages/client-runtime/src/rpc/session.test.ts+3-1| ... | ... | @@ -714,7 +714,9 @@ describe("RpcSessionFactory", () => { |
| 714 | 714 | clearVcsRefs: () => Effect.void, |
| 715 | 715 | clear: () => Effect.void, |
| 716 | 716 | }); |
| 717 | const configState = yield* makeEnvironmentServerConfigState(true).pipe( | |
| 717 | const configState = yield* makeEnvironmentServerConfigState({ | |
| 718 | environmentThemes: true, | |
| 719 | }).pipe( | |
| 718 | 720 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 719 | 721 | Effect.provideService(Persistence.EnvironmentCacheStore, cache), |
| 720 | 722 | ); |
packages/client-runtime/src/state/server.test.ts+2-2| ... | ... | @@ -590,7 +590,7 @@ describe("server state projection", () => { |
| 590 | 590 | |
| 591 | 591 | yield* Effect.scoped( |
| 592 | 592 | Effect.gen(function* () { |
| 593 | const state = yield* makeEnvironmentServerConfigState().pipe( | |
| 593 | const state = yield* makeEnvironmentServerConfigState({}).pipe( | |
| 594 | 594 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 595 | 595 | Effect.provideService(Persistence.EnvironmentCacheStore, cache), |
| 596 | 596 | ); |
| ... | ... | @@ -650,7 +650,7 @@ describe("server state projection", () => { |
| 650 | 650 | }); |
| 651 | 651 | |
| 652 | 652 | yield* Effect.scoped( |
| 653 | makeEnvironmentServerConfigState().pipe( | |
| 653 | makeEnvironmentServerConfigState({}).pipe( | |
| 654 | 654 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 655 | 655 | Effect.provideService(Persistence.EnvironmentCacheStore, cache), |
| 656 | 656 | ), |
packages/client-runtime/src/state/server.ts+20-8| ... | ... | @@ -353,8 +353,13 @@ const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEven |
| 353 | 353 | config, |
| 354 | 354 | }); |
| 355 | 355 | |
| 356 | export interface ServerConfigSubscriptionOptions { | |
| 357 | readonly environmentThemes?: boolean; | |
| 358 | readonly usageLimitSources?: boolean; | |
| 359 | } | |
| 360 | ||
| 356 | 361 | export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")( |
| 357 | function* (environmentThemes?: boolean) { | |
| 362 | function* (subscription: ServerConfigSubscriptionOptions) { | |
| 358 | 363 | const supervisor = yield* EnvironmentSupervisor; |
| 359 | 364 | const cache = yield* EnvironmentCacheStore; |
| 360 | 365 | const environmentId = supervisor.target.environmentId; |
| ... | ... | @@ -415,10 +420,10 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf |
| 415 | 420 | Effect.forkScoped, |
| 416 | 421 | ); |
| 417 | 422 | |
| 418 | yield* subscribe( | |
| 419 | WS_METHODS.subscribeServerConfig, | |
| 420 | environmentThemes === true ? { environmentThemes: true } : {}, | |
| 421 | ).pipe( | |
| 423 | yield* subscribe(WS_METHODS.subscribeServerConfig, { | |
| 424 | ...(subscription.environmentThemes === true ? { environmentThemes: true } : {}), | |
| 425 | ...(subscription.usageLimitSources === true ? { usageLimitSources: true } : {}), | |
| 426 | }).pipe( | |
| 422 | 427 | Stream.runForEach((event) => |
| 423 | 428 | Effect.gen(function* () { |
| 424 | 429 | const next = applyServerConfigProjection(yield* SubscriptionRef.get(state), event); |
| ... | ... | @@ -450,12 +455,12 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf |
| 450 | 455 | |
| 451 | 456 | export function serverConfigStateChanges( |
| 452 | 457 | environmentId: EnvironmentId, |
| 453 | environmentThemes?: boolean, | |
| 458 | subscription: ServerConfigSubscriptionOptions, | |
| 454 | 459 | ) { |
| 455 | 460 | return followStreamInEnvironment( |
| 456 | 461 | environmentId, |
| 457 | 462 | Stream.unwrap( |
| 458 | makeEnvironmentServerConfigState(environmentThemes).pipe( | |
| 463 | makeEnvironmentServerConfigState(subscription).pipe( | |
| 459 | 464 | Effect.map((state) => |
| 460 | 465 | SubscriptionRef.changes(state).pipe( |
| 461 | 466 | Stream.filterMap((projection) => |
| ... | ... | @@ -514,6 +519,8 @@ export function createServerEnvironmentAtoms<R, E>( |
| 514 | 519 | * receives the payload. |
| 515 | 520 | */ |
| 516 | 521 | readonly environmentThemes?: boolean; |
| 522 | /** Whether this surface renders quota from configured usage-limit sources. */ | |
| 523 | readonly usageLimitSources?: boolean; | |
| 517 | 524 | }, |
| 518 | 525 | ) { |
| 519 | 526 | const configScheduler = createAtomCommandScheduler(); |
| ... | ... | @@ -525,7 +532,12 @@ export function createServerEnvironmentAtoms<R, E>( |
| 525 | 532 | }; |
| 526 | 533 | const configProjectionFamily = Atom.family((environmentId: EnvironmentId) => |
| 527 | 534 | runtime |
| 528 | .atom(serverConfigStateChanges(environmentId, options.environmentThemes)) | |
| 535 | .atom( | |
| 536 | serverConfigStateChanges(environmentId, { | |
| 537 | ...(options.environmentThemes === true ? { environmentThemes: true } : {}), | |
| 538 | ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}), | |
| 539 | }), | |
| 540 | ) | |
| 529 | 541 | .pipe( |
| 530 | 542 | Atom.setIdleTTL(5 * 60_000), |
| 531 | 543 | Atom.withLabel(`environment-data:server:config-projection:${environmentId}`), |
packages/client-runtime/src/state/serverConfigProjection.ts+28-8| ... | ... | @@ -9,12 +9,15 @@ export interface ServerConfigProjection { |
| 9 | 9 | |
| 10 | 10 | /** |
| 11 | 11 | * Cached config keeps the provider and model catalog available across reconnects. |
| 12 | * Published themes are current machine state, so a cache could restore themes | |
| 13 | * that the machine no longer publishes. Replay sends themes as a separate event. | |
| 12 | * Published themes and usage-limit sources are current machine state, so a | |
| 13 | * cache could restore a set the machine no longer reports. Replay sends both | |
| 14 | * as separate events. | |
| 14 | 15 | */ |
| 15 | 16 | export function withoutEnvironmentThemes(config: ServerConfig): ServerConfig { |
| 16 | if (config.environmentThemes === undefined) return config; | |
| 17 | const { environmentThemes: _ephemeral, ...rest } = config; | |
| 17 | if (config.environmentThemes === undefined && config.usageLimitSources === undefined) { | |
| 18 | return config; | |
| 19 | } | |
| 20 | const { environmentThemes: _themes, usageLimitSources: _sources, ...rest } = config; | |
| 18 | 21 | return rest; |
| 19 | 22 | } |
| 20 | 23 | |
| ... | ... | @@ -27,13 +30,21 @@ export function applyServerConfigProjection( |
| 27 | 30 | // Wire snapshots never contain published themes. Keep the previous set |
| 28 | 31 | // until a capable server sends its authoritative theme event. A legacy |
| 29 | 32 | // server cannot send a later removal, so a downgrade must clear the set. |
| 30 | const carried = | |
| 31 | event.config.environment.capabilities.environmentThemes === true && Option.isSome(current) | |
| 33 | const capabilities = event.config.environment.capabilities; | |
| 34 | const carriedThemes = | |
| 35 | capabilities.environmentThemes === true && Option.isSome(current) | |
| 32 | 36 | ? current.value.config.environmentThemes |
| 33 | 37 | : undefined; |
| 38 | const carriedSources = | |
| 39 | capabilities.usageLimitSources === true && Option.isSome(current) | |
| 40 | ? current.value.config.usageLimitSources | |
| 41 | : undefined; | |
| 34 | 42 | return Option.some({ |
| 35 | config: | |
| 36 | carried === undefined ? event.config : { ...event.config, environmentThemes: carried }, | |
| 43 | config: { | |
| 44 | ...event.config, | |
| 45 | ...(carriedThemes === undefined ? {} : { environmentThemes: carriedThemes }), | |
| 46 | ...(carriedSources === undefined ? {} : { usageLimitSources: carriedSources }), | |
| 47 | }, | |
| 37 | 48 | latestEvent: event, |
| 38 | 49 | source: "live" as const, |
| 39 | 50 | }); |
| ... | ... | @@ -75,5 +86,14 @@ export function applyServerConfigProjection( |
| 75 | 86 | latestEvent: event, |
| 76 | 87 | source: "live", |
| 77 | 88 | })); |
| 89 | case "usageLimitSourcesUpdated": | |
| 90 | return Option.map(current, (projection) => ({ | |
| 91 | config: { | |
| 92 | ...projection.config, | |
| 93 | usageLimitSources: event.payload.sources.length > 0 ? event.payload.sources : undefined, | |
| 94 | }, | |
| 95 | latestEvent: event, | |
| 96 | source: "live", | |
| 97 | })); | |
| 78 | 98 | } |
| 79 | 99 | } |
packages/contracts/src/environment.ts+3| ... | ... | @@ -102,6 +102,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 102 | 102 | client reconnecting to one must drop published themes rather than keep |
| 103 | 103 | showing a set nothing will ever update. */ |
| 104 | 104 | environmentThemes: Schema.optionalKey(Schema.Boolean), |
| 105 | /** Server streams quota from configured usage-limit sources. Same | |
| 106 | version-skew contract as environmentThemes. */ | |
| 107 | usageLimitSources: Schema.optionalKey(Schema.Boolean), | |
| 105 | 108 | /** Server understands thread.pin / thread.unpin commands. Same |
| 106 | 109 | version-skew contract as threadSettlement. */ |
| 107 | 110 | threadPinning: Schema.optionalKey(Schema.Boolean), |
packages/contracts/src/index.ts+2| ... | ... | @@ -14,6 +14,8 @@ export * from "./provider.ts"; |
| 14 | 14 | export * from "./providerInstance.ts"; |
| 15 | 15 | export * from "./providerSetup.ts"; |
| 16 | 16 | export * from "./providerRuntime.ts"; |
| 17 | export * from "./providerUsageLimits.ts"; | |
| 18 | export * from "./usageLimitSourceId.ts"; | |
| 17 | 19 | export * from "./model.ts"; |
| 18 | 20 | export * from "./keybindings.ts"; |
| 19 | 21 | export * from "./server.ts"; |
packages/contracts/src/providerRuntime.ts+6-1| ... | ... | @@ -14,6 +14,7 @@ import { |
| 14 | 14 | TurnId, |
| 15 | 15 | } from "./baseSchemas.ts"; |
| 16 | 16 | import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts"; |
| 17 | import { ProviderUsageLimitsUpdate } from "./providerUsageLimits.ts"; | |
| 17 | 18 | import { ProviderApprovalOption } from "./orchestration.ts"; |
| 18 | 19 | |
| 19 | 20 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; |
| ... | ... | @@ -750,8 +751,12 @@ const AccountUpdatedPayload = Schema.Struct({ |
| 750 | 751 | }); |
| 751 | 752 | export type AccountUpdatedPayload = typeof AccountUpdatedPayload.Type; |
| 752 | 753 | |
| 754 | /** | |
| 755 | * Adapters normalise their native rate-limit payload at the boundary so the | |
| 756 | * consumer that folds it into the provider snapshot never sees driver shapes. | |
| 757 | */ | |
| 753 | 758 | const AccountRateLimitsUpdatedPayload = Schema.Struct({ |
| 754 | rateLimits: Schema.Unknown, | |
| 759 | limits: ProviderUsageLimitsUpdate, | |
| 755 | 760 | }); |
| 756 | 761 | export type AccountRateLimitsUpdatedPayload = typeof AccountRateLimitsUpdatedPayload.Type; |
| 757 | 762 |
packages/contracts/src/providerUsageLimits.ts created+92| ... | ... | @@ -0,0 +1,92 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | ||
| 3 | import { | |
| 4 | ForwardCompatibleArray, | |
| 5 | IsoDateTime, | |
| 6 | NonNegativeInt, | |
| 7 | TrimmedNonEmptyString, | |
| 8 | } from "./baseSchemas.ts"; | |
| 9 | import { ProviderDriverKind } from "./providerInstance.ts"; | |
| 10 | import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; | |
| 11 | ||
| 12 | /** | |
| 13 | * One rolling quota window a subscription provider reports for the signed-in | |
| 14 | * account, e.g. Claude's five-hour session or Codex's weekly allowance. | |
| 15 | * | |
| 16 | * `id` is stable per provider (`five_hour`, `seven_day_opus`, `primary`) so a | |
| 17 | * sparse turn-driven update lands on the same row a full probe produced. | |
| 18 | * `kind` only orders and labels the bar. | |
| 19 | */ | |
| 20 | export const ServerProviderUsageWindow = Schema.Struct({ | |
| 21 | id: TrimmedNonEmptyString, | |
| 22 | kind: Schema.Literals(["session", "weekly", "monthly", "other"]), | |
| 23 | label: TrimmedNonEmptyString, | |
| 24 | usedPercent: Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 100 })), | |
| 25 | resetsAt: Schema.optional(IsoDateTime), | |
| 26 | windowDurationMins: Schema.optional(NonNegativeInt), | |
| 27 | }); | |
| 28 | export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type; | |
| 29 | ||
| 30 | /** | |
| 31 | * Subscription usage the provider knows about the signed-in account. | |
| 32 | * | |
| 33 | * `unavailable` distinguishes an account that can never report windows (API | |
| 34 | * key, Bedrock) from a probe that failed this time, so clients can keep the | |
| 35 | * last good bars for the latter and clear them for the former. | |
| 36 | */ | |
| 37 | export const ServerProviderUsageLimits = Schema.Struct({ | |
| 38 | checkedAt: IsoDateTime, | |
| 39 | windows: ForwardCompatibleArray(ServerProviderUsageWindow), | |
| 40 | unavailable: Schema.optional( | |
| 41 | Schema.Struct({ | |
| 42 | reason: Schema.Literals(["unsupported", "probeFailed"]), | |
| 43 | message: Schema.optional(TrimmedNonEmptyString), | |
| 44 | }), | |
| 45 | ), | |
| 46 | }); | |
| 47 | export type ServerProviderUsageLimits = typeof ServerProviderUsageLimits.Type; | |
| 48 | ||
| 49 | /** | |
| 50 | * What an adapter reports when its runtime pushes a rate-limit update during | |
| 51 | * a turn. Sparse by contract: Claude's `rate_limit_event` names one window at | |
| 52 | * a time and Codex documents its notification as a partial. Windows merge by | |
| 53 | * `id` onto the instance's published snapshot; omitted windows are unchanged. | |
| 54 | */ | |
| 55 | export const ProviderUsageLimitsUpdate = Schema.Struct({ | |
| 56 | windows: Schema.Array(ServerProviderUsageWindow), | |
| 57 | }); | |
| 58 | export type ProviderUsageLimitsUpdate = typeof ProviderUsageLimitsUpdate.Type; | |
| 59 | ||
| 60 | /** | |
| 61 | * One account a usage-limit source reports on. `driver` is the provider the | |
| 62 | * account belongs to, for the icon and colour clients already have; the | |
| 63 | * account itself is not something this environment can run turns on. | |
| 64 | */ | |
| 65 | export const UsageLimitSourceAccount = Schema.Struct({ | |
| 66 | id: TrimmedNonEmptyString, | |
| 67 | driver: ProviderDriverKind, | |
| 68 | /** The signed-in address, when the source names one; clients blur it like provider auth. */ | |
| 69 | email: Schema.optional(TrimmedNonEmptyString), | |
| 70 | /** Plan as the matching provider would label it (`ChatGPT Pro 20x Subscription`). */ | |
| 71 | plan: Schema.optional(TrimmedNonEmptyString), | |
| 72 | usageLimits: ServerProviderUsageLimits, | |
| 73 | }); | |
| 74 | export type UsageLimitSourceAccount = typeof UsageLimitSourceAccount.Type; | |
| 75 | ||
| 76 | /** | |
| 77 | * The published state of one configured `usageLimitSources` entry. A source | |
| 78 | * that could not be read keeps `error` beside an empty account list rather | |
| 79 | * than vanishing, so the user can see it is configured but failing. | |
| 80 | */ | |
| 81 | export const UsageLimitSourceSnapshot = Schema.Struct({ | |
| 82 | id: UsageLimitSourceId, | |
| 83 | kind: Schema.Literal("cliproxy"), | |
| 84 | label: TrimmedNonEmptyString, | |
| 85 | checkedAt: IsoDateTime, | |
| 86 | accounts: ForwardCompatibleArray(UsageLimitSourceAccount), | |
| 87 | error: Schema.optional(TrimmedNonEmptyString), | |
| 88 | }); | |
| 89 | export type UsageLimitSourceSnapshot = typeof UsageLimitSourceSnapshot.Type; | |
| 90 | ||
| 91 | export const UsageLimitSourceSnapshots = ForwardCompatibleArray(UsageLimitSourceSnapshot); | |
| 92 | export type UsageLimitSourceSnapshots = typeof UsageLimitSourceSnapshots.Type; |
packages/contracts/src/rpc.ts+2| ... | ... | @@ -1116,6 +1116,8 @@ export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerCon |
| 1116 | 1116 | * dropped by old servers. |
| 1117 | 1117 | */ |
| 1118 | 1118 | environmentThemes: Schema.optional(Schema.Boolean), |
| 1119 | /** Whether this client understands `usageLimitSourcesUpdated` events. */ | |
| 1120 | usageLimitSources: Schema.optional(Schema.Boolean), | |
| 1119 | 1121 | }), |
| 1120 | 1122 | success: ServerConfigStreamEvent, |
| 1121 | 1123 | error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), |
packages/contracts/src/server.test.ts+18| ... | ... | @@ -157,6 +157,24 @@ describe("server config forward compatibility", () => { |
| 157 | 157 | |
| 158 | 158 | expect(parsed).toEqual([decodedBase]); |
| 159 | 159 | }); |
| 160 | ||
| 161 | it("drops usage windows this build cannot decode instead of failing the provider", () => { | |
| 162 | const parsed = decodeServerProvider({ | |
| 163 | ...baseProviderSnapshot, | |
| 164 | usageLimits: { | |
| 165 | checkedAt: "2026-04-10T00:00:00.000Z", | |
| 166 | windows: [ | |
| 167 | { id: "primary", kind: "session", label: "Session", usedPercent: 12 }, | |
| 168 | { id: "future", kind: "some-future-kind", label: "Future", usedPercent: 1 }, | |
| 169 | { id: "bad", kind: "weekly", label: "Weekly", usedPercent: 120 }, | |
| 170 | ], | |
| 171 | }, | |
| 172 | }); | |
| 173 | ||
| 174 | expect(parsed.usageLimits?.windows).toEqual([ | |
| 175 | { id: "primary", kind: "session", label: "Session", usedPercent: 12 }, | |
| 176 | ]); | |
| 177 | }); | |
| 160 | 178 | }); |
| 161 | 179 | |
| 162 | 180 | describe("resolveEnvironmentMachineKind", () => { |
packages/contracts/src/server.ts+25| ... | ... | @@ -24,6 +24,7 @@ import { |
| 24 | 24 | import { EditorId, FileManagerRevealKind, RemoteOpenTarget } from "./editor.ts"; |
| 25 | 25 | import { ModelCapabilities } from "./model.ts"; |
| 26 | 26 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; |
| 27 | import { ServerProviderUsageLimits, UsageLimitSourceSnapshots } from "./providerUsageLimits.ts"; | |
| 27 | 28 | import { ServerSettings } from "./settings.ts"; |
| 28 | 29 | |
| 29 | 30 | const KeybindingsMalformedConfigIssue = Schema.Struct({ |
| ... | ... | @@ -227,6 +228,8 @@ export const ServerProvider = Schema.Struct({ |
| 227 | 228 | ), |
| 228 | 229 | skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), |
| 229 | 230 | workspaceSnapshots: Schema.optionalKey(Schema.Array(ServerProviderWorkspaceSnapshot)), |
| 231 | // Absent when the driver has no notion of subscription usage. | |
| 232 | usageLimits: Schema.optional(ServerProviderUsageLimits), | |
| 230 | 233 | versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), |
| 231 | 234 | updateState: Schema.optionalKey(ServerProviderUpdateState), |
| 232 | 235 | }); |
| ... | ... | @@ -581,6 +584,12 @@ export const ServerConfig = Schema.Struct({ |
| 581 | 584 | * and it stays absent for subscribers that did not opt in. |
| 582 | 585 | */ |
| 583 | 586 | environmentThemes: Schema.optional(Schema.Array(EnvironmentTheme)), |
| 587 | /** | |
| 588 | * Quota reported by configured `usageLimitSources`. Like themes, never in | |
| 589 | * a snapshot: the source stream emits the current set on subscribe, and it | |
| 590 | * stays absent for subscribers that did not opt in. | |
| 591 | */ | |
| 592 | usageLimitSources: Schema.optional(UsageLimitSourceSnapshots), | |
| 584 | 593 | }); |
| 585 | 594 | export type ServerConfig = typeof ServerConfig.Type; |
| 586 | 595 | |
| ... | ... | @@ -692,12 +701,28 @@ export const ServerConfigStreamEnvironmentThemesUpdatedEvent = Schema.Struct({ |
| 692 | 701 | export type ServerConfigStreamEnvironmentThemesUpdatedEvent = |
| 693 | 702 | typeof ServerConfigStreamEnvironmentThemesUpdatedEvent.Type; |
| 694 | 703 | |
| 704 | export const ServerConfigUsageLimitSourcesUpdatedPayload = Schema.Struct({ | |
| 705 | /** The full set; empty once no source is configured. */ | |
| 706 | sources: UsageLimitSourceSnapshots, | |
| 707 | }); | |
| 708 | export type ServerConfigUsageLimitSourcesUpdatedPayload = | |
| 709 | typeof ServerConfigUsageLimitSourcesUpdatedPayload.Type; | |
| 710 | ||
| 711 | export const ServerConfigStreamUsageLimitSourcesUpdatedEvent = Schema.Struct({ | |
| 712 | version: Schema.Literal(1), | |
| 713 | type: Schema.Literal("usageLimitSourcesUpdated"), | |
| 714 | payload: ServerConfigUsageLimitSourcesUpdatedPayload, | |
| 715 | }); | |
| 716 | export type ServerConfigStreamUsageLimitSourcesUpdatedEvent = | |
| 717 | typeof ServerConfigStreamUsageLimitSourcesUpdatedEvent.Type; | |
| 718 | ||
| 695 | 719 | export const ServerConfigStreamEvent = Schema.Union([ |
| 696 | 720 | ServerConfigStreamSnapshotEvent, |
| 697 | 721 | ServerConfigStreamKeybindingsUpdatedEvent, |
| 698 | 722 | ServerConfigStreamProviderStatusesEvent, |
| 699 | 723 | ServerConfigStreamSettingsUpdatedEvent, |
| 700 | 724 | ServerConfigStreamEnvironmentThemesUpdatedEvent, |
| 725 | ServerConfigStreamUsageLimitSourcesUpdatedEvent, | |
| 701 | 726 | ]); |
| 702 | 727 | export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type; |
| 703 | 728 |
packages/contracts/src/settings.ts+27| ... | ... | @@ -3,6 +3,7 @@ import * as Duration from "effect/Duration"; |
| 3 | 3 | import * as Schema from "effect/Schema"; |
| 4 | 4 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 5 | 5 | import { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; |
| 6 | import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; | |
| 6 | 7 | import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts"; |
| 7 | 8 | import { |
| 8 | 9 | DEFAULT_TEXT_GENERATION_MODEL, |
| ... | ... | @@ -731,6 +732,21 @@ export const OpenCodeSettings = makeProviderSettingsSchema( |
| 731 | 732 | ); |
| 732 | 733 | export type OpenCodeSettings = typeof OpenCodeSettings.Type; |
| 733 | 734 | |
| 735 | /** | |
| 736 | * A read-only quota source outside this environment's provider CLIs. The | |
| 737 | * only kind today is a CLIProxyAPI hub, whose management API reports the | |
| 738 | * windows of every pooled account. The key travels in settings for now, like | |
| 739 | * provider environment secrets; it is redacted before reaching a client. | |
| 740 | */ | |
| 741 | export const UsageLimitSourceConfig = Schema.Struct({ | |
| 742 | kind: Schema.Literal("cliproxy"), | |
| 743 | label: Schema.optional(TrimmedNonEmptyString), | |
| 744 | url: TrimmedNonEmptyString, | |
| 745 | managementKey: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | |
| 746 | enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | |
| 747 | }); | |
| 748 | export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; | |
| 749 | ||
| 734 | 750 | export const ObservabilitySettings = Schema.Struct({ |
| 735 | 751 | otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 736 | 752 | otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| ... | ... | @@ -909,6 +925,11 @@ export const ServerSettings = Schema.Struct({ |
| 909 | 925 | Schema.withDecodingDefault(Effect.succeed({})), |
| 910 | 926 | ), |
| 911 | 927 | observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 928 | // Keyed by a user-chosen id so a source keeps its rows across edits. Entries | |
| 929 | // this build cannot decode round-trip untouched, as provider instances do. | |
| 930 | usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe( | |
| 931 | Schema.withDecodingDefault(Effect.succeed({})), | |
| 932 | ), | |
| 912 | 933 | }); |
| 913 | 934 | export type ServerSettings = typeof ServerSettings.Type; |
| 914 | 935 | |
| ... | ... | @@ -1114,6 +1135,12 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 1114 | 1135 | // patches risk leaving driver-specific config in a half-merged state. |
| 1115 | 1136 | // The web UI sends a fully-formed map every time it edits this field. |
| 1116 | 1137 | providerInstances: Schema.optionalKey(Schema.Record(ProviderInstanceId, ProviderInstanceConfig)), |
| 1138 | // Per-entry, unlike `providerInstances`: a client only ever adds or removes | |
| 1139 | // one source, and sending the whole map races another edit that has not | |
| 1140 | // echoed back yet. `null` removes; the server merges into its current map. | |
| 1141 | usageLimitSources: Schema.optionalKey( | |
| 1142 | Schema.Record(UsageLimitSourceId, Schema.NullOr(UsageLimitSourceConfig)), | |
| 1143 | ), | |
| 1117 | 1144 | }); |
| 1118 | 1145 | export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; |
| 1119 | 1146 |
packages/contracts/src/usageLimitSourceId.ts created+9| ... | ... | @@ -0,0 +1,9 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | ||
| 3 | /** | |
| 4 | * Key of one `settings.usageLimitSources` entry. Lives in its own module so | |
| 5 | * both the settings and the usage-limit contracts can import it without | |
| 6 | * importing each other. | |
| 7 | */ | |
| 8 | export const UsageLimitSourceId = Schema.String.pipe(Schema.brand("UsageLimitSourceId")); | |
| 9 | export type UsageLimitSourceId = typeof UsageLimitSourceId.Type; |
packages/shared/package.json+4| ... | ... | @@ -243,6 +243,10 @@ |
| 243 | 243 | "types": "./src/usageFormat.ts", |
| 244 | 244 | "import": "./src/usageFormat.ts" |
| 245 | 245 | }, |
| 246 | "./usageLimits": { | |
| 247 | "types": "./src/usageLimits.ts", | |
| 248 | "import": "./src/usageLimits.ts" | |
| 249 | }, | |
| 246 | 250 | "./desktopAppControl": { |
| 247 | 251 | "types": "./src/desktopAppControl.ts", |
| 248 | 252 | "import": "./src/desktopAppControl.ts" |
packages/shared/src/serverSettings.test.ts+24| ... | ... | @@ -2,6 +2,7 @@ import { |
| 2 | 2 | DEFAULT_SERVER_SETTINGS, |
| 3 | 3 | ProviderDriverKind, |
| 4 | 4 | ProviderInstanceId, |
| 5 | UsageLimitSourceId, | |
| 5 | 6 | type ServerProvider, |
| 6 | 7 | } from "@t3tools/contracts"; |
| 7 | 8 | import * as Duration from "effect/Duration"; |
| ... | ... | @@ -298,6 +299,29 @@ describe("serverSettings helpers", () => { |
| 298 | 299 | }); |
| 299 | 300 | }); |
| 300 | 301 | |
| 302 | it("upserts and removes usageLimitSources per entry so concurrent edits cannot clobber", () => { | |
| 303 | const hubA = UsageLimitSourceId.make("cliproxy-a"); | |
| 304 | const hubB = UsageLimitSourceId.make("cliproxy-b"); | |
| 305 | const source = (url: string) => ({ | |
| 306 | kind: "cliproxy" as const, | |
| 307 | url, | |
| 308 | managementKey: "secret", | |
| 309 | enabled: true, | |
| 310 | }); | |
| 311 | const current = { | |
| 312 | ...DEFAULT_SERVER_SETTINGS, | |
| 313 | usageLimitSources: { [hubA]: source("http://a:8318") }, | |
| 314 | }; | |
| 315 | ||
| 316 | const added = applyServerSettingsPatch(current, { | |
| 317 | usageLimitSources: { [hubB]: source("http://b:8318") }, | |
| 318 | }); | |
| 319 | expect(Object.keys(added.usageLimitSources)).toEqual([hubA, hubB]); | |
| 320 | ||
| 321 | const removed = applyServerSettingsPatch(added, { usageLimitSources: { [hubA]: null } }); | |
| 322 | expect(Object.keys(removed.usageLimitSources)).toEqual([hubB]); | |
| 323 | }); | |
| 324 | ||
| 301 | 325 | it("stores background activity profiles as a versioned object and syncs legacy aliases", () => { |
| 302 | 326 | const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 303 | 327 | backgroundActivity: { |
packages/shared/src/serverSettings.ts+26| ... | ... | @@ -122,6 +122,22 @@ function mergeModelSelectionOptionsById(input: { |
| 122 | 122 | return [...merged.entries()].map(([id, value]) => ({ id, value })); |
| 123 | 123 | } |
| 124 | 124 | |
| 125 | /** Upsert each patched entry; `null` removes it. Entries the patch omits are untouched. */ | |
| 126 | function mergeUsageLimitSources( | |
| 127 | current: ServerSettings["usageLimitSources"], | |
| 128 | patch: NonNullable<ServerSettingsPatch["usageLimitSources"]>, | |
| 129 | ): ServerSettings["usageLimitSources"] { | |
| 130 | const next = new Map(Object.entries(current)); | |
| 131 | for (const [id, config] of Object.entries(patch)) { | |
| 132 | if (config === null) { | |
| 133 | next.delete(id); | |
| 134 | } else { | |
| 135 | next.set(id, config); | |
| 136 | } | |
| 137 | } | |
| 138 | return Object.fromEntries(next) as ServerSettings["usageLimitSources"]; | |
| 139 | } | |
| 140 | ||
| 125 | 141 | export function applyServerSettingsPatch( |
| 126 | 142 | current: ServerSettings, |
| 127 | 143 | patch: ServerSettingsPatch, |
| ... | ... | @@ -132,6 +148,8 @@ export function applyServerSettingsPatch( |
| 132 | 148 | providerHealthRefreshInterval, |
| 133 | 149 | backgroundActivityProfile, |
| 134 | 150 | backgroundActivity, |
| 151 | // Merged per entry below; its `null` removals must not reach deepMerge. | |
| 152 | usageLimitSources: usageLimitSourcesPatch, | |
| 135 | 153 | ...patchForMerge |
| 136 | 154 | } = patch; |
| 137 | 155 | const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current); |
| ... | ... | @@ -188,6 +206,14 @@ export function applyServerSettingsPatch( |
| 188 | 206 | ...(patch.providerInstances !== undefined |
| 189 | 207 | ? { providerInstances: patch.providerInstances } |
| 190 | 208 | : {}), |
| 209 | ...(usageLimitSourcesPatch !== undefined | |
| 210 | ? { | |
| 211 | usageLimitSources: mergeUsageLimitSources( | |
| 212 | current.usageLimitSources, | |
| 213 | usageLimitSourcesPatch, | |
| 214 | ), | |
| 215 | } | |
| 216 | : {}), | |
| 191 | 217 | ...(patch.sourceControlWriterModelSelection !== undefined |
| 192 | 218 | ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection } |
| 193 | 219 | : {}), |
packages/shared/src/usageLimits.test.ts created+181| ... | ... | @@ -0,0 +1,181 @@ |
| 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | collectLimitSources, | |
| 6 | collectLimitsGroups, | |
| 7 | elapsedShare, | |
| 8 | formatResetsIn, | |
| 9 | limitsNotice, | |
| 10 | paceOf, | |
| 11 | providersWithLimits, | |
| 12 | } from "./usageLimits.ts"; | |
| 13 | ||
| 14 | const now = Date.parse("2026-09-03T12:00:00.000Z"); | |
| 15 | ||
| 16 | const window = { | |
| 17 | id: "five_hour", | |
| 18 | kind: "session", | |
| 19 | label: "Session", | |
| 20 | usedPercent: 40, | |
| 21 | windowDurationMins: 300, | |
| 22 | resetsAt: "2026-09-03T14:00:00.000Z", | |
| 23 | } as const; | |
| 24 | ||
| 25 | function provider(overrides: Partial<ServerProvider>): ServerProvider { | |
| 26 | return { | |
| 27 | instanceId: ProviderInstanceId.make("codex"), | |
| 28 | driver: ProviderDriverKind.make("codex"), | |
| 29 | enabled: true, | |
| 30 | installed: true, | |
| 31 | version: null, | |
| 32 | status: "ready", | |
| 33 | auth: { status: "authenticated" }, | |
| 34 | checkedAt: "2026-09-03T11:00:00.000Z", | |
| 35 | models: [], | |
| 36 | slashCommands: [], | |
| 37 | skills: [], | |
| 38 | ...overrides, | |
| 39 | }; | |
| 40 | } | |
| 41 | ||
| 42 | describe("pace", () => { | |
| 43 | it("places the clock three fifths through a five-hour window with two hours left", () => { | |
| 44 | expect(elapsedShare(window, now)).toBeCloseTo(0.6); | |
| 45 | expect(paceOf(window, now)).toBe("under"); | |
| 46 | expect(paceOf({ ...window, usedPercent: 62 }, now)).toBe("on"); | |
| 47 | expect(paceOf({ ...window, usedPercent: 80 }, now)).toBe("ahead"); | |
| 48 | }); | |
| 49 | ||
| 50 | it("has no pace without a reset or a duration", () => { | |
| 51 | expect(paceOf({ ...window, resetsAt: undefined }, now)).toBeNull(); | |
| 52 | expect(paceOf({ ...window, windowDurationMins: undefined }, now)).toBeNull(); | |
| 53 | expect(formatResetsIn({ ...window, resetsAt: undefined }, now)).toBeNull(); | |
| 54 | }); | |
| 55 | ||
| 56 | it("phrases the reset as a countdown", () => { | |
| 57 | expect(formatResetsIn(window, now)).toBe("resets in 2h 0m"); | |
| 58 | expect(formatResetsIn({ ...window, resetsAt: "2026-09-06T15:30:00.000Z" }, now)).toBe( | |
| 59 | "resets in 3d 3h", | |
| 60 | ); | |
| 61 | expect(formatResetsIn({ ...window, resetsAt: "2026-09-03T11:00:00.000Z" }, now)).toBe( | |
| 62 | "resets now", | |
| 63 | ); | |
| 64 | }); | |
| 65 | }); | |
| 66 | ||
| 67 | describe("limitsNotice", () => { | |
| 68 | it("explains empty bars and passes provider messages through", () => { | |
| 69 | const checkedAt = "2026-09-03T11:00:00.000Z"; | |
| 70 | expect(limitsNotice({ checkedAt, windows: [window] })).toBeNull(); | |
| 71 | expect(limitsNotice({ checkedAt, windows: [] })).toBe("No limits reported."); | |
| 72 | expect(limitsNotice({ checkedAt, windows: [], unavailable: { reason: "unsupported" } })).toBe( | |
| 73 | "This account has no subscription limits.", | |
| 74 | ); | |
| 75 | expect( | |
| 76 | limitsNotice({ | |
| 77 | checkedAt, | |
| 78 | windows: [], | |
| 79 | unavailable: { reason: "probeFailed", message: "Codex timed out." }, | |
| 80 | }), | |
| 81 | ).toBe("Codex timed out."); | |
| 82 | }); | |
| 83 | }); | |
| 84 | ||
| 85 | describe("providersWithLimits", () => { | |
| 86 | it("keeps only usable providers whose driver reports limits at all", () => { | |
| 87 | const limits = { checkedAt: "2026-09-03T11:00:00.000Z", windows: [window] }; | |
| 88 | const codex = provider({ usageLimits: limits }); | |
| 89 | expect( | |
| 90 | providersWithLimits([ | |
| 91 | codex, | |
| 92 | provider({ | |
| 93 | instanceId: ProviderInstanceId.make("cursor"), | |
| 94 | driver: ProviderDriverKind.make("cursor"), | |
| 95 | }), | |
| 96 | provider({ | |
| 97 | instanceId: ProviderInstanceId.make("off"), | |
| 98 | enabled: false, | |
| 99 | usageLimits: limits, | |
| 100 | }), | |
| 101 | provider({ | |
| 102 | instanceId: ProviderInstanceId.make("gone"), | |
| 103 | installed: false, | |
| 104 | usageLimits: limits, | |
| 105 | }), | |
| 106 | provider({ | |
| 107 | instanceId: ProviderInstanceId.make("shadow"), | |
| 108 | availability: "unavailable", | |
| 109 | usageLimits: limits, | |
| 110 | }), | |
| 111 | ]), | |
| 112 | ).toEqual([codex]); | |
| 113 | }); | |
| 114 | }); | |
| 115 | ||
| 116 | describe("collectLimitsGroups", () => { | |
| 117 | it("labels environments only when more than one reports limits", () => { | |
| 118 | const limits = { checkedAt: "2026-09-03T11:00:00.000Z", windows: [window] }; | |
| 119 | const codex = provider({ usageLimits: limits }); | |
| 120 | const one = new Map([ | |
| 121 | ["env-a", { entry: { target: { label: "Laptop" } }, serverConfig: { providers: [codex] } }], | |
| 122 | [ | |
| 123 | "env-b", | |
| 124 | { entry: { target: { label: "Desktop" } }, serverConfig: { providers: [provider({})] } }, | |
| 125 | ], | |
| 126 | ] as const); | |
| 127 | expect(collectLimitsGroups(one as never).map((group) => group.environmentLabel)).toEqual([ | |
| 128 | null, | |
| 129 | ]); | |
| 130 | ||
| 131 | const two = new Map([ | |
| 132 | ["env-a", { entry: { target: { label: "Laptop" } }, serverConfig: { providers: [codex] } }], | |
| 133 | ["env-b", { entry: { target: { label: "Desktop" } }, serverConfig: { providers: [codex] } }], | |
| 134 | ] as const); | |
| 135 | expect(collectLimitsGroups(two as never).map((group) => group.environmentLabel)).toEqual([ | |
| 136 | "Laptop", | |
| 137 | "Desktop", | |
| 138 | ]); | |
| 139 | }); | |
| 140 | }); | |
| 141 | ||
| 142 | describe("collectLimitSources", () => { | |
| 143 | const source = { | |
| 144 | id: "cliproxy-hub" as never, | |
| 145 | kind: "cliproxy" as const, | |
| 146 | label: "hub", | |
| 147 | checkedAt: "2026-09-03T11:00:00.000Z", | |
| 148 | accounts: [], | |
| 149 | }; | |
| 150 | ||
| 151 | it("keys sources per environment and names the environment only when several have some", () => { | |
| 152 | const one = new Map([ | |
| 153 | [ | |
| 154 | "env-a", | |
| 155 | { entry: { target: { label: "Laptop" } }, serverConfig: { usageLimitSources: [source] } }, | |
| 156 | ], | |
| 157 | [ | |
| 158 | "env-b", | |
| 159 | { entry: { target: { label: "Desktop" } }, serverConfig: { usageLimitSources: [] } }, | |
| 160 | ], | |
| 161 | ] as const); | |
| 162 | expect(collectLimitSources(one as never).map((entry) => [entry.key, entry.label])).toEqual([ | |
| 163 | ["env-a:cliproxy-hub", "hub"], | |
| 164 | ]); | |
| 165 | ||
| 166 | const two = new Map([ | |
| 167 | [ | |
| 168 | "env-a", | |
| 169 | { entry: { target: { label: "Laptop" } }, serverConfig: { usageLimitSources: [source] } }, | |
| 170 | ], | |
| 171 | [ | |
| 172 | "env-b", | |
| 173 | { entry: { target: { label: "Desktop" } }, serverConfig: { usageLimitSources: [source] } }, | |
| 174 | ], | |
| 175 | ] as const); | |
| 176 | expect(collectLimitSources(two as never).map((entry) => entry.label)).toEqual([ | |
| 177 | "Laptop · hub", | |
| 178 | "Desktop · hub", | |
| 179 | ]); | |
| 180 | }); | |
| 181 | }); |
packages/shared/src/usageLimits.ts created+178| ... | ... | @@ -0,0 +1,178 @@ |
| 1 | /** | |
| 2 | * Selection and pace maths for the provider limits view, shared by web and | |
| 3 | * mobile so both agree on which providers show, what "ahead of pace" means, | |
| 4 | * and how a reset is phrased. | |
| 5 | * | |
| 6 | * @module usageLimits | |
| 7 | */ | |
| 8 | import { | |
| 9 | type EnvironmentId, | |
| 10 | isProviderAvailable, | |
| 11 | type ServerProvider, | |
| 12 | type ServerProviderUsageLimits, | |
| 13 | type ServerProviderUsageWindow, | |
| 14 | type UsageLimitSourceSnapshot, | |
| 15 | type UsageLimitSourceSnapshots, | |
| 16 | } from "@t3tools/contracts"; | |
| 17 | ||
| 18 | const MINUTE = 60_000; | |
| 19 | const HOUR = 60 * MINUTE; | |
| 20 | const DAY = 24 * HOUR; | |
| 21 | ||
| 22 | /** | |
| 23 | * Providers that belong on the Limits view: enabled, installed, and one whose | |
| 24 | * driver reports subscription usage at all. A driver with no notion of usage | |
| 25 | * never sets `usageLimits`, so it has no row rather than an empty one. | |
| 26 | */ | |
| 27 | export function providersWithLimits( | |
| 28 | providers: readonly ServerProvider[], | |
| 29 | ): readonly ServerProvider[] { | |
| 30 | return providers.filter( | |
| 31 | (provider) => | |
| 32 | provider.enabled && | |
| 33 | provider.installed && | |
| 34 | isProviderAvailable(provider) && | |
| 35 | provider.usageLimits !== undefined, | |
| 36 | ); | |
| 37 | } | |
| 38 | ||
| 39 | export interface LimitsGroup { | |
| 40 | readonly environmentId: EnvironmentId; | |
| 41 | /** Null while only one environment is connected; there is nothing to tell apart. */ | |
| 42 | readonly environmentLabel: string | null; | |
| 43 | readonly providers: readonly ServerProvider[]; | |
| 44 | } | |
| 45 | ||
| 46 | /** | |
| 47 | * One group per connected environment with a provider reporting limits. | |
| 48 | * Provider snapshots come from the config stream every client already holds, | |
| 49 | * so opening the view costs no extra request. | |
| 50 | */ | |
| 51 | export function collectLimitsGroups( | |
| 52 | presentations: ReadonlyMap< | |
| 53 | EnvironmentId, | |
| 54 | { | |
| 55 | readonly entry: { readonly target: { readonly label: string } }; | |
| 56 | readonly serverConfig: { readonly providers: readonly ServerProvider[] } | null; | |
| 57 | } | |
| 58 | >, | |
| 59 | ): readonly LimitsGroup[] { | |
| 60 | const groups: LimitsGroup[] = []; | |
| 61 | for (const [environmentId, presentation] of presentations) { | |
| 62 | const providers = providersWithLimits(presentation.serverConfig?.providers ?? []); | |
| 63 | if (providers.length === 0) continue; | |
| 64 | groups.push({ environmentId, environmentLabel: presentation.entry.target.label, providers }); | |
| 65 | } | |
| 66 | return groups.length > 1 ? groups : groups.map((group) => ({ ...group, environmentLabel: null })); | |
| 67 | } | |
| 68 | ||
| 69 | /** | |
| 70 | * Every usage-limit source across connected environments, keyed so two | |
| 71 | * environments pointing at the same hub still get their own rows. The label | |
| 72 | * carries the environment only when more than one environment has sources. | |
| 73 | */ | |
| 74 | export function collectLimitSources( | |
| 75 | presentations: ReadonlyMap< | |
| 76 | EnvironmentId, | |
| 77 | { | |
| 78 | readonly entry: { readonly target: { readonly label: string } }; | |
| 79 | readonly serverConfig: { | |
| 80 | readonly usageLimitSources?: UsageLimitSourceSnapshots | undefined; | |
| 81 | } | null; | |
| 82 | } | |
| 83 | >, | |
| 84 | ): ReadonlyArray< | |
| 85 | UsageLimitSourceSnapshot & { readonly key: string; readonly environmentId: EnvironmentId } | |
| 86 | > { | |
| 87 | const perEnvironment: Array<{ | |
| 88 | readonly environmentId: EnvironmentId; | |
| 89 | readonly environmentLabel: string; | |
| 90 | readonly sources: UsageLimitSourceSnapshots; | |
| 91 | }> = []; | |
| 92 | for (const [environmentId, presentation] of presentations) { | |
| 93 | const sources = presentation.serverConfig?.usageLimitSources ?? []; | |
| 94 | if (sources.length === 0) continue; | |
| 95 | perEnvironment.push({ | |
| 96 | environmentId, | |
| 97 | environmentLabel: presentation.entry.target.label, | |
| 98 | sources, | |
| 99 | }); | |
| 100 | } | |
| 101 | const labelEnvironment = perEnvironment.length > 1; | |
| 102 | return perEnvironment.flatMap(({ environmentId, environmentLabel, sources }) => | |
| 103 | sources.map((source) => ({ | |
| 104 | ...source, | |
| 105 | environmentId, | |
| 106 | key: `${environmentId}:${source.id}`, | |
| 107 | label: labelEnvironment ? `${environmentLabel} · ${source.label}` : source.label, | |
| 108 | })), | |
| 109 | ); | |
| 110 | } | |
| 111 | ||
| 112 | /** The instance's configured name, else the driver's, else its raw kind. */ | |
| 113 | export function providerLimitsLabel( | |
| 114 | provider: ServerProvider, | |
| 115 | driverLabel: (driver: ServerProvider["driver"]) => string | undefined, | |
| 116 | ): string { | |
| 117 | return provider.displayName?.trim() || driverLabel(provider.driver) || String(provider.driver); | |
| 118 | } | |
| 119 | ||
| 120 | /** The one-line status under a provider heading when there are no bars to draw. */ | |
| 121 | export function limitsNotice(limits: ServerProviderUsageLimits): string | null { | |
| 122 | if (limits.unavailable?.reason === "unsupported") { | |
| 123 | return limits.unavailable.message ?? "This account has no subscription limits."; | |
| 124 | } | |
| 125 | if (limits.unavailable?.reason === "probeFailed") { | |
| 126 | return limits.unavailable.message ?? "Could not read limits."; | |
| 127 | } | |
| 128 | return limits.windows.length === 0 ? "No limits reported." : null; | |
| 129 | } | |
| 130 | ||
| 131 | export function resetMillis(window: ServerProviderUsageWindow): number | null { | |
| 132 | if (window.resetsAt === undefined) return null; | |
| 133 | const at = Date.parse(window.resetsAt); | |
| 134 | return Number.isFinite(at) ? at : null; | |
| 135 | } | |
| 136 | ||
| 137 | /** Elapsed share of the window, 0..1, or null when its length or reset is unknown. */ | |
| 138 | export function elapsedShare(window: ServerProviderUsageWindow, now: number): number | null { | |
| 139 | const resetsAt = resetMillis(window); | |
| 140 | if (resetsAt === null || window.windowDurationMins === undefined) return null; | |
| 141 | const length = window.windowDurationMins * MINUTE; | |
| 142 | if (length <= 0) return null; | |
| 143 | return Math.max(0, Math.min(1, (length - (resetsAt - now)) / length)); | |
| 144 | } | |
| 145 | ||
| 146 | export type LimitPace = "ahead" | "on" | "under"; | |
| 147 | ||
| 148 | /** | |
| 149 | * Usage against the clock. The bar is the whole window, so the elapsed share | |
| 150 | * is also where even spending would have put the fill; within five points of | |
| 151 | * it counts as on pace. | |
| 152 | */ | |
| 153 | export function paceOf(window: ServerProviderUsageWindow, now: number): LimitPace | null { | |
| 154 | const elapsed = elapsedShare(window, now); | |
| 155 | if (elapsed === null) return null; | |
| 156 | const gap = window.usedPercent - elapsed * 100; | |
| 157 | if (gap > 5) return "ahead"; | |
| 158 | if (gap < -5) return "under"; | |
| 159 | return "on"; | |
| 160 | } | |
| 161 | ||
| 162 | /** `2h 13m`, `3d 4h`, `12m`. */ | |
| 163 | export function formatDuration(ms: number): string { | |
| 164 | const remaining = Math.max(0, ms); | |
| 165 | const days = Math.floor(remaining / DAY); | |
| 166 | const hours = Math.floor((remaining % DAY) / HOUR); | |
| 167 | const minutes = Math.floor((remaining % HOUR) / MINUTE); | |
| 168 | if (days > 0) return `${days}d ${hours}h`; | |
| 169 | if (hours > 0) return `${hours}h ${minutes}m`; | |
| 170 | return `${minutes}m`; | |
| 171 | } | |
| 172 | ||
| 173 | /** `resets in 2h 13m`, or null when the window has no reset. */ | |
| 174 | export function formatResetsIn(window: ServerProviderUsageWindow, now: number): string | null { | |
| 175 | const resetsAt = resetMillis(window); | |
| 176 | if (resetsAt === null) return null; | |
| 177 | return resetsAt <= now ? "resets now" : `resets in ${formatDuration(resetsAt - now)}`; | |
| 178 | } |