feat(site/src): filter spend by provider, model, and client (#29632)

Wires the provider, model, and client filter menus shared with the
sessions page into the spend page, in the same order as the table
columns. Their options come from deployment-wide AI Gateway endpoints,
so only viewers with `viewAnyAIBridgeInterception` get the menus; for
anyone else the URL parameters are ignored and no option request is
made.

## Why

Organization-scoped filter options need a new endpoint and are a
follow-up; gating on the deployment-wide permission keeps the page open
to organization readers without leaking filter options they cannot list.

## What to scrutinize

- `shows spend without dimension filters to viewers who cannot read AI
sessions` asserts no provider, model, or client request and no
`provider_name` in the report request.
- The pending-report test drives the offered presets and checks their
request boundaries against the retained cutoff. The keyboard test
applies a second range after the first request without moving focus back
to the trigger.
- The shared model menu sends raw identifiers through `model`,
introduced in #29627. Quoting and SQL LIKE escaping no longer live in
the frontend.

## Validation

`pnpm test src/pages/AISettingsPage/SpendPage/SpendPage.test.tsx
src/pages/AIBridgePage/filters/ModelFilter.test.tsx`, `pnpm
test:storybook` on `SpendPage.stories.tsx` (`ProviderMenu`,
`FilteredByProvider`), repository pre-commit.

## Delivery record

Published head `ece31d4795d` passed per-layer integration checks and
commit hooks. The composed head `d899228f798` passed format, check, full
frontend lint, 125 unit tests, and 86 story tests. Pre-push hooks were
skipped as requested. See #28964 for audit findings, their disposition,
and historical UAT limits. No exact-head live UAT or new independent
re-review is claimed.

## Stack context

PR 8 of 9. The stack adds a **User spend** page under **Admin settings**
> **AI** > **User spend**: total and per-user AI Gateway spend for one
organization over a reporting period, read from `GET
/api/v2/organizations/{organization}/ai/spend/users` (#29129, merged).
It is #28964 split into one-concern PRs. Historical UAT covers earlier
heads only; live UAT has not been rerun after these review fixes. Bottom
to top: DateRangePicker limits, shared dimension badge, API client
method, spend table, page view, page container, reporting period,
dimension filters, route and navigation (#28964).

> Xum prepared this PR on behalf of @ibetitsmike.
This commit is contained in:
Michael Suchacz
2026-09-22 13:33:50 +00:00
committed by GitHub
parent d532b40ed0
commit 4f78bb8535
6 changed files with 263 additions and 8 deletions
+3 -1
View File
@@ -3065,7 +3065,9 @@ class ApiMethods {
await this.axios.put<void>("/api/v2/notifications/inbox/mark-all-as-read");
};
getAIBridgeModels = async (options: SearchParamOptions) => {
getAIBridgeModels = async (
options: TypesGen.Pagination & { model?: string },
) => {
const url = getURLWithSearchParams(`${aiGatewayPath}/models`, options);
const response = await this.axios.get<string[]>(url);
@@ -0,0 +1,39 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { FC } from "react";
import { afterEach, expect, it, vi } from "vitest";
import { API } from "#/api/api";
import { render } from "#/testHelpers/renderHelpers";
import { ModelFilter, useModelFilterMenu } from "./ModelFilter";
afterEach(() => {
vi.restoreAllMocks();
});
const bedrockModel = "us.anthropic.claude-3-5-sonnet-20241022-v2:0";
const ModelFilterHarness: FC<{ value?: string }> = ({ value }) => {
const menu = useModelFilterMenu({ value, onChange: vi.fn(), enabled: true });
return <ModelFilter menu={menu} />;
};
it("looks up the selected model by its literal identifier", async () => {
const modelsSpy = vi
.spyOn(API, "getAIBridgeModels")
.mockResolvedValue([bedrockModel]);
render(<ModelFilterHarness value={bedrockModel} />);
await waitFor(() =>
expect(modelsSpy).toHaveBeenCalledWith({ model: bedrockModel, limit: 1 }),
);
});
it("searches typed text as a literal model prefix", async () => {
const user = userEvent.setup();
const modelsSpy = vi.spyOn(API, "getAIBridgeModels").mockResolvedValue([]);
render(<ModelFilterHarness />);
await user.click(screen.getByRole("button", { name: "Select model" }));
await user.type(await screen.findByRole("combobox"), "gpt_4%:");
await waitFor(() =>
expect(modelsSpy).toHaveBeenCalledWith({ model: "gpt_4%:", limit: 25 }),
);
});
@@ -17,7 +17,7 @@ export const useModelFilterMenu = ({
id: "model",
getSelectedOption: async () => {
const modelsRes = await API.getAIBridgeModels({
q: value,
model: value,
limit: 1,
});
const firstModel = modelsRes.at(0);
@@ -36,7 +36,7 @@ export const useModelFilterMenu = ({
},
getOptions: async (query) => {
const modelsRes = await API.getAIBridgeModels({
q: query,
model: query,
limit: 25,
});
return modelsRes.map((model) => ({
@@ -5,6 +5,7 @@ import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { API } from "#/api/api";
import type { OrganizationAISpendUser } from "#/api/typesGenerated";
import {
MockAIProviders,
MockOrganization,
MockOrganization2,
MockOrganizationAISpendReport,
@@ -55,6 +56,7 @@ const meta = {
args: { now: fixedNow.toDate() },
parameters: {
user: MockUserMember,
permissions: { viewAnyAIBridgeInterception: true },
features: ["aibridge"],
reactRouter: reactRouterParameters({
location: { path: "/ai/settings/spend" },
@@ -83,6 +85,9 @@ const meta = {
),
}),
);
spyOn(API, "getAIBridgeProviders").mockResolvedValue(MockAIProviders);
spyOn(API, "getAIBridgeClients").mockResolvedValue(["Claude Code"]);
spyOn(API, "getAIBridgeModels").mockResolvedValue(["gpt-4o"]);
},
} satisfies Meta<typeof SpendPage>;
export default meta;
@@ -101,6 +106,48 @@ export const BudgetPeriod: Story = {
},
};
export const ProviderMenu: Story = {
parameters: { reactRouter: explicitRange },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByRole("button", { name: "Select provider" }),
);
await screen.findByRole("option", { name: /OpenAI/ });
},
};
export const FilteredByProvider: Story = {
parameters: {
reactRouter: reactRouterParameters({
location: {
path: "/ai/settings/spend",
searchParams: {
startDate: "2026-02-10T00:00:00.000Z",
endDate: "2026-03-12T00:00:00.000Z",
provider_name: "openai",
},
},
routing,
}),
},
beforeEach: () => {
spyOn(API, "getOrganizationAISpendUsers").mockResolvedValue({
...MockOrganizationAISpendReport,
count: 3,
totals: { cost_micros: 27_000_000, unpriced_usage_count: 0 },
users: [
{ ...mockSpendUsers[0], providers: ["openai"] },
{ ...mockSpendUsers[3], providers: ["openai"] },
{ ...mockSpendUsers[6], providers: ["openai"] },
],
});
},
play: async ({ canvasElement }) => {
await within(canvasElement).findByRole("table", { name: "Spend by user" });
},
};
// Without a URL range the server narrows the budget period to retention, and
// the endpoint rejects any explicit start before that cutoff.
export const RetentionLimitedPicker: Story = {
@@ -10,7 +10,9 @@ import type {
OrganizationAISpendUser,
} from "#/api/typesGenerated";
import { usePaginatedQuery } from "#/hooks/usePaginatedQuery";
import type { Permissions } from "#/modules/permissions";
import {
MockAIProviders,
MockEntitlements,
MockNoPermissions,
MockOrganization,
@@ -23,10 +25,15 @@ import { renderHookWithAuth } from "#/testHelpers/hooks";
import { renderWithRouter } from "#/testHelpers/renderHelpers";
import SpendPage from "./SpendPage";
const sessionViewerPermissions: Permissions = {
...MockNoPermissions,
viewAnyAIBridgeInterception: true,
};
const auth = { permissions: sessionViewerPermissions };
vi.mock("#/hooks/useAuthenticated", () => ({
useAuthenticated: () => ({
user: MockUserMember,
permissions: MockNoPermissions,
permissions: auth.permissions,
}),
}));
vi.mock("#/modules/dashboard/useDashboard", () => ({
@@ -42,6 +49,7 @@ vi.mock("#/modules/dashboard/useDashboard", () => ({
afterEach(() => {
vi.restoreAllMocks();
auth.permissions = sessionViewerPermissions;
});
const fixedNow = dayjs("2026-03-12T12:00:00Z");
@@ -54,7 +62,7 @@ const initialSearch = new URLSearchParams({
endDate: period.period_end,
}).toString();
function mockSpendApi() {
function mockSpendApi(report: Partial<OrganizationAISpendReport> = {}) {
const users: OrganizationAISpendUser[] = Array.from(
{ length: 12 },
(_, i) => ({
@@ -83,6 +91,7 @@ function mockSpendApi() {
params.offset ?? 0,
(params.offset ?? 0) + (params.limit ?? 10),
),
...report,
});
const spendSpy = vi
.spyOn(API, "getOrganizationAISpendUsers")
@@ -90,8 +99,14 @@ function mockSpendApi() {
return { spendSpy, buildReport };
}
function renderSpend(search = initialSearch) {
const { spendSpy, buildReport } = mockSpendApi();
function renderSpend(
search = initialSearch,
report: Partial<OrganizationAISpendReport> = {},
) {
const { spendSpy, buildReport } = mockSpendApi(report);
vi.spyOn(API, "getAIBridgeProviders").mockResolvedValue(MockAIProviders);
vi.spyOn(API, "getAIBridgeClients").mockResolvedValue(["Claude Code"]);
vi.spyOn(API, "getAIBridgeModels").mockResolvedValue(["gpt-4o"]);
const router = createMemoryRouter(
[
{
@@ -196,6 +211,121 @@ it("applies a date preset and resets pagination", async () => {
expect(searchParam(router, "page")).toBeNull();
});
it("keeps the retention bound while a filtered report is pending", async () => {
const user = userEvent.setup();
const retentionStart = fixedNow.subtract(10, "day");
const { spendSpy } = renderSpend(initialSearch, {
retention_start: retentionStart.toISOString(),
});
await screen.findByRole("table", { name: "Spend by user" });
// Leave the refiltered report pending so its retention bound never arrives.
spendSpy.mockImplementationOnce(() => new Promise(() => {}));
await user.click(screen.getByRole("button", { name: "Select provider" }));
await user.click(await screen.findByRole("option", { name: /OpenAI/ }));
await waitFor(() =>
expect(spendSpy).toHaveBeenCalledWith(
MockOrganization.id,
expect.objectContaining({ provider_name: "openai" }),
),
);
// The picker stays usable with the bound the first report delivered, so
// every preset it offers requests a period that starts within retention.
const requestsBeforePicking = spendSpy.mock.calls.length;
const picker = screen.getByRole("button", {
name: /Feb 10, 2026.*Mar 11, 2026/,
});
await user.click(picker);
const offered = screen
.getAllByRole("button", { name: /^Last \d+ days$/ })
.map((preset) => preset.textContent ?? "");
for (const label of offered) {
await user.click(screen.getByRole("button", { name: label }));
await waitFor(() =>
expect(spendSpy.mock.calls.length).toBeGreaterThan(requestsBeforePicking),
);
await user.click(
screen.getByRole("button", { name: /Mar \d+, 2026.*Mar 12, 2026/ }),
);
}
const presetRequests = spendSpy.mock.calls.slice(requestsBeforePicking);
expect(presetRequests).toHaveLength(offered.length);
expect(presetRequests.length).toBeGreaterThan(0);
for (const [, params] of presetRequests) {
expect(params.provider_name).toBe("openai");
expect(dayjs(params.period_start).isBefore(retentionStart)).toBe(false);
}
expect(spendSpy).not.toHaveBeenCalledWith(
MockOrganization.id,
expect.objectContaining({
period_start: fixedNow.subtract(29, "day").startOf("day").toISOString(),
}),
);
});
it("applies a second range from the keyboard after the first one resolves", async () => {
const user = userEvent.setup();
const { spendSpy } = renderSpend();
await screen.findByRole("table", { name: "Spend by user" });
await user.click(
screen.getByRole("button", { name: /Feb 10, 2026.*Mar 11, 2026/ }),
);
await user.click(await screen.findByRole("button", { name: "Last 7 days" }));
await waitFor(() =>
expect(spendSpy).toHaveBeenCalledWith(
MockOrganization.id,
expect.objectContaining({
period_start: fixedNow.subtract(6, "day").startOf("day").toISOString(),
}),
),
);
await screen.findByRole("button", { name: /Mar 6, 2026.*Mar 12, 2026/ });
// Focus returned to the picker when its popover closed, so Enter reopens it.
await user.keyboard("{Enter}");
await user.click(await screen.findByRole("button", { name: "Yesterday" }));
await waitFor(() =>
expect(spendSpy).toHaveBeenCalledWith(
MockOrganization.id,
expect.objectContaining({
period_start: fixedNow.subtract(1, "day").startOf("day").toISOString(),
}),
),
);
});
it("shows spend without dimension filters to viewers who cannot read AI sessions", async () => {
auth.permissions = MockNoPermissions;
const { spendSpy } = renderSpend(`${initialSearch}&provider_name=openai`);
await screen.findByRole("table", { name: "Spend by user" });
expect(API.getAIBridgeProviders).not.toHaveBeenCalled();
expect(API.getAIBridgeModels).not.toHaveBeenCalled();
expect(API.getAIBridgeClients).not.toHaveBeenCalled();
expect(spendSpy).toHaveBeenCalledWith(
MockOrganization.id,
expect.objectContaining(period),
);
expect(spendSpy.mock.calls[0][1]).not.toHaveProperty("provider_name");
});
it("applies the provider filter and resets pagination", async () => {
const user = userEvent.setup();
const { router, spendSpy } = renderSpend(`${initialSearch}&page=2`);
await screen.findByRole("table", { name: "Spend by user" });
await user.click(screen.getByRole("button", { name: "Select provider" }));
await user.click(await screen.findByRole("option", { name: /OpenAI/ }));
await waitFor(() =>
expect(spendSpy).toHaveBeenCalledWith(
MockOrganization.id,
expect.objectContaining({ provider_name: "openai", offset: 0 }),
),
);
expect(searchParam(router, "provider_name")).toBe("openai");
expect(searchParam(router, "page")).toBeNull();
});
it("requests the next page offset", async () => {
const user = userEvent.setup();
const { router, spendSpy } = renderSpend();
@@ -16,6 +16,9 @@ import {
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { usePaginatedQuery } from "#/hooks/usePaginatedQuery";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { useClientFilterMenu } from "#/pages/AIBridgePage/filters/ClientFilter";
import { useModelFilterMenu } from "#/pages/AIBridgePage/filters/ModelFilter";
import { useProviderFilterMenu } from "#/pages/AIBridgePage/filters/ProviderFilter";
import { getAIBridgePermissions } from "#/pages/AIBridgePage/getAIBridgePermissions";
import {
modelOrganizationSearchParam,
@@ -27,6 +30,11 @@ import { SpendPageView } from "./SpendPageView";
const startDateSearchParam = "startDate";
const endDateSearchParam = "endDate";
type SpendDimensions = Pick<
OrganizationAISpendFilter,
"provider_name" | "client" | "model"
>;
// Local midnight of the UTC calendar date that the instant falls on.
const localDayOf = (instant: Date): Date =>
new Date(
@@ -98,6 +106,9 @@ const SpendPage: FC<SpendPageProps> = ({ now }) => {
permissions,
);
const isSpendAvailable = isEntitled && isEnabled;
// The provider, model, and client options come from deployment-wide AI
// Gateway endpoints, so only viewers of every session get those filters.
const canFilterDimensions = permissions.viewAnyAIBridgeInterception;
const [searchParams, setSearchParams] = useSearchParams();
@@ -133,6 +144,31 @@ const SpendPage: FC<SpendPageProps> = ({ now }) => {
? undefined
: organizationSelection.organization;
const dimensions: SpendDimensions = canFilterDimensions
? {
provider_name: searchParams.get("provider_name") || undefined,
client: searchParams.get("client") || undefined,
model: searchParams.get("model") || undefined,
}
: {};
const filterMenus = {
provider: useProviderFilterMenu({
value: dimensions.provider_name,
onChange: (option) => setFilterParams({ provider_name: option?.value }),
enabled: isSpendAvailable && canFilterDimensions,
}),
client: useClientFilterMenu({
value: dimensions.client,
onChange: (option) => setFilterParams({ client: option?.value }),
enabled: isSpendAvailable && canFilterDimensions,
}),
model: useModelFilterMenu({
value: dimensions.model,
onChange: (option) => setFilterParams({ model: option?.value }),
enabled: isSpendAvailable && canFilterDimensions,
}),
};
const startDateParam = searchParams.get(startDateSearchParam)?.trim() ?? "";
const endDateParam = searchParams.get(endDateSearchParam)?.trim() ?? "";
@@ -161,6 +197,7 @@ const SpendPage: FC<SpendPageProps> = ({ now }) => {
period_start: dateRange.startDate.toISOString(),
period_end: dateRange.endDate.toISOString(),
}),
...dimensions,
};
// DateRangePicker already emits exclusive API boundaries (midnight after
@@ -217,7 +254,7 @@ const SpendPage: FC<SpendPageProps> = ({ now }) => {
minDate={minDate}
isRetentionLoading={retention === undefined && reportQuery.isLoading}
onDateRangeChange={onDateRangeChange}
filterMenus={undefined}
filterMenus={canFilterDimensions ? filterMenus : undefined}
reportQuery={reportQuery}
/>
</>