authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-06 16:30:10-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 16:30:10-07:00
log0a89364f153f7ba890ae4dedd33d16675d73744f
tree4928eb4d489fdf74e78fc50e2f3e9b005e6be32d
parent45387700bf49401fd9de4f2db36575e50f9649eb
signature Signed by PGP key B5690EEEBB952194

fix(usage): read and redeem hub reset credits through CLIProxyAPI (#10395)


18 files changed, 978 insertions(+), 398 deletions(-)

apps/mobile/src/features/threads/ComposerUsageLimits.tsx+5-2
...@@ -42,6 +42,9 @@ export function ComposerUsageLimits({...@@ -42,6 +42,9 @@ export function ComposerUsageLimits({
42 style={{ maxHeight: Math.round(height * 0.4) }}42 style={{ maxHeight: Math.round(height * 0.4) }}
43 >43 >
44 {report.accounts.map((account, index) => {44 {report.accounts.map((account, index) => {
45 const resetCreditInput =
46 account.resetCreditInput ??
47 (account.instanceId ? { instanceId: account.instanceId } : undefined);
45 const driverLabel = DRIVER_LABEL[account.driver] ?? String(account.driver);48 const driverLabel = DRIVER_LABEL[account.driver] ?? String(account.driver);
46 return (49 return (
47 <AccountLimits50 <AccountLimits
...@@ -65,11 +68,11 @@ export function ComposerUsageLimits({...@@ -65,11 +68,11 @@ export function ComposerUsageLimits({
65 now={now}68 now={now}
66 trailing={index === 0 ? close : undefined}69 trailing={index === 0 ? close : undefined}
67 footer={70 footer={
68 account.instanceId && account.limits.resetCredits ? (71 resetCreditInput && account.limits.resetCredits ? (
69 <ResetCredits72 <ResetCredits
70 dense73 dense
71 environmentId={environmentId}74 environmentId={environmentId}
72 instanceId={account.instanceId}75 input={resetCreditInput}
73 credits={account.limits.resetCredits}76 credits={account.limits.resetCredits}
74 now={now}77 now={now}
75 />78 />
apps/mobile/src/features/usage/UsageLimitsPooled.tsx+3-3
...@@ -140,7 +140,7 @@ function PoolWindowCard({...@@ -140,7 +140,7 @@ function PoolWindowCard({
140 </View>140 </View>
141 <View>141 <View>
142 {pool.members.map(({ account, window }, index) => {142 {pool.members.map(({ account, window }, index) => {
143 const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0;143 const credits = account.limits.resetCredits?.availableCount ?? 0;
144 const resetsIn = formatResetsIn(window, now);144 const resetsIn = formatResetsIn(window, now);
145 return (145 return (
146 <Pressable146 <Pressable
...@@ -358,9 +358,9 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) {...@@ -358,9 +358,9 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) {
358 <View className="gap-3 rounded-[24px] border-continuous bg-card p-4">358 <View className="gap-3 rounded-[24px] border-continuous bg-card p-4">
359 <Text className="text-sm font-t3-medium text-foreground">Reset credits</Text>359 <Text className="text-sm font-t3-medium text-foreground">Reset credits</Text>
360 <ResetCredits360 <ResetCredits
361 key={`${account.redeem.environmentId}:${account.redeem.instanceId}`}361 key={account.key}
362 environmentId={account.redeem.environmentId}362 environmentId={account.redeem.environmentId}
363 instanceId={account.redeem.instanceId}363 input={account.redeem.input}
364 credits={account.limits.resetCredits}364 credits={account.limits.resetCredits}
365 now={now}365 now={now}
366 />366 />
apps/mobile/src/features/usage/UsageLimitsSection.tsx+5-5
...@@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react";...@@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
2import type {2import type {
3 EnvironmentId,3 EnvironmentId,
4 ProviderConsumeResetCreditOutcome,4 ProviderConsumeResetCreditOutcome,
5 ProviderInstanceId,5 ProviderConsumeResetCreditInput,
6 ServerProvider,6 ServerProvider,
7 ServerProviderResetCredits,7 ServerProviderResetCredits,
8 ServerProviderUsageWindow,8 ServerProviderUsageWindow,
...@@ -168,13 +168,13 @@ const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = {...@@ -168,13 +168,13 @@ const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = {
168 */168 */
169export function ResetCredits(props: {169export function ResetCredits(props: {
170 readonly environmentId: EnvironmentId;170 readonly environmentId: EnvironmentId;
171 readonly instanceId: ProviderInstanceId;171 readonly input: ProviderConsumeResetCreditInput;
172 readonly credits: ServerProviderResetCredits;172 readonly credits: ServerProviderResetCredits;
173 readonly now: number;173 readonly now: number;
174 /** A smaller pill for the composer card. */174 /** A smaller pill for the composer card. */
175 readonly dense?: boolean;175 readonly dense?: boolean;
176}) {176}) {
177 const { environmentId, instanceId, credits, now, dense = false } = props;177 const { environmentId, input, credits, now, dense = false } = props;
178 const consume = useAtomCommand(serverEnvironment.consumeResetCredit, {178 const consume = useAtomCommand(serverEnvironment.consumeResetCredit, {
179 reportFailure: false,179 reportFailure: false,
180 });180 });
...@@ -195,10 +195,10 @@ export function ResetCredits(props: {...@@ -195,10 +195,10 @@ export function ResetCredits(props: {
195 const redeem = async () => {195 const redeem = async () => {
196 setBusy(true);196 setBusy(true);
197 setStatus(null);197 setStatus(null);
198 const result = await consume({ environmentId, input: { instanceId } });198 const result = await consume({ environmentId, input });
199 setBusy(false);199 setBusy(false);
200 if (result._tag === "Success") {200 if (result._tag === "Success") {
201 setStatus(OUTCOME_TEXT[result.value.outcome]);201 setStatus(result.value.warning ?? OUTCOME_TEXT[result.value.outcome]);
202 return;202 return;
203 }203 }
204 setStatus(204 setStatus(
apps/server/src/usage/UsageLimitSources.ts+32-50
...@@ -13,15 +13,16 @@...@@ -13,15 +13,16 @@
13 */13 */
14import {14import {
15 DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL,15 DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL,
16 UsageLimitSourceError,
17 type UsageLimitSourceConsumeResetCreditInput,
18 type ProviderConsumeResetCreditResult,
16 type ServerSettings,19 type ServerSettings,
17 type UsageLimitSourceConfig,20 type UsageLimitSourceConfig,
18 type UsageLimitSourceId,21 type UsageLimitSourceId,
19 type UsageLimitSourceSnapshot,22 type UsageLimitSourceSnapshot,
20} from "@t3tools/contracts";23} from "@t3tools/contracts";
21import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";24import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
22import type * as Cause from "effect/Cause";
23import * as Context from "effect/Context";25import * as Context from "effect/Context";
24import * as Data from "effect/Data";
25import * as DateTime from "effect/DateTime";26import * as DateTime from "effect/DateTime";
26import * as Duration from "effect/Duration";27import * as Duration from "effect/Duration";
27import * as Effect from "effect/Effect";28import * as Effect from "effect/Effect";
...@@ -30,16 +31,11 @@ import * as Layer from "effect/Layer";...@@ -30,16 +31,11 @@ import * as Layer from "effect/Layer";
30import * as PubSub from "effect/PubSub";31import * as PubSub from "effect/PubSub";
31import * as Ref from "effect/Ref";32import * as Ref from "effect/Ref";
32import * as Semaphore from "effect/Semaphore";33import * as Semaphore from "effect/Semaphore";
33import type * as Schema from "effect/Schema";
34import * as Stream from "effect/Stream";34import * as Stream from "effect/Stream";
35import { HttpClient, type HttpClientError, HttpClientResponse } from "effect/unstable/http";
3635
37import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";36import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
38import { ServerSettingsService } from "../serverSettings.ts";37import { ServerSettingsService } from "../serverSettings.ts";
39import { cliproxyStatusToAccounts, decodeCliproxyQuotaStatus } from "./cliproxyUsageLimits.ts";38import { makeCliproxyApi } from "./cliproxyApi.ts";
40
41const FETCH_TIMEOUT = "10 seconds";
42const QUOTA_STATUS_PATH = "/v0/management/quota-scheduler/status";
4339
44export class UsageLimitSources extends Context.Service<40export class UsageLimitSources extends Context.Service<
45 UsageLimitSources,41 UsageLimitSources,
...@@ -49,35 +45,12 @@ export class UsageLimitSources extends Context.Service<...@@ -49,35 +45,12 @@ export class UsageLimitSources extends Context.Service<
49 readonly streamChanges: Stream.Stream<ReadonlyArray<UsageLimitSourceSnapshot>>;45 readonly streamChanges: Stream.Stream<ReadonlyArray<UsageLimitSourceSnapshot>>;
50 /** Re-read every source now. Never fails; failures land on the snapshot. */46 /** Re-read every source now. Never fails; failures land on the snapshot. */
51 readonly refresh: Effect.Effect<void>;47 readonly refresh: Effect.Effect<void>;
48 readonly consumeResetCredit: (
49 input: UsageLimitSourceConsumeResetCreditInput,
50 ) => Effect.Effect<ProviderConsumeResetCreditResult, UsageLimitSourceError>;
52 }51 }
53>()("t3/usage/UsageLimitSources") {}52>()("t3/usage/UsageLimitSources") {}
5453
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 {54function sourceLabel(id: string, config: UsageLimitSourceConfig): string {
82 if (config.label) return config.label;55 if (config.label) return config.label;
83 try {56 try {
...@@ -88,7 +61,7 @@ function sourceLabel(id: string, config: UsageLimitSourceConfig): string {...@@ -88,7 +61,7 @@ function sourceLabel(id: string, config: UsageLimitSourceConfig): string {
88}61}
8962
90export const make = Effect.gen(function* () {63export const make = Effect.gen(function* () {
91 const httpClient = yield* HttpClient.HttpClient;64 const api = yield* makeCliproxyApi;
92 const settingsService = yield* ServerSettingsService;65 const settingsService = yield* ServerSettingsService;
93 const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;66 const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;
94 const stateRef = yield* Ref.make<ReadonlyArray<UsageLimitSourceSnapshot>>([]);67 const stateRef = yield* Ref.make<ReadonlyArray<UsageLimitSourceSnapshot>>([]);
...@@ -106,23 +79,10 @@ export const make = Effect.gen(function* () {...@@ -106,23 +79,10 @@ export const make = Effect.gen(function* () {
106 if (config.managementKey.length === 0) {79 if (config.managementKey.length === 0) {
107 return { ...base, accounts: [], error: "No management key configured." };80 return { ...base, accounts: [], error: "No management key configured." };
108 }81 }
109 const accounts = yield* Effect.try({82 const accounts = yield* api.readAccounts(config).pipe(Effect.result);
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") {83 if (accounts._tag === "Failure") {
124 yield* Effect.logDebug("usage limit source read failed", { id, cause: accounts.failure });84 yield* Effect.logDebug("usage limit source read failed", { id, cause: accounts.failure });
125 return { ...base, accounts: [], error: readFailureMessage(accounts.failure) };85 return { ...base, accounts: [], error: accounts.failure.detail };
126 }86 }
127 return { ...base, accounts: accounts.success };87 return { ...base, accounts: accounts.success };
128 });88 });
...@@ -154,6 +114,27 @@ export const make = Effect.gen(function* () {...@@ -154,6 +114,27 @@ export const make = Effect.gen(function* () {
154 yield* publish(snapshots);114 yield* publish(snapshots);
155 }).pipe(refreshLock.withPermits(1), Effect.ignoreCause({ log: true }));115 }).pipe(refreshLock.withPermits(1), Effect.ignoreCause({ log: true }));
156116
117 // Shares the refresh lock so a stale in-flight read cannot overwrite a redemption.
118 const consumeResetCredit = (input: UsageLimitSourceConsumeResetCreditInput) =>
119 Effect.gen(function* () {
120 const settings = yield* settingsService.getSettings.pipe(
121 Effect.mapError(
122 () => new UsageLimitSourceError({ detail: "Could not read hub settings." }),
123 ),
124 );
125 const config = settings.usageLimitSources[input.sourceId];
126 if (!config?.enabled || !config.managementKey) {
127 return yield* new UsageLimitSourceError({
128 detail: "The usage limit source is missing or disabled.",
129 });
130 }
131 const result = yield* api.consume(config, input.accountId, input.creditId);
132 const snapshot = yield* readSource(input.sourceId, config);
133 const previous = yield* Ref.get(stateRef);
134 yield* publish(previous.map((source) => (source.id === input.sourceId ? snapshot : source)));
135 return result;
136 }).pipe(refreshLock.withPermits(1));
137
157 // Settings edits re-read straight away so a new hub shows up without138 // 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.139 // waiting for the interval, and a removed one leaves the list.
159 yield* settingsService.streamChanges.pipe(140 yield* settingsService.streamChanges.pipe(
...@@ -184,6 +165,7 @@ export const make = Effect.gen(function* () {...@@ -184,6 +165,7 @@ export const make = Effect.gen(function* () {
184165
185 return {166 return {
186 current: Ref.get(stateRef),167 current: Ref.get(stateRef),
168 consumeResetCredit,
187 refresh,169 refresh,
188 get streamChanges() {170 get streamChanges() {
189 return Stream.unwrap(171 return Stream.unwrap(
apps/server/src/usage/cliproxyApi.test.ts created+310
...@@ -0,0 +1,310 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4import * as TestClock from "effect/testing/TestClock";
5import { HttpClient, HttpClientResponse } from "effect/unstable/http";
6
7import { creditRedeemRequestId, makeCliproxyApi } from "./cliproxyApi.ts";
8
9const config = {
10 kind: "cliproxy",
11 url: "http://hub.test:8317",
12 managementKey: "management-secret",
13 enabled: true,
14} as const;
15const accounts = [
16 {
17 id: "first.json",
18 auth_index: "a",
19 provider: "codex",
20 email: "first@example.com",
21 id_token: { chatgpt_account_id: "account-a" },
22 },
23 {
24 id: "second.json",
25 auth_index: "b",
26 provider: "codex",
27 email: "second@example.com",
28 id_token: { chatgpt_account_id: "account-b" },
29 },
30];
31const credit = (id: string, expires_at = "2099-01-01T00:00:00Z") => ({
32 id,
33 expires_at,
34 status: "available",
35 reset_type: "codex_rate_limits",
36});
37const RequestBody = Schema.Struct({
38 auth_index: Schema.String,
39 method: Schema.optional(Schema.String),
40 url: Schema.optional(Schema.String),
41 header: Schema.optional(Schema.Record(Schema.String, Schema.String)),
42 data: Schema.optional(Schema.String),
43});
44type RequestBody = typeof RequestBody.Type;
45const decodeRequest = Schema.decodeUnknownSync(Schema.fromJsonString(RequestBody));
46const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
47
48function fixture(
49 options: {
50 accounts?: Array<(typeof accounts)[number] & { disabled?: boolean }>;
51 upstream?: (request: RequestBody) => { status: number; body: unknown };
52 cooldownStatus?: number;
53 } = {},
54) {
55 const requests: Array<{ path: string; body?: RequestBody }> = [];
56 const http = HttpClient.make((request) =>
57 Effect.sync(() => {
58 expect(request.headers.authorization).toBe("Bearer management-secret");
59 const path = new URL(request.url).pathname;
60 const body =
61 request.body._tag === "Uint8Array"
62 ? decodeRequest(new TextDecoder().decode(request.body.body))
63 : undefined;
64 requests.push({ path, ...(body ? { body } : {}) });
65 if (path.endsWith("/auth-files"))
66 return HttpClientResponse.fromWeb(
67 request,
68 Response.json({ files: options.accounts ?? accounts }),
69 );
70 if (path.endsWith("/reset-quota"))
71 return HttpClientResponse.fromWeb(
72 request,
73 Response.json({}, { status: options.cooldownStatus ?? 200 }),
74 );
75 expect(path).toBe("/v0/management/api-call");
76 expect(body?.header?.Authorization).toBe("Bearer $TOKEN$");
77 const upstream = options.upstream?.(body!) ?? {
78 status: 200,
79 body: body?.url?.endsWith("/consume")
80 ? { code: "reset" }
81 : body?.url?.endsWith("/rate-limit-reset-credits")
82 ? {
83 credits: [
84 credit("later", "2099-02-01T00:00:00Z"),
85 credit("first"),
86 credit("expired", "2000-01-01T00:00:00Z"),
87 { ...credit("used"), status: "redeemed" },
88 ],
89 }
90 : {
91 plan_type: "pro",
92 rate_limit: {
93 secondary_window: {
94 used_percent: 78,
95 reset_at: 4070908800,
96 limit_window_seconds: 604800,
97 },
98 },
99 },
100 };
101 return HttpClientResponse.fromWeb(
102 request,
103 Response.json({ status_code: upstream.status, body: encodeJson(upstream.body) }),
104 );
105 }),
106 );
107 return {
108 requests,
109 api: makeCliproxyApi.pipe(Effect.provideService(HttpClient.HttpClient, http)),
110 };
111}
112
113describe("CLIProxyAPI built-in management API", () => {
114 it.effect(
115 "reads both accounts and their earliest unexpired credits without plugin endpoints",
116 () =>
117 Effect.gen(function* () {
118 yield* TestClock.setTime(1788710400000);
119 const test = fixture();
120 const api = yield* test.api;
121 const result = yield* api.readAccounts(config);
122 expect(result.map((account) => account.usageLimits.resetCredits)).toEqual([
123 { availableCount: 2, nextCreditId: "first", nextExpiresAt: "2099-01-01T00:00:00.000Z" },
124 { availableCount: 2, nextCreditId: "first", nextExpiresAt: "2099-01-01T00:00:00.000Z" },
125 ]);
126 expect(result[0]?.usageLimits.windows).toMatchObject([
127 { id: "secondary", usedPercent: 78, kind: "weekly" },
128 ]);
129 const calls = test.requests.filter((request) => request.body?.url);
130 expect(calls.map((request) => request.body?.auth_index).sort()).toEqual([
131 "a",
132 "a",
133 "b",
134 "b",
135 ]);
136 expect(
137 calls.find((request) => request.body?.auth_index === "b")?.body?.header?.[
138 "Chatgpt-Account-Id"
139 ],
140 ).toBe("account-b");
141 }),
142 );
143
144 it.effect("keeps usage when the credits endpoint fails", () =>
145 Effect.gen(function* () {
146 const test = fixture({
147 upstream: (request) =>
148 request.url?.endsWith("rate-limit-reset-credits")
149 ? { status: 503, body: { token: "do-not-publish" } }
150 : { status: 200, body: { rate_limit: { primary_window: { used_percent: 12 } } } },
151 });
152 const api = yield* test.api;
153 const result = yield* api.readAccounts(config);
154 expect(result[0]?.usageLimits.windows[0]?.usedPercent).toBe(12);
155 expect(result[0]?.usageLimits.resetCredits).toBeUndefined();
156 }),
157 );
158
159 it.effect("isolates a failed account and never publishes upstream error bodies", () =>
160 Effect.gen(function* () {
161 const test = fixture({
162 upstream: (request) =>
163 request.auth_index === "a"
164 ? { status: 401, body: { token: "do-not-publish" } }
165 : {
166 status: 200,
167 body: request.url?.endsWith("rate-limit-reset-credits")
168 ? { credits: [] }
169 : { rate_limit: { primary_window: { used_percent: 12 } } },
170 },
171 });
172 const api = yield* test.api;
173 const result = yield* api.readAccounts(config);
174 expect(result[0]?.usageLimits.unavailable?.reason).toBe("probeFailed");
175 expect(result[1]?.usageLimits.windows[0]?.usedPercent).toBe(12);
176 expect(encodeJson(result)).not.toContain("do-not-publish");
177 }),
178 );
179
180 it.effect("maps Claude scoped windows without a scheduler plugin", () =>
181 Effect.gen(function* () {
182 const test = fixture({
183 accounts: [{ ...accounts[0]!, provider: "claude" }],
184 upstream: () => ({
185 status: 200,
186 body: {
187 five_hour: { utilization: 10, resets_at: null },
188 seven_day: { utilization: 50, resets_at: "2099-01-01T00:00:00Z" },
189 limits: [
190 {
191 kind: "weekly_scoped",
192 percent: 80,
193 resets_at: null,
194 scope: { model: { display_name: "Fable" } },
195 },
196 ],
197 },
198 }),
199 });
200 const api = yield* test.api;
201 const result = yield* api.readAccounts(config);
202 expect(
203 result[0]?.usageLimits.windows.map((window) => [window.id, window.usedPercent]),
204 ).toEqual([
205 ["five_hour", 10],
206 ["seven_day", 50],
207 ["seven_day_fable", 80],
208 ]);
209 }),
210 );
211
212 it.effect("pins redemption to the displayed credit and clears only that account's cooldown", () =>
213 Effect.gen(function* () {
214 const test = fixture();
215 const api = yield* test.api;
216 expect(yield* api.consume(config, "second.json", "credit-b")).toEqual({ outcome: "reset" });
217 expect(yield* api.consume(config, "second.json", "credit-b")).toEqual({ outcome: "reset" });
218 const redemptions = test.requests.filter((request) =>
219 request.body?.url?.endsWith("/consume"),
220 );
221 expect(redemptions).toHaveLength(2);
222 expect(redemptions[0]?.body?.data).toBe(redemptions[1]?.body?.data);
223 expect(redemptions[0]?.body?.data).toBe(
224 encodeJson({
225 redeem_request_id: creditRedeemRequestId("account-b", "credit-b"),
226 credit_id: "credit-b",
227 }),
228 );
229 expect(
230 test.requests
231 .filter((request) => request.path.endsWith("/reset-quota"))
232 .map((request) => request.body?.auth_index),
233 ).toEqual(["b", "b"]);
234 }),
235 );
236
237 for (const [code, outcome] of [
238 ["nothing_to_reset", "nothingToReset"],
239 ["no_credit", "noCredit"],
240 ["already_redeemed", "alreadyRedeemed"],
241 ] as const) {
242 it.effect(`reports ${code} accurately`, () =>
243 Effect.gen(function* () {
244 const test = fixture({ upstream: () => ({ status: 200, body: { code } }) });
245 const api = yield* test.api;
246 expect(yield* api.consume(config, "first.json", "credit")).toEqual({ outcome });
247 expect(test.requests.some((request) => request.path.endsWith("/reset-quota"))).toBe(
248 code === "already_redeemed",
249 );
250 }),
251 );
252 }
253
254 it.effect("reports redemption success even if cooldown clearing fails", () =>
255 Effect.gen(function* () {
256 const api = yield* fixture({ cooldownStatus: 404 }).api;
257 const result = yield* api.consume(config, "first.json", "credit");
258 expect(result.outcome).toBe("reset");
259 expect(result.warning).toContain("cooldown");
260 }),
261 );
262
263 it.effect("skips disabled accounts and rejects redemption on them", () =>
264 Effect.gen(function* () {
265 const test = fixture({ accounts: [{ ...accounts[0]!, disabled: true }] });
266 const api = yield* test.api;
267 expect(yield* api.readAccounts(config)).toEqual([]);
268 expect((yield* api.consume(config, "first.json", "credit").pipe(Effect.result))._tag).toBe(
269 "Failure",
270 );
271 expect(test.requests.every((request) => request.path.endsWith("/auth-files"))).toBe(true);
272 }),
273 );
274
275 it.effect("keeps the same redemption id after an uncertain upstream failure", () =>
276 Effect.gen(function* () {
277 let attempts = 0;
278 const test = fixture({
279 upstream: () =>
280 ++attempts === 1
281 ? { status: 503, body: {} }
282 : { status: 200, body: { code: "already_redeemed" } },
283 });
284 const api = yield* test.api;
285 expect((yield* api.consume(config, "first.json", "credit").pipe(Effect.result))._tag).toBe(
286 "Failure",
287 );
288 expect(yield* api.consume(config, "first.json", "credit")).toEqual({
289 outcome: "alreadyRedeemed",
290 });
291 const data = test.requests
292 .filter((request) => request.body?.url?.endsWith("/consume"))
293 .map((request) => request.body?.data);
294 expect(data[0]).toBe(data[1]);
295 expect(test.requests.filter((request) => request.path.endsWith("/reset-quota"))).toHaveLength(
296 1,
297 );
298 }),
299 );
300
301 it.effect("rejects unknown accounts without forwarding a redemption", () =>
302 Effect.gen(function* () {
303 const test = fixture();
304 const api = yield* test.api;
305 const result = yield* api.consume(config, "missing.json", "credit").pipe(Effect.result);
306 expect(result._tag).toBe("Failure");
307 expect(test.requests).toHaveLength(1);
308 }),
309 );
310});
apps/server/src/usage/cliproxyApi.ts created+366
...@@ -0,0 +1,366 @@
1import * as NodeCrypto from "node:crypto";
2
3import {
4 ProviderDriverKind,
5 UsageLimitSourceError,
6 type ProviderConsumeResetCreditResult,
7 type UsageLimitSourceAccount,
8 type UsageLimitSourceConfig,
9} from "@t3tools/contracts";
10import * as DateTime from "effect/DateTime";
11import * as Effect from "effect/Effect";
12import * as Schema from "effect/Schema";
13import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
14
15import { codexPlanLabel } from "../provider/Layers/CodexProvider.ts";
16import { codexRateLimitsToLimits } from "../provider/Layers/codexUsageLimits.ts";
17import { claudeUsageResponseToLimits } from "../provider/Layers/claudeUsageLimits.ts";
18import { makeUnavailableUsageLimits } from "../provider/providerUsageLimits.ts";
19
20const AuthFile = Schema.Struct({
21 id: Schema.String,
22 auth_index: Schema.String,
23 provider: Schema.String,
24 email: Schema.optional(Schema.String),
25 disabled: Schema.optional(Schema.Boolean),
26 id_token: Schema.optional(
27 Schema.Struct({
28 chatgpt_account_id: Schema.optional(Schema.String),
29 chatgpt_plan_type: Schema.optional(Schema.String),
30 }),
31 ),
32});
33const AuthFiles = Schema.Struct({ files: Schema.Array(AuthFile) });
34const ApiResponse = Schema.Struct({ status_code: Schema.Number, body: Schema.String });
35const CodexWindow = Schema.Struct({
36 used_percent: Schema.Number,
37 reset_at: Schema.optional(Schema.NullOr(Schema.Number)),
38 limit_window_seconds: Schema.optional(Schema.Number),
39});
40const CodexUsage = Schema.Struct({
41 plan_type: Schema.optional(Schema.String),
42 rate_limit: Schema.NullOr(
43 Schema.Struct({
44 primary_window: Schema.optional(Schema.NullOr(CodexWindow)),
45 secondary_window: Schema.optional(Schema.NullOr(CodexWindow)),
46 }),
47 ),
48});
49const ClaudeWindow = Schema.Struct({
50 utilization: Schema.Number,
51 resets_at: Schema.NullOr(Schema.String),
52});
53const ClaudeUsage = Schema.Struct({
54 five_hour: Schema.optional(Schema.NullOr(ClaudeWindow)),
55 seven_day: Schema.optional(Schema.NullOr(ClaudeWindow)),
56 limits: Schema.optional(
57 Schema.Array(
58 Schema.Struct({
59 kind: Schema.String,
60 percent: Schema.optional(Schema.NullOr(Schema.Number)),
61 resets_at: Schema.optional(Schema.NullOr(Schema.String)),
62 scope: Schema.optional(
63 Schema.NullOr(
64 Schema.Struct({
65 model: Schema.optional(Schema.NullOr(Schema.Struct({ display_name: Schema.String }))),
66 }),
67 ),
68 ),
69 }),
70 ),
71 ),
72});
73const CreditList = Schema.Struct({
74 credits: Schema.Array(
75 Schema.Struct({
76 id: Schema.String,
77 status: Schema.String,
78 reset_type: Schema.String,
79 expires_at: Schema.String,
80 }),
81 ),
82});
83
84const decodeAuthFiles = Schema.decodeUnknownEffect(AuthFiles);
85const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
86const decodeApiResponse = Schema.decodeUnknownEffect(ApiResponse);
87const decodeCreditList = Schema.decodeUnknownEffect(Schema.fromJsonString(CreditList));
88const decodeClaudeUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(ClaudeUsage));
89const decodeCodexUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(CodexUsage));
90const isUsageLimitSourceError = Schema.is(UsageLimitSourceError);
91const decodeConsumeResponse = Schema.decodeUnknownEffect(
92 Schema.fromJsonString(
93 Schema.Struct({
94 code: Schema.Literals(["reset", "nothing_to_reset", "no_credit", "already_redeemed"]),
95 }),
96 ),
97);
98
99const CODEX_BASE = "https://chatgpt.com/backend-api/wham";
100const CREDIT_URL = `${CODEX_BASE}/rate-limit-reset-credits`;
101
102// UUIDv5 per account and credit also deduplicates retries across T3 environments.
103export function creditRedeemRequestId(accountId: string, creditId: string): string {
104 const bytes = NodeCrypto.createHash("sha1")
105 .update(Buffer.from("6f1c2a9e2d4b4c1e9a7f3b8d5e0c1a42", "hex"))
106 .update(`${accountId}:${creditId}`)
107 .digest()
108 .subarray(0, 16);
109 bytes[6] = (bytes[6]! & 0x0f) | 0x50;
110 bytes[8] = (bytes[8]! & 0x3f) | 0x80;
111 const hex = bytes.toString("hex");
112 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
113}
114
115export const makeCliproxyApi = Effect.gen(function* () {
116 const client = yield* HttpClient.HttpClient;
117
118 const management = Effect.fn("CliproxyApi.management")(function* (
119 config: UsageLimitSourceConfig,
120 path: string,
121 body?: unknown,
122 ) {
123 const url = yield* Effect.try({
124 try: () => new URL(`/v0/management/${path}`, config.url).toString(),
125 catch: () => new UsageLimitSourceError({ detail: "The hub URL is not valid." }),
126 });
127 const request = (
128 body === undefined ? HttpClientRequest.get(url) : HttpClientRequest.post(url)
129 ).pipe(HttpClientRequest.setHeader("Authorization", `Bearer ${config.managementKey}`));
130 const response = yield* client
131 .execute(body === undefined ? request : request.pipe(HttpClientRequest.bodyJsonUnsafe(body)))
132 .pipe(
133 Effect.flatMap(HttpClientResponse.filterStatusOk),
134 Effect.flatMap((response) => response.json),
135 Effect.timeout("15 seconds"),
136 Effect.mapError(
137 () => new UsageLimitSourceError({ detail: "The hub management request failed." }),
138 ),
139 );
140 return response;
141 });
142
143 const authFiles = Effect.fn("CliproxyApi.authFiles")(function* (config: UsageLimitSourceConfig) {
144 const response = yield* management(config, "auth-files");
145 return (yield* decodeAuthFiles(response)).files;
146 });
147
148 const apiCall = Effect.fn("CliproxyApi.apiCall")(function* (
149 config: UsageLimitSourceConfig,
150 account: typeof AuthFile.Type,
151 url: string,
152 data?: unknown,
153 ) {
154 const header =
155 account.provider === "codex"
156 ? {
157 Authorization: "Bearer $TOKEN$",
158 "Content-Type": "application/json",
159 "OpenAI-Beta": "codex-1",
160 Originator: "Codex Desktop",
161 ...(account.id_token?.chatgpt_account_id
162 ? { "Chatgpt-Account-Id": account.id_token.chatgpt_account_id }
163 : {}),
164 }
165 : { Authorization: "Bearer $TOKEN$", "anthropic-beta": "oauth-2025-04-20" };
166 const raw = yield* management(config, "api-call", {
167 auth_index: account.auth_index,
168 method: data === undefined ? "GET" : "POST",
169 url,
170 header,
171 ...(data === undefined ? {} : { data: yield* encodeJson(data) }),
172 });
173 const response = yield* decodeApiResponse(raw);
174 if (response.status_code < 200 || response.status_code >= 300) {
175 return yield* new UsageLimitSourceError({
176 detail: `The provider refused the hub request (HTTP ${response.status_code}).`,
177 });
178 }
179 return response.body;
180 });
181
182 const credits = Effect.fn("CliproxyApi.credits")(function* (
183 config: UsageLimitSourceConfig,
184 account: typeof AuthFile.Type,
185 ) {
186 const body = yield* apiCall(config, account, CREDIT_URL);
187 const response = yield* decodeCreditList(body);
188 const now = DateTime.toEpochMillis(yield* DateTime.now);
189 return response.credits
190 .filter(
191 (credit) =>
192 credit.reset_type === "codex_rate_limits" &&
193 credit.status === "available" &&
194 Date.parse(credit.expires_at) > now,
195 )
196 .toSorted((a, b) => Date.parse(a.expires_at) - Date.parse(b.expires_at));
197 });
198
199 const readAccount = Effect.fn("CliproxyApi.readAccount")(function* (
200 config: UsageLimitSourceConfig,
201 account: typeof AuthFile.Type,
202 ) {
203 const checkedAt = DateTime.formatIso(yield* DateTime.now);
204 const base = {
205 id: account.id,
206 driver: ProviderDriverKind.make(account.provider === "codex" ? "codex" : "claudeAgent"),
207 ...(account.email ? { email: account.email } : {}),
208 };
209 const read = Effect.gen(function* () {
210 if (account.provider === "claude") {
211 const body = yield* apiCall(config, account, "https://api.anthropic.com/api/oauth/usage");
212 const usage = yield* decodeClaudeUsage(body);
213 const model_scoped = (usage.limits ?? []).flatMap((limit) =>
214 limit.kind === "weekly_scoped" && limit.scope?.model && typeof limit.percent === "number"
215 ? [
216 {
217 display_name: limit.scope.model.display_name,
218 utilization: limit.percent,
219 resets_at: limit.resets_at ?? null,
220 },
221 ]
222 : [],
223 );
224 return {
225 ...base,
226 plan: "Claude Subscription",
227 usageLimits: claudeUsageResponseToLimits({
228 checkedAt,
229 response: {
230 rate_limits_available: true,
231 rate_limits: {
232 five_hour: usage.five_hour ?? null,
233 seven_day: usage.seven_day ?? null,
234 model_scoped,
235 },
236 },
237 }).limits,
238 };
239 }
240 const body = yield* apiCall(config, account, `${CODEX_BASE}/usage`);
241 const usage = yield* decodeCodexUsage(body);
242 const toWindow = (window: typeof CodexWindow.Type | null | undefined) =>
243 window
244 ? {
245 usedPercent: window.used_percent,
246 resetsAt: window.reset_at ?? null,
247 ...(window.limit_window_seconds === undefined
248 ? {}
249 : { windowDurationMins: window.limit_window_seconds / 60 }),
250 }
251 : null;
252 // A credits outage must not hide successfully fetched quota windows.
253 const available = yield* credits(config, account).pipe(Effect.orElseSucceed(() => undefined));
254 const next = available?.[0];
255 return {
256 ...base,
257 plan: codexPlanLabel(usage.plan_type ?? account.id_token?.chatgpt_plan_type),
258 usageLimits: {
259 ...codexRateLimitsToLimits({
260 checkedAt,
261 snapshot: {
262 planType: usage.plan_type ?? null,
263 primary: toWindow(usage.rate_limit?.primary_window),
264 secondary: toWindow(usage.rate_limit?.secondary_window),
265 },
266 }),
267 ...(available
268 ? {
269 resetCredits: {
270 availableCount: available.length,
271 ...(next
272 ? {
273 nextCreditId: next.id,
274 nextExpiresAt: DateTime.formatIso(DateTime.makeUnsafe(next.expires_at)),
275 }
276 : {}),
277 },
278 }
279 : {}),
280 },
281 };
282 });
283 return yield* read.pipe(
284 Effect.orElseSucceed(() => ({
285 ...base,
286 usageLimits: makeUnavailableUsageLimits({
287 checkedAt,
288 reason: "probeFailed",
289 message: "The hub could not read this account's usage.",
290 }),
291 })),
292 );
293 });
294
295 const readAccounts = Effect.fn("CliproxyApi.readAccounts")(function* (
296 config: UsageLimitSourceConfig,
297 ): Effect.fn.Return<ReadonlyArray<UsageLimitSourceAccount>, UsageLimitSourceError> {
298 const accounts = yield* authFiles(config).pipe(
299 Effect.mapError(
300 () => new UsageLimitSourceError({ detail: "The hub could not list accounts." }),
301 ),
302 );
303 return yield* Effect.forEach(
304 accounts.filter(
305 (account) =>
306 !account.disabled && (account.provider === "codex" || account.provider === "claude"),
307 ),
308 (account) => readAccount(config, account),
309 { concurrency: 4 },
310 );
311 });
312
313 const consume = Effect.fn("CliproxyApi.consume")(function* (
314 config: UsageLimitSourceConfig,
315 accountId: string,
316 creditId: string,
317 ): Effect.fn.Return<ProviderConsumeResetCreditResult, UsageLimitSourceError> {
318 const operation = Effect.gen(function* () {
319 const account = (yield* authFiles(config)).find((account) => account.id === accountId);
320 if (!account || account.disabled || account.provider !== "codex") {
321 return yield* new UsageLimitSourceError({
322 detail: "The Codex hub account is missing or disabled.",
323 });
324 }
325 const body = yield* apiCall(config, account, `${CREDIT_URL}/consume`, {
326 redeem_request_id: creditRedeemRequestId(
327 account.id_token?.chatgpt_account_id ?? account.id,
328 creditId,
329 ),
330 credit_id: creditId,
331 });
332 const response = yield* decodeConsumeResponse(body);
333 const outcome = (
334 {
335 reset: "reset",
336 nothing_to_reset: "nothingToReset",
337 no_credit: "noCredit",
338 already_redeemed: "alreadyRedeemed",
339 } as const
340 )[response.code];
341 if (outcome !== "reset" && outcome !== "alreadyRedeemed") return { outcome };
342 const cleared = yield* management(config, "reset-quota", {
343 auth_index: account.auth_index,
344 }).pipe(Effect.result);
345 return {
346 outcome,
347 ...(cleared._tag === "Failure"
348 ? {
349 warning:
350 "Credit redeemed, but the hub cooldown could not be cleared. Routing may resume after its cooldown expires.",
351 }
352 : {}),
353 } as const;
354 });
355 return yield* operation.pipe(
356 Effect.mapError((error) =>
357 isUsageLimitSourceError(error)
358 ? error
359 : new UsageLimitSourceError({
360 detail: "The hub returned an unexpected reset-credit response.",
361 }),
362 ),
363 );
364 });
365 return { readAccounts, consume };
366});
apps/server/src/usage/cliproxyUsageLimits.test.ts deleted-134
...@@ -1,134 +0,0 @@
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 it("names a Codex five-hour window `primary`, as the Codex driver does", () => {
107 const accounts = cliproxyStatusToAccounts(
108 {
109 accounts: {
110 "codex-abc-someone@example.com-pro.json": {
111 provider: "codex",
112 plan: "pro",
113 five_hour: { hard_limited: false, known: true, used_percent: 40 },
114 },
115 },
116 },
117 checkedAt,
118 );
119 expect(accounts[0]?.usageLimits.windows.map((window) => window.id)).toEqual(["primary"]);
120 });
121});
122
123describe("accountEmailFromAuthFile", () => {
124 it("pulls the email out of the hub's auth file names", () => {
125 expect(accountEmailFromAuthFile("claude-julius@ping.gg.json")).toBe("julius@ping.gg");
126 expect(accountEmailFromAuthFile("codex-e413dce6-julius@ping.gg-pro.json")).toBe(
127 "julius@ping.gg",
128 );
129 expect(accountEmailFromAuthFile("claude-first-last@example.com.json")).toBe(
130 "first-last@example.com",
131 );
132 expect(accountEmailFromAuthFile("mystery.json")).toBeUndefined();
133 });
134});
apps/server/src/usage/cliproxyUsageLimits.ts deleted-162
...@@ -1,162 +0,0 @@
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 // Codex names its five-hour window by position, so a hub row and a
128 // native row for the same account pool together.
129 id: spec.key === "five_hour" && account.provider === "codex" ? "primary" : spec.id,
130 kind: spec.kind,
131 label: spec.label,
132 windowDurationMins: spec.windowDurationMins,
133 // The hub flags a window it has seen a 429 on; the percent may lag.
134 usedPercent: window.hard_limited ? 100 : clampPercent(window.used_percent),
135 ...(resetsAt ? { resetsAt } : {}),
136 });
137 }
138 return makeUsageLimits({ checkedAt: isoFromHub(account.fetched_at) ?? checkedAt, windows });
139}
140
141export function cliproxyStatusToAccounts(
142 status: CliproxyQuotaStatus,
143 checkedAt: string,
144): ReadonlyArray<UsageLimitSourceAccount> {
145 const accounts: UsageLimitSourceAccount[] = [];
146 for (const [fileName, account] of Object.entries(status.accounts)) {
147 const driver = DRIVER_BY_HUB_PROVIDER[account.provider];
148 if (!driver) continue;
149 const email = accountEmailFromAuthFile(fileName);
150 const plan = planLabel(account);
151 accounts.push({
152 id: fileName,
153 driver,
154 ...(email ? { email } : {}),
155 ...(plan ? { plan } : {}),
156 usageLimits: cliproxyAccountToUsageLimits(account, checkedAt),
157 });
158 }
159 return accounts.toSorted(
160 (left, right) => left.driver.localeCompare(right.driver) || left.id.localeCompare(right.id),
161 );
162}
apps/server/src/ws.ts+1
...@@ -1847,6 +1847,7 @@ const makeWsRpcLayer = (...@@ -1847,6 +1847,7 @@ const makeWsRpcLayer = (
1847 observeRpcEffect(1847 observeRpcEffect(
1848 WS_METHODS.providerConsumeResetCredit,1848 WS_METHODS.providerConsumeResetCredit,
1849 Effect.gen(function* () {1849 Effect.gen(function* () {
1850 if ("sourceId" in input) return yield* usageLimitSources.consumeResetCredit(input);
1850 const instance = yield* providerInstances.getInstance(input.instanceId);1851 const instance = yield* providerInstances.getInstance(input.instanceId);
1851 // A disabled instance must not spend anything on its account.1852 // A disabled instance must not spend anything on its account.
1852 if (instance === undefined || !instance.enabled) {1853 if (instance === undefined || !instance.enabled) {
apps/web/src/components/chat/ComposerUsageLimits.tsx+5-2
...@@ -57,6 +57,9 @@ function UsageLimitsBannerBody({...@@ -57,6 +57,9 @@ function UsageLimitsBannerBody({
57 <ComposerBanner.Scroll>57 <ComposerBanner.Scroll>
58 <ComposerBanner.Body className="flex flex-col gap-2 pt-1 pb-1.5 pe-2">58 <ComposerBanner.Body className="flex flex-col gap-2 pt-1 pb-1.5 pe-2">
59 {report.accounts.map((account) => {59 {report.accounts.map((account) => {
60 const resetCreditInput =
61 account.resetCreditInput ??
62 (account.instanceId ? { instanceId: account.instanceId } : undefined);
60 const notice = limitsNotice(account.limits);63 const notice = limitsNotice(account.limits);
61 return (64 return (
62 <div key={account.id} className="flex min-w-0 flex-col gap-1">65 <div key={account.id} className="flex min-w-0 flex-col gap-1">
...@@ -75,10 +78,10 @@ function UsageLimitsBannerBody({...@@ -75,10 +78,10 @@ function UsageLimitsBannerBody({
75 now={now}78 now={now}
76 />79 />
77 )}80 )}
78 {account.instanceId && account.limits.resetCredits ? (81 {resetCreditInput && account.limits.resetCredits ? (
79 <ResetCredits82 <ResetCredits
80 environmentId={environmentId}83 environmentId={environmentId}
81 instanceId={account.instanceId}84 input={resetCreditInput}
82 credits={account.limits.resetCredits}85 credits={account.limits.resetCredits}
83 now={now}86 now={now}
84 />87 />
apps/web/src/components/usage/UsageLimits.tsx+10-10
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import {1import {
2 type EnvironmentId,2 type EnvironmentId,
3 type ProviderConsumeResetCreditOutcome,3 type ProviderConsumeResetCreditOutcome,
4 ProviderInstanceId,4 ProviderConsumeResetCreditInput,
5 ServerProvider,5 ServerProvider,
6 ServerProviderResetCredits,6 ServerProviderResetCredits,
7 ServerProviderUsageWindow,7 ServerProviderUsageWindow,
...@@ -202,7 +202,10 @@ const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = {...@@ -202,7 +202,10 @@ const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = {
202};202};
203203
204/** Everything a redeem needs: where to send it and what to say afterwards. */204/** Everything a redeem needs: where to send it and what to say afterwards. */
205export function useResetCredit(environmentId: EnvironmentId, instanceId: ProviderInstanceId) {205export function useResetCredit(
206 environmentId: EnvironmentId,
207 input: ProviderConsumeResetCreditInput,
208) {
206 const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false });209 const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false });
207 const [confirming, setConfirming] = useState(false);210 const [confirming, setConfirming] = useState(false);
208 const [busy, setBusy] = useState(false);211 const [busy, setBusy] = useState(false);
...@@ -212,10 +215,10 @@ export function useResetCredit(environmentId: EnvironmentId, instanceId: Provide...@@ -212,10 +215,10 @@ export function useResetCredit(environmentId: EnvironmentId, instanceId: Provide
212 setConfirming(false);215 setConfirming(false);
213 setBusy(true);216 setBusy(true);
214 setStatus(null);217 setStatus(null);
215 const result = await consume({ environmentId, input: { instanceId } });218 const result = await consume({ environmentId, input });
216 setBusy(false);219 setBusy(false);
217 if (result._tag === "Success") {220 if (result._tag === "Success") {
218 setStatus(OUTCOME_TEXT[result.value.outcome]);221 setStatus(result.value.warning ?? OUTCOME_TEXT[result.value.outcome]);
219 return;222 return;
220 }223 }
221 setStatus(224 setStatus(
...@@ -282,19 +285,16 @@ export function resetCreditsSummary(...@@ -282,19 +285,16 @@ export function resetCreditsSummary(
282/** Banked reset credits with the redeem button and its confirm, self-contained. */285/** Banked reset credits with the redeem button and its confirm, self-contained. */
283export function ResetCredits({286export function ResetCredits({
284 environmentId,287 environmentId,
285 instanceId,288 input,
286 credits,289 credits,
287 now,290 now,
288}: {291}: {
289 readonly environmentId: EnvironmentId;292 readonly environmentId: EnvironmentId;
290 readonly instanceId: ProviderInstanceId;293 readonly input: ProviderConsumeResetCreditInput;
291 readonly credits: ServerProviderResetCredits;294 readonly credits: ServerProviderResetCredits;
292 readonly now: number;295 readonly now: number;
293}) {296}) {
294 const { confirming, setConfirming, busy, status, redeem } = useResetCredit(297 const { confirming, setConfirming, busy, status, redeem } = useResetCredit(environmentId, input);
295 environmentId,
296 instanceId,
297 );
298 if (credits.availableCount === 0 && status === null) return null;298 if (credits.availableCount === 0 && status === null) return null;
299 return (299 return (
300 <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">300 <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
apps/web/src/components/usage/UsageLimitsPooled.tsx+3-3
...@@ -235,7 +235,7 @@ function PoolSegment({...@@ -235,7 +235,7 @@ function PoolSegment({
235 const [open, setOpen] = useState(false);235 const [open, setOpen] = useState(false);
236 const remaining = remainingPercent(window);236 const remaining = remainingPercent(window);
237 const resetsIn = formatResetsIn(window, now);237 const resetsIn = formatResetsIn(window, now);
238 const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0;238 const credits = account.limits.resetCredits?.availableCount ?? 0;
239 return (239 return (
240 <Popover open={open} onOpenChange={setOpen}>240 <Popover open={open} onOpenChange={setOpen}>
241 <PopoverTrigger241 <PopoverTrigger
...@@ -340,7 +340,7 @@ function LegendRow({...@@ -340,7 +340,7 @@ function LegendRow({
340}) {340}) {
341 const remaining = remainingPercent(window);341 const remaining = remainingPercent(window);
342 const resetsIn = formatResetsIn(window, now);342 const resetsIn = formatResetsIn(window, now);
343 const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0;343 const credits = account.limits.resetCredits?.availableCount ?? 0;
344 return (344 return (
345 <PopoverTrigger345 <PopoverTrigger
346 style={{ gridColumn: "1 / -1", gridRow: index + 1 }}346 style={{ gridColumn: "1 / -1", gridRow: index + 1 }}
...@@ -395,7 +395,7 @@ function RedeemableSegmentPopup({...@@ -395,7 +395,7 @@ function RedeemableSegmentPopup({
395 readonly redeemAt: NonNullable<LimitAccount["redeem"]>;395 readonly redeemAt: NonNullable<LimitAccount["redeem"]>;
396 readonly closePopover: () => void;396 readonly closePopover: () => void;
397}) {397}) {
398 const redeem = useResetCredit(redeemAt.environmentId, redeemAt.instanceId);398 const redeem = useResetCredit(redeemAt.environmentId, redeemAt.input);
399 return (399 return (
400 <>400 <>
401 <PopoverPopup side="top" sideOffset={6}>401 <PopoverPopup side="top" sideOffset={6}>
docs/user/usage.md+4-1
...@@ -67,6 +67,9 @@ using a proxy through `ANTHROPIC_AUTH_TOKEN`....@@ -67,6 +67,9 @@ using a proxy through `ANTHROPIC_AUTH_TOKEN`.
67To see pooled accounts, open **Settings → Providers → Usage providers → Add hub**. Choose the67To see pooled accounts, open **Settings → Providers → Usage providers → Add hub**. Choose the
68environment that will connect to the hub and enter its URL and management key.68environment that will connect to the hub and enter its URL and management key.
6969
70The accounts appear under **Usage → Limits**. This connection supplies usage information; configure70The accounts appear under **Usage → Limits**. Codex accounts show banked reset credits; select an
71account and choose **Use reset** to redeem one. No hub plugin is required.
72
73This connection supplies usage information; configure
71the provider separately to send agent requests through the hub. Remove the hub from the same74the provider separately to send agent requests through the hub. Remove the hub from the same
72settings section when you no longer need it.75settings section when you no longer need it.
packages/client-runtime/src/state/server.ts+2-2
...@@ -975,7 +975,7 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -975,7 +975,7 @@ export function createServerEnvironmentAtoms<R, E>(
975 tag: WS_METHODS.providerAuthStart,975 tag: WS_METHODS.providerAuthStart,
976 concurrency: {976 concurrency: {
977 mode: "singleFlight",977 mode: "singleFlight",
978 key: ({ environmentId, input }) => JSON.stringify([environmentId, input.instanceId]),978 key: ({ environmentId, input }) => JSON.stringify([environmentId, input]),
979 },979 },
980 }),980 }),
981 completeProviderAuth: createEnvironmentRpcCommand(runtime, {981 completeProviderAuth: createEnvironmentRpcCommand(runtime, {
...@@ -1056,7 +1056,7 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -1056,7 +1056,7 @@ export function createServerEnvironmentAtoms<R, E>(
1056 concurrency: {1056 concurrency: {
1057 mode: "singleFlight",1057 mode: "singleFlight",
1058 // Both ids are free-form strings; a delimiter could collide.1058 // Both ids are free-form strings; a delimiter could collide.
1059 key: ({ environmentId, input }) => JSON.stringify([environmentId, input.instanceId]),1059 key: ({ environmentId, input }) => JSON.stringify([environmentId, input]),
1060 },1060 },
1061 }),1061 }),
1062 refreshProviders: createEnvironmentRpcCommand(runtime, {1062 refreshProviders: createEnvironmentRpcCommand(runtime, {
packages/contracts/src/providerUsageLimits.ts+25-2
...@@ -35,6 +35,8 @@ export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type;...@@ -35,6 +35,8 @@ export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type;
35export const ServerProviderResetCredits = Schema.Struct({35export const ServerProviderResetCredits = Schema.Struct({
36 availableCount: NonNegativeInt,36 availableCount: NonNegativeInt,
37 nextExpiresAt: Schema.optional(IsoDateTime),37 nextExpiresAt: Schema.optional(IsoDateTime),
38 /** Pins hub redemption to the displayed credit, including retries from another client. */
39 nextCreditId: Schema.optional(TrimmedNonEmptyString),
38});40});
39export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type;41export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type;
4042
...@@ -103,11 +105,29 @@ export type UsageLimitSourceSnapshot = typeof UsageLimitSourceSnapshot.Type;...@@ -103,11 +105,29 @@ export type UsageLimitSourceSnapshot = typeof UsageLimitSourceSnapshot.Type;
103export const UsageLimitSourceSnapshots = ForwardCompatibleArray(UsageLimitSourceSnapshot);105export const UsageLimitSourceSnapshots = ForwardCompatibleArray(UsageLimitSourceSnapshot);
104export type UsageLimitSourceSnapshots = typeof UsageLimitSourceSnapshots.Type;106export type UsageLimitSourceSnapshots = typeof UsageLimitSourceSnapshots.Type;
105107
106export const ProviderConsumeResetCreditInput = Schema.Struct({108export const UsageLimitSourceConsumeResetCreditInput = Schema.Struct({
107 instanceId: ProviderInstanceId,109 sourceId: UsageLimitSourceId,
110 accountId: TrimmedNonEmptyString,
111 creditId: TrimmedNonEmptyString,
108});112});
113export type UsageLimitSourceConsumeResetCreditInput =
114 typeof UsageLimitSourceConsumeResetCreditInput.Type;
115
116export const ProviderConsumeResetCreditInput = Schema.Union([
117 Schema.Struct({ instanceId: ProviderInstanceId }),
118 UsageLimitSourceConsumeResetCreditInput,
119]);
109export type ProviderConsumeResetCreditInput = typeof ProviderConsumeResetCreditInput.Type;120export type ProviderConsumeResetCreditInput = typeof ProviderConsumeResetCreditInput.Type;
110121
122export class UsageLimitSourceError extends Schema.TaggedErrorClass<UsageLimitSourceError>()(
123 "UsageLimitSourceError",
124 { detail: Schema.String },
125) {
126 override get message(): string {
127 return this.detail;
128 }
129}
130
111/** Mirrors Codex's own outcome set; other providers map onto it. */131/** Mirrors Codex's own outcome set; other providers map onto it. */
112export const ProviderConsumeResetCreditOutcome = Schema.Literals([132export const ProviderConsumeResetCreditOutcome = Schema.Literals([
113 "reset",133 "reset",
...@@ -119,6 +139,8 @@ export type ProviderConsumeResetCreditOutcome = typeof ProviderConsumeResetCredi...@@ -119,6 +139,8 @@ export type ProviderConsumeResetCreditOutcome = typeof ProviderConsumeResetCredi
119139
120export const ProviderConsumeResetCreditResult = Schema.Struct({140export const ProviderConsumeResetCreditResult = Schema.Struct({
121 outcome: ProviderConsumeResetCreditOutcome,141 outcome: ProviderConsumeResetCreditOutcome,
142 /** Redemption succeeded, but a follow-up such as clearing the hub cooldown failed. */
143 warning: Schema.optional(TrimmedNonEmptyString),
122});144});
123export type ProviderConsumeResetCreditResult = typeof ProviderConsumeResetCreditResult.Type;145export type ProviderConsumeResetCreditResult = typeof ProviderConsumeResetCreditResult.Type;
124146
...@@ -134,6 +156,7 @@ export const UsageLimitsReport = Schema.Struct({...@@ -134,6 +156,7 @@ export const UsageLimitsReport = Schema.Struct({
134 email: Schema.optional(TrimmedNonEmptyString),156 email: Schema.optional(TrimmedNonEmptyString),
135 sourceLabel: Schema.optional(TrimmedNonEmptyString),157 sourceLabel: Schema.optional(TrimmedNonEmptyString),
136 instanceId: Schema.optional(ProviderInstanceId),158 instanceId: Schema.optional(ProviderInstanceId),
159 resetCreditInput: Schema.optional(ProviderConsumeResetCreditInput),
137 displayName: Schema.optional(Schema.String),160 displayName: Schema.optional(Schema.String),
138 accentColor: Schema.optional(Schema.String),161 accentColor: Schema.optional(Schema.String),
139 limits: ServerProviderUsageLimits,162 limits: ServerProviderUsageLimits,
packages/contracts/src/rpc.ts+2-1
...@@ -217,6 +217,7 @@ import {...@@ -217,6 +217,7 @@ import {
217 ResourceTelemetrySnapshot,217 ResourceTelemetrySnapshot,
218} from "./resourceTelemetry.ts";218} from "./resourceTelemetry.ts";
219import {219import {
220 UsageLimitSourceError,
220 ProviderConsumeResetCreditInput,221 ProviderConsumeResetCreditInput,
221 ProviderConsumeResetCreditResult,222 ProviderConsumeResetCreditResult,
222} from "./providerUsageLimits.ts";223} from "./providerUsageLimits.ts";
...@@ -432,7 +433,7 @@ const ProviderSetupRpcError = Schema.Union([ProviderSetupError, EnvironmentAutho...@@ -432,7 +433,7 @@ const ProviderSetupRpcError = Schema.Union([ProviderSetupError, EnvironmentAutho
432const WsProviderConsumeResetCreditRpc = Rpc.make(WS_METHODS.providerConsumeResetCredit, {433const WsProviderConsumeResetCreditRpc = Rpc.make(WS_METHODS.providerConsumeResetCredit, {
433 payload: ProviderConsumeResetCreditInput,434 payload: ProviderConsumeResetCreditInput,
434 success: ProviderConsumeResetCreditResult,435 success: ProviderConsumeResetCreditResult,
435 error: ProviderSetupRpcError,436 error: Schema.Union([ProviderSetupError, UsageLimitSourceError, EnvironmentAuthorizationError]),
436});437});
437438
438const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, {439const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, {
packages/shared/src/usageLimits.test.ts+129-3
...@@ -378,7 +378,7 @@ describe("pools", () => {...@@ -378,7 +378,7 @@ describe("pools", () => {
378 key: "env-a:claude",378 key: "env-a:claude",
379 sourceLabel: null,379 sourceLabel: null,
380 // Desktop's read is fresher, so its credits and its redeem are the ones on show.380 // Desktop's read is fresher, so its credits and its redeem are the ones on show.
381 redeem: { environmentId: "env-b", instanceId: "claude" },381 redeem: { environmentId: "env-b", input: { instanceId: "claude" } },
382 environments: [382 environments: [
383 { environmentId: "env-a", label: "Laptop" },383 { environmentId: "env-a", label: "Laptop" },
384 { environmentId: "env-b", label: "Desktop" },384 { environmentId: "env-b", label: "Desktop" },
...@@ -429,7 +429,7 @@ describe("pools", () => {...@@ -429,7 +429,7 @@ describe("pools", () => {
429 const [account] = collectLimitAccounts(input);429 const [account] = collectLimitAccounts(input);
430 expect(account?.limits.windows[0]?.usedPercent).toBe(55);430 expect(account?.limits.windows[0]?.usedPercent).toBe(55);
431 expect(account?.limits.resetCredits?.availableCount).toBe(2);431 expect(account?.limits.resetCredits?.availableCount).toBe(2);
432 expect(account?.redeem).toEqual({ environmentId: "env-a", instanceId: "claude" });432 expect(account?.redeem).toEqual({ environmentId: "env-a", input: { instanceId: "claude" } });
433 expect(account?.environments).toEqual([{ environmentId: "env-a", label: "Laptop" }]);433 expect(account?.environments).toEqual([{ environmentId: "env-a", label: "Laptop" }]);
434 });434 });
435435
...@@ -459,7 +459,108 @@ describe("pools", () => {...@@ -459,7 +459,108 @@ describe("pools", () => {
459 ]);459 ]);
460 const [account] = collectLimitAccounts(input);460 const [account] = collectLimitAccounts(input);
461 expect(account?.limits.resetCredits?.availableCount).toBe(2);461 expect(account?.limits.resetCredits?.availableCount).toBe(2);
462 expect(account?.redeem).toEqual({ environmentId: "env-b", instanceId: "codex" });462 expect(account?.redeem).toEqual({ environmentId: "env-b", input: { instanceId: "codex" } });
463 });
464
465 it("uses the freshest hub credit and its environment even when the account is also native", () => {
466 const native = provider({
467 auth: { status: "authenticated", email: "same@example.com" },
468 usageLimits: { checkedAt, windows: [window], resetCredits: { availableCount: 1 } },
469 });
470 const hubAccount = {
471 id: "codex-same.json",
472 driver: native.driver,
473 email: "same@example.com",
474 usageLimits: {
475 checkedAt: "2026-09-03T11:30:00.000Z",
476 windows: [window],
477 resetCredits: { availableCount: 2, nextCreditId: "credit-2" },
478 },
479 };
480 const input = new Map([
481 [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [native] } }],
482 [
483 EnvironmentId.make("env-b"),
484 {
485 ...laptop,
486 serverConfig: {
487 providers: [],
488 usageLimitSources: [{ ...source, accounts: [hubAccount] }],
489 },
490 },
491 ],
492 ]);
493 const [account] = collectLimitAccounts(input);
494 expect(account?.limits.resetCredits?.availableCount).toBe(2);
495 expect(account?.redeem).toEqual({
496 environmentId: "env-b",
497 input: { sourceId: "hub", accountId: "codex-same.json", creditId: "credit-2" },
498 });
499 hubAccount.usageLimits.resetCredits.availableCount = 0;
500 expect(collectLimitAccounts(input)[0]?.limits.resetCredits?.availableCount).toBe(0);
501 });
502
503 it("keeps distinct hub accounts redeemable through their own source", () => {
504 const hubAccounts = ["first", "second"].map((id) => ({
505 id,
506 driver: ProviderDriverKind.make("codex"),
507 email: `${id}@example.com`,
508 usageLimits: {
509 checkedAt,
510 windows: [window],
511 resetCredits: { availableCount: 2, nextCreditId: `${id}-credit` },
512 },
513 }));
514 const input = new Map([
515 [
516 EnvironmentId.make("env-a"),
517 {
518 ...laptop,
519 serverConfig: {
520 providers: [],
521 usageLimitSources: [{ ...source, accounts: hubAccounts }],
522 },
523 },
524 ],
525 ]);
526 expect(collectLimitAccounts(input).map((account) => account.redeem)).toEqual(
527 hubAccounts.map((account) => ({
528 environmentId: "env-a",
529 input: { sourceId: "hub", accountId: account.id, creditId: `${account.id}-credit` },
530 })),
531 );
532 });
533
534 it("does not give old credits the timestamp of a newer window-only read", () => {
535 const snapshots = [
536 { checkedAt, windows: [window], resetCredits: { availableCount: 2 } },
537 { checkedAt: "2026-09-03T12:00:00.000Z", windows: [window] },
538 {
539 checkedAt: "2026-09-03T11:30:00.000Z",
540 windows: [window],
541 resetCredits: { availableCount: 1 },
542 },
543 ];
544 const input = new Map(
545 snapshots.map((usageLimits, i) => [
546 EnvironmentId.make(`env-${i}`),
547 {
548 ...laptop,
549 serverConfig: {
550 providers: [
551 provider({
552 auth: { status: "authenticated", email: "same@example.com" },
553 usageLimits,
554 }),
555 ],
556 },
557 },
558 ]),
559 );
560 const [account] = collectLimitAccounts(input);
561 expect(account?.limits.checkedAt).toBe("2026-09-03T12:00:00.000Z");
562 expect(account?.limits.resetCredits?.availableCount).toBe(1);
563 expect(account?.redeem?.environmentId).toBe("env-2");
463 });564 });
464565
465 it("names an environment once however many of its instances share the account", () => {566 it("names an environment once however many of its instances share the account", () => {
...@@ -713,6 +814,31 @@ describe("/usage-limits", () => {...@@ -713,6 +814,31 @@ describe("/usage-limits", () => {
713 },814 },
714 ];815 ];
715816
817 it("uses hub credit balances and redemption targets in the composer, including native duplicates", () => {
818 const hubs = sources.map((source) => ({
819 ...source,
820 accounts: source.accounts.map((account) => ({
821 ...account,
822 usageLimits: {
823 ...account.usageLimits,
824 resetCredits: { availableCount: 2, nextCreditId: `${account.id}-credit` },
825 },
826 })),
827 }));
828 const report = collectProviderUsageLimits(selected.instanceId, [selected], hubs, now);
829 expect(report?.accounts[0]?.limits.resetCredits?.availableCount).toBe(2);
830 expect(report?.accounts[0]?.resetCreditInput).toEqual({
831 sourceId: "hub",
832 accountId: "duplicate",
833 creditId: "duplicate-credit",
834 });
835 expect(report?.accounts.find((account) => account.id === "hub:oss")?.resetCreditInput).toEqual({
836 sourceId: "hub",
837 accountId: "oss",
838 creditId: "oss-credit",
839 });
840 });
841
716 it("keeps accounts and custom instances separate, filtering by driver", () => {842 it("keeps accounts and custom instances separate, filtering by driver", () => {
717 const report = collectProviderUsageLimits(843 const report = collectProviderUsageLimits(
718 selected.instanceId,844 selected.instanceId,
packages/shared/src/usageLimits.ts+76-18
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9 type EnvironmentId,9 type EnvironmentId,
10 type UsageLimitsReport,10 type UsageLimitsReport,
11 type ProviderInstanceId,11 type ProviderInstanceId,
12 type ProviderConsumeResetCreditInput,
12 type ServerProviderSlashCommand,13 type ServerProviderSlashCommand,
13 isProviderAvailable,14 isProviderAvailable,
14 type ServerProvider,15 type ServerProvider,
...@@ -170,24 +171,33 @@ export interface LimitAccount {...@@ -170,24 +171,33 @@ export interface LimitAccount {
170 }>;171 }>;
171 /** The hub that reported it, when no environment has it natively. */172 /** The hub that reported it, when no environment has it natively. */
172 readonly sourceLabel: string | null;173 readonly sourceLabel: string | null;
173 /** Where a reset credit can be redeemed; only native instances can. */174 /** Where the displayed reset credit can be redeemed. */
174 readonly redeem: {175 readonly redeem: {
175 readonly environmentId: EnvironmentId;176 readonly environmentId: EnvironmentId;
176 readonly instanceId: ProviderInstanceId;177 readonly input: ProviderConsumeResetCreditInput;
177 } | null;178 } | null;
178 readonly limits: ServerProviderUsageLimits;179 readonly limits: ServerProviderUsageLimits;
179}180}
180181
181/**182/**
182 * Every account with usable windows across the connected environments, one183 * Every account with usable windows across the connected environments, one
183 * entry per distinct account. Native instances win over hub reports, and the184 * entry per distinct account. The freshest reads supply windows and credits;
184 * freshest snapshot wins when the same account is reported twice.185 * native instances supply names and environment labels.
185 */186 */
186export function collectLimitAccounts(187export function collectLimitAccounts(
187 presentations: Parameters<typeof collectLimitSources>[0],188 presentations: Parameters<typeof collectLimitSources>[0],
188): readonly LimitAccount[] {189): readonly LimitAccount[] {
189 const accounts = new Map<string, LimitAccount>();190 const accounts = new Map<string, LimitAccount>();
191 const creditSources = new Map<string, LimitAccount>();
190 const merge = (key: string, next: LimitAccount) => {192 const merge = (key: string, next: LimitAccount) => {
193 const previousCredit = creditSources.get(key);
194 if (
195 next.limits.resetCredits &&
196 (!previousCredit ||
197 Date.parse(next.limits.checkedAt) > Date.parse(previousCredit.limits.checkedAt))
198 ) {
199 creditSources.set(key, next);
200 }
191 const previous = accounts.get(key);201 const previous = accounts.get(key);
192 if (!previous) {202 if (!previous) {
193 accounts.set(key, next);203 accounts.set(key, next);
...@@ -203,13 +213,9 @@ export function collectLimitAccounts(...@@ -203,13 +213,9 @@ export function collectLimitAccounts(
203 ),213 ),
204 ];214 ];
205 const winner = fresher ? next : previous;215 const winner = fresher ? next : previous;
206 // Windows come from the freshest snapshot, wherever it was read. Reset216 // Credits and their redemption target travel together. A failed credit
207 // credits only ever come from a native instance, and the redeem must go217 // probe must not erase a successful read from another environment.
208 // to the instance whose credits are on show, so the two travel together:218 const creditSource = creditSources.get(key);
209 // the freshest native snapshot supplies both, or neither.
210 const native = [previous, next]
211 .filter((candidate) => candidate.redeem !== null)
212 .sort((a, b) => Date.parse(b.limits.checkedAt) - Date.parse(a.limits.checkedAt))[0];
213 accounts.set(key, {219 accounts.set(key, {
214 ...previous,220 ...previous,
215 displayName: previous.displayName ?? next.displayName,221 displayName: previous.displayName ?? next.displayName,
...@@ -218,11 +224,13 @@ export function collectLimitAccounts(...@@ -218,11 +224,13 @@ export function collectLimitAccounts(
218 environments,224 environments,
219 // A hub only names the account when no environment has it natively.225 // A hub only names the account when no environment has it natively.
220 sourceLabel: environments.length > 0 ? null : (previous.sourceLabel ?? next.sourceLabel),226 sourceLabel: environments.length > 0 ? null : (previous.sourceLabel ?? next.sourceLabel),
221 redeem: native?.redeem ?? null,227 redeem: creditSource
228 ? creditSource.redeem
229 : (winner.redeem ?? previous.redeem ?? next.redeem),
222 limits: {230 limits: {
223 ...winner.limits,231 ...winner.limits,
224 ...(native?.limits.resetCredits232 ...(creditSource?.limits.resetCredits
225 ? { resetCredits: native.limits.resetCredits }233 ? { resetCredits: creditSource.limits.resetCredits }
226 : { resetCredits: undefined }),234 : { resetCredits: undefined }),
227 },235 },
228 });236 });
...@@ -243,7 +251,7 @@ export function collectLimitAccounts(...@@ -243,7 +251,7 @@ export function collectLimitAccounts(
243 accentColor: provider.accentColor,251 accentColor: provider.accentColor,
244 environments: [{ environmentId, label }],252 environments: [{ environmentId, label }],
245 sourceLabel: null,253 sourceLabel: null,
246 redeem: { environmentId, instanceId: provider.instanceId },254 redeem: { environmentId, input: { instanceId: provider.instanceId } },
247 limits: provider.usageLimits,255 limits: provider.usageLimits,
248 },256 },
249 );257 );
...@@ -253,7 +261,7 @@ export function collectLimitAccounts(...@@ -253,7 +261,7 @@ export function collectLimitAccounts(
253 // may hold a fresher read of the same subscription, and the merge above261 // may hold a fresher read of the same subscription, and the merge above
254 // keeps the redeem target consistent with whichever snapshot wins.262 // keeps the redeem target consistent with whichever snapshot wins.
255 const labelEnvironment = presentations.size > 1;263 const labelEnvironment = presentations.size > 1;
256 for (const presentation of presentations.values()) {264 for (const [environmentId, presentation] of presentations) {
257 for (const source of presentation.serverConfig?.usageLimitSources ?? []) {265 for (const source of presentation.serverConfig?.usageLimitSources ?? []) {
258 const sourceLabel = labelEnvironment266 const sourceLabel = labelEnvironment
259 ? `${presentation.entry.target.label} · ${source.label}`267 ? `${presentation.entry.target.label} · ${source.label}`
...@@ -269,7 +277,16 @@ export function collectLimitAccounts(...@@ -269,7 +277,16 @@ export function collectLimitAccounts(
269 accentColor: undefined,277 accentColor: undefined,
270 environments: [],278 environments: [],
271 sourceLabel,279 sourceLabel,
272 redeem: null,280 redeem: account.usageLimits.resetCredits?.nextCreditId
281 ? {
282 environmentId,
283 input: {
284 sourceId: source.id,
285 accountId: account.id,
286 creditId: account.usageLimits.resetCredits.nextCreditId,
287 },
288 }
289 : null,
273 limits: account.usageLimits,290 limits: account.usageLimits,
274 });291 });
275 }292 }
...@@ -614,16 +631,48 @@ export function collectProviderUsageLimits(...@@ -614,16 +631,48 @@ export function collectProviderUsageLimits(
614 const notices: string[] = [];631 const notices: string[] = [];
615 for (const provider of native) {632 for (const provider of native) {
616 if (!provider.usageLimits) continue;633 if (!provider.usageLimits) continue;
634 const key = accountKey(provider.driver, provider.auth.email);
635 const hubCredits = sources
636 .flatMap((source) => source.accounts.map((account) => ({ source, account })))
637 .filter(
638 ({ account }) =>
639 key !== null &&
640 accountKey(account.driver, account.email) === key &&
641 account.usageLimits.resetCredits &&
642 !limitsNotice(account.usageLimits),
643 )
644 .sort(
645 (a, b) =>
646 Date.parse(b.account.usageLimits.checkedAt) - Date.parse(a.account.usageLimits.checkedAt),
647 )[0];
648 const useHubCredits =
649 hubCredits &&
650 (!provider.usageLimits.resetCredits ||
651 Date.parse(hubCredits.account.usageLimits.checkedAt) >
652 Date.parse(provider.usageLimits.checkedAt));
653 const hubCreditId = useHubCredits
654 ? hubCredits.account.usageLimits.resetCredits?.nextCreditId
655 : undefined;
617 accounts.push({656 accounts.push({
618 id: provider.instanceId,657 id: provider.instanceId,
619 driver: provider.driver,658 driver: provider.driver,
620 label: `${provider.displayName?.trim() || String(provider.driver)} [${provider.instanceId}]`,659 label: `${provider.displayName?.trim() || String(provider.driver)} [${provider.instanceId}]`,
621 ...(provider.auth.label ? { plan: provider.auth.label } : {}),660 ...(provider.auth.label ? { plan: provider.auth.label } : {}),
622 instanceId: provider.instanceId,661 instanceId: provider.instanceId,
662 resetCreditInput:
663 hubCreditId && hubCredits
664 ? {
665 sourceId: hubCredits.source.id,
666 accountId: hubCredits.account.id,
667 creditId: hubCreditId,
668 }
669 : { instanceId: provider.instanceId },
623 ...(provider.displayName ? { displayName: provider.displayName } : {}),670 ...(provider.displayName ? { displayName: provider.displayName } : {}),
624 ...(provider.accentColor ? { accentColor: provider.accentColor } : {}),671 ...(provider.accentColor ? { accentColor: provider.accentColor } : {}),
625 ...(provider.auth.email ? { email: provider.auth.email } : {}),672 ...(provider.auth.email ? { email: provider.auth.email } : {}),
626 limits: provider.usageLimits,673 limits: useHubCredits
674 ? { ...provider.usageLimits, resetCredits: hubCredits.account.usageLimits.resetCredits }
675 : provider.usageLimits,
627 });676 });
628 }677 }
629 for (const source of sources) {678 for (const source of sources) {
...@@ -636,6 +685,15 @@ export function collectProviderUsageLimits(...@@ -636,6 +685,15 @@ export function collectProviderUsageLimits(
636 driver: account.driver,685 driver: account.driver,
637 label: `${source.label} · ${account.id}`,686 label: `${source.label} · ${account.id}`,
638 sourceLabel: "CLI Proxy",687 sourceLabel: "CLI Proxy",
688 ...(account.usageLimits.resetCredits?.nextCreditId
689 ? {
690 resetCreditInput: {
691 sourceId: source.id,
692 accountId: account.id,
693 creditId: account.usageLimits.resetCredits.nextCreditId,
694 },
695 }
696 : {}),
639 ...(account.plan ? { plan: account.plan } : {}),697 ...(account.plan ? { plan: account.plan } : {}),
640 ...(account.email ? { email: account.email } : {}),698 ...(account.email ? { email: account.email } : {}),
641 limits: account.usageLimits,699 limits: account.usageLimits,