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/ChatGptConnectDialog.tsx b/apps/web/src/components/settings/ChatGptConnectDialog.tsx index 05263ea36..f254eb146 100644 --- a/apps/web/src/components/settings/ChatGptConnectDialog.tsx +++ b/apps/web/src/components/settings/ChatGptConnectDialog.tsx @@ -87,6 +87,9 @@ export function ChatGptConnectDialog({ await queryClient.invalidateQueries({ queryKey: trpc.chatgptSubscription.status.queryKey(), }); + await queryClient.invalidateQueries({ + queryKey: trpc.subscriptionUsage.list.queryKey(), + }); await onConnected?.(); handleOpenChange(false); } else if (result.status === 'failed') { diff --git a/apps/web/src/components/settings/GitHubCopilotConnectDialog.tsx b/apps/web/src/components/settings/GitHubCopilotConnectDialog.tsx index cfde0f71e..023a25d81 100644 --- a/apps/web/src/components/settings/GitHubCopilotConnectDialog.tsx +++ b/apps/web/src/components/settings/GitHubCopilotConnectDialog.tsx @@ -94,6 +94,9 @@ export function GitHubCopilotConnectDialog({ queryClient.invalidateQueries({ queryKey: trpc.githubCopilotSubscription.status.queryKey(), }), + queryClient.invalidateQueries({ + queryKey: trpc.subscriptionUsage.list.queryKey(), + }), ]); await onConnected?.(); close(false); diff --git a/apps/web/src/components/settings/InferenceProviderSection.test.tsx b/apps/web/src/components/settings/InferenceProviderSection.test.tsx index 74ca4db57..cd3e34391 100644 --- a/apps/web/src/components/settings/InferenceProviderSection.test.tsx +++ b/apps/web/src/components/settings/InferenceProviderSection.test.tsx @@ -14,14 +14,19 @@ const chatgptStatusData = vi.hoisted(() => ({ const githubCopilotStatusData = vi.hoisted(() => ({ current: null as Record | null, })); +const subscriptionUsageData = vi.hoisted(() => ({ + current: null as Array> | null, +})); const mutateAsyncMock = vi.hoisted(() => vi.fn()); vi.mock('@tanstack/react-query', () => ({ useMutation: () => ({ mutateAsync: mutateAsyncMock }), useQuery: (options: { queryKey?: string[] }) => ({ - data: options.queryKey?.[0]?.includes('githubCopilot') - ? githubCopilotStatusData.current - : chatgptStatusData.current, + data: options.queryKey?.[0]?.includes('subscriptionUsage') + ? subscriptionUsageData.current + : options.queryKey?.[0]?.includes('githubCopilot') + ? githubCopilotStatusData.current + : chatgptStatusData.current, }), useQueryClient: () => ({ invalidateQueries: vi.fn() }), })); @@ -79,6 +84,12 @@ vi.mock('@/trpc/client', () => ({ mutationOptions: () => ({ mutationKey: ['disconnectCopilot'] }), }, }, + subscriptionUsage: { + list: { + queryOptions: () => ({ queryKey: ['subscriptionUsage', 'list'] }), + queryKey: () => ['subscriptionUsage', 'list'], + }, + }, }), })); @@ -219,6 +230,8 @@ describe('InferenceProviderSection', () => { window.HTMLElement.prototype.scrollIntoView = vi.fn(); providerSetupData.current = null; chatgptStatusData.current = null; + githubCopilotStatusData.current = null; + subscriptionUsageData.current = null; }); const renderInferenceProviderSection = () => { @@ -309,6 +322,68 @@ describe('InferenceProviderSection', () => { ).not.toBeInTheDocument(); }); + it('shows a usage line under a connected subscription row', () => { + providerSetupData.current = buildProviderSetup({ chatgptConnected: true }); + chatgptStatusData.current = { + connected: true, + status: 'connected', + email: 'user@example.com', + }; + githubCopilotStatusData.current = { connected: true, status: 'connected' }; + subscriptionUsageData.current = [ + { + providerId: 'chatgpt', + planType: 'pro', + windows: [ + { + label: 'Weekly limit', + usedPercent: 8, + resetsAt: new Date(Date.now() + 5 * 3_600_000).toISOString(), + }, + ], + fetchedAt: new Date().toISOString(), + }, + { + providerId: 'github-copilot', + windows: [{ label: 'Premium requests', remaining: 211, limit: 300 }], + fetchedAt: new Date().toISOString(), + }, + ]; + + renderInferenceProviderSection(); + + expect( + screen.getByText('Weekly limit: 8% used (resets in 5h)'), + ).toBeInTheDocument(); + expect( + screen.getByText('Premium requests: 211 of 300 left'), + ).toBeInTheDocument(); + }); + + it('omits the usage line when no usage data is available or the row errored', () => { + providerSetupData.current = buildProviderSetup({ chatgptConnected: true }); + chatgptStatusData.current = { + connected: false, + status: 'error', + error: 'ChatGPT token refresh failed: 401', + }; + githubCopilotStatusData.current = { connected: true, status: 'connected' }; + subscriptionUsageData.current = [ + { + providerId: 'chatgpt', + windows: [{ label: 'Weekly limit', usedPercent: 8 }], + fetchedAt: new Date().toISOString(), + }, + ]; + + renderInferenceProviderSection(); + + // The errored ChatGPT row hides its stale usage; the Copilot row has no + // usage entry at all, so neither renders a usage line. + expect(screen.queryByText(/Weekly limit/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Premium requests/)).not.toBeInTheDocument(); + }); + it('renders Reconnect and Disconnect for an errored ChatGPT subscription', () => { providerSetupData.current = buildProviderSetup(); chatgptStatusData.current = { diff --git a/apps/web/src/components/settings/InferenceProviderSection.tsx b/apps/web/src/components/settings/InferenceProviderSection.tsx index 4034316e2..c0c37d5ee 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 ? (