diff --git a/docs/ai-coder/agents/platform-controls/index.md b/docs/ai-coder/agents/platform-controls/index.md index d8b7469c47b..53bc04d0a4d 100644 --- a/docs/ai-coder/agents/platform-controls/index.md +++ b/docs/ai-coder/agents/platform-controls/index.md @@ -116,12 +116,13 @@ none, if the template does not define any). ### Spend management -Administrators can set spend limits to cap LLM usage per user within a rolling -time period, with per-user and per-group overrides. The cost tracking dashboard -provides visibility into per-user spending, token consumption, and per-model -breakdowns. +The Spend page provides usage-only reporting for Coder Agents chats. The native +usage-limit configuration UI has been removed from release 2.36, but values +configured before upgrading remain stored and enforced. Existing native values +are not migrated to AI Gateway budgets. -See [Spend Management](./usage-insights.md) for details. +Configure new AI Gateway budgets from group settings or through the generated +group budget API. See [Spend Management](./usage-insights.md) for details. ### Git providers diff --git a/docs/ai-coder/agents/platform-controls/usage-insights.md b/docs/ai-coder/agents/platform-controls/usage-insights.md index b6b2d1e5db1..3ffca0273ec 100644 --- a/docs/ai-coder/agents/platform-controls/usage-insights.md +++ b/docs/ai-coder/agents/platform-controls/usage-insights.md @@ -1,90 +1,51 @@ # Spend Management -Coder provides admin-only controls for monitoring and controlling agent -spend: usage limits and cost tracking. +Coder provides usage reporting for Coder Agents and two independent ways to +limit spend on release 2.36: existing native chat usage limits and AI Gateway +budgets. -## Usage limits +## Native chat usage limits -Navigate to **Agents** > **Settings** > **Manage Agents** > **Spend**. +The native usage-limit configuration UI has been removed from release 2.36. +Values configured before upgrading remain stored and enforced. Coder checks the +user's current spend before processing each chat message and returns a **409 +Conflict** response when the applicable limit is reached. -Usage limits cap how much each user can spend on LLM usage within a rolling -time period. When enabled, the system checks the user's current spend before -processing each chat message. +Existing native values are not migrated to AI Gateway budgets. To change spend +controls after upgrading, configure new AI Gateway budgets. Native limits remain +in effect until they are changed through the existing experimental API or +removed in a later release. -### Configuration +## AI Gateway budgets -- **Enable/disable toggle** — master on/off for the entire limit system. -- **Period** — `day`, `week`, or `month`. Periods are UTC-aligned: midnight - UTC for daily, Monday start for weekly, first of the month for monthly. -- **Default limit** — deployment-wide default in dollars. Applies to all - users who do not have a more specific override. Leave unset for no limit. -- **Per-user overrides** — set a custom dollar limit for an individual user. - Takes highest priority. -- **Per-group overrides** — set a limit for a group. When a user belongs to - multiple groups, the lowest group limit applies. +AI Gateway budgets cap each user's AI Gateway spend, including Coder Agents +chats, over a monthly period. -### Priority hierarchy +- Set a group budget from the group's settings page. +- When a user belongs to several budgeted groups, the deployment budget policy + selects the applicable group. The default policy selects the highest budget. +- A per-user override takes priority over group budgets. -The system resolves a user's effective limit in this order: +Budget controls require a license that includes AI Gateway. Existing native +usage-limit values and recorded spend are not migrated when you configure a +budget. -1. Individual user override (highest priority) -1. Minimum group limit across all of the user's groups -1. Global default limit -1. No limit (if limits are disabled or no value is configured) +The API reference documents how to +[get](../../../reference/api/enterprise.md#get-group-ai-budget), +[upsert](../../../reference/api/enterprise.md#upsert-group-ai-budget), and +[delete](../../../reference/api/enterprise.md#delete-group-ai-budget) a group +budget. -### Enforcement +The Agents page shows the signed-in user's current AI Gateway spend and budget +when AI Gateway is available. -- Checked before each chat message is processed. -- When current spend meets or exceeds the limit, the chat returns a - **409 Conflict** response and the message is blocked. -- Fail-open: if the limit query itself fails, the message is allowed - through. -- Brief overage is possible when concurrent messages are in flight, because - cost is determined only after the LLM returns. +## Spend visibility -### User-facing status +Navigate to **Agents** > **Settings** > **Manage Agents** > **Spend** to view +usage-only reporting for deployment-wide Coder Agents chat costs. -Users can view their own spend status, including whether a limit is active, -their effective limit, current spend, and when the current period resets. +The top-level table includes total cost, message and chat counts, and token usage +for each user. It supports date range filtering, search, and pagination. -> [!NOTE] -> The admin configuration page shows the count of models without pricing -> data. Models missing pricing cannot be tracked accurately against limits. - -## Cost tracking - -Navigate to **Agents** > **Settings** > **Manage Agents** > **Spend**. - -This view shows deployment-wide LLM chat costs with per-user drill-down. - -### Top-level view - -A per-user rollup table with the following columns: - -| Column | Description | -|--------------------|-------------------------------------| -| Total cost | Aggregate dollar spend for the user | -| Messages | Number of chat messages sent | -| Chats | Number of distinct chat sessions | -| Input tokens | Total input tokens consumed | -| Output tokens | Total output tokens consumed | -| Cache read tokens | Tokens served from cache | -| Cache write tokens | Tokens written to cache | - -The table supports date range filtering (default: last 30 days), search by -name or username, and pagination. - -### Per-user detail view - -Select a user to see: - -- **Summary cards** — total cost, token breakdowns, and message counts. -- **Usage limit progress** — if a limit is active, a color-coded progress - bar shows current spend relative to the limit. -- **Per-model breakdown** — table of costs and token usage by model. -- **Per-chat breakdown** — table of costs and token usage by chat session. - -> [!NOTE] -> Automatic title generation uses lightweight models, such as Claude Haiku or GPT-4o -> Mini. Its token usage is not counted towards usage limits or shown in usage -> summaries. +Select a user to view summary cards and per-model and per-chat breakdowns. The +Spend page does not configure native usage limits or AI Gateway budgets. diff --git a/docs/ai-coder/ai-gateway/cost-controls.md b/docs/ai-coder/ai-gateway/cost-controls.md index 52c4fa13c44..e6bec820274 100644 --- a/docs/ai-coder/ai-gateway/cost-controls.md +++ b/docs/ai-coder/ai-gateway/cost-controls.md @@ -270,35 +270,31 @@ see [Prometheus metrics](../../admin/integrations/prometheus.md). ## Migrate from Coder Agents Cost Control -In v2.36, AI Governance Cost Control replaces Coder Agents Cost Control. The -legacy Coder Agents Spend page remains available until v2.37. +In release 2.36, the native Coder Agents usage-limit configuration UI is no +longer available. Existing native values remain stored and enforced, and are not +migrated to AI Gateway budgets. -> [!WARNING] -> Spend limits configured under **Admin settings** > **AI** > **Spend** are no -> longer enforced by Coder Agents. To enforce spend, set an AI Governance budget. - -To migrate existing limits: - -1. Record the limits currently set under **Admin settings** > **AI** > - **Spend**, including the default limit and any group or user overrides. -1. Recreate group limits as [group budgets](#group-budget). -1. Recreate per-user limits as [user overrides](#user-override). +Configure new AI Gateway budgets from the group settings page or use the API to +[get](../../reference/api/enterprise.md#get-group-ai-budget), +[upsert](../../reference/api/enterprise.md#upsert-group-ai-budget), or +[delete](../../reference/api/enterprise.md#delete-group-ai-budget) a group +budget. Expect the following differences: -- No deployment-wide default exists. Each group that needs a limit requires its - own budget. -- The UTC calendar month is the only period. Daily and weekly periods are not - currently supported. -- Users in several budgeted groups receive the highest budget. Coder Agents Cost - Control applied the lowest. +- Native limits and AI Gateway budgets are independent. A user can be subject to + both until native limits are removed in a later release. +- AI Gateway has no deployment-wide default budget. Each group that needs a + limit requires its own budget. +- The UTC calendar month is the only budget period. Daily and weekly periods are + not currently supported. +- Users in several budgeted groups receive the highest budget by default. Native + Coder Agents usage limits apply the lowest group limit. - Budgets cover priced AI Gateway traffic. Chat, IDE extensions, and CLI agents draw on the same budget when their provider and model are priced. See [How spend is estimated](#how-spend-is-estimated). -- Recorded spend does not carry over. Every user starts the first period at - $0 USD. -- Coder Agents users who exceed their budget see a usage limit error in chat. - The error details include the AI Governance budget limit. +- Recorded spend and native limit values do not carry over to AI Gateway + budgets. ## Next steps diff --git a/docs/manifest.json b/docs/manifest.json index b7f38de4a83..99d5cdab331 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1079,7 +1079,7 @@ }, { "title": "Spend Management", - "description": "Set spend limits and track AI costs for Coder Agents with admin-only controls.", + "description": "Track Coder Agents usage and configure new spend controls with AI Gateway budgets.", "path": "./ai-coder/agents/platform-controls/usage-insights.md", "state": ["beta"] }, diff --git a/site/src/api/api.ts b/site/src/api/api.ts index b87d08a9f2b..46248aceea1 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3948,71 +3948,6 @@ class ExperimentalApiMethods { const response = await this.axios.get(url); return response.data; }; - - getChatUsageLimitConfig = - async (): Promise => { - const response = - await this.axios.get( - "/api/experimental/chats/usage-limits", - ); - return response.data; - }; - - getChatUsageLimitStatus = - async (): Promise => { - const response = await this.axios.get( - "/api/experimental/chats/usage-limits/status", - ); - return response.data; - }; - - updateChatUsageLimitConfig = async ( - req: TypesGen.ChatUsageLimitConfig, - ): Promise => { - const response = await this.axios.put( - "/api/experimental/chats/usage-limits", - req, - ); - return response.data; - }; - - upsertChatUsageLimitOverride = async ( - userID: string, - req: TypesGen.UpsertChatUsageLimitOverrideRequest, - ): Promise => { - const response = await this.axios.put( - `/api/experimental/chats/usage-limits/overrides/${encodeURIComponent(userID)}`, - req, - ); - return response.data; - }; - - deleteChatUsageLimitOverride = async (userID: string): Promise => { - const response = await this.axios.delete( - `/api/experimental/chats/usage-limits/overrides/${encodeURIComponent(userID)}`, - ); - return response.data; - }; - - upsertChatUsageLimitGroupOverride = async ( - groupID: string, - req: TypesGen.UpsertChatUsageLimitGroupOverrideRequest, - ): Promise => { - const response = await this.axios.put( - `/api/experimental/chats/usage-limits/group-overrides/${encodeURIComponent(groupID)}`, - req, - ); - return response.data; - }; - - deleteChatUsageLimitGroupOverride = async ( - groupID: string, - ): Promise => { - const response = await this.axios.delete( - `/api/experimental/chats/usage-limits/group-overrides/${encodeURIComponent(groupID)}`, - ); - return response.data; - }; } // This is a hard coded CSRF token/cookie pair for local development. In prod, diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index a23e7efd7d3..751696b414a 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -1994,91 +1994,6 @@ export function paginatedChatCostUsers( }; } -export const chatUsageLimitStatusKey = [ - ...chatsKey, - "usageLimitStatus", -] as const; - -export const chatUsageLimitStatus = () => ({ - queryKey: chatUsageLimitStatusKey, - queryFn: () => API.experimental.getChatUsageLimitStatus(), - refetchInterval: 60_000, -}); - -const chatUsageLimitConfigKey = [...chatsKey, "usageLimitConfig"] as const; - -export const chatUsageLimitConfig = () => ({ - queryKey: chatUsageLimitConfigKey, - queryFn: () => API.experimental.getChatUsageLimitConfig(), -}); - -export const updateChatUsageLimitConfig = (queryClient: QueryClient) => ({ - mutationFn: (req: TypesGen.ChatUsageLimitConfig) => - API.experimental.updateChatUsageLimitConfig(req), - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: chatUsageLimitConfigKey, - }); - }, -}); - -type UpsertChatUsageLimitOverrideMutationArgs = { - userID: string; - req: TypesGen.UpsertChatUsageLimitOverrideRequest; -}; - -export const upsertChatUsageLimitOverride = (queryClient: QueryClient) => ({ - mutationFn: ({ userID, req }: UpsertChatUsageLimitOverrideMutationArgs) => - API.experimental.upsertChatUsageLimitOverride(userID, req), - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: chatUsageLimitConfigKey, - }); - }, -}); - -export const deleteChatUsageLimitOverride = (queryClient: QueryClient) => ({ - mutationFn: (userID: string) => - API.experimental.deleteChatUsageLimitOverride(userID), - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: chatUsageLimitConfigKey, - }); - }, -}); - -type UpsertChatUsageLimitGroupOverrideMutationArgs = { - groupID: string; - req: TypesGen.UpsertChatUsageLimitGroupOverrideRequest; -}; - -export const upsertChatUsageLimitGroupOverride = ( - queryClient: QueryClient, -) => ({ - mutationFn: ({ - groupID, - req, - }: UpsertChatUsageLimitGroupOverrideMutationArgs) => - API.experimental.upsertChatUsageLimitGroupOverride(groupID, req), - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: chatUsageLimitConfigKey, - }); - }, -}); - -export const deleteChatUsageLimitGroupOverride = ( - queryClient: QueryClient, -) => ({ - mutationFn: (groupID: string) => - API.experimental.deleteChatUsageLimitGroupOverride(groupID), - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: chatUsageLimitConfigKey, - }); - }, -}); - // ── MCP Server Configs ─────────────────────────────────────── export const mcpServerConfigsKey = ["mcp-server-configs"] as const; diff --git a/site/src/pages/AISettingsPage/SpendPage/SpendPage.tsx b/site/src/pages/AISettingsPage/SpendPage/SpendPage.tsx index 19306a7cc98..1acc4dade0f 100644 --- a/site/src/pages/AISettingsPage/SpendPage/SpendPage.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/SpendPage.tsx @@ -1,18 +1,8 @@ import dayjs from "dayjs"; import { type FC, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "react-query"; +import { useQuery } from "react-query"; import { useSearchParams } from "react-router"; -import { - chatCostSummary, - chatUsageLimitConfig, - deleteChatUsageLimitGroupOverride, - deleteChatUsageLimitOverride, - paginatedChatCostUsers, - updateChatUsageLimitConfig, - upsertChatUsageLimitGroupOverride, - upsertChatUsageLimitOverride, -} from "#/api/queries/chats"; -import { groups } from "#/api/queries/groups"; +import { chatCostSummary, paginatedChatCostUsers } from "#/api/queries/chats"; import { user } from "#/api/queries/users"; import type { ChatCostUserRollup } from "#/api/typesGenerated"; import type { DateRangeValue } from "#/components/DateRangePicker/DateRangePicker"; @@ -25,7 +15,6 @@ import { toExclusiveEndOfDayDateRange } from "./utils/dateRange"; const startDateSearchParam = "startDate"; const endDateSearchParam = "endDate"; -const tabSearchParam = "tab"; const DEFAULT_DATE_RANGE_DAYS = 30; const SEARCH_DEBOUNCE_MS = 300; const USAGE_USERS_PAGE_SIZE = 10; @@ -44,33 +33,10 @@ interface SpendPageProps { const SpendPage: FC = ({ now }) => { const { permissions } = useAuthenticated(); - const queryClient = useQueryClient(); - - const configQuery = useQuery(chatUsageLimitConfig()); - const groupsQuery = useQuery(groups()); - - const updateConfigMutation = useMutation( - updateChatUsageLimitConfig(queryClient), - ); - const upsertOverrideMutation = useMutation( - upsertChatUsageLimitOverride(queryClient), - ); - const deleteOverrideMutation = useMutation( - deleteChatUsageLimitOverride(queryClient), - ); - const upsertGroupOverrideMutation = useMutation( - upsertChatUsageLimitGroupOverride(queryClient), - ); - const deleteGroupOverrideMutation = useMutation( - deleteChatUsageLimitGroupOverride(queryClient), - ); - const [searchParams, setSearchParams] = useSearchParams(); const searchFilter = searchParams.get("search") ?? ""; const debouncedSearch = useDebouncedValue(searchFilter, SEARCH_DEBOUNCE_MS); - const tabParam = searchParams.get(tabSearchParam); - const activeTab = tabParam === "usage" ? "usage" : "limits"; const setSearchFilter = (value: string) => { setSearchParams( @@ -117,21 +83,6 @@ const SpendPage: FC = ({ now }) => { end_date: dateRange.endDate.toISOString(), }; - const onActiveTabChange = (tab: "limits" | "usage") => { - setSearchParams( - (prev) => { - const next = new URLSearchParams(prev); - if (tab === "usage") { - next.set(tabSearchParam, tab); - } else { - next.delete(tabSearchParam); - } - return next; - }, - { replace: true }, - ); - }; - const onDateRangeChange = (value: DateRangeValue) => { const nextDateRange = toExclusiveEndOfDayDateRange(value); @@ -170,51 +121,6 @@ const SpendPage: FC = ({ now }) => { return ( void configQuery.refetch()} - groupsData={groupsQuery.data} - isLoadingGroups={groupsQuery.isLoading} - groupsError={groupsQuery.isError ? groupsQuery.error : null} - onUpdateConfig={(req, options) => { - updateConfigMutation.mutate(req, { - onSuccess: options?.onSuccess, - }); - }} - isUpdatingConfig={updateConfigMutation.isPending} - updateConfigError={ - updateConfigMutation.isError ? updateConfigMutation.error : null - } - resetUpdateConfig={updateConfigMutation.reset} - onUpsertOverride={({ userID, req, onSuccess }) => - upsertOverrideMutation.mutate({ userID, req }, { onSuccess }) - } - isUpsertingOverride={upsertOverrideMutation.isPending} - upsertOverrideError={ - upsertOverrideMutation.isError ? upsertOverrideMutation.error : null - } - onDeleteOverride={deleteOverrideMutation.mutate} - isDeletingOverride={deleteOverrideMutation.isPending} - deleteOverrideError={ - deleteOverrideMutation.isError ? deleteOverrideMutation.error : null - } - onUpsertGroupOverride={({ groupID, req, onSuccess }) => - upsertGroupOverrideMutation.mutate({ groupID, req }, { onSuccess }) - } - isUpsertingGroupOverride={upsertGroupOverrideMutation.isPending} - upsertGroupOverrideError={ - upsertGroupOverrideMutation.isError - ? upsertGroupOverrideMutation.error - : null - } - onDeleteGroupOverride={deleteGroupOverrideMutation.mutate} - isDeletingGroupOverride={deleteGroupOverrideMutation.isPending} - deleteGroupOverrideError={ - deleteGroupOverrideMutation.isError - ? deleteGroupOverrideMutation.error - : null - } dateRange={dateRange} endDateIsExclusive={endDateIsExclusive} onDateRangeChange={onDateRangeChange} @@ -245,8 +151,6 @@ const SpendPage: FC = ({ now }) => { isSummaryLoading={summaryQuery.isLoading} summaryError={summaryQuery.error} onSummaryRetry={() => void summaryQuery.refetch()} - activeTab={activeTab} - onActiveTabChange={onActiveTabChange} /> ); diff --git a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx index 91e63fb29bd..83345076a61 100644 --- a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx @@ -1,5 +1,4 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { useState } from "react"; import { expect, fn, userEvent, within } from "storybook/test"; import type * as TypesGen from "#/api/typesGenerated"; import type { PaginationResult } from "#/components/PaginationWidget/PaginationContainer"; @@ -100,68 +99,6 @@ const mockCostSummary = { ], } as TypesGen.ChatCostSummary; -const mockConfigData = { - spend_limit_micros: 50_000_000, - period: "month", - updated_at: "2026-03-01T00:00:00Z", - unpriced_model_count: 0, - overrides: [ - { - user_id: "user-3", - username: "dave", - name: "Dave Grohl", - avatar_url: "", - spend_limit_micros: 100_000_000, - }, - { - user_id: "user-4", - username: "charlie", - name: "Charlie Chaplin", - avatar_url: "", - spend_limit_micros: 25_000_000, - }, - ], - group_overrides: [ - { - group_id: "group-1", - group_name: "engineering", - group_display_name: "Engineering", - group_avatar_url: "", - member_count: 12, - spend_limit_micros: 75_000_000, - }, - ], -} as TypesGen.ChatUsageLimitConfigResponse; - -const mockGroups = [ - { - id: "group-1", - name: "engineering", - display_name: "Engineering", - organization_id: "org-1", - members: [], - total_member_count: 12, - avatar_url: "", - quota_allowance: 0, - source: "user", - organization_name: "default", - organization_display_name: "Default", - }, - { - id: "group-2", - name: "design", - display_name: "Design", - organization_id: "org-1", - members: [], - total_member_count: 5, - avatar_url: "", - quota_allowance: 0, - source: "user", - organization_name: "default", - organization_display_name: "Default", - }, -] as TypesGen.Group[]; - const defaultDateRange = { startDate: new Date("2026-02-10T00:00:00Z"), endDate: new Date("2026-03-12T00:00:00Z"), @@ -219,22 +156,6 @@ function mockUsersQuery( } const baseProps = { - configData: undefined as TypesGen.ChatUsageLimitConfigResponse | undefined, - isLoadingConfig: false, - configError: null as Error | null, - groupsData: undefined as TypesGen.Group[] | undefined, - isLoadingGroups: false, - groupsError: null as Error | null, - isUpdatingConfig: false, - updateConfigError: null as Error | null, - isUpsertingOverride: false, - upsertOverrideError: null as Error | null, - isDeletingOverride: false, - deleteOverrideError: null as Error | null, - isUpsertingGroupOverride: false, - upsertGroupOverrideError: null as Error | null, - isDeletingGroupOverride: false, - deleteGroupOverrideError: null as Error | null, dateRange: defaultDateRange, endDateIsExclusive: false, searchFilter: "", @@ -254,84 +175,22 @@ const meta = { component: SpendPageView, // TODO: Stories in this file fail when pixel runs their play functions. Fix them and remove the exclude. parameters: { pixel: { exclude: true } }, - render: (args) => { - const [activeTab, setActiveTab] = useState(args.activeTab); - return ( - { - setActiveTab(tab); - args.onActiveTabChange(tab); - }} - /> - ); - }, args: { ...baseProps, - refetchConfig: fn(), - onUpdateConfig: fn(), - resetUpdateConfig: fn(), - onUpsertOverride: fn(), - onDeleteOverride: fn(), - onUpsertGroupOverride: fn(), - onDeleteGroupOverride: fn(), onDateRangeChange: fn(), onSearchFilterChange: fn(), onDrillInUserRetry: fn(), onClearSelectedUser: fn(), onSelectUser: fn(), onSummaryRetry: fn(), - activeTab: "limits", - onActiveTabChange: fn(), }, } satisfies Meta; export default meta; type Story = StoryObj; -export const SpendWithLimitsAndUsers: Story = { - args: { - configData: mockConfigData, - groupsData: mockGroups, - usersQuery: mockUsersQuery({ data: mockUsersResponse }), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await canvas.findByText("Spend limits and usage"); - await expect( - canvas.getByText( - /Cost controls features will move to AI Governance in v2\.36\./, - ), - ).toBeInTheDocument(); - await expect( - canvas.getByRole("link", { name: /Read more here/ }), - ).toHaveAttribute( - "href", - expect.stringContaining("/ai-coder/ai-gateway/cost-controls"), - ); - await expect( - canvas.getByRole("switch", { name: "Spend limit" }), - ).toBeInTheDocument(); - await expect(canvas.getByText("Group limits")).toBeInTheDocument(); - await expect(canvas.getByText("Usage")).toBeInTheDocument(); - - await userEvent.click(canvas.getByRole("tab", { name: "Usage" })); - - await expect(await canvas.findByText("Alice Liddell")).toBeInTheDocument(); - await expect(canvas.getByText("Bob Builder")).toBeInTheDocument(); - - await expect( - canvas.getByPlaceholderText("Search by name or username"), - ).toBeInTheDocument(); - }, -}; - export const SpendUsersEmpty: Story = { args: { - configData: mockConfigData, - groupsData: mockGroups, usersQuery: mockUsersQuery({ data: { start_date: "2026-02-10T00:00:00Z", @@ -344,8 +203,7 @@ export const SpendUsersEmpty: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await canvas.findByText("Spend limits and usage"); - await userEvent.click(canvas.getByRole("tab", { name: "Usage" })); + await canvas.findByText("AI spend usage"); await expect( await canvas.findByText("No usage data for this period."), ).toBeInTheDocument(); @@ -354,8 +212,6 @@ export const SpendUsersEmpty: Story = { export const SpendUserDrillIn: Story = { args: { - configData: mockConfigData, - groupsData: mockGroups, drillInUserId: "user-1", drillInUser: mockUserProfile, summaryData: mockCostSummary, @@ -373,8 +229,6 @@ export const SpendUserDrillIn: Story = { export const SpendUserDrillInAndBack: Story = { args: { - configData: mockConfigData, - groupsData: mockGroups, drillInUserId: "user-1", drillInUser: mockUserProfile, summaryData: mockCostSummary, @@ -392,8 +246,6 @@ export const SpendUserDrillInAndBack: Story = { export const SpendDrillInLoading: Story = { args: { - configData: mockConfigData, - groupsData: mockGroups, drillInUserId: "user-1", drillInUser: null, isDrillInUserLoading: true, @@ -408,8 +260,6 @@ export const SpendDrillInLoading: Story = { export const SpendDrillInError: Story = { args: { - configData: mockConfigData, - groupsData: mockGroups, drillInUserId: "user-1", drillInUser: null, isDrillInUserError: true, @@ -424,8 +274,6 @@ export const SpendDrillInError: Story = { export const SpendRefetchOverlay: Story = { args: { - configData: mockConfigData, - groupsData: mockGroups, usersQuery: mockUsersQuery({ data: mockUsersResponse, isFetching: true, @@ -434,8 +282,6 @@ export const SpendRefetchOverlay: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole("tab", { name: "Usage" })); - await canvas.findByText("Alice Liddell"); await expect( @@ -444,43 +290,13 @@ export const SpendRefetchOverlay: Story = { }, }; -export const SpendConfigLoading: Story = { - args: { - isLoadingConfig: true, - }, -}; - -export const SpendConfigError: Story = { - args: { - configError: new Error("Network error: failed to fetch config"), - usersQuery: mockUsersQuery({ data: mockUsersResponse }), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await canvas.findByText("Network error: failed to fetch config"); - await expect(canvas.getByText("Retry")).toBeInTheDocument(); - - await userEvent.click(canvas.getByRole("tab", { name: "Usage" })); - - await expect(canvas.getByText("Alice Liddell")).toBeInTheDocument(); - await expect(canvas.getByText("Bob Builder")).toBeInTheDocument(); - }, -}; - export const SpendUsersLoading: Story = { args: { - configData: mockConfigData, - groupsData: mockGroups, usersQuery: mockUsersQuery({ isLoading: true }), }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await canvas.findByRole("switch", { name: "Spend limit" }); - - await userEvent.click(canvas.getByRole("tab", { name: "Usage" })); - await expect( await canvas.findByRole("status", { name: "Loading usage" }), ).toBeInTheDocument(); @@ -489,8 +305,6 @@ export const SpendUsersLoading: Story = { export const SpendUsersError: Story = { args: { - configData: mockConfigData, - groupsData: mockGroups, usersQuery: mockUsersQuery({ error: new Error("Failed to load usage data"), }), @@ -498,10 +312,6 @@ export const SpendUsersError: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await canvas.findByRole("switch", { name: "Spend limit" }); - - await userEvent.click(canvas.getByRole("tab", { name: "Usage" })); - await expect( canvas.getByText("Failed to load usage data"), ).toBeInTheDocument(); @@ -511,15 +321,11 @@ export const SpendUsersError: Story = { export const SpendUserClickToDrillIn: Story = { args: { - configData: mockConfigData, - groupsData: mockGroups, usersQuery: mockUsersQuery({ data: mockUsersResponse }), }, play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole("tab", { name: "Usage" })); - const row = await canvas.findByRole("button", { name: /^View details for Alice Liddell/, }); diff --git a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx index 3cf1aec74d3..773af71f2c5 100644 --- a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx @@ -1,80 +1,17 @@ import type { FC } from "react"; -import { getErrorMessage } from "#/api/errors"; import type * as TypesGen from "#/api/typesGenerated"; -import { Alert, AlertDescription } from "#/components/Alert/Alert"; -import { ErrorAlert } from "#/components/Alert/ErrorAlert"; -import { Button } from "#/components/Button/Button"; import type { DateRangeValue } from "#/components/DateRangePicker/DateRangePicker"; -import { Link } from "#/components/Link/Link"; import type { PaginationResult } from "#/components/PaginationWidget/PaginationContainer"; import { SettingsHeader, SettingsHeaderDescription, SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; -import { Spinner } from "#/components/Spinner/Spinner"; -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "#/components/Tabs/Tabs"; -import { - TemporarySavedState, - useTemporarySavedState, -} from "#/components/TemporarySavedState/TemporarySavedState"; -import { dollarsToMicros, microsToDollars } from "#/utils/currency"; -import { docs } from "#/utils/docs"; -import { - DefaultLimitController, - type DefaultLimitFormValues, -} from "./components/LimitsTab/DefaultLimitController"; -import { DefaultLimitSection } from "./components/LimitsTab/DefaultLimitSection"; -import { GroupLimitsSection } from "./components/LimitsTab/GroupLimitsSection"; -import { GroupOverrideController } from "./components/LimitsTab/GroupOverrideController"; -import { normalizeChatUsageLimitPeriod } from "./components/LimitsTab/limitsFormLogic"; -import { UserOverrideController } from "./components/LimitsTab/UserOverrideController"; -import { UserOverridesSection } from "./components/LimitsTab/UserOverridesSection"; import { SpendDrillInView } from "./components/SpendDrillInView"; -import { SpendSectionHeader } from "./components/SpendSectionHeader"; import { UsageTab } from "./components/UsageTab/UsageTab"; import { formatUsageDateRange, toInclusiveDateRange } from "./utils/dateRange"; interface SpendPageViewProps { - configData: TypesGen.ChatUsageLimitConfigResponse | undefined; - isLoadingConfig: boolean; - configError: Error | null; - refetchConfig: () => void; - groupsData: TypesGen.Group[] | undefined; - isLoadingGroups: boolean; - groupsError: Error | null; - onUpdateConfig: ( - req: TypesGen.ChatUsageLimitConfig, - options?: { onSuccess?: () => void }, - ) => void; - isUpdatingConfig: boolean; - updateConfigError: Error | null; - resetUpdateConfig: () => void; - onUpsertOverride: (args: { - userID: string; - req: TypesGen.UpsertChatUsageLimitOverrideRequest; - onSuccess: () => void; - }) => void; - isUpsertingOverride: boolean; - upsertOverrideError: Error | null; - onDeleteOverride: (userID: string) => void; - isDeletingOverride: boolean; - deleteOverrideError: Error | null; - onUpsertGroupOverride: (args: { - groupID: string; - req: TypesGen.UpsertChatUsageLimitGroupOverrideRequest; - onSuccess: () => void; - }) => void; - isUpsertingGroupOverride: boolean; - upsertGroupOverrideError: Error | null; - onDeleteGroupOverride: (groupID: string) => void; - isDeletingGroupOverride: boolean; - deleteGroupOverrideError: Error | null; dateRange: DateRangeValue; endDateIsExclusive: boolean; onDateRangeChange: (value: DateRangeValue) => void; @@ -99,34 +36,9 @@ interface SpendPageViewProps { isSummaryLoading: boolean; summaryError: unknown; onSummaryRetry: () => void; - activeTab: "limits" | "usage"; - onActiveTabChange: (tab: "limits" | "usage") => void; } export const SpendPageView: FC = ({ - configData, - isLoadingConfig, - configError, - refetchConfig, - groupsData, - isLoadingGroups, - groupsError, - onUpdateConfig, - isUpdatingConfig, - updateConfigError, - resetUpdateConfig, - onUpsertOverride, - isUpsertingOverride, - upsertOverrideError, - onDeleteOverride, - isDeletingOverride, - deleteOverrideError, - onUpsertGroupOverride, - isUpsertingGroupOverride, - upsertGroupOverrideError, - onDeleteGroupOverride, - isDeletingGroupOverride, - deleteGroupOverrideError, dateRange, endDateIsExclusive, onDateRangeChange, @@ -145,63 +57,12 @@ export const SpendPageView: FC = ({ isSummaryLoading, summaryError, onSummaryRetry, - activeTab, - onActiveTabChange, }) => { - const defaultLimitValues: DefaultLimitFormValues = (() => { - const spendLimitMicros = configData?.spend_limit_micros; - const enabled = spendLimitMicros !== null && spendLimitMicros !== undefined; - - return { - enabled, - period: normalizeChatUsageLimitPeriod(configData?.period), - amountDollars: enabled - ? microsToDollars(spendLimitMicros).toString() - : "", - }; - })(); - const defaultLimitKey = JSON.stringify({ - spend_limit_micros: configData?.spend_limit_micros ?? null, - period: defaultLimitValues.period, - }); - const displayDateRange = toInclusiveDateRange(dateRange, endDateIsExclusive); const dateRangeLabel = formatUsageDateRange(dateRange, { endDateIsExclusive, }); - const handleResetUpdateConfig = () => { - if (!isUpdatingConfig) { - resetUpdateConfig(); - } - }; - - const handleSaveDefault = ({ - enabled, - period, - amountDollars, - }: DefaultLimitFormValues) => { - const spendLimitMicros = enabled ? dollarsToMicros(amountDollars) : null; - onUpdateConfig( - { - spend_limit_micros: spendLimitMicros, - period, - updated_at: new Date().toISOString(), - }, - { onSuccess: showSavedState }, - ); - }; - - const groupOverrides = configData?.group_overrides ?? []; - const overrides = configData?.overrides ?? []; - const unpricedModelCount = configData?.unpriced_model_count ?? 0; - const groupOrganizationNames = - groupsData?.reduce>((acc, group) => { - acc[group.id] = group.organization_name; - return acc; - }, {}) ?? {}; - const { isSavedVisible, showSavedState } = useTemporarySavedState(); - if (drillInUserId) { return ( = ({ /> ); } - return ( - - {(groupCtrl) => ( - - {(userCtrl) => ( -
- - - As of v2.36, AI Governance Cost Control replaces Coder Agents - Cost Control. The limits on this page are no longer enforced - and do not carry over. Recreate each limit as an AI Governance - budget to restore enforcement.{" "} - - Read more here - - - - - - - Spend limits and usage - - - Configure spend limits and monitor AI usage across your - deployment. - - - - { - if (tab === "limits" || tab === "usage") { - onActiveTabChange(tab); - } - }} - > - - Spend limits - Usage - - - -
- {isLoadingConfig ? ( -
- -
- ) : configError ? ( -
- - -
- ) : ( - <> - - {({ - enabled, - onEnabledChange, - period, - onPeriodChange, - amountDollars, - onAmountDollarsChange, - isAmountValid, - isDirty, - saveDefault, - }) => ( - { - handleResetUpdateConfig(); - onEnabledChange(v); - }} - period={period} - onPeriodChange={(v) => { - handleResetUpdateConfig(); - onPeriodChange(v); - }} - amountDollars={amountDollars} - onAmountDollarsChange={(v) => { - handleResetUpdateConfig(); - onAmountDollarsChange(v); - }} - unpricedModelCount={unpricedModelCount} - isSaving={isUpdatingConfig} - isSavedVisible={isSavedVisible} - saveDisabled={ - isUpdatingConfig || !isAmountValid || !isDirty - } - onSave={isDirty ? saveDefault : undefined} - saveStatus={ - isSavedVisible ? ( - - ) : updateConfigError ? ( - getErrorMessage( - updateConfigError, - "Failed to save the default spend limit.", - ) - ) : null - } - /> - )} - -
- - { - userCtrl.handleShowUserFormChange(false); - groupCtrl.handleEditGroupOverride(override); - }} - onAddGroupOverride={ - groupCtrl.handleAddGroupOverride - } - onDeleteGroupOverride={onDeleteGroupOverride} - upsertPending={isUpsertingGroupOverride} - upsertError={upsertGroupOverrideError} - deletePending={isDeletingGroupOverride} - deleteError={deleteGroupOverrideError} - groupsError={groupsError} - /> -
- -
- - { - groupCtrl.handleShowGroupFormChange(false); - userCtrl.handleEditUserOverride(override); - }} - onAddOverride={userCtrl.handleAddOverride} - onDeleteOverride={onDeleteOverride} - upsertPending={isUpsertingOverride} - upsertError={upsertOverrideError} - deletePending={isDeletingOverride} - deleteError={deleteOverrideError} - /> -
- - )} -
-
+ return ( +
+ + AI spend usage + + Monitor AI usage across your deployment. + + - - { - groupCtrl.handleShowGroupFormChange(false); - userCtrl.handleEditUserOverride(override); - }} - /> - - -
- )} - - )} - + +
); }; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.tsx b/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.tsx index 4f1e954f550..622cf1eec25 100644 --- a/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.tsx @@ -1,5 +1,4 @@ -import dayjs from "dayjs"; -import { InfoIcon, TriangleAlertIcon } from "lucide-react"; +import { TriangleAlertIcon } from "lucide-react"; import { type FC, useState } from "react"; import { getErrorMessage } from "#/api/errors"; import type * as TypesGen from "#/api/typesGenerated"; @@ -32,25 +31,6 @@ interface ChatCostSummaryViewProps { emptyMessage: string; } -export const getUsageLimitPeriodLabel = ( - period: TypesGen.ChatUsageLimitPeriod | undefined, -): string => { - if (!period) { - return ""; - } - - switch (period) { - case "day": - return "Daily"; - case "week": - return "Weekly"; - case "month": - return "Monthly"; - default: - return ""; - } -}; - export const ChatCostSummaryView: FC = ({ summary, isLoading, @@ -110,41 +90,6 @@ export const ChatCostSummaryView: FC = ({ hasNextPage: hasChatNext, } = paginateItems(summary.by_chat, chatPageSize, chatPage); - const usageLimit = summary.usage_limit; - const showUsageLimitCard = usageLimit?.is_limited === true; - const usageLimitCurrentSpend = usageLimit?.current_spend ?? 0; - const usageLimitSpendMicros = usageLimit?.spend_limit_micros ?? 0; - const usageLimitPeriodLabel = usageLimit - ? getUsageLimitPeriodLabel(usageLimit.period) - : ""; - const usageProgressPercentage = - showUsageLimitCard && usageLimitSpendMicros > 0 - ? Math.min((usageLimitCurrentSpend / usageLimitSpendMicros) * 100, 100) - : 0; - const usageProgressBarClass = - usageProgressPercentage > 90 - ? "bg-content-destructive" - : usageProgressPercentage >= 75 - ? "bg-content-warning" - : "bg-content-success"; - const usageLimitExceeded = - showUsageLimitCard && usageLimitCurrentSpend >= usageLimitSpendMicros; - const usageLimitStatusText = usageLimitExceeded - ? "Limit exceeded" - : `${formatCostMicros( - Math.max(usageLimitSpendMicros - usageLimitCurrentSpend, 0), - )} remaining`; - const usageLimitCurrentPeriod = - showUsageLimitCard && usageLimit?.period_start && usageLimit?.period_end - ? `Current period: ${dayjs(usageLimit.period_start).format("MMM D")} to ${dayjs( - usageLimit.period_end, - ).format("MMM D")}` - : ""; - const usageLimitResetAt = - showUsageLimitCard && usageLimit?.period_end - ? dayjs(usageLimit.period_end).format("MMM D, YYYY h:mm A") - : ""; - return (
@@ -201,59 +146,6 @@ export const ChatCostSummaryView: FC = ({
- {showUsageLimitCard && usageLimit && ( -
-
-
-
-

- {usageLimitPeriodLabel} spend limit -

- {usageLimitCurrentPeriod && ( -

- {usageLimitCurrentPeriod} -

- )} -

- {formatCostMicros(usageLimitCurrentSpend)} /{" "} - {formatCostMicros(usageLimitSpendMicros)} -

-
-

- {Math.round(usageProgressPercentage)}% used -

-
-
-
-
-
-

- {usageLimitStatusText} -

-

- Resets {usageLimitResetAt} -

-
-
-
- )} - {summary.unpriced_messages_having_usage_count > 0 && (
@@ -266,14 +158,6 @@ export const ChatCostSummaryView: FC = ({
)} -
- - - Automatic title generation uses lightweight models and is not counted - towards usage limits. - -
- {summary.by_model.length === 0 && summary.by_chat.length === 0 ? (

{emptyMessage} diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/DefaultLimitController.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/DefaultLimitController.tsx deleted file mode 100644 index 855a25e0dac..00000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/DefaultLimitController.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { type FC, type ReactNode, useState } from "react"; - -import type { ChatUsageLimitPeriod } from "#/api/typesGenerated"; -import { isPositiveFiniteDollarAmount } from "#/utils/currency"; - -export interface DefaultLimitFormValues { - enabled: boolean; - period: ChatUsageLimitPeriod; - amountDollars: string; -} - -interface DefaultLimitControllerProps { - initialValues: DefaultLimitFormValues; - onSave: (values: DefaultLimitFormValues) => void; - children: (props: { - enabled: boolean; - onEnabledChange: (enabled: boolean) => void; - period: ChatUsageLimitPeriod; - onPeriodChange: (period: ChatUsageLimitPeriod) => void; - amountDollars: string; - onAmountDollarsChange: (amount: string) => void; - isAmountValid: boolean; - isDirty: boolean; - saveDefault: () => void; - }) => ReactNode; -} - -export const DefaultLimitController: FC = ({ - initialValues, - onSave, - children, -}) => { - const [enabled, setEnabled] = useState(initialValues.enabled); - const [period, setPeriod] = useState( - initialValues.period, - ); - const [amountDollars, setAmountDollars] = useState( - initialValues.amountDollars, - ); - const isAmountValid = !enabled || isPositiveFiniteDollarAmount(amountDollars); - const isDirty = - enabled !== initialValues.enabled || - period !== initialValues.period || - amountDollars !== initialValues.amountDollars; - - const handleSave = () => { - if (enabled && !isPositiveFiniteDollarAmount(amountDollars)) { - return; - } - - onSave({ enabled, period, amountDollars }); - }; - - return children({ - enabled, - onEnabledChange: setEnabled, - period, - onPeriodChange: setPeriod, - amountDollars, - onAmountDollarsChange: setAmountDollars, - isAmountValid, - isDirty, - saveDefault: handleSave, - }); -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/DefaultLimitSection.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/DefaultLimitSection.tsx deleted file mode 100644 index 3e837469e9d..00000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/DefaultLimitSection.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { TriangleAlertIcon } from "lucide-react"; -import type { FC, ReactNode } from "react"; -import type { ChatUsageLimitPeriod } from "#/api/typesGenerated"; -import { Button } from "#/components/Button/Button"; -import { - InputGroup, - InputGroupAddon, - InputGroupInput, -} from "#/components/InputGroup/InputGroup"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "#/components/Select/Select"; -import { Spinner } from "#/components/Spinner/Spinner"; -import { Switch } from "#/components/Switch/Switch"; - -interface DefaultLimitSectionProps { - enabled: boolean; - onEnabledChange: (enabled: boolean) => void; - period: ChatUsageLimitPeriod; - onPeriodChange: (period: ChatUsageLimitPeriod) => void; - amountDollars: string; - onAmountDollarsChange: (amount: string) => void; - unpricedModelCount: number; - isSaving?: boolean; - isSavedVisible?: boolean; - saveDisabled?: boolean; - onSave?: () => void; - saveStatus?: ReactNode; -} - -export const DefaultLimitSection: FC = ({ - enabled, - onEnabledChange, - period, - onPeriodChange, - amountDollars, - onAmountDollarsChange, - unpricedModelCount, - isSaving = false, - isSavedVisible = false, - saveDisabled = false, - onSave, - saveStatus, -}) => { - return ( -

-
- -
-

- Spend limit -

-

- Set a deployment-wide spend cap that applies to all users by - default. When disabled, users have unlimited spending. -

-
- - $ - onAmountDollarsChange(event.target.value)} - placeholder="0.00" - aria-label="Spend limit amount in dollars" - disabled={!enabled} - /> - - -
- {(onSave || isSavedVisible || isSaving) && - (isSavedVisible ? ( - saveStatus - ) : ( - - ))} -
-
- {saveStatus && !isSavedVisible && ( -
{saveStatus}
- )} -
-
- - {enabled && unpricedModelCount > 0 && ( -
- -
- {unpricedModelCount === 1 - ? "1 enabled model does not have pricing configured." - : `${unpricedModelCount} enabled models do not have pricing configured.`}{" "} - Usage of unpriced models cannot be tracked against the spend limit. -
-
- )} -
- ); -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitDialog.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitDialog.tsx deleted file mode 100644 index b3c8e555219..00000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitDialog.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { CheckIcon } from "lucide-react"; -import type { FC } from "react"; -import { getErrorMessage } from "#/api/errors"; -import type { Group } from "#/api/typesGenerated"; -import { Autocomplete } from "#/components/Autocomplete/Autocomplete"; -import { AvatarData } from "#/components/Avatar/AvatarData"; -import { Button } from "#/components/Button/Button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "#/components/Dialog/Dialog"; -import { Input } from "#/components/Input/Input"; -import { Label } from "#/components/Label/Label"; -import { Spinner } from "#/components/Spinner/Spinner"; -import { getGroupSubtitle } from "#/modules/groups"; -import { isPositiveFiniteDollarAmount } from "#/utils/currency"; -import type { GroupLimitOverrideGroup } from "./GroupLimitsSection"; - -interface GroupLimitDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - selectedGroup: Group | null; - onSelectedGroupChange: (group: Group | null) => void; - groupAmount: string; - onGroupAmountChange: (amount: string) => void; - availableGroups: Group[]; - groupAutocompleteNoOptionsText: string; - groupsLoading: boolean; - editingGroupOverride: GroupLimitOverrideGroup | null; - upsertPending: boolean; - upsertError: Error | null; - onSave: () => void; - groupAutocompleteId: string; - groupAmountId: string; -} - -export const GroupLimitDialog: FC = ({ - open, - onOpenChange, - selectedGroup, - onSelectedGroupChange, - groupAmount, - onGroupAmountChange, - availableGroups, - groupAutocompleteNoOptionsText, - groupsLoading, - editingGroupOverride, - upsertPending, - upsertError, - onSave, - groupAutocompleteId, - groupAmountId, -}) => { - const isEditing = editingGroupOverride !== null; - const saveDisabled = isEditing - ? upsertPending || !isPositiveFiniteDollarAmount(groupAmount) - : upsertPending || - selectedGroup === null || - !isPositiveFiniteDollarAmount(groupAmount); - - return ( - { - if (upsertPending) { - return; - } - - onOpenChange(nextOpen); - if (!nextOpen) { - onSelectedGroupChange(null); - onGroupAmountChange(""); - } - }} - > - - - - {isEditing ? "Update group budget" : "Add group budget"} - - - {isEditing - ? "Update this group's spend limit override." - : "Set a spend limit override for a specific group."} - - - -
-
- {editingGroupOverride ? ( - <> - -
- -
- - ) : ( - <> - - group.id} - getOptionLabel={(group) => group.display_name || group.name} - isOptionEqualToValue={(option, optionValue) => - option.id === optionValue.id - } - renderOption={(option, isSelected) => ( -
- - {isSelected && } -
- )} - placeholder="Search groups..." - noOptionsText={groupAutocompleteNoOptionsText} - loading={groupsLoading} - disabled={groupsLoading} - className="w-full" - /> - - )} -
-
- - onGroupAmountChange(event.target.value)} - placeholder="0.00" - /> -
- {upsertError && ( -

- {getErrorMessage(upsertError, "Failed to save group override.")} -

- )} -
- - - - - -
-
- ); -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsSection.stories.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsSection.stories.tsx deleted file mode 100644 index eb1963020ac..00000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsSection.stories.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, fn, userEvent, waitFor, within } from "storybook/test"; -import type { Group } from "#/api/typesGenerated"; -import { GroupLimitsSection } from "./GroupLimitsSection"; - -const mockGroupOverrides = [ - { - group_id: "group-1", - group_display_name: "Engineering", - group_name: "engineering", - group_avatar_url: "", - member_count: 15, - spend_limit_micros: 10_000_000, - }, - { - group_id: "group-2", - group_display_name: "Design", - group_name: "design", - group_avatar_url: "", - member_count: 8, - spend_limit_micros: 5_000_000, - }, - { - group_id: "group-4", - group_display_name: "Support", - group_name: "support", - group_avatar_url: "", - member_count: 11, - spend_limit_micros: null, - }, -]; - -const mockAvailableGroups: Group[] = [ - { - id: "group-3", - name: "marketing", - display_name: "Marketing", - avatar_url: "", - organization_id: "org-1", - organization_name: "Acme", - organization_display_name: "Acme", - members: [], - quota_allowance: 0, - source: "user", - total_member_count: 5, - }, - { - id: "group-5", - name: "sales", - display_name: "Sales", - avatar_url: "", - organization_id: "org-1", - organization_name: "Acme", - organization_display_name: "Acme", - members: [], - quota_allowance: 0, - source: "user", - total_member_count: 12, - }, -]; - -const editingGroupOverride = { - group_id: mockGroupOverrides[0].group_id, - group_display_name: mockGroupOverrides[0].group_display_name, - group_name: mockGroupOverrides[0].group_name, - group_avatar_url: mockGroupOverrides[0].group_avatar_url, - member_count: mockGroupOverrides[0].member_count, -}; - -const meta: Meta = { - title: "pages/AISettingsPage/SpendPage/LimitsTab/GroupLimitsSection", - component: GroupLimitsSection, - args: { - groupOverrides: mockGroupOverrides, - groupOrganizationNames: { - "group-1": "Acme", - "group-2": "Acme", - }, - showGroupForm: false, - onShowGroupFormChange: fn(), - selectedGroup: null, - onSelectedGroupChange: fn(), - groupAmount: "", - onGroupAmountChange: fn(), - availableGroups: [], - groupAutocompleteNoOptionsText: "No groups available", - groupsLoading: false, - editingGroupOverride: null, - onEditGroupOverride: fn(), - onAddGroupOverride: fn(), - onDeleteGroupOverride: fn(), - upsertPending: false, - upsertError: null, - deletePending: false, - deleteError: null, - groupsError: null, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const EmptyState: Story = { - args: { - groupOverrides: [], - }, -}; - -export const AddForm: Story = { - args: { - showGroupForm: true, - availableGroups: mockAvailableGroups, - }, -}; - -export const EditForm: Story = { - args: { - showGroupForm: true, - editingGroupOverride, - groupAmount: "10.00", - }, - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - // Update budget button confirms edit mode is active. - await expect( - body.getByRole("button", { name: /update budget/i }), - ).toBeInTheDocument(); - // The editing group name appears in both the table row and the - // read-only edit form identity, confirming it was populated. - const nameElements = body.getAllByText( - editingGroupOverride.group_display_name, - ); - expect(nameElements.length).toBeGreaterThanOrEqual(2); - }, -}; - -export const DeleteGroupOverride: Story = { - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - - const actionButtons = await body.findAllByRole("button", { - name: /actions for/i, - }); - await userEvent.click(actionButtons[0]); - await userEvent.click( - await body.findByRole("menuitem", { name: /remove group limit/i }), - ); - - // The confirmation dialog should appear. - const dialog = await body.findByRole("dialog"); - await expect(dialog).toBeInTheDocument(); - await expect( - body.getByText(/Are you sure you want to delete this group override/i), - ).toBeInTheDocument(); - }, -}; - -export const DeleteGroupOverrideCancelled: Story = { - play: async ({ canvasElement, args }) => { - const body = within(canvasElement.ownerDocument.body); - - const actionButtons = await body.findAllByRole("button", { - name: /actions for/i, - }); - await userEvent.click(actionButtons[0]); - await userEvent.click( - await body.findByRole("menuitem", { name: /remove group limit/i }), - ); - - // Cancel the dialog. - await body.findByRole("dialog"); - await userEvent.click(body.getByRole("button", { name: "Cancel" })); - - // The dialog should be closed and the callback should not have been called. - await waitFor(() => { - expect(body.queryByRole("dialog")).not.toBeInTheDocument(); - }); - expect(args.onDeleteGroupOverride).not.toHaveBeenCalled(); - }, -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsSection.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsSection.tsx deleted file mode 100644 index b0914b6e6bf..00000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsSection.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { PlusIcon } from "lucide-react"; -import { type FC, useId, useState } from "react"; -import { getErrorMessage } from "#/api/errors"; -import type { ChatUsageLimitGroupOverride, Group } from "#/api/typesGenerated"; -import { Button } from "#/components/Button/Button"; -import { ConfirmDeleteDialog } from "#/components/Dialogs/ConfirmDeleteDialog/ConfirmDeleteDialog"; -import { SearchField } from "#/components/SearchField/SearchField"; -import { paginateItems } from "#/utils/paginateItems"; -import { SpendSectionHeader } from "../SpendSectionHeader"; -import { GroupLimitDialog } from "./GroupLimitDialog"; -import { GROUP_LIMITS_PAGE_SIZE, GroupLimitsTable } from "./GroupLimitsTable"; - -export interface GroupLimitOverrideGroup { - group_id: string; - group_display_name: string; - group_name: string; - group_avatar_url: string; - member_count: number; -} - -export type GroupLimitOverride = ChatUsageLimitGroupOverride; - -interface GroupLimitsSectionProps { - hideHeader?: boolean; - groupOverrides: readonly GroupLimitOverride[]; - groupOrganizationNames?: Record; - showGroupForm: boolean; - onShowGroupFormChange: (show: boolean) => void; - selectedGroup: Group | null; - onSelectedGroupChange: (group: Group | null) => void; - groupAmount: string; - onGroupAmountChange: (amount: string) => void; - availableGroups: Group[]; - groupAutocompleteNoOptionsText: string; - groupsLoading: boolean; - editingGroupOverride: GroupLimitOverrideGroup | null; - onEditGroupOverride: (override: GroupLimitOverride) => void; - onAddGroupOverride: () => void; - onDeleteGroupOverride: (groupID: string) => void; - upsertPending: boolean; - upsertError: Error | null; - deletePending: boolean; - deleteError: Error | null; - groupsError: Error | null; -} - -export const GroupLimitsSection: FC = ({ - hideHeader, - groupOverrides, - groupOrganizationNames, - showGroupForm, - onShowGroupFormChange, - selectedGroup, - onSelectedGroupChange, - groupAmount, - onGroupAmountChange, - availableGroups, - groupAutocompleteNoOptionsText, - groupsLoading, - editingGroupOverride, - onEditGroupOverride, - onAddGroupOverride, - onDeleteGroupOverride, - upsertPending, - upsertError, - deletePending, - deleteError, - groupsError, -}) => { - const groupAutocompleteId = useId(); - const groupAmountId = useId(); - const isEditing = editingGroupOverride !== null; - const [pendingDeleteGroupId, setPendingDeleteGroupId] = useState< - string | null - >(null); - const [page, setPage] = useState(1); - const [searchQuery, setSearchQuery] = useState(""); - const normalizedSearchQuery = searchQuery.trim().toLowerCase(); - const filteredGroupOverrides = normalizedSearchQuery - ? groupOverrides.filter((override) => - [override.group_display_name, override.group_name].some((value) => - value.toLowerCase().includes(normalizedSearchQuery), - ), - ) - : groupOverrides; - const { pagedItems, clampedPage, hasPreviousPage, hasNextPage } = - paginateItems(filteredGroupOverrides, GROUP_LIMITS_PAGE_SIZE, page); - - return ( -
- {!hideHeader && ( - - )} -
-
- { - setSearchQuery(value); - setPage(1); - }} - placeholder="Search groups..." - aria-label="Search group limits" - className="w-full sm:max-w-md" - /> - -
- - - - {deleteError && ( -

- {getErrorMessage(deleteError, "Failed to delete group override.")} -

- )} - - - {groupsError && ( -

- {getErrorMessage(groupsError, "Failed to load groups.")} -

- )} -
- {pendingDeleteGroupId && ( - { - void onDeleteGroupOverride(pendingDeleteGroupId); - setPendingDeleteGroupId(null); - }} - isPending={deletePending} - open - onOpenChange={(open) => !open && setPendingDeleteGroupId(null)} - /> - )} -
- ); -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsTable.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsTable.tsx deleted file mode 100644 index 56ff6084346..00000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsTable.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { EllipsisVerticalIcon, TrashIcon } from "lucide-react"; -import type { FC } from "react"; -import { Link } from "react-router"; -import { AvatarData } from "#/components/Avatar/AvatarData"; -import { Button } from "#/components/Button/Button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "#/components/DropdownMenu/DropdownMenu"; -import { PaginationAmount } from "#/components/PaginationWidget/PaginationAmount"; -import { PaginationWidgetBase } from "#/components/PaginationWidget/PaginationWidgetBase"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "#/components/Table/Table"; -import { formatCostMicros } from "#/utils/currency"; -import type { GroupLimitOverride } from "./GroupLimitsSection"; - -export const GROUP_LIMITS_PAGE_SIZE = 10; - -interface GroupLimitsTableProps { - pagedOverrides: readonly GroupLimitOverride[]; - totalOverrides: number; - clampedPage: number; - hasPreviousPage: boolean; - hasNextPage: boolean; - onPageChange: (page: number) => void; - onEditGroupOverride: (override: GroupLimitOverride) => void; - onRequestDelete: (groupID: string) => void; - groupOrganizationNames?: Record; - deletePending: boolean; - upsertPending: boolean; - isEditing: boolean; - emptyMessage: string; -} - -export const GroupLimitsTable: FC = ({ - pagedOverrides, - totalOverrides, - clampedPage, - hasPreviousPage, - hasNextPage, - onPageChange, - onEditGroupOverride, - onRequestDelete, - groupOrganizationNames, - deletePending, - upsertPending, - isEditing, - emptyMessage, -}) => { - if (totalOverrides === 0) { - return ( -
- {emptyMessage} -
- ); - } - - return ( - <> -
- - - - Group - Members - Spend limit - Actions - - - - {pagedOverrides.map((override) => { - const orgName = groupOrganizationNames?.[override.group_id]; - const groupAvatar = ( - - ); - - return ( - - - {orgName ? ( - - {groupAvatar} - - ) : ( - groupAvatar - )} - - {override.member_count} - - {override.spend_limit_micros !== null - ? formatCostMicros(override.spend_limit_micros) - : "Unlimited"} - - - - - - - - onEditGroupOverride(override)} - > - Update budget - - onRequestDelete(override.group_id)} - > - - Remove group limit - - - - - - ); - })} - -
- -
- {totalOverrides > GROUP_LIMITS_PAGE_SIZE && ( - - )} - - ); -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupOverrideController.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupOverrideController.tsx deleted file mode 100644 index 2ccd1ae44fa..00000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupOverrideController.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { type FC, type ReactNode, useState } from "react"; - -import type { - Group, - UpsertChatUsageLimitGroupOverrideRequest, -} from "#/api/typesGenerated"; -import { - dollarsToMicros, - isPositiveFiniteDollarAmount, - microsToDollars, -} from "#/utils/currency"; - -interface EditingGroupOverride { - group_id: string; - group_display_name: string; - group_name: string; - group_avatar_url: string; - member_count: number; -} - -type GroupOverrideChildProps = { - showGroupForm: boolean; - setShowGroupForm: (show: boolean) => void; - selectedGroup: Group | null; - setSelectedGroup: (group: Group | null) => void; - groupAmount: string; - setGroupAmount: (amount: string) => void; - editingGroupOverride: EditingGroupOverride | null; - setEditingGroupOverride: (override: EditingGroupOverride | null) => void; - handleShowGroupFormChange: (show: boolean) => void; - handleEditGroupOverride: ( - override: EditingGroupOverride & { - spend_limit_micros: number | null; - }, - ) => void; - handleAddGroupOverride: () => void; - existingGroupIds: Set; - availableGroups: Group[]; - groupAutocompleteNoOptionsText: string; -}; - -interface GroupOverrideControllerProps { - groupOverrides: ReadonlyArray<{ group_id: string }>; - groups: ReadonlyArray; - isLoadingGroups: boolean; - onUpsertGroupOverride: (args: { - groupID: string; - req: UpsertChatUsageLimitGroupOverrideRequest; - onSuccess: () => void; - }) => void; - children: (props: GroupOverrideChildProps) => ReactNode; -} - -export const GroupOverrideController: FC = ({ - groupOverrides, - groups, - isLoadingGroups, - onUpsertGroupOverride, - children, -}) => { - const [showGroupForm, setShowGroupForm] = useState(false); - const [selectedGroup, setSelectedGroup] = useState(null); - const [groupAmount, setGroupAmount] = useState(""); - const [editingGroupOverride, setEditingGroupOverride] = - useState(null); - - // Derived values. - const existingGroupIds = new Set(groupOverrides.map((g) => g.group_id)); - const availableGroups = groups.filter((g) => !existingGroupIds.has(g.id)); - const groupAutocompleteNoOptionsText = isLoadingGroups - ? "Loading groups..." - : groups.length === 0 - ? "No groups configured" - : availableGroups.length === 0 - ? "All groups already have overrides" - : "No groups available"; - - // Handlers. - const handleShowGroupFormChange = (show: boolean) => { - setShowGroupForm(show); - if (!show) { - setEditingGroupOverride(null); - } - }; - - const handleEditGroupOverride = ( - override: EditingGroupOverride & { - spend_limit_micros: number | null; - }, - ) => { - setEditingGroupOverride({ - group_id: override.group_id, - group_display_name: override.group_display_name, - group_name: override.group_name, - group_avatar_url: override.group_avatar_url, - member_count: override.member_count, - }); - setSelectedGroup(null); - setGroupAmount( - override.spend_limit_micros !== null - ? microsToDollars(override.spend_limit_micros).toString() - : "", - ); - setShowGroupForm(true); - }; - - const handleAddGroupOverride = () => { - const targetGroupID = editingGroupOverride?.group_id ?? selectedGroup?.id; - - if (!targetGroupID || !isPositiveFiniteDollarAmount(groupAmount)) { - return; - } - onUpsertGroupOverride({ - groupID: targetGroupID, - req: { spend_limit_micros: dollarsToMicros(groupAmount) }, - onSuccess: () => { - setEditingGroupOverride(null); - setSelectedGroup(null); - setGroupAmount(""); - setShowGroupForm(false); - }, - }); - }; - - return children({ - showGroupForm, - setShowGroupForm, - selectedGroup, - setSelectedGroup, - groupAmount, - setGroupAmount, - editingGroupOverride, - setEditingGroupOverride, - handleShowGroupFormChange, - handleEditGroupOverride, - handleAddGroupOverride, - existingGroupIds, - availableGroups, - groupAutocompleteNoOptionsText, - }); -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverrideController.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverrideController.tsx deleted file mode 100644 index 99f406592a1..00000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverrideController.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { type FC, type ReactNode, useState } from "react"; - -import type { - UpsertChatUsageLimitOverrideRequest, - User, -} from "#/api/typesGenerated"; -import { - dollarsToMicros, - isPositiveFiniteDollarAmount, - microsToDollars, -} from "#/utils/currency"; - -interface EditingUserOverride { - user_id: string; - name: string; - username: string; - avatar_url: string; -} - -type UserOverrideChildProps = { - showUserForm: boolean; - setShowUserForm: (show: boolean) => void; - selectedUserOverride: User | null; - setSelectedUserOverride: (user: User | null) => void; - userOverrideAmount: string; - setUserOverrideAmount: (amount: string) => void; - editingUserOverride: EditingUserOverride | null; - setEditingUserOverride: (override: EditingUserOverride | null) => void; - handleShowUserFormChange: (show: boolean) => void; - handleEditUserOverride: ( - override: EditingUserOverride & { - spend_limit_micros: number | null; - }, - ) => void; - handleAddOverride: () => void; - existingUserIds: Set; - selectedUserAlreadyOverridden: boolean; -}; - -interface UserOverrideControllerProps { - overrides: ReadonlyArray<{ user_id: string }>; - onUpsertOverride: (args: { - userID: string; - req: UpsertChatUsageLimitOverrideRequest; - onSuccess: () => void; - }) => void; - children: (props: UserOverrideChildProps) => ReactNode; -} - -export const UserOverrideController: FC = ({ - overrides, - onUpsertOverride, - children, -}) => { - const [showUserForm, setShowUserForm] = useState(false); - const [selectedUserOverride, setSelectedUserOverride] = useState( - null, - ); - const [userOverrideAmount, setUserOverrideAmount] = useState(""); - const [editingUserOverride, setEditingUserOverride] = - useState(null); - - // Derived values. - const existingUserIds = new Set(overrides.map((o) => o.user_id)); - const selectedUserAlreadyOverridden = selectedUserOverride - ? existingUserIds.has(selectedUserOverride.id) - : false; - - // Handlers. - const handleShowUserFormChange = (show: boolean) => { - setShowUserForm(show); - if (!show) { - setEditingUserOverride(null); - } - }; - - const handleEditUserOverride = ( - override: EditingUserOverride & { - spend_limit_micros: number | null; - }, - ) => { - setEditingUserOverride({ - user_id: override.user_id, - name: override.name, - username: override.username, - avatar_url: override.avatar_url, - }); - setSelectedUserOverride(null); - setUserOverrideAmount( - override.spend_limit_micros !== null - ? microsToDollars(override.spend_limit_micros).toString() - : "", - ); - setShowUserForm(true); - }; - - const handleAddOverride = () => { - const targetUserID = - editingUserOverride?.user_id ?? selectedUserOverride?.id; - - if (!targetUserID || !isPositiveFiniteDollarAmount(userOverrideAmount)) { - return; - } - onUpsertOverride({ - userID: targetUserID, - req: { spend_limit_micros: dollarsToMicros(userOverrideAmount) }, - onSuccess: () => { - setEditingUserOverride(null); - setSelectedUserOverride(null); - setUserOverrideAmount(""); - setShowUserForm(false); - }, - }); - }; - - return children({ - showUserForm, - setShowUserForm, - selectedUserOverride, - setSelectedUserOverride, - userOverrideAmount, - setUserOverrideAmount, - editingUserOverride, - setEditingUserOverride, - handleShowUserFormChange, - handleEditUserOverride, - handleAddOverride, - existingUserIds, - selectedUserAlreadyOverridden, - }); -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverrideDialog.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverrideDialog.tsx deleted file mode 100644 index 5226f10687e..00000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverrideDialog.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import type { FC } from "react"; -import { getErrorMessage } from "#/api/errors"; -import type { User } from "#/api/typesGenerated"; -import { AvatarData } from "#/components/Avatar/AvatarData"; -import { Button } from "#/components/Button/Button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "#/components/Dialog/Dialog"; -import { Input } from "#/components/Input/Input"; -import { Label } from "#/components/Label/Label"; -import { Spinner } from "#/components/Spinner/Spinner"; -import { UserAutocomplete } from "#/components/UserAutocomplete/UserAutocomplete"; -import { isPositiveFiniteDollarAmount } from "#/utils/currency"; -import type { UserOverrideUser } from "./UserOverridesSection"; - -interface UserOverrideDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - selectedUser: User | null; - onSelectedUserChange: (user: User | null) => void; - userOverrideAmount: string; - onUserOverrideAmountChange: (amount: string) => void; - selectedUserAlreadyOverridden: boolean; - editingUserOverride: UserOverrideUser | null; - upsertPending: boolean; - upsertError: Error | null; - onSave: () => void; - amountInputId: string; -} - -export const UserOverrideDialog: FC = ({ - open, - onOpenChange, - selectedUser, - onSelectedUserChange, - userOverrideAmount, - onUserOverrideAmountChange, - selectedUserAlreadyOverridden, - editingUserOverride, - upsertPending, - upsertError, - onSave, - amountInputId, -}) => { - const isEditing = editingUserOverride !== null; - const saveDisabled = isEditing - ? upsertPending || !isPositiveFiniteDollarAmount(userOverrideAmount) - : upsertPending || - !selectedUser || - selectedUserAlreadyOverridden || - !isPositiveFiniteDollarAmount(userOverrideAmount); - - return ( - { - if (upsertPending) { - return; - } - - onOpenChange(nextOpen); - if (!nextOpen) { - onSelectedUserChange(null); - onUserOverrideAmountChange(""); - } - }} - > - - - - {isEditing ? "Update user budget" : "Add user budget"} - - - {isEditing - ? "Update this user's spend limit override." - : "Set a spend limit override for a specific user."} - - - -
-
- {editingUserOverride ? ( - <> - -
- -
- - ) : ( - - )} -
-
- - - onUserOverrideAmountChange(event.target.value) - } - placeholder="0.00" - /> -
- {!isEditing && selectedUserAlreadyOverridden && ( -

- This user already has an override. -

- )} - {upsertError && ( -

- {getErrorMessage(upsertError, "Failed to save the override.")} -

- )} -
- - - - - -
-
- ); -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesSection.stories.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesSection.stories.tsx deleted file mode 100644 index 7486a749c9a..00000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesSection.stories.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { QueryClient, QueryClientProvider } from "react-query"; -import { expect, fn, userEvent, waitFor, within } from "storybook/test"; -import { UserOverridesSection } from "./UserOverridesSection"; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - gcTime: 0, - refetchOnWindowFocus: false, - }, - }, -}); - -const mockOverrides = [ - { - user_id: "user-1", - name: "Alice Johnson", - username: "alice", - avatar_url: "", - spend_limit_micros: 5_000_000, - }, - { - user_id: "user-2", - name: "Bob Smith", - username: "bob", - avatar_url: "", - spend_limit_micros: 10_000_000, - }, - { - user_id: "user-3", - name: "Charlie Davis", - username: "charlie", - avatar_url: "", - spend_limit_micros: null, - }, -]; - -const meta: Meta = { - title: "pages/AISettingsPage/SpendPage/LimitsTab/UserOverridesSection", - component: UserOverridesSection, - args: { - overrides: mockOverrides, - showUserForm: false, - onShowUserFormChange: fn(), - selectedUser: null, - onSelectedUserChange: fn(), - userOverrideAmount: "", - onUserOverrideAmountChange: fn(), - selectedUserAlreadyOverridden: false, - editingUserOverride: null, - onEditUserOverride: fn(), - onAddOverride: fn(), - onDeleteOverride: fn(), - upsertPending: false, - upsertError: null, - deletePending: false, - deleteError: null, - }, - decorators: [ - (Story) => ( - - - - ), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const EmptyState: Story = { - args: { - overrides: [], - }, -}; - -export const AddForm: Story = { - args: { - showUserForm: true, - overrides: mockOverrides, - }, -}; - -export const EditForm: Story = { - args: { - showUserForm: true, - editingUserOverride: { - user_id: mockOverrides[0].user_id, - name: mockOverrides[0].name, - username: mockOverrides[0].username, - avatar_url: mockOverrides[0].avatar_url, - }, - userOverrideAmount: "5.00", - }, - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - // Update budget button confirms edit mode is active. - await expect( - body.getByRole("button", { name: /update budget/i }), - ).toBeInTheDocument(); - // The editing user name appears in both the table row and the - // read-only edit form identity, confirming it was populated. - const nameElements = body.getAllByText("Alice Johnson"); - expect(nameElements.length).toBeGreaterThanOrEqual(2); - }, -}; - -export const DeleteUserOverride: Story = { - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - - const actionButtons = await body.findAllByRole("button", { - name: /actions for/i, - }); - await userEvent.click(actionButtons[0]); - await userEvent.click( - await body.findByRole("menuitem", { name: /remove override/i }), - ); - - // The confirmation dialog should appear. - const dialog = await body.findByRole("dialog"); - await expect(dialog).toBeInTheDocument(); - await expect( - body.getByText(/Are you sure you want to delete this user override/i), - ).toBeInTheDocument(); - }, -}; - -export const DeleteUserOverrideCancelled: Story = { - play: async ({ canvasElement, args }) => { - const body = within(canvasElement.ownerDocument.body); - - const actionButtons = await body.findAllByRole("button", { - name: /actions for/i, - }); - await userEvent.click(actionButtons[0]); - await userEvent.click( - await body.findByRole("menuitem", { name: /remove override/i }), - ); - - // Cancel the dialog. - await body.findByRole("dialog"); - await userEvent.click(body.getByRole("button", { name: "Cancel" })); - - // The dialog should be closed and the callback should not have been called. - await waitFor(() => { - expect(body.queryByRole("dialog")).not.toBeInTheDocument(); - }); - expect(args.onDeleteOverride).not.toHaveBeenCalled(); - }, -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesSection.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesSection.tsx deleted file mode 100644 index 15b8c4832e9..00000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesSection.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { PlusIcon } from "lucide-react"; -import { type FC, useId, useState } from "react"; -import { getErrorMessage } from "#/api/errors"; -import type { User } from "#/api/typesGenerated"; -import { Button } from "#/components/Button/Button"; -import { ConfirmDeleteDialog } from "#/components/Dialogs/ConfirmDeleteDialog/ConfirmDeleteDialog"; -import { SearchField } from "#/components/SearchField/SearchField"; -import { paginateItems } from "#/utils/paginateItems"; -import { SpendSectionHeader } from "../SpendSectionHeader"; -import { UserOverrideDialog } from "./UserOverrideDialog"; -import { - USER_OVERRIDES_PAGE_SIZE, - UserOverridesTable, -} from "./UserOverridesTable"; - -export interface UserOverrideUser { - user_id: string; - name: string; - username: string; - avatar_url: string; -} - -export interface UserOverride extends UserOverrideUser { - spend_limit_micros: number | null; -} - -interface UserOverridesSectionProps { - hideHeader?: boolean; - overrides: readonly UserOverride[]; - showUserForm: boolean; - onShowUserFormChange: (show: boolean) => void; - selectedUser: User | null; - onSelectedUserChange: (user: User | null) => void; - userOverrideAmount: string; - onUserOverrideAmountChange: (amount: string) => void; - selectedUserAlreadyOverridden: boolean; - editingUserOverride: UserOverrideUser | null; - onEditUserOverride: (override: UserOverride) => void; - onAddOverride: () => void; - onDeleteOverride: (userID: string) => void; - upsertPending: boolean; - upsertError: Error | null; - deletePending: boolean; - deleteError: Error | null; -} - -export const UserOverridesSection: FC = ({ - hideHeader, - overrides, - showUserForm, - onShowUserFormChange, - selectedUser, - onSelectedUserChange, - userOverrideAmount, - onUserOverrideAmountChange, - selectedUserAlreadyOverridden, - editingUserOverride, - onEditUserOverride, - onAddOverride, - onDeleteOverride, - upsertPending, - upsertError, - deletePending, - deleteError, -}) => { - const userOverrideAmountId = useId(); - const isEditing = editingUserOverride !== null; - const [pendingDeleteUserId, setPendingDeleteUserId] = useState( - null, - ); - const [page, setPage] = useState(1); - const [searchQuery, setSearchQuery] = useState(""); - const normalizedSearchQuery = searchQuery.trim().toLowerCase(); - const filteredOverrides = normalizedSearchQuery - ? overrides.filter((override) => - [override.name, override.username].some((value) => - value.toLowerCase().includes(normalizedSearchQuery), - ), - ) - : overrides; - const { pagedItems, clampedPage, hasPreviousPage, hasNextPage } = - paginateItems(filteredOverrides, USER_OVERRIDES_PAGE_SIZE, page); - - return ( -
- {!hideHeader && ( - - )} -
-
- { - setSearchQuery(value); - setPage(1); - }} - placeholder="Search users..." - aria-label="Search user overrides" - className="w-full sm:max-w-md" - /> - -
- - - - {deleteError && ( -

- {getErrorMessage(deleteError, "Failed to delete override.")} -

- )} - - -
- {pendingDeleteUserId && ( - { - void onDeleteOverride(pendingDeleteUserId); - setPendingDeleteUserId(null); - }} - isPending={deletePending} - open - onOpenChange={(open) => !open && setPendingDeleteUserId(null)} - /> - )} -
- ); -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesTable.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesTable.tsx deleted file mode 100644 index 1ab125f2c45..00000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesTable.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { EllipsisVerticalIcon, TrashIcon } from "lucide-react"; -import type { FC } from "react"; -import { AvatarData } from "#/components/Avatar/AvatarData"; -import { Button } from "#/components/Button/Button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "#/components/DropdownMenu/DropdownMenu"; -import { PaginationAmount } from "#/components/PaginationWidget/PaginationAmount"; -import { PaginationWidgetBase } from "#/components/PaginationWidget/PaginationWidgetBase"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "#/components/Table/Table"; -import { formatCostMicros } from "#/utils/currency"; -import type { UserOverride } from "./UserOverridesSection"; - -export const USER_OVERRIDES_PAGE_SIZE = 10; - -interface UserOverridesTableProps { - pagedOverrides: readonly UserOverride[]; - totalOverrides: number; - clampedPage: number; - hasPreviousPage: boolean; - hasNextPage: boolean; - onPageChange: (page: number) => void; - onEditUserOverride: (override: UserOverride) => void; - onRequestDelete: (userID: string) => void; - deletePending: boolean; - upsertPending: boolean; - isEditing: boolean; - emptyMessage: string; -} - -export const UserOverridesTable: FC = ({ - pagedOverrides, - totalOverrides, - clampedPage, - hasPreviousPage, - hasNextPage, - onPageChange, - onEditUserOverride, - onRequestDelete, - deletePending, - upsertPending, - isEditing, - emptyMessage, -}) => { - if (totalOverrides === 0) { - return ( -
- {emptyMessage} -
- ); - } - - return ( - <> -
- - - - User - Spend limit - Actions - - - - {pagedOverrides.map((override) => ( - - - - - - {override.spend_limit_micros !== null - ? formatCostMicros(override.spend_limit_micros) - : "Unlimited"} - - - - - - - - onEditUserOverride(override)} - > - Update budget - - onRequestDelete(override.user_id)} - > - - Remove override - - - - - - ))} - -
- -
- {totalOverrides > USER_OVERRIDES_PAGE_SIZE && ( - - )} - - ); -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/limitsFormLogic.test.ts b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/limitsFormLogic.test.ts deleted file mode 100644 index 97307cee086..00000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/limitsFormLogic.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { normalizeChatUsageLimitPeriod } from "./limitsFormLogic"; - -describe("limitsFormLogic", () => { - describe("normalizeChatUsageLimitPeriod", () => { - it("defaults invalid periods to month", () => { - expect(normalizeChatUsageLimitPeriod("year")).toBe("month"); - expect(normalizeChatUsageLimitPeriod(undefined)).toBe("month"); - }); - }); -}); diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/limitsFormLogic.ts b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/limitsFormLogic.ts deleted file mode 100644 index 63da616d9cc..00000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/limitsFormLogic.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ChatUsageLimitPeriod } from "#/api/typesGenerated"; - -export const normalizeChatUsageLimitPeriod = ( - period: string | null | undefined, -): ChatUsageLimitPeriod => { - switch (period) { - case "day": - case "week": - case "month": - return period; - default: - return "month"; - } -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/UsageTab/UsageTab.tsx b/site/src/pages/AISettingsPage/SpendPage/components/UsageTab/UsageTab.tsx index c3d5f634605..676a89614e3 100644 --- a/site/src/pages/AISettingsPage/SpendPage/components/UsageTab/UsageTab.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/components/UsageTab/UsageTab.tsx @@ -1,4 +1,3 @@ -import { EllipsisVerticalIcon } from "lucide-react"; import type { FC } from "react"; import type * as TypesGen from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; @@ -8,12 +7,6 @@ import { DateRangePicker, type DateRangeValue, } from "#/components/DateRangePicker/DateRangePicker"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "#/components/DropdownMenu/DropdownMenu"; import { PaginationContainer, type PaginationResult, @@ -38,14 +31,6 @@ import { formatTokenCount } from "#/utils/analytics"; import { formatCostMicros } from "#/utils/currency"; import { SpendSectionHeader } from "../SpendSectionHeader"; -type UsageUserOverride = { - user_id: string; - name: string; - username: string; - avatar_url: string; - spend_limit_micros: number | null; -}; - interface UsageTabProps { displayDateRange: DateRangeValue; onDateRangeChange: (value: DateRangeValue) => void; @@ -58,9 +43,7 @@ interface UsageTabProps { error: unknown; refetch: () => unknown; }; - overrides: readonly UsageUserOverride[]; onSelectUser: (user: TypesGen.ChatCostUserRollup) => void; - onEditBudget: (override: UsageUserOverride) => void; } export const UsageTab: FC = ({ @@ -69,9 +52,7 @@ export const UsageTab: FC = ({ searchFilter, onSearchFilterChange, usersQuery, - overrides, onSelectUser, - onEditBudget, }) => { return (
@@ -146,7 +127,6 @@ export const UsageTab: FC = ({ Output Cache Read Cache Write - Actions @@ -155,18 +135,6 @@ export const UsageTab: FC = ({ key={user.user_id} user={user} onSelect={onSelectUser} - onEditBudget={(selectedUser) => { - const override = overrides.find( - (o) => o.user_id === selectedUser.user_id, - ) ?? { - user_id: selectedUser.user_id, - name: selectedUser.name, - username: selectedUser.username, - avatar_url: selectedUser.avatar_url, - spend_limit_micros: null, - }; - onEditBudget(override); - }} /> ))} @@ -183,8 +151,7 @@ export const UsageTab: FC = ({ const UserRow: FC<{ user: TypesGen.ChatCostUserRollup; onSelect: (user: TypesGen.ChatCostUserRollup) => void; - onEditBudget: (user: TypesGen.ChatCostUserRollup) => void; -}> = ({ user, onSelect, onEditBudget }) => { +}> = ({ user, onSelect }) => { const clickableRowProps = useClickableTableRow({ onClick: () => onSelect(user), }); @@ -237,28 +204,6 @@ const UserRow: FC<{ {formatTokenCount(user.total_cache_creation_tokens)} - event.stopPropagation()}> - - - - - - onEditBudget(user)}> - Update budget - - onSelect(user)}> - View spend details - - - - ); }; diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index b95f88258cd..8bb6602fff9 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx @@ -500,14 +500,6 @@ const meta: Meta = { retention_days: 30, }); spyOn(API.experimental, "updateChatRetentionDays").mockResolvedValue(); - spyOn(API.experimental, "getChatUsageLimitConfig").mockResolvedValue({ - spend_limit_micros: null, - period: "month", - updated_at: "2026-02-18T00:00:00.000Z", - unpriced_model_count: 0, - overrides: [], - group_overrides: [], - }); spyOn(API, "getGroups").mockResolvedValue([]); spyOn(API.experimental, "getChatCostUsers").mockResolvedValue({ start_date: "2026-02-10T00:00:00Z", diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 5e72778eb50..e38c344494d 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -814,6 +814,15 @@ export const UsageLimitExceeded: Story = { }, ), }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText(/^You've used \$0\.90 of your \$0\.50 limit\./), + ).toBeVisible(); + await expect( + canvas.queryByRole("link", { name: /view usage/i }), + ).not.toBeInTheDocument(); + }, }; export const ForbiddenErrorWithRole: Story = { diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 358a1d70b3c..0a494c01dcf 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -1,6 +1,5 @@ import { type FC, useEffect, useEffectEvent, useRef, useState } from "react"; import { useQuery } from "react-query"; -import { Link } from "react-router"; import { toast } from "sonner"; import { isApiError } from "#/api/errors"; import { permittedOrganizations } from "#/api/queries/organizations"; @@ -8,7 +7,6 @@ import type * as TypesGen from "#/api/typesGenerated"; import type { AgentChatSendShortcut } from "#/api/typesGenerated"; import { Alert, AlertDescription } from "#/components/Alert/Alert"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; -import { Button } from "#/components/Button/Button"; import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import { docs } from "#/utils/docs"; @@ -515,14 +513,7 @@ export const AgentCreateForm: FC = ({ isApiError(createError) && createError.response?.status === 409 && isChatUsageLimitExceededResponse(createError.response.data) ? ( - - View usage - - } - > + {formatUsageLimitMessage(createError.response.data)} diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx index 0cf96976d4a..748f650fb91 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx @@ -40,7 +40,6 @@ export const EmptyConversationPrompt: Story = { }, }; -/** Usage-limit failures replace the idle prompt with the analytics CTA. */ export const UsageLimitExceeded: Story = { args: { ...defaultArgs, @@ -55,17 +54,12 @@ export const UsageLimitExceeded: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); expect(canvas.getByText(/spend limit/i)).toBeVisible(); - const link = canvas.getByRole("link", { name: /view usage/i }); - expect(link).toBeVisible(); - expect(link).toHaveAttribute("href", "/agents/analytics"); + expect( + canvas.queryByRole("link", { name: /view usage/i }), + ).not.toBeInTheDocument(); }, }; -/** - * Provider quota errors use the standard ChatStatusCallout instead of the - * "View usage" CTA (which links to Coder's analytics, not the provider's - * billing page). - */ export const ProviderQuotaExceeded: Story = { args: { ...defaultArgs, @@ -87,7 +81,6 @@ export const ProviderQuotaExceeded: Story = { expect( canvas.queryByRole("link", { name: /view usage/i }), ).not.toBeInTheDocument(); - // Should render ChatStatusCallout instead. expect( canvas.getByRole("heading", { name: /usage limit reached/i }), ).toBeVisible(); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx index b4b732a21b9..48efbbd58ee 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx @@ -1,8 +1,6 @@ -import { Link } from "react-router"; import type { UrlTransform } from "streamdown"; import type * as TypesGen from "#/api/typesGenerated"; import { Alert, AlertDescription } from "#/components/Alert/Alert"; -import { Button } from "#/components/Button/Button"; import type { ChatDetailError } from "../../utils/usageLimitMessage"; import type { SubagentVariant } from "../ChatElements/tools/subagentDescriptor"; import { ChatStatusCallout } from "./ChatStatusCallout"; @@ -94,14 +92,7 @@ export const LiveStreamTailContent = ({ /> )} {usageLimitStatus && !usageLimitStatus.provider ? ( - - View usage - - } - > + {usageLimitStatus.message} ) : terminalStatus ? ( diff --git a/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx b/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx index 1d177bfbb27..09a671e0641 100644 --- a/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx +++ b/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx @@ -1,12 +1,12 @@ import type { Decorator, Meta, StoryObj } from "@storybook/react-vite"; import type { FC } from "react"; import { useQueryClient } from "react-query"; -import { expect, userEvent, within } from "storybook/test"; -import { chatUsageLimitStatusKey } from "#/api/queries/chats"; +import { expect, userEvent, waitFor, within } from "storybook/test"; +import { meAISpendKey } from "#/api/queries/users"; import { getWorkspaceQuotaQueryKey } from "#/api/queries/workspaceQuota"; import { workspacesKey } from "#/api/queries/workspaces"; import type { - ChatUsageLimitStatus, + UserAISpendStatus, WorkspaceQuota, WorkspacesResponse, } from "#/api/typesGenerated"; @@ -21,9 +21,9 @@ import { } from "#/testHelpers/storybook"; import { UsageIndicator } from "./UsageIndicator"; -const withUsageLimitStatus = (status: ChatUsageLimitStatus) => (Story: FC) => { +const withAISpend = (status: UserAISpendStatus) => (Story: FC) => { const queryClient = useQueryClient(); - queryClient.setQueryData(chatUsageLimitStatusKey, status); + queryClient.setQueryData(meAISpendKey, status); return ; }; @@ -79,22 +79,23 @@ const openUsageMenu = async (canvasElement: HTMLElement) => { await userEvent.click(canvas.getByRole("button")); }; -const limitedUsageStatus = ( - overrides: Partial = {}, -): ChatUsageLimitStatus => ({ - is_limited: true, - period: "month", - spend_limit_micros: 50_000_000, - current_spend: 12_500_000, - period_start: "2026-02-10T00:00:00Z", - period_end: "2026-03-12T00:00:00Z", +const aiSpendStatus = ( + overrides: Partial = {}, +): UserAISpendStatus => ({ + user_id: MockUserOwner.id, + effective_group_id: "group-1", + effective_budget: { spend_limit_micros: 50_000_000, limit_source: "group" }, + current_spend_micros: 12_500_000, + period_start: "2026-07-01T00:00:00Z", + period_end: "2026-08-01T00:00:00Z", ...overrides, }); -const unlimitedUsageStatus = { - is_limited: false, - current_spend: 0, -} satisfies ChatUsageLimitStatus; +const noBudgetStatus = aiSpendStatus({ + effective_group_id: null, + effective_budget: null, + current_spend_micros: 0, +}); const userWorkspacesRequest = { q: `owner:me organization:${MockDefaultOrganization.name}`, @@ -120,6 +121,7 @@ const meta: Meta = { parameters: { user: MockUserOwner, permissions: MockPermissions, + features: ["aibridge"], }, }; @@ -128,18 +130,20 @@ type Story = StoryObj; export const LowUsage: Story = { decorators: [ - withUsageLimitStatus(limitedUsageStatus()), + withAISpend(aiSpendStatus()), withWorkspaceQuota(noWorkspaceQuota), ], }; export const MediumUsage: Story = { decorators: [ - withUsageLimitStatus( - limitedUsageStatus({ - period: "week", - spend_limit_micros: 20_000_000, - current_spend: 16_000_000, + withAISpend( + aiSpendStatus({ + effective_budget: { + spend_limit_micros: 20_000_000, + limit_source: "group", + }, + current_spend_micros: 16_000_000, }), ), withWorkspaceQuota(noWorkspaceQuota), @@ -148,11 +152,13 @@ export const MediumUsage: Story = { export const HighUsage: Story = { decorators: [ - withUsageLimitStatus( - limitedUsageStatus({ - period: "day", - spend_limit_micros: 10_000_000, - current_spend: 9_500_000, + withAISpend( + aiSpendStatus({ + effective_budget: { + spend_limit_micros: 10_000_000, + limit_source: "group", + }, + current_spend_micros: 9_500_000, }), ), withWorkspaceQuota(noWorkspaceQuota), @@ -161,10 +167,13 @@ export const HighUsage: Story = { export const LimitExceeded: Story = { decorators: [ - withUsageLimitStatus( - limitedUsageStatus({ - spend_limit_micros: 30_000_000, - current_spend: 32_000_000, + withAISpend( + aiSpendStatus({ + effective_budget: { + spend_limit_micros: 30_000_000, + limit_source: "group", + }, + current_spend_micros: 32_000_000, }), ), withWorkspaceQuota(noWorkspaceQuota), @@ -173,7 +182,7 @@ export const LimitExceeded: Story = { export const WorkspaceQuotaOnly: Story = { decorators: [ - withUsageLimitStatus(unlimitedUsageStatus), + withAISpend(noBudgetStatus), withWorkspaceQuota(defaultWorkspaceQuota), withWorkspaceCount(3), ], @@ -189,7 +198,7 @@ export const WorkspaceQuotaOnly: Story = { export const UsageAndWorkspaceQuota: Story = { decorators: [ - withUsageLimitStatus(limitedUsageStatus()), + withAISpend(aiSpendStatus()), withWorkspaceQuota(defaultWorkspaceQuota), withWorkspaceCount(3), ], @@ -199,18 +208,23 @@ export const UsageAndWorkspaceQuota: Story = { expect(canvas.getByRole("button", { name: "Usage" })).toBeVisible(); expect(progressBars.map((bar) => bar.getAttribute("aria-label"))).toEqual([ - "Monthly spend usage", + "AI spend usage", "Workspace quota usage", ]); await openUsageMenu(canvasElement); + const menu = within(await within(document.body).findByRole("menu")); + await waitFor(() => { + expect(menu.getByText("$12.50 of $50.00 used")).toBeVisible(); + expect(menu.getByText("July 1 - August 1, 2026")).toBeVisible(); + }); }, }; export const TriggerTiny: Story = { decorators: [ withUsageIndicatorFrame("w-[240px]", "usage-indicator-frame"), - withUsageLimitStatus(limitedUsageStatus()), + withAISpend(aiSpendStatus()), withWorkspaceQuota(defaultWorkspaceQuota), withWorkspaceCount(3), ], @@ -218,7 +232,7 @@ export const TriggerTiny: Story = { export const WorkspaceQuotaUnused: Story = { decorators: [ - withUsageLimitStatus(unlimitedUsageStatus), + withAISpend(noBudgetStatus), withWorkspaceQuota({ credits_consumed: 0, budget: 100, @@ -233,7 +247,7 @@ export const WorkspaceQuotaUnused: Story = { export const WorkspaceQuotaWithoutBudget: Story = { decorators: [ - withUsageLimitStatus(unlimitedUsageStatus), + withAISpend(noBudgetStatus), withWorkspaceQuota({ credits_consumed: 20, budget: 0, @@ -258,7 +272,7 @@ export const WorkspaceQuotaWithoutBudget: Story = { export const WorkspaceQuotaExceeded: Story = { decorators: [ - withUsageLimitStatus(unlimitedUsageStatus), + withAISpend(noBudgetStatus), withWorkspaceQuota({ credits_consumed: 125, budget: 100, @@ -272,7 +286,7 @@ export const WorkspaceQuotaExceeded: Story = { export const WorkspaceQuotaWithoutWorkspaceCount: Story = { decorators: [ - withUsageLimitStatus(unlimitedUsageStatus), + withAISpend(noBudgetStatus), withWorkspaceQuota(defaultWorkspaceQuota), withUnavailableWorkspaceCount, ], @@ -283,7 +297,45 @@ export const WorkspaceQuotaWithoutWorkspaceCount: Story = { export const NotLimited: Story = { decorators: [ - withUsageLimitStatus(unlimitedUsageStatus), + withAISpend(noBudgetStatus), withWorkspaceQuota(noWorkspaceQuota), ], }; + +export const ZeroBudget: Story = { + decorators: [ + withAISpend( + aiSpendStatus({ + effective_budget: { spend_limit_micros: 0, limit_source: "group" }, + current_spend_micros: 0, + }), + ), + withWorkspaceQuota(noWorkspaceQuota), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = await canvas.findByRole("button"); + await userEvent.click(trigger); + const body = within(canvasElement.ownerDocument.body); + await body.findByText(/limit exceeded/); + }, +}; + +export const GatewayUnavailable: Story = { + parameters: { features: [], experiments: [] }, + decorators: [ + withAISpend(aiSpendStatus()), + withWorkspaceQuota(defaultWorkspaceQuota), + withWorkspaceCount(3), + ], + play: async ({ canvasElement }) => { + await openUsageMenu(canvasElement); + const progressBars = within(canvasElement.ownerDocument.body).getAllByRole( + "progressbar", + ); + + expect(progressBars.map((bar) => bar.getAttribute("aria-label"))).toEqual([ + "Workspace quota usage", + ]); + }, +}; diff --git a/site/src/pages/AgentsPage/components/UsageIndicator.tsx b/site/src/pages/AgentsPage/components/UsageIndicator.tsx index 3e582864daf..872e20b76a1 100644 --- a/site/src/pages/AgentsPage/components/UsageIndicator.tsx +++ b/site/src/pages/AgentsPage/components/UsageIndicator.tsx @@ -1,15 +1,12 @@ -import dayjs from "dayjs"; import { CoinsIcon, InfoIcon, ServerIcon } from "lucide-react"; import { type FC, Fragment, type ReactNode } from "react"; import { useQuery } from "react-query"; -import { Link } from "react-router"; -import { chatUsageLimitStatus } from "#/api/queries/chats"; +import { meAISpend } from "#/api/queries/users"; import { workspaceQuota } from "#/api/queries/workspaceQuota"; import { workspaces } from "#/api/queries/workspaces"; import { DropdownMenu, DropdownMenuContent, - DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "#/components/DropdownMenu/DropdownMenu"; @@ -25,9 +22,10 @@ import { getDefaultOrganizationName, useDashboard, } from "#/modules/dashboard/useDashboard"; -import { getUsageLimitPeriodLabel } from "#/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView"; +import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { clampPercentage, + formatSpendPeriodLabel, getSeverity, type UsageSeverity, usageProgressPercentage, @@ -52,11 +50,13 @@ type UsageSectionData = { const numberFormatter = new Intl.NumberFormat("en-US"); export const UsageIndicator: FC = () => { - const { data: chatUsage, isError: isChatUsageError } = useQuery( - chatUsageLimitStatus(), - ); const { user } = useAuthenticated(); const { organizations } = useDashboard(); + const aiSpendAvailable = Boolean(useFeatureVisibility().aibridge); + const { data: aiSpend, isError: isAISpendError } = useQuery({ + ...meAISpend(), + enabled: aiSpendAvailable, + }); const organizationName = getDefaultOrganizationName(organizations); const username = user.username; const { data: quota, isError: isQuotaError } = useQuery({ @@ -75,18 +75,26 @@ export const UsageIndicator: FC = () => { }); const sections: UsageSectionData[] = []; - if (!isChatUsageError && chatUsage?.is_limited) { - const spendLimit = chatUsage.spend_limit_micros ?? 0; - const currentSpend = chatUsage.current_spend; - const periodLabel = getUsageLimitPeriodLabel(chatUsage.period); - const exceeded = spendLimit > 0 && currentSpend >= spendLimit; + if ( + aiSpendAvailable && + !isAISpendError && + aiSpend && + aiSpend.effective_budget !== null + ) { + const spendLimit = aiSpend.effective_budget.spend_limit_micros; + const currentSpend = aiSpend.current_spend_micros; + // The gateway blocks once spend >= limit, so a zero budget is + // always exhausted. + const exceeded = currentSpend >= spendLimit; sections.push({ - id: "ai-usage", - title: `${periodLabel} usage`, - progressLabel: `${periodLabel} spend usage`, - percent: usageProgressPercentage(currentSpend, spendLimit), - severity: getSeverity(currentSpend, spendLimit), + id: "ai-spend", + title: "AI spend", + progressLabel: "AI spend usage", + percent: exceeded + ? 100 + : usageProgressPercentage(currentSpend, spendLimit), + severity: exceeded ? "exceeded" : getSeverity(currentSpend, spendLimit), icon: , hoverLabel: `Spend ${formatCostMicros(currentSpend)}`, detail: ( @@ -100,9 +108,10 @@ export const UsageIndicator: FC = () => { )} ), - secondaryDetail: chatUsage.period_end - ? `Resets ${dayjs(chatUsage.period_end).format("MMM D, YYYY")}` - : undefined, + secondaryDetail: formatSpendPeriodLabel( + aiSpend.period_start, + aiSpend.period_end, + ), }); } @@ -167,12 +176,6 @@ const UsageMenu: FC<{ sections: readonly UsageSectionData[] }> = ({ ))} - - - - - View usage - ); diff --git a/site/src/pages/GroupsPage/AIBudgetPeriod.tsx b/site/src/pages/GroupsPage/AIBudgetPeriod.tsx index b716da63136..3ab592efa35 100644 --- a/site/src/pages/GroupsPage/AIBudgetPeriod.tsx +++ b/site/src/pages/GroupsPage/AIBudgetPeriod.tsx @@ -1,8 +1,8 @@ -import dayjs from "dayjs"; import type { FC } from "react"; import { useQuery } from "react-query"; import { meAISpend } from "#/api/queries/users"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; +import { formatSpendPeriodLabel } from "#/utils/budget"; /** The current AI budget window, e.g. "June 1 - July 1, 2026". */ export const AIBudgetPeriod: FC = () => { @@ -13,12 +13,10 @@ export const AIBudgetPeriod: FC = () => { return null; } - // Local time and raw exclusive period_end, matching the spend page. - const start = dayjs(aiSpend.period_start).format("MMMM D"); - const end = dayjs(aiSpend.period_end).format("MMMM D, YYYY"); return ( - AI budget period: {start} - {end} + AI budget period:{" "} + {formatSpendPeriodLabel(aiSpend.period_start, aiSpend.period_end)} ); }; diff --git a/site/src/pages/GroupsPage/GroupPage.stories.tsx b/site/src/pages/GroupsPage/GroupPage.stories.tsx index e51514515b5..3556d507d75 100644 --- a/site/src/pages/GroupsPage/GroupPage.stories.tsx +++ b/site/src/pages/GroupsPage/GroupPage.stories.tsx @@ -348,9 +348,8 @@ export const WithMemberAIBudget: Story = { const canvas = within(canvasElement); await expect(await canvas.findByText("AI budget")).toBeInTheDocument(); await expect(await canvas.findByText("Budget group")).toBeInTheDocument(); - // Dates depend on the runner's timezone; match loosely. await expect( - await canvas.findByText(/^AI budget period: \w+ \d+ - \w+ \d+, 2026$/), + await canvas.findByText("AI budget period: June 1 - July 1, 2026"), ).toBeInTheDocument(); await expect( diff --git a/site/src/utils/budget.test.ts b/site/src/utils/budget.test.ts index 7ee182c8fa8..a475eaa6159 100644 --- a/site/src/utils/budget.test.ts +++ b/site/src/utils/budget.test.ts @@ -1,10 +1,25 @@ import { describe, expect, it } from "vitest"; import { clampPercentage, + formatSpendPeriodLabel, getSeverity, usageProgressPercentage, } from "./budget"; +describe("formatSpendPeriodLabel", () => { + it("renders API timestamps in UTC with the year on the exclusive end", () => { + expect( + formatSpendPeriodLabel("2026-06-01T00:00:00Z", "2026-07-01T00:00:00Z"), + ).toBe("June 1 - July 1, 2026"); + }); + + it("uses the end year when the window crosses into the next year", () => { + expect( + formatSpendPeriodLabel("2026-12-01T00:00:00Z", "2027-01-01T00:00:00Z"), + ).toBe("December 1 - January 1, 2027"); + }); +}); + describe("getSeverity", () => { it("returns normal below the warning threshold", () => { expect(getSeverity(0, 50)).toBe("normal"); diff --git a/site/src/utils/budget.ts b/site/src/utils/budget.ts index 6b14b76970f..78d537a2445 100644 --- a/site/src/utils/budget.ts +++ b/site/src/utils/budget.ts @@ -1,5 +1,23 @@ +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; + +dayjs.extend(utc); + export type UsageSeverity = "normal" | "warning" | "exceeded"; +/** + * Formats an AI budget window in UTC, e.g. "June 1 - July 1, 2026". Renders + * the exclusive period_end as-is. + */ +export function formatSpendPeriodLabel( + periodStart: string, + periodEnd: string, +): string { + const start = dayjs.utc(periodStart).format("MMMM D"); + const end = dayjs.utc(periodEnd).format("MMMM D, YYYY"); + return `${start} - ${end}`; +} + /** * Classifies usage against a budget. Returns "warning" once usage reaches 85% * of the budget and "exceeded" once it meets or passes the budget. A budget of