diff --git a/apps/mobile/src/features/settings/components/SettingsSection.tsx b/apps/mobile/src/features/settings/components/SettingsSection.tsx index 9c87561a8ee9a8855bb4e82bcaf7a49fc426d30a..6bea4ffe24a4409d608aeb8ec9258b553afc1d19 100644 --- a/apps/mobile/src/features/settings/components/SettingsSection.tsx +++ b/apps/mobile/src/features/settings/components/SettingsSection.tsx @@ -4,14 +4,16 @@ import { View } from "react-native"; import { AppText as Text } from "../../../components/AppText"; export function SettingsSection(props: { - readonly title: string; + readonly title?: string; readonly children: ReactNode; /** Force the grouped card background; Android otherwise lists options flat. */ readonly card?: boolean; }) { return ( - {props.title} + {props.title ? ( + {props.title} + ) : null} {sources.map((source) => ( - + {source.error ? ( {source.error} ) : source.accounts.length === 0 ? ( diff --git a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..80727a8e970f4971c8158a5fad3b112d0574cc92 --- /dev/null +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx @@ -0,0 +1,157 @@ +import { type EnvironmentId, UsageLimitSourceId } from "@t3tools/contracts"; +import { useState } from "react"; + +import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; + +/** + * Stable per hub and readable in settings.json. Dots and dashes in the host + * are kept so `foo-bar.com` and `foo.bar.com` do not collide; anything else + * (a port's colon, a path) is folded to a dash. + */ +function sourceIdFromUrl(url: string): UsageLimitSourceId { + let host = url; + try { + host = new URL(url).host; + } catch { + // Keep the raw text; the server reports the bad URL on its row. + } + const slug = host + .toLowerCase() + .replace(/[^a-z0-9.-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return UsageLimitSourceId.make(`cliproxy-${slug || "hub"}`); +} + +/** + * Adds a CLIProxyAPI hub from provider settings on one environment. The + * management key is sent once and kept in that server's secret store; + * settings only ever carry a redaction marker for it afterwards. + */ +export function AddUsageLimitSourceDialog({ + open, + onOpenChange, + environmentId, + environmentLabel, +}: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; +}) { + const updateSettings = useUpdateEnvironmentSettings(environmentId); + const [label, setLabel] = useState(""); + const [url, setUrl] = useState(""); + const [managementKey, setManagementKey] = useState(""); + const trimmedUrl = url.trim(); + const canSave = trimmedUrl.length > 0 && managementKey.trim().length > 0; + + const reset = () => { + setLabel(""); + setUrl(""); + setManagementKey(""); + }; + + const save = () => { + if (!canSave) return; + const id = sourceIdFromUrl(trimmedUrl); + // The patch names only this entry; the server merges it into its map. + updateSettings({ + usageLimitSources: { + [id]: { + kind: "cliproxy", + ...(label.trim() ? { label: label.trim() } : {}), + url: trimmedUrl, + managementKey: managementKey.trim(), + enabled: true, + }, + }, + }); + reset(); + onOpenChange(false); + }; + + return ( + { + if (!next) reset(); + onOpenChange(next); + }} + > + + + Add a CLIProxyAPI hub + + Show the quota of every account the hub pools, next to the providers on{" "} + {environmentLabel}. The key stays on that server. + + + +
{ + event.preventDefault(); + save(); + }} + > +
+ + setUrl(event.target.value)} + autoFocus + /> +
+
+ + setManagementKey(event.target.value)} + /> +
+
+ + setLabel(event.target.value)} + /> +
+
+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 88ea31a8ffe28f1db06098ca7335a441ba45dfc8..b25c127570c6e639ba0869243c9915c94e95ec46 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -68,6 +68,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; import { ProviderInstanceCard } from "./ProviderInstanceCard"; +import { UsageProviderSettings } from "./UsageProviderSettings"; import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { providerSettingsTabClassName } from "./providerSettingsTabs"; @@ -241,7 +242,8 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { )?.environmentId; useEffect(() => { if ( - searchTargetId === searchableSetting("provider-health-check-interval").id && + (searchTargetId === searchableSetting("provider-health-check-interval").id || + searchTargetId === searchableSetting("usage-providers").id) && !selectedEnvironmentCanRenderSettings && searchableEnvironmentId !== undefined ) { @@ -997,6 +999,14 @@ export function EnvironmentProviderSettings({
+ + + setAdding(true)}> + + Add hub + + ) : null + } + > + {entries.length === 0 ? ( + + ) : ( + entries.map(([id, source]) => { + const label = source.label?.trim() || source.url; + return ( + + CLI Proxy{source.enabled ? "" : " · Disabled"} + {label !== source.url ? ` · ${source.url}` : ""} + + } + control={ + !readOnly ? ( + updateSettings({ usageLimitSources: { [id]: null } })} + /> + ) : null + } + /> + ); + }) + )} + + {adding && !readOnly ? ( + + ) : null} + + ); +} + +/** Removing a hub deletes its stored management key, so it requires confirmation. */ +function RemoveUsageProviderButton({ + label, + onConfirm, +}: { + readonly label: string; + readonly onConfirm: () => void; +}) { + const [open, setOpen] = useState(false); + return ( + <> + + + + + Remove {label}? + + The hub's management key is deleted from this server. Its accounts leave the Limits + view; the hub itself is untouched. Add it again with the URL and key to bring them + back. + + + + }>Cancel + + + + + + ); +} diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 6176f51121e4b77529fa6a60e128fe612b0e3305..bf206584f1e9c327e17f85a166b7590d59af940e 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -101,6 +101,16 @@ describe("searchSettings", () => { ]); }); + it.each(["usage providers", "CLIProxyAPI", "CLI proxy hub", "management key"])( + "finds usage-provider management by %s", + (query) => { + expect(searchSettings(query)[0]).toMatchObject({ + id: "usage-providers", + to: "/settings/providers", + }); + }, + ); + it("returns no results for an empty query", () => { expect(searchSettings(" ", ITEMS)).toEqual([]); }); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index ea1a221036d8c6a4c1fe888cce379dd763fbcdc8..22e2204d8a27052a599ad39e6759d6896a5f8563 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -324,6 +324,15 @@ export const SETTINGS_SEARCH_ITEMS = [ "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", ], }, + { + id: "usage-providers", + title: "Usage providers", + to: "/settings/providers", + searchTerms: [ + "usage sources CLIProxyAPI CLI proxy hub quota subscription limits management key add remove", + ], + providerSettingsOnly: true, + }, { id: "provider-health-check-interval", title: "Health check interval", diff --git a/apps/web/src/components/usage/AddUsageLimitSourceDialog.tsx b/apps/web/src/components/usage/AddUsageLimitSourceDialog.tsx deleted file mode 100644 index 9b1329d6bd7ec2edc65cca953d3890c30fbbb16b..0000000000000000000000000000000000000000 --- a/apps/web/src/components/usage/AddUsageLimitSourceDialog.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { type EnvironmentId, UsageLimitSourceId } from "@t3tools/contracts"; -import { useState } from "react"; - -import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; -import { Button } from "../ui/button"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "../ui/dialog"; -import { Input } from "../ui/input"; -import { Label } from "../ui/label"; - -/** - * Stable per hub and readable in settings.json. Dots and dashes in the host - * are kept so `foo-bar.com` and `foo.bar.com` do not collide; anything else - * (a port's colon, a path) is folded to a dash. - */ -function sourceIdFromUrl(url: string): UsageLimitSourceId { - let host = url; - try { - host = new URL(url).host; - } catch { - // Keep the raw text; the server reports the bad URL on its row. - } - const slug = host - .toLowerCase() - .replace(/[^a-z0-9.-]+/g, "-") - .replace(/^-+|-+$/g, ""); - return UsageLimitSourceId.make(`cliproxy-${slug || "hub"}`); -} - -/** - * Adds a CLIProxyAPI hub as a usage-limit source on one environment. The - * management key is sent once and kept in that server's secret store; - * settings only ever carry a redaction marker for it afterwards. - */ -export function AddUsageLimitSourceDialog({ - open, - onOpenChange, - environmentId, - environmentLabel, -}: { - readonly open: boolean; - readonly onOpenChange: (open: boolean) => void; - readonly environmentId: EnvironmentId; - readonly environmentLabel: string; -}) { - const updateSettings = useUpdateEnvironmentSettings(environmentId); - const [label, setLabel] = useState(""); - const [url, setUrl] = useState(""); - const [managementKey, setManagementKey] = useState(""); - const trimmedUrl = url.trim(); - const canSave = trimmedUrl.length > 0 && managementKey.trim().length > 0; - - const reset = () => { - setLabel(""); - setUrl(""); - setManagementKey(""); - }; - - const save = () => { - if (!canSave) return; - const id = sourceIdFromUrl(trimmedUrl); - // The patch names only this entry; the server merges it into its map. - updateSettings({ - usageLimitSources: { - [id]: { - kind: "cliproxy", - ...(label.trim() ? { label: label.trim() } : {}), - url: trimmedUrl, - managementKey: managementKey.trim(), - enabled: true, - }, - }, - }); - reset(); - onOpenChange(false); - }; - - return ( - { - if (!next) reset(); - onOpenChange(next); - }} - > - - - Add a CLIProxyAPI hub - - Show the quota of every account the hub pools, next to the providers on{" "} - {environmentLabel}. The key stays on that server. - - - -
{ - event.preventDefault(); - save(); - }} - > -
- - setUrl(event.target.value)} - autoFocus - /> -
-
- - setManagementKey(event.target.value)} - /> -
-
- - setLabel(event.target.value)} - /> -
-
-
- - - - -
-
- ); -} diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 8e626d0a526703b89a4d1f5cbce84816a6f303b4..b1a57582bc9fe629de7b3e0fe64aee466a5e96b8 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -1,5 +1,5 @@ import { - EnvironmentId, + type EnvironmentId, type ProviderConsumeResetCreditOutcome, ProviderInstanceId, ServerProvider, @@ -21,28 +21,16 @@ import { paceOf, providerLimitsLabel, } from "@t3tools/shared/usageLimits"; -import { GaugeIcon, PlusIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; +import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; import { Fragment, useState } from "react"; -import { isElectron } from "../../env"; -import { usePrimarySessionState } from "../../environments/primary"; -import { usePrimarySettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; -import { - type EnvironmentPresentation, - useEnvironments, - usePrimaryEnvironmentId, -} from "../../state/environments"; -import { useEnvironmentSessionState } from "../../state/session"; +import { usePrimarySettings } from "../../hooks/useSettings"; import { environmentPresentations } from "../../state/presentation"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { formatUpcomingTimestamp } from "../../timestampFormat"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { getDriverOption } from "../settings/providerDriverMeta"; -import { - resolvePrimaryOperateAccess, - resolveRemoteOperateAccess, -} from "../settings/ProviderSettingsPanel.logic"; import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; import { AlertDialog, @@ -54,9 +42,7 @@ import { AlertDialogTitle, } from "../ui/alert-dialog"; import { Button } from "../ui/button"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog"; import { PROVIDER_PRESENTATION } from "./usageProviders"; const PACE: Record = { @@ -407,79 +393,17 @@ function SourceAccountLimits({ ); } -/** - * Accounts a configured source (a CLIProxyAPI hub) pools, grouped under the - * source's name. Unlike provider rows these are read-only: nothing on this - * environment can run a turn against them. - */ const SOURCE_KIND_LABEL: Record = { cliproxy: "CLI Proxy", }; type LimitsSource = ReturnType[number]; -/** - * Removing a hub also deletes its management key from the server, so it - * asks first and says so. A bare icon that acted on click was too easy to - * hit while reaching for the row beside it. - */ -function RemoveSourceButton({ - source, - onConfirm, -}: { - readonly source: UsageLimitSourceSnapshot; - readonly onConfirm: () => void; -}) { - const [open, setOpen] = useState(false); - return ( - <> - - - - - Remove {source.label}? - - The hub's management key is deleted from this server. Its accounts leave the Limits - view; the hub itself is untouched. Add it again with the URL and key to bring them - back. - - - - }>Cancel - - - - - - ); -} - -function SourceLimits({ - source, - now, - onRemove, -}: { - readonly source: LimitsSource; - readonly now: number; - readonly onRemove: (() => void) | null; -}) { +/** Read-only accounts pooled by a configured usage source. */ +function SourceLimits({ source, now }: { readonly source: LimitsSource; readonly now: number }) { const kind = SOURCE_KIND_LABEL[source.kind]; return (
-
-

{source.label}

- {onRemove ? : null} -
{source.error ? ( {source.error} ) : source.accounts.length === 0 ? ( @@ -497,49 +421,6 @@ function SourceLimits({ ); } -/** - * Whether this client's credential may write settings on an environment, - * resolved the way Settings → Providers does: the desktop app owns its - * primary outright; a browser session checks the scopes it was granted; - * a remote environment reports scopes over its own session endpoint. - */ -function useCanOperateEnvironment(environment: EnvironmentPresentation | null): boolean { - const isPrimary = environment?.entry.target._tag === "PrimaryConnectionTarget"; - const primarySession = usePrimarySessionState(); - const remoteSession = useEnvironmentSessionState( - environment?.environmentId ?? EnvironmentId.make("none"), - ); - if (environment === null || environment.connection.phase !== "connected") return false; - if (isPrimary && isElectron) return true; - const access = isPrimary - ? resolvePrimaryOperateAccess({ - isPrimary: true, - hasDesktopBridge: false, - session: primarySession.data, - isPending: primarySession.isPending, - hasError: primarySession.error !== null, - }) - : resolveRemoteOperateAccess({ - session: remoteSession.data, - isPending: remoteSession.isPending, - hasError: remoteSession.hasError, - }); - return access === "granted"; -} - -/** One source with a remove control bound to the environment it lives in. */ -function SourceLimitsRow({ source, now }: { readonly source: LimitsSource; readonly now: number }) { - const updateSettings = useUpdateEnvironmentSettings(source.environmentId); - const { environments } = useEnvironments(); - const environment = - environments.find((entry) => entry.environmentId === source.environmentId) ?? null; - const canOperate = useCanOperateEnvironment(environment); - // The patch names only this entry, so two edits in flight cannot clobber - // each other's map. - const remove = () => updateSettings({ usageLimitSources: { [source.id]: null } }); - return ; -} - /** * Subscription quota windows from every connected environment's providers. * Countdowns anchor to render time rather than ticking: a live clock would @@ -549,108 +430,18 @@ export function UsageLimitsSection() { const presentations = useAtomValue(environmentPresentations.presentationsAtom); const groups = collectLimitsGroups(presentations); const sources = collectLimitSources(presentations); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const { environments } = useEnvironments(); - const [adding, setAdding] = useState(false); // Anchored once per mount on purpose: countdowns must not tick (see below). const [now] = useState(() => Date.now()); - // Sources live in one environment's settings. Writing them needs only the - // operate scope, like any provider control, so a T3 Connect client can add - // a hub to whichever environment it is connected to: the primary when there - // is one, else the first connected environment, with a picker for more. - const connected = environments.filter( - (environment) => environment.connection.phase === "connected", - ); - const [pickedEnvironmentId, setPickedEnvironmentId] = useState(null); - const targetEnvironment = - (pickedEnvironmentId !== null - ? connected.find((environment) => environment.environmentId === pickedEnvironmentId) - : undefined) ?? - (primaryEnvironmentId !== null - ? connected.find((environment) => environment.environmentId === primaryEnvironmentId) - : undefined) ?? - connected[0] ?? - null; - const canOperateTarget = useCanOperateEnvironment(targetEnvironment); - return (
- {/* Sources first: they are the thing a user configures here, so the - control to add one sits at the top rather than after every row. */} -
-
-

Usage sources

-

- Quota from a CLIProxyAPI hub shows beside the providers signed in on this machine. -

-
- {/* The picker stays whenever several environments are connected, so - a read-only default target does not hide the way to an operable - one; only the button follows the picked target's access. */} - {targetEnvironment ? ( -
- {connected.length > 1 ? ( - - ) : null} - {canOperateTarget ? ( - - ) : ( - - - } - > - - - - - - Your session cannot change settings on {targetEnvironment.label}. - - - )} -
- ) : null} -
{groups.length === 0 && sources.length === 0 ? (

No provider on a connected environment reports subscription limits.

) : null} {sources.map((source) => ( - + ))} {groups.map((group) => (
@@ -669,18 +460,6 @@ export function UsageLimitsSection() { ))}
))} - {targetEnvironment && canOperateTarget ? ( - // Keyed on the target: if it disconnects or the primary changes while - // the dialog is open, a fresh dialog mounts empty rather than carrying - // a typed key over to a different environment. - - ) : null}
); } diff --git a/docs/user/usage.md b/docs/user/usage.md index 2fcb5c54210b8da478a69611dc84a5510f8cff57..fceedc3c25600cc5b49a57f6c04d78b7618e623f 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -18,8 +18,10 @@ provider health-check interval and update live while a turn runs. API-key accoun subscription windows and say so; that includes a Claude Code that reaches Anthropic through a proxy via `ANTHROPIC_AUTH_TOKEN`, since the CLI then treats itself as an API-key client. -If you pool accounts behind a CLIProxyAPI hub, **Add hub** on the Limits view shows the accounts -the hub manages. Each row shows its provider and instance name, or a small _CLI Proxy_ label for +If you pool accounts behind a CLIProxyAPI hub, open **Settings → Providers → Usage providers** +and choose **Add hub**. Select the device that should connect to the hub; its accounts appear on +the Limits view. Remove hubs from the same settings section. Each limits row shows its provider +and instance name, or a small _CLI Proxy_ label for hub accounts. When a connected provider reports limits for the same provider and email, its row replaces the hub copy, keeping details such as banked reset credits. The hub copy remains visible if the connected provider cannot report limits. Enter the hub's URL and management key; the key