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({
4242 style={{ maxHeight: Math.round(height * 0.4) }}
4343 >
4444 {report.accounts.map((account, index) => {
45 const resetCreditInput =
46 account.resetCreditInput ??
47 (account.instanceId ? { instanceId: account.instanceId } : undefined);
4548 const driverLabel = DRIVER_LABEL[account.driver] ?? String(account.driver);
4649 return (
4750 <AccountLimits
......@@ -65,11 +68,11 @@ export function ComposerUsageLimits({
6568 now={now}
6669 trailing={index === 0 ? close : undefined}
6770 footer={
68 account.instanceId && account.limits.resetCredits ? (
71 resetCreditInput && account.limits.resetCredits ? (
6972 <ResetCredits
7073 dense
7174 environmentId={environmentId}
72 instanceId={account.instanceId}
75 input={resetCreditInput}
7376 credits={account.limits.resetCredits}
7477 now={now}
7578 />
apps/mobile/src/features/usage/UsageLimitsPooled.tsx+3-3
......@@ -140,7 +140,7 @@ function PoolWindowCard({
140140 </View>
141141 <View>
142142 {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;
144144 const resetsIn = formatResetsIn(window, now);
145145 return (
146146 <Pressable
......@@ -358,9 +358,9 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) {
358358 <View className="gap-3 rounded-[24px] border-continuous bg-card p-4">
359359 <Text className="text-sm font-t3-medium text-foreground">Reset credits</Text>
360360 <ResetCredits
361 key={`${account.redeem.environmentId}:${account.redeem.instanceId}`}
361 key={account.key}
362362 environmentId={account.redeem.environmentId}
363 instanceId={account.redeem.instanceId}
363 input={account.redeem.input}
364364 credits={account.limits.resetCredits}
365365 now={now}
366366 />
apps/mobile/src/features/usage/UsageLimitsSection.tsx+5-5
......@@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
22import type {
33 EnvironmentId,
44 ProviderConsumeResetCreditOutcome,
5 ProviderInstanceId,
5 ProviderConsumeResetCreditInput,
66 ServerProvider,
77 ServerProviderResetCredits,
88 ServerProviderUsageWindow,
......@@ -168,13 +168,13 @@ const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = {
168168 */
169169export function ResetCredits(props: {
170170 readonly environmentId: EnvironmentId;
171 readonly instanceId: ProviderInstanceId;
171 readonly input: ProviderConsumeResetCreditInput;
172172 readonly credits: ServerProviderResetCredits;
173173 readonly now: number;
174174 /** A smaller pill for the composer card. */
175175 readonly dense?: boolean;
176176}) {
177 const { environmentId, instanceId, credits, now, dense = false } = props;
177 const { environmentId, input, credits, now, dense = false } = props;
178178 const consume = useAtomCommand(serverEnvironment.consumeResetCredit, {
179179 reportFailure: false,
180180 });
......@@ -195,10 +195,10 @@ export function ResetCredits(props: {
195195 const redeem = async () => {
196196 setBusy(true);
197197 setStatus(null);
198 const result = await consume({ environmentId, input: { instanceId } });
198 const result = await consume({ environmentId, input });
199199 setBusy(false);
200200 if (result._tag === "Success") {
201 setStatus(OUTCOME_TEXT[result.value.outcome]);
201 setStatus(result.value.warning ?? OUTCOME_TEXT[result.value.outcome]);
202202 return;
203203 }
204204 setStatus(
apps/server/src/usage/UsageLimitSources.ts+32-50
......@@ -13,15 +13,16 @@
1313 */
1414import {
1515 DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL,
16 UsageLimitSourceError,
17 type UsageLimitSourceConsumeResetCreditInput,
18 type ProviderConsumeResetCreditResult,
1619 type ServerSettings,
1720 type UsageLimitSourceConfig,
1821 type UsageLimitSourceId,
1922 type UsageLimitSourceSnapshot,
2023} from "@t3tools/contracts";
2124import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
22import type * as Cause from "effect/Cause";
2325import * as Context from "effect/Context";
24import * as Data from "effect/Data";
2526import * as DateTime from "effect/DateTime";
2627import * as Duration from "effect/Duration";
2728import * as Effect from "effect/Effect";
......@@ -30,16 +31,11 @@ import * as Layer from "effect/Layer";
3031import * as PubSub from "effect/PubSub";
3132import * as Ref from "effect/Ref";
3233import * as Semaphore from "effect/Semaphore";
33import type * as Schema from "effect/Schema";
3434import * as Stream from "effect/Stream";
35import { HttpClient, type HttpClientError, HttpClientResponse } from "effect/unstable/http";
3635
3736import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
3837import { 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";
38import { makeCliproxyApi } from "./cliproxyApi.ts";
4339
4440export class UsageLimitSources extends Context.Service<
4541 UsageLimitSources,
......@@ -49,35 +45,12 @@ export class UsageLimitSources extends Context.Service<
4945 readonly streamChanges: Stream.Stream<ReadonlyArray<UsageLimitSourceSnapshot>>;
5046 /** Re-read every source now. Never fails; failures land on the snapshot. */
5147 readonly refresh: Effect.Effect<void>;
48 readonly consumeResetCredit: (
49 input: UsageLimitSourceConsumeResetCreditInput,
50 ) => Effect.Effect<ProviderConsumeResetCreditResult, UsageLimitSourceError>;
5251 }
5352>()("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
8154function sourceLabel(id: string, config: UsageLimitSourceConfig): string {
8255 if (config.label) return config.label;
8356 try {
......@@ -88,7 +61,7 @@ function sourceLabel(id: string, config: UsageLimitSourceConfig): string {
8861}
8962
9063export const make = Effect.gen(function* () {
91 const httpClient = yield* HttpClient.HttpClient;
64 const api = yield* makeCliproxyApi;
9265 const settingsService = yield* ServerSettingsService;
9366 const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;
9467 const stateRef = yield* Ref.make<ReadonlyArray<UsageLimitSourceSnapshot>>([]);
......@@ -106,23 +79,10 @@ export const make = Effect.gen(function* () {
10679 if (config.managementKey.length === 0) {
10780 return { ...base, accounts: [], error: "No management key configured." };
10881 }
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 );
82 const accounts = yield* api.readAccounts(config).pipe(Effect.result);
12383 if (accounts._tag === "Failure") {
12484 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 };
12686 }
12787 return { ...base, accounts: accounts.success };
12888 });
......@@ -154,6 +114,27 @@ export const make = Effect.gen(function* () {
154114 yield* publish(snapshots);
155115 }).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
157138 // Settings edits re-read straight away so a new hub shows up without
158139 // waiting for the interval, and a removed one leaves the list.
159140 yield* settingsService.streamChanges.pipe(
......@@ -184,6 +165,7 @@ export const make = Effect.gen(function* () {
184165
185166 return {
186167 current: Ref.get(stateRef),
168 consumeResetCredit,
187169 refresh,
188170 get streamChanges() {
189171 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 = (
18471847 observeRpcEffect(
18481848 WS_METHODS.providerConsumeResetCredit,
18491849 Effect.gen(function* () {
1850 if ("sourceId" in input) return yield* usageLimitSources.consumeResetCredit(input);
18501851 const instance = yield* providerInstances.getInstance(input.instanceId);
18511852 // A disabled instance must not spend anything on its account.
18521853 if (instance === undefined || !instance.enabled) {
apps/web/src/components/chat/ComposerUsageLimits.tsx+5-2
......@@ -57,6 +57,9 @@ function UsageLimitsBannerBody({
5757 <ComposerBanner.Scroll>
5858 <ComposerBanner.Body className="flex flex-col gap-2 pt-1 pb-1.5 pe-2">
5959 {report.accounts.map((account) => {
60 const resetCreditInput =
61 account.resetCreditInput ??
62 (account.instanceId ? { instanceId: account.instanceId } : undefined);
6063 const notice = limitsNotice(account.limits);
6164 return (
6265 <div key={account.id} className="flex min-w-0 flex-col gap-1">
......@@ -75,10 +78,10 @@ function UsageLimitsBannerBody({
7578 now={now}
7679 />
7780 )}
78 {account.instanceId && account.limits.resetCredits ? (
81 {resetCreditInput && account.limits.resetCredits ? (
7982 <ResetCredits
8083 environmentId={environmentId}
81 instanceId={account.instanceId}
84 input={resetCreditInput}
8285 credits={account.limits.resetCredits}
8386 now={now}
8487 />
apps/web/src/components/usage/UsageLimits.tsx+10-10
......@@ -1,7 +1,7 @@
11import {
22 type EnvironmentId,
33 type ProviderConsumeResetCreditOutcome,
4 ProviderInstanceId,
4 ProviderConsumeResetCreditInput,
55 ServerProvider,
66 ServerProviderResetCredits,
77 ServerProviderUsageWindow,
......@@ -202,7 +202,10 @@ const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = {
202202};
203203
204204/** 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) {
206209 const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false });
207210 const [confirming, setConfirming] = useState(false);
208211 const [busy, setBusy] = useState(false);
......@@ -212,10 +215,10 @@ export function useResetCredit(environmentId: EnvironmentId, instanceId: Provide
212215 setConfirming(false);
213216 setBusy(true);
214217 setStatus(null);
215 const result = await consume({ environmentId, input: { instanceId } });
218 const result = await consume({ environmentId, input });
216219 setBusy(false);
217220 if (result._tag === "Success") {
218 setStatus(OUTCOME_TEXT[result.value.outcome]);
221 setStatus(result.value.warning ?? OUTCOME_TEXT[result.value.outcome]);
219222 return;
220223 }
221224 setStatus(
......@@ -282,19 +285,16 @@ export function resetCreditsSummary(
282285/** Banked reset credits with the redeem button and its confirm, self-contained. */
283286export function ResetCredits({
284287 environmentId,
285 instanceId,
288 input,
286289 credits,
287290 now,
288291}: {
289292 readonly environmentId: EnvironmentId;
290 readonly instanceId: ProviderInstanceId;
293 readonly input: ProviderConsumeResetCreditInput;
291294 readonly credits: ServerProviderResetCredits;
292295 readonly now: number;
293296}) {
294 const { confirming, setConfirming, busy, status, redeem } = useResetCredit(
295 environmentId,
296 instanceId,
297 );
297 const { confirming, setConfirming, busy, status, redeem } = useResetCredit(environmentId, input);
298298 if (credits.availableCount === 0 && status === null) return null;
299299 return (
300300 <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({
235235 const [open, setOpen] = useState(false);
236236 const remaining = remainingPercent(window);
237237 const resetsIn = formatResetsIn(window, now);
238 const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0;
238 const credits = account.limits.resetCredits?.availableCount ?? 0;
239239 return (
240240 <Popover open={open} onOpenChange={setOpen}>
241241 <PopoverTrigger
......@@ -340,7 +340,7 @@ function LegendRow({
340340}) {
341341 const remaining = remainingPercent(window);
342342 const resetsIn = formatResetsIn(window, now);
343 const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0;
343 const credits = account.limits.resetCredits?.availableCount ?? 0;
344344 return (
345345 <PopoverTrigger
346346 style={{ gridColumn: "1 / -1", gridRow: index + 1 }}
......@@ -395,7 +395,7 @@ function RedeemableSegmentPopup({
395395 readonly redeemAt: NonNullable<LimitAccount["redeem"]>;
396396 readonly closePopover: () => void;
397397}) {
398 const redeem = useResetCredit(redeemAt.environmentId, redeemAt.instanceId);
398 const redeem = useResetCredit(redeemAt.environmentId, redeemAt.input);
399399 return (
400400 <>
401401 <PopoverPopup side="top" sideOffset={6}>
docs/user/usage.md+4-1
......@@ -67,6 +67,9 @@ using a proxy through `ANTHROPIC_AUTH_TOKEN`.
6767To see pooled accounts, open **Settings → Providers → Usage providers → Add hub**. Choose the
6868environment 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; configure
70The 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
7174the provider separately to send agent requests through the hub. Remove the hub from the same
7275settings 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>(
975975 tag: WS_METHODS.providerAuthStart,
976976 concurrency: {
977977 mode: "singleFlight",
978 key: ({ environmentId, input }) => JSON.stringify([environmentId, input.instanceId]),
978 key: ({ environmentId, input }) => JSON.stringify([environmentId, input]),
979979 },
980980 }),
981981 completeProviderAuth: createEnvironmentRpcCommand(runtime, {
......@@ -1056,7 +1056,7 @@ export function createServerEnvironmentAtoms<R, E>(
10561056 concurrency: {
10571057 mode: "singleFlight",
10581058 // 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]),
10601060 },
10611061 }),
10621062 refreshProviders: createEnvironmentRpcCommand(runtime, {
packages/contracts/src/providerUsageLimits.ts+25-2
......@@ -35,6 +35,8 @@ export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type;
3535export const ServerProviderResetCredits = Schema.Struct({
3636 availableCount: NonNegativeInt,
3737 nextExpiresAt: Schema.optional(IsoDateTime),
38 /** Pins hub redemption to the displayed credit, including retries from another client. */
39 nextCreditId: Schema.optional(TrimmedNonEmptyString),
3840});
3941export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type;
4042
......@@ -103,11 +105,29 @@ export type UsageLimitSourceSnapshot = typeof UsageLimitSourceSnapshot.Type;
103105export const UsageLimitSourceSnapshots = ForwardCompatibleArray(UsageLimitSourceSnapshot);
104106export type UsageLimitSourceSnapshots = typeof UsageLimitSourceSnapshots.Type;
105107
106export const ProviderConsumeResetCreditInput = Schema.Struct({
107 instanceId: ProviderInstanceId,
108export const UsageLimitSourceConsumeResetCreditInput = Schema.Struct({
109 sourceId: UsageLimitSourceId,
110 accountId: TrimmedNonEmptyString,
111 creditId: TrimmedNonEmptyString,
108112});
113export type UsageLimitSourceConsumeResetCreditInput =
114 typeof UsageLimitSourceConsumeResetCreditInput.Type;
115
116export const ProviderConsumeResetCreditInput = Schema.Union([
117 Schema.Struct({ instanceId: ProviderInstanceId }),
118 UsageLimitSourceConsumeResetCreditInput,
119]);
109120export 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
111131/** Mirrors Codex's own outcome set; other providers map onto it. */
112132export const ProviderConsumeResetCreditOutcome = Schema.Literals([
113133 "reset",
......@@ -119,6 +139,8 @@ export type ProviderConsumeResetCreditOutcome = typeof ProviderConsumeResetCredi
119139
120140export const ProviderConsumeResetCreditResult = Schema.Struct({
121141 outcome: ProviderConsumeResetCreditOutcome,
142 /** Redemption succeeded, but a follow-up such as clearing the hub cooldown failed. */
143 warning: Schema.optional(TrimmedNonEmptyString),
122144});
123145export type ProviderConsumeResetCreditResult = typeof ProviderConsumeResetCreditResult.Type;
124146
......@@ -134,6 +156,7 @@ export const UsageLimitsReport = Schema.Struct({
134156 email: Schema.optional(TrimmedNonEmptyString),
135157 sourceLabel: Schema.optional(TrimmedNonEmptyString),
136158 instanceId: Schema.optional(ProviderInstanceId),
159 resetCreditInput: Schema.optional(ProviderConsumeResetCreditInput),
137160 displayName: Schema.optional(Schema.String),
138161 accentColor: Schema.optional(Schema.String),
139162 limits: ServerProviderUsageLimits,
packages/contracts/src/rpc.ts+2-1
......@@ -217,6 +217,7 @@ import {
217217 ResourceTelemetrySnapshot,
218218} from "./resourceTelemetry.ts";
219219import {
220 UsageLimitSourceError,
220221 ProviderConsumeResetCreditInput,
221222 ProviderConsumeResetCreditResult,
222223} from "./providerUsageLimits.ts";
......@@ -432,7 +433,7 @@ const ProviderSetupRpcError = Schema.Union([ProviderSetupError, EnvironmentAutho
432433const WsProviderConsumeResetCreditRpc = Rpc.make(WS_METHODS.providerConsumeResetCredit, {
433434 payload: ProviderConsumeResetCreditInput,
434435 success: ProviderConsumeResetCreditResult,
435 error: ProviderSetupRpcError,
436 error: Schema.Union([ProviderSetupError, UsageLimitSourceError, EnvironmentAuthorizationError]),
436437});
437438
438439const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, {
packages/shared/src/usageLimits.test.ts+129-3
......@@ -378,7 +378,7 @@ describe("pools", () => {
378378 key: "env-a:claude",
379379 sourceLabel: null,
380380 // 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" } },
382382 environments: [
383383 { environmentId: "env-a", label: "Laptop" },
384384 { environmentId: "env-b", label: "Desktop" },
......@@ -429,7 +429,7 @@ describe("pools", () => {
429429 const [account] = collectLimitAccounts(input);
430430 expect(account?.limits.windows[0]?.usedPercent).toBe(55);
431431 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" } });
433433 expect(account?.environments).toEqual([{ environmentId: "env-a", label: "Laptop" }]);
434434 });
435435
......@@ -459,7 +459,108 @@ describe("pools", () => {
459459 ]);
460460 const [account] = collectLimitAccounts(input);
461461 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");
463564 });
464565
465566 it("names an environment once however many of its instances share the account", () => {
......@@ -713,6 +814,31 @@ describe("/usage-limits", () => {
713814 },
714815 ];
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
716842 it("keeps accounts and custom instances separate, filtering by driver", () => {
717843 const report = collectProviderUsageLimits(
718844 selected.instanceId,
packages/shared/src/usageLimits.ts+76-18
......@@ -9,6 +9,7 @@ import {
99 type EnvironmentId,
1010 type UsageLimitsReport,
1111 type ProviderInstanceId,
12 type ProviderConsumeResetCreditInput,
1213 type ServerProviderSlashCommand,
1314 isProviderAvailable,
1415 type ServerProvider,
......@@ -170,24 +171,33 @@ export interface LimitAccount {
170171 }>;
171172 /** The hub that reported it, when no environment has it natively. */
172173 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. */
174175 readonly redeem: {
175176 readonly environmentId: EnvironmentId;
176 readonly instanceId: ProviderInstanceId;
177 readonly input: ProviderConsumeResetCreditInput;
177178 } | null;
178179 readonly limits: ServerProviderUsageLimits;
179180}
180181
181182/**
182183 * Every account with usable windows across the connected environments, one
183 * entry per distinct account. Native instances win over hub reports, and the
184 * freshest snapshot wins when the same account is reported twice.
184 * entry per distinct account. The freshest reads supply windows and credits;
185 * native instances supply names and environment labels.
185186 */
186187export function collectLimitAccounts(
187188 presentations: Parameters<typeof collectLimitSources>[0],
188189): readonly LimitAccount[] {
189190 const accounts = new Map<string, LimitAccount>();
191 const creditSources = new Map<string, LimitAccount>();
190192 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 }
191201 const previous = accounts.get(key);
192202 if (!previous) {
193203 accounts.set(key, next);
......@@ -203,13 +213,9 @@ export function collectLimitAccounts(
203213 ),
204214 ];
205215 const winner = fresher ? next : previous;
206 // Windows come from the freshest snapshot, wherever it was read. Reset
207 // credits only ever come from a native instance, and the redeem must go
208 // to the instance whose credits are on show, so the two travel together:
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];
216 // Credits and their redemption target travel together. A failed credit
217 // probe must not erase a successful read from another environment.
218 const creditSource = creditSources.get(key);
213219 accounts.set(key, {
214220 ...previous,
215221 displayName: previous.displayName ?? next.displayName,
......@@ -218,11 +224,13 @@ export function collectLimitAccounts(
218224 environments,
219225 // A hub only names the account when no environment has it natively.
220226 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),
222230 limits: {
223231 ...winner.limits,
224 ...(native?.limits.resetCredits
225 ? { resetCredits: native.limits.resetCredits }
232 ...(creditSource?.limits.resetCredits
233 ? { resetCredits: creditSource.limits.resetCredits }
226234 : { resetCredits: undefined }),
227235 },
228236 });
......@@ -243,7 +251,7 @@ export function collectLimitAccounts(
243251 accentColor: provider.accentColor,
244252 environments: [{ environmentId, label }],
245253 sourceLabel: null,
246 redeem: { environmentId, instanceId: provider.instanceId },
254 redeem: { environmentId, input: { instanceId: provider.instanceId } },
247255 limits: provider.usageLimits,
248256 },
249257 );
......@@ -253,7 +261,7 @@ export function collectLimitAccounts(
253261 // may hold a fresher read of the same subscription, and the merge above
254262 // keeps the redeem target consistent with whichever snapshot wins.
255263 const labelEnvironment = presentations.size > 1;
256 for (const presentation of presentations.values()) {
264 for (const [environmentId, presentation] of presentations) {
257265 for (const source of presentation.serverConfig?.usageLimitSources ?? []) {
258266 const sourceLabel = labelEnvironment
259267 ? `${presentation.entry.target.label} · ${source.label}`
......@@ -269,7 +277,16 @@ export function collectLimitAccounts(
269277 accentColor: undefined,
270278 environments: [],
271279 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,
273290 limits: account.usageLimits,
274291 });
275292 }
......@@ -614,16 +631,48 @@ export function collectProviderUsageLimits(
614631 const notices: string[] = [];
615632 for (const provider of native) {
616633 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;
617656 accounts.push({
618657 id: provider.instanceId,
619658 driver: provider.driver,
620659 label: `${provider.displayName?.trim() || String(provider.driver)} [${provider.instanceId}]`,
621660 ...(provider.auth.label ? { plan: provider.auth.label } : {}),
622661 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 },
623670 ...(provider.displayName ? { displayName: provider.displayName } : {}),
624671 ...(provider.accentColor ? { accentColor: provider.accentColor } : {}),
625672 ...(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,
627676 });
628677 }
629678 for (const source of sources) {
......@@ -636,6 +685,15 @@ export function collectProviderUsageLimits(
636685 driver: account.driver,
637686 label: `${source.label} · ${account.id}`,
638687 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 : {}),
639697 ...(account.plan ? { plan: account.plan } : {}),
640698 ...(account.email ? { email: account.email } : {}),
641699 limits: account.usageLimits,