authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-03 17:47:54-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 17:47:54-07:00
log19d8ab2ae9fc562ee7b216a0d72903fbfafa9572
tree6b48da114bc1823cab0f0c80a6fd85b9a88681b4
parent0a0b6be96833adae68b36540c00d347eda278736
signature Signed by PGP key B5690EEEBB952194

feat(usage): show Codex and Claude subscription limits on a Limits tab (#9507)

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 @@
1import { useAtomValue } from "@effect/atom-react";
2import type {
3 ServerProvider,
4 ServerProviderUsageWindow,
5 UsageLimitSourceAccount,
6} from "@t3tools/contracts";
7import {
8 collectLimitSources,
9 collectLimitsGroups,
10 elapsedShare,
11 formatResetsIn,
12 limitsNotice,
13 paceOf,
14 providerLimitsLabel,
15} from "@t3tools/shared/usageLimits";
16import { useState } from "react";
17import { View } from "react-native";
18
19import { AppText as Text } from "../../components/AppText";
20import { environmentPresentations } from "../../state/presentation";
21import { SettingsSection } from "../settings/components/SettingsSection";
22
23const 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 */
29function 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
68function 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
95function 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
112const 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. */
115function 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 */
137export 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";
2121import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
2222import { SettingsSection } from "../settings/components/SettingsSection";
2323import { UsageDailyChart } from "./UsageDailyChart";
24import { UsageLimitsSection } from "./UsageLimitsSection";
2425import type { UsageChartMetric } from "./usageChartData";
2526import { PROVIDER_LABEL, useProviderColors } from "./usageProviders";
2627
......@@ -139,6 +140,7 @@ export function UsageRouteScreen() {
139140 timeZone={window.timeZone}
140141 />
141142 <ProviderSection merged={merged} metric={metric} />
143 <UsageLimitsSection />
142144 <TotalsSection merged={merged} isPast24Hours={isPast24Hours} />
143145 <ModelsSection merged={merged} />
144146 </>
apps/mobile/src/state/server.ts+1
......@@ -7,6 +7,7 @@ import { environmentSession } from "./session";
77
88export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, {
99 initialConfigValueAtom: environmentSession.initialConfigValueAtom,
10 usageLimitSources: true,
1011});
1112export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({
1213 catalogValueAtom: environmentCatalog.catalogValueAtom,
apps/server/src/environment/ServerEnvironment.ts+1
......@@ -221,6 +221,7 @@ export const make = Effect.gen(function* () {
221221 threadAutoSettlement: true,
222222 threadSnooze: true,
223223 environmentThemes: true,
224 usageLimitSources: true,
224225 threadPinning: true,
225226 threadPinReorder: true,
226227 threadTitleRegeneration: true,
apps/server/src/provider/Drivers/ClaudeDriver.ts+6
......@@ -29,6 +29,7 @@ import { ServerConfig } from "../../config.ts";
2929import { ServerSettingsService } from "../../serverSettings.ts";
3030import { ProviderDriverError } from "../Errors.ts";
3131import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts";
32import { makeClaudeScopedLimitNames } from "../Layers/claudeUsageLimits.ts";
3233import {
3334 checkClaudeProviderStatus,
3435 makePendingClaudeProvider,
......@@ -134,10 +135,14 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
134135 continuationGroupKey,
135136 });
136137
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;
137141 const adapterOptions = {
138142 instanceId,
139143 environment: processEnv,
140144 modelCatalog,
145 scopedLimitNames,
141146 ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
142147 };
143148 const adapter = yield* makeClaudeAdapter(effectiveConfig, adapterOptions);
......@@ -171,6 +176,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
171176 processEnv,
172177 cwd,
173178 resolveClaudeModelCatalog(manifest),
179 scopedLimitNames,
174180 ),
175181 ),
176182 Effect.map(stampIdentity),
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+82
......@@ -28,6 +28,7 @@ import * as Effect from "effect/Effect";
2828import * as Fiber from "effect/Fiber";
2929import * as Layer from "effect/Layer";
3030import * as Random from "effect/Random";
31import * as Ref from "effect/Ref";
3132import * as Schema from "effect/Schema";
3233import * as Stream from "effect/Stream";
3334import * as TestClock from "effect/testing/TestClock";
......@@ -44,6 +45,7 @@ import {
4445} from "../ClaudeModelCatalog.testFixtures.ts";
4546import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts";
4647import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
48import type { ClaudeScopedLimitNames } from "./claudeUsageLimits.ts";
4749import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts";
4850const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
4951const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
......@@ -163,6 +165,7 @@ function makeHarness(config?: {
163165 readonly baseDir?: string;
164166 readonly claudeConfig?: Partial<ClaudeSettings>;
165167 readonly instanceId?: ProviderInstanceId;
168 readonly scopedLimitNames?: ClaudeAdapterLiveOptions["scopedLimitNames"];
166169}) {
167170 const query = new FakeClaudeQuery();
168171 let createInput:
......@@ -174,6 +177,7 @@ function makeHarness(config?: {
174177
175178 const adapterOptions: ClaudeAdapterLiveOptions = {
176179 ...(config?.instanceId ? { instanceId: config.instanceId } : {}),
180 ...(config?.scopedLimitNames ? { scopedLimitNames: config.scopedLimitNames } : {}),
177181 modelCatalog: Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG),
178182 createQuery: (input) => {
179183 createInput = input;
......@@ -1239,6 +1243,84 @@ describe("ClaudeAdapterLive", () => {
12391243 );
12401244 });
12411245
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
12421324 it.effect("does not emit turn.completed for a result with no active turn", () => {
12431325 const harness = makeHarness();
12441326 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeAdapter.ts+9-3
......@@ -21,6 +21,7 @@ import {
2121} from "@anthropic-ai/claude-agent-sdk";
2222import { parseCliArgs } from "@t3tools/shared/cliArgs";
2323import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
24import { type ClaudeScopedLimitNames, claudeRateLimitEventToUpdate } from "./claudeUsageLimits.ts";
2425import {
2526 ApprovalRequestId,
2627 type CanonicalItemType,
......@@ -342,6 +343,8 @@ export interface ClaudeAdapterLiveOptions {
342343 readonly nativeEventLogPath?: string;
343344 readonly nativeEventLogger?: EventNdjsonLogger;
344345 readonly modelCatalog?: Effect.Effect<ClaudeModelCatalog>;
346 /** Scoped-bucket names the driver's status probe last saw; see `claudeUsageLimits`. */
347 readonly scopedLimitNames?: Ref.Ref<ClaudeScopedLimitNames>;
345348}
346349
347350function isUuid(value: string): boolean {
......@@ -3595,12 +3598,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
35953598 }
35963599
35973600 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;
35983606 yield* offerRuntimeEvent({
35993607 ...base,
36003608 type: "account.rate-limits.updated",
3601 payload: {
3602 rateLimits: message,
3603 },
3609 payload: { limits },
36043610 });
36053611 return;
36063612 }
apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts+27-14
......@@ -77,22 +77,31 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
7777 "const lines = createInterface({ input: process.stdin });",
7878 'lines.on("line", (line) => {',
7979 " 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({",
8282 ' 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 },',
9584 ' }) + "\\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 " }",
96105 "});",
97106 "setInterval(() => {}, 1_000);",
98107 "",
......@@ -122,6 +131,10 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
122131 input: { hint: "[path]" },
123132 },
124133 ],
134 usage: {
135 rate_limits_available: true,
136 rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } },
137 },
125138 });
126139
127140 // @effect-diagnostics-next-line preferSchemaOverJson:off
apps/server/src/provider/Layers/ClaudeProvider.ts+35
......@@ -8,6 +8,7 @@ import * as Effect from "effect/Effect";
88import * as FileSystem from "effect/FileSystem";
99import * as Option from "effect/Option";
1010import * as Path from "effect/Path";
11import * as Ref from "effect/Ref";
1112import * as Result from "effect/Result";
1213import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1314import { createModelCapabilities } from "@t3tools/shared/model";
......@@ -16,6 +17,7 @@ import {
1617 query as claudeQuery,
1718 type Options as ClaudeQueryOptions,
1819 type SlashCommand as ClaudeSlashCommand,
20 type SDKControlGetUsageResponse,
1921 type SDKUserMessage,
2022 type SettingSource,
2123} from "@anthropic-ai/claude-agent-sdk";
......@@ -32,6 +34,12 @@ import {
3234import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
3335import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
3436import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";
37import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts";
38import {
39 type ClaudeScopedLimitNames,
40 claudeUsageResponseToLimits,
41 recordClaudeUsageResponse,
42} from "./claudeUsageLimits.ts";
3543import {
3644 BUNDLED_CLAUDE_MODEL_CATALOG,
3745 type ClaudeModelCatalog,
......@@ -225,6 +233,12 @@ type ClaudeCapabilitiesProbe = {
225233 */
226234 readonly apiProvider: string | undefined;
227235 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">;
228242};
229243
230244function parseClaudeInitializationCommands(
......@@ -340,6 +354,15 @@ const probeClaudeCapabilities = (
340354 }),
341355 });
342356 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 );
343366 const account = init.account as
344367 | {
345368 readonly email?: string;
......@@ -354,6 +377,7 @@ const probeClaudeCapabilities = (
354377 tokenSource: account?.tokenSource,
355378 apiProvider: account?.apiProvider,
356379 slashCommands: parseClaudeInitializationCommands(init.commands),
380 ...(usage ? { usage } : {}),
357381 } satisfies ClaudeCapabilitiesProbe;
358382 });
359383 }).pipe(
......@@ -395,6 +419,8 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
395419 environment?: NodeJS.ProcessEnv,
396420 cwd?: string,
397421 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>,
398424): Effect.fn.Return<
399425 ServerProviderDraft,
400426 never,
......@@ -535,6 +561,14 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
535561 subscriptionType: capabilities.subscriptionType,
536562 authMethod: capabilities.tokenSource,
537563 }) ?? 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;
538572 return buildServerProvider({
539573 presentation: CLAUDE_PRESENTATION,
540574 enabled: claudeSettings.enabled,
......@@ -552,6 +586,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
552586 ...(authMetadata ? authMetadata : {}),
553587 },
554588 ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}),
589 usageLimits,
555590 },
556591 });
557592});
apps/server/src/provider/Layers/CodexAdapter.ts+8-4
......@@ -70,6 +70,7 @@ import {
7070} from "./CodexSessionRuntime.ts";
7171import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
7272import { resolveCodexLaunchArgs } from "./codexLaunchArgs.ts";
73import { codexRateLimitsToUpdate } from "./codexUsageLimits.ts";
7374const isCodexAppServerProcessExitedError = Schema.is(CodexErrors.CodexAppServerProcessExitedError);
7475const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTransportError);
7576const isCodexSessionRuntimeThreadIdMissingError = Schema.is(
......@@ -1747,16 +1748,19 @@ function mapToRuntimeEvents(
17471748 }
17481749
17491750 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) {
17511757 return [];
17521758 }
17531759 return [
17541760 {
17551761 type: "account.rate-limits.updated",
17561762 ...runtimeEventBase(event, canonicalThreadId),
1757 payload: {
1758 rateLimits: event.payload ?? {},
1759 },
1763 payload: { limits },
17601764 },
17611765 ];
17621766 }
apps/server/src/provider/Layers/CodexProvider.ts+46-3
......@@ -33,8 +33,19 @@ import {
3333 type ServerProviderDraft,
3434} from "../providerSnapshot.ts";
3535import { expandHomePath } from "../../pathExpansion.ts";
36import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts";
37import {
38 codexRateLimitsFailureMessage,
39 codexRateLimitsToLimits,
40 type CodexRateLimitSnapshot,
41} from "./codexUsageLimits.ts";
3642import packageJson from "../../../package.json" with { type: "json" };
3743const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError);
44const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000;
45
46type CodexRateLimitsProbe =
47 | { readonly snapshot: CodexRateLimitSnapshot }
48 | { readonly failure: string };
3849
3950const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const;
4051
......@@ -45,6 +56,7 @@ const CODEX_PRESENTATION = {
4556
4657export interface CodexAppServerProviderSnapshot {
4758 readonly account: CodexSchema.V2GetAccountResponse;
59 readonly rateLimits?: CodexRateLimitsProbe;
4860 readonly version: string | undefined;
4961 readonly models: ReadonlyArray<ServerProviderModel>;
5062 readonly skills: ReadonlyArray<ServerProviderSkill>;
......@@ -72,8 +84,12 @@ function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["accoun
7284 if (account.type === "apiKey") return "OpenAI API Key";
7385 if (account.type === "amazonBedrock") return "Amazon Bedrock";
7486 if (account.type !== "chatgpt") return undefined;
87 return codexPlanLabel(account.planType);
88}
7589
76 switch (account.planType) {
90/** Shared with usage-limit sources, which report the same `planType` slugs. */
91export function codexPlanLabel(planType: string | null | undefined): string | undefined {
92 switch (planType) {
7793 case "free":
7894 return "ChatGPT Free Subscription";
7995 case "go":
......@@ -102,7 +118,6 @@ function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["accoun
102118 case "unknown":
103119 return "ChatGPT Subscription";
104120 default:
105 account.planType satisfies never;
106121 return undefined;
107122 }
108123}
......@@ -394,18 +409,35 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
394409 } satisfies CodexAppServerProviderSnapshot;
395410 }
396411
397 const [skillsResponse, models] = yield* Effect.all(
412 const [skillsResponse, models, rateLimits] = yield* Effect.all(
398413 [
399414 client.request("skills/list", {
400415 cwds: [input.cwd],
401416 }),
402417 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 ),
403434 ],
404435 { concurrency: "unbounded" },
405436 );
406437
407438 return {
408439 account: accountResponse,
440 rateLimits,
409441 version,
410442 models: applyPreferredCodexDefaultModel(
411443 appendCustomCodexModels(models, input.customModels ?? []),
......@@ -640,6 +672,16 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
640672
641673 const snapshot = probeResult.success.value;
642674 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 });
643685
644686 return buildServerProvider({
645687 presentation: CODEX_PRESENTATION,
......@@ -660,6 +702,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
660702 status: accountStatus.status,
661703 auth: accountStatus.auth,
662704 ...(accountStatus.message ? { message: accountStatus.message } : {}),
705 usageLimits,
663706 },
664707 });
665708});
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+1
......@@ -118,6 +118,7 @@ const makeFakeInstance = (
118118 getSnapshot: Effect.succeed({} as unknown as ServerProvider),
119119 refresh: Effect.succeed({} as unknown as ServerProvider),
120120 streamChanges: Stream.empty,
121 applyUsageLimits: () => Effect.void,
121122 },
122123 adapter,
123124 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
10981098 Effect.andThen(Effect.never),
10991099 ),
11001100 streamChanges: Stream.empty,
1101 applyUsageLimits: () => Effect.void,
11011102 },
11021103 adapter: {} as ProviderInstance["adapter"],
11031104 textGeneration: {} as ProviderInstance["textGeneration"],
......@@ -1187,6 +1188,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
11871188 getSnapshot: Effect.succeed(provider),
11881189 refresh: Effect.succeed(provider),
11891190 streamChanges: Stream.empty,
1191 applyUsageLimits: () => Effect.void,
11901192 },
11911193 snapshotForCwd,
11921194 adapter: {} as ProviderInstance["adapter"],
......@@ -1377,6 +1379,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
13771379 Effect.as(codexProvider),
13781380 ),
13791381 streamChanges: Stream.empty,
1382 applyUsageLimits: () => Effect.void,
13801383 },
13811384 adapter: {} as ProviderInstance["adapter"],
13821385 textGeneration: {} as ProviderInstance["textGeneration"],
......@@ -1400,6 +1403,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
14001403 Effect.andThen(Ref.get(catalogSnapshot)),
14011404 ),
14021405 streamChanges: Stream.empty,
1406 applyUsageLimits: () => Effect.void,
14031407 },
14041408 adapter: {} as ProviderInstance["adapter"],
14051409 textGeneration: {} as ProviderInstance["textGeneration"],
......@@ -1521,6 +1525,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
15211525 getSnapshot: Effect.succeed(initialProvider),
15221526 refresh: Effect.succeed(refreshedProvider),
15231527 streamChanges: Stream.fromPubSub(changes),
1528 applyUsageLimits: () => Effect.void,
15241529 },
15251530 adapter: {} as ProviderInstance["adapter"],
15261531 textGeneration: {} as ProviderInstance["textGeneration"],
......@@ -1650,6 +1655,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
16501655 getSnapshot: Effect.succeed(initialProvider),
16511656 refresh: Effect.succeed(authoritativeProvider),
16521657 streamChanges: Stream.fromPubSub(changes),
1658 applyUsageLimits: () => Effect.void,
16531659 },
16541660 adapter: {} as ProviderInstance["adapter"],
16551661 textGeneration: {} as ProviderInstance["textGeneration"],
......@@ -1757,6 +1763,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
17571763 getSnapshot: Effect.succeed(cachedProvider),
17581764 refresh: Effect.die(new Error("simulated refresh failure")),
17591765 streamChanges: Stream.empty,
1766 applyUsageLimits: () => Effect.void,
17601767 },
17611768 adapter: {} as ProviderInstance["adapter"],
17621769 textGeneration: {} as ProviderInstance["textGeneration"],
......@@ -1850,6 +1857,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
18501857 getSnapshot: Effect.succeed(provider),
18511858 refresh: Effect.succeed(provider),
18521859 streamChanges: Stream.empty,
1860 applyUsageLimits: () => Effect.void,
18531861 },
18541862 adapter: {} as ProviderInstance["adapter"],
18551863 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 */
12import * as DateTime from "effect/DateTime";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import * as Stream from "effect/Stream";
16
17import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
18import { ProviderService } from "../Services/ProviderService.ts";
19
20export 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { claudeRateLimitEventToUpdate, claudeUsageResponseToLimits } from "./claudeUsageLimits.ts";
4
5const checkedAt = "2026-07-18T10:00:00.000Z";
6const noNames = { overageIncluded: undefined } as const;
7
8describe("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
122describe("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 */
13import type { SDKControlGetUsageResponse, SDKRateLimitInfo } from "@anthropic-ai/claude-agent-sdk";
14import type {
15 ProviderUsageLimitsUpdate,
16 ServerProviderUsageLimits,
17 ServerProviderUsageWindow,
18} from "@t3tools/contracts";
19import * as DateTime from "effect/DateTime";
20import * as Effect from "effect/Effect";
21import * as Option from "effect/Option";
22import * as Ref from "effect/Ref";
23
24import {
25 clampPercent,
26 makeUnavailableUsageLimits,
27 makeUsageLimits,
28} from "../providerUsageLimits.ts";
29
30const SESSION_MINS = 5 * 60;
31const 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 */
39const 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 */
53const OVERAGE_INCLUDED_EVENT_TYPE = "seven_day_overage_included";
54
55export interface ClaudeScopedLimitNames {
56 readonly overageIncluded: string | undefined;
57}
58
59export const makeClaudeScopedLimitNames = Ref.make<ClaudeScopedLimitNames>({
60 overageIncluded: undefined,
61});
62
63function scopedWindowId(displayName: string): string {
64 return `seven_day_${displayName.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`;
65}
66
67function 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 */
86interface ModelScopedWindow {
87 readonly display_name: string;
88 readonly utilization: number | null;
89 readonly resets_at: string | null;
90}
91
92function 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
103function 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
109function 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
115function 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 */
134export 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 */
157export 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. */
193export 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 @@
1import * as CodexErrors from "effect-codex-app-server/errors";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 codexRateLimitsFailureMessage,
6 codexRateLimitsToLimits,
7 codexRateLimitsToUpdate,
8} from "./codexUsageLimits.ts";
9
10const checkedAt = "2026-07-18T10:00:00.000Z";
11
12describe("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
64describe("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
85describe("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 */
9import type {
10 ProviderUsageLimitsUpdate,
11 ServerProviderUsageLimits,
12 ServerProviderUsageWindow,
13} from "@t3tools/contracts";
14import * as DateTime from "effect/DateTime";
15import * as Option from "effect/Option";
16import type * as CodexErrors from "effect-codex-app-server/errors";
17
18import { clampPercent, makeUsageLimits } from "../providerUsageLimits.ts";
19
20interface 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. */
27export interface CodexRateLimitSnapshot {
28 readonly planType?: string | null;
29 readonly primary?: CodexRateLimitWindow | null;
30 readonly secondary?: CodexRateLimitWindow | null;
31}
32
33const SESSION_MINS = 5 * 60;
34const WEEK_MINS = 7 * 24 * 60;
35const MONTH_MINS = 30 * 24 * 60;
36
37function 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
43function kindForDuration(mins: number): ServerProviderUsageWindow["kind"] {
44 if (mins >= MONTH_MINS) return "monthly";
45 if (mins >= WEEK_MINS) return "weekly";
46 return "session";
47}
48
49function 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 */
58export 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
85export 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
95export 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 */
107export 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 @@
1import type { ServerProvider } from "@t3tools/contracts";
1import type { ProviderUsageLimitsUpdate, ServerProvider } from "@t3tools/contracts";
22import type * as Effect from "effect/Effect";
33import type * as Stream from "effect/Stream";
44import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
......@@ -8,4 +8,12 @@ export interface ServerProviderShape {
88 readonly getSnapshot: Effect.Effect<ServerProvider>;
99 readonly refresh: Effect.Effect<ServerProvider>;
1010 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>;
1119}
apps/server/src/provider/makeManagedServerProvider.test.ts+133
......@@ -522,4 +522,137 @@ describe("makeManagedServerProvider", () => {
522522 }),
523523 ).pipe(Effect.provide(AlwaysRunTestLayer)),
524524 );
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 );
525658});
apps/server/src/provider/makeManagedServerProvider.ts+67-21
......@@ -17,6 +17,7 @@ import * as Semaphore from "effect/Semaphore";
1717
1818import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
1919import { ServerSettingsService } from "../serverSettings.ts";
20import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "./providerUsageLimits.ts";
2021import type { ServerProviderShape } from "./Services/ServerProvider.ts";
2122
2223interface ProviderSnapshotState {
......@@ -24,6 +25,17 @@ interface ProviderSnapshotState {
2425 readonly enrichmentGeneration: number;
2526}
2627
28function 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
2739export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* <
2840 Settings,
2941>(input: {
......@@ -69,16 +81,16 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(
6981 nextSnapshot: ServerProvider,
7082 ) {
7183 const snapshotToPublish = yield* Ref.modify(snapshotStateRef, (state) => {
72 if (state.enrichmentGeneration !== generation || Equal.equals(state.snapshot, nextSnapshot)) {
84 if (state.enrichmentGeneration !== generation) {
7385 return [null, state] as const;
7486 }
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;
8294 });
8395 if (snapshotToPublish === null) {
8496 return;
......@@ -138,19 +150,26 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(
138150 return state.snapshot;
139151 }
140152
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 );
154173 yield* Ref.set(settingsRef, nextSettings);
155174 yield* PubSub.publish(changesPubSub, nextSnapshot);
156175 yield* restartSnapshotEnrichment(nextSettings, nextSnapshot, nextGeneration);
......@@ -159,6 +178,32 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(
159178 const applySnapshot = (nextSettings: Settings, options?: { readonly forceRefresh?: boolean }) =>
160179 refreshSemaphore.withPermits(1)(applySnapshotBase(nextSettings, options));
161180
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
162207 const refreshSnapshot = Effect.fn("refreshSnapshot")(function* () {
163208 const nextSettings = yield* input.getSettings;
164209 return yield* applySnapshot(nextSettings, { forceRefresh: true });
......@@ -241,6 +286,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(
241286 maintenanceCapabilities: input.maintenanceCapabilities,
242287 getSnapshot: Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)),
243288 refresh: refreshSnapshot().pipe(Effect.tapError(Effect.logError), Effect.orDie),
289 applyUsageLimits,
244290 get streamChanges() {
245291 return Stream.fromPubSub(changesPubSub);
246292 },
apps/server/src/provider/providerSnapshot.ts+3
......@@ -7,6 +7,7 @@ import type {
77 ServerProviderSlashCommand,
88 ServerProviderModel,
99 ServerProviderState,
10 ServerProviderUsageLimits,
1011} from "@t3tools/contracts";
1112import * as Effect from "effect/Effect";
1213import * as PlatformError from "effect/PlatformError";
......@@ -50,6 +51,7 @@ export interface ProviderProbeResult {
5051 readonly status: Exclude<ServerProviderState, "disabled">;
5152 readonly auth: ServerProviderAuth;
5253 readonly message?: string;
54 readonly usageLimits?: ServerProviderUsageLimits;
5355}
5456
5557export interface ServerProviderPresentation {
......@@ -249,6 +251,7 @@ export function buildServerProvider(input: {
249251 models: input.models,
250252 slashCommands: [...(input.slashCommands ?? [])],
251253 skills: [...(input.skills ?? [])],
254 ...(input.probe.usageLimits ? { usageLimits: input.probe.usageLimits } : {}),
252255 ...(versionAdvisory ? { versionAdvisory } : {}),
253256 };
254257}
apps/server/src/provider/providerUsageLimits.test.ts created+74
......@@ -0,0 +1,74 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "./providerUsageLimits.ts";
4
5const checkedAt = "2026-09-03T12:00:00.000Z";
6const 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;
14const weekly = {
15 id: "seven_day",
16 kind: "weekly",
17 label: "Weekly",
18 usedPercent: 20,
19 windowDurationMins: 10_080,
20} as const;
21const published = { checkedAt, windows: [session, weekly] };
22
23describe("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
66describe("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 @@
1import type {
2 ProviderUsageLimitsUpdate,
3 ServerProviderUsageLimits,
4 ServerProviderUsageWindow,
5} from "@t3tools/contracts";
6
7const WINDOW_KIND_ORDER: Record<ServerProviderUsageWindow["kind"], number> = {
8 session: 0,
9 weekly: 1,
10 monthly: 2,
11 other: 3,
12};
13
14export function clampPercent(value: number): number {
15 return Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0;
16}
17
18function 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
28export 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
35export 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 */
60export 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
97function 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 */
122export 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 {
103103import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
104104import * as GitManager from "./git/GitManager.ts";
105105import * as EnvironmentTheme from "./environmentTheme.ts";
106import * as UsageLimitSources from "./usage/UsageLimitSources.ts";
106107import * as Keybindings from "./keybindings.ts";
107108import * as ExternalLauncher from "./process/externalLauncher.ts";
108109import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
......@@ -744,6 +745,11 @@ const buildAppUnderTest = (options?: {
744745 streamChanges: Stream.empty,
745746 ...options?.layers?.environmentTheme,
746747 }),
748 Layer.mock(UsageLimitSources.UsageLimitSources)({
749 current: Effect.succeed([]),
750 streamChanges: Stream.empty,
751 refresh: Effect.void,
752 }),
747753 ),
748754 ),
749755 Layer.provide(
apps/server/src/server.ts+8-1
......@@ -41,6 +41,7 @@ import { AntigravityInstallation } from "./provider/AntigravityInstallation.ts";
4141import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts";
4242import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts";
4343import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts";
44import { ProviderUsageLimitsIngestionLive } from "./provider/Layers/ProviderUsageLimitsIngestion.ts";
4445import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts";
4546import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
4647import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";
......@@ -118,6 +119,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien
118119import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts";
119120import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts";
120121import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
122import * as UsageLimitSources from "./usage/UsageLimitSources.ts";
121123import * as UsageService from "./usage/UsageService.ts";
122124import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
123125import {
......@@ -407,6 +409,9 @@ const CloudManagedEndpointRuntimeLive = Layer.mergeAll(
407409);
408410
409411const 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),
410415 Layer.provideMerge(ProviderLayerLive),
411416 Layer.provideMerge(OrchestrationLayerLive),
412417);
......@@ -454,7 +459,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
454459 Layer.provideMerge(PersistenceLayerLive),
455460 // Both read a user-owned file out of the state directory and stream changes
456461 // 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 ),
458465 Layer.provideMerge(ProviderRegistryLive),
459466 // The instance registry is the new routing keystone — text generation,
460467 // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId`
apps/server/src/serverSettings.ts+85-1
......@@ -18,6 +18,7 @@ import {
1818 type ModelSelection,
1919 type ProviderInstanceConfig,
2020 type ProviderInstanceEnvironmentVariable,
21 type UsageLimitSourceConfig,
2122 ProviderDriverKind,
2223 ProviderInstanceId,
2324 ServerSettings,
......@@ -133,6 +134,17 @@ function providerEnvironmentSecretName(input: {
133134 return `provider-env-${Buffer.from(input.instanceId, "utf8").toString("base64url")}-${Buffer.from(input.name, "utf8").toString("base64url")}`;
134135}
135136
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 */
142const USAGE_LIMIT_SOURCE_KEY_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022";
143
144export function usageLimitSourceSecretName(sourceId: string): string {
145 return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`;
146}
147
136148function redactProviderEnvironmentVariable(
137149 variable: ProviderInstanceEnvironmentVariable,
138150): ProviderInstanceEnvironmentVariable {
......@@ -159,7 +171,17 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS
159171 : instance,
160172 ]),
161173 );
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 };
163185}
164186
165187export class ServerSettingsService extends Context.Service<
......@@ -512,9 +534,28 @@ const make = Effect.gen(function* () {
512534 environment,
513535 } satisfies ProviderInstanceConfig;
514536 }
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 }
515555 return {
516556 ...settings,
517557 providerInstances: providerInstances as ServerSettings["providerInstances"],
558 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],
518559 };
519560 });
520561
......@@ -630,9 +671,52 @@ const make = Effect.gen(function* () {
630671 }
631672 }
632673
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
633716 return {
634717 ...next,
635718 providerInstances: providerInstances as ServerSettings["providerInstances"],
719 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],
636720 };
637721 });
638722
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 */
14import {
15 DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL,
16 type ServerSettings,
17 type UsageLimitSourceConfig,
18 type UsageLimitSourceId,
19 type UsageLimitSourceSnapshot,
20} from "@t3tools/contracts";
21import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
22import type * as Cause from "effect/Cause";
23import * as Context from "effect/Context";
24import * as Data from "effect/Data";
25import * as DateTime from "effect/DateTime";
26import * as Duration from "effect/Duration";
27import * as Effect from "effect/Effect";
28import * as Equal from "effect/Equal";
29import * as Layer from "effect/Layer";
30import * as PubSub from "effect/PubSub";
31import * as Ref from "effect/Ref";
32import * as Semaphore from "effect/Semaphore";
33import type * as Schema from "effect/Schema";
34import * as Stream from "effect/Stream";
35import { HttpClient, type HttpClientError, HttpClientResponse } from "effect/unstable/http";
36
37import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
38import { ServerSettingsService } from "../serverSettings.ts";
39import { cliproxyStatusToAccounts, decodeCliproxyQuotaStatus } from "./cliproxyUsageLimits.ts";
40
41const FETCH_TIMEOUT = "10 seconds";
42const QUOTA_STATUS_PATH = "/v0/management/quota-scheduler/status";
43
44export 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 */
59function 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
76class InvalidUrl extends Data.TaggedError("InvalidUrl")<{
77 readonly url: string;
78 readonly cause: unknown;
79}> {}
80
81function 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
90export 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
202export const layer = Layer.effect(UsageLimitSources, make);
apps/server/src/usage/cliproxyUsageLimits.test.ts created+118
......@@ -0,0 +1,118 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { accountEmailFromAuthFile, cliproxyStatusToAccounts } from "./cliproxyUsageLimits.ts";
4
5const checkedAt = "2026-09-03T22:00:00.000Z";
6
7describe("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
107describe("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 */
11import {
12 ProviderDriverKind,
13 type ServerProviderUsageLimits,
14 type ServerProviderUsageWindow,
15 type UsageLimitSourceAccount,
16} from "@t3tools/contracts";
17import * as DateTime from "effect/DateTime";
18import * as Option from "effect/Option";
19import * as Schema from "effect/Schema";
20
21import { codexPlanLabel } from "../provider/Layers/CodexProvider.ts";
22import { clampPercent, makeUsageLimits } from "../provider/providerUsageLimits.ts";
23
24const 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
31const 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
41export const CliproxyQuotaStatus = Schema.Struct({
42 accounts: Schema.Record(Schema.String, QuotaAccount),
43});
44export type CliproxyQuotaStatus = typeof CliproxyQuotaStatus.Type;
45export const decodeCliproxyQuotaStatus = Schema.decodeUnknownEffect(CliproxyQuotaStatus);
46
47const SESSION_MINS = 5 * 60;
48const WEEK_MINS = 7 * 24 * 60;
49
50/** Window ids match what the provider drivers emit, so rows read the same across sources. */
51const 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
88const DRIVER_BY_HUB_PROVIDER: Readonly<Record<string, ProviderDriverKind>> = {
89 claude: ProviderDriverKind.make("claudeAgent"),
90 codex: ProviderDriverKind.make("codex"),
91};
92
93function 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`. */
100export 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 */
111function 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
117export 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
139export 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";
127127import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
128128import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
129129import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
130import * as UsageLimitSources from "./usage/UsageLimitSources.ts";
130131import * as UsageService from "./usage/UsageService.ts";
131132import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
132133import * as PullRequestService from "./pullRequest/PullRequestService.ts";
......@@ -501,6 +502,7 @@ const makeWsRpcLayer = (
501502 const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery;
502503 const keybindings = yield* Keybindings.Keybindings;
503504 const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService;
505 const usageLimitSources = yield* UsageLimitSources.UsageLimitSources;
504506 const externalLauncher = yield* ExternalLauncher.ExternalLauncher;
505507 const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets;
506508 const gitWorkflow = yield* GitWorkflowService.GitWorkflowService;
......@@ -1676,6 +1678,13 @@ const makeWsRpcLayer = (
16761678 observeRpcEffect(
16771679 WS_METHODS.serverRefreshProviders,
16781680 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 }
16791688 let providers = yield* input.cwd !== undefined && input.instanceId !== undefined
16801689 ? providerRegistry.refreshWorkspaceSnapshot({
16811690 instanceId: input.instanceId,
......@@ -2573,6 +2582,17 @@ const makeWsRpcLayer = (
25732582 })),
25742583 )
25752584 : 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;
25762596 const settingsUpdates = serverSettings.streamChanges.pipe(
25772597 Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)),
25782598 Stream.map((settings) => ({
......@@ -2590,7 +2610,10 @@ const makeWsRpcLayer = (
25902610 keybindingsUpdates,
25912611 Stream.merge(
25922612 providerStatuses,
2593 Stream.merge(settingsUpdates, environmentThemeUpdates),
2613 Stream.merge(
2614 settingsUpdates,
2615 Stream.merge(environmentThemeUpdates, usageLimitSourceUpdates),
2616 ),
25942617 ),
25952618 );
25962619
apps/web/src/components/usage/AddUsageLimitSourceDialog.tsx created+153
......@@ -0,0 +1,153 @@
1import { UsageLimitSourceId } from "@t3tools/contracts";
2import { useState } from "react";
3
4import { useUpdatePrimarySettings } from "../../hooks/useSettings";
5import { Button } from "../ui/button";
6import {
7 Dialog,
8 DialogDescription,
9 DialogFooter,
10 DialogHeader,
11 DialogPanel,
12 DialogPopup,
13 DialogTitle,
14} from "../ui/dialog";
15import { Input } from "../ui/input";
16import { 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 */
23function 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 */
42export 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 @@
1import type {
2 ServerProvider,
3 ServerProviderUsageWindow,
4 UsageLimitSourceAccount,
5 UsageLimitSourceId,
6 UsageLimitSourceSnapshot,
7 UsageProviderKind,
8} from "@t3tools/contracts";
9import { useAtomValue } from "@effect/atom-react";
10import {
11 collectLimitSources,
12 collectLimitsGroups,
13 elapsedShare,
14 formatResetsIn,
15 limitsNotice,
16 type LimitPace,
17 paceOf,
18 providerLimitsLabel,
19} from "@t3tools/shared/usageLimits";
20import { GaugeIcon, PlusIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react";
21import { Fragment, useState } from "react";
22
23import {
24 usePrimarySettings,
25 usePrimarySettingsAvailable,
26 useUpdatePrimarySettings,
27} from "../../hooks/useSettings";
28import { usePrimaryEnvironmentId } from "../../state/environments";
29import { environmentPresentations } from "../../state/presentation";
30import { formatUpcomingTimestamp } from "../../timestampFormat";
31import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
32import { getDriverOption } from "../settings/providerDriverMeta";
33import { RedactedSensitiveText } from "../settings/RedactedSensitiveText";
34import {
35 AlertDialog,
36 AlertDialogClose,
37 AlertDialogDescription,
38 AlertDialogFooter,
39 AlertDialogHeader,
40 AlertDialogPopup,
41 AlertDialogTitle,
42} from "../ui/alert-dialog";
43import { Badge } from "../ui/badge";
44import { Button } from "../ui/button";
45import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
46import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog";
47import { PROVIDER_PRESENTATION } from "./usageProviders";
48
49const 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. */
56function 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. */
63function 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 */
89function 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. */
158function 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 */
201function 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
246function 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. */
275function 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 */
308const 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 */
317function 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
358function 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 */
394export 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";
66
77import { isElectron } from "../../env";
88import { cn } from "../../lib/utils";
9import { usePrimaryEnvironmentId } from "../../state/environments";
10import { serverEnvironment } from "../../state/server";
911import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
12import { useAtomCommand } from "../../state/use-atom-command";
1013import {
1114 enumerateDays,
1215 enumerateHourStarts,
......@@ -32,9 +35,21 @@ import {
3235} from "../WorkspaceBreadcrumb";
3336import { WorkspacePageContainer } from "../WorkspacePageContainer";
3437import { WorkspacePageHeader } from "../WorkspacePageHeader";
38import { UsageLimitsSection } from "./UsageLimits";
3539import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
3640import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders";
3741
42type UsageMetric = UsageChartMetric | "limits";
43const 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
49function isUsageMetric(value: string | null | undefined): value is UsageMetric {
50 return METRIC_OPTIONS.some((option) => option.value === value);
51}
52
3853const WINDOW_OPTIONS = [
3954 { days: 1, label: "Past 24h" },
4055 { days: 7, label: "7 days" },
......@@ -47,11 +62,16 @@ export function UsagePage() {
4762 days: 30,
4863 window: makeWindow(30),
4964 }));
50 const [metric, setMetric] = useState<UsageChartMetric>("cost");
65 const [metric, setMetric] = useState<UsageMetric>("cost");
66 const showingLimits = metric === "limits";
5167 const [breakdown, setBreakdown] = useState<"model" | "time">("model");
5268 const { days: windowDays, window } = windowSelection;
5369 const isPast24Hours = windowDays === 1;
5470 const { merged, environments, isPending, isPartial, refresh } = useUsage(window);
71 const primaryEnvironmentId = usePrimaryEnvironmentId();
72 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
73 reportFailure: false,
74 });
5575
5676 // Hold the content until every environment is terminal. Rendering merged
5777 // totals while devices are still answering makes every number on the page
......@@ -94,6 +114,15 @@ export function UsagePage() {
94114 });
95115 };
96116 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 }
97126 const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day");
98127 if (
99128 nextWindow.sinceDay === window.sinceDay &&
......@@ -116,10 +145,14 @@ export function UsagePage() {
116145 <WorkspaceBreadcrumbItem current>
117146 <h1>Usage</h1>
118147 </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 )}
123156 </WorkspaceBreadcrumb>
124157 <div className="ms-auto hidden min-w-0 items-center justify-end gap-2 lg:flex">
125158 <ToggleGroup
......@@ -128,19 +161,22 @@ export function UsagePage() {
128161 value={[metric]}
129162 onValueChange={(next) => {
130163 const value = next[0];
131 if (value === "cost" || value === "tokens") setMetric(value);
164 if (isUsageMetric(value)) setMetric(value);
132165 }}
133166 >
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}
137170 </Toggle>
138171 ))}
139172 </ToggleGroup>
173 {/* The period does not apply to Limits, so it stays in place but
174 disabled; unmounting it shifted the metric toggle ~300px. */}
140175 <ToggleGroup
141176 aria-label="Usage period"
142177 variant="segmented"
143178 value={[String(windowDays)]}
179 disabled={showingLimits}
144180 onValueChange={(next) => {
145181 const value = next[0];
146182 if (value) selectWindow(Number(value));
......@@ -152,7 +188,12 @@ export function UsagePage() {
152188 </Toggle>
153189 ))}
154190 </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 >
156197 <RefreshCwIcon className="size-3.5" />
157198 </Button>
158199 </div>
......@@ -160,7 +201,7 @@ export function UsagePage() {
160201 <Select
161202 value={metric}
162203 onValueChange={(value) => {
163 if (value === "cost" || value === "tokens") setMetric(value);
204 if (isUsageMetric(value)) setMetric(value);
164205 }}
165206 >
166207 <SelectTrigger
......@@ -169,14 +210,23 @@ export function UsagePage() {
169210 variant="ghost"
170211 className="w-auto min-w-0"
171212 >
172 <SelectValue>{metric === "cost" ? "Cost" : "Tokens"}</SelectValue>
213 <SelectValue>
214 {METRIC_OPTIONS.find((option) => option.value === metric)?.label}
215 </SelectValue>
173216 </SelectTrigger>
174217 <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 ))}
177223 </SelectPopup>
178224 </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 >
180230 <SelectTrigger
181231 aria-label="Usage period"
182232 size="compact"
......@@ -195,7 +245,12 @@ export function UsagePage() {
195245 ))}
196246 </SelectPopup>
197247 </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 >
199254 <RefreshCwIcon className="size-3.5" />
200255 </Button>
201256 </div>
......@@ -209,7 +264,9 @@ export function UsagePage() {
209264
210265 <ScrollArea className="min-h-0 flex-1">
211266 <WorkspacePageContainer width="wide">
212 {settling ? (
267 {showingLimits ? (
268 <UsageLimitsSection />
269 ) : settling ? (
213270 <>
214271 {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null}
215272 <UsageSkeleton />
apps/web/src/state/server.ts+1
......@@ -28,6 +28,7 @@ import { environmentSession } from "./session";
2828export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, {
2929 initialConfigValueAtom: environmentSession.initialConfigValueAtom,
3030 environmentThemes: true,
31 usageLimitSources: true,
3132});
3233export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({
3334 catalogValueAtom: environmentCatalog.catalogValueAtom,
apps/web/src/timestampFormat.ts+26
......@@ -159,6 +159,32 @@ export function formatDayAwareTimestamp(
159159 return `${dateFormatter.format(date)} ${time}`;
160160}
161161
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 */
167export 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
162188/**
163189 * Format a relative time string from an ISO date.
164190 * 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],
117117
118118A 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].
119119
120#### Usage limits
121
122The 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
126A 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
120128#### Model manifest
121129
122130The 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.
88Grok Build totals come from persisted session updates. Interactive turns that never wrote a
99completed-turn record will not appear.
1010
11The **Limits** view shows how much of each subscription window you have used on Codex and Claude
12Code, per connected environment: the session and weekly windows, plus a per-model weekly window
13such as Fable when your plan has one. Each window is a bar from the moment it opened to its reset,
14filled by the share of quota spent; a thin line marks how far into the window you are, which is
15also where even spending would have put the fill, and the icon beside the label says whether you
16are ahead of, on, or under that pace. Hover a bar for the exact reset time. Limits refresh on the
17provider health-check interval and update live while a turn runs. API-key accounts have no
18subscription windows and say so; that includes a Claude Code that reaches Anthropic through a proxy
19via `ANTHROPIC_AUTH_TOKEN`, since the CLI then treats itself as an API-key client.
20
21If you pool accounts behind a CLIProxyAPI hub, **Add CLIProxyAPI hub** on the Limits view shows
22every account the hub manages, each marked _via CLIProxyAPI_ so it is not mistaken for the provider
23signed in on this machine. Enter the hub's URL and management key; the key is stored on the server
24and never sent back to a client. Emails are blurred until clicked, as in provider settings.
25
1126Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**,
1227**30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the
1328headline 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", () => {
714714 clearVcsRefs: () => Effect.void,
715715 clear: () => Effect.void,
716716 });
717 const configState = yield* makeEnvironmentServerConfigState(true).pipe(
717 const configState = yield* makeEnvironmentServerConfigState({
718 environmentThemes: true,
719 }).pipe(
718720 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
719721 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
720722 );
packages/client-runtime/src/state/server.test.ts+2-2
......@@ -590,7 +590,7 @@ describe("server state projection", () => {
590590
591591 yield* Effect.scoped(
592592 Effect.gen(function* () {
593 const state = yield* makeEnvironmentServerConfigState().pipe(
593 const state = yield* makeEnvironmentServerConfigState({}).pipe(
594594 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
595595 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
596596 );
......@@ -650,7 +650,7 @@ describe("server state projection", () => {
650650 });
651651
652652 yield* Effect.scoped(
653 makeEnvironmentServerConfigState().pipe(
653 makeEnvironmentServerConfigState({}).pipe(
654654 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
655655 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
656656 ),
packages/client-runtime/src/state/server.ts+20-8
......@@ -353,8 +353,13 @@ const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEven
353353 config,
354354});
355355
356export interface ServerConfigSubscriptionOptions {
357 readonly environmentThemes?: boolean;
358 readonly usageLimitSources?: boolean;
359}
360
356361export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")(
357 function* (environmentThemes?: boolean) {
362 function* (subscription: ServerConfigSubscriptionOptions) {
358363 const supervisor = yield* EnvironmentSupervisor;
359364 const cache = yield* EnvironmentCacheStore;
360365 const environmentId = supervisor.target.environmentId;
......@@ -415,10 +420,10 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf
415420 Effect.forkScoped,
416421 );
417422
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(
422427 Stream.runForEach((event) =>
423428 Effect.gen(function* () {
424429 const next = applyServerConfigProjection(yield* SubscriptionRef.get(state), event);
......@@ -450,12 +455,12 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf
450455
451456export function serverConfigStateChanges(
452457 environmentId: EnvironmentId,
453 environmentThemes?: boolean,
458 subscription: ServerConfigSubscriptionOptions,
454459) {
455460 return followStreamInEnvironment(
456461 environmentId,
457462 Stream.unwrap(
458 makeEnvironmentServerConfigState(environmentThemes).pipe(
463 makeEnvironmentServerConfigState(subscription).pipe(
459464 Effect.map((state) =>
460465 SubscriptionRef.changes(state).pipe(
461466 Stream.filterMap((projection) =>
......@@ -514,6 +519,8 @@ export function createServerEnvironmentAtoms<R, E>(
514519 * receives the payload.
515520 */
516521 readonly environmentThemes?: boolean;
522 /** Whether this surface renders quota from configured usage-limit sources. */
523 readonly usageLimitSources?: boolean;
517524 },
518525) {
519526 const configScheduler = createAtomCommandScheduler();
......@@ -525,7 +532,12 @@ export function createServerEnvironmentAtoms<R, E>(
525532 };
526533 const configProjectionFamily = Atom.family((environmentId: EnvironmentId) =>
527534 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 )
529541 .pipe(
530542 Atom.setIdleTTL(5 * 60_000),
531543 Atom.withLabel(`environment-data:server:config-projection:${environmentId}`),
packages/client-runtime/src/state/serverConfigProjection.ts+28-8
......@@ -9,12 +9,15 @@ export interface ServerConfigProjection {
99
1010/**
1111 * 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.
1415 */
1516export 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;
1821 return rest;
1922}
2023
......@@ -27,13 +30,21 @@ export function applyServerConfigProjection(
2730 // Wire snapshots never contain published themes. Keep the previous set
2831 // until a capable server sends its authoritative theme event. A legacy
2932 // 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)
3236 ? current.value.config.environmentThemes
3337 : undefined;
38 const carriedSources =
39 capabilities.usageLimitSources === true && Option.isSome(current)
40 ? current.value.config.usageLimitSources
41 : undefined;
3442 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 },
3748 latestEvent: event,
3849 source: "live" as const,
3950 });
......@@ -75,5 +86,14 @@ export function applyServerConfigProjection(
7586 latestEvent: event,
7687 source: "live",
7788 }));
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 }));
7898 }
7999}
packages/contracts/src/environment.ts+3
......@@ -102,6 +102,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
102102 client reconnecting to one must drop published themes rather than keep
103103 showing a set nothing will ever update. */
104104 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),
105108 /** Server understands thread.pin / thread.unpin commands. Same
106109 version-skew contract as threadSettlement. */
107110 threadPinning: Schema.optionalKey(Schema.Boolean),
packages/contracts/src/index.ts+2
......@@ -14,6 +14,8 @@ export * from "./provider.ts";
1414export * from "./providerInstance.ts";
1515export * from "./providerSetup.ts";
1616export * from "./providerRuntime.ts";
17export * from "./providerUsageLimits.ts";
18export * from "./usageLimitSourceId.ts";
1719export * from "./model.ts";
1820export * from "./keybindings.ts";
1921export * from "./server.ts";
packages/contracts/src/providerRuntime.ts+6-1
......@@ -14,6 +14,7 @@ import {
1414 TurnId,
1515} from "./baseSchemas.ts";
1616import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts";
17import { ProviderUsageLimitsUpdate } from "./providerUsageLimits.ts";
1718import { ProviderApprovalOption } from "./orchestration.ts";
1819
1920const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
......@@ -750,8 +751,12 @@ const AccountUpdatedPayload = Schema.Struct({
750751});
751752export type AccountUpdatedPayload = typeof AccountUpdatedPayload.Type;
752753
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 */
753758const AccountRateLimitsUpdatedPayload = Schema.Struct({
754 rateLimits: Schema.Unknown,
759 limits: ProviderUsageLimitsUpdate,
755760});
756761export type AccountRateLimitsUpdatedPayload = typeof AccountRateLimitsUpdatedPayload.Type;
757762
packages/contracts/src/providerUsageLimits.ts created+92
......@@ -0,0 +1,92 @@
1import * as Schema from "effect/Schema";
2
3import {
4 ForwardCompatibleArray,
5 IsoDateTime,
6 NonNegativeInt,
7 TrimmedNonEmptyString,
8} from "./baseSchemas.ts";
9import { ProviderDriverKind } from "./providerInstance.ts";
10import { 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 */
20export 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});
28export 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 */
37export 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});
47export 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 */
55export const ProviderUsageLimitsUpdate = Schema.Struct({
56 windows: Schema.Array(ServerProviderUsageWindow),
57});
58export 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 */
65export 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});
74export 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 */
81export 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});
89export type UsageLimitSourceSnapshot = typeof UsageLimitSourceSnapshot.Type;
90
91export const UsageLimitSourceSnapshots = ForwardCompatibleArray(UsageLimitSourceSnapshot);
92export type UsageLimitSourceSnapshots = typeof UsageLimitSourceSnapshots.Type;
packages/contracts/src/rpc.ts+2
......@@ -1116,6 +1116,8 @@ export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerCon
11161116 * dropped by old servers.
11171117 */
11181118 environmentThemes: Schema.optional(Schema.Boolean),
1119 /** Whether this client understands `usageLimitSourcesUpdated` events. */
1120 usageLimitSources: Schema.optional(Schema.Boolean),
11191121 }),
11201122 success: ServerConfigStreamEvent,
11211123 error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]),
packages/contracts/src/server.test.ts+18
......@@ -157,6 +157,24 @@ describe("server config forward compatibility", () => {
157157
158158 expect(parsed).toEqual([decodedBase]);
159159 });
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 });
160178});
161179
162180describe("resolveEnvironmentMachineKind", () => {
packages/contracts/src/server.ts+25
......@@ -24,6 +24,7 @@ import {
2424import { EditorId, FileManagerRevealKind, RemoteOpenTarget } from "./editor.ts";
2525import { ModelCapabilities } from "./model.ts";
2626import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
27import { ServerProviderUsageLimits, UsageLimitSourceSnapshots } from "./providerUsageLimits.ts";
2728import { ServerSettings } from "./settings.ts";
2829
2930const KeybindingsMalformedConfigIssue = Schema.Struct({
......@@ -227,6 +228,8 @@ export const ServerProvider = Schema.Struct({
227228 ),
228229 skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
229230 workspaceSnapshots: Schema.optionalKey(Schema.Array(ServerProviderWorkspaceSnapshot)),
231 // Absent when the driver has no notion of subscription usage.
232 usageLimits: Schema.optional(ServerProviderUsageLimits),
230233 versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory),
231234 updateState: Schema.optionalKey(ServerProviderUpdateState),
232235});
......@@ -581,6 +584,12 @@ export const ServerConfig = Schema.Struct({
581584 * and it stays absent for subscribers that did not opt in.
582585 */
583586 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),
584593});
585594export type ServerConfig = typeof ServerConfig.Type;
586595
......@@ -692,12 +701,28 @@ export const ServerConfigStreamEnvironmentThemesUpdatedEvent = Schema.Struct({
692701export type ServerConfigStreamEnvironmentThemesUpdatedEvent =
693702 typeof ServerConfigStreamEnvironmentThemesUpdatedEvent.Type;
694703
704export const ServerConfigUsageLimitSourcesUpdatedPayload = Schema.Struct({
705 /** The full set; empty once no source is configured. */
706 sources: UsageLimitSourceSnapshots,
707});
708export type ServerConfigUsageLimitSourcesUpdatedPayload =
709 typeof ServerConfigUsageLimitSourcesUpdatedPayload.Type;
710
711export const ServerConfigStreamUsageLimitSourcesUpdatedEvent = Schema.Struct({
712 version: Schema.Literal(1),
713 type: Schema.Literal("usageLimitSourcesUpdated"),
714 payload: ServerConfigUsageLimitSourcesUpdatedPayload,
715});
716export type ServerConfigStreamUsageLimitSourcesUpdatedEvent =
717 typeof ServerConfigStreamUsageLimitSourcesUpdatedEvent.Type;
718
695719export const ServerConfigStreamEvent = Schema.Union([
696720 ServerConfigStreamSnapshotEvent,
697721 ServerConfigStreamKeybindingsUpdatedEvent,
698722 ServerConfigStreamProviderStatusesEvent,
699723 ServerConfigStreamSettingsUpdatedEvent,
700724 ServerConfigStreamEnvironmentThemesUpdatedEvent,
725 ServerConfigStreamUsageLimitSourcesUpdatedEvent,
701726]);
702727export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type;
703728
packages/contracts/src/settings.ts+27
......@@ -3,6 +3,7 @@ import * as Duration from "effect/Duration";
33import * as Schema from "effect/Schema";
44import * as SchemaTransformation from "effect/SchemaTransformation";
55import { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";
6import { UsageLimitSourceId } from "./usageLimitSourceId.ts";
67import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts";
78import {
89 DEFAULT_TEXT_GENERATION_MODEL,
......@@ -731,6 +732,21 @@ export const OpenCodeSettings = makeProviderSettingsSchema(
731732);
732733export type OpenCodeSettings = typeof OpenCodeSettings.Type;
733734
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 */
741export 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});
748export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type;
749
734750export const ObservabilitySettings = Schema.Struct({
735751 otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
736752 otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
......@@ -909,6 +925,11 @@ export const ServerSettings = Schema.Struct({
909925 Schema.withDecodingDefault(Effect.succeed({})),
910926 ),
911927 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 ),
912933});
913934export type ServerSettings = typeof ServerSettings.Type;
914935
......@@ -1114,6 +1135,12 @@ export const ServerSettingsPatch = Schema.Struct({
11141135 // patches risk leaving driver-specific config in a half-merged state.
11151136 // The web UI sends a fully-formed map every time it edits this field.
11161137 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 ),
11171144});
11181145export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
11191146
packages/contracts/src/usageLimitSourceId.ts created+9
......@@ -0,0 +1,9 @@
1import * 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 */
8export const UsageLimitSourceId = Schema.String.pipe(Schema.brand("UsageLimitSourceId"));
9export type UsageLimitSourceId = typeof UsageLimitSourceId.Type;
packages/shared/package.json+4
......@@ -243,6 +243,10 @@
243243 "types": "./src/usageFormat.ts",
244244 "import": "./src/usageFormat.ts"
245245 },
246 "./usageLimits": {
247 "types": "./src/usageLimits.ts",
248 "import": "./src/usageLimits.ts"
249 },
246250 "./desktopAppControl": {
247251 "types": "./src/desktopAppControl.ts",
248252 "import": "./src/desktopAppControl.ts"
packages/shared/src/serverSettings.test.ts+24
......@@ -2,6 +2,7 @@ import {
22 DEFAULT_SERVER_SETTINGS,
33 ProviderDriverKind,
44 ProviderInstanceId,
5 UsageLimitSourceId,
56 type ServerProvider,
67} from "@t3tools/contracts";
78import * as Duration from "effect/Duration";
......@@ -298,6 +299,29 @@ describe("serverSettings helpers", () => {
298299 });
299300 });
300301
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
301325 it("stores background activity profiles as a versioned object and syncs legacy aliases", () => {
302326 const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
303327 backgroundActivity: {
packages/shared/src/serverSettings.ts+26
......@@ -122,6 +122,22 @@ function mergeModelSelectionOptionsById(input: {
122122 return [...merged.entries()].map(([id, value]) => ({ id, value }));
123123}
124124
125/** Upsert each patched entry; `null` removes it. Entries the patch omits are untouched. */
126function 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
125141export function applyServerSettingsPatch(
126142 current: ServerSettings,
127143 patch: ServerSettingsPatch,
......@@ -132,6 +148,8 @@ export function applyServerSettingsPatch(
132148 providerHealthRefreshInterval,
133149 backgroundActivityProfile,
134150 backgroundActivity,
151 // Merged per entry below; its `null` removals must not reach deepMerge.
152 usageLimitSources: usageLimitSourcesPatch,
135153 ...patchForMerge
136154 } = patch;
137155 const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current);
......@@ -188,6 +206,14 @@ export function applyServerSettingsPatch(
188206 ...(patch.providerInstances !== undefined
189207 ? { providerInstances: patch.providerInstances }
190208 : {}),
209 ...(usageLimitSourcesPatch !== undefined
210 ? {
211 usageLimitSources: mergeUsageLimitSources(
212 current.usageLimitSources,
213 usageLimitSourcesPatch,
214 ),
215 }
216 : {}),
191217 ...(patch.sourceControlWriterModelSelection !== undefined
192218 ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection }
193219 : {}),
packages/shared/src/usageLimits.test.ts created+181
......@@ -0,0 +1,181 @@
1import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 collectLimitSources,
6 collectLimitsGroups,
7 elapsedShare,
8 formatResetsIn,
9 limitsNotice,
10 paceOf,
11 providersWithLimits,
12} from "./usageLimits.ts";
13
14const now = Date.parse("2026-09-03T12:00:00.000Z");
15
16const 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
25function 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
42describe("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
67describe("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
85describe("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
116describe("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
142describe("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 */
8import {
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
18const MINUTE = 60_000;
19const HOUR = 60 * MINUTE;
20const 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 */
27export 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
39export 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 */
51export 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 */
74export 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. */
113export 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. */
121export 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
131export 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. */
138export 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
146export 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 */
153export 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`. */
163export 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. */
174export 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}