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";...@@ -21,6 +21,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
21import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";21import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
22import { SettingsSection } from "../settings/components/SettingsSection";22import { SettingsSection } from "../settings/components/SettingsSection";
23import { UsageDailyChart } from "./UsageDailyChart";23import { UsageDailyChart } from "./UsageDailyChart";
24import { UsageLimitsSection } from "./UsageLimitsSection";
24import type { UsageChartMetric } from "./usageChartData";25import type { UsageChartMetric } from "./usageChartData";
25import { PROVIDER_LABEL, useProviderColors } from "./usageProviders";26import { PROVIDER_LABEL, useProviderColors } from "./usageProviders";
2627
...@@ -139,6 +140,7 @@ export function UsageRouteScreen() {...@@ -139,6 +140,7 @@ export function UsageRouteScreen() {
139 timeZone={window.timeZone}140 timeZone={window.timeZone}
140 />141 />
141 <ProviderSection merged={merged} metric={metric} />142 <ProviderSection merged={merged} metric={metric} />
143 <UsageLimitsSection />
142 <TotalsSection merged={merged} isPast24Hours={isPast24Hours} />144 <TotalsSection merged={merged} isPast24Hours={isPast24Hours} />
143 <ModelsSection merged={merged} />145 <ModelsSection merged={merged} />
144 </>146 </>
apps/mobile/src/state/server.ts+1
...@@ -7,6 +7,7 @@ import { environmentSession } from "./session";...@@ -7,6 +7,7 @@ import { environmentSession } from "./session";
77
8export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, {8export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, {
9 initialConfigValueAtom: environmentSession.initialConfigValueAtom,9 initialConfigValueAtom: environmentSession.initialConfigValueAtom,
10 usageLimitSources: true,
10});11});
11export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({12export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({
12 catalogValueAtom: environmentCatalog.catalogValueAtom,13 catalogValueAtom: environmentCatalog.catalogValueAtom,
apps/server/src/environment/ServerEnvironment.ts+1
...@@ -221,6 +221,7 @@ export const make = Effect.gen(function* () {...@@ -221,6 +221,7 @@ export const make = Effect.gen(function* () {
221 threadAutoSettlement: true,221 threadAutoSettlement: true,
222 threadSnooze: true,222 threadSnooze: true,
223 environmentThemes: true,223 environmentThemes: true,
224 usageLimitSources: true,
224 threadPinning: true,225 threadPinning: true,
225 threadPinReorder: true,226 threadPinReorder: true,
226 threadTitleRegeneration: true,227 threadTitleRegeneration: true,
apps/server/src/provider/Drivers/ClaudeDriver.ts+6
...@@ -29,6 +29,7 @@ import { ServerConfig } from "../../config.ts";...@@ -29,6 +29,7 @@ import { ServerConfig } from "../../config.ts";
29import { ServerSettingsService } from "../../serverSettings.ts";29import { ServerSettingsService } from "../../serverSettings.ts";
30import { ProviderDriverError } from "../Errors.ts";30import { ProviderDriverError } from "../Errors.ts";
31import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts";31import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts";
32import { makeClaudeScopedLimitNames } from "../Layers/claudeUsageLimits.ts";
32import {33import {
33 checkClaudeProviderStatus,34 checkClaudeProviderStatus,
34 makePendingClaudeProvider,35 makePendingClaudeProvider,
...@@ -134,10 +135,14 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {...@@ -134,10 +135,14 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
134 continuationGroupKey,135 continuationGroupKey,
135 });136 });
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;
137 const adapterOptions = {141 const adapterOptions = {
138 instanceId,142 instanceId,
139 environment: processEnv,143 environment: processEnv,
140 modelCatalog,144 modelCatalog,
145 scopedLimitNames,
141 ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),146 ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
142 };147 };
143 const adapter = yield* makeClaudeAdapter(effectiveConfig, adapterOptions);148 const adapter = yield* makeClaudeAdapter(effectiveConfig, adapterOptions);
...@@ -171,6 +176,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {...@@ -171,6 +176,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
171 processEnv,176 processEnv,
172 cwd,177 cwd,
173 resolveClaudeModelCatalog(manifest),178 resolveClaudeModelCatalog(manifest),
179 scopedLimitNames,
174 ),180 ),
175 ),181 ),
176 Effect.map(stampIdentity),182 Effect.map(stampIdentity),
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+82
...@@ -28,6 +28,7 @@ import * as Effect from "effect/Effect";...@@ -28,6 +28,7 @@ import * as Effect from "effect/Effect";
28import * as Fiber from "effect/Fiber";28import * as Fiber from "effect/Fiber";
29import * as Layer from "effect/Layer";29import * as Layer from "effect/Layer";
30import * as Random from "effect/Random";30import * as Random from "effect/Random";
31import * as Ref from "effect/Ref";
31import * as Schema from "effect/Schema";32import * as Schema from "effect/Schema";
32import * as Stream from "effect/Stream";33import * as Stream from "effect/Stream";
33import * as TestClock from "effect/testing/TestClock";34import * as TestClock from "effect/testing/TestClock";
...@@ -44,6 +45,7 @@ import {...@@ -44,6 +45,7 @@ import {
44} from "../ClaudeModelCatalog.testFixtures.ts";45} from "../ClaudeModelCatalog.testFixtures.ts";
45import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts";46import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts";
46import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";47import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
48import type { ClaudeScopedLimitNames } from "./claudeUsageLimits.ts";
47import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts";49import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts";
48const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);50const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
49const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));51const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
...@@ -163,6 +165,7 @@ function makeHarness(config?: {...@@ -163,6 +165,7 @@ function makeHarness(config?: {
163 readonly baseDir?: string;165 readonly baseDir?: string;
164 readonly claudeConfig?: Partial<ClaudeSettings>;166 readonly claudeConfig?: Partial<ClaudeSettings>;
165 readonly instanceId?: ProviderInstanceId;167 readonly instanceId?: ProviderInstanceId;
168 readonly scopedLimitNames?: ClaudeAdapterLiveOptions["scopedLimitNames"];
166}) {169}) {
167 const query = new FakeClaudeQuery();170 const query = new FakeClaudeQuery();
168 let createInput:171 let createInput:
...@@ -174,6 +177,7 @@ function makeHarness(config?: {...@@ -174,6 +177,7 @@ function makeHarness(config?: {
174177
175 const adapterOptions: ClaudeAdapterLiveOptions = {178 const adapterOptions: ClaudeAdapterLiveOptions = {
176 ...(config?.instanceId ? { instanceId: config.instanceId } : {}),179 ...(config?.instanceId ? { instanceId: config.instanceId } : {}),
180 ...(config?.scopedLimitNames ? { scopedLimitNames: config.scopedLimitNames } : {}),
177 modelCatalog: Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG),181 modelCatalog: Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG),
178 createQuery: (input) => {182 createQuery: (input) => {
179 createInput = input;183 createInput = input;
...@@ -1239,6 +1243,84 @@ describe("ClaudeAdapterLive", () => {...@@ -1239,6 +1243,84 @@ describe("ClaudeAdapterLive", () => {
1239 );1243 );
1240 });1244 });
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
1242 it.effect("does not emit turn.completed for a result with no active turn", () => {1324 it.effect("does not emit turn.completed for a result with no active turn", () => {
1243 const harness = makeHarness();1325 const harness = makeHarness();
1244 return Effect.gen(function* () {1326 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeAdapter.ts+9-3
...@@ -21,6 +21,7 @@ import {...@@ -21,6 +21,7 @@ import {
21} from "@anthropic-ai/claude-agent-sdk";21} from "@anthropic-ai/claude-agent-sdk";
22import { parseCliArgs } from "@t3tools/shared/cliArgs";22import { parseCliArgs } from "@t3tools/shared/cliArgs";
23import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";23import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
24import { type ClaudeScopedLimitNames, claudeRateLimitEventToUpdate } from "./claudeUsageLimits.ts";
24import {25import {
25 ApprovalRequestId,26 ApprovalRequestId,
26 type CanonicalItemType,27 type CanonicalItemType,
...@@ -342,6 +343,8 @@ export interface ClaudeAdapterLiveOptions {...@@ -342,6 +343,8 @@ export interface ClaudeAdapterLiveOptions {
342 readonly nativeEventLogPath?: string;343 readonly nativeEventLogPath?: string;
343 readonly nativeEventLogger?: EventNdjsonLogger;344 readonly nativeEventLogger?: EventNdjsonLogger;
344 readonly modelCatalog?: Effect.Effect<ClaudeModelCatalog>;345 readonly modelCatalog?: Effect.Effect<ClaudeModelCatalog>;
346 /** Scoped-bucket names the driver's status probe last saw; see `claudeUsageLimits`. */
347 readonly scopedLimitNames?: Ref.Ref<ClaudeScopedLimitNames>;
345}348}
346349
347function isUuid(value: string): boolean {350function isUuid(value: string): boolean {
...@@ -3595,12 +3598,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3595,12 +3598,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3595 }3598 }
35963599
3597 if (message.type === "rate_limit_event") {3600 if (message.type === "rate_limit_event") {
3601 const names = options?.scopedLimitNames
3602 ? yield* Ref.get(options.scopedLimitNames)
3603 : { overageIncluded: undefined };
3604 const limits = claudeRateLimitEventToUpdate(message.rate_limit_info, names);
3605 if (!limits) return;
3598 yield* offerRuntimeEvent({3606 yield* offerRuntimeEvent({
3599 ...base,3607 ...base,
3600 type: "account.rate-limits.updated",3608 type: "account.rate-limits.updated",
3601 payload: {3609 payload: { limits },
3602 rateLimits: message,
3603 },
3604 });3610 });
3605 return;3611 return;
3606 }3612 }
apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts+27-14
...@@ -77,22 +77,31 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {...@@ -77,22 +77,31 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
77 "const lines = createInterface({ input: process.stdin });",77 "const lines = createInterface({ input: process.stdin });",
78 'lines.on("line", (line) => {',78 'lines.on("line", (line) => {',
79 " const message = JSON.parse(line);",79 " const message = JSON.parse(line);",
80 ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;',80 ' if (message.type !== "control_request") return;',
81 " process.stdout.write(JSON.stringify({",81 " const reply = (response) => process.stdout.write(JSON.stringify({",
82 ' type: "control_response",',82 ' type: "control_response",',
83 " response: {",83 ' response: { subtype: "success", request_id: message.request_id, 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 " },",
95 ' }) + "\\n");',84 ' }) + "\\n");',
85 ' if (message.request?.subtype === "initialize") {',
86 " reply({",
87 ' commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],',
88 " agents: [],",
89 ' output_style: "default",',
90 ' available_output_styles: ["default"],',
91 " models: [],",
92 ' account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },',
93 " });",
94 " }",
95 " // The probe follows initialize with get_usage on the same process.",
96 ' if (message.request?.subtype === "get_usage") {',
97 " reply({",
98 " session: {},",
99 ' subscription_type: "pro",',
100 " rate_limits_available: true,",
101 ' rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } },',
102 " behaviors: null,",
103 " });",
104 " }",
96 "});",105 "});",
97 "setInterval(() => {}, 1_000);",106 "setInterval(() => {}, 1_000);",
98 "",107 "",
...@@ -122,6 +131,10 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {...@@ -122,6 +131,10 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
122 input: { hint: "[path]" },131 input: { hint: "[path]" },
123 },132 },
124 ],133 ],
134 usage: {
135 rate_limits_available: true,
136 rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } },
137 },
125 });138 });
126139
127 // @effect-diagnostics-next-line preferSchemaOverJson:off140 // @effect-diagnostics-next-line preferSchemaOverJson:off
apps/server/src/provider/Layers/ClaudeProvider.ts+35
...@@ -8,6 +8,7 @@ import * as Effect from "effect/Effect";...@@ -8,6 +8,7 @@ import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";8import * as FileSystem from "effect/FileSystem";
9import * as Option from "effect/Option";9import * as Option from "effect/Option";
10import * as Path from "effect/Path";10import * as Path from "effect/Path";
11import * as Ref from "effect/Ref";
11import * as Result from "effect/Result";12import * as Result from "effect/Result";
12import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";13import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
13import { createModelCapabilities } from "@t3tools/shared/model";14import { createModelCapabilities } from "@t3tools/shared/model";
...@@ -16,6 +17,7 @@ import {...@@ -16,6 +17,7 @@ import {
16 query as claudeQuery,17 query as claudeQuery,
17 type Options as ClaudeQueryOptions,18 type Options as ClaudeQueryOptions,
18 type SlashCommand as ClaudeSlashCommand,19 type SlashCommand as ClaudeSlashCommand,
20 type SDKControlGetUsageResponse,
19 type SDKUserMessage,21 type SDKUserMessage,
20 type SettingSource,22 type SettingSource,
21} from "@anthropic-ai/claude-agent-sdk";23} from "@anthropic-ai/claude-agent-sdk";
...@@ -32,6 +34,12 @@ import {...@@ -32,6 +34,12 @@ import {
32import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";34import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
33import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";35import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
34import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";36import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";
37import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts";
38import {
39 type ClaudeScopedLimitNames,
40 claudeUsageResponseToLimits,
41 recordClaudeUsageResponse,
42} from "./claudeUsageLimits.ts";
35import {43import {
36 BUNDLED_CLAUDE_MODEL_CATALOG,44 BUNDLED_CLAUDE_MODEL_CATALOG,
37 type ClaudeModelCatalog,45 type ClaudeModelCatalog,
...@@ -225,6 +233,12 @@ type ClaudeCapabilitiesProbe = {...@@ -225,6 +233,12 @@ type ClaudeCapabilitiesProbe = {
225 */233 */
226 readonly apiProvider: string | undefined;234 readonly apiProvider: string | undefined;
227 readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;235 readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
236 /**
237 * Subscription windows from the SDK's `get_usage` control request, or
238 * `undefined` when the request itself failed. Absent windows on an
239 * otherwise successful response mean the account has none (API key).
240 */
241 readonly usage?: Pick<SDKControlGetUsageResponse, "rate_limits_available" | "rate_limits">;
228};242};
229243
230function parseClaudeInitializationCommands(244function parseClaudeInitializationCommands(
...@@ -340,6 +354,15 @@ const probeClaudeCapabilities = (...@@ -340,6 +354,15 @@ const probeClaudeCapabilities = (
340 }),354 }),
341 });355 });
342 const init = await q.initializationResult();356 const init = await q.initializationResult();
357 // Usage is a second control round trip on the same process; a failure
358 // there must not cost the slash commands and account we already have.
359 const usage = await q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET().then(
360 (response) => ({
361 rate_limits_available: response.rate_limits_available,
362 rate_limits: response.rate_limits,
363 }),
364 () => undefined,
365 );
343 const account = init.account as366 const account = init.account as
344 | {367 | {
345 readonly email?: string;368 readonly email?: string;
...@@ -354,6 +377,7 @@ const probeClaudeCapabilities = (...@@ -354,6 +377,7 @@ const probeClaudeCapabilities = (
354 tokenSource: account?.tokenSource,377 tokenSource: account?.tokenSource,
355 apiProvider: account?.apiProvider,378 apiProvider: account?.apiProvider,
356 slashCommands: parseClaudeInitializationCommands(init.commands),379 slashCommands: parseClaudeInitializationCommands(init.commands),
380 ...(usage ? { usage } : {}),
357 } satisfies ClaudeCapabilitiesProbe;381 } satisfies ClaudeCapabilitiesProbe;
358 });382 });
359 }).pipe(383 }).pipe(
...@@ -395,6 +419,8 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(...@@ -395,6 +419,8 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
395 environment?: NodeJS.ProcessEnv,419 environment?: NodeJS.ProcessEnv,
396 cwd?: string,420 cwd?: string,
397 modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG,421 modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG,
422 /** Shared with the adapter so turn events reuse the scoped-bucket names this probe saw. */
423 scopedLimitNames?: Ref.Ref<ClaudeScopedLimitNames>,
398): Effect.fn.Return<424): Effect.fn.Return<
399 ServerProviderDraft,425 ServerProviderDraft,
400 never,426 never,
...@@ -535,6 +561,14 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(...@@ -535,6 +561,14 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
535 subscriptionType: capabilities.subscriptionType,561 subscriptionType: capabilities.subscriptionType,
536 authMethod: capabilities.tokenSource,562 authMethod: capabilities.tokenSource,
537 }) ?? apiProviderAuthMetadata(capabilities.apiProvider);563 }) ?? apiProviderAuthMetadata(capabilities.apiProvider);
564 const usageLimits = !capabilities.usage
565 ? makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed" })
566 : scopedLimitNames
567 ? yield* recordClaudeUsageResponse(scopedLimitNames, {
568 response: capabilities.usage,
569 checkedAt,
570 })
571 : claudeUsageResponseToLimits({ response: capabilities.usage, checkedAt }).limits;
538 return buildServerProvider({572 return buildServerProvider({
539 presentation: CLAUDE_PRESENTATION,573 presentation: CLAUDE_PRESENTATION,
540 enabled: claudeSettings.enabled,574 enabled: claudeSettings.enabled,
...@@ -552,6 +586,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(...@@ -552,6 +586,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
552 ...(authMetadata ? authMetadata : {}),586 ...(authMetadata ? authMetadata : {}),
553 },587 },
554 ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}),588 ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}),
589 usageLimits,
555 },590 },
556 });591 });
557});592});
apps/server/src/provider/Layers/CodexAdapter.ts+8-4
...@@ -70,6 +70,7 @@ import {...@@ -70,6 +70,7 @@ import {
70} from "./CodexSessionRuntime.ts";70} from "./CodexSessionRuntime.ts";
71import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";71import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
72import { resolveCodexLaunchArgs } from "./codexLaunchArgs.ts";72import { resolveCodexLaunchArgs } from "./codexLaunchArgs.ts";
73import { codexRateLimitsToUpdate } from "./codexUsageLimits.ts";
73const isCodexAppServerProcessExitedError = Schema.is(CodexErrors.CodexAppServerProcessExitedError);74const isCodexAppServerProcessExitedError = Schema.is(CodexErrors.CodexAppServerProcessExitedError);
74const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTransportError);75const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTransportError);
75const isCodexSessionRuntimeThreadIdMissingError = Schema.is(76const isCodexSessionRuntimeThreadIdMissingError = Schema.is(
...@@ -1747,16 +1748,19 @@ function mapToRuntimeEvents(...@@ -1747,16 +1748,19 @@ function mapToRuntimeEvents(
1747 }1748 }
17481749
1749 if (event.method === "account/rateLimits/updated") {1750 if (event.method === "account/rateLimits/updated") {
1750 if (!readPayload(EffectCodexSchema.V2AccountRateLimitsUpdatedNotification, event.payload)) {1751 const payload = readPayload(
1752 EffectCodexSchema.V2AccountRateLimitsUpdatedNotification,
1753 event.payload,
1754 );
1755 const limits = payload ? codexRateLimitsToUpdate(payload.rateLimits) : undefined;
1756 if (!limits) {
1751 return [];1757 return [];
1752 }1758 }
1753 return [1759 return [
1754 {1760 {
1755 type: "account.rate-limits.updated",1761 type: "account.rate-limits.updated",
1756 ...runtimeEventBase(event, canonicalThreadId),1762 ...runtimeEventBase(event, canonicalThreadId),
1757 payload: {1763 payload: { limits },
1758 rateLimits: event.payload ?? {},
1759 },
1760 },1764 },
1761 ];1765 ];
1762 }1766 }
apps/server/src/provider/Layers/CodexProvider.ts+46-3
...@@ -33,8 +33,19 @@ import {...@@ -33,8 +33,19 @@ import {
33 type ServerProviderDraft,33 type ServerProviderDraft,
34} from "../providerSnapshot.ts";34} from "../providerSnapshot.ts";
35import { expandHomePath } from "../../pathExpansion.ts";35import { expandHomePath } from "../../pathExpansion.ts";
36import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts";
37import {
38 codexRateLimitsFailureMessage,
39 codexRateLimitsToLimits,
40 type CodexRateLimitSnapshot,
41} from "./codexUsageLimits.ts";
36import packageJson from "../../../package.json" with { type: "json" };42import packageJson from "../../../package.json" with { type: "json" };
37const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError);43const 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
39const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const;50const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const;
4051
...@@ -45,6 +56,7 @@ const CODEX_PRESENTATION = {...@@ -45,6 +56,7 @@ const CODEX_PRESENTATION = {
4556
46export interface CodexAppServerProviderSnapshot {57export interface CodexAppServerProviderSnapshot {
47 readonly account: CodexSchema.V2GetAccountResponse;58 readonly account: CodexSchema.V2GetAccountResponse;
59 readonly rateLimits?: CodexRateLimitsProbe;
48 readonly version: string | undefined;60 readonly version: string | undefined;
49 readonly models: ReadonlyArray<ServerProviderModel>;61 readonly models: ReadonlyArray<ServerProviderModel>;
50 readonly skills: ReadonlyArray<ServerProviderSkill>;62 readonly skills: ReadonlyArray<ServerProviderSkill>;
...@@ -72,8 +84,12 @@ function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["accoun...@@ -72,8 +84,12 @@ function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["accoun
72 if (account.type === "apiKey") return "OpenAI API Key";84 if (account.type === "apiKey") return "OpenAI API Key";
73 if (account.type === "amazonBedrock") return "Amazon Bedrock";85 if (account.type === "amazonBedrock") return "Amazon Bedrock";
74 if (account.type !== "chatgpt") return undefined;86 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) {
77 case "free":93 case "free":
78 return "ChatGPT Free Subscription";94 return "ChatGPT Free Subscription";
79 case "go":95 case "go":
...@@ -102,7 +118,6 @@ function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["accoun...@@ -102,7 +118,6 @@ function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["accoun
102 case "unknown":118 case "unknown":
103 return "ChatGPT Subscription";119 return "ChatGPT Subscription";
104 default:120 default:
105 account.planType satisfies never;
106 return undefined;121 return undefined;
107 }122 }
108}123}
...@@ -394,18 +409,35 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun...@@ -394,18 +409,35 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
394 } satisfies CodexAppServerProviderSnapshot;409 } satisfies CodexAppServerProviderSnapshot;
395 }410 }
396411
397 const [skillsResponse, models] = yield* Effect.all(412 const [skillsResponse, models, rateLimits] = yield* Effect.all(
398 [413 [
399 client.request("skills/list", {414 client.request("skills/list", {
400 cwds: [input.cwd],415 cwds: [input.cwd],
401 }),416 }),
402 requestAllCodexModels(client),417 requestAllCodexModels(client),
418 // Usage is an enrichment: a failure or a slow answer degrades to "no
419 // usage this probe" rather than costing the account and models.
420 client.request("account/rateLimits/read", undefined).pipe(
421 Effect.map((response): CodexRateLimitsProbe => ({ snapshot: response.rateLimits })),
422 Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)),
423 Effect.map(
424 Option.getOrElse((): CodexRateLimitsProbe => ({
425 failure: "Codex did not answer the usage request.",
426 })),
427 ),
428 Effect.catch((error) =>
429 Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe(
430 Effect.as<CodexRateLimitsProbe>({ failure: codexRateLimitsFailureMessage(error) }),
431 ),
432 ),
433 ),
403 ],434 ],
404 { concurrency: "unbounded" },435 { concurrency: "unbounded" },
405 );436 );
406437
407 return {438 return {
408 account: accountResponse,439 account: accountResponse,
440 rateLimits,
409 version,441 version,
410 models: applyPreferredCodexDefaultModel(442 models: applyPreferredCodexDefaultModel(
411 appendCustomCodexModels(models, input.customModels ?? []),443 appendCustomCodexModels(models, input.customModels ?? []),
...@@ -640,6 +672,16 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu...@@ -640,6 +672,16 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
640672
641 const snapshot = probeResult.success.value;673 const snapshot = probeResult.success.value;
642 const accountStatus = accountProbeStatus(snapshot.account);674 const accountStatus = accountProbeStatus(snapshot.account);
675 const usageLimits =
676 snapshot.account.account?.type === "apiKey"
677 ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" })
678 : snapshot.rateLimits === undefined || "failure" in snapshot.rateLimits
679 ? makeUnavailableUsageLimits({
680 checkedAt,
681 reason: "probeFailed",
682 ...(snapshot.rateLimits ? { message: snapshot.rateLimits.failure } : {}),
683 })
684 : codexRateLimitsToLimits({ snapshot: snapshot.rateLimits.snapshot, checkedAt });
643685
644 return buildServerProvider({686 return buildServerProvider({
645 presentation: CODEX_PRESENTATION,687 presentation: CODEX_PRESENTATION,
...@@ -660,6 +702,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu...@@ -660,6 +702,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
660 status: accountStatus.status,702 status: accountStatus.status,
661 auth: accountStatus.auth,703 auth: accountStatus.auth,
662 ...(accountStatus.message ? { message: accountStatus.message } : {}),704 ...(accountStatus.message ? { message: accountStatus.message } : {}),
705 usageLimits,
663 },706 },
664 });707 });
665});708});
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+1
...@@ -118,6 +118,7 @@ const makeFakeInstance = (...@@ -118,6 +118,7 @@ const makeFakeInstance = (
118 getSnapshot: Effect.succeed({} as unknown as ServerProvider),118 getSnapshot: Effect.succeed({} as unknown as ServerProvider),
119 refresh: Effect.succeed({} as unknown as ServerProvider),119 refresh: Effect.succeed({} as unknown as ServerProvider),
120 streamChanges: Stream.empty,120 streamChanges: Stream.empty,
121 applyUsageLimits: () => Effect.void,
121 },122 },
122 adapter,123 adapter,
123 textGeneration: {} as unknown as TextGeneration.TextGeneration["Service"],124 textGeneration: {} as unknown as TextGeneration.TextGeneration["Service"],
apps/server/src/provider/Layers/ProviderRegistry.test.ts+8
...@@ -1098,6 +1098,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1098,6 +1098,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1098 Effect.andThen(Effect.never),1098 Effect.andThen(Effect.never),
1099 ),1099 ),
1100 streamChanges: Stream.empty,1100 streamChanges: Stream.empty,
1101 applyUsageLimits: () => Effect.void,
1101 },1102 },
1102 adapter: {} as ProviderInstance["adapter"],1103 adapter: {} as ProviderInstance["adapter"],
1103 textGeneration: {} as ProviderInstance["textGeneration"],1104 textGeneration: {} as ProviderInstance["textGeneration"],
...@@ -1187,6 +1188,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1187,6 +1188,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1187 getSnapshot: Effect.succeed(provider),1188 getSnapshot: Effect.succeed(provider),
1188 refresh: Effect.succeed(provider),1189 refresh: Effect.succeed(provider),
1189 streamChanges: Stream.empty,1190 streamChanges: Stream.empty,
1191 applyUsageLimits: () => Effect.void,
1190 },1192 },
1191 snapshotForCwd,1193 snapshotForCwd,
1192 adapter: {} as ProviderInstance["adapter"],1194 adapter: {} as ProviderInstance["adapter"],
...@@ -1377,6 +1379,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1377,6 +1379,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1377 Effect.as(codexProvider),1379 Effect.as(codexProvider),
1378 ),1380 ),
1379 streamChanges: Stream.empty,1381 streamChanges: Stream.empty,
1382 applyUsageLimits: () => Effect.void,
1380 },1383 },
1381 adapter: {} as ProviderInstance["adapter"],1384 adapter: {} as ProviderInstance["adapter"],
1382 textGeneration: {} as ProviderInstance["textGeneration"],1385 textGeneration: {} as ProviderInstance["textGeneration"],
...@@ -1400,6 +1403,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1400,6 +1403,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1400 Effect.andThen(Ref.get(catalogSnapshot)),1403 Effect.andThen(Ref.get(catalogSnapshot)),
1401 ),1404 ),
1402 streamChanges: Stream.empty,1405 streamChanges: Stream.empty,
1406 applyUsageLimits: () => Effect.void,
1403 },1407 },
1404 adapter: {} as ProviderInstance["adapter"],1408 adapter: {} as ProviderInstance["adapter"],
1405 textGeneration: {} as ProviderInstance["textGeneration"],1409 textGeneration: {} as ProviderInstance["textGeneration"],
...@@ -1521,6 +1525,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1521,6 +1525,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1521 getSnapshot: Effect.succeed(initialProvider),1525 getSnapshot: Effect.succeed(initialProvider),
1522 refresh: Effect.succeed(refreshedProvider),1526 refresh: Effect.succeed(refreshedProvider),
1523 streamChanges: Stream.fromPubSub(changes),1527 streamChanges: Stream.fromPubSub(changes),
1528 applyUsageLimits: () => Effect.void,
1524 },1529 },
1525 adapter: {} as ProviderInstance["adapter"],1530 adapter: {} as ProviderInstance["adapter"],
1526 textGeneration: {} as ProviderInstance["textGeneration"],1531 textGeneration: {} as ProviderInstance["textGeneration"],
...@@ -1650,6 +1655,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1650,6 +1655,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1650 getSnapshot: Effect.succeed(initialProvider),1655 getSnapshot: Effect.succeed(initialProvider),
1651 refresh: Effect.succeed(authoritativeProvider),1656 refresh: Effect.succeed(authoritativeProvider),
1652 streamChanges: Stream.fromPubSub(changes),1657 streamChanges: Stream.fromPubSub(changes),
1658 applyUsageLimits: () => Effect.void,
1653 },1659 },
1654 adapter: {} as ProviderInstance["adapter"],1660 adapter: {} as ProviderInstance["adapter"],
1655 textGeneration: {} as ProviderInstance["textGeneration"],1661 textGeneration: {} as ProviderInstance["textGeneration"],
...@@ -1757,6 +1763,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1757,6 +1763,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1757 getSnapshot: Effect.succeed(cachedProvider),1763 getSnapshot: Effect.succeed(cachedProvider),
1758 refresh: Effect.die(new Error("simulated refresh failure")),1764 refresh: Effect.die(new Error("simulated refresh failure")),
1759 streamChanges: Stream.empty,1765 streamChanges: Stream.empty,
1766 applyUsageLimits: () => Effect.void,
1760 },1767 },
1761 adapter: {} as ProviderInstance["adapter"],1768 adapter: {} as ProviderInstance["adapter"],
1762 textGeneration: {} as ProviderInstance["textGeneration"],1769 textGeneration: {} as ProviderInstance["textGeneration"],
...@@ -1850,6 +1857,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1850,6 +1857,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1850 getSnapshot: Effect.succeed(provider),1857 getSnapshot: Effect.succeed(provider),
1851 refresh: Effect.succeed(provider),1858 refresh: Effect.succeed(provider),
1852 streamChanges: Stream.empty,1859 streamChanges: Stream.empty,
1860 applyUsageLimits: () => Effect.void,
1853 },1861 },
1854 adapter: {} as ProviderInstance["adapter"],1862 adapter: {} as ProviderInstance["adapter"],
1855 textGeneration: {} as ProviderInstance["textGeneration"],1863 textGeneration: {} as ProviderInstance["textGeneration"],
apps/server/src/provider/Layers/ProviderUsageLimitsIngestion.ts created+44
...@@ -0,0 +1,44 @@
1/**
2 * ProviderUsageLimitsIngestionLive — folds `account.rate-limits.updated`
3 * runtime events into the owning instance's published snapshot.
4 *
5 * Adapters normalise their native payloads before emitting, so this layer
6 * never sees a driver shape: it routes the typed update to the instance and
7 * lets `ServerProviderShape.applyUsageLimits` merge and republish on the
8 * instance's own change stream, which `ProviderRegistry` already aggregates.
9 *
10 * @module provider/Layers/ProviderUsageLimitsIngestion
11 */
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 @@...@@ -1,4 +1,4 @@
1import type { ServerProvider } from "@t3tools/contracts";1import type { ProviderUsageLimitsUpdate, ServerProvider } from "@t3tools/contracts";
2import type * as Effect from "effect/Effect";2import type * as Effect from "effect/Effect";
3import type * as Stream from "effect/Stream";3import type * as Stream from "effect/Stream";
4import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts";4import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
...@@ -8,4 +8,12 @@ export interface ServerProviderShape {...@@ -8,4 +8,12 @@ export interface ServerProviderShape {
8 readonly getSnapshot: Effect.Effect<ServerProvider>;8 readonly getSnapshot: Effect.Effect<ServerProvider>;
9 readonly refresh: Effect.Effect<ServerProvider>;9 readonly refresh: Effect.Effect<ServerProvider>;
10 readonly streamChanges: Stream.Stream<ServerProvider>;10 readonly streamChanges: Stream.Stream<ServerProvider>;
11 /**
12 * Fold a runtime rate-limit update into the published snapshot without
13 * waiting for the next status probe. Sparse: windows merge by id and an
14 * update with no usable window leaves the snapshot untouched.
15 */
16 readonly applyUsageLimits: (
17 update: ProviderUsageLimitsUpdate & { readonly checkedAt: string },
18 ) => Effect.Effect<void>;
11}19}
apps/server/src/provider/makeManagedServerProvider.test.ts+133
...@@ -522,4 +522,137 @@ describe("makeManagedServerProvider", () => {...@@ -522,4 +522,137 @@ describe("makeManagedServerProvider", () => {
522 }),522 }),
523 ).pipe(Effect.provide(AlwaysRunTestLayer)),523 ).pipe(Effect.provide(AlwaysRunTestLayer)),
524 );524 );
525
526 it.effect("applies runtime usage updates onto the published snapshot", () =>
527 Effect.scoped(
528 Effect.gen(function* () {
529 const provider = yield* makeManagedServerProvider<TestSettings>({
530 maintenanceCapabilities,
531 getSettings: Effect.succeed({ enabled: true }),
532 streamSettings: Stream.empty,
533 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
534 initialSnapshot: () => Effect.succeed(initialSnapshot),
535 checkProvider: Effect.succeed({
536 ...refreshedSnapshot,
537 usageLimits: {
538 checkedAt: "2026-04-10T00:00:01.000Z",
539 windows: [
540 { id: "five_hour", kind: "session", label: "Session", usedPercent: 10 },
541 {
542 id: "seven_day",
543 kind: "weekly",
544 label: "Weekly",
545 usedPercent: 20,
546 resetsAt: "2026-04-17T00:00:00.000Z",
547 },
548 ],
549 },
550 } satisfies ServerProvider),
551 refreshInterval: "1 hour",
552 });
553 yield* Stream.take(provider.streamChanges, 1).pipe(Stream.runDrain);
554
555 const updatesFiber = yield* Stream.take(provider.streamChanges, 1).pipe(
556 Stream.runCollect,
557 Effect.forkChild,
558 );
559 yield* Effect.yieldNow;
560
561 // Percent-only weekly update: keeps the probe's reset time.
562 yield* provider.applyUsageLimits({
563 checkedAt: "2026-04-10T00:05:00.000Z",
564 windows: [{ id: "seven_day", kind: "weekly", label: "Weekly", usedPercent: 25 }],
565 });
566 // No windows: nothing to merge, nothing published.
567 yield* provider.applyUsageLimits({ checkedAt: "2026-04-10T00:06:00.000Z", windows: [] });
568
569 const [update] = Array.from(yield* Fiber.join(updatesFiber));
570 assert.deepStrictEqual(update?.usageLimits, {
571 checkedAt: "2026-04-10T00:05:00.000Z",
572 windows: [
573 { id: "five_hour", kind: "session", label: "Session", usedPercent: 10 },
574 {
575 id: "seven_day",
576 kind: "weekly",
577 label: "Weekly",
578 usedPercent: 25,
579 resetsAt: "2026-04-17T00:00:00.000Z",
580 },
581 ],
582 });
583 assert.deepStrictEqual(yield* provider.getSnapshot, update);
584 }),
585 ).pipe(Effect.provide(AlwaysRunTestLayer)),
586 );
587
588 it.effect("keeps live usage windows across a failed probe and a stale enrichment", () =>
589 Effect.scoped(
590 Effect.gen(function* () {
591 const releaseEnrichment = yield* Deferred.make<void>();
592 const refreshCount = yield* Ref.make(0);
593 const probedLimits = {
594 checkedAt: "2026-04-10T00:00:01.000Z",
595 windows: [{ id: "primary", kind: "session", label: "Session", usedPercent: 10 }],
596 } as const;
597 const provider = yield* makeManagedServerProvider<TestSettings>({
598 maintenanceCapabilities,
599 getSettings: Effect.succeed({ enabled: true }),
600 streamSettings: Stream.empty,
601 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
602 initialSnapshot: () => Effect.succeed(initialSnapshot),
603 checkProvider: Ref.updateAndGet(refreshCount, (count) => count + 1).pipe(
604 Effect.map((count) =>
605 count === 1
606 ? { ...refreshedSnapshot, usageLimits: probedLimits }
607 : {
608 ...refreshedSnapshotSecond,
609 usageLimits: {
610 checkedAt: "2026-04-10T00:00:03.000Z",
611 windows: [],
612 unavailable: { reason: "probeFailed" },
613 },
614 },
615 ),
616 ),
617 enrichSnapshot: ({ snapshot, publishSnapshot }) =>
618 Deferred.await(releaseEnrichment).pipe(
619 Effect.flatMap(() =>
620 publishSnapshot({
621 ...enrichedSnapshot,
622 ...snapshot,
623 models: enrichedSnapshot.models,
624 }),
625 ),
626 ),
627 refreshInterval: "1 hour",
628 });
629 yield* Stream.take(provider.streamChanges, 1).pipe(Stream.runDrain);
630
631 const liveWindow = {
632 id: "primary",
633 kind: "session",
634 label: "Session",
635 usedPercent: 60,
636 } as const;
637 yield* provider.applyUsageLimits({
638 checkedAt: "2026-04-10T00:00:02.000Z",
639 windows: [liveWindow],
640 });
641
642 // Enrichment computed from the pre-update snapshot lands afterwards.
643 yield* Deferred.succeed(releaseEnrichment, undefined);
644 const enriched = yield* Stream.take(provider.streamChanges, 1).pipe(
645 Stream.runCollect,
646 Effect.map((chunk) => Array.from(chunk)[0]!),
647 );
648 assert.deepStrictEqual(enriched.models, enrichedSnapshot.models);
649 assert.deepStrictEqual(enriched.usageLimits?.windows, [liveWindow]);
650
651 // A probe that could not read usage keeps the last good windows.
652 const refreshed = yield* provider.refresh;
653 assert.strictEqual(refreshed.message, refreshedSnapshotSecond.message);
654 assert.deepStrictEqual(refreshed.usageLimits?.windows, [liveWindow]);
655 }),
656 ).pipe(Effect.provide(AlwaysRunTestLayer)),
657 );
525});658});
apps/server/src/provider/makeManagedServerProvider.ts+67-21
...@@ -17,6 +17,7 @@ import * as Semaphore from "effect/Semaphore";...@@ -17,6 +17,7 @@ import * as Semaphore from "effect/Semaphore";
1717
18import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";18import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
19import { ServerSettingsService } from "../serverSettings.ts";19import { ServerSettingsService } from "../serverSettings.ts";
20import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "./providerUsageLimits.ts";
20import type { ServerProviderShape } from "./Services/ServerProvider.ts";21import type { ServerProviderShape } from "./Services/ServerProvider.ts";
2122
22interface ProviderSnapshotState {23interface ProviderSnapshotState {
...@@ -24,6 +25,17 @@ interface ProviderSnapshotState {...@@ -24,6 +25,17 @@ interface ProviderSnapshotState {
24 readonly enrichmentGeneration: number;25 readonly enrichmentGeneration: number;
25}26}
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
27export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* <39export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* <
28 Settings,40 Settings,
29>(input: {41>(input: {
...@@ -69,16 +81,16 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(...@@ -69,16 +81,16 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(
69 nextSnapshot: ServerProvider,81 nextSnapshot: ServerProvider,
70 ) {82 ) {
71 const snapshotToPublish = yield* Ref.modify(snapshotStateRef, (state) => {83 const snapshotToPublish = yield* Ref.modify(snapshotStateRef, (state) => {
72 if (state.enrichmentGeneration !== generation || Equal.equals(state.snapshot, nextSnapshot)) {84 if (state.enrichmentGeneration !== generation) {
73 return [null, state] as const;85 return [null, state] as const;
74 }86 }
75 return [87 // Enrichment derives from the snapshot it was handed; a runtime usage
76 nextSnapshot,88 // update that landed since must not be reverted by it.
77 {89 const merged = withUsageLimits(nextSnapshot, state.snapshot.usageLimits);
78 ...state,90 if (Equal.equals(state.snapshot, merged)) {
79 snapshot: nextSnapshot,91 return [null, state] as const;
80 },92 }
81 ] as const;93 return [merged, { ...state, snapshot: merged }] as const;
82 });94 });
83 if (snapshotToPublish === null) {95 if (snapshotToPublish === null) {
84 return;96 return;
...@@ -138,19 +150,26 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(...@@ -138,19 +150,26 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(
138 return state.snapshot;150 return state.snapshot;
139 }151 }
140152
141 const nextSnapshot = yield* input.checkProvider;153 const probedSnapshot = yield* input.checkProvider;
142 const nextGeneration = yield* Ref.modify(snapshotStateRef, (state) => {154 const { snapshot: nextSnapshot, generation: nextGeneration } = yield* Ref.modify(
143 const generation = input.enrichSnapshot155 snapshotStateRef,
144 ? state.enrichmentGeneration + 1156 (state) => {
145 : state.enrichmentGeneration;157 const generation = input.enrichSnapshot
146 return [158 ? state.enrichmentGeneration + 1
147 generation,159 : state.enrichmentGeneration;
148 {160 const snapshot = withUsageLimits(
149 snapshot: nextSnapshot,161 probedSnapshot,
150 enrichmentGeneration: generation,162 resolveUsageLimitsAfterProbe({
151 },163 published: state.snapshot.usageLimits,
152 ] as const;164 probed: probedSnapshot.usageLimits,
153 });165 }),
166 );
167 return [
168 { snapshot, generation },
169 { snapshot, enrichmentGeneration: generation },
170 ] as const;
171 },
172 );
154 yield* Ref.set(settingsRef, nextSettings);173 yield* Ref.set(settingsRef, nextSettings);
155 yield* PubSub.publish(changesPubSub, nextSnapshot);174 yield* PubSub.publish(changesPubSub, nextSnapshot);
156 yield* restartSnapshotEnrichment(nextSettings, nextSnapshot, nextGeneration);175 yield* restartSnapshotEnrichment(nextSettings, nextSnapshot, nextGeneration);
...@@ -159,6 +178,32 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(...@@ -159,6 +178,32 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(
159 const applySnapshot = (nextSettings: Settings, options?: { readonly forceRefresh?: boolean }) =>178 const applySnapshot = (nextSettings: Settings, options?: { readonly forceRefresh?: boolean }) =>
160 refreshSemaphore.withPermits(1)(applySnapshotBase(nextSettings, options));179 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
162 const refreshSnapshot = Effect.fn("refreshSnapshot")(function* () {207 const refreshSnapshot = Effect.fn("refreshSnapshot")(function* () {
163 const nextSettings = yield* input.getSettings;208 const nextSettings = yield* input.getSettings;
164 return yield* applySnapshot(nextSettings, { forceRefresh: true });209 return yield* applySnapshot(nextSettings, { forceRefresh: true });
...@@ -241,6 +286,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(...@@ -241,6 +286,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(
241 maintenanceCapabilities: input.maintenanceCapabilities,286 maintenanceCapabilities: input.maintenanceCapabilities,
242 getSnapshot: Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)),287 getSnapshot: Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)),
243 refresh: refreshSnapshot().pipe(Effect.tapError(Effect.logError), Effect.orDie),288 refresh: refreshSnapshot().pipe(Effect.tapError(Effect.logError), Effect.orDie),
289 applyUsageLimits,
244 get streamChanges() {290 get streamChanges() {
245 return Stream.fromPubSub(changesPubSub);291 return Stream.fromPubSub(changesPubSub);
246 },292 },
apps/server/src/provider/providerSnapshot.ts+3
...@@ -7,6 +7,7 @@ import type {...@@ -7,6 +7,7 @@ import type {
7 ServerProviderSlashCommand,7 ServerProviderSlashCommand,
8 ServerProviderModel,8 ServerProviderModel,
9 ServerProviderState,9 ServerProviderState,
10 ServerProviderUsageLimits,
10} from "@t3tools/contracts";11} from "@t3tools/contracts";
11import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
12import * as PlatformError from "effect/PlatformError";13import * as PlatformError from "effect/PlatformError";
...@@ -50,6 +51,7 @@ export interface ProviderProbeResult {...@@ -50,6 +51,7 @@ export interface ProviderProbeResult {
50 readonly status: Exclude<ServerProviderState, "disabled">;51 readonly status: Exclude<ServerProviderState, "disabled">;
51 readonly auth: ServerProviderAuth;52 readonly auth: ServerProviderAuth;
52 readonly message?: string;53 readonly message?: string;
54 readonly usageLimits?: ServerProviderUsageLimits;
53}55}
5456
55export interface ServerProviderPresentation {57export interface ServerProviderPresentation {
...@@ -249,6 +251,7 @@ export function buildServerProvider(input: {...@@ -249,6 +251,7 @@ export function buildServerProvider(input: {
249 models: input.models,251 models: input.models,
250 slashCommands: [...(input.slashCommands ?? [])],252 slashCommands: [...(input.slashCommands ?? [])],
251 skills: [...(input.skills ?? [])],253 skills: [...(input.skills ?? [])],
254 ...(input.probe.usageLimits ? { usageLimits: input.probe.usageLimits } : {}),
252 ...(versionAdvisory ? { versionAdvisory } : {}),255 ...(versionAdvisory ? { versionAdvisory } : {}),
253 };256 };
254}257}
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 {...@@ -103,6 +103,7 @@ import {
103import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";103import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
104import * as GitManager from "./git/GitManager.ts";104import * as GitManager from "./git/GitManager.ts";
105import * as EnvironmentTheme from "./environmentTheme.ts";105import * as EnvironmentTheme from "./environmentTheme.ts";
106import * as UsageLimitSources from "./usage/UsageLimitSources.ts";
106import * as Keybindings from "./keybindings.ts";107import * as Keybindings from "./keybindings.ts";
107import * as ExternalLauncher from "./process/externalLauncher.ts";108import * as ExternalLauncher from "./process/externalLauncher.ts";
108import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";109import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
...@@ -744,6 +745,11 @@ const buildAppUnderTest = (options?: {...@@ -744,6 +745,11 @@ const buildAppUnderTest = (options?: {
744 streamChanges: Stream.empty,745 streamChanges: Stream.empty,
745 ...options?.layers?.environmentTheme,746 ...options?.layers?.environmentTheme,
746 }),747 }),
748 Layer.mock(UsageLimitSources.UsageLimitSources)({
749 current: Effect.succeed([]),
750 streamChanges: Stream.empty,
751 refresh: Effect.void,
752 }),
747 ),753 ),
748 ),754 ),
749 Layer.provide(755 Layer.provide(
apps/server/src/server.ts+8-1
...@@ -41,6 +41,7 @@ import { AntigravityInstallation } from "./provider/AntigravityInstallation.ts";...@@ -41,6 +41,7 @@ import { AntigravityInstallation } from "./provider/AntigravityInstallation.ts";
41import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts";41import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts";
42import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts";42import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts";
43import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts";43import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts";
44import { ProviderUsageLimitsIngestionLive } from "./provider/Layers/ProviderUsageLimitsIngestion.ts";
44import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts";45import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts";
45import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";46import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
46import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";47import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";
...@@ -118,6 +119,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien...@@ -118,6 +119,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien
118import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts";119import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts";
119import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts";120import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts";
120import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";121import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
122import * as UsageLimitSources from "./usage/UsageLimitSources.ts";
121import * as UsageService from "./usage/UsageService.ts";123import * as UsageService from "./usage/UsageService.ts";
122import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";124import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
123import {125import {
...@@ -407,6 +409,9 @@ const CloudManagedEndpointRuntimeLive = Layer.mergeAll(...@@ -407,6 +409,9 @@ const CloudManagedEndpointRuntimeLive = Layer.mergeAll(
407);409);
408410
409const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe(411const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe(
412 // Subscribes to `account.rate-limits.updated` so usage bars track live
413 // telemetry instead of waiting for the next status probe.
414 Layer.provideMerge(ProviderUsageLimitsIngestionLive),
410 Layer.provideMerge(ProviderLayerLive),415 Layer.provideMerge(ProviderLayerLive),
411 Layer.provideMerge(OrchestrationLayerLive),416 Layer.provideMerge(OrchestrationLayerLive),
412);417);
...@@ -454,7 +459,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(...@@ -454,7 +459,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
454 Layer.provideMerge(PersistenceLayerLive),459 Layer.provideMerge(PersistenceLayerLive),
455 // Both read a user-owned file out of the state directory and stream changes460 // Both read a user-owned file out of the state directory and stream changes
456 // to clients; neither depends on the other.461 // to clients; neither depends on the other.
457 Layer.provideMerge(Layer.mergeAll(Keybindings.layer, EnvironmentTheme.layer)),462 Layer.provideMerge(
463 Layer.mergeAll(Keybindings.layer, EnvironmentTheme.layer, UsageLimitSources.layer),
464 ),
458 Layer.provideMerge(ProviderRegistryLive),465 Layer.provideMerge(ProviderRegistryLive),
459 // The instance registry is the new routing keystone — text generation,466 // The instance registry is the new routing keystone — text generation,
460 // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId`467 // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId`
apps/server/src/serverSettings.ts+85-1
...@@ -18,6 +18,7 @@ import {...@@ -18,6 +18,7 @@ import {
18 type ModelSelection,18 type ModelSelection,
19 type ProviderInstanceConfig,19 type ProviderInstanceConfig,
20 type ProviderInstanceEnvironmentVariable,20 type ProviderInstanceEnvironmentVariable,
21 type UsageLimitSourceConfig,
21 ProviderDriverKind,22 ProviderDriverKind,
22 ProviderInstanceId,23 ProviderInstanceId,
23 ServerSettings,24 ServerSettings,
...@@ -133,6 +134,17 @@ function providerEnvironmentSecretName(input: {...@@ -133,6 +134,17 @@ function providerEnvironmentSecretName(input: {
133 return `provider-env-${Buffer.from(input.instanceId, "utf8").toString("base64url")}-${Buffer.from(input.name, "utf8").toString("base64url")}`;134 return `provider-env-${Buffer.from(input.instanceId, "utf8").toString("base64url")}-${Buffer.from(input.name, "utf8").toString("base64url")}`;
134}135}
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
136function redactProviderEnvironmentVariable(148function redactProviderEnvironmentVariable(
137 variable: ProviderInstanceEnvironmentVariable,149 variable: ProviderInstanceEnvironmentVariable,
138): ProviderInstanceEnvironmentVariable {150): ProviderInstanceEnvironmentVariable {
...@@ -159,7 +171,17 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS...@@ -159,7 +171,17 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS
159 : instance,171 : instance,
160 ]),172 ]),
161 );173 );
162 return { ...settings, providerInstances };174 // The hub key is a bearer secret; clients only need to know one is set.
175 const usageLimitSources = Object.fromEntries(
176 Object.entries(settings.usageLimitSources).map(([id, source]) => [
177 id,
178 {
179 ...source,
180 managementKey: source.managementKey.length > 0 ? USAGE_LIMIT_SOURCE_KEY_REDACTED : "",
181 },
182 ]),
183 );
184 return { ...settings, providerInstances, usageLimitSources };
163}185}
164186
165export class ServerSettingsService extends Context.Service<187export class ServerSettingsService extends Context.Service<
...@@ -512,9 +534,28 @@ const make = Effect.gen(function* () {...@@ -512,9 +534,28 @@ const make = Effect.gen(function* () {
512 environment,534 environment,
513 } satisfies ProviderInstanceConfig;535 } satisfies ProviderInstanceConfig;
514 }536 }
537 const usageLimitSources: Record<string, UsageLimitSourceConfig> = {};
538 for (const [sourceId, source] of Object.entries(settings.usageLimitSources)) {
539 if (source.managementKey !== USAGE_LIMIT_SOURCE_KEY_REDACTED) {
540 usageLimitSources[sourceId] = source;
541 continue;
542 }
543 const secret = yield* secretStore
544 .get(usageLimitSourceSecretName(sourceId))
545 .pipe(
546 Effect.mapError(
547 (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }),
548 ),
549 );
550 usageLimitSources[sourceId] = {
551 ...source,
552 managementKey: Option.isSome(secret) ? textDecoder.decode(secret.value) : "",
553 };
554 }
515 return {555 return {
516 ...settings,556 ...settings,
517 providerInstances: providerInstances as ServerSettings["providerInstances"],557 providerInstances: providerInstances as ServerSettings["providerInstances"],
558 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],
518 };559 };
519 });560 });
520561
...@@ -630,9 +671,52 @@ const make = Effect.gen(function* () {...@@ -630,9 +671,52 @@ const make = Effect.gen(function* () {
630 }671 }
631 }672 }
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
633 return {716 return {
634 ...next,717 ...next,
635 providerInstances: providerInstances as ServerSettings["providerInstances"],718 providerInstances: providerInstances as ServerSettings["providerInstances"],
719 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],
636 };720 };
637 });721 });
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";...@@ -127,6 +127,7 @@ import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
127import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";127import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
128import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";128import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
129import * as AnalyticsService from "./telemetry/AnalyticsService.ts";129import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
130import * as UsageLimitSources from "./usage/UsageLimitSources.ts";
130import * as UsageService from "./usage/UsageService.ts";131import * as UsageService from "./usage/UsageService.ts";
131import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";132import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
132import * as PullRequestService from "./pullRequest/PullRequestService.ts";133import * as PullRequestService from "./pullRequest/PullRequestService.ts";
...@@ -501,6 +502,7 @@ const makeWsRpcLayer = (...@@ -501,6 +502,7 @@ const makeWsRpcLayer = (
501 const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery;502 const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery;
502 const keybindings = yield* Keybindings.Keybindings;503 const keybindings = yield* Keybindings.Keybindings;
503 const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService;504 const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService;
505 const usageLimitSources = yield* UsageLimitSources.UsageLimitSources;
504 const externalLauncher = yield* ExternalLauncher.ExternalLauncher;506 const externalLauncher = yield* ExternalLauncher.ExternalLauncher;
505 const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets;507 const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets;
506 const gitWorkflow = yield* GitWorkflowService.GitWorkflowService;508 const gitWorkflow = yield* GitWorkflowService.GitWorkflowService;
...@@ -1676,6 +1678,13 @@ const makeWsRpcLayer = (...@@ -1676,6 +1678,13 @@ const makeWsRpcLayer = (
1676 observeRpcEffect(1678 observeRpcEffect(
1677 WS_METHODS.serverRefreshProviders,1679 WS_METHODS.serverRefreshProviders,
1678 Effect.gen(function* () {1680 Effect.gen(function* () {
1681 // An untargeted refresh is "re-read everything's status", which
1682 // includes quota from configured usage-limit sources. Awaited,
1683 // not forked: the RPC scope closes on return and would
1684 // interrupt a fork before the hub answered.
1685 if (input.instanceId === undefined) {
1686 yield* usageLimitSources.refresh;
1687 }
1679 let providers = yield* input.cwd !== undefined && input.instanceId !== undefined1688 let providers = yield* input.cwd !== undefined && input.instanceId !== undefined
1680 ? providerRegistry.refreshWorkspaceSnapshot({1689 ? providerRegistry.refreshWorkspaceSnapshot({
1681 instanceId: input.instanceId,1690 instanceId: input.instanceId,
...@@ -2573,6 +2582,17 @@ const makeWsRpcLayer = (...@@ -2573,6 +2582,17 @@ const makeWsRpcLayer = (
2573 })),2582 })),
2574 )2583 )
2575 : Stream.empty;2584 : Stream.empty;
2585 // Same gate as themes: an older client dies on an unknown event.
2586 const usageLimitSourceUpdates =
2587 input.usageLimitSources === true
2588 ? usageLimitSources.streamChanges.pipe(
2589 Stream.map((sources) => ({
2590 version: 1 as const,
2591 type: "usageLimitSourcesUpdated" as const,
2592 payload: { sources },
2593 })),
2594 )
2595 : Stream.empty;
2576 const settingsUpdates = serverSettings.streamChanges.pipe(2596 const settingsUpdates = serverSettings.streamChanges.pipe(
2577 Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)),2597 Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)),
2578 Stream.map((settings) => ({2598 Stream.map((settings) => ({
...@@ -2590,7 +2610,10 @@ const makeWsRpcLayer = (...@@ -2590,7 +2610,10 @@ const makeWsRpcLayer = (
2590 keybindingsUpdates,2610 keybindingsUpdates,
2591 Stream.merge(2611 Stream.merge(
2592 providerStatuses,2612 providerStatuses,
2593 Stream.merge(settingsUpdates, environmentThemeUpdates),2613 Stream.merge(
2614 settingsUpdates,
2615 Stream.merge(environmentThemeUpdates, usageLimitSourceUpdates),
2616 ),
2594 ),2617 ),
2595 );2618 );
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";...@@ -6,7 +6,10 @@ import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge";
66
7import { isElectron } from "../../env";7import { isElectron } from "../../env";
8import { cn } from "../../lib/utils";8import { cn } from "../../lib/utils";
9import { usePrimaryEnvironmentId } from "../../state/environments";
10import { serverEnvironment } from "../../state/server";
9import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";11import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
12import { useAtomCommand } from "../../state/use-atom-command";
10import {13import {
11 enumerateDays,14 enumerateDays,
12 enumerateHourStarts,15 enumerateHourStarts,
...@@ -32,9 +35,21 @@ import {...@@ -32,9 +35,21 @@ import {
32} from "../WorkspaceBreadcrumb";35} from "../WorkspaceBreadcrumb";
33import { WorkspacePageContainer } from "../WorkspacePageContainer";36import { WorkspacePageContainer } from "../WorkspacePageContainer";
34import { WorkspacePageHeader } from "../WorkspacePageHeader";37import { WorkspacePageHeader } from "../WorkspacePageHeader";
38import { UsageLimitsSection } from "./UsageLimits";
35import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";39import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
36import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders";40import { 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
38const WINDOW_OPTIONS = [53const WINDOW_OPTIONS = [
39 { days: 1, label: "Past 24h" },54 { days: 1, label: "Past 24h" },
40 { days: 7, label: "7 days" },55 { days: 7, label: "7 days" },
...@@ -47,11 +62,16 @@ export function UsagePage() {...@@ -47,11 +62,16 @@ export function UsagePage() {
47 days: 30,62 days: 30,
48 window: makeWindow(30),63 window: makeWindow(30),
49 }));64 }));
50 const [metric, setMetric] = useState<UsageChartMetric>("cost");65 const [metric, setMetric] = useState<UsageMetric>("cost");
66 const showingLimits = metric === "limits";
51 const [breakdown, setBreakdown] = useState<"model" | "time">("model");67 const [breakdown, setBreakdown] = useState<"model" | "time">("model");
52 const { days: windowDays, window } = windowSelection;68 const { days: windowDays, window } = windowSelection;
53 const isPast24Hours = windowDays === 1;69 const isPast24Hours = windowDays === 1;
54 const { merged, environments, isPending, isPartial, refresh } = useUsage(window);70 const { merged, environments, isPending, isPartial, refresh } = useUsage(window);
71 const primaryEnvironmentId = usePrimaryEnvironmentId();
72 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
73 reportFailure: false,
74 });
5575
56 // Hold the content until every environment is terminal. Rendering merged76 // Hold the content until every environment is terminal. Rendering merged
57 // totals while devices are still answering makes every number on the page77 // totals while devices are still answering makes every number on the page
...@@ -94,6 +114,15 @@ export function UsagePage() {...@@ -94,6 +114,15 @@ export function UsagePage() {
94 });114 });
95 };115 };
96 const refreshWindow = () => {116 const refreshWindow = () => {
117 // On Limits the button re-probes every provider (and usage-limit source)
118 // on the primary environment; the live snapshots then flow in over the
119 // config stream, so nothing else needs to move.
120 if (showingLimits) {
121 if (primaryEnvironmentId) {
122 void refreshProviders({ environmentId: primaryEnvironmentId, input: {} });
123 }
124 return;
125 }
97 const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day");126 const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day");
98 if (127 if (
99 nextWindow.sinceDay === window.sinceDay &&128 nextWindow.sinceDay === window.sinceDay &&
...@@ -116,10 +145,14 @@ export function UsagePage() {...@@ -116,10 +145,14 @@ export function UsagePage() {
116 <WorkspaceBreadcrumbItem current>145 <WorkspaceBreadcrumbItem current>
117 <h1>Usage</h1>146 <h1>Usage</h1>
118 </WorkspaceBreadcrumbItem>147 </WorkspaceBreadcrumbItem>
119 <WorkspaceBreadcrumbSeparator className="hidden md:flex" />148 {showingLimits ? null : (
120 <WorkspaceBreadcrumbItem className="hidden min-w-0 shrink md:flex">149 <>
121 <span className="truncate">{windowLabel}</span>150 <WorkspaceBreadcrumbSeparator className="hidden md:flex" />
122 </WorkspaceBreadcrumbItem>151 <WorkspaceBreadcrumbItem className="hidden min-w-0 shrink md:flex">
152 <span className="truncate">{windowLabel}</span>
153 </WorkspaceBreadcrumbItem>
154 </>
155 )}
123 </WorkspaceBreadcrumb>156 </WorkspaceBreadcrumb>
124 <div className="ms-auto hidden min-w-0 items-center justify-end gap-2 lg:flex">157 <div className="ms-auto hidden min-w-0 items-center justify-end gap-2 lg:flex">
125 <ToggleGroup158 <ToggleGroup
...@@ -128,19 +161,22 @@ export function UsagePage() {...@@ -128,19 +161,22 @@ export function UsagePage() {
128 value={[metric]}161 value={[metric]}
129 onValueChange={(next) => {162 onValueChange={(next) => {
130 const value = next[0];163 const value = next[0];
131 if (value === "cost" || value === "tokens") setMetric(value);164 if (isUsageMetric(value)) setMetric(value);
132 }}165 }}
133 >166 >
134 {(["cost", "tokens"] as const).map((option) => (167 {METRIC_OPTIONS.map((option) => (
135 <Toggle key={option} value={option}>168 <Toggle key={option.value} value={option.value}>
136 {option === "cost" ? "Cost" : "Tokens"}169 {option.label}
137 </Toggle>170 </Toggle>
138 ))}171 ))}
139 </ToggleGroup>172 </ToggleGroup>
173 {/* The period does not apply to Limits, so it stays in place but
174 disabled; unmounting it shifted the metric toggle ~300px. */}
140 <ToggleGroup175 <ToggleGroup
141 aria-label="Usage period"176 aria-label="Usage period"
142 variant="segmented"177 variant="segmented"
143 value={[String(windowDays)]}178 value={[String(windowDays)]}
179 disabled={showingLimits}
144 onValueChange={(next) => {180 onValueChange={(next) => {
145 const value = next[0];181 const value = next[0];
146 if (value) selectWindow(Number(value));182 if (value) selectWindow(Number(value));
...@@ -152,7 +188,12 @@ export function UsagePage() {...@@ -152,7 +188,12 @@ export function UsagePage() {
152 </Toggle>188 </Toggle>
153 ))}189 ))}
154 </ToggleGroup>190 </ToggleGroup>
155 <Button onClick={refreshWindow} aria-label="Refresh usage" size="icon-sm" variant="ghost">191 <Button
192 onClick={refreshWindow}
193 aria-label={showingLimits ? "Refresh limits" : "Refresh usage"}
194 size="icon-sm"
195 variant="ghost"
196 >
156 <RefreshCwIcon className="size-3.5" />197 <RefreshCwIcon className="size-3.5" />
157 </Button>198 </Button>
158 </div>199 </div>
...@@ -160,7 +201,7 @@ export function UsagePage() {...@@ -160,7 +201,7 @@ export function UsagePage() {
160 <Select201 <Select
161 value={metric}202 value={metric}
162 onValueChange={(value) => {203 onValueChange={(value) => {
163 if (value === "cost" || value === "tokens") setMetric(value);204 if (isUsageMetric(value)) setMetric(value);
164 }}205 }}
165 >206 >
166 <SelectTrigger207 <SelectTrigger
...@@ -169,14 +210,23 @@ export function UsagePage() {...@@ -169,14 +210,23 @@ export function UsagePage() {
169 variant="ghost"210 variant="ghost"
170 className="w-auto min-w-0"211 className="w-auto min-w-0"
171 >212 >
172 <SelectValue>{metric === "cost" ? "Cost" : "Tokens"}</SelectValue>213 <SelectValue>
214 {METRIC_OPTIONS.find((option) => option.value === metric)?.label}
215 </SelectValue>
173 </SelectTrigger>216 </SelectTrigger>
174 <SelectPopup align="end" alignItemWithTrigger={false}>217 <SelectPopup align="end" alignItemWithTrigger={false}>
175 <SelectItem value="cost">Cost</SelectItem>218 {METRIC_OPTIONS.map((option) => (
176 <SelectItem value="tokens">Tokens</SelectItem>219 <SelectItem key={option.value} value={option.value}>
220 {option.label}
221 </SelectItem>
222 ))}
177 </SelectPopup>223 </SelectPopup>
178 </Select>224 </Select>
179 <Select value={String(windowDays)} onValueChange={(value) => selectWindow(Number(value))}>225 <Select
226 value={String(windowDays)}
227 disabled={showingLimits}
228 onValueChange={(value) => selectWindow(Number(value))}
229 >
180 <SelectTrigger230 <SelectTrigger
181 aria-label="Usage period"231 aria-label="Usage period"
182 size="compact"232 size="compact"
...@@ -195,7 +245,12 @@ export function UsagePage() {...@@ -195,7 +245,12 @@ export function UsagePage() {
195 ))}245 ))}
196 </SelectPopup>246 </SelectPopup>
197 </Select>247 </Select>
198 <Button onClick={refreshWindow} aria-label="Refresh usage" size="icon-sm" variant="ghost">248 <Button
249 onClick={refreshWindow}
250 aria-label={showingLimits ? "Refresh limits" : "Refresh usage"}
251 size="icon-sm"
252 variant="ghost"
253 >
199 <RefreshCwIcon className="size-3.5" />254 <RefreshCwIcon className="size-3.5" />
200 </Button>255 </Button>
201 </div>256 </div>
...@@ -209,7 +264,9 @@ export function UsagePage() {...@@ -209,7 +264,9 @@ export function UsagePage() {
209264
210 <ScrollArea className="min-h-0 flex-1">265 <ScrollArea className="min-h-0 flex-1">
211 <WorkspacePageContainer width="wide">266 <WorkspacePageContainer width="wide">
212 {settling ? (267 {showingLimits ? (
268 <UsageLimitsSection />
269 ) : settling ? (
213 <>270 <>
214 {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null}271 {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null}
215 <UsageSkeleton />272 <UsageSkeleton />
apps/web/src/state/server.ts+1
...@@ -28,6 +28,7 @@ import { environmentSession } from "./session";...@@ -28,6 +28,7 @@ import { environmentSession } from "./session";
28export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, {28export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, {
29 initialConfigValueAtom: environmentSession.initialConfigValueAtom,29 initialConfigValueAtom: environmentSession.initialConfigValueAtom,
30 environmentThemes: true,30 environmentThemes: true,
31 usageLimitSources: true,
31});32});
32export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({33export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({
33 catalogValueAtom: environmentCatalog.catalogValueAtom,34 catalogValueAtom: environmentCatalog.catalogValueAtom,
apps/web/src/timestampFormat.ts+26
...@@ -159,6 +159,32 @@ export function formatDayAwareTimestamp(...@@ -159,6 +159,32 @@ export function formatDayAwareTimestamp(
159 return `${dateFormatter.format(date)} ${time}`;159 return `${dateFormatter.format(date)} ${time}`;
160}160}
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
162/**188/**
163 * Format a relative time string from an ISO date.189 * Format a relative time string from an ISO date.
164 * Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }`190 * Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }`
docs/internals/glossary.md+8
...@@ -117,6 +117,14 @@ Controls how assistant text reaches the thread timeline. In [the contracts][1],...@@ -117,6 +117,14 @@ Controls how assistant text reaches the thread timeline. In [the contracts][1],
117117
118A 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].118A 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
120#### Model manifest128#### Model manifest
121129
122The 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.130The per-driver list of current model slugs that decides which models land in the model picker's legacy section. Bundled at `apps/server/src/provider/model-manifest.json` and refreshed at runtime from the same file on `main`, so classification updates ship as commits instead of releases. See the [provider architecture][16] model manifest section.
docs/user/usage.md+15
...@@ -8,6 +8,21 @@ separate from the raw token cost shown here....@@ -8,6 +8,21 @@ separate from the raw token cost shown here.
8Grok Build totals come from persisted session updates. Interactive turns that never wrote a8Grok Build totals come from persisted session updates. Interactive turns that never wrote a
9completed-turn record will not appear.9completed-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
11Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**,26Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**,
12**30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the27**30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the
13headline and chart. Refreshing rescans every connected environment and refetches model pricing on28headline and chart. Refreshing rescans every connected environment and refetches model pricing on
packages/client-runtime/src/rpc/session.test.ts+3-1
...@@ -714,7 +714,9 @@ describe("RpcSessionFactory", () => {...@@ -714,7 +714,9 @@ describe("RpcSessionFactory", () => {
714 clearVcsRefs: () => Effect.void,714 clearVcsRefs: () => Effect.void,
715 clear: () => Effect.void,715 clear: () => Effect.void,
716 });716 });
717 const configState = yield* makeEnvironmentServerConfigState(true).pipe(717 const configState = yield* makeEnvironmentServerConfigState({
718 environmentThemes: true,
719 }).pipe(
718 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),720 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
719 Effect.provideService(Persistence.EnvironmentCacheStore, cache),721 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
720 );722 );
packages/client-runtime/src/state/server.test.ts+2-2
...@@ -590,7 +590,7 @@ describe("server state projection", () => {...@@ -590,7 +590,7 @@ describe("server state projection", () => {
590590
591 yield* Effect.scoped(591 yield* Effect.scoped(
592 Effect.gen(function* () {592 Effect.gen(function* () {
593 const state = yield* makeEnvironmentServerConfigState().pipe(593 const state = yield* makeEnvironmentServerConfigState({}).pipe(
594 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),594 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
595 Effect.provideService(Persistence.EnvironmentCacheStore, cache),595 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
596 );596 );
...@@ -650,7 +650,7 @@ describe("server state projection", () => {...@@ -650,7 +650,7 @@ describe("server state projection", () => {
650 });650 });
651651
652 yield* Effect.scoped(652 yield* Effect.scoped(
653 makeEnvironmentServerConfigState().pipe(653 makeEnvironmentServerConfigState({}).pipe(
654 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),654 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
655 Effect.provideService(Persistence.EnvironmentCacheStore, cache),655 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
656 ),656 ),
packages/client-runtime/src/state/server.ts+20-8
...@@ -353,8 +353,13 @@ const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEven...@@ -353,8 +353,13 @@ const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEven
353 config,353 config,
354});354});
355355
356export interface ServerConfigSubscriptionOptions {
357 readonly environmentThemes?: boolean;
358 readonly usageLimitSources?: boolean;
359}
360
356export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")(361export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")(
357 function* (environmentThemes?: boolean) {362 function* (subscription: ServerConfigSubscriptionOptions) {
358 const supervisor = yield* EnvironmentSupervisor;363 const supervisor = yield* EnvironmentSupervisor;
359 const cache = yield* EnvironmentCacheStore;364 const cache = yield* EnvironmentCacheStore;
360 const environmentId = supervisor.target.environmentId;365 const environmentId = supervisor.target.environmentId;
...@@ -415,10 +420,10 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf...@@ -415,10 +420,10 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf
415 Effect.forkScoped,420 Effect.forkScoped,
416 );421 );
417422
418 yield* subscribe(423 yield* subscribe(WS_METHODS.subscribeServerConfig, {
419 WS_METHODS.subscribeServerConfig,424 ...(subscription.environmentThemes === true ? { environmentThemes: true } : {}),
420 environmentThemes === true ? { environmentThemes: true } : {},425 ...(subscription.usageLimitSources === true ? { usageLimitSources: true } : {}),
421 ).pipe(426 }).pipe(
422 Stream.runForEach((event) =>427 Stream.runForEach((event) =>
423 Effect.gen(function* () {428 Effect.gen(function* () {
424 const next = applyServerConfigProjection(yield* SubscriptionRef.get(state), event);429 const next = applyServerConfigProjection(yield* SubscriptionRef.get(state), event);
...@@ -450,12 +455,12 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf...@@ -450,12 +455,12 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf
450455
451export function serverConfigStateChanges(456export function serverConfigStateChanges(
452 environmentId: EnvironmentId,457 environmentId: EnvironmentId,
453 environmentThemes?: boolean,458 subscription: ServerConfigSubscriptionOptions,
454) {459) {
455 return followStreamInEnvironment(460 return followStreamInEnvironment(
456 environmentId,461 environmentId,
457 Stream.unwrap(462 Stream.unwrap(
458 makeEnvironmentServerConfigState(environmentThemes).pipe(463 makeEnvironmentServerConfigState(subscription).pipe(
459 Effect.map((state) =>464 Effect.map((state) =>
460 SubscriptionRef.changes(state).pipe(465 SubscriptionRef.changes(state).pipe(
461 Stream.filterMap((projection) =>466 Stream.filterMap((projection) =>
...@@ -514,6 +519,8 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -514,6 +519,8 @@ export function createServerEnvironmentAtoms<R, E>(
514 * receives the payload.519 * receives the payload.
515 */520 */
516 readonly environmentThemes?: boolean;521 readonly environmentThemes?: boolean;
522 /** Whether this surface renders quota from configured usage-limit sources. */
523 readonly usageLimitSources?: boolean;
517 },524 },
518) {525) {
519 const configScheduler = createAtomCommandScheduler();526 const configScheduler = createAtomCommandScheduler();
...@@ -525,7 +532,12 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -525,7 +532,12 @@ export function createServerEnvironmentAtoms<R, E>(
525 };532 };
526 const configProjectionFamily = Atom.family((environmentId: EnvironmentId) =>533 const configProjectionFamily = Atom.family((environmentId: EnvironmentId) =>
527 runtime534 runtime
528 .atom(serverConfigStateChanges(environmentId, options.environmentThemes))535 .atom(
536 serverConfigStateChanges(environmentId, {
537 ...(options.environmentThemes === true ? { environmentThemes: true } : {}),
538 ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}),
539 }),
540 )
529 .pipe(541 .pipe(
530 Atom.setIdleTTL(5 * 60_000),542 Atom.setIdleTTL(5 * 60_000),
531 Atom.withLabel(`environment-data:server:config-projection:${environmentId}`),543 Atom.withLabel(`environment-data:server:config-projection:${environmentId}`),
packages/client-runtime/src/state/serverConfigProjection.ts+28-8
...@@ -9,12 +9,15 @@ export interface ServerConfigProjection {...@@ -9,12 +9,15 @@ export interface ServerConfigProjection {
99
10/**10/**
11 * Cached config keeps the provider and model catalog available across reconnects.11 * Cached config keeps the provider and model catalog available across reconnects.
12 * Published themes are current machine state, so a cache could restore themes12 * Published themes and usage-limit sources are current machine state, so a
13 * that the machine no longer publishes. Replay sends themes as a separate event.13 * cache could restore a set the machine no longer reports. Replay sends both
14 * as separate events.
14 */15 */
15export function withoutEnvironmentThemes(config: ServerConfig): ServerConfig {16export function withoutEnvironmentThemes(config: ServerConfig): ServerConfig {
16 if (config.environmentThemes === undefined) return config;17 if (config.environmentThemes === undefined && config.usageLimitSources === undefined) {
17 const { environmentThemes: _ephemeral, ...rest } = config;18 return config;
19 }
20 const { environmentThemes: _themes, usageLimitSources: _sources, ...rest } = config;
18 return rest;21 return rest;
19}22}
2023
...@@ -27,13 +30,21 @@ export function applyServerConfigProjection(...@@ -27,13 +30,21 @@ export function applyServerConfigProjection(
27 // Wire snapshots never contain published themes. Keep the previous set30 // Wire snapshots never contain published themes. Keep the previous set
28 // until a capable server sends its authoritative theme event. A legacy31 // until a capable server sends its authoritative theme event. A legacy
29 // server cannot send a later removal, so a downgrade must clear the set.32 // server cannot send a later removal, so a downgrade must clear the set.
30 const carried =33 const capabilities = event.config.environment.capabilities;
31 event.config.environment.capabilities.environmentThemes === true && Option.isSome(current)34 const carriedThemes =
35 capabilities.environmentThemes === true && Option.isSome(current)
32 ? current.value.config.environmentThemes36 ? current.value.config.environmentThemes
33 : undefined;37 : undefined;
38 const carriedSources =
39 capabilities.usageLimitSources === true && Option.isSome(current)
40 ? current.value.config.usageLimitSources
41 : undefined;
34 return Option.some({42 return Option.some({
35 config:43 config: {
36 carried === undefined ? event.config : { ...event.config, environmentThemes: carried },44 ...event.config,
45 ...(carriedThemes === undefined ? {} : { environmentThemes: carriedThemes }),
46 ...(carriedSources === undefined ? {} : { usageLimitSources: carriedSources }),
47 },
37 latestEvent: event,48 latestEvent: event,
38 source: "live" as const,49 source: "live" as const,
39 });50 });
...@@ -75,5 +86,14 @@ export function applyServerConfigProjection(...@@ -75,5 +86,14 @@ export function applyServerConfigProjection(
75 latestEvent: event,86 latestEvent: event,
76 source: "live",87 source: "live",
77 }));88 }));
89 case "usageLimitSourcesUpdated":
90 return Option.map(current, (projection) => ({
91 config: {
92 ...projection.config,
93 usageLimitSources: event.payload.sources.length > 0 ? event.payload.sources : undefined,
94 },
95 latestEvent: event,
96 source: "live",
97 }));
78 }98 }
79}99}
packages/contracts/src/environment.ts+3
...@@ -102,6 +102,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -102,6 +102,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
102 client reconnecting to one must drop published themes rather than keep102 client reconnecting to one must drop published themes rather than keep
103 showing a set nothing will ever update. */103 showing a set nothing will ever update. */
104 environmentThemes: Schema.optionalKey(Schema.Boolean),104 environmentThemes: Schema.optionalKey(Schema.Boolean),
105 /** Server streams quota from configured usage-limit sources. Same
106 version-skew contract as environmentThemes. */
107 usageLimitSources: Schema.optionalKey(Schema.Boolean),
105 /** Server understands thread.pin / thread.unpin commands. Same108 /** Server understands thread.pin / thread.unpin commands. Same
106 version-skew contract as threadSettlement. */109 version-skew contract as threadSettlement. */
107 threadPinning: Schema.optionalKey(Schema.Boolean),110 threadPinning: Schema.optionalKey(Schema.Boolean),
packages/contracts/src/index.ts+2
...@@ -14,6 +14,8 @@ export * from "./provider.ts";...@@ -14,6 +14,8 @@ export * from "./provider.ts";
14export * from "./providerInstance.ts";14export * from "./providerInstance.ts";
15export * from "./providerSetup.ts";15export * from "./providerSetup.ts";
16export * from "./providerRuntime.ts";16export * from "./providerRuntime.ts";
17export * from "./providerUsageLimits.ts";
18export * from "./usageLimitSourceId.ts";
17export * from "./model.ts";19export * from "./model.ts";
18export * from "./keybindings.ts";20export * from "./keybindings.ts";
19export * from "./server.ts";21export * from "./server.ts";
packages/contracts/src/providerRuntime.ts+6-1
...@@ -14,6 +14,7 @@ import {...@@ -14,6 +14,7 @@ import {
14 TurnId,14 TurnId,
15} from "./baseSchemas.ts";15} from "./baseSchemas.ts";
16import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts";16import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts";
17import { ProviderUsageLimitsUpdate } from "./providerUsageLimits.ts";
17import { ProviderApprovalOption } from "./orchestration.ts";18import { ProviderApprovalOption } from "./orchestration.ts";
1819
19const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;20const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
...@@ -750,8 +751,12 @@ const AccountUpdatedPayload = Schema.Struct({...@@ -750,8 +751,12 @@ const AccountUpdatedPayload = Schema.Struct({
750});751});
751export type AccountUpdatedPayload = typeof AccountUpdatedPayload.Type;752export 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 */
753const AccountRateLimitsUpdatedPayload = Schema.Struct({758const AccountRateLimitsUpdatedPayload = Schema.Struct({
754 rateLimits: Schema.Unknown,759 limits: ProviderUsageLimitsUpdate,
755});760});
756export type AccountRateLimitsUpdatedPayload = typeof AccountRateLimitsUpdatedPayload.Type;761export 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...@@ -1116,6 +1116,8 @@ export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerCon
1116 * dropped by old servers.1116 * dropped by old servers.
1117 */1117 */
1118 environmentThemes: Schema.optional(Schema.Boolean),1118 environmentThemes: Schema.optional(Schema.Boolean),
1119 /** Whether this client understands `usageLimitSourcesUpdated` events. */
1120 usageLimitSources: Schema.optional(Schema.Boolean),
1119 }),1121 }),
1120 success: ServerConfigStreamEvent,1122 success: ServerConfigStreamEvent,
1121 error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]),1123 error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]),
packages/contracts/src/server.test.ts+18
...@@ -157,6 +157,24 @@ describe("server config forward compatibility", () => {...@@ -157,6 +157,24 @@ describe("server config forward compatibility", () => {
157157
158 expect(parsed).toEqual([decodedBase]);158 expect(parsed).toEqual([decodedBase]);
159 });159 });
160
161 it("drops usage windows this build cannot decode instead of failing the provider", () => {
162 const parsed = decodeServerProvider({
163 ...baseProviderSnapshot,
164 usageLimits: {
165 checkedAt: "2026-04-10T00:00:00.000Z",
166 windows: [
167 { id: "primary", kind: "session", label: "Session", usedPercent: 12 },
168 { id: "future", kind: "some-future-kind", label: "Future", usedPercent: 1 },
169 { id: "bad", kind: "weekly", label: "Weekly", usedPercent: 120 },
170 ],
171 },
172 });
173
174 expect(parsed.usageLimits?.windows).toEqual([
175 { id: "primary", kind: "session", label: "Session", usedPercent: 12 },
176 ]);
177 });
160});178});
161179
162describe("resolveEnvironmentMachineKind", () => {180describe("resolveEnvironmentMachineKind", () => {
packages/contracts/src/server.ts+25
...@@ -24,6 +24,7 @@ import {...@@ -24,6 +24,7 @@ import {
24import { EditorId, FileManagerRevealKind, RemoteOpenTarget } from "./editor.ts";24import { EditorId, FileManagerRevealKind, RemoteOpenTarget } from "./editor.ts";
25import { ModelCapabilities } from "./model.ts";25import { ModelCapabilities } from "./model.ts";
26import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";26import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
27import { ServerProviderUsageLimits, UsageLimitSourceSnapshots } from "./providerUsageLimits.ts";
27import { ServerSettings } from "./settings.ts";28import { ServerSettings } from "./settings.ts";
2829
29const KeybindingsMalformedConfigIssue = Schema.Struct({30const KeybindingsMalformedConfigIssue = Schema.Struct({
...@@ -227,6 +228,8 @@ export const ServerProvider = Schema.Struct({...@@ -227,6 +228,8 @@ export const ServerProvider = Schema.Struct({
227 ),228 ),
228 skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))),229 skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
229 workspaceSnapshots: Schema.optionalKey(Schema.Array(ServerProviderWorkspaceSnapshot)),230 workspaceSnapshots: Schema.optionalKey(Schema.Array(ServerProviderWorkspaceSnapshot)),
231 // Absent when the driver has no notion of subscription usage.
232 usageLimits: Schema.optional(ServerProviderUsageLimits),
230 versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory),233 versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory),
231 updateState: Schema.optionalKey(ServerProviderUpdateState),234 updateState: Schema.optionalKey(ServerProviderUpdateState),
232});235});
...@@ -581,6 +584,12 @@ export const ServerConfig = Schema.Struct({...@@ -581,6 +584,12 @@ export const ServerConfig = Schema.Struct({
581 * and it stays absent for subscribers that did not opt in.584 * and it stays absent for subscribers that did not opt in.
582 */585 */
583 environmentThemes: Schema.optional(Schema.Array(EnvironmentTheme)),586 environmentThemes: Schema.optional(Schema.Array(EnvironmentTheme)),
587 /**
588 * Quota reported by configured `usageLimitSources`. Like themes, never in
589 * a snapshot: the source stream emits the current set on subscribe, and it
590 * stays absent for subscribers that did not opt in.
591 */
592 usageLimitSources: Schema.optional(UsageLimitSourceSnapshots),
584});593});
585export type ServerConfig = typeof ServerConfig.Type;594export type ServerConfig = typeof ServerConfig.Type;
586595
...@@ -692,12 +701,28 @@ export const ServerConfigStreamEnvironmentThemesUpdatedEvent = Schema.Struct({...@@ -692,12 +701,28 @@ export const ServerConfigStreamEnvironmentThemesUpdatedEvent = Schema.Struct({
692export type ServerConfigStreamEnvironmentThemesUpdatedEvent =701export type ServerConfigStreamEnvironmentThemesUpdatedEvent =
693 typeof ServerConfigStreamEnvironmentThemesUpdatedEvent.Type;702 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
695export const ServerConfigStreamEvent = Schema.Union([719export const ServerConfigStreamEvent = Schema.Union([
696 ServerConfigStreamSnapshotEvent,720 ServerConfigStreamSnapshotEvent,
697 ServerConfigStreamKeybindingsUpdatedEvent,721 ServerConfigStreamKeybindingsUpdatedEvent,
698 ServerConfigStreamProviderStatusesEvent,722 ServerConfigStreamProviderStatusesEvent,
699 ServerConfigStreamSettingsUpdatedEvent,723 ServerConfigStreamSettingsUpdatedEvent,
700 ServerConfigStreamEnvironmentThemesUpdatedEvent,724 ServerConfigStreamEnvironmentThemesUpdatedEvent,
725 ServerConfigStreamUsageLimitSourcesUpdatedEvent,
701]);726]);
702export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type;727export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type;
703728
packages/contracts/src/settings.ts+27
...@@ -3,6 +3,7 @@ import * as Duration from "effect/Duration";...@@ -3,6 +3,7 @@ import * as Duration from "effect/Duration";
3import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
4import * as SchemaTransformation from "effect/SchemaTransformation";4import * as SchemaTransformation from "effect/SchemaTransformation";
5import { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";5import { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";
6import { UsageLimitSourceId } from "./usageLimitSourceId.ts";
6import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts";7import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts";
7import {8import {
8 DEFAULT_TEXT_GENERATION_MODEL,9 DEFAULT_TEXT_GENERATION_MODEL,
...@@ -731,6 +732,21 @@ export const OpenCodeSettings = makeProviderSettingsSchema(...@@ -731,6 +732,21 @@ export const OpenCodeSettings = makeProviderSettingsSchema(
731);732);
732export type OpenCodeSettings = typeof OpenCodeSettings.Type;733export 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
734export const ObservabilitySettings = Schema.Struct({750export const ObservabilitySettings = Schema.Struct({
735 otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),751 otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
736 otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),752 otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
...@@ -909,6 +925,11 @@ export const ServerSettings = Schema.Struct({...@@ -909,6 +925,11 @@ export const ServerSettings = Schema.Struct({
909 Schema.withDecodingDefault(Effect.succeed({})),925 Schema.withDecodingDefault(Effect.succeed({})),
910 ),926 ),
911 observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),927 observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
928 // Keyed by a user-chosen id so a source keeps its rows across edits. Entries
929 // this build cannot decode round-trip untouched, as provider instances do.
930 usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe(
931 Schema.withDecodingDefault(Effect.succeed({})),
932 ),
912});933});
913export type ServerSettings = typeof ServerSettings.Type;934export type ServerSettings = typeof ServerSettings.Type;
914935
...@@ -1114,6 +1135,12 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -1114,6 +1135,12 @@ export const ServerSettingsPatch = Schema.Struct({
1114 // patches risk leaving driver-specific config in a half-merged state.1135 // patches risk leaving driver-specific config in a half-merged state.
1115 // The web UI sends a fully-formed map every time it edits this field.1136 // The web UI sends a fully-formed map every time it edits this field.
1116 providerInstances: Schema.optionalKey(Schema.Record(ProviderInstanceId, ProviderInstanceConfig)),1137 providerInstances: Schema.optionalKey(Schema.Record(ProviderInstanceId, ProviderInstanceConfig)),
1138 // Per-entry, unlike `providerInstances`: a client only ever adds or removes
1139 // one source, and sending the whole map races another edit that has not
1140 // echoed back yet. `null` removes; the server merges into its current map.
1141 usageLimitSources: Schema.optionalKey(
1142 Schema.Record(UsageLimitSourceId, Schema.NullOr(UsageLimitSourceConfig)),
1143 ),
1117});1144});
1118export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;1145export 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 @@...@@ -243,6 +243,10 @@
243 "types": "./src/usageFormat.ts",243 "types": "./src/usageFormat.ts",
244 "import": "./src/usageFormat.ts"244 "import": "./src/usageFormat.ts"
245 },245 },
246 "./usageLimits": {
247 "types": "./src/usageLimits.ts",
248 "import": "./src/usageLimits.ts"
249 },
246 "./desktopAppControl": {250 "./desktopAppControl": {
247 "types": "./src/desktopAppControl.ts",251 "types": "./src/desktopAppControl.ts",
248 "import": "./src/desktopAppControl.ts"252 "import": "./src/desktopAppControl.ts"
packages/shared/src/serverSettings.test.ts+24
...@@ -2,6 +2,7 @@ import {...@@ -2,6 +2,7 @@ import {
2 DEFAULT_SERVER_SETTINGS,2 DEFAULT_SERVER_SETTINGS,
3 ProviderDriverKind,3 ProviderDriverKind,
4 ProviderInstanceId,4 ProviderInstanceId,
5 UsageLimitSourceId,
5 type ServerProvider,6 type ServerProvider,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
7import * as Duration from "effect/Duration";8import * as Duration from "effect/Duration";
...@@ -298,6 +299,29 @@ describe("serverSettings helpers", () => {...@@ -298,6 +299,29 @@ describe("serverSettings helpers", () => {
298 });299 });
299 });300 });
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
301 it("stores background activity profiles as a versioned object and syncs legacy aliases", () => {325 it("stores background activity profiles as a versioned object and syncs legacy aliases", () => {
302 const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {326 const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
303 backgroundActivity: {327 backgroundActivity: {
packages/shared/src/serverSettings.ts+26
...@@ -122,6 +122,22 @@ function mergeModelSelectionOptionsById(input: {...@@ -122,6 +122,22 @@ function mergeModelSelectionOptionsById(input: {
122 return [...merged.entries()].map(([id, value]) => ({ id, value }));122 return [...merged.entries()].map(([id, value]) => ({ id, value }));
123}123}
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
125export function applyServerSettingsPatch(141export function applyServerSettingsPatch(
126 current: ServerSettings,142 current: ServerSettings,
127 patch: ServerSettingsPatch,143 patch: ServerSettingsPatch,
...@@ -132,6 +148,8 @@ export function applyServerSettingsPatch(...@@ -132,6 +148,8 @@ export function applyServerSettingsPatch(
132 providerHealthRefreshInterval,148 providerHealthRefreshInterval,
133 backgroundActivityProfile,149 backgroundActivityProfile,
134 backgroundActivity,150 backgroundActivity,
151 // Merged per entry below; its `null` removals must not reach deepMerge.
152 usageLimitSources: usageLimitSourcesPatch,
135 ...patchForMerge153 ...patchForMerge
136 } = patch;154 } = patch;
137 const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current);155 const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current);
...@@ -188,6 +206,14 @@ export function applyServerSettingsPatch(...@@ -188,6 +206,14 @@ export function applyServerSettingsPatch(
188 ...(patch.providerInstances !== undefined206 ...(patch.providerInstances !== undefined
189 ? { providerInstances: patch.providerInstances }207 ? { providerInstances: patch.providerInstances }
190 : {}),208 : {}),
209 ...(usageLimitSourcesPatch !== undefined
210 ? {
211 usageLimitSources: mergeUsageLimitSources(
212 current.usageLimitSources,
213 usageLimitSourcesPatch,
214 ),
215 }
216 : {}),
191 ...(patch.sourceControlWriterModelSelection !== undefined217 ...(patch.sourceControlWriterModelSelection !== undefined
192 ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection }218 ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection }
193 : {}),219 : {}),
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}