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
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, screen, spyOn, userEvent, within } from "storybook/test";
import {
expect,
fn,
mocked,
screen,
spyOn,
userEvent,
waitFor,
within,
} from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { API } from "#/api/api";
import { aiModelPrices } from "#/api/queries/aiProviders";
import type * as TypesGen from "#/api/typesGenerated";
import {
MockGPT5BelowThresholdModelPrice,
MockGPT5ModelPrice,
} from "#/testHelpers/chatModels";
import { withDashboardProvider, withToaster } from "#/testHelpers/storybook";
import {
MockAnthropicProviderState,
Expand All @@ -23,6 +36,15 @@ const onUpdateModel = fn(
): Promise<unknown> => undefined,
);

// The price loading placeholder is transient: it disappears once the
// debounce settles and the lookup resolves, so poll for it instead of
// asserting synchronously.
const waitForPriceLoading = async (canvas: ReturnType<typeof within>) => {
await waitFor(() =>
expect(canvas.getByLabelText("Input price loading")).toBeInTheDocument(),
);
};

const meta: Meta<typeof ModelForm> = {
title: "pages/AISettingsPage/ModelsPage/ModelForm",
component: ModelForm,
Expand Down Expand Up @@ -484,18 +506,7 @@ export const CostEstimateFromLivePriceBook: Story = {
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",
},
],
data: [MockGPT5ModelPrice],
},
],
},
Expand All @@ -511,6 +522,123 @@ export const CostEstimateFromLivePriceBook: Story = {
},
};

// A live price below $0.0001 per million tokens would render as $0.00 and
// read as free, so the field shows a threshold instead.
export const CostEstimateBelowThresholdPrice: Story = {
args: {
editingModel: mockGPT5,
onDeleteModel: fn(async () => undefined),
},
parameters: {
queries: [
{
key: aiModelPrices("openai", "gpt-5").queryKey,
data: [MockGPT5BelowThresholdModelPrice],
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: /cost estimate/i }),
);
const inputField = canvas.getByLabelText(/^input$/i);
await expect(inputField).toHaveValue("0.0001");
await expect(canvas.getByText("<$")).toBeInTheDocument();
// The threshold marker is part of the input's accessible description
// so screen readers announce "less than $0.0001" rather than an exact
// price.
await expect(inputField).toHaveAccessibleDescription(
"less than $0.0001 USD per million tokens",
);
},
};

// Editing the model identifier fires a live price lookup per settled value
// rather than per keystroke, so typing a full identifier costs one request.
export const CostEstimateDebouncesLivePriceLookup: Story = {
args: {
editingModel: mockGPT5,
onDeleteModel: fn(async () => undefined),
},
beforeEach: () => {
spyOn(API.experimental, "getAIModelPrices").mockResolvedValue([]);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: /cost estimate/i }),
);
const modelInput = canvas.getByLabelText(/model identifier/i);
await userEvent.clear(modelInput);
await userEvent.type(modelInput, "gpt-5.5");
await waitFor(() =>
expect(API.experimental.getAIModelPrices).toHaveBeenCalledWith({
provider: "openai",
model: "gpt-5.5",
}),
);
const calls = mocked(API.experimental.getAIModelPrices).mock.calls.map(
([params]) => params.model,
);
expect(calls).toStrictEqual(["gpt-5", "gpt-5.5"]);
},
};

// On an entitled form the live lookup may override the catalog, so typing
// into an empty identifier shows the loading placeholder immediately rather
// than briefly flashing catalog prices before the lookup fires.
export const CostEstimateShowsLoadingWhileDebouncePending: Story = {
args: {
selectedProviderState: MockAnthropicProviderState,
},
beforeEach: () => {
spyOn(API.experimental, "getAIModelPrices").mockResolvedValue([]);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: /cost estimate/i }),
);
await userEvent.type(
canvas.getByLabelText(/model identifier/i),
"claude-haiku-4-5",
);
await waitForPriceLoading(canvas);
// The catalog price must not render while the lookup is pending.
expect(canvas.queryByDisplayValue("1")).not.toBeInTheDocument();
},
};

// Clearing the identifier discards the settled live price immediately:
// until the debounce settles the section shows the loading placeholder
// rather than the previous model's prices.
export const CostEstimateClearingModelDiscardsStalePrices: Story = {
args: {
editingModel: mockGPT5,
onDeleteModel: fn(async () => undefined),
},
parameters: {
queries: [
{
key: aiModelPrices("openai", "gpt-5").queryKey,
data: [MockGPT5ModelPrice],
},
],
},
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 userEvent.clear(canvas.getByLabelText(/model identifier/i));
await waitForPriceLoading(canvas);
expect(canvas.queryByDisplayValue("1.25")).not.toBeInTheDocument();
},
};

// 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.
Expand Down Expand Up @@ -602,6 +730,63 @@ export const CostEstimateCatalogOnlyWhenNotEntitled: Story = {
},
};

// Without the entitlement the live price query is disabled, so changing the
// model identifier must not flash loading placeholders: catalog prices are
// synchronously available and update immediately.
export const CostEstimateCatalogNotHiddenByDebounce: Story = {
args: {
editingModel: mockClaude,
selectedProviderState: MockAnthropicProviderState,
onDeleteModel: fn(async () => undefined),
},
parameters: {
features: [],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: /cost estimate/i }),
);
const modelInput = canvas.getByLabelText(/model identifier/i);
await userEvent.clear(modelInput);
await userEvent.type(modelInput, "claude-haiku-4-5");
await expect(canvas.getByLabelText(/^input$/i)).toHaveValue("1");
expect(canvas.queryByLabelText(/price loading/i)).not.toBeInTheDocument();
},
};

// A failed lookup must not linger while the debounce settles for a new
// model: the section shows the loading placeholder until the new request
// has actually fired.
export const CostEstimateErrorClearsWhileDebouncePending: 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.getByText("Couldn't load pricing."),
).toBeInTheDocument();

const modelInput = canvas.getByLabelText(/model identifier/i);
await userEvent.type(modelInput, "-20251001");
await waitForPriceLoading(canvas);
expect(
canvas.queryByText("Couldn't load pricing."),
).not.toBeInTheDocument();
},
};

// 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { useDebouncedValue } from "#/hooks/debounce";
import { normalizeProvider } from "#/modules/aiModels/helpers";
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
import { cn } from "#/utils/cn";
Expand Down Expand Up @@ -683,14 +684,25 @@ export const PricingEstimateFields: FC<{
const aibridgeEntitled = Boolean(useFeatureVisibility().aibridge);
const normalizedProvider = normalizeProvider(provider);
const trimmedModel = model.trim();
// Debounce the pair so the lookup never mixes a new provider with the
// previous model identifier.
const debouncedLookup = useDebouncedValue(
{ provider: normalizedProvider, model: trimmedModel },
500,
);
const entitledWithProvider = aibridgeEntitled && normalizedProvider !== "";
const livePricesQuery = useQuery({
...aiModelPrices(normalizedProvider, trimmedModel),
enabled:
aibridgeEntitled && normalizedProvider !== "" && trimmedModel !== "",
...aiModelPrices(debouncedLookup.provider, debouncedLookup.model),
enabled: entitledWithProvider && debouncedLookup.model !== "",
});
const debouncePending =
entitledWithProvider &&
(debouncedLookup.provider !== normalizedProvider ||
debouncedLookup.model !== trimmedModel);
const livePriceLoading =
livePricesQuery.fetchStatus !== "idle" && !livePricesQuery.isSuccess;
const livePrice = livePricesQuery.data?.[0];
debouncePending ||
(livePricesQuery.fetchStatus !== "idle" && !livePricesQuery.isSuccess);
const livePrice = debouncePending ? undefined : livePricesQuery.data?.[0];
Comment on lines 702 to +705

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Hide errors belonging to the previous model

When the current live-price lookup has failed and the user changes to another nonempty model, debouncePending becomes true but the query remains keyed to the old model for 500 ms. Its isError state therefore wins at the return below, showing "Couldn't load pricing" for the newly typed model even though its request has not started. Suppress the old query's error while the debounce is pending so this interval uses the intended loading state.

AGENTS.md reference: site/AGENTS.md:L16-L17

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ec8aeee. The previous model's error state is now suppressed while the debounce is pending, so changing the identifier after a failure shows the loading placeholder instead of "Couldn't load pricing." until the new request fires. Covered by the CostEstimateErrorClearsWhileDebouncePending story.

Reply generated by Coder Agents on behalf of @DanielleMaywood.


const knownModel =
findKnownModelByCanonicalId(normalizedProvider, trimmedModel) ??
Expand All @@ -709,7 +721,7 @@ export const PricingEstimateFields: FC<{
}
: knownModel;

if (livePricesQuery.isError) {
if (!debouncePending && livePricesQuery.isError) {
return (
<p className="m-0 flex items-center gap-1.5 text-xs text-content-secondary sm:col-span-full">
<InfoIcon className="size-3.5 shrink-0" />
Expand All @@ -735,13 +747,15 @@ export const PricingEstimateFields: FC<{
{priceEstimateFields.map(([label, key]) => {
const cost = costs?.[key];
const fieldId = `${fieldIdPrefix}-${label.toLowerCase().replace(/\s+/g, "-")}`;
const displayValue =
cost === undefined ? "" : formatPricePerMillionTokens(cost).slice(1);
const price =
cost === undefined ? undefined : formatPricePerMillionTokens(cost);
return (
<div key={label} className="flex min-w-0 flex-col gap-1.5">
<FieldLabel htmlFor={fieldId} label={label} />
<InputGroup className="cursor-not-allowed bg-surface-secondary">
<InputGroupAddon align="inline-start">$</InputGroupAddon>
<InputGroupAddon align="inline-start">
{price?.belowThreshold ? "<$" : "$"}
</InputGroupAddon>
Comment on lines +756 to +758

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Expose the threshold marker to assistive technology

For a price below the threshold, <$ is rendered only as a sibling of the read-only input and is not part of that input's accessible name or value. A screen-reader user focusing the field therefore hears an exact value such as Input, 0.0001 rather than “less than $0.0001”; associate the marker with the input, for example through an accessible description, so the estimate retains its meaning.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 449103e. Below-threshold price inputs now carry aria-valuetext="less than $0.0001", so screen readers announce the threshold semantics instead of an exact value. Normal prices rely on the default value announcement (the $ addon is read as part of the group); only the < marker needed the override. Asserted in the CostEstimateBelowThresholdPrice story.

Reply generated by Coder Agents on behalf of @DanielleMaywood.

{livePriceLoading ? (
<Skeleton
aria-label={`${label} price loading`}
Expand All @@ -751,10 +765,18 @@ export const PricingEstimateFields: FC<{
<InputGroupInput
id={fieldId}
className="min-w-0 cursor-not-allowed text-content-secondary"
value={displayValue}
value={price?.value ?? ""}
aria-describedby={
price?.belowThreshold ? `${fieldId}-threshold` : undefined
}
readOnly
/>
)}
{price?.belowThreshold && !livePriceLoading && (
<span id={`${fieldId}-threshold`} className="sr-only">
{`less than $${price.value} USD per million tokens`}
</span>
)}
<InputGroupAddon align="inline-end">
<span className="text-xs text-content-disabled">
USD/1M tokens
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,29 +126,38 @@ describe("findKnownModelByExactAlias", () => {

describe("formatPricePerMillionTokens", () => {
it("formats whole-dollar prices", () => {
expect(formatPricePerMillionTokens(10)).toBe("$10");
expect(formatPricePerMillionTokens(10)).toStrictEqual({
belowThreshold: false,
value: "10",
});
});

it("formats fractional prices without dropping precision", () => {
expect(formatPricePerMillionTokens(1.25)).toBe("$1.25");
expect(formatPricePerMillionTokens(0.1)).toBe("$0.10");
expect(formatPricePerMillionTokens(0.3)).toBe("$0.30");
expect(formatPricePerMillionTokens(1.25).value).toBe("1.25");
expect(formatPricePerMillionTokens(0.1).value).toBe("0.10");
expect(formatPricePerMillionTokens(0.3).value).toBe("0.30");
});

it("keeps sub-cent prices visible", () => {
expect(formatPricePerMillionTokens(0.075)).toBe("$0.075");
expect(formatPricePerMillionTokens(0.003625)).toBe("$0.0036");
expect(formatPricePerMillionTokens(0.125)).toBe("$0.125");
expect(formatPricePerMillionTokens(0.075).value).toBe("0.075");
expect(formatPricePerMillionTokens(0.003625).value).toBe("0.0036");
expect(formatPricePerMillionTokens(0.125).value).toBe("0.125");
});

it("shows a threshold for positive prices below four decimals", () => {
expect(formatPricePerMillionTokens(0.000001)).toBe("<$0.0001");
expect(formatPricePerMillionTokens(0.000049)).toBe("<$0.0001");
expect(formatPricePerMillionTokens(0.0001)).toBe("$0.0001");
it("flags positive prices below four decimals as a threshold", () => {
expect(formatPricePerMillionTokens(0.000001)).toStrictEqual({
belowThreshold: true,
value: "0.0001",
});
expect(formatPricePerMillionTokens(0.000049).belowThreshold).toBe(true);
expect(formatPricePerMillionTokens(0.0001)).toStrictEqual({
belowThreshold: false,
value: "0.0001",
});
});

it("formats zero", () => {
expect(formatPricePerMillionTokens(0)).toBe("$0");
expect(formatPricePerMillionTokens(0).value).toBe("0");
});

it("rejects non-finite values", () => {
Expand Down
Loading
Loading