| 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,6 +28,7 @@ import { useAtomCommand } from "../../state/use-atom-command"; |
| 28 | import { SettingsSection } from "../settings/components/SettingsSection"; | 28 | import { SettingsSection } from "../settings/components/SettingsSection"; |
| 29 | 29 | ||
| 30 | const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const; | 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 | * 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 |
| 74 | 75 | ||
| 75 | function AccountLimits(props: { | 76 | function 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 | <AccountLimits | 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 | 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 | } |
| 216 | 222 | ||
| 217 | const 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. */ |
| 220 | function SourceAccountLimits(props: { | 224 | function 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 | <AccountLimits | 231 | <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 | <SourceAccountLimits | 269 | <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"; |
| 56 | import { Badge } from "../ui/badge"; | ||
| 57 | import { Button } from "../ui/button"; | 56 | import { Button } from "../ui/button"; |
| 58 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | 57 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 59 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 58 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| ... | @@ -209,29 +208,29 @@ function LimitWindows({ | ... | @@ -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 | * 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 | */ |
| 215 | function AccountHeading({ | 214 | function 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 | <ProviderInstanceIcon | 231 | <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 | <RedactedSensitiveText | 248 | <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 | <AccountHeading | 273 | <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 | <AccountHeading | 394 | <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 | */ |
| 415 | const SOURCE_KIND_LABEL: Record<UsageLimitSourceSnapshot["kind"], string> = { | 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 | * Removing a hub also deletes its management key from the server, so it | 422 | * 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 to | 423 | * 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 | } |
| 525 | 529 | ||
| 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. */ |
| 527 | function SourceLimitsRow({ | 531 | function 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 |
| 18 | subscription windows and say so; that includes a Claude Code that reaches Anthropic through a proxy | 18 | subscription windows and say so; that includes a Claude Code that reaches Anthropic through a proxy |
| 19 | via `ANTHROPIC_AUTH_TOKEN`, since the CLI then treats itself as an API-key client. | 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 | 21 | If you pool accounts behind a CLIProxyAPI hub, **Add hub** on the Limits view shows the accounts |
| 22 | every account the hub manages, each marked _via CLIProxyAPI_ so it is not mistaken for the provider | 22 | the hub manages. Each row shows its provider and instance name, or a small _CLI Proxy_ label for |
| 23 | signed in on this machine. Enter the hub's URL and management key; the key is stored on the server | 23 | hub accounts. When a connected provider reports limits for the same provider and email, its row |
| 24 | and never sent back to a client. Emails are blurred until clicked, as in provider settings. | 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 | Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**, | 29 | Use **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 the | 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,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 | import { describe, expect, it } from "vite-plus/test"; | 9 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 10 | ||
| 4 | import { | 11 | import { |
| ... | @@ -141,12 +148,130 @@ describe("collectLimitsGroups", () => { | ... | @@ -141,12 +148,130 @@ describe("collectLimitsGroups", () => { |
| 141 | 148 | ||
| 142 | describe("collectLimitSources", () => { | 149 | describe("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 | }); | ||
| 150 | 275 | ||
| 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 two | 70 | * Every usage-limit source across connected environments, keyed so two |
| 71 | * environments pointing at the same hub still get their own rows. The label | 71 | * 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 | */ |
| 74 | export function collectLimitSources( | 76 | export 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 | } |
| 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 | /** 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. */ |
| 113 | export function providerLimitsLabel( | 146 | export function providerLimitsLabel( |
| 114 | provider: ServerProvider, | 147 | provider: ServerProvider, |