From 0af6774b4c37f9bc3cb883d825a156c7379c7f3c Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:39:05 -0400 Subject: [PATCH 1/3] [Feat] Show subscription provider usage on Models settings Fetch usage/quota for ChatGPT, GitHub Copilot, and Kimi for Coding server-side using the credentials the deployment already holds, expose it via an admin-gated subscriptionUsage.list tRPC query, and render a usage line under each provider row in Settings > Models. The upstream usage endpoints are unofficial (each is what the provider's own CLI polls), so parsers accept known field aliases and any failure just hides the usage line instead of erroring the page. --- apps/docs/models.mdx | 7 + .../settings/InferenceProviderSection.tsx | 147 ++++- .../trpc/commands/subscription-usage/index.ts | 20 + apps/web/src/trpc/routers/_app.ts | 7 + .../subscription-provider-usage.test.ts | 410 +++++++++++++ .../db/src/lib/subscription-provider-usage.ts | 553 ++++++++++++++++++ packages/db/src/server.ts | 1 + packages/types/src/index.ts | 1 + .../types/src/subscription-provider-usage.ts | 65 ++ 9 files changed, 1193 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/trpc/commands/subscription-usage/index.ts create mode 100644 packages/db/src/lib/__tests__/subscription-provider-usage.test.ts create mode 100644 packages/db/src/lib/subscription-provider-usage.ts create mode 100644 packages/types/src/subscription-provider-usage.ts diff --git a/apps/docs/models.mdx b/apps/docs/models.mdx index 00793a275..bc7de68fa 100644 --- a/apps/docs/models.mdx +++ b/apps/docs/models.mdx @@ -137,6 +137,13 @@ list. - **Models** controls the provider/model pairs that are available, the default model, and specialized model roles. +For subscription-based providers (ChatGPT, GitHub Copilot, and Kimi for +Coding), the provider row also shows current plan usage when the provider +reports it, such as remaining Copilot premium requests or the percent used of +the ChatGPT 5-hour and weekly limits. These numbers come from unofficial +provider endpoints, so the usage line is hidden whenever the provider does not +return usable data. + Admins can enable or disable models from the task model list. The default model must stay enabled, because Roomote uses it when a task does not request a specific model. diff --git a/apps/web/src/components/settings/InferenceProviderSection.tsx b/apps/web/src/components/settings/InferenceProviderSection.tsx index 4034316e2..4a0a7e769 100644 --- a/apps/web/src/components/settings/InferenceProviderSection.tsx +++ b/apps/web/src/components/settings/InferenceProviderSection.tsx @@ -12,6 +12,9 @@ import type { SetupModelProviderId, SetupModelProviderStatus, SetupModelStatus, + SubscriptionProviderUsage, + SubscriptionUsageProviderId, + SubscriptionUsageWindow, } from '@roomote/types'; import { useTRPC } from '@/trpc/client'; @@ -113,14 +116,86 @@ function getSubmitAdditionalEnvValues( ); } +function formatUsageReset(resetsAt: string): string | null { + const reset = new Date(resetsAt).getTime(); + + if (Number.isNaN(reset)) { + return null; + } + + const deltaMinutes = Math.round((reset - Date.now()) / 60_000); + + if (deltaMinutes <= 0) { + return null; + } + if (deltaMinutes < 60) { + return `resets in ${deltaMinutes}m`; + } + if (deltaMinutes < 48 * 60) { + return `resets in ${Math.round(deltaMinutes / 60)}h`; + } + + return `resets ${new Date(reset).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + })}`; +} + +function formatUsageWindow(window: SubscriptionUsageWindow): string | null { + let value: string; + + if (window.unlimited) { + value = 'unlimited'; + } else if (window.remaining !== undefined && window.limit !== undefined) { + value = `${window.remaining.toLocaleString()} of ${window.limit.toLocaleString()} left`; + } else if (window.usedPercent !== undefined) { + value = `${Math.round(window.usedPercent)}% used`; + } else { + return null; + } + + const reset = + !window.unlimited && window.resetsAt + ? formatUsageReset(window.resetsAt) + : null; + + return `${window.label}: ${value}${reset ? ` (${reset})` : ''}`; +} + +function SubscriptionUsageLine({ + usage, + className, +}: { + usage: SubscriptionProviderUsage | undefined; + className?: string; +}) { + const parts = (usage?.windows ?? []) + .map(formatUsageWindow) + .filter((part): part is string => part !== null); + + if (parts.length === 0) { + return null; + } + + return ( +

+ {parts.join(' · ')} +

+ ); +} + function ConnectedProviderRow({ provider, + usage, isSaving, canDelete, onEdit, onDelete, }: { provider: SetupModelProviderStatus; + usage?: SubscriptionProviderUsage; isSaving: boolean; canDelete: boolean; onEdit: () => void; @@ -158,6 +233,7 @@ function ConnectedProviderRow({ aria-label={`${primaryCredentialLabel} for ${provider.label}`} data-1p-ignore /> + {hasRuntimeKey ? ( @@ -549,6 +625,7 @@ function ChatGptSubscriptionRow({ errored, accountEmail, errorMessage, + usage, onReconnect, onDisconnect, isDisconnecting, @@ -556,6 +633,7 @@ function ChatGptSubscriptionRow({ errored: boolean; accountEmail?: string; errorMessage?: string; + usage?: SubscriptionProviderUsage; onReconnect: () => void; onDisconnect: () => Promise; isDisconnecting: boolean; @@ -569,17 +647,20 @@ function ChatGptSubscriptionRow({
- {errored ? ( -

- {errorMessage ?? 'ChatGPT subscription needs to be reconnected.'} -

- ) : ( -

- {accountEmail - ? `Connected as ${accountEmail}` - : 'Connected to a ChatGPT account.'} -

- )} +
+ {errored ? ( +

+ {errorMessage ?? 'ChatGPT subscription needs to be reconnected.'} +

+ ) : ( +

+ {accountEmail + ? `Connected as ${accountEmail}` + : 'Connected to a ChatGPT account.'} +

+ )} + {!errored ? : null} +
{errored ? (