Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3477,6 +3477,17 @@ class ExperimentalApiMethods {
return response.data;
};

getAIModelPrices = async (filter: {
provider?: string;
model?: string;
}): Promise<TypesGen.AIModelPrice[]> => {
const response = await this.axios.get<TypesGen.AIModelPrice[]>(
"/api/experimental/ai/model-prices",
{ params: filter },
);
return response.data;
};

listAIProviders = async (): Promise<TypesGen.AIProvider[]> => {
const response = await this.axios.get<TypesGen.AIProvider[]>(
aiProviderConfigsPath,
Expand Down
8 changes: 8 additions & 0 deletions site/src/api/queries/aiProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ import type {

const aiProvidersListKey = ["ai", "providers"] as const;

const aiModelPricesKey = ["ai", "model-prices"] as const;

export const aiModelPrices = (provider: string, model: string) =>
queryOptions({
queryKey: [...aiModelPricesKey, provider, model] as const,
queryFn: () => API.experimental.getAIModelPrices({ provider, model }),
});

export const aiProviderKeyFor = (idOrName: string) =>
[...aiProvidersListKey, idOrName] as const;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,31 +1,43 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, screen, userEvent, within } from "storybook/test";
import { expect, fn, screen, spyOn, userEvent, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { withToaster } from "#/testHelpers/storybook";
import { API } from "#/api/api";
import { aiModelPrices } from "#/api/queries/aiProviders";
import type * as TypesGen from "#/api/typesGenerated";
import { withDashboardProvider, withToaster } from "#/testHelpers/storybook";
import {
MockAnthropicProviderState,
MockAzureProviderState,
MockDisabledProviderState,
MockOpenAIProviderState,
mockClaude,
mockGPT5,
mockProviderDisabledModel,
} from "../testFixtures";
import { ModelForm } from "./ModelForm";

const onUpdateModel = fn(
async (
_modelConfigId: string,
_req: TypesGen.UpdateChatModelConfigRequest,
): Promise<unknown> => undefined,
);

const meta: Meta<typeof ModelForm> = {
title: "pages/AISettingsPage/ModelsPage/ModelForm",
component: ModelForm,
decorators: [withToaster],
decorators: [withToaster, withDashboardProvider],
args: {
providerStates: [MockOpenAIProviderState, MockAnthropicProviderState],
selectedProviderState: MockOpenAIProviderState,
onProviderChange: fn(),
isSaving: false,
isDeleting: false,
onCreateModel: fn(async () => undefined),
onUpdateModel: fn(async () => undefined),
onUpdateModel,
},
parameters: {
features: ["aibridge"],
reactRouter: reactRouterParameters({
location: { path: "/ai/settings/models/add" },
routing: [
Expand Down Expand Up @@ -411,19 +423,239 @@ export const ReasoningEffortValidationError: Story = {
},
};

export const NativeCostTrackingIsUnavailable: Story = {
// The catalog fallback covers an entitled deployment with no matching price
// book row. Values come from the baked-in catalog and must not be submitted.
export const CostEstimateFieldsAreImmutable: Story = {
args: {
editingModel: mockClaude,
selectedProviderState: MockAnthropicProviderState,
onDeleteModel: fn(async () => undefined),
},
parameters: {
queries: [
{
key: aiModelPrices("anthropic", "claude-sonnet-4-5").queryKey,
data: [],
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: /cost estimate/i }),
);

const expectedValues: [RegExp, string][] = [
[/^input$/i, "3"],
[/^output$/i, "15"],
[/cache read/i, "0.30"],
[/cache write/i, "3.75"],
];
for (const [name, value] of expectedValues) {
const field = canvas.getByLabelText(name);
await expect(field).toHaveValue(value);
await expect(field).toHaveAttribute("readonly");
}

// Update is disabled until the form is dirty. A display name edit
// unlocks it so the payload can be checked for pricing keys.
await userEvent.type(canvas.getByLabelText(/display name/i), " (updated)");
await userEvent.click(
canvas.getByRole("button", { name: /^update model$/i }),
);
await expect(onUpdateModel).toHaveBeenCalledTimes(1);
expect(onUpdateModel.mock.calls[0]?.[1]).toStrictEqual({
display_name: "Claude Sonnet 4.5 (updated)",
model_config: {},
});
},
};

// With the AI Gateway feature entitled, prices come from the live price
// book, so admin overrides and models missing from the catalog are
// reflected. mockGPT5 is openai/gpt-5, absent from the catalog but present
// in the price book. Prices are micro-units per million tokens.
export const CostEstimateFromLivePriceBook: Story = {
args: {
editingModel: mockGPT5,
onDeleteModel: fn(async () => undefined),
},
parameters: {
queries: [
{
key: aiModelPrices("openai", "gpt-5").queryKey,
data: [
{
provider: "openai",
model: "gpt-5",
input_price: 1250000,
output_price: 10000000,
cache_read_price: 125000,
cache_write_price: null,
created_at: "2026-02-18T12:00:00.000Z",
updated_at: "2026-02-18T12:00:00.000Z",
},
],
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: /cost estimate/i }),
);
await expect(canvas.getByLabelText(/^input$/i)).toHaveValue("1.25");
await expect(canvas.getByLabelText(/^output$/i)).toHaveValue("10");
await expect(canvas.getByLabelText(/cache read/i)).toHaveValue("0.125");
await expect(canvas.getByLabelText(/cache write/i)).toHaveValue("");
},
};

// A price book row is the deployment's own pricing, so it wins outright. A
// null category on the row means the model is unpriced there and bills as
// zero, so the field stays blank instead of falling back to the catalog.
// The catalog only fills in when the model has no row at all.
export const CostEstimateRowWinsOverCatalog: Story = {
args: {
editingModel: mockClaude,
selectedProviderState: MockAnthropicProviderState,
onDeleteModel: fn(async () => undefined),
},
parameters: {
queries: [
{
key: aiModelPrices("anthropic", "claude-sonnet-4-5").queryKey,
data: [
{
provider: "anthropic",
model: "claude-sonnet-4-5",
input_price: 4000000,
output_price: null,
cache_read_price: null,
cache_write_price: null,
created_at: "2026-02-18T12:00:00.000Z",
updated_at: "2026-02-18T12:00:00.000Z",
},
],
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: /cost estimate/i }),
);
// The override sets input to $4. Output and both cache categories are
// null on the row, so they render blank even though the catalog has
// values for them.
await expect(canvas.getByLabelText(/^input$/i)).toHaveValue("4");
await expect(canvas.getByLabelText(/^output$/i)).toHaveValue("");
await expect(canvas.getByLabelText(/cache read/i)).toHaveValue("");
await expect(canvas.getByLabelText(/cache write/i)).toHaveValue("");
},
};

// Without the AI Gateway entitlement the price endpoint is not queried, so
// a model with no catalog entry gets the empty state.
export const CostEstimateUnavailableForUnknownModel: Story = {
args: {
editingModel: mockGPT5,
onDeleteModel: fn(async () => undefined),
},
parameters: {
features: [],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: /cost estimate/i }),
);
await expect(
canvas.getByRole("button", { name: /provider configuration/i }),
).toBeVisible();
canvas.getByText("No pricing data for this model."),
).toBeInTheDocument();
await expect(canvas.queryByLabelText(/^input$/i)).not.toBeInTheDocument();
},
};

// The entitlement gate means the endpoint is not called at all without the
// AI Gateway feature. A catalog model still shows catalog prices.
export const CostEstimateCatalogOnlyWhenNotEntitled: Story = {
args: {
editingModel: mockClaude,
selectedProviderState: MockAnthropicProviderState,
onDeleteModel: fn(async () => undefined),
},
parameters: {
features: [],
},
beforeEach: () => {
spyOn(API.experimental, "getAIModelPrices").mockResolvedValue([]);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: /cost estimate/i }),
);
await expect(canvas.getByLabelText(/^input$/i)).toHaveValue("3");
await expect(canvas.getByLabelText(/^output$/i)).toHaveValue("15");
expect(API.experimental.getAIModelPrices).not.toHaveBeenCalled();
},
};

// While the price book lookup is in flight, the four boxes stay rendered
// with a loading placeholder in each instead of a catalog price. The
// catalog must not appear because the model may have a deployment override.
export const CostEstimateLoading: Story = {
args: {
editingModel: mockClaude,
selectedProviderState: MockAnthropicProviderState,
onDeleteModel: fn(async () => undefined),
},
beforeEach: () => {
spyOn(API.experimental, "getAIModelPrices").mockImplementation(
() => new Promise(() => {}),
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: /cost estimate/i }),
);
// Each box shows a loading placeholder while the lookup is in flight.
for (const label of ["Input", "Output", "Cache read", "Cache write"]) {
await expect(
canvas.getByLabelText(`${label} price loading`),
).toBeInTheDocument();
}
// The catalog numbers must not render while the lookup is pending.
expect(canvas.queryByDisplayValue("3")).not.toBeInTheDocument();
},
};

// When the price book lookup fails, the section says so instead of falling
// back to the catalog, because the model may have a deployment override.
export const CostEstimateError: Story = {
args: {
editingModel: mockClaude,
selectedProviderState: MockAnthropicProviderState,
onDeleteModel: fn(async () => undefined),
},
beforeEach: () => {
spyOn(API.experimental, "getAIModelPrices").mockRejectedValue(
new Error("request failed"),
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: /cost estimate/i }),
);
await expect(
canvas.queryByRole("button", { name: /cost tracking/i }),
).not.toBeInTheDocument();
canvas.getByText("Couldn't load pricing."),
).toBeInTheDocument();
// The catalog numbers must not render on error.
expect(canvas.queryByDisplayValue("3")).not.toBeInTheDocument();
expect(canvas.queryByDisplayValue("15")).not.toBeInTheDocument();
},
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export const ModelForm: FC<ModelFormProps> = ({
...(isDuplicating && { isDefault: false }),
};
const [showAdvanced, setShowAdvanced] = useState(false);
const [showCostEstimate, setShowCostEstimate] = useState(false);
const [showProviderConfig, setShowProviderConfig] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [confirmingReplaceDefault, setConfirmingReplaceDefault] =
Expand Down Expand Up @@ -330,6 +331,8 @@ export const ModelForm: FC<ModelFormProps> = ({
displayNameField={displayNameField}
setDefaultDisabled={setDefaultDisabled}
modelConfigFormBuildResult={modelConfigFormBuildResult}
showCostEstimate={showCostEstimate}
setShowCostEstimate={setShowCostEstimate}
showProviderConfig={showProviderConfig}
setShowProviderConfig={setShowProviderConfig}
showAdvanced={showAdvanced}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type { ProviderState } from "#/modules/aiModels/providerStates";
import {
GeneralModelConfigFields,
ModelConfigFields,
PricingEstimateFields,
ReasoningEffortConfigFields,
} from "#/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields";
import { ModelIdentifierField } from "#/pages/AgentsPage/components/ChatModelAdminPanel/ModelIdentifierField";
Expand Down Expand Up @@ -101,6 +102,8 @@ export const ModelFormFields: FC<{
displayNameField: FormHelpers;
setDefaultDisabled: boolean;
modelConfigFormBuildResult: ModelConfigFormBuildResult;
showCostEstimate: boolean;
setShowCostEstimate: (open: boolean) => void;
showProviderConfig: boolean;
setShowProviderConfig: (open: boolean) => void;
showAdvanced: boolean;
Expand All @@ -124,6 +127,8 @@ export const ModelFormFields: FC<{
displayNameField,
setDefaultDisabled,
modelConfigFormBuildResult,
showCostEstimate,
setShowCostEstimate,
showProviderConfig,
setShowProviderConfig,
showAdvanced,
Expand Down Expand Up @@ -239,6 +244,19 @@ export const ModelFormFields: FC<{
</div>

<div className="overflow-hidden rounded-lg border border-solid border-border">
<CollapsibleSection
title="Cost estimate"
description="Estimated price per million tokens in USD. Prices are read-only."
open={showCostEstimate}
onOpenChange={setShowCostEstimate}
contentClassName="grid grid-cols-2 gap-3 pt-3 pl-6 sm:grid-cols-4"
>
<PricingEstimateFields
provider={selectedProviderType}
model={form.values.model}
/>
</CollapsibleSection>

{hasProviderConfigFields && (
<CollapsibleSection
title="Provider configuration"
Expand Down
Loading
Loading