authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-03 21:38:45-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 21:38:45-07:00
logb34ff8f56469afa8f3f85d89894e1b4cf49b5213
tree640e8555e46d0ccfc6915e4a25a59a1572e41f45
parent09b81a34954c990f70257ae05efbb602c90aac97
signature Signed by PGP key B5690EEEBB952194

fix(usage): deduplicate CLI proxy subscription accounts (#9584)


5 files changed, 215 insertions(+), 50 deletions(-)

apps/mobile/src/features/usage/UsageLimitsSection.tsx+16-7
......@@ -28,6 +28,7 @@ import { useAtomCommand } from "../../state/use-atom-command";
2828import { SettingsSection } from "../settings/components/SettingsSection";
2929
3030const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const;
31const DRIVER_LABEL: Partial<Record<string, string>> = { codex: "Codex", claudeAgent: "Claude" };
3132
3233/**
3334 * One window as a bar spanning its whole duration: the fill is quota spent,
......@@ -74,6 +75,7 @@ function WindowBar(props: { readonly window: ServerProviderUsageWindow; readonly
7475
7576function AccountLimits(props: {
7677 readonly label: string;
78 readonly instanceLabel: string;
7779 readonly detail: string | undefined;
7880 readonly limits: ServerProvider["usageLimits"];
7981 readonly now: number;
......@@ -85,10 +87,13 @@ function AccountLimits(props: {
8587 const notice = limitsNotice(limits);
8688 return (
8789 <View className={props.first ? "gap-3 p-4" : "gap-3 border-t border-border-subtle p-4"}>
88 <View className="flex-row items-baseline gap-2">
90 <View className="flex-row flex-wrap items-baseline gap-x-2 gap-y-1">
8991 <Text className="text-lg text-foreground">{props.label}</Text>
92 {props.instanceLabel !== props.label ? (
93 <Text className="shrink text-xs text-foreground-tertiary">· {props.instanceLabel}</Text>
94 ) : null}
9095 {props.detail ? (
91 <Text className="text-sm text-foreground-muted">{props.detail}</Text>
96 <Text className="shrink text-sm text-foreground-muted">· {props.detail}</Text>
9297 ) : null}
9398 </View>
9499 {notice ? (
......@@ -195,7 +200,8 @@ function ProviderLimits(props: {
195200 const credits = provider.usageLimits?.resetCredits;
196201 return (
197202 <AccountLimits
198 label={providerLimitsLabel(provider, () => undefined)}
203 label={DRIVER_LABEL[provider.driver] ?? String(provider.driver)}
204 instanceLabel={providerLimitsLabel(provider, (driver) => DRIVER_LABEL[driver])}
199205 detail={provider.auth.label}
200206 limits={provider.usageLimits}
201207 now={now}
......@@ -214,8 +220,6 @@ function ProviderLimits(props: {
214220 );
215221}
216222
217const DRIVER_LABEL: Partial<Record<string, string>> = { codex: "Codex", claudeAgent: "Claude" };
218
219223/** Emails stay off the phone screen; the plan and driver identify the row. */
220224function SourceAccountLimits(props: {
221225 readonly account: UsageLimitSourceAccount;
......@@ -226,6 +230,7 @@ function SourceAccountLimits(props: {
226230 return (
227231 <AccountLimits
228232 label={DRIVER_LABEL[account.driver] ?? String(account.driver)}
233 instanceLabel="CLI Proxy"
229234 detail={account.plan}
230235 limits={account.usageLimits}
231236 now={props.now}
......@@ -250,11 +255,15 @@ export function UsageLimitsSection() {
250255 return (
251256 <>
252257 {sources.map((source) => (
253 <SettingsSection key={source.key} title={`${source.label} · CLIProxyAPI`} card>
258 <SettingsSection key={source.key} title={source.label} card>
254259 {source.error ? (
255260 <Text className="p-4 text-sm text-foreground-muted">{source.error}</Text>
256261 ) : source.accounts.length === 0 ? (
257 <Text className="p-4 text-sm text-foreground-muted">No accounts reported.</Text>
262 <Text className="p-4 text-sm text-foreground-muted">
263 {source.hiddenAccountCount > 0
264 ? "All accounts are shown by connected providers."
265 : "No accounts reported."}
266 </Text>
258267 ) : (
259268 source.accounts.map((account, index) => (
260269 <SourceAccountLimits
apps/web/src/components/usage/UsageLimits.tsx+25-30
......@@ -53,7 +53,6 @@ import {
5353 AlertDialogPopup,
5454 AlertDialogTitle,
5555} from "../ui/alert-dialog";
56import { Badge } from "../ui/badge";
5756import { Button } from "../ui/button";
5857import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
5958import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
......@@ -209,29 +208,29 @@ function LimitWindows({
209208}
210209
211210/**
212 * Heading shared by local providers and source accounts: icon, name, plan,
211 * Heading shared by local providers and source accounts: icon, driver, instance, plan,
213212 * and the signed-in email blurred until clicked, as provider settings do.
214213 */
215214function AccountHeading({
216215 driver,
217216 label,
217 instanceLabel,
218218 plan,
219219 email,
220220 accentColor,
221 badge,
222221}: {
223222 readonly driver: ServerProvider["driver"];
224223 readonly label: string;
224 readonly instanceLabel: string;
225225 readonly plan: string | undefined;
226226 readonly email: string | undefined;
227227 readonly accentColor?: string | undefined;
228 readonly badge?: string | undefined;
229228}) {
230229 return (
231 <h2 className="flex min-w-0 items-center gap-2 text-sm font-medium text-foreground">
230 <h2 className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-sm font-medium text-foreground">
232231 <ProviderInstanceIcon
233232 driverKind={driver}
234 displayName={label}
233 displayName={instanceLabel}
235234 accentColor={accentColor}
236235 showBadge={Boolean(accentColor)}
237236 indicatorBackground="var(--background)"
......@@ -239,7 +238,12 @@ function AccountHeading({
239238 iconClassName="size-4 text-foreground/80"
240239 />
241240 <span className="truncate">{label}</span>
242 {plan ? <span className="shrink-0 font-normal text-muted-foreground">· {plan}</span> : null}
241 {instanceLabel !== label ? (
242 <span className="min-w-0 truncate text-xs font-normal text-muted-foreground">
243 · {instanceLabel}
244 </span>
245 ) : null}
246 {plan ? <span className="font-normal text-muted-foreground">· {plan}</span> : null}
243247 {email ? (
244248 <RedactedSensitiveText
245249 value={email}
......@@ -248,11 +252,6 @@ function AccountHeading({
248252 hideTooltip="Click to hide email"
249253 />
250254 ) : null}
251 {badge ? (
252 <Badge variant="outline" size="sm" className="ms-auto shrink-0 font-normal">
253 {badge}
254 </Badge>
255 ) : null}
256255 </h2>
257256 );
258257}
......@@ -273,7 +272,8 @@ function ProviderLimits({
273272 <section className="flex flex-col gap-3">
274273 <AccountHeading
275274 driver={provider.driver}
276 label={providerLimitsLabel(provider, (driver) => getDriverOption(driver)?.label)}
275 label={getDriverOption(provider.driver)?.label ?? String(provider.driver)}
276 instanceLabel={providerLimitsLabel(provider, (driver) => getDriverOption(driver)?.label)}
277277 plan={provider.auth.label}
278278 email={provider.auth.email}
279279 accentColor={provider.accentColor}
......@@ -394,9 +394,9 @@ function SourceAccountLimits({
394394 <AccountHeading
395395 driver={account.driver}
396396 label={getDriverOption(account.driver)?.label ?? String(account.driver)}
397 instanceLabel={sourceKind}
397398 plan={account.plan}
398399 email={account.email}
399 badge={`via ${sourceKind}`}
400400 />
401401 {notice ? (
402402 <span className="text-xs text-muted-foreground">{notice}</span>
......@@ -413,9 +413,11 @@ function SourceAccountLimits({
413413 * environment can run a turn against them.
414414 */
415415const SOURCE_KIND_LABEL: Record<UsageLimitSourceSnapshot["kind"], string> = {
416 cliproxy: "CLIProxyAPI",
416 cliproxy: "CLI Proxy",
417417};
418418
419type LimitsSource = ReturnType<typeof collectLimitSources>[number];
420
419421/**
420422 * Removing a hub also deletes its management key from the server, so it
421423 * asks first and says so. A bare icon that acted on click was too easy to
......@@ -467,7 +469,7 @@ function SourceLimits({
467469 now,
468470 onRemove,
469471}: {
470 readonly source: UsageLimitSourceSnapshot;
472 readonly source: LimitsSource;
471473 readonly now: number;
472474 readonly onRemove: (() => void) | null;
473475}) {
......@@ -475,15 +477,17 @@ function SourceLimits({
475477 return (
476478 <div className="flex flex-col gap-6">
477479 <div className="flex items-center justify-between gap-3">
478 <h2 className="text-xs tracking-wide text-muted-foreground uppercase">
479 {source.label} · {kind}
480 </h2>
480 <h2 className="text-xs tracking-wide text-muted-foreground uppercase">{source.label}</h2>
481481 {onRemove ? <RemoveSourceButton source={source} onConfirm={onRemove} /> : null}
482482 </div>
483483 {source.error ? (
484484 <span className="text-xs text-muted-foreground">{source.error}</span>
485485 ) : source.accounts.length === 0 ? (
486 <span className="text-xs text-muted-foreground">No accounts reported.</span>
486 <span className="text-xs text-muted-foreground">
487 {source.hiddenAccountCount > 0
488 ? "All accounts are shown by connected providers."
489 : "No accounts reported."}
490 </span>
487491 ) : (
488492 source.accounts.map((account) => (
489493 <SourceAccountLimits key={account.id} account={account} sourceKind={kind} now={now} />
......@@ -524,16 +528,7 @@ function useCanOperateEnvironment(environment: EnvironmentPresentation | null):
524528}
525529
526530/** One source with a remove control bound to the environment it lives in. */
527function SourceLimitsRow({
528 source,
529 now,
530}: {
531 readonly source: UsageLimitSourceSnapshot & {
532 readonly key: string;
533 readonly environmentId: EnvironmentId;
534 };
535 readonly now: number;
536}) {
531function SourceLimitsRow({ source, now }: { readonly source: LimitsSource; readonly now: number }) {
537532 const updateSettings = useUpdateEnvironmentSettings(source.environmentId);
538533 const { environments } = useEnvironments();
539534 const environment =
docs/user/usage.md+7-4
......@@ -18,10 +18,13 @@ provider health-check interval and update live while a turn runs. API-key accoun
1818subscription windows and say so; that includes a Claude Code that reaches Anthropic through a proxy
1919via `ANTHROPIC_AUTH_TOKEN`, since the CLI then treats itself as an API-key client.
2020
21If you pool accounts behind a CLIProxyAPI hub, **Add CLIProxyAPI hub** on the Limits view shows
22every account the hub manages, each marked _via CLIProxyAPI_ so it is not mistaken for the provider
23signed in on this machine. Enter the hub's URL and management key; the key is stored on the server
24and never sent back to a client. Emails are blurred until clicked, as in provider settings.
21If you pool accounts behind a CLIProxyAPI hub, **Add hub** on the Limits view shows the accounts
22the hub manages. Each row shows its provider and instance name, or a small _CLI Proxy_ label for
23hub accounts. When a connected provider reports limits for the same provider and email, its row
24replaces the hub copy, keeping details such as banked reset credits. The hub copy remains visible
25if the connected provider cannot report limits. Enter the hub's URL and management key; the key
26is stored on the server and never sent back to a client. Emails are blurred until clicked, as in
27provider settings.
2528
2629Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**,
2730**30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the
packages/shared/src/usageLimits.test.ts+127-2
......@@ -1,4 +1,11 @@
1import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
1import {
2 EnvironmentId,
3 ProviderDriverKind,
4 ProviderInstanceId,
5 type ServerProvider,
6 type UsageLimitSourceAccount,
7 UsageLimitSourceId,
8} from "@t3tools/contracts";
29import { describe, expect, it } from "vite-plus/test";
310
411import {
......@@ -141,12 +148,130 @@ describe("collectLimitsGroups", () => {
141148
142149describe("collectLimitSources", () => {
143150 const source = {
144 id: "cliproxy-hub" as never,
151 id: UsageLimitSourceId.make("cliproxy-hub"),
145152 kind: "cliproxy" as const,
146153 label: "hub",
147154 checkedAt: "2026-09-03T11:00:00.000Z",
148155 accounts: [],
149156 };
157 const limits = { checkedAt: source.checkedAt, windows: [window] };
158 const account: UsageLimitSourceAccount = {
159 id: "codex-personal",
160 driver: ProviderDriverKind.make("codex"),
161 email: "person@example.com",
162 plan: "ChatGPT Pro Subscription",
163 usageLimits: limits,
164 };
165 const native = provider({
166 displayName: "Personal",
167 auth: { status: "authenticated", email: account.email },
168 usageLimits: { ...limits, resetCredits: { availableCount: 2 } },
169 });
170
171 function presentations(
172 providers: readonly ServerProvider[],
173 accounts: readonly UsageLimitSourceAccount[] = [account],
174 ) {
175 return new Map([
176 [
177 EnvironmentId.make("env-a"),
178 {
179 entry: { target: { label: "Laptop" } },
180 serverConfig: { providers, usageLimitSources: [{ ...source, accounts }] },
181 },
182 ],
183 ]);
184 }
185
186 it.each(["codex", "claudeAgent"])(
187 "prefers native %s limits by email without changing provider rows or source snapshots",
188 (kind) => {
189 const driver = ProviderDriverKind.make(kind);
190 const first = { ...native, driver };
191 const second = { ...first, instanceId: ProviderInstanceId.make("work") };
192 const accounts = [{ ...account, driver, email: " Person@Example.COM " }];
193 const input = presentations([first, second], accounts);
194
195 expect(collectLimitSources(input)).toMatchObject([{ accounts: [], hiddenAccountCount: 1 }]);
196 expect(collectLimitsGroups(input)[0]?.providers).toEqual([first, second]);
197 expect(accounts).toHaveLength(1);
198 expect(first.usageLimits?.resetCredits?.availableCount).toBe(2);
199 },
200 );
201
202 it("matches across environments even when the hub is visited before the native provider", () => {
203 const input = presentations([]);
204 input.set(EnvironmentId.make("env-b"), {
205 entry: { target: { label: "Desktop" } },
206 serverConfig: { providers: [native], usageLimitSources: [] },
207 });
208
209 expect(collectLimitSources(input)).toMatchObject([
210 { accounts: [], hiddenAccountCount: 1, environmentId: "env-a" },
211 ]);
212 });
213
214 it("keeps other providers, other emails, and unidentified accounts with the same plan", () => {
215 const accounts = [
216 account,
217 { ...account, id: "other-provider", driver: ProviderDriverKind.make("claudeAgent") },
218 { ...account, id: "other-email", email: "other@example.com" },
219 { ...account, id: "unknown-email", email: undefined },
220 ];
221
222 expect(collectLimitSources(presentations([native], accounts))).toMatchObject([
223 { accounts: accounts.slice(1), hiddenAccountCount: 1 },
224 ]);
225 expect(
226 collectLimitSources(
227 presentations([{ ...native, auth: { status: "authenticated" } }], accounts),
228 )[0]?.accounts,
229 ).toEqual(accounts);
230 });
231
232 it.each([
233 { enabled: false },
234 { installed: false },
235 { availability: "unavailable" },
236 { usageLimits: undefined },
237 { usageLimits: { ...limits, windows: [] } },
238 { usageLimits: { ...limits, unavailable: { reason: "probeFailed" } } },
239 { usageLimits: { ...limits, unavailable: { reason: "unsupported" } } },
240 ] satisfies Partial<ServerProvider>[])(
241 "retains hub limits when the native provider cannot show them: %j",
242 (overrides) => {
243 expect(collectLimitSources(presentations([{ ...native, ...overrides }]))).toMatchObject([
244 { accounts: [account], hiddenAccountCount: 0 },
245 ]);
246 },
247 );
248
249 it("restores the hub account when the matching provider disappears", () => {
250 const input = presentations([native]);
251 expect(collectLimitSources(input)[0]?.accounts).toEqual([]);
252 input.delete(EnvironmentId.make("env-a"));
253 for (const [id, entry] of presentations([])) input.set(id, entry);
254
255 expect(collectLimitSources(input)[0]?.accounts).toEqual([account]);
256 });
257
258 it("keeps source errors and genuinely empty sources distinguishable from hidden accounts", () => {
259 const input = new Map([
260 [
261 EnvironmentId.make("env-a"),
262 {
263 entry: { target: { label: "Laptop" } },
264 serverConfig: {
265 providers: [native],
266 usageLimitSources: [{ ...source, error: "Hub unavailable" }],
267 },
268 },
269 ],
270 ]);
271 expect(collectLimitSources(input)).toMatchObject([
272 { accounts: [], hiddenAccountCount: 0, error: "Hub unavailable" },
273 ]);
274 });
150275
151276 it("keys sources per environment and names the environment only when several have some", () => {
152277 const one = new Map([
packages/shared/src/usageLimits.ts+40-7
......@@ -70,6 +70,8 @@ export function collectLimitsGroups(
7070 * Every usage-limit source across connected environments, keyed so two
7171 * environments pointing at the same hub still get their own rows. The label
7272 * carries the environment only when more than one environment has sources.
73 * A native provider with usable limits takes precedence over the same account
74 * in a source, even when it belongs to another connected environment.
7375 */
7476export function collectLimitSources(
7577 presentations: ReadonlyMap<
......@@ -77,13 +79,31 @@ export function collectLimitSources(
7779 {
7880 readonly entry: { readonly target: { readonly label: string } };
7981 readonly serverConfig: {
82 readonly providers?: readonly ServerProvider[] | undefined;
8083 readonly usageLimitSources?: UsageLimitSourceSnapshots | undefined;
8184 } | null;
8285 }
8386 >,
8487): ReadonlyArray<
85 UsageLimitSourceSnapshot & { readonly key: string; readonly environmentId: EnvironmentId }
88 UsageLimitSourceSnapshot & {
89 readonly key: string;
90 readonly environmentId: EnvironmentId;
91 readonly hiddenAccountCount: number;
92 }
8693> {
94 const nativeAccounts = new Set<string>();
95 for (const presentation of presentations.values()) {
96 for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) {
97 const key = accountKey(provider.driver, provider.auth.email);
98 if (
99 key !== null &&
100 provider.usageLimits?.windows.length &&
101 !provider.usageLimits.unavailable
102 ) {
103 nativeAccounts.add(key);
104 }
105 }
106 }
87107 const perEnvironment: Array<{
88108 readonly environmentId: EnvironmentId;
89109 readonly environmentLabel: string;
......@@ -100,15 +120,28 @@ export function collectLimitSources(
100120 }
101121 const labelEnvironment = perEnvironment.length > 1;
102122 return perEnvironment.flatMap(({ environmentId, environmentLabel, sources }) =>
103 sources.map((source) => ({
104 ...source,
105 environmentId,
106 key: `${environmentId}:${source.id}`,
107 label: labelEnvironment ? `${environmentLabel} · ${source.label}` : source.label,
108 })),
123 sources.map((source) => {
124 const accounts = source.accounts.filter((account) => {
125 const key = accountKey(account.driver, account.email);
126 return key === null || !nativeAccounts.has(key);
127 });
128 return {
129 ...source,
130 accounts,
131 hiddenAccountCount: source.accounts.length - accounts.length,
132 environmentId,
133 key: `${environmentId}:${source.id}`,
134 label: labelEnvironment ? `${environmentLabel} · ${source.label}` : source.label,
135 };
136 }),
109137 );
110138}
111139
140function accountKey(driver: ServerProvider["driver"], email: string | undefined): string | null {
141 const normalizedEmail = email?.trim().toLowerCase();
142 return normalizedEmail ? `${driver}:${normalizedEmail}` : null;
143}
144
112145/** The instance's configured name, else the driver's, else its raw kind. */
113146export function providerLimitsLabel(
114147 provider: ServerProvider,