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";...@@ -28,6 +28,7 @@ import { useAtomCommand } from "../../state/use-atom-command";
28import { SettingsSection } from "../settings/components/SettingsSection";28import { SettingsSection } from "../settings/components/SettingsSection";
2929
30const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const;30const 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
32/**33/**
33 * One window as a bar spanning its whole duration: the fill is quota spent,34 * 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...@@ -74,6 +75,7 @@ function WindowBar(props: { readonly window: ServerProviderUsageWindow; readonly
7475
75function AccountLimits(props: {76function AccountLimits(props: {
76 readonly label: string;77 readonly label: string;
78 readonly instanceLabel: string;
77 readonly detail: string | undefined;79 readonly detail: string | undefined;
78 readonly limits: ServerProvider["usageLimits"];80 readonly limits: ServerProvider["usageLimits"];
79 readonly now: number;81 readonly now: number;
...@@ -85,10 +87,13 @@ function AccountLimits(props: {...@@ -85,10 +87,13 @@ function AccountLimits(props: {
85 const notice = limitsNotice(limits);87 const notice = limitsNotice(limits);
86 return (88 return (
87 <View className={props.first ? "gap-3 p-4" : "gap-3 border-t border-border-subtle p-4"}>89 <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">
89 <Text className="text-lg text-foreground">{props.label}</Text>91 <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}
90 {props.detail ? (95 {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>
92 ) : null}97 ) : null}
93 </View>98 </View>
94 {notice ? (99 {notice ? (
...@@ -195,7 +200,8 @@ function ProviderLimits(props: {...@@ -195,7 +200,8 @@ function ProviderLimits(props: {
195 const credits = provider.usageLimits?.resetCredits;200 const credits = provider.usageLimits?.resetCredits;
196 return (201 return (
197 <AccountLimits202 <AccountLimits
198 label={providerLimitsLabel(provider, () => undefined)}203 label={DRIVER_LABEL[provider.driver] ?? String(provider.driver)}
204 instanceLabel={providerLimitsLabel(provider, (driver) => DRIVER_LABEL[driver])}
199 detail={provider.auth.label}205 detail={provider.auth.label}
200 limits={provider.usageLimits}206 limits={provider.usageLimits}
201 now={now}207 now={now}
...@@ -214,8 +220,6 @@ function ProviderLimits(props: {...@@ -214,8 +220,6 @@ function ProviderLimits(props: {
214 );220 );
215}221}
216222
217const DRIVER_LABEL: Partial<Record<string, string>> = { codex: "Codex", claudeAgent: "Claude" };
218
219/** Emails stay off the phone screen; the plan and driver identify the row. */223/** Emails stay off the phone screen; the plan and driver identify the row. */
220function SourceAccountLimits(props: {224function SourceAccountLimits(props: {
221 readonly account: UsageLimitSourceAccount;225 readonly account: UsageLimitSourceAccount;
...@@ -226,6 +230,7 @@ function SourceAccountLimits(props: {...@@ -226,6 +230,7 @@ function SourceAccountLimits(props: {
226 return (230 return (
227 <AccountLimits231 <AccountLimits
228 label={DRIVER_LABEL[account.driver] ?? String(account.driver)}232 label={DRIVER_LABEL[account.driver] ?? String(account.driver)}
233 instanceLabel="CLI Proxy"
229 detail={account.plan}234 detail={account.plan}
230 limits={account.usageLimits}235 limits={account.usageLimits}
231 now={props.now}236 now={props.now}
...@@ -250,11 +255,15 @@ export function UsageLimitsSection() {...@@ -250,11 +255,15 @@ export function UsageLimitsSection() {
250 return (255 return (
251 <>256 <>
252 {sources.map((source) => (257 {sources.map((source) => (
253 <SettingsSection key={source.key} title={`${source.label} · CLIProxyAPI`} card>258 <SettingsSection key={source.key} title={source.label} card>
254 {source.error ? (259 {source.error ? (
255 <Text className="p-4 text-sm text-foreground-muted">{source.error}</Text>260 <Text className="p-4 text-sm text-foreground-muted">{source.error}</Text>
256 ) : source.accounts.length === 0 ? (261 ) : 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>
258 ) : (267 ) : (
259 source.accounts.map((account, index) => (268 source.accounts.map((account, index) => (
260 <SourceAccountLimits269 <SourceAccountLimits
apps/web/src/components/usage/UsageLimits.tsx+25-30
...@@ -53,7 +53,6 @@ import {...@@ -53,7 +53,6 @@ import {
53 AlertDialogPopup,53 AlertDialogPopup,
54 AlertDialogTitle,54 AlertDialogTitle,
55} from "../ui/alert-dialog";55} from "../ui/alert-dialog";
56import { Badge } from "../ui/badge";
57import { Button } from "../ui/button";56import { Button } from "../ui/button";
58import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";57import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
59import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";58import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
...@@ -209,29 +208,29 @@ function LimitWindows({...@@ -209,29 +208,29 @@ function LimitWindows({
209}208}
210209
211/**210/**
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,
213 * and the signed-in email blurred until clicked, as provider settings do.212 * and the signed-in email blurred until clicked, as provider settings do.
214 */213 */
215function AccountHeading({214function AccountHeading({
216 driver,215 driver,
217 label,216 label,
217 instanceLabel,
218 plan,218 plan,
219 email,219 email,
220 accentColor,220 accentColor,
221 badge,
222}: {221}: {
223 readonly driver: ServerProvider["driver"];222 readonly driver: ServerProvider["driver"];
224 readonly label: string;223 readonly label: string;
224 readonly instanceLabel: string;
225 readonly plan: string | undefined;225 readonly plan: string | undefined;
226 readonly email: string | undefined;226 readonly email: string | undefined;
227 readonly accentColor?: string | undefined;227 readonly accentColor?: string | undefined;
228 readonly badge?: string | undefined;
229}) {228}) {
230 return (229 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">
232 <ProviderInstanceIcon231 <ProviderInstanceIcon
233 driverKind={driver}232 driverKind={driver}
234 displayName={label}233 displayName={instanceLabel}
235 accentColor={accentColor}234 accentColor={accentColor}
236 showBadge={Boolean(accentColor)}235 showBadge={Boolean(accentColor)}
237 indicatorBackground="var(--background)"236 indicatorBackground="var(--background)"
...@@ -239,7 +238,12 @@ function AccountHeading({...@@ -239,7 +238,12 @@ function AccountHeading({
239 iconClassName="size-4 text-foreground/80"238 iconClassName="size-4 text-foreground/80"
240 />239 />
241 <span className="truncate">{label}</span>240 <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}
243 {email ? (247 {email ? (
244 <RedactedSensitiveText248 <RedactedSensitiveText
245 value={email}249 value={email}
...@@ -248,11 +252,6 @@ function AccountHeading({...@@ -248,11 +252,6 @@ function AccountHeading({
248 hideTooltip="Click to hide email"252 hideTooltip="Click to hide email"
249 />253 />
250 ) : null}254 ) : null}
251 {badge ? (
252 <Badge variant="outline" size="sm" className="ms-auto shrink-0 font-normal">
253 {badge}
254 </Badge>
255 ) : null}
256 </h2>255 </h2>
257 );256 );
258}257}
...@@ -273,7 +272,8 @@ function ProviderLimits({...@@ -273,7 +272,8 @@ function ProviderLimits({
273 <section className="flex flex-col gap-3">272 <section className="flex flex-col gap-3">
274 <AccountHeading273 <AccountHeading
275 driver={provider.driver}274 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)}
277 plan={provider.auth.label}277 plan={provider.auth.label}
278 email={provider.auth.email}278 email={provider.auth.email}
279 accentColor={provider.accentColor}279 accentColor={provider.accentColor}
...@@ -394,9 +394,9 @@ function SourceAccountLimits({...@@ -394,9 +394,9 @@ function SourceAccountLimits({
394 <AccountHeading394 <AccountHeading
395 driver={account.driver}395 driver={account.driver}
396 label={getDriverOption(account.driver)?.label ?? String(account.driver)}396 label={getDriverOption(account.driver)?.label ?? String(account.driver)}
397 instanceLabel={sourceKind}
397 plan={account.plan}398 plan={account.plan}
398 email={account.email}399 email={account.email}
399 badge={`via ${sourceKind}`}
400 />400 />
401 {notice ? (401 {notice ? (
402 <span className="text-xs text-muted-foreground">{notice}</span>402 <span className="text-xs text-muted-foreground">{notice}</span>
...@@ -413,9 +413,11 @@ function SourceAccountLimits({...@@ -413,9 +413,11 @@ function SourceAccountLimits({
413 * environment can run a turn against them.413 * environment can run a turn against them.
414 */414 */
415const SOURCE_KIND_LABEL: Record<UsageLimitSourceSnapshot["kind"], string> = {415const SOURCE_KIND_LABEL: Record<UsageLimitSourceSnapshot["kind"], string> = {
416 cliproxy: "CLIProxyAPI",416 cliproxy: "CLI Proxy",
417};417};
418418
419type LimitsSource = ReturnType<typeof collectLimitSources>[number];
420
419/**421/**
420 * Removing a hub also deletes its management key from the server, so it422 * Removing a hub also deletes its management key from the server, so it
421 * asks first and says so. A bare icon that acted on click was too easy to423 * asks first and says so. A bare icon that acted on click was too easy to
...@@ -467,7 +469,7 @@ function SourceLimits({...@@ -467,7 +469,7 @@ function SourceLimits({
467 now,469 now,
468 onRemove,470 onRemove,
469}: {471}: {
470 readonly source: UsageLimitSourceSnapshot;472 readonly source: LimitsSource;
471 readonly now: number;473 readonly now: number;
472 readonly onRemove: (() => void) | null;474 readonly onRemove: (() => void) | null;
473}) {475}) {
...@@ -475,15 +477,17 @@ function SourceLimits({...@@ -475,15 +477,17 @@ function SourceLimits({
475 return (477 return (
476 <div className="flex flex-col gap-6">478 <div className="flex flex-col gap-6">
477 <div className="flex items-center justify-between gap-3">479 <div className="flex items-center justify-between gap-3">
478 <h2 className="text-xs tracking-wide text-muted-foreground uppercase">480 <h2 className="text-xs tracking-wide text-muted-foreground uppercase">{source.label}</h2>
479 {source.label} · {kind}
480 </h2>
481 {onRemove ? <RemoveSourceButton source={source} onConfirm={onRemove} /> : null}481 {onRemove ? <RemoveSourceButton source={source} onConfirm={onRemove} /> : null}
482 </div>482 </div>
483 {source.error ? (483 {source.error ? (
484 <span className="text-xs text-muted-foreground">{source.error}</span>484 <span className="text-xs text-muted-foreground">{source.error}</span>
485 ) : source.accounts.length === 0 ? (485 ) : 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>
487 ) : (491 ) : (
488 source.accounts.map((account) => (492 source.accounts.map((account) => (
489 <SourceAccountLimits key={account.id} account={account} sourceKind={kind} now={now} />493 <SourceAccountLimits key={account.id} account={account} sourceKind={kind} now={now} />
...@@ -524,16 +528,7 @@ function useCanOperateEnvironment(environment: EnvironmentPresentation | null):...@@ -524,16 +528,7 @@ function useCanOperateEnvironment(environment: EnvironmentPresentation | null):
524}528}
525529
526/** One source with a remove control bound to the environment it lives in. */530/** One source with a remove control bound to the environment it lives in. */
527function SourceLimitsRow({531function SourceLimitsRow({ source, now }: { readonly source: LimitsSource; readonly now: number }) {
528 source,
529 now,
530}: {
531 readonly source: UsageLimitSourceSnapshot & {
532 readonly key: string;
533 readonly environmentId: EnvironmentId;
534 };
535 readonly now: number;
536}) {
537 const updateSettings = useUpdateEnvironmentSettings(source.environmentId);532 const updateSettings = useUpdateEnvironmentSettings(source.environmentId);
538 const { environments } = useEnvironments();533 const { environments } = useEnvironments();
539 const environment =534 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...@@ -18,10 +18,13 @@ provider health-check interval and update live while a turn runs. API-key accoun
18subscription windows and say so; that includes a Claude Code that reaches Anthropic through a proxy18subscription windows and say so; that includes a Claude Code that reaches Anthropic through a proxy
19via `ANTHROPIC_AUTH_TOKEN`, since the CLI then treats itself as an API-key client.19via `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 shows21If you pool accounts behind a CLIProxyAPI hub, **Add hub** on the Limits view shows the accounts
22every account the hub manages, each marked _via CLIProxyAPI_ so it is not mistaken for the provider22the hub manages. Each row shows its provider and instance name, or a small _CLI Proxy_ label for
23signed in on this machine. Enter the hub's URL and management key; the key is stored on the server23hub accounts. When a connected provider reports limits for the same provider and email, its row
24and never sent back to a client. Emails are blurred until clicked, as in provider settings.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
26Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**,29Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**,
27**30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the30**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 @@...@@ -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";
2import { describe, expect, it } from "vite-plus/test";9import { describe, expect, it } from "vite-plus/test";
310
4import {11import {
...@@ -141,12 +148,130 @@ describe("collectLimitsGroups", () => {...@@ -141,12 +148,130 @@ describe("collectLimitsGroups", () => {
141148
142describe("collectLimitSources", () => {149describe("collectLimitSources", () => {
143 const source = {150 const source = {
144 id: "cliproxy-hub" as never,151 id: UsageLimitSourceId.make("cliproxy-hub"),
145 kind: "cliproxy" as const,152 kind: "cliproxy" as const,
146 label: "hub",153 label: "hub",
147 checkedAt: "2026-09-03T11:00:00.000Z",154 checkedAt: "2026-09-03T11:00:00.000Z",
148 accounts: [],155 accounts: [],
149 };156 };
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
151 it("keys sources per environment and names the environment only when several have some", () => {276 it("keys sources per environment and names the environment only when several have some", () => {
152 const one = new Map([277 const one = new Map([
packages/shared/src/usageLimits.ts+40-7
...@@ -70,6 +70,8 @@ export function collectLimitsGroups(...@@ -70,6 +70,8 @@ export function collectLimitsGroups(
70 * Every usage-limit source across connected environments, keyed so two70 * Every usage-limit source across connected environments, keyed so two
71 * environments pointing at the same hub still get their own rows. The label71 * environments pointing at the same hub still get their own rows. The label
72 * carries the environment only when more than one environment has sources.72 * 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.
73 */75 */
74export function collectLimitSources(76export function collectLimitSources(
75 presentations: ReadonlyMap<77 presentations: ReadonlyMap<
...@@ -77,13 +79,31 @@ export function collectLimitSources(...@@ -77,13 +79,31 @@ export function collectLimitSources(
77 {79 {
78 readonly entry: { readonly target: { readonly label: string } };80 readonly entry: { readonly target: { readonly label: string } };
79 readonly serverConfig: {81 readonly serverConfig: {
82 readonly providers?: readonly ServerProvider[] | undefined;
80 readonly usageLimitSources?: UsageLimitSourceSnapshots | undefined;83 readonly usageLimitSources?: UsageLimitSourceSnapshots | undefined;
81 } | null;84 } | null;
82 }85 }
83 >,86 >,
84): ReadonlyArray<87): ReadonlyArray<
85 UsageLimitSourceSnapshot & { readonly key: string; readonly environmentId: EnvironmentId }88 UsageLimitSourceSnapshot & {
89 readonly key: string;
90 readonly environmentId: EnvironmentId;
91 readonly hiddenAccountCount: number;
92 }
86> {93> {
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 }
87 const perEnvironment: Array<{107 const perEnvironment: Array<{
88 readonly environmentId: EnvironmentId;108 readonly environmentId: EnvironmentId;
89 readonly environmentLabel: string;109 readonly environmentLabel: string;
...@@ -100,15 +120,28 @@ export function collectLimitSources(...@@ -100,15 +120,28 @@ export function collectLimitSources(
100 }120 }
101 const labelEnvironment = perEnvironment.length > 1;121 const labelEnvironment = perEnvironment.length > 1;
102 return perEnvironment.flatMap(({ environmentId, environmentLabel, sources }) =>122 return perEnvironment.flatMap(({ environmentId, environmentLabel, sources }) =>
103 sources.map((source) => ({123 sources.map((source) => {
104 ...source,124 const accounts = source.accounts.filter((account) => {
105 environmentId,125 const key = accountKey(account.driver, account.email);
106 key: `${environmentId}:${source.id}`,126 return key === null || !nativeAccounts.has(key);
107 label: labelEnvironment ? `${environmentLabel} · ${source.label}` : source.label,127 });
108 })),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 }),
109 );137 );
110}138}
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
112/** The instance's configured name, else the driver's, else its raw kind. */145/** The instance's configured name, else the driver's, else its raw kind. */
113export function providerLimitsLabel(146export function providerLimitsLabel(
114 provider: ServerProvider,147 provider: ServerProvider,