| author | |
| committer | |
| log | b34ff8f56469afa8f3f85d89894e1b4cf49b5213 |
| tree | 640e8555e46d0ccfc6915e4a25a59a1572e41f45 |
| parent | 09b81a34954c990f70257ae05efbb602c90aac97 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 28 | import { SettingsSection } from "../settings/components/SettingsSection"; |
| 29 | 29 | |
| 30 | 30 | const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const; |
| 31 | const DRIVER_LABEL: Partial<Record<string, string>> = { codex: "Codex", claudeAgent: "Claude" }; | |
| 31 | 32 | |
| 32 | 33 | /** |
| 33 | 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 | 75 | |
| 75 | 76 | function AccountLimits(props: { |
| 76 | 77 | readonly label: string; |
| 78 | readonly instanceLabel: string; | |
| 77 | 79 | readonly detail: string | undefined; |
| 78 | 80 | readonly limits: ServerProvider["usageLimits"]; |
| 79 | 81 | readonly now: number; |
| ... | ... | @@ -85,10 +87,13 @@ function AccountLimits(props: { |
| 85 | 87 | const notice = limitsNotice(limits); |
| 86 | 88 | return ( |
| 87 | 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 | 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 | 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 | 97 | ) : null} |
| 93 | 98 | </View> |
| 94 | 99 | {notice ? ( |
| ... | ... | @@ -195,7 +200,8 @@ function ProviderLimits(props: { |
| 195 | 200 | const credits = provider.usageLimits?.resetCredits; |
| 196 | 201 | return ( |
| 197 | 202 | <AccountLimits |
| 198 | label={providerLimitsLabel(provider, () => undefined)} | |
| 203 | label={DRIVER_LABEL[provider.driver] ?? String(provider.driver)} | |
| 204 | instanceLabel={providerLimitsLabel(provider, (driver) => DRIVER_LABEL[driver])} | |
| 199 | 205 | detail={provider.auth.label} |
| 200 | 206 | limits={provider.usageLimits} |
| 201 | 207 | now={now} |
| ... | ... | @@ -214,8 +220,6 @@ function ProviderLimits(props: { |
| 214 | 220 | ); |
| 215 | 221 | } |
| 216 | 222 | |
| 217 | const DRIVER_LABEL: Partial<Record<string, string>> = { codex: "Codex", claudeAgent: "Claude" }; | |
| 218 | ||
| 219 | 223 | /** Emails stay off the phone screen; the plan and driver identify the row. */ |
| 220 | 224 | function SourceAccountLimits(props: { |
| 221 | 225 | readonly account: UsageLimitSourceAccount; |
| ... | ... | @@ -226,6 +230,7 @@ function SourceAccountLimits(props: { |
| 226 | 230 | return ( |
| 227 | 231 | <AccountLimits |
| 228 | 232 | label={DRIVER_LABEL[account.driver] ?? String(account.driver)} |
| 233 | instanceLabel="CLI Proxy" | |
| 229 | 234 | detail={account.plan} |
| 230 | 235 | limits={account.usageLimits} |
| 231 | 236 | now={props.now} |
| ... | ... | @@ -250,11 +255,15 @@ export function UsageLimitsSection() { |
| 250 | 255 | return ( |
| 251 | 256 | <> |
| 252 | 257 | {sources.map((source) => ( |
| 253 | <SettingsSection key={source.key} title={`${source.label} · CLIProxyAPI`} card> | |
| 258 | <SettingsSection key={source.key} title={source.label} card> | |
| 254 | 259 | {source.error ? ( |
| 255 | 260 | <Text className="p-4 text-sm text-foreground-muted">{source.error}</Text> |
| 256 | 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 | 268 | source.accounts.map((account, index) => ( |
| 260 | 269 | <SourceAccountLimits |
apps/web/src/components/usage/UsageLimits.tsx+25-30| ... | ... | @@ -53,7 +53,6 @@ import { |
| 53 | 53 | AlertDialogPopup, |
| 54 | 54 | AlertDialogTitle, |
| 55 | 55 | } from "../ui/alert-dialog"; |
| 56 | import { Badge } from "../ui/badge"; | |
| 57 | 56 | import { Button } from "../ui/button"; |
| 58 | 57 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 59 | 58 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| ... | ... | @@ -209,29 +208,29 @@ function LimitWindows({ |
| 209 | 208 | } |
| 210 | 209 | |
| 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 | 212 | * and the signed-in email blurred until clicked, as provider settings do. |
| 214 | 213 | */ |
| 215 | 214 | function AccountHeading({ |
| 216 | 215 | driver, |
| 217 | 216 | label, |
| 217 | instanceLabel, | |
| 218 | 218 | plan, |
| 219 | 219 | email, |
| 220 | 220 | accentColor, |
| 221 | badge, | |
| 222 | 221 | }: { |
| 223 | 222 | readonly driver: ServerProvider["driver"]; |
| 224 | 223 | readonly label: string; |
| 224 | readonly instanceLabel: string; | |
| 225 | 225 | readonly plan: string | undefined; |
| 226 | 226 | readonly email: string | undefined; |
| 227 | 227 | readonly accentColor?: string | undefined; |
| 228 | readonly badge?: string | undefined; | |
| 229 | 228 | }) { |
| 230 | 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 | 231 | <ProviderInstanceIcon |
| 233 | 232 | driverKind={driver} |
| 234 | displayName={label} | |
| 233 | displayName={instanceLabel} | |
| 235 | 234 | accentColor={accentColor} |
| 236 | 235 | showBadge={Boolean(accentColor)} |
| 237 | 236 | indicatorBackground="var(--background)" |
| ... | ... | @@ -239,7 +238,12 @@ function AccountHeading({ |
| 239 | 238 | iconClassName="size-4 text-foreground/80" |
| 240 | 239 | /> |
| 241 | 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 | 247 | {email ? ( |
| 244 | 248 | <RedactedSensitiveText |
| 245 | 249 | value={email} |
| ... | ... | @@ -248,11 +252,6 @@ function AccountHeading({ |
| 248 | 252 | hideTooltip="Click to hide email" |
| 249 | 253 | /> |
| 250 | 254 | ) : null} |
| 251 | {badge ? ( | |
| 252 | <Badge variant="outline" size="sm" className="ms-auto shrink-0 font-normal"> | |
| 253 | {badge} | |
| 254 | </Badge> | |
| 255 | ) : null} | |
| 256 | 255 | </h2> |
| 257 | 256 | ); |
| 258 | 257 | } |
| ... | ... | @@ -273,7 +272,8 @@ function ProviderLimits({ |
| 273 | 272 | <section className="flex flex-col gap-3"> |
| 274 | 273 | <AccountHeading |
| 275 | 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 | 277 | plan={provider.auth.label} |
| 278 | 278 | email={provider.auth.email} |
| 279 | 279 | accentColor={provider.accentColor} |
| ... | ... | @@ -394,9 +394,9 @@ function SourceAccountLimits({ |
| 394 | 394 | <AccountHeading |
| 395 | 395 | driver={account.driver} |
| 396 | 396 | label={getDriverOption(account.driver)?.label ?? String(account.driver)} |
| 397 | instanceLabel={sourceKind} | |
| 397 | 398 | plan={account.plan} |
| 398 | 399 | email={account.email} |
| 399 | badge={`via ${sourceKind}`} | |
| 400 | 400 | /> |
| 401 | 401 | {notice ? ( |
| 402 | 402 | <span className="text-xs text-muted-foreground">{notice}</span> |
| ... | ... | @@ -413,9 +413,11 @@ function SourceAccountLimits({ |
| 413 | 413 | * environment can run a turn against them. |
| 414 | 414 | */ |
| 415 | 415 | const SOURCE_KIND_LABEL: Record<UsageLimitSourceSnapshot["kind"], string> = { |
| 416 | cliproxy: "CLIProxyAPI", | |
| 416 | cliproxy: "CLI Proxy", | |
| 417 | 417 | }; |
| 418 | 418 | |
| 419 | type LimitsSource = ReturnType<typeof collectLimitSources>[number]; | |
| 420 | ||
| 419 | 421 | /** |
| 420 | 422 | * Removing a hub also deletes its management key from the server, so it |
| 421 | 423 | * asks first and says so. A bare icon that acted on click was too easy to |
| ... | ... | @@ -467,7 +469,7 @@ function SourceLimits({ |
| 467 | 469 | now, |
| 468 | 470 | onRemove, |
| 469 | 471 | }: { |
| 470 | readonly source: UsageLimitSourceSnapshot; | |
| 472 | readonly source: LimitsSource; | |
| 471 | 473 | readonly now: number; |
| 472 | 474 | readonly onRemove: (() => void) | null; |
| 473 | 475 | }) { |
| ... | ... | @@ -475,15 +477,17 @@ function SourceLimits({ |
| 475 | 477 | return ( |
| 476 | 478 | <div className="flex flex-col gap-6"> |
| 477 | 479 | <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> | |
| 481 | 481 | {onRemove ? <RemoveSourceButton source={source} onConfirm={onRemove} /> : null} |
| 482 | 482 | </div> |
| 483 | 483 | {source.error ? ( |
| 484 | 484 | <span className="text-xs text-muted-foreground">{source.error}</span> |
| 485 | 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 | 492 | source.accounts.map((account) => ( |
| 489 | 493 | <SourceAccountLimits key={account.id} account={account} sourceKind={kind} now={now} /> |
| ... | ... | @@ -524,16 +528,7 @@ function useCanOperateEnvironment(environment: EnvironmentPresentation | null): |
| 524 | 528 | } |
| 525 | 529 | |
| 526 | 530 | /** One source with a remove control bound to the environment it lives in. */ |
| 527 | function SourceLimitsRow({ | |
| 528 | source, | |
| 529 | now, | |
| 530 | }: { | |
| 531 | readonly source: UsageLimitSourceSnapshot & { | |
| 532 | readonly key: string; | |
| 533 | readonly environmentId: EnvironmentId; | |
| 534 | }; | |
| 535 | readonly now: number; | |
| 536 | }) { | |
| 531 | function SourceLimitsRow({ source, now }: { readonly source: LimitsSource; readonly now: number }) { | |
| 537 | 532 | const updateSettings = useUpdateEnvironmentSettings(source.environmentId); |
| 538 | 533 | const { environments } = useEnvironments(); |
| 539 | 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 | 18 | subscription windows and say so; that includes a Claude Code that reaches Anthropic through a proxy |
| 19 | 19 | via `ANTHROPIC_AUTH_TOKEN`, since the CLI then treats itself as an API-key client. |
| 20 | 20 | |
| 21 | If you pool accounts behind a CLIProxyAPI hub, **Add CLIProxyAPI hub** on the Limits view shows | |
| 22 | every account the hub manages, each marked _via CLIProxyAPI_ so it is not mistaken for the provider | |
| 23 | signed in on this machine. Enter the hub's URL and management key; the key is stored on the server | |
| 24 | and never sent back to a client. Emails are blurred until clicked, as in provider settings. | |
| 21 | If you pool accounts behind a CLIProxyAPI hub, **Add hub** on the Limits view shows the accounts | |
| 22 | the hub manages. Each row shows its provider and instance name, or a small _CLI Proxy_ label for | |
| 23 | hub accounts. When a connected provider reports limits for the same provider and email, its row | |
| 24 | replaces the hub copy, keeping details such as banked reset credits. The hub copy remains visible | |
| 25 | if the connected provider cannot report limits. Enter the hub's URL and management key; the key | |
| 26 | is stored on the server and never sent back to a client. Emails are blurred until clicked, as in | |
| 27 | provider settings. | |
| 25 | 28 | |
| 26 | 29 | Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**, |
| 27 | 30 | **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 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | EnvironmentId, | |
| 3 | ProviderDriverKind, | |
| 4 | ProviderInstanceId, | |
| 5 | type ServerProvider, | |
| 6 | type UsageLimitSourceAccount, | |
| 7 | UsageLimitSourceId, | |
| 8 | } from "@t3tools/contracts"; | |
| 2 | 9 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 10 | |
| 4 | 11 | import { |
| ... | ... | @@ -141,12 +148,130 @@ describe("collectLimitsGroups", () => { |
| 141 | 148 | |
| 142 | 149 | describe("collectLimitSources", () => { |
| 143 | 150 | const source = { |
| 144 | id: "cliproxy-hub" as never, | |
| 151 | id: UsageLimitSourceId.make("cliproxy-hub"), | |
| 145 | 152 | kind: "cliproxy" as const, |
| 146 | 153 | label: "hub", |
| 147 | 154 | checkedAt: "2026-09-03T11:00:00.000Z", |
| 148 | 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 | }); | |
| 150 | 275 | |
| 151 | 276 | it("keys sources per environment and names the environment only when several have some", () => { |
| 152 | 277 | const one = new Map([ |
packages/shared/src/usageLimits.ts+40-7| ... | ... | @@ -70,6 +70,8 @@ export function collectLimitsGroups( |
| 70 | 70 | * Every usage-limit source across connected environments, keyed so two |
| 71 | 71 | * environments pointing at the same hub still get their own rows. The label |
| 72 | 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 | */ |
| 74 | 76 | export function collectLimitSources( |
| 75 | 77 | presentations: ReadonlyMap< |
| ... | ... | @@ -77,13 +79,31 @@ export function collectLimitSources( |
| 77 | 79 | { |
| 78 | 80 | readonly entry: { readonly target: { readonly label: string } }; |
| 79 | 81 | readonly serverConfig: { |
| 82 | readonly providers?: readonly ServerProvider[] | undefined; | |
| 80 | 83 | readonly usageLimitSources?: UsageLimitSourceSnapshots | undefined; |
| 81 | 84 | } | null; |
| 82 | 85 | } |
| 83 | 86 | >, |
| 84 | 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 | 107 | const perEnvironment: Array<{ |
| 88 | 108 | readonly environmentId: EnvironmentId; |
| 89 | 109 | readonly environmentLabel: string; |
| ... | ... | @@ -100,15 +120,28 @@ export function collectLimitSources( |
| 100 | 120 | } |
| 101 | 121 | const labelEnvironment = perEnvironment.length > 1; |
| 102 | 122 | 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 | }), | |
| 109 | 137 | ); |
| 110 | 138 | } |
| 111 | 139 | |
| 140 | function accountKey(driver: ServerProvider["driver"], email: string | undefined): string | null { | |
| 141 | const normalizedEmail = email?.trim().toLowerCase(); | |
| 142 | return normalizedEmail ? `${driver}:${normalizedEmail}` : null; | |
| 143 | } | |
| 144 | ||
| 112 | 145 | /** The instance's configured name, else the driver's, else its raw kind. */ |
| 113 | 146 | export function providerLimitsLabel( |
| 114 | 147 | provider: ServerProvider, |