| author | |
| committer | |
| log | f1e90e388b86fe4b007a55c0e685a1fa878115e6 |
| tree | 0dbc112c02ab0e9dc05ba70241254d84075e10a6 |
| parent | caab2fdbac041ac2e851ad4fa3ac4a40a1d4a8f6 |
| signature | Signed by PGP key B5690EEEBB952194 |
10 files changed, 332 insertions(+), 390 deletions(-)
apps/mobile/src/features/settings/components/SettingsSection.tsx+4-2| ... | @@ -4,14 +4,16 @@ import { View } from "react-native"; | ... | @@ -4,14 +4,16 @@ import { View } from "react-native"; |
| 4 | import { AppText as Text } from "../../../components/AppText"; | 4 | import { AppText as Text } from "../../../components/AppText"; |
| 5 | 5 | ||
| 6 | export function SettingsSection(props: { | 6 | export function SettingsSection(props: { |
| 7 | readonly title: string; | 7 | readonly title?: string; |
| 8 | readonly children: ReactNode; | 8 | readonly children: ReactNode; |
| 9 | /** Force the grouped card background; Android otherwise lists options flat. */ | 9 | /** Force the grouped card background; Android otherwise lists options flat. */ |
| 10 | readonly card?: boolean; | 10 | readonly card?: boolean; |
| 11 | }) { | 11 | }) { |
| 12 | return ( | 12 | return ( |
| 13 | <View className="gap-2"> | 13 | <View className="gap-2"> |
| 14 | <Text className="px-2 text-sm font-t3-medium text-foreground-muted">{props.title}</Text> | 14 | {props.title ? ( |
| 15 | <Text className="px-2 text-sm font-t3-medium text-foreground-muted">{props.title}</Text> | ||
| 16 | ) : null} | ||
| 15 | <View | 17 | <View |
| 16 | className={ | 18 | className={ |
| 17 | props.card | 19 | props.card |
apps/mobile/src/features/usage/UsageLimitsSection.tsx+1-1| ... | @@ -255,7 +255,7 @@ export function UsageLimitsSection() { | ... | @@ -255,7 +255,7 @@ export function UsageLimitsSection() { |
| 255 | return ( | 255 | return ( |
| 256 | <> | 256 | <> |
| 257 | {sources.map((source) => ( | 257 | {sources.map((source) => ( |
| 258 | <SettingsSection key={source.key} title={source.label} card> | 258 | <SettingsSection key={source.key} card> |
| 259 | {source.error ? ( | 259 | {source.error ? ( |
| 260 | <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> |
| 261 | ) : source.accounts.length === 0 ? ( | 261 | ) : source.accounts.length === 0 ? ( |
apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx created+157| ... | @@ -0,0 +1,157 @@ | ||
| 1 | import { type EnvironmentId, UsageLimitSourceId } from "@t3tools/contracts"; | ||
| 2 | import { useState } from "react"; | ||
| 3 | |||
| 4 | import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; | ||
| 5 | import { Button } from "../ui/button"; | ||
| 6 | import { | ||
| 7 | Dialog, | ||
| 8 | DialogDescription, | ||
| 9 | DialogFooter, | ||
| 10 | DialogHeader, | ||
| 11 | DialogPanel, | ||
| 12 | DialogPopup, | ||
| 13 | DialogTitle, | ||
| 14 | } from "../ui/dialog"; | ||
| 15 | import { Input } from "../ui/input"; | ||
| 16 | import { Label } from "../ui/label"; | ||
| 17 | |||
| 18 | /** | ||
| 19 | * Stable per hub and readable in settings.json. Dots and dashes in the host | ||
| 20 | * are kept so `foo-bar.com` and `foo.bar.com` do not collide; anything else | ||
| 21 | * (a port's colon, a path) is folded to a dash. | ||
| 22 | */ | ||
| 23 | function sourceIdFromUrl(url: string): UsageLimitSourceId { | ||
| 24 | let host = url; | ||
| 25 | try { | ||
| 26 | host = new URL(url).host; | ||
| 27 | } catch { | ||
| 28 | // Keep the raw text; the server reports the bad URL on its row. | ||
| 29 | } | ||
| 30 | const slug = host | ||
| 31 | .toLowerCase() | ||
| 32 | .replace(/[^a-z0-9.-]+/g, "-") | ||
| 33 | .replace(/^-+|-+$/g, ""); | ||
| 34 | return UsageLimitSourceId.make(`cliproxy-${slug || "hub"}`); | ||
| 35 | } | ||
| 36 | |||
| 37 | /** | ||
| 38 | * Adds a CLIProxyAPI hub from provider settings on one environment. The | ||
| 39 | * management key is sent once and kept in that server's secret store; | ||
| 40 | * settings only ever carry a redaction marker for it afterwards. | ||
| 41 | */ | ||
| 42 | export function AddUsageLimitSourceDialog({ | ||
| 43 | open, | ||
| 44 | onOpenChange, | ||
| 45 | environmentId, | ||
| 46 | environmentLabel, | ||
| 47 | }: { | ||
| 48 | readonly open: boolean; | ||
| 49 | readonly onOpenChange: (open: boolean) => void; | ||
| 50 | readonly environmentId: EnvironmentId; | ||
| 51 | readonly environmentLabel: string; | ||
| 52 | }) { | ||
| 53 | const updateSettings = useUpdateEnvironmentSettings(environmentId); | ||
| 54 | const [label, setLabel] = useState(""); | ||
| 55 | const [url, setUrl] = useState(""); | ||
| 56 | const [managementKey, setManagementKey] = useState(""); | ||
| 57 | const trimmedUrl = url.trim(); | ||
| 58 | const canSave = trimmedUrl.length > 0 && managementKey.trim().length > 0; | ||
| 59 | |||
| 60 | const reset = () => { | ||
| 61 | setLabel(""); | ||
| 62 | setUrl(""); | ||
| 63 | setManagementKey(""); | ||
| 64 | }; | ||
| 65 | |||
| 66 | const save = () => { | ||
| 67 | if (!canSave) return; | ||
| 68 | const id = sourceIdFromUrl(trimmedUrl); | ||
| 69 | // The patch names only this entry; the server merges it into its map. | ||
| 70 | updateSettings({ | ||
| 71 | usageLimitSources: { | ||
| 72 | [id]: { | ||
| 73 | kind: "cliproxy", | ||
| 74 | ...(label.trim() ? { label: label.trim() } : {}), | ||
| 75 | url: trimmedUrl, | ||
| 76 | managementKey: managementKey.trim(), | ||
| 77 | enabled: true, | ||
| 78 | }, | ||
| 79 | }, | ||
| 80 | }); | ||
| 81 | reset(); | ||
| 82 | onOpenChange(false); | ||
| 83 | }; | ||
| 84 | |||
| 85 | return ( | ||
| 86 | <Dialog | ||
| 87 | open={open} | ||
| 88 | onOpenChange={(next) => { | ||
| 89 | if (!next) reset(); | ||
| 90 | onOpenChange(next); | ||
| 91 | }} | ||
| 92 | > | ||
| 93 | <DialogPopup className="max-w-md"> | ||
| 94 | <DialogHeader> | ||
| 95 | <DialogTitle>Add a CLIProxyAPI hub</DialogTitle> | ||
| 96 | <DialogDescription> | ||
| 97 | Show the quota of every account the hub pools, next to the providers on{" "} | ||
| 98 | {environmentLabel}. The key stays on that server. | ||
| 99 | </DialogDescription> | ||
| 100 | </DialogHeader> | ||
| 101 | <DialogPanel> | ||
| 102 | <form | ||
| 103 | className="grid gap-4" | ||
| 104 | onSubmit={(event) => { | ||
| 105 | event.preventDefault(); | ||
| 106 | save(); | ||
| 107 | }} | ||
| 108 | > | ||
| 109 | <div className="grid gap-1.5"> | ||
| 110 | <Label htmlFor="usage-source-url">Hub URL</Label> | ||
| 111 | <Input | ||
| 112 | id="usage-source-url" | ||
| 113 | placeholder="https://hub.example.ts.net:8318" | ||
| 114 | value={url} | ||
| 115 | onChange={(event) => setUrl(event.target.value)} | ||
| 116 | autoFocus | ||
| 117 | /> | ||
| 118 | </div> | ||
| 119 | <div className="grid gap-1.5"> | ||
| 120 | <Label htmlFor="usage-source-key">Management key</Label> | ||
| 121 | <Input | ||
| 122 | id="usage-source-key" | ||
| 123 | type="password" | ||
| 124 | autoComplete="off" | ||
| 125 | value={managementKey} | ||
| 126 | onChange={(event) => setManagementKey(event.target.value)} | ||
| 127 | /> | ||
| 128 | </div> | ||
| 129 | <div className="grid gap-1.5"> | ||
| 130 | <Label htmlFor="usage-source-label">Label (optional)</Label> | ||
| 131 | <Input | ||
| 132 | id="usage-source-label" | ||
| 133 | placeholder="Defaults to the hub's host name" | ||
| 134 | value={label} | ||
| 135 | onChange={(event) => setLabel(event.target.value)} | ||
| 136 | /> | ||
| 137 | </div> | ||
| 138 | </form> | ||
| 139 | </DialogPanel> | ||
| 140 | <DialogFooter variant="bare"> | ||
| 141 | <Button | ||
| 142 | variant="outline" | ||
| 143 | onClick={() => { | ||
| 144 | reset(); | ||
| 145 | onOpenChange(false); | ||
| 146 | }} | ||
| 147 | > | ||
| 148 | Cancel | ||
| 149 | </Button> | ||
| 150 | <Button onClick={save} disabled={!canSave}> | ||
| 151 | Add hub | ||
| 152 | </Button> | ||
| 153 | </DialogFooter> | ||
| 154 | </DialogPopup> | ||
| 155 | </Dialog> | ||
| 156 | ); | ||
| 157 | } | ||
apps/web/src/components/settings/ProviderSettingsPanel.tsx+11-1| ... | @@ -68,6 +68,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ... | @@ -68,6 +68,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 68 | import { stackedThreadToast, toastManager } from "../ui/toast"; | 68 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 69 | import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; | 69 | import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; |
| 70 | import { ProviderInstanceCard } from "./ProviderInstanceCard"; | 70 | import { ProviderInstanceCard } from "./ProviderInstanceCard"; |
| 71 | import { UsageProviderSettings } from "./UsageProviderSettings"; | ||
| 71 | import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection"; | 72 | import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection"; |
| 72 | import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; | 73 | import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; |
| 73 | import { providerSettingsTabClassName } from "./providerSettingsTabs"; | 74 | import { providerSettingsTabClassName } from "./providerSettingsTabs"; |
| ... | @@ -241,7 +242,8 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { | ... | @@ -241,7 +242,8 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { |
| 241 | )?.environmentId; | 242 | )?.environmentId; |
| 242 | useEffect(() => { | 243 | useEffect(() => { |
| 243 | if ( | 244 | if ( |
| 244 | searchTargetId === searchableSetting("provider-health-check-interval").id && | 245 | (searchTargetId === searchableSetting("provider-health-check-interval").id || |
| 246 | searchTargetId === searchableSetting("usage-providers").id) && | ||
| 245 | !selectedEnvironmentCanRenderSettings && | 247 | !selectedEnvironmentCanRenderSettings && |
| 246 | searchableEnvironmentId !== undefined | 248 | searchableEnvironmentId !== undefined |
| 247 | ) { | 249 | ) { |
| ... | @@ -997,6 +999,14 @@ export function EnvironmentProviderSettings({ | ... | @@ -997,6 +999,14 @@ export function EnvironmentProviderSettings({ |
| 997 | </div> | 999 | </div> |
| 998 | </SettingsSection> | 1000 | </SettingsSection> |
| 999 | 1001 | ||
| 1002 | <UsageProviderSettings | ||
| 1003 | key={environmentId} | ||
| 1004 | environmentId={environmentId} | ||
| 1005 | environmentLabel={environmentLabel} | ||
| 1006 | sources={settings.usageLimitSources} | ||
| 1007 | readOnly={readOnly} | ||
| 1008 | /> | ||
| 1009 | |||
| 1000 | <SettingsSection title="Advanced"> | 1010 | <SettingsSection title="Advanced"> |
| 1001 | <SettingsRow | 1011 | <SettingsRow |
| 1002 | id={searchableSetting("provider-health-check-interval").id} | 1012 | id={searchableSetting("provider-health-check-interval").id} |
apps/web/src/components/settings/UsageProviderSettings.tsx created+130| ... | @@ -0,0 +1,130 @@ | ||
| 1 | import type { EnvironmentId, UnifiedSettings } from "@t3tools/contracts"; | ||
| 2 | import { PlusIcon } from "lucide-react"; | ||
| 3 | import { useState } from "react"; | ||
| 4 | |||
| 5 | import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; | ||
| 6 | import { | ||
| 7 | AlertDialog, | ||
| 8 | AlertDialogClose, | ||
| 9 | AlertDialogDescription, | ||
| 10 | AlertDialogFooter, | ||
| 11 | AlertDialogHeader, | ||
| 12 | AlertDialogPopup, | ||
| 13 | AlertDialogTitle, | ||
| 14 | } from "../ui/alert-dialog"; | ||
| 15 | import { Button } from "../ui/button"; | ||
| 16 | import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog"; | ||
| 17 | import { searchableSetting } from "./settingsSearch"; | ||
| 18 | import { SettingsRow, SettingsSection } from "./settingsLayout"; | ||
| 19 | |||
| 20 | /** Hub management follows the selected device and access rules of provider settings. */ | ||
| 21 | export function UsageProviderSettings({ | ||
| 22 | environmentId, | ||
| 23 | environmentLabel, | ||
| 24 | sources, | ||
| 25 | readOnly, | ||
| 26 | }: { | ||
| 27 | readonly environmentId: EnvironmentId; | ||
| 28 | readonly environmentLabel: string; | ||
| 29 | readonly sources: UnifiedSettings["usageLimitSources"]; | ||
| 30 | readonly readOnly: boolean; | ||
| 31 | }) { | ||
| 32 | const updateSettings = useUpdateEnvironmentSettings(environmentId); | ||
| 33 | const [adding, setAdding] = useState(false); | ||
| 34 | const entries = Object.entries(sources); | ||
| 35 | |||
| 36 | return ( | ||
| 37 | <> | ||
| 38 | <SettingsSection | ||
| 39 | {...searchableSetting("usage-providers")} | ||
| 40 | description="Connect a CLIProxyAPI hub to show its accounts on Usage → Limits." | ||
| 41 | headerAction={ | ||
| 42 | !readOnly ? ( | ||
| 43 | <Button size="xs" variant="outline" onClick={() => setAdding(true)}> | ||
| 44 | <PlusIcon className="size-3" aria-hidden /> | ||
| 45 | Add hub | ||
| 46 | </Button> | ||
| 47 | ) : null | ||
| 48 | } | ||
| 49 | > | ||
| 50 | {entries.length === 0 ? ( | ||
| 51 | <SettingsRow title="No usage providers configured." /> | ||
| 52 | ) : ( | ||
| 53 | entries.map(([id, source]) => { | ||
| 54 | const label = source.label?.trim() || source.url; | ||
| 55 | return ( | ||
| 56 | <SettingsRow | ||
| 57 | key={id} | ||
| 58 | title={label} | ||
| 59 | description={ | ||
| 60 | <span className="break-all"> | ||
| 61 | CLI Proxy{source.enabled ? "" : " · Disabled"} | ||
| 62 | {label !== source.url ? ` · ${source.url}` : ""} | ||
| 63 | </span> | ||
| 64 | } | ||
| 65 | control={ | ||
| 66 | !readOnly ? ( | ||
| 67 | <RemoveUsageProviderButton | ||
| 68 | label={label} | ||
| 69 | onConfirm={() => updateSettings({ usageLimitSources: { [id]: null } })} | ||
| 70 | /> | ||
| 71 | ) : null | ||
| 72 | } | ||
| 73 | /> | ||
| 74 | ); | ||
| 75 | }) | ||
| 76 | )} | ||
| 77 | </SettingsSection> | ||
| 78 | {adding && !readOnly ? ( | ||
| 79 | <AddUsageLimitSourceDialog | ||
| 80 | open | ||
| 81 | onOpenChange={setAdding} | ||
| 82 | environmentId={environmentId} | ||
| 83 | environmentLabel={environmentLabel} | ||
| 84 | /> | ||
| 85 | ) : null} | ||
| 86 | </> | ||
| 87 | ); | ||
| 88 | } | ||
| 89 | |||
| 90 | /** Removing a hub deletes its stored management key, so it requires confirmation. */ | ||
| 91 | function RemoveUsageProviderButton({ | ||
| 92 | label, | ||
| 93 | onConfirm, | ||
| 94 | }: { | ||
| 95 | readonly label: string; | ||
| 96 | readonly onConfirm: () => void; | ||
| 97 | }) { | ||
| 98 | const [open, setOpen] = useState(false); | ||
| 99 | return ( | ||
| 100 | <> | ||
| 101 | <Button size="xs" variant="ghost" onClick={() => setOpen(true)}> | ||
| 102 | Remove | ||
| 103 | </Button> | ||
| 104 | <AlertDialog open={open} onOpenChange={setOpen}> | ||
| 105 | <AlertDialogPopup> | ||
| 106 | <AlertDialogHeader> | ||
| 107 | <AlertDialogTitle>Remove {label}?</AlertDialogTitle> | ||
| 108 | <AlertDialogDescription> | ||
| 109 | The hub's management key is deleted from this server. Its accounts leave the Limits | ||
| 110 | view; the hub itself is untouched. Add it again with the URL and key to bring them | ||
| 111 | back. | ||
| 112 | </AlertDialogDescription> | ||
| 113 | </AlertDialogHeader> | ||
| 114 | <AlertDialogFooter> | ||
| 115 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> | ||
| 116 | <Button | ||
| 117 | variant="destructive" | ||
| 118 | onClick={() => { | ||
| 119 | setOpen(false); | ||
| 120 | onConfirm(); | ||
| 121 | }} | ||
| 122 | > | ||
| 123 | Remove hub | ||
| 124 | </Button> | ||
| 125 | </AlertDialogFooter> | ||
| 126 | </AlertDialogPopup> | ||
| 127 | </AlertDialog> | ||
| 128 | </> | ||
| 129 | ); | ||
| 130 | } | ||
apps/web/src/components/settings/settingsSearch.test.ts+10| ... | @@ -101,6 +101,16 @@ describe("searchSettings", () => { | ... | @@ -101,6 +101,16 @@ describe("searchSettings", () => { |
| 101 | ]); | 101 | ]); |
| 102 | }); | 102 | }); |
| 103 | 103 | ||
| 104 | it.each(["usage providers", "CLIProxyAPI", "CLI proxy hub", "management key"])( | ||
| 105 | "finds usage-provider management by %s", | ||
| 106 | (query) => { | ||
| 107 | expect(searchSettings(query)[0]).toMatchObject({ | ||
| 108 | id: "usage-providers", | ||
| 109 | to: "/settings/providers", | ||
| 110 | }); | ||
| 111 | }, | ||
| 112 | ); | ||
| 113 | |||
| 104 | it("returns no results for an empty query", () => { | 114 | it("returns no results for an empty query", () => { |
| 105 | expect(searchSettings(" ", ITEMS)).toEqual([]); | 115 | expect(searchSettings(" ", ITEMS)).toEqual([]); |
| 106 | }); | 116 | }); |
apps/web/src/components/settings/settingsSearch.ts+9| ... | @@ -324,6 +324,15 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -324,6 +324,15 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 324 | "agents cli codex claude cursor grok opencode antigravity google sign in sign out install subscription instances authentication api key models configuration binary path config directory endpoint arguments environment variables display name accent color custom favorite hidden auto compact", | 324 | "agents cli codex claude cursor grok opencode antigravity google sign in sign out install subscription instances authentication api key models configuration binary path config directory endpoint arguments environment variables display name accent color custom favorite hidden auto compact", |
| 325 | ], | 325 | ], |
| 326 | }, | 326 | }, |
| 327 | { | ||
| 328 | id: "usage-providers", | ||
| 329 | title: "Usage providers", | ||
| 330 | to: "/settings/providers", | ||
| 331 | searchTerms: [ | ||
| 332 | "usage sources CLIProxyAPI CLI proxy hub quota subscription limits management key add remove", | ||
| 333 | ], | ||
| 334 | providerSettingsOnly: true, | ||
| 335 | }, | ||
| 327 | { | 336 | { |
| 328 | id: "provider-health-check-interval", | 337 | id: "provider-health-check-interval", |
| 329 | title: "Health check interval", | 338 | title: "Health check interval", |
apps/web/src/components/usage/AddUsageLimitSourceDialog.tsx deleted-157| ... | @@ -1,157 +0,0 @@ | ||
| 1 | import { type EnvironmentId, UsageLimitSourceId } from "@t3tools/contracts"; | ||
| 2 | import { useState } from "react"; | ||
| 3 | |||
| 4 | import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; | ||
| 5 | import { Button } from "../ui/button"; | ||
| 6 | import { | ||
| 7 | Dialog, | ||
| 8 | DialogDescription, | ||
| 9 | DialogFooter, | ||
| 10 | DialogHeader, | ||
| 11 | DialogPanel, | ||
| 12 | DialogPopup, | ||
| 13 | DialogTitle, | ||
| 14 | } from "../ui/dialog"; | ||
| 15 | import { Input } from "../ui/input"; | ||
| 16 | import { Label } from "../ui/label"; | ||
| 17 | |||
| 18 | /** | ||
| 19 | * Stable per hub and readable in settings.json. Dots and dashes in the host | ||
| 20 | * are kept so `foo-bar.com` and `foo.bar.com` do not collide; anything else | ||
| 21 | * (a port's colon, a path) is folded to a dash. | ||
| 22 | */ | ||
| 23 | function sourceIdFromUrl(url: string): UsageLimitSourceId { | ||
| 24 | let host = url; | ||
| 25 | try { | ||
| 26 | host = new URL(url).host; | ||
| 27 | } catch { | ||
| 28 | // Keep the raw text; the server reports the bad URL on its row. | ||
| 29 | } | ||
| 30 | const slug = host | ||
| 31 | .toLowerCase() | ||
| 32 | .replace(/[^a-z0-9.-]+/g, "-") | ||
| 33 | .replace(/^-+|-+$/g, ""); | ||
| 34 | return UsageLimitSourceId.make(`cliproxy-${slug || "hub"}`); | ||
| 35 | } | ||
| 36 | |||
| 37 | /** | ||
| 38 | * Adds a CLIProxyAPI hub as a usage-limit source on one environment. The | ||
| 39 | * management key is sent once and kept in that server's secret store; | ||
| 40 | * settings only ever carry a redaction marker for it afterwards. | ||
| 41 | */ | ||
| 42 | export function AddUsageLimitSourceDialog({ | ||
| 43 | open, | ||
| 44 | onOpenChange, | ||
| 45 | environmentId, | ||
| 46 | environmentLabel, | ||
| 47 | }: { | ||
| 48 | readonly open: boolean; | ||
| 49 | readonly onOpenChange: (open: boolean) => void; | ||
| 50 | readonly environmentId: EnvironmentId; | ||
| 51 | readonly environmentLabel: string; | ||
| 52 | }) { | ||
| 53 | const updateSettings = useUpdateEnvironmentSettings(environmentId); | ||
| 54 | const [label, setLabel] = useState(""); | ||
| 55 | const [url, setUrl] = useState(""); | ||
| 56 | const [managementKey, setManagementKey] = useState(""); | ||
| 57 | const trimmedUrl = url.trim(); | ||
| 58 | const canSave = trimmedUrl.length > 0 && managementKey.trim().length > 0; | ||
| 59 | |||
| 60 | const reset = () => { | ||
| 61 | setLabel(""); | ||
| 62 | setUrl(""); | ||
| 63 | setManagementKey(""); | ||
| 64 | }; | ||
| 65 | |||
| 66 | const save = () => { | ||
| 67 | if (!canSave) return; | ||
| 68 | const id = sourceIdFromUrl(trimmedUrl); | ||
| 69 | // The patch names only this entry; the server merges it into its map. | ||
| 70 | updateSettings({ | ||
| 71 | usageLimitSources: { | ||
| 72 | [id]: { | ||
| 73 | kind: "cliproxy", | ||
| 74 | ...(label.trim() ? { label: label.trim() } : {}), | ||
| 75 | url: trimmedUrl, | ||
| 76 | managementKey: managementKey.trim(), | ||
| 77 | enabled: true, | ||
| 78 | }, | ||
| 79 | }, | ||
| 80 | }); | ||
| 81 | reset(); | ||
| 82 | onOpenChange(false); | ||
| 83 | }; | ||
| 84 | |||
| 85 | return ( | ||
| 86 | <Dialog | ||
| 87 | open={open} | ||
| 88 | onOpenChange={(next) => { | ||
| 89 | if (!next) reset(); | ||
| 90 | onOpenChange(next); | ||
| 91 | }} | ||
| 92 | > | ||
| 93 | <DialogPopup className="max-w-md"> | ||
| 94 | <DialogHeader> | ||
| 95 | <DialogTitle>Add a CLIProxyAPI hub</DialogTitle> | ||
| 96 | <DialogDescription> | ||
| 97 | Show the quota of every account the hub pools, next to the providers on{" "} | ||
| 98 | {environmentLabel}. The key stays on that server. | ||
| 99 | </DialogDescription> | ||
| 100 | </DialogHeader> | ||
| 101 | <DialogPanel> | ||
| 102 | <form | ||
| 103 | className="grid gap-4" | ||
| 104 | onSubmit={(event) => { | ||
| 105 | event.preventDefault(); | ||
| 106 | save(); | ||
| 107 | }} | ||
| 108 | > | ||
| 109 | <div className="grid gap-1.5"> | ||
| 110 | <Label htmlFor="usage-source-url">Hub URL</Label> | ||
| 111 | <Input | ||
| 112 | id="usage-source-url" | ||
| 113 | placeholder="https://hub.example.ts.net:8318" | ||
| 114 | value={url} | ||
| 115 | onChange={(event) => setUrl(event.target.value)} | ||
| 116 | autoFocus | ||
| 117 | /> | ||
| 118 | </div> | ||
| 119 | <div className="grid gap-1.5"> | ||
| 120 | <Label htmlFor="usage-source-key">Management key</Label> | ||
| 121 | <Input | ||
| 122 | id="usage-source-key" | ||
| 123 | type="password" | ||
| 124 | autoComplete="off" | ||
| 125 | value={managementKey} | ||
| 126 | onChange={(event) => setManagementKey(event.target.value)} | ||
| 127 | /> | ||
| 128 | </div> | ||
| 129 | <div className="grid gap-1.5"> | ||
| 130 | <Label htmlFor="usage-source-label">Label (optional)</Label> | ||
| 131 | <Input | ||
| 132 | id="usage-source-label" | ||
| 133 | placeholder="Defaults to the hub's host name" | ||
| 134 | value={label} | ||
| 135 | onChange={(event) => setLabel(event.target.value)} | ||
| 136 | /> | ||
| 137 | </div> | ||
| 138 | </form> | ||
| 139 | </DialogPanel> | ||
| 140 | <DialogFooter variant="bare"> | ||
| 141 | <Button | ||
| 142 | variant="outline" | ||
| 143 | onClick={() => { | ||
| 144 | reset(); | ||
| 145 | onOpenChange(false); | ||
| 146 | }} | ||
| 147 | > | ||
| 148 | Cancel | ||
| 149 | </Button> | ||
| 150 | <Button onClick={save} disabled={!canSave}> | ||
| 151 | Add hub | ||
| 152 | </Button> | ||
| 153 | </DialogFooter> | ||
| 154 | </DialogPopup> | ||
| 155 | </Dialog> | ||
| 156 | ); | ||
| 157 | } | ||
apps/web/src/components/usage/UsageLimits.tsx+6-227| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | EnvironmentId, | 2 | type EnvironmentId, |
| 3 | type ProviderConsumeResetCreditOutcome, | 3 | type ProviderConsumeResetCreditOutcome, |
| 4 | ProviderInstanceId, | 4 | ProviderInstanceId, |
| 5 | ServerProvider, | 5 | ServerProvider, |
| ... | @@ -21,28 +21,16 @@ import { | ... | @@ -21,28 +21,16 @@ import { |
| 21 | paceOf, | 21 | paceOf, |
| 22 | providerLimitsLabel, | 22 | providerLimitsLabel, |
| 23 | } from "@t3tools/shared/usageLimits"; | 23 | } from "@t3tools/shared/usageLimits"; |
| 24 | import { GaugeIcon, PlusIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; | 24 | import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; |
| 25 | import { Fragment, useState } from "react"; | 25 | import { Fragment, useState } from "react"; |
| 26 | 26 | ||
| 27 | import { isElectron } from "../../env"; | 27 | import { usePrimarySettings } from "../../hooks/useSettings"; |
| 28 | import { usePrimarySessionState } from "../../environments/primary"; | ||
| 29 | import { usePrimarySettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; | ||
| 30 | import { | ||
| 31 | type EnvironmentPresentation, | ||
| 32 | useEnvironments, | ||
| 33 | usePrimaryEnvironmentId, | ||
| 34 | } from "../../state/environments"; | ||
| 35 | import { useEnvironmentSessionState } from "../../state/session"; | ||
| 36 | import { environmentPresentations } from "../../state/presentation"; | 28 | import { environmentPresentations } from "../../state/presentation"; |
| 37 | import { serverEnvironment } from "../../state/server"; | 29 | import { serverEnvironment } from "../../state/server"; |
| 38 | import { useAtomCommand } from "../../state/use-atom-command"; | 30 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 39 | import { formatUpcomingTimestamp } from "../../timestampFormat"; | 31 | import { formatUpcomingTimestamp } from "../../timestampFormat"; |
| 40 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; | 32 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; |
| 41 | import { getDriverOption } from "../settings/providerDriverMeta"; | 33 | import { getDriverOption } from "../settings/providerDriverMeta"; |
| 42 | import { | ||
| 43 | resolvePrimaryOperateAccess, | ||
| 44 | resolveRemoteOperateAccess, | ||
| 45 | } from "../settings/ProviderSettingsPanel.logic"; | ||
| 46 | import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; | 34 | import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; |
| 47 | import { | 35 | import { |
| 48 | AlertDialog, | 36 | AlertDialog, |
| ... | @@ -54,9 +42,7 @@ import { | ... | @@ -54,9 +42,7 @@ import { |
| 54 | AlertDialogTitle, | 42 | AlertDialogTitle, |
| 55 | } from "../ui/alert-dialog"; | 43 | } from "../ui/alert-dialog"; |
| 56 | import { Button } from "../ui/button"; | 44 | import { Button } from "../ui/button"; |
| 57 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | ||
| 58 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 45 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 59 | import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog"; | ||
| 60 | import { PROVIDER_PRESENTATION } from "./usageProviders"; | 46 | import { PROVIDER_PRESENTATION } from "./usageProviders"; |
| 61 | 47 | ||
| 62 | const PACE: Record<LimitPace, { readonly label: string; readonly icon: typeof GaugeIcon }> = { | 48 | const PACE: Record<LimitPace, { readonly label: string; readonly icon: typeof GaugeIcon }> = { |
| ... | @@ -407,79 +393,17 @@ function SourceAccountLimits({ | ... | @@ -407,79 +393,17 @@ function SourceAccountLimits({ |
| 407 | ); | 393 | ); |
| 408 | } | 394 | } |
| 409 | 395 | ||
| 410 | /** | ||
| 411 | * Accounts a configured source (a CLIProxyAPI hub) pools, grouped under the | ||
| 412 | * source's name. Unlike provider rows these are read-only: nothing on this | ||
| 413 | * environment can run a turn against them. | ||
| 414 | */ | ||
| 415 | const SOURCE_KIND_LABEL: Record<UsageLimitSourceSnapshot["kind"], string> = { | 396 | const SOURCE_KIND_LABEL: Record<UsageLimitSourceSnapshot["kind"], string> = { |
| 416 | cliproxy: "CLI Proxy", | 397 | cliproxy: "CLI Proxy", |
| 417 | }; | 398 | }; |
| 418 | 399 | ||
| 419 | type LimitsSource = ReturnType<typeof collectLimitSources>[number]; | 400 | type LimitsSource = ReturnType<typeof collectLimitSources>[number]; |
| 420 | 401 | ||
| 421 | /** | 402 | /** Read-only accounts pooled by a configured usage source. */ |
| 422 | * Removing a hub also deletes its management key from the server, so it | 403 | function SourceLimits({ source, now }: { readonly source: LimitsSource; readonly now: number }) { |
| 423 | * asks first and says so. A bare icon that acted on click was too easy to | ||
| 424 | * hit while reaching for the row beside it. | ||
| 425 | */ | ||
| 426 | function RemoveSourceButton({ | ||
| 427 | source, | ||
| 428 | onConfirm, | ||
| 429 | }: { | ||
| 430 | readonly source: UsageLimitSourceSnapshot; | ||
| 431 | readonly onConfirm: () => void; | ||
| 432 | }) { | ||
| 433 | const [open, setOpen] = useState(false); | ||
| 434 | return ( | ||
| 435 | <> | ||
| 436 | <Button size="xs" variant="ghost" onClick={() => setOpen(true)}> | ||
| 437 | Remove | ||
| 438 | </Button> | ||
| 439 | <AlertDialog open={open} onOpenChange={setOpen}> | ||
| 440 | <AlertDialogPopup> | ||
| 441 | <AlertDialogHeader> | ||
| 442 | <AlertDialogTitle>Remove {source.label}?</AlertDialogTitle> | ||
| 443 | <AlertDialogDescription> | ||
| 444 | The hub's management key is deleted from this server. Its accounts leave the Limits | ||
| 445 | view; the hub itself is untouched. Add it again with the URL and key to bring them | ||
| 446 | back. | ||
| 447 | </AlertDialogDescription> | ||
| 448 | </AlertDialogHeader> | ||
| 449 | <AlertDialogFooter> | ||
| 450 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> | ||
| 451 | <Button | ||
| 452 | variant="destructive" | ||
| 453 | onClick={() => { | ||
| 454 | setOpen(false); | ||
| 455 | onConfirm(); | ||
| 456 | }} | ||
| 457 | > | ||
| 458 | Remove hub | ||
| 459 | </Button> | ||
| 460 | </AlertDialogFooter> | ||
| 461 | </AlertDialogPopup> | ||
| 462 | </AlertDialog> | ||
| 463 | </> | ||
| 464 | ); | ||
| 465 | } | ||
| 466 | |||
| 467 | function SourceLimits({ | ||
| 468 | source, | ||
| 469 | now, | ||
| 470 | onRemove, | ||
| 471 | }: { | ||
| 472 | readonly source: LimitsSource; | ||
| 473 | readonly now: number; | ||
| 474 | readonly onRemove: (() => void) | null; | ||
| 475 | }) { | ||
| 476 | const kind = SOURCE_KIND_LABEL[source.kind]; | 404 | const kind = SOURCE_KIND_LABEL[source.kind]; |
| 477 | return ( | 405 | return ( |
| 478 | <div className="flex flex-col gap-6"> | 406 | <div className="flex flex-col gap-6"> |
| 479 | <div className="flex items-center justify-between gap-3"> | ||
| 480 | <h2 className="text-xs tracking-wide text-muted-foreground uppercase">{source.label}</h2> | ||
| 481 | {onRemove ? <RemoveSourceButton source={source} onConfirm={onRemove} /> : null} | ||
| 482 | </div> | ||
| 483 | {source.error ? ( | 407 | {source.error ? ( |
| 484 | <span className="text-xs text-muted-foreground">{source.error}</span> | 408 | <span className="text-xs text-muted-foreground">{source.error}</span> |
| 485 | ) : source.accounts.length === 0 ? ( | 409 | ) : source.accounts.length === 0 ? ( |
| ... | @@ -497,49 +421,6 @@ function SourceLimits({ | ... | @@ -497,49 +421,6 @@ function SourceLimits({ |
| 497 | ); | 421 | ); |
| 498 | } | 422 | } |
| 499 | 423 | ||
| 500 | /** | ||
| 501 | * Whether this client's credential may write settings on an environment, | ||
| 502 | * resolved the way Settings → Providers does: the desktop app owns its | ||
| 503 | * primary outright; a browser session checks the scopes it was granted; | ||
| 504 | * a remote environment reports scopes over its own session endpoint. | ||
| 505 | */ | ||
| 506 | function useCanOperateEnvironment(environment: EnvironmentPresentation | null): boolean { | ||
| 507 | const isPrimary = environment?.entry.target._tag === "PrimaryConnectionTarget"; | ||
| 508 | const primarySession = usePrimarySessionState(); | ||
| 509 | const remoteSession = useEnvironmentSessionState( | ||
| 510 | environment?.environmentId ?? EnvironmentId.make("none"), | ||
| 511 | ); | ||
| 512 | if (environment === null || environment.connection.phase !== "connected") return false; | ||
| 513 | if (isPrimary && isElectron) return true; | ||
| 514 | const access = isPrimary | ||
| 515 | ? resolvePrimaryOperateAccess({ | ||
| 516 | isPrimary: true, | ||
| 517 | hasDesktopBridge: false, | ||
| 518 | session: primarySession.data, | ||
| 519 | isPending: primarySession.isPending, | ||
| 520 | hasError: primarySession.error !== null, | ||
| 521 | }) | ||
| 522 | : resolveRemoteOperateAccess({ | ||
| 523 | session: remoteSession.data, | ||
| 524 | isPending: remoteSession.isPending, | ||
| 525 | hasError: remoteSession.hasError, | ||
| 526 | }); | ||
| 527 | return access === "granted"; | ||
| 528 | } | ||
| 529 | |||
| 530 | /** One source with a remove control bound to the environment it lives in. */ | ||
| 531 | function SourceLimitsRow({ source, now }: { readonly source: LimitsSource; readonly now: number }) { | ||
| 532 | const updateSettings = useUpdateEnvironmentSettings(source.environmentId); | ||
| 533 | const { environments } = useEnvironments(); | ||
| 534 | const environment = | ||
| 535 | environments.find((entry) => entry.environmentId === source.environmentId) ?? null; | ||
| 536 | const canOperate = useCanOperateEnvironment(environment); | ||
| 537 | // The patch names only this entry, so two edits in flight cannot clobber | ||
| 538 | // each other's map. | ||
| 539 | const remove = () => updateSettings({ usageLimitSources: { [source.id]: null } }); | ||
| 540 | return <SourceLimits source={source} now={now} onRemove={canOperate ? remove : null} />; | ||
| 541 | } | ||
| 542 | |||
| 543 | /** | 424 | /** |
| 544 | * Subscription quota windows from every connected environment's providers. | 425 | * Subscription quota windows from every connected environment's providers. |
| 545 | * Countdowns anchor to render time rather than ticking: a live clock would | 426 | * Countdowns anchor to render time rather than ticking: a live clock would |
| ... | @@ -549,108 +430,18 @@ export function UsageLimitsSection() { | ... | @@ -549,108 +430,18 @@ export function UsageLimitsSection() { |
| 549 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); | 430 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); |
| 550 | const groups = collectLimitsGroups(presentations); | 431 | const groups = collectLimitsGroups(presentations); |
| 551 | const sources = collectLimitSources(presentations); | 432 | const sources = collectLimitSources(presentations); |
| 552 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | ||
| 553 | const { environments } = useEnvironments(); | ||
| 554 | const [adding, setAdding] = useState(false); | ||
| 555 | // Anchored once per mount on purpose: countdowns must not tick (see below). | 433 | // Anchored once per mount on purpose: countdowns must not tick (see below). |
| 556 | const [now] = useState(() => Date.now()); | 434 | const [now] = useState(() => Date.now()); |
| 557 | 435 | ||
| 558 | // Sources live in one environment's settings. Writing them needs only the | ||
| 559 | // operate scope, like any provider control, so a T3 Connect client can add | ||
| 560 | // a hub to whichever environment it is connected to: the primary when there | ||
| 561 | // is one, else the first connected environment, with a picker for more. | ||
| 562 | const connected = environments.filter( | ||
| 563 | (environment) => environment.connection.phase === "connected", | ||
| 564 | ); | ||
| 565 | const [pickedEnvironmentId, setPickedEnvironmentId] = useState<EnvironmentId | null>(null); | ||
| 566 | const targetEnvironment = | ||
| 567 | (pickedEnvironmentId !== null | ||
| 568 | ? connected.find((environment) => environment.environmentId === pickedEnvironmentId) | ||
| 569 | : undefined) ?? | ||
| 570 | (primaryEnvironmentId !== null | ||
| 571 | ? connected.find((environment) => environment.environmentId === primaryEnvironmentId) | ||
| 572 | : undefined) ?? | ||
| 573 | connected[0] ?? | ||
| 574 | null; | ||
| 575 | const canOperateTarget = useCanOperateEnvironment(targetEnvironment); | ||
| 576 | |||
| 577 | return ( | 436 | return ( |
| 578 | <div className="flex flex-col gap-8"> | 437 | <div className="flex flex-col gap-8"> |
| 579 | {/* Sources first: they are the thing a user configures here, so the | ||
| 580 | control to add one sits at the top rather than after every row. */} | ||
| 581 | <div className="flex items-center justify-between gap-3"> | ||
| 582 | <div className="flex flex-col gap-1"> | ||
| 583 | <h2 className="text-sm font-medium text-foreground">Usage sources</h2> | ||
| 584 | <p className="text-xs text-muted-foreground"> | ||
| 585 | Quota from a CLIProxyAPI hub shows beside the providers signed in on this machine. | ||
| 586 | </p> | ||
| 587 | </div> | ||
| 588 | {/* The picker stays whenever several environments are connected, so | ||
| 589 | a read-only default target does not hide the way to an operable | ||
| 590 | one; only the button follows the picked target's access. */} | ||
| 591 | {targetEnvironment ? ( | ||
| 592 | <div className="flex items-center gap-2"> | ||
| 593 | {connected.length > 1 ? ( | ||
| 594 | <Select | ||
| 595 | value={targetEnvironment.environmentId} | ||
| 596 | onValueChange={(value) => { | ||
| 597 | if (value !== null) setPickedEnvironmentId(EnvironmentId.make(value)); | ||
| 598 | }} | ||
| 599 | > | ||
| 600 | <SelectTrigger | ||
| 601 | aria-label="Environment to add the hub to" | ||
| 602 | size="compact" | ||
| 603 | variant="ghost" | ||
| 604 | className="w-auto min-w-0" | ||
| 605 | > | ||
| 606 | <SelectValue>{targetEnvironment.label}</SelectValue> | ||
| 607 | </SelectTrigger> | ||
| 608 | <SelectPopup align="end" alignItemWithTrigger={false}> | ||
| 609 | {connected.map((environment) => ( | ||
| 610 | <SelectItem key={environment.environmentId} value={environment.environmentId}> | ||
| 611 | {environment.label} | ||
| 612 | </SelectItem> | ||
| 613 | ))} | ||
| 614 | </SelectPopup> | ||
| 615 | </Select> | ||
| 616 | ) : null} | ||
| 617 | {canOperateTarget ? ( | ||
| 618 | <Button size="xs" variant="outline" onClick={() => setAdding(true)}> | ||
| 619 | <PlusIcon className="size-3" aria-hidden /> | ||
| 620 | Add hub | ||
| 621 | </Button> | ||
| 622 | ) : ( | ||
| 623 | <Tooltip> | ||
| 624 | <TooltipTrigger | ||
| 625 | render={ | ||
| 626 | <span | ||
| 627 | tabIndex={0} | ||
| 628 | className="rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring" | ||
| 629 | /> | ||
| 630 | } | ||
| 631 | > | ||
| 632 | <span className="inline-flex" inert> | ||
| 633 | <Button size="xs" variant="outline" disabled> | ||
| 634 | <PlusIcon className="size-3" aria-hidden /> | ||
| 635 | Add hub | ||
| 636 | </Button> | ||
| 637 | </span> | ||
| 638 | </TooltipTrigger> | ||
| 639 | <TooltipPopup side="top" className="max-w-72"> | ||
| 640 | Your session cannot change settings on {targetEnvironment.label}. | ||
| 641 | </TooltipPopup> | ||
| 642 | </Tooltip> | ||
| 643 | )} | ||
| 644 | </div> | ||
| 645 | ) : null} | ||
| 646 | </div> | ||
| 647 | {groups.length === 0 && sources.length === 0 ? ( | 438 | {groups.length === 0 && sources.length === 0 ? ( |
| 648 | <p className="text-sm text-muted-foreground"> | 439 | <p className="text-sm text-muted-foreground"> |
| 649 | No provider on a connected environment reports subscription limits. | 440 | No provider on a connected environment reports subscription limits. |
| 650 | </p> | 441 | </p> |
| 651 | ) : null} | 442 | ) : null} |
| 652 | {sources.map((source) => ( | 443 | {sources.map((source) => ( |
| 653 | <SourceLimitsRow key={source.key} source={source} now={now} /> | 444 | <SourceLimits key={source.key} source={source} now={now} /> |
| 654 | ))} | 445 | ))} |
| 655 | {groups.map((group) => ( | 446 | {groups.map((group) => ( |
| 656 | <div key={group.environmentId} className="flex flex-col gap-6"> | 447 | <div key={group.environmentId} className="flex flex-col gap-6"> |
| ... | @@ -669,18 +460,6 @@ export function UsageLimitsSection() { | ... | @@ -669,18 +460,6 @@ export function UsageLimitsSection() { |
| 669 | ))} | 460 | ))} |
| 670 | </div> | 461 | </div> |
| 671 | ))} | 462 | ))} |
| 672 | {targetEnvironment && canOperateTarget ? ( | ||
| 673 | // Keyed on the target: if it disconnects or the primary changes while | ||
| 674 | // the dialog is open, a fresh dialog mounts empty rather than carrying | ||
| 675 | // a typed key over to a different environment. | ||
| 676 | <AddUsageLimitSourceDialog | ||
| 677 | key={targetEnvironment.environmentId} | ||
| 678 | open={adding} | ||
| 679 | onOpenChange={setAdding} | ||
| 680 | environmentId={targetEnvironment.environmentId} | ||
| 681 | environmentLabel={targetEnvironment.label} | ||
| 682 | /> | ||
| 683 | ) : null} | ||
| 684 | </div> | 463 | </div> |
| 685 | ); | 464 | ); |
| 686 | } | 465 | } |
docs/user/usage.md+4-2| ... | @@ -18,8 +18,10 @@ provider health-check interval and update live while a turn runs. API-key accoun | ... | @@ -18,8 +18,10 @@ 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 hub** on the Limits view shows the accounts | 21 | If you pool accounts behind a CLIProxyAPI hub, open **Settings → Providers → Usage providers** |
| 22 | the hub manages. Each row shows its provider and instance name, or a small _CLI Proxy_ label for | 22 | and choose **Add hub**. Select the device that should connect to the hub; its accounts appear on |
| 23 | the Limits view. Remove hubs from the same settings section. Each limits row shows its provider | ||
| 24 | 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 | 25 | 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 | 26 | 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 | 27 | if the connected provider cannot report limits. Enter the hub's URL and management key; the key |