Compare commits

...

7 Commits

Author SHA1 Message Date
Gaurav Tewari
2c42c13a5c feat: update modal 2026-06-16 01:27:56 +05:30
Gaurav Tewari
9af7fd6170 fix: self review changes 2026-06-15 23:44:51 +05:30
Gaurav Tewari
e49d17861c feat: update e2es 2026-06-15 10:16:13 +05:30
Gaurav Tewari
990a4e63af fix: modal cost drawer 2026-06-15 00:21:17 +05:30
Gaurav Tewari
67f56e0be1 fix: minor fixes 2026-06-14 17:27:43 +05:30
Gaurav Tewari
c9a6b26be0 chore: add more featueres 2026-06-12 11:42:23 +05:30
Gaurav Tewari
1dd887f7fd feat: add config page initial draft 2026-06-11 20:30:32 +05:30
29 changed files with 3480 additions and 2 deletions

View File

@@ -323,3 +323,10 @@ export const AIAssistantPage = Loadable(
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
),
);
export const LLMObservabilityModelPricingPage = Loadable(
() =>
import(
/* webpackChunkName: "LLM Observability Model Pricing Page" */ 'pages/LLMObservabilityModelPricing'
),
);

View File

@@ -22,6 +22,7 @@ import {
IntegrationsDetailsPage,
LicensePage,
ListAllALertsPage,
LLMObservabilityModelPricingPage,
LiveLogs,
Login,
Logs,
@@ -507,6 +508,13 @@ const routes: AppRoutes[] = [
key: 'AI_ASSISTANT',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
exact: true,
component: LLMObservabilityModelPricingPage,
key: 'LLM_OBSERVABILITY_MODEL_PRICING',
isPrivate: true,
},
];
export const SUPPORT_ROUTE: AppRoutes = {

View File

@@ -91,6 +91,7 @@ const ROUTES = {
AI_ASSISTANT_BASE: '/ai-assistant',
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
MCP_SERVER: '/settings/mcp-server',
LLM_OBSERVABILITY_MODEL_PRICING: '/llm-observability/model-pricing',
} as const;
export default ROUTES;

View File

@@ -0,0 +1,172 @@
.llm-observability-model-pricing {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px 32px;
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
&__title {
h1 {
margin: 0;
font-size: 22px;
font-weight: 600;
}
p {
margin: 4px 0 0;
color: var(--bg-vanilla-400);
font-size: 13px;
}
}
&__actions {
display: flex;
gap: 8px;
}
}
.page-tabs {
.ant-tabs-nav {
margin-bottom: 0;
}
}
.filters-bar {
display: flex;
gap: 12px;
align-items: center;
&__search {
flex: 1;
max-width: 360px;
}
&__source,
&__currency {
min-width: 160px;
}
&__add {
margin-left: auto;
}
}
.page-error {
padding: 12px 16px;
border-radius: 4px;
background: rgba(255, 90, 90, 0.08);
color: var(--bg-cherry-400);
font-size: 13px;
}
.page-pagination {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.page-footer {
color: var(--bg-vanilla-400);
font-size: 12px;
}
}
.model-costs-table {
.ant-table-thead > tr > th {
color: var(--bg-vanilla-400) !important;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ant-table-tbody > tr > td {
color: var(--bg-vanilla-400);
}
.model-cell {
display: flex;
flex-direction: column;
gap: 2px;
// Allow the flex children to shrink below their content width so the
// table's fixed-layout / nowrap cells truncate instead of overflowing
// into the Provider column.
min-width: 0;
&__name {
color: var(--bg-vanilla-100);
font-weight: 600;
}
&__name,
&__canonical-id {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__canonical-id {
color: var(--bg-vanilla-400);
font-family: var(--code-font-family, monospace);
font-size: 12px;
}
}
.price-cell {
font-family: var(--code-font-family, monospace);
color: var(--bg-vanilla-400);
}
.extra-buckets {
display: flex;
flex-wrap: wrap;
gap: 6px;
&__chip {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0;
}
&__key {
font-family: var(--code-font-family, monospace);
font-size: 12px;
}
&__price {
font-family: var(--code-font-family, monospace);
font-weight: 600;
}
}
.muted {
color: var(--bg-vanilla-400);
}
.source-badge {
margin: 0;
&--auto {
background: rgba(78, 116, 248, 0.12);
color: var(--bg-robin-400);
border-color: rgba(78, 116, 248, 0.24);
}
&--override {
background: rgba(245, 175, 25, 0.12);
color: var(--bg-amber-400);
border-color: rgba(245, 175, 25, 0.24);
}
}
&__row--selected {
background: rgba(78, 116, 248, 0.06);
}
}

View File

@@ -0,0 +1,179 @@
import { useMemo, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Pagination } from '@signozhq/ui/pagination';
import { SelectSimple } from '@signozhq/ui/select';
import { Tabs } from '@signozhq/ui/tabs';
import { Plus, Search } from '@signozhq/icons';
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import ModelCostDrawer from './ModelCostDrawer';
import ModelCostsTable from './ModelCostsTable';
import { useModelCostDrawer } from './useModelCostDrawer';
import { filterRules, type PricingRule, type SourceFilter } from './utils';
import './LLMObservabilityModelPricing.styles.scss';
const SOURCE_OPTIONS: { value: SourceFilter; label: string }[] = [
{ value: 'all', label: 'Source: All' },
{ value: 'auto', label: 'Auto-populated' },
{ value: 'override', label: 'User override' },
];
const CURRENCY_OPTIONS = [
{ value: 'USD', label: 'USD' },
{ value: 'EUR', label: 'EUR', disabled: true },
{ value: 'INR', label: 'INR', disabled: true },
];
const PAGE_SIZE = 20;
function LLMObservabilityModelPricing(): JSX.Element {
const [search, setSearch] = useState<string>('');
const [source, setSource] = useState<SourceFilter>('all');
const [currency, setCurrency] = useState<string>('USD');
const [page, setPage] = useState<number>(1);
const { data, isLoading, isError } = useListLLMPricingRules({
offset: (page - 1) * PAGE_SIZE,
limit: PAGE_SIZE,
});
const { user } = useAppContext();
const [canManagePricing] = useComponentPermission(
['manage_llm_pricing'],
user.role,
);
const rules: PricingRule[] = useMemo(() => data?.data?.items || [], [data]);
const total = data?.data?.total ?? 0;
const filteredRules = useMemo(
() => filterRules(rules, search, source),
[rules, search, source],
);
const drawer = useModelCostDrawer();
// Search/source filter the current page client-side (the list endpoint only
// supports offset/limit), so reset to the first page when they change.
const resetToFirstPage = (): void => setPage(1);
return (
<div
className="llm-observability-model-pricing"
data-testid="llm-observability-model-pricing-page"
>
<header className="page-header">
<div className="page-header__title">
<h1>Configuration</h1>
<p>Model pricing and cost estimation settings</p>
</div>
</header>
<Tabs
className="page-tabs"
defaultValue="model-costs"
items={[
{ key: 'model-costs', label: 'Model costs', children: null },
{
key: 'unpriced-models',
label: 'Unpriced models',
disabled: true,
children: null,
},
]}
/>
<div className="filters-bar">
<Input
className="filters-bar__search"
placeholder="Search by model or provider…"
prefix={<Search size={14} />}
value={search}
onChange={(event): void => {
setSearch(event.target.value);
resetToFirstPage();
}}
testId="search-input"
/>
<SelectSimple
className="filters-bar__source"
value={source}
onChange={(value): void => {
setSource(value as SourceFilter);
resetToFirstPage();
}}
items={SOURCE_OPTIONS}
testId="source-select"
/>
<SelectSimple
className="filters-bar__currency"
value={currency}
onChange={(value): void => setCurrency(value as string)}
items={CURRENCY_OPTIONS}
testId="currency-select"
/>
{canManagePricing && (
<Button
variant="solid"
color="primary"
className="filters-bar__add"
prefix={<Plus size={14} />}
onClick={(): void => drawer.openForAdd()}
testId="add-model-cost-btn"
>
Add model cost
</Button>
)}
</div>
{isError && (
<div className="page-error" role="alert">
Failed to load pricing rules. Please try again.
</div>
)}
<ModelCostsTable
rules={filteredRules}
isLoading={isLoading}
selectedRuleId={drawer.selectedRuleId}
canManage={canManagePricing}
onEdit={drawer.openForEdit}
/>
{total > PAGE_SIZE && (
<Pagination
className="page-pagination"
total={total}
pageSize={PAGE_SIZE}
current={page}
onPageChange={setPage}
/>
)}
<footer className="page-footer">
Showing {filteredRules.length} of {total} model{total === 1 ? '' : 's'}
{' · '}All prices per 1M tokens (USD)
</footer>
<ModelCostDrawer
isOpen={drawer.isOpen}
mode={drawer.mode}
draft={drawer.draft}
setDraft={drawer.setDraft}
onClose={drawer.close}
onSave={drawer.save}
onDelete={drawer.deleteRule}
isSaving={drawer.isSaving}
isDeleting={drawer.isDeleting}
saveError={drawer.saveError}
canManage={canManagePricing}
/>
</div>
);
}
export default LLMObservabilityModelPricing;

View File

@@ -0,0 +1,313 @@
.model-cost-drawer {
// Uniform horizontal padding across header / body / footer. The header and
// footer read these dialog vars; the body (rendered in drawer-description)
// is set directly below.
--dialog-header-padding: 20px 24px;
--dialog-footer-padding: 16px 24px;
// The drawer body — children render inside [data-slot='drawer-description']
// (this is the @signozhq drawer, not antd, so .ant-drawer-body was a no-op).
[data-slot='drawer-description'] {
display: flex;
flex-direction: column;
gap: 18px;
padding: 20px 24px;
}
[data-slot='select-content'] {
width: var(--radix-select-trigger-width);
}
display: flex;
overflow-y: auto;
&__title {
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
p {
margin: 4px 0 0;
color: var(--bg-vanilla-400);
font-size: 12px;
}
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
// Horizontal padding is provided by the drawer-footer slot var above.
padding: 0;
width: 100%;
&-right {
display: flex;
gap: 8px;
margin-left: auto;
}
}
.drawer-section {
display: flex;
flex-direction: column;
gap: 6px;
label,
.field-label {
font-size: 12px;
font-weight: 600;
color: var(--bg-vanilla-200);
}
.help {
margin: 0;
font-size: 11px;
}
}
.muted {
color: var(--bg-vanilla-400);
}
.required {
color: var(--bg-cherry-400);
}
.full-width {
width: 100%;
}
.drawer-surface {
padding: 14px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--bg-slate-300);
&__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
h4 {
margin: 0;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bg-vanilla-300);
}
}
}
.managed-label {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--bg-vanilla-400);
}
.pattern-box {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--bg-slate-300);
}
.pattern-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 28px;
}
.pattern-chip {
display: inline-flex;
align-items: center;
gap: 4px;
&__remove {
background: transparent;
border: none;
padding: 0;
margin-left: 2px;
cursor: pointer;
color: inherit;
display: inline-flex;
align-items: center;
&:hover {
color: var(--bg-cherry-400);
}
}
}
.pattern-add {
display: flex;
gap: 6px;
}
.help {
code {
padding: 1px 4px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.06);
font-family: var(--code-font-family, monospace);
font-size: 10px;
}
}
.source-radio-group {
// @signozhq/ui's RadioGroupItem defaults its unchecked border to
// --l3-background, which matches the drawer surface and makes the dot
// invisible. Override with a contrasting border so users can see the
// unchecked state.
--radio-group-item-border-color: var(--bg-slate-200);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
// Layout overrides for @signozhq/ui's RadioGroupItem wrapper. The
// library injects single-class CSS at runtime (after our bundled
// stylesheet loads), so we use a two-class selector to win the
// cascade and force the wrapper to lay the dot on the left with the
// label text flush beside it.
.source-radio {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-start;
gap: 10px;
padding: 10px 12px;
border-radius: 4px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.02);
margin: 0;
width: 100%;
// Include padding + border in the 100% width so the card fits inside
// the SOURCE surface instead of overflowing its right edge.
box-sizing: border-box;
cursor: pointer;
transition:
background-color 0.12s ease,
border-color 0.12s ease;
// The radio button itself: keep it fixed-size and aligned with
// the title baseline (margin-top compensates for align-items:
// flex-start vs the title's line-box).
> button[role='radio'] {
flex: 0 0 16px;
width: 16px;
height: 16px;
margin-top: 3px;
}
// The library wraps children in a <label>. Make it grow into the
// remaining width and reset the .drawer-section label typography
// leak (set earlier in this file) so the title/desc divs use
// their own styles.
> label {
flex: 1 1 auto;
min-width: 0;
display: block;
text-align: left;
cursor: pointer;
font-size: inherit;
font-weight: inherit;
color: inherit;
}
&__title {
font-weight: 600;
font-size: 13px;
color: var(--bg-vanilla-100);
}
&__desc {
margin-top: 2px;
font-size: 12px;
color: var(--bg-vanilla-400);
}
// Radix RadioGroupItem renders <button data-state="checked|unchecked">.
// Use :has() to highlight the wrapper card when its inner button is checked.
&.source-radio--auto:has(button[data-state='checked']) {
background: rgba(78, 116, 248, 0.1);
border-color: rgba(78, 116, 248, 0.3);
}
&.source-radio--override:has(button[data-state='checked']) {
background: rgba(245, 175, 25, 0.1);
border-color: rgba(245, 175, 25, 0.3);
}
&:hover {
background: rgba(255, 255, 255, 0.04);
}
}
}
.reset-confirm {
margin-top: 12px;
padding: 12px;
border-radius: 4px;
background: rgba(78, 116, 248, 0.06);
border: 1px solid rgba(78, 116, 248, 0.2);
p {
margin: 0 0 10px;
font-size: 12px;
}
&__actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
}
.pricing-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.pricing-field {
display: flex;
flex-direction: column;
gap: 4px;
input {
width: 100%;
}
}
.cache-mode-field {
margin-top: 10px;
}
.extras-divider {
margin-top: 14px;
margin-bottom: 6px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bg-vanilla-400);
}
.drawer-error {
padding: 10px 12px;
border-radius: 4px;
background: rgba(255, 90, 90, 0.08);
color: var(--bg-cherry-400);
font-size: 12px;
}
}

View File

@@ -0,0 +1,179 @@
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Trash2 } from '@signozhq/icons';
import PatternEditor from './PatternEditor';
import PricingFields from './PricingFields';
import SourceSelector from './SourceSelector';
import {
PROVIDER_OPTIONS,
validateDraft,
type DrawerDraft,
type DrawerMode,
} from './drawerUtils';
import './ModelCostDrawer.styles.scss';
interface ModelCostDrawerProps {
isOpen: boolean;
mode: DrawerMode;
draft: DrawerDraft;
setDraft: (next: DrawerDraft) => void;
onClose: () => void;
onSave: () => void;
onDelete: () => void;
isSaving: boolean;
isDeleting: boolean;
saveError: string | null;
canManage: boolean;
}
function ModelCostDrawer({
isOpen,
mode,
draft,
setDraft,
onClose,
onSave,
onDelete,
isSaving,
isDeleting,
saveError,
canManage,
}: ModelCostDrawerProps): JSX.Element {
// Metadata (model id / provider / patterns / source) is editable by any
// manager. Pricing fields are editable only once the user picks "User
// override" — auto-populated pricing is managed by SigNoz. Write APIs are
// Admin-only, so non-managers can't edit anything.
const metadataReadOnly = !canManage;
const pricingReadOnly = !canManage || !draft.isOverride;
const validation = validateDraft(draft, mode);
const showValidationTooltip =
canManage && !validation.ok && !!validation.message;
const update = (patch: Partial<DrawerDraft>): void => {
setDraft({ ...draft, ...patch });
};
const footer = (
<div className="model-cost-drawer__footer">
{mode === 'edit' && canManage && (
<Button
variant="ghost"
color="destructive"
prefix={<Trash2 size={14} />}
onClick={onDelete}
loading={isDeleting}
testId="drawer-delete-btn"
>
Delete
</Button>
)}
<div className="model-cost-drawer__footer-right">
<Button
variant="outlined"
color="secondary"
onClick={onClose}
testId="drawer-cancel-btn"
>
{canManage ? 'Cancel' : 'Close'}
</Button>
{canManage && (
<TooltipSimple
title={showValidationTooltip ? validation.message : ''}
withPortal={false}
>
{/* span wrapper so the tooltip fires even when the button is disabled */}
<span className="model-cost-drawer__save-wrap">
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={!validation.ok}
testId="drawer-save-btn"
>
Save
</Button>
</span>
</TooltipSimple>
)}
</div>
</div>
);
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
direction="right"
width="base"
className="model-cost-drawer"
footer={footer}
title={mode === 'edit' ? 'Edit model cost' : 'Add model cost'}
subTitle="Pricing computes gen_ai.estimated_total_cost at ingest."
drawerHeaderProps={{ className: 'model-cost-drawer__title' }}
>
<div className="drawer-section">
<label htmlFor="billing-model-id">Billing model ID</label>
<Input
id="billing-model-id"
placeholder="e.g. openai:gpt-4o"
value={draft.modelName}
disabled={mode === 'edit' || metadataReadOnly}
onChange={(e): void => update({ modelName: e.target.value })}
testId="drawer-model-id-input"
/>
</div>
<div className="drawer-section">
<label htmlFor="provider-select">Provider</label>
<SelectSimple
id="provider-select"
value={draft.provider}
onChange={(value): void => update({ provider: value as string })}
items={PROVIDER_OPTIONS}
disabled={mode === 'edit' || metadataReadOnly}
className="full-width"
withPortal={false}
testId="drawer-provider-select"
/>
</div>
<PatternEditor
patterns={draft.patterns}
isReadOnly={metadataReadOnly}
onChange={(patterns): void => update({ patterns })}
/>
<SourceSelector
isOverride={draft.isOverride}
isReadOnly={metadataReadOnly}
onChange={(isOverride): void => update({ isOverride })}
/>
<PricingFields
pricing={draft.pricing}
isReadOnly={pricingReadOnly}
onChange={(patch): void =>
setDraft({ ...draft, pricing: { ...draft.pricing, ...patch } })
}
/>
{saveError && (
<div className="drawer-error" role="alert">
{saveError}
</div>
)}
</DrawerWrapper>
);
}
export default ModelCostDrawer;

View File

@@ -0,0 +1,179 @@
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@signozhq/ui/table';
import { ChevronDown } from '@signozhq/icons';
import cx from 'classnames';
import { startCase } from 'lodash-es';
import {
formatPricePerMillion,
getCanonicalId,
getExtraBuckets,
getRelativeLastSeen,
getSourceLabel,
type PricingRule,
} from './utils';
const COLUMN_COUNT = 8;
interface ModelCostsTableProps {
rules: PricingRule[];
isLoading: boolean;
selectedRuleId: string | null;
canManage: boolean;
onEdit: (rule: PricingRule) => void;
}
interface RowProps {
rule: PricingRule;
isSelected: boolean;
canManage: boolean;
onEdit: (rule: PricingRule) => void;
}
function ModelCostRow({
rule,
isSelected,
canManage,
onEdit,
}: RowProps): JSX.Element {
const buckets = getExtraBuckets(rule);
return (
<TableRow
className={cx({ 'model-costs-table__row--selected': isSelected })}
data-testid={`model-cost-row-${rule.id}`}
>
<TableCell>
<div className="model-cell">
<div
className="model-cell__name"
data-testid={`model-cell-name-${rule.id}`}
>
{rule.modelName}
</div>
<div
className="model-cell__canonical-id"
data-testid={`model-cell-canonical-id-${rule.id}`}
>
{getCanonicalId(rule)}
</div>
</div>
</TableCell>
<TableCell>{rule.provider}</TableCell>
<TableCell>
<span className="price-cell" data-testid={`price-cell-input-${rule.id}`}>
{formatPricePerMillion(rule.pricing?.input)}
</span>
</TableCell>
<TableCell>
<span className="price-cell" data-testid={`price-cell-output-${rule.id}`}>
{formatPricePerMillion(rule.pricing?.output)}
</span>
</TableCell>
<TableCell>
{buckets.length === 0 ? (
<span className="muted"></span>
) : (
<div className="extra-buckets">
{buckets.map((bucket) => (
<Badge
key={bucket.key}
color="vanilla"
variant="outline"
className="extra-buckets__chip"
>
<span className="extra-buckets__key">{startCase(bucket.key)}</span>
<span className="extra-buckets__price">
{formatPricePerMillion(bucket.pricePerMillion)}
</span>
</Badge>
))}
</div>
)}
</TableCell>
<TableCell>
<Badge
color={rule.isOverride ? 'amber' : 'robin'}
variant="outline"
className="source-badge"
data-testid={`source-badge-${rule.id}`}
>
{getSourceLabel(rule)}
</Badge>
</TableCell>
<TableCell>{getRelativeLastSeen(rule)}</TableCell>
<TableCell>
<Button
variant="ghost"
color="secondary"
size="sm"
suffix={<ChevronDown size={14} />}
testId={`edit-rule-${rule.id}`}
onClick={(): void => onEdit(rule)}
>
{canManage ? 'Edit' : 'View'}
</Button>
</TableCell>
</TableRow>
);
}
function ModelCostsTable({
rules,
isLoading,
selectedRuleId,
canManage,
onEdit,
}: ModelCostsTableProps): JSX.Element {
return (
<Table className="model-costs-table" testId="model-costs-table">
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Input / 1M</TableHead>
<TableHead>Output / 1M</TableHead>
<TableHead>Extra buckets</TableHead>
<TableHead>Source</TableHead>
<TableHead>Last seen</TableHead>
<TableHead aria-label="Actions" />
</TableRow>
</TableHeader>
<TableBody>
{isLoading && rules.length === 0 && (
<TableRow>
<TableCell colSpan={COLUMN_COUNT} className="model-costs-table__empty">
Loading pricing rules
</TableCell>
</TableRow>
)}
{!isLoading && rules.length === 0 && (
<TableRow>
<TableCell colSpan={COLUMN_COUNT} className="model-costs-table__empty">
No model costs yet.
</TableCell>
</TableRow>
)}
{rules.map((rule) => (
<ModelCostRow
key={rule.id}
rule={rule}
isSelected={rule.id === selectedRuleId}
canManage={canManage}
onEdit={onEdit}
/>
))}
</TableBody>
</Table>
);
}
export default ModelCostsTable;

View File

@@ -0,0 +1,96 @@
import { useState } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { X } from '@signozhq/icons';
interface PatternEditorProps {
patterns: string[];
isReadOnly: boolean;
onChange: (patterns: string[]) => void;
}
// Model-name prefix patterns as removable chips + an add input.
function PatternEditor({
patterns,
isReadOnly,
onChange,
}: PatternEditorProps): JSX.Element {
const [patternInput, setPatternInput] = useState<string>('');
const addPattern = (): void => {
const next = patternInput.trim();
if (!next || patterns.includes(next)) {
setPatternInput('');
return;
}
onChange([...patterns, next]);
setPatternInput('');
};
const removePattern = (pattern: string): void => {
onChange(patterns.filter((p) => p !== pattern));
};
return (
<div className="drawer-section">
<span className="field-label">
Model name patterns <span className="muted">(prefix match)</span>
</span>
<div className="pattern-box">
<div className="pattern-chips">
{patterns.map((pattern) => (
<Badge
key={pattern}
color="vanilla"
variant="outline"
className="pattern-chip"
>
{pattern}*
{!isReadOnly && (
<button
type="button"
aria-label={`Remove pattern ${pattern}`}
className="pattern-chip__remove"
onClick={(): void => removePattern(pattern)}
>
<X size={10} />
</button>
)}
</Badge>
))}
</div>
{!isReadOnly && (
<div className="pattern-add">
<Input
placeholder="Add pattern…"
value={patternInput}
onChange={(e): void => setPatternInput(e.target.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
e.preventDefault();
addPattern();
}
}}
testId="drawer-pattern-input"
/>
<Button
variant="outlined"
color="secondary"
onClick={addPattern}
testId="drawer-pattern-add-btn"
>
+ Add
</Button>
</div>
)}
</div>
<p className="muted help">
Each pattern uses <strong>prefix matching</strong> against{' '}
<code>gen_ai.request.model</code>.
</p>
</div>
);
}
export default PatternEditor;

View File

@@ -0,0 +1,136 @@
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Lock } from '@signozhq/icons';
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
import { CACHE_MODE_OPTIONS, type DrawerDraft } from './drawerUtils';
type Pricing = DrawerDraft['pricing'];
interface PricingFieldsProps {
pricing: Pricing;
isReadOnly: boolean;
onChange: (patch: Partial<Pricing>) => void;
}
// Parses a number input's raw string. Empty → null (used by optional buckets),
// otherwise a finite number (NaN coerced to 0).
function parseAmount(raw: string): number | null {
if (raw.trim() === '') {
return null;
}
const value = Number(raw);
return Number.isFinite(value) ? value : 0;
}
function PricingFields({
pricing,
isReadOnly,
onChange,
}: PricingFieldsProps): JSX.Element {
const hasCacheBucket =
pricing.cacheRead !== null || pricing.cacheWrite !== null;
return (
<div className="drawer-section drawer-surface">
<div className="drawer-surface__head">
<h4>Pricing (per 1M tokens, USD)</h4>
{isReadOnly && (
<span className="managed-label" data-testid="drawer-readonly-label">
<Lock size={12} />
Read-only
</span>
)}
</div>
<div className="pricing-grid">
<div className="pricing-field">
<label htmlFor="input-cost">
Input cost <span className="required">*</span>
</label>
<Input
id="input-cost"
type="number"
min={0}
step={0.01}
value={pricing.input}
disabled={isReadOnly}
onChange={(e): void =>
onChange({ input: parseAmount(e.target.value) ?? 0 })
}
testId="drawer-input-cost"
/>
</div>
<div className="pricing-field">
<label htmlFor="output-cost">
Output cost <span className="required">*</span>
</label>
<Input
id="output-cost"
type="number"
min={0}
step={0.01}
value={pricing.output}
disabled={isReadOnly}
onChange={(e): void =>
onChange({ output: parseAmount(e.target.value) ?? 0 })
}
testId="drawer-output-cost"
/>
</div>
</div>
<div className="extras-divider">Extra pricing buckets</div>
<div className="pricing-grid">
<div className="pricing-field">
<label htmlFor="cache-read">cache_read</label>
<Input
id="cache-read"
type="number"
min={0}
step={0.01}
value={pricing.cacheRead ?? ''}
placeholder="—"
disabled={isReadOnly}
onChange={(e): void =>
onChange({ cacheRead: parseAmount(e.target.value) })
}
testId="drawer-cache-read-cost"
/>
</div>
<div className="pricing-field">
<label htmlFor="cache-write">cache_write</label>
<Input
id="cache-write"
type="number"
min={0}
step={0.01}
value={pricing.cacheWrite ?? ''}
placeholder="—"
disabled={isReadOnly}
onChange={(e): void =>
onChange({ cacheWrite: parseAmount(e.target.value) })
}
testId="drawer-cache-write-cost"
/>
</div>
</div>
{hasCacheBucket && (
<div className="pricing-field cache-mode-field">
<label htmlFor="cache-mode">Cache mode</label>
<SelectSimple
id="cache-mode"
value={pricing.cacheMode}
items={CACHE_MODE_OPTIONS}
onChange={(v): void => onChange({ cacheMode: v as CacheModeDTO })}
disabled={isReadOnly}
className="full-width"
withPortal={false}
testId="drawer-cache-mode"
/>
</div>
)}
</div>
);
}
export default PricingFields;

View File

@@ -0,0 +1,103 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Lock } from '@signozhq/icons';
interface SourceSelectorProps {
isOverride: boolean;
isReadOnly: boolean;
onChange: (isOverride: boolean) => void;
}
// Auto-populated vs user-override selector, with a confirm step before
// discarding custom values back to defaults.
function SourceSelector({
isOverride,
isReadOnly,
onChange,
}: SourceSelectorProps): JSX.Element {
const [showResetConfirm, setShowResetConfirm] = useState<boolean>(false);
const handleSourceChange = (value: 'auto' | 'override'): void => {
if (value === 'auto' && isOverride) {
setShowResetConfirm(true);
return;
}
if (value === 'override' && !isOverride) {
onChange(true);
}
};
const confirmReset = (): void => {
onChange(false);
setShowResetConfirm(false);
};
return (
<div className="drawer-section drawer-surface">
<div className="drawer-surface__head">
<h4>Source</h4>
{isReadOnly && (
<span className="managed-label" data-testid="drawer-managed-label">
<Lock size={12} />
Managed by SigNoz
</span>
)}
</div>
<RadioGroup
value={isOverride ? 'override' : 'auto'}
onChange={(value): void => handleSourceChange(value as 'auto' | 'override')}
className="source-radio-group"
>
<RadioGroupItem
value="auto"
containerClassName="source-radio source-radio--auto"
testId="drawer-source-auto"
>
<div className="source-radio__title">Auto-populated</div>
<div className="source-radio__desc">Default pricing from SigNoz.</div>
</RadioGroupItem>
<RadioGroupItem
value="override"
containerClassName="source-radio source-radio--override"
testId="drawer-source-override"
>
<div className="source-radio__title">User override</div>
<div className="source-radio__desc">Custom pricing. Takes precedence.</div>
</RadioGroupItem>
</RadioGroup>
{showResetConfirm && (
<div
className="reset-confirm"
role="dialog"
aria-label="Reset to default pricing"
>
<p>
Reset to default pricing? Custom values will be discarded. it might take
24 hours for changes to take effect.
</p>
<div className="reset-confirm__actions">
<Button
variant="outlined"
color="secondary"
onClick={(): void => setShowResetConfirm(false)}
testId="drawer-reset-keep-btn"
>
Keep
</Button>
<Button
variant="solid"
color="primary"
onClick={confirmReset}
testId="drawer-reset-confirm-btn"
>
Reset
</Button>
</div>
</div>
)}
</div>
);
}
export default SourceSelector;

View File

@@ -0,0 +1,149 @@
import {
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
type LlmpricingruletypesLLMPricingRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { fireEvent, render, screen } from 'tests/test-utils';
import LLMObservabilityModelPricing from '../LLMObservabilityModelPricing';
const ENDPOINT = '*/api/v1/llm_pricing_rules';
const mockRules: LlmpricingruletypesLLMPricingRuleDTO[] = [
{
id: 'rule-gpt4o',
orgId: 'org-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
modelPattern: ['gpt-4o'],
isOverride: false,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: { input: 15, output: 60 },
},
{
id: 'rule-llama',
orgId: 'org-1',
modelName: 'llama-3.1-70b',
provider: 'Self-hosted',
modelPattern: ['llama-3.1'],
isOverride: true,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: { input: 0, output: 0 },
},
];
describe('LLMObservabilityModelPricing', () => {
beforeEach(() => {
server.use(
rest.get(ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
items: mockRules,
limit: 100,
offset: 0,
total: mockRules.length,
},
}),
),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('renders the page header and both rules', async () => {
render(<LLMObservabilityModelPricing />);
await screen.findByText('gpt-4o');
expect(screen.getByText('Configuration')).toBeInTheDocument();
expect(screen.getByText('llama-3.1-70b')).toBeInTheDocument();
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument();
});
it('filters rules by the search input', async () => {
render(<LLMObservabilityModelPricing />);
await screen.findByText('gpt-4o');
fireEvent.change(screen.getByTestId('search-input'), {
target: { value: 'llama' },
});
expect(screen.queryByText('gpt-4o')).not.toBeInTheDocument();
expect(screen.getByText('llama-3.1-70b')).toBeInTheDocument();
});
it('opens the drawer in Add mode when the Add button is clicked', async () => {
render(<LLMObservabilityModelPricing />);
await screen.findByText('gpt-4o');
fireEvent.click(screen.getByTestId('add-model-cost-btn'));
const input = (await screen.findByTestId(
'drawer-model-id-input',
)) as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(input.value).toBe('');
});
it('opens the drawer in Edit mode with prefilled values when a row Edit is clicked', async () => {
render(<LLMObservabilityModelPricing />);
await screen.findByText('gpt-4o');
fireEvent.click(screen.getByTestId('edit-rule-rule-gpt4o'));
const input = (await screen.findByTestId(
'drawer-model-id-input',
)) as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(input.value).toBe('gpt-4o');
});
it('hides the Add button for non-admin users (write APIs are Admin-only)', async () => {
render(<LLMObservabilityModelPricing />, {}, { role: 'VIEWER' });
await screen.findByText('gpt-4o');
expect(screen.queryByTestId('add-model-cost-btn')).not.toBeInTheDocument();
// rows still open in read-only "View" mode
expect(screen.getByTestId('edit-rule-rule-gpt4o')).toHaveTextContent('View');
});
it('paginates server-side: selecting page 2 requests the next offset', async () => {
const requestedOffsets: number[] = [];
server.use(
rest.get(ENDPOINT, (req, res, ctx) => {
const offset = Number(req.url.searchParams.get('offset') ?? '0');
requestedOffsets.push(offset);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
items: [
{ ...mockRules[0], id: `rule-${offset}`, modelName: `model-${offset}` },
],
limit: 20,
offset,
total: 25,
},
}),
);
}),
);
render(<LLMObservabilityModelPricing />);
await screen.findByText('model-0');
fireEvent.click(screen.getByRole('button', { name: '2' }));
await screen.findByText('model-20');
expect(requestedOffsets).toContain(20);
});
});

View File

@@ -0,0 +1,201 @@
import { useState } from 'react';
import userEvent from '@testing-library/user-event';
import { fireEvent, render, screen } from 'tests/test-utils';
import { EMPTY_DRAFT, type DrawerDraft } from '../drawerUtils';
import ModelCostDrawer from '../ModelCostDrawer';
interface HarnessProps {
initialDraft?: DrawerDraft;
mode?: 'add' | 'edit';
canManage?: boolean;
onSave?: () => void;
onDelete?: () => void;
}
function Harness({
initialDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
pricing: { ...EMPTY_DRAFT.pricing, input: 1, output: 1 },
},
mode = 'add',
canManage = true,
onSave = jest.fn(),
onDelete = jest.fn(),
}: HarnessProps): JSX.Element {
const [draft, setDraft] = useState<DrawerDraft>(initialDraft);
return (
<ModelCostDrawer
isOpen
mode={mode}
draft={draft}
setDraft={setDraft}
onClose={jest.fn()}
onSave={onSave}
onDelete={onDelete}
isSaving={false}
isDeleting={false}
saveError={null}
canManage={canManage}
/>
);
}
describe('ModelCostDrawer', () => {
it('adds a pattern chip when the user types and presses Enter', () => {
render(<Harness />);
fireEvent.change(screen.getByTestId('drawer-pattern-input'), {
target: { value: 'gpt-4o-mini' },
});
fireEvent.keyDown(screen.getByTestId('drawer-pattern-input'), {
key: 'Enter',
code: 'Enter',
});
expect(screen.getByText('gpt-4o-mini*')).toBeInTheDocument();
});
it('disables pricing fields when isOverride is false', () => {
render(
<Harness
mode="edit"
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
isOverride: false,
}}
/>,
);
expect(screen.getByTestId('drawer-input-cost')).toBeDisabled();
expect(screen.getByTestId('drawer-output-cost')).toBeDisabled();
});
it('enables pricing fields when isOverride is true', () => {
render(
<Harness
mode="edit"
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
isOverride: true,
}}
/>,
);
expect(screen.getByTestId('drawer-input-cost')).not.toBeDisabled();
expect(screen.getByTestId('drawer-output-cost')).not.toBeDisabled();
});
it('disables the Provider select in Edit mode but allows it in Add mode', () => {
const { unmount } = render(<Harness mode="add" />);
expect(screen.getByTestId('drawer-provider-select')).not.toHaveAttribute(
'data-disabled',
);
unmount();
render(
<Harness
mode="edit"
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
isOverride: true,
}}
/>,
);
expect(screen.getByTestId('drawer-provider-select')).toHaveAttribute(
'data-disabled',
);
});
it('keeps metadata editable but locks pricing when source is auto-populated', () => {
render(
<Harness
mode="add"
initialDraft={{ ...EMPTY_DRAFT, modelName: 'gpt-4o', isOverride: false }}
/>,
);
// Metadata stays editable while the rule is auto-populated…
expect(screen.getByTestId('drawer-model-id-input')).not.toBeDisabled();
// …but pricing is read-only until "User override" is chosen.
expect(screen.getByTestId('drawer-input-cost')).toBeDisabled();
});
it('shows a reset confirmation when switching from Override to Auto', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<Harness
initialDraft={{
...EMPTY_DRAFT,
modelName: 'gpt-4o',
isOverride: true,
}}
/>,
);
await user.click(screen.getByTestId('drawer-source-auto'));
expect(screen.getByTestId('drawer-reset-confirm-btn')).toBeInTheDocument();
expect(screen.getByTestId('drawer-reset-keep-btn')).toBeInTheDocument();
});
it('hides the Delete action in Add mode', () => {
render(<Harness mode="add" />);
expect(screen.queryByTestId('drawer-delete-btn')).not.toBeInTheDocument();
});
it('shows the Delete action in Edit mode', () => {
render(
<Harness
mode="edit"
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
isOverride: true,
}}
/>,
);
expect(screen.getByTestId('drawer-delete-btn')).toBeInTheDocument();
});
it('calls onSave when the Save button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onSave = jest.fn();
render(<Harness onSave={onSave} />);
await user.click(screen.getByTestId('drawer-save-btn'));
expect(onSave).toHaveBeenCalledTimes(1);
});
it('is read-only when the user cannot manage pricing (hides Save/Delete)', () => {
render(
<Harness
mode="edit"
canManage={false}
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
isOverride: true,
}}
/>,
);
expect(screen.queryByTestId('drawer-save-btn')).not.toBeInTheDocument();
expect(screen.queryByTestId('drawer-delete-btn')).not.toBeInTheDocument();
expect(screen.getByTestId('drawer-model-id-input')).toBeDisabled();
});
});

View File

@@ -0,0 +1,150 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
buildPricingPayload,
buildRulePayload,
draftFromRule,
EMPTY_DRAFT,
validateDraft,
type DrawerDraft,
} from '../drawerUtils';
import type { PricingRule } from '../utils';
const makeRule = (overrides: Partial<PricingRule> = {}): PricingRule => ({
id: 'rule-1',
orgId: 'org-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
modelPattern: ['gpt-4o'],
isOverride: false,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: { input: 15, output: 60 },
...overrides,
});
describe('drawerUtils', () => {
describe('draftFromRule', () => {
it('maps a rule to a draft with cache values when present', () => {
const rule = makeRule({
pricing: {
input: 3,
output: 15,
cache: {
mode: CacheModeDTO.additive,
read: 0.3,
write: 3.75,
},
},
});
const draft = draftFromRule(rule);
expect(draft.modelName).toBe('gpt-4o');
expect(draft.pricing.input).toBe(3);
expect(draft.pricing.cacheRead).toBe(0.3);
expect(draft.pricing.cacheWrite).toBe(3.75);
expect(draft.pricing.cacheMode).toBe(CacheModeDTO.additive);
});
it('falls back to defaults when cache is missing', () => {
const draft = draftFromRule(makeRule());
expect(draft.pricing.cacheRead).toBeNull();
expect(draft.pricing.cacheWrite).toBeNull();
expect(draft.pricing.cacheMode).toBe(CacheModeDTO.unknown);
});
});
describe('buildPricingPayload', () => {
it('omits the cache block when no cache values are set', () => {
const payload = buildPricingPayload(EMPTY_DRAFT);
expect(payload.cache).toBeUndefined();
});
it('includes only the cache values that are > 0', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
pricing: {
...EMPTY_DRAFT.pricing,
cacheRead: 1.5,
cacheWrite: 0,
cacheMode: CacheModeDTO.subtract,
},
};
const payload = buildPricingPayload(draft);
expect(payload.cache?.read).toBe(1.5);
expect(payload.cache?.write).toBeUndefined();
expect(payload.cache?.mode).toBe(CacheModeDTO.subtract);
});
});
describe('buildRulePayload', () => {
it('uses the modelName as a default pattern when no patterns are supplied', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
patterns: [],
provider: 'OpenAI',
};
const payload = buildRulePayload(draft);
expect(payload.modelPattern).toStrictEqual(['gpt-4o']);
expect(payload.unit).toBe(UnitDTO.per_million_tokens);
expect(payload.enabled).toBe(true);
});
it('omits id and sourceId for an Add draft', () => {
const payload = buildRulePayload(EMPTY_DRAFT);
expect(payload.id).toBeUndefined();
expect(payload.sourceId).toBeUndefined();
});
});
describe('validateDraft', () => {
it('requires a model name in Add mode', () => {
const result = validateDraft(EMPTY_DRAFT, 'add');
expect(result.ok).toBe(false);
expect(result.message).toMatch(/billing model id/i);
});
it('rejects negative pricing', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
pricing: { ...EMPTY_DRAFT.pricing, input: -1 },
};
expect(validateDraft(draft, 'add').ok).toBe(false);
});
it('accepts a valid Add draft', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
pricing: { ...EMPTY_DRAFT.pricing, input: 1, output: 2 },
};
expect(validateDraft(draft, 'add').ok).toBe(true);
});
it('rejects zero input/output cost for overrides', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
isOverride: true,
pricing: { ...EMPTY_DRAFT.pricing, input: 0, output: 5 },
};
const result = validateDraft(draft, 'add');
expect(result.ok).toBe(false);
expect(result.message).toMatch(/input cost must be greater than 0/i);
});
it('skips pricing validation for auto-populated (non-override) rules', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
isOverride: false,
pricing: { ...EMPTY_DRAFT.pricing, input: 0, output: 0 },
};
expect(validateDraft(draft, 'edit').ok).toBe(true);
});
});
});

View File

@@ -0,0 +1,119 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
filterRules,
formatPricePerMillion,
getCanonicalId,
getExtraBuckets,
getRelativeLastSeen,
getSourceLabel,
type PricingRule,
} from '../utils';
const makeRule = (overrides: Partial<PricingRule> = {}): PricingRule => ({
id: 'rule-1',
orgId: 'org-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
modelPattern: ['gpt-4o'],
isOverride: false,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: { input: 15, output: 60 },
...overrides,
});
describe('utils', () => {
describe('formatPricePerMillion', () => {
it('formats numbers with 2 decimals and dollar prefix', () => {
expect(formatPricePerMillion(15)).toBe('$15.00');
expect(formatPricePerMillion(0.15)).toBe('$0.15');
});
it('returns em-dash for nullish or NaN', () => {
expect(formatPricePerMillion(undefined)).toBe('—');
expect(formatPricePerMillion(Number.NaN)).toBe('—');
});
});
describe('getExtraBuckets', () => {
it('returns an empty array when there is no cache pricing', () => {
expect(getExtraBuckets(makeRule())).toStrictEqual([]);
});
it('returns only buckets with values > 0', () => {
const rule = makeRule({
pricing: {
input: 3,
output: 15,
cache: {
mode: CacheModeDTO.additive,
read: 0.3,
write: 0,
},
},
});
const buckets = getExtraBuckets(rule);
expect(buckets).toStrictEqual([{ key: 'cache_read', pricePerMillion: 0.3 }]);
});
});
describe('getSourceLabel', () => {
it('returns "Auto" for non-override and "User override" otherwise', () => {
expect(getSourceLabel(makeRule({ isOverride: false }))).toBe('Auto');
expect(getSourceLabel(makeRule({ isOverride: true }))).toBe('User override');
});
});
describe('getCanonicalId', () => {
it('lowercases the provider and joins with the model name', () => {
expect(getCanonicalId(makeRule({ provider: 'OpenAI' }))).toBe(
'openai:gpt-4o',
);
});
});
describe('getRelativeLastSeen', () => {
it('returns em-dash when no timestamp is present', () => {
expect(getRelativeLastSeen(makeRule())).toBe('—');
});
it('formats minutes-old timestamps', () => {
const recent = new Date(Date.now() - 5 * 60 * 1000).toISOString();
expect(getRelativeLastSeen(makeRule({ updatedAt: recent }))).toMatch(
/min ago/,
);
});
});
describe('filterRules', () => {
const auto = makeRule({ id: 'r1', modelName: 'gpt-4o', isOverride: false });
const override = makeRule({
id: 'r2',
modelName: 'llama-3',
provider: 'Self-hosted',
modelPattern: ['llama-3'],
isOverride: true,
});
it('returns everything when no filters are applied', () => {
expect(filterRules([auto, override], '', 'all')).toHaveLength(2);
});
it('narrows by source = override', () => {
expect(filterRules([auto, override], '', 'override')).toStrictEqual([
override,
]);
});
it('narrows by free-text search across model and provider', () => {
expect(filterRules([auto, override], 'self', 'all')).toStrictEqual([
override,
]);
expect(filterRules([auto, override], 'gpt-4', 'all')).toStrictEqual([auto]);
});
});
});

View File

@@ -0,0 +1,160 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
type LlmpricingruletypesLLMPricingCacheCostsDTO,
type LlmpricingruletypesLLMRulePricingDTO,
type LlmpricingruletypesUpdatableLLMPricingRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PricingRule } from './utils';
export const PROVIDER_OPTIONS = [
{ value: 'OpenAI', label: 'OpenAI' },
{ value: 'Anthropic', label: 'Anthropic' },
{ value: 'Azure OpenAI', label: 'Azure OpenAI' },
{ value: 'Google', label: 'Google' },
{ value: 'Self-hosted', label: 'Self-hosted' },
{ value: 'Other', label: 'Other' },
];
export const CACHE_MODE_OPTIONS = [
{
value: CacheModeDTO.subtract,
label: 'Subtract (OpenAI style)',
},
{
value: CacheModeDTO.additive,
label: 'Additive (Anthropic style)',
},
{
value: CacheModeDTO.unknown,
label: 'Unknown',
},
];
export type DrawerMode = 'add' | 'edit';
export interface DrawerDraft {
id: string | null;
sourceId: string | null;
modelName: string;
provider: string;
patterns: string[];
isOverride: boolean;
pricing: {
input: number;
output: number;
cacheMode: CacheModeDTO;
cacheRead: number | null;
cacheWrite: number | null;
};
}
export const EMPTY_DRAFT: DrawerDraft = {
id: null,
sourceId: null,
modelName: '',
provider: 'OpenAI',
patterns: [],
isOverride: true,
pricing: {
input: 0,
output: 0,
cacheMode: CacheModeDTO.unknown,
cacheRead: null,
cacheWrite: null,
},
};
export const draftFromRule = (rule: PricingRule): DrawerDraft => ({
id: rule.id,
sourceId: rule.sourceId ?? null,
modelName: rule.modelName,
provider: rule.provider || 'OpenAI',
patterns: rule.modelPattern || [],
isOverride: !!rule.isOverride,
pricing: {
input: rule.pricing?.input ?? 0,
output: rule.pricing?.output ?? 0,
cacheMode: rule.pricing?.cache?.mode ?? CacheModeDTO.unknown,
cacheRead: rule.pricing?.cache?.read ?? null,
cacheWrite: rule.pricing?.cache?.write ?? null,
},
});
const hasCacheValue = (value: number | null): boolean =>
typeof value === 'number' && value > 0;
export const buildPricingPayload = (
draft: DrawerDraft,
): LlmpricingruletypesLLMRulePricingDTO => {
const pricing: LlmpricingruletypesLLMRulePricingDTO = {
input: draft.pricing.input,
output: draft.pricing.output,
};
if (
hasCacheValue(draft.pricing.cacheRead) ||
hasCacheValue(draft.pricing.cacheWrite)
) {
const cache: LlmpricingruletypesLLMPricingCacheCostsDTO = {
mode: draft.pricing.cacheMode,
};
if (hasCacheValue(draft.pricing.cacheRead)) {
cache.read = draft.pricing.cacheRead as number;
}
if (hasCacheValue(draft.pricing.cacheWrite)) {
cache.write = draft.pricing.cacheWrite as number;
}
pricing.cache = cache;
}
return pricing;
};
export const buildRulePayload = (
draft: DrawerDraft,
): LlmpricingruletypesUpdatableLLMPricingRuleDTO => ({
id: draft.id || undefined,
sourceId: draft.sourceId || undefined,
modelName: draft.modelName.trim(),
provider: draft.provider.trim(),
modelPattern:
draft.patterns.length > 0 ? draft.patterns : [draft.modelName.trim()],
isOverride: draft.isOverride,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: buildPricingPayload(draft),
});
export interface ValidationResult {
ok: boolean;
message?: string;
}
export const validateDraft = (
draft: DrawerDraft,
mode: DrawerMode,
): ValidationResult => {
if (mode === 'add' && !draft.modelName.trim()) {
return { ok: false, message: 'Billing model ID is required.' };
}
if (!draft.provider.trim()) {
return { ok: false, message: 'Provider is required.' };
}
// Pricing is only user-entered for overrides; auto-populated rules are
// managed by SigNoz (and may legitimately be 0 for self-hosted models).
if (draft.isOverride) {
if (!(draft.pricing.input > 0)) {
return { ok: false, message: 'Input cost must be greater than 0.' };
}
if (!(draft.pricing.output > 0)) {
return { ok: false, message: 'Output cost must be greater than 0.' };
}
if (
(draft.pricing.cacheRead !== null && draft.pricing.cacheRead < 0) ||
(draft.pricing.cacheWrite !== null && draft.pricing.cacheWrite < 0)
) {
return { ok: false, message: 'Cache costs must be non-negative.' };
}
}
return { ok: true };
};

View File

@@ -0,0 +1,128 @@
import { useCallback, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import {
getListLLMPricingRulesQueryKey,
useCreateOrUpdateLLMPricingRules,
useDeleteLLMPricingRule,
} from 'api/generated/services/llmpricingrules';
import {
buildRulePayload,
draftFromRule,
EMPTY_DRAFT,
type DrawerDraft,
type DrawerMode,
} from './drawerUtils';
import type { PricingRule } from './utils';
interface UseModelCostDrawerResult {
isOpen: boolean;
mode: DrawerMode;
draft: DrawerDraft;
setDraft: (next: DrawerDraft) => void;
openForAdd: (prefillModelName?: string) => void;
openForEdit: (rule: PricingRule) => void;
close: () => void;
save: () => Promise<void>;
deleteRule: () => Promise<void>;
isSaving: boolean;
isDeleting: boolean;
saveError: string | null;
selectedRuleId: string | null;
}
export function useModelCostDrawer(): UseModelCostDrawerResult {
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState<boolean>(false);
const [mode, setMode] = useState<DrawerMode>('add');
const [draft, setDraft] = useState<DrawerDraft>(EMPTY_DRAFT);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const { mutateAsync: createOrUpdate, isLoading: isSaving } =
useCreateOrUpdateLLMPricingRules();
const { mutateAsync: deleteRuleApi, isLoading: isDeleting } =
useDeleteLLMPricingRule();
const invalidateList = useCallback(async (): Promise<void> => {
await queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
});
}, [queryClient]);
const openForAdd = useCallback((prefillModelName?: string): void => {
setMode('add');
setDraft({
...EMPTY_DRAFT,
modelName: prefillModelName || '',
patterns: prefillModelName ? [prefillModelName] : [],
});
setSelectedRuleId(null);
setSaveError(null);
setIsOpen(true);
}, []);
const openForEdit = useCallback((rule: PricingRule): void => {
setMode('edit');
setDraft(draftFromRule(rule));
setSelectedRuleId(rule.id);
setSaveError(null);
setIsOpen(true);
}, []);
const close = useCallback((): void => {
setIsOpen(false);
setSelectedRuleId(null);
setSaveError(null);
}, []);
const save = useCallback(async (): Promise<void> => {
setSaveError(null);
try {
await createOrUpdate({
data: { rules: [buildRulePayload(draft)] },
});
await invalidateList();
setIsOpen(false);
setSelectedRuleId(null);
toast.success(mode === 'edit' ? 'Model cost updated' : 'Model cost added');
} catch (error) {
const message = error instanceof Error ? error.message : 'Save failed';
setSaveError(message);
}
}, [createOrUpdate, draft, invalidateList, mode]);
const deleteRule = useCallback(async (): Promise<void> => {
if (!draft.id) {
return;
}
setSaveError(null);
try {
await deleteRuleApi({ pathParams: { id: draft.id } });
await invalidateList();
setIsOpen(false);
setSelectedRuleId(null);
toast.success('Model cost deleted');
} catch (error) {
const message = error instanceof Error ? error.message : 'Delete failed';
setSaveError(message);
}
}, [deleteRuleApi, draft.id, invalidateList]);
return {
isOpen,
mode,
draft,
setDraft,
openForAdd,
openForEdit,
close,
save,
deleteRule,
isSaving,
isDeleting,
saveError,
selectedRuleId,
};
}

View File

@@ -0,0 +1,101 @@
import type { LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
export type PricingRule = LlmpricingruletypesLLMPricingRuleDTO;
export type SourceFilter = 'all' | 'auto' | 'override';
export interface ExtraBucket {
key: string;
pricePerMillion: number;
}
export const formatPricePerMillion = (value: number | undefined): string => {
if (value === undefined || value === null || Number.isNaN(value)) {
return '—';
}
return `$${value.toFixed(2)}`;
};
export const getExtraBuckets = (rule: PricingRule): ExtraBucket[] => {
const cache = rule.pricing?.cache;
if (!cache) {
return [];
}
const buckets: ExtraBucket[] = [];
if (typeof cache.read === 'number' && cache.read > 0) {
buckets.push({ key: 'cache_read', pricePerMillion: cache.read });
}
if (typeof cache.write === 'number' && cache.write > 0) {
buckets.push({ key: 'cache_write', pricePerMillion: cache.write });
}
return buckets;
};
export const getSourceLabel = (rule: PricingRule): 'Auto' | 'User override' =>
rule.isOverride ? 'User override' : 'Auto';
const MINUTE = 60;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const MONTH = 30 * DAY;
const YEAR = 365 * DAY;
export const getRelativeLastSeen = (rule: PricingRule): string => {
const ts = rule.updatedAt || rule.syncedAt || rule.createdAt;
if (!ts) {
return '—';
}
const now = Date.now();
const target = new Date(ts).getTime();
if (Number.isNaN(target)) {
return '—';
}
const seconds = Math.max(0, Math.round((now - target) / 1000));
if (seconds < MINUTE) {
return 'just now';
}
if (seconds < HOUR) {
return `${Math.floor(seconds / MINUTE)} min ago`;
}
if (seconds < DAY) {
return `${Math.floor(seconds / HOUR)} hr ago`;
}
if (seconds < MONTH) {
return `${Math.floor(seconds / DAY)} days ago`;
}
if (seconds < YEAR) {
return `${Math.floor(seconds / MONTH)} mo ago`;
}
return `${Math.floor(seconds / YEAR)} yr ago`;
};
const lc = (value: string): string => value.toLowerCase();
export const filterRules = (
rules: PricingRule[],
search: string,
source: SourceFilter,
): PricingRule[] => {
const normalized = lc(search.trim());
return rules.filter((rule) => {
if (source === 'auto' && rule.isOverride) {
return false;
}
if (source === 'override' && !rule.isOverride) {
return false;
}
if (!normalized) {
return true;
}
return (
lc(rule.modelName).includes(normalized) ||
lc(rule.provider).includes(normalized) ||
(rule.modelPattern || []).some((pattern) => lc(pattern).includes(normalized))
);
});
};
export const getCanonicalId = (rule: PricingRule): string => {
const provider = rule.provider?.trim() || 'unknown';
return `${lc(provider)}:${rule.modelName}`;
};

View File

@@ -11,6 +11,7 @@ import {
Building2,
ChartArea,
Cloudy,
Coins,
DraftingCompass,
FileKey2,
Github,
@@ -365,6 +366,13 @@ export const settingsNavSections: SettingsNavSection[] = [
isEnabled: false,
itemKey: 'mcp-server',
},
{
key: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
label: 'Model Pricing',
icon: <Coins size={16} />,
isEnabled: true,
itemKey: 'llm-observability-model-pricing',
},
],
},

View File

@@ -206,6 +206,7 @@ export const routesToSkip = [
ROUTES.METER,
ROUTES.METER_EXPLORER_VIEWS,
ROUTES.SOMETHING_WENT_WRONG,
ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
];
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];

View File

@@ -0,0 +1,7 @@
import LLMObservabilityModelPricing from 'container/LLMObservabilityModelPricing/LLMObservabilityModelPricing';
function LLMObservabilityModelPricingPage(): JSX.Element {
return <LLMObservabilityModelPricing />;
}
export default LLMObservabilityModelPricingPage;

View File

@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { render, RenderOptions, RenderResult } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { FeatureKeys } from 'constants/features';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import { ResourceProvider } from 'hooks/useResourceAttribute';
@@ -301,7 +302,7 @@ export function AllTheProviders({
<ErrorModalProvider>
<TimezoneProvider>
<PreferenceContextProvider>
{queryBuilderContent}
<TooltipProvider>{queryBuilderContent}</TooltipProvider>
</PreferenceContextProvider>
</TimezoneProvider>
</ErrorModalProvider>

View File

@@ -20,7 +20,8 @@ export type ComponentTypes =
| 'add_panel'
| 'page_pipelines'
| 'edit_locked_dashboard'
| 'add_panel_locked_dashboard';
| 'add_panel_locked_dashboard'
| 'manage_llm_pricing';
export const componentPermission: Record<ComponentTypes, ROLES[]> = {
current_org_settings: ['ADMIN'],
@@ -42,6 +43,7 @@ export const componentPermission: Record<ComponentTypes, ROLES[]> = {
page_pipelines: ['ADMIN', 'EDITOR'],
edit_locked_dashboard: ['ADMIN', 'AUTHOR'],
add_panel_locked_dashboard: ['ADMIN', 'AUTHOR'],
manage_llm_pricing: ['ADMIN', 'EDITOR', 'AUTHOR'],
};
export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
@@ -136,4 +138,5 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
AI_ASSISTANT_ICON_PREVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
MCP_SERVER: ['ADMIN', 'EDITOR', 'VIEWER'],
AI_ASSISTANT_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
LLM_OBSERVABILITY_MODEL_PRICING: ['ADMIN', 'EDITOR', 'VIEWER'],
};

View File

@@ -0,0 +1,119 @@
import type { APIRequestContext, Page } from '@playwright/test';
export const LLM_PRICING_PATH = '/llm-observability/model-pricing';
// ─── Auth ────────────────────────────────────────────────────────────────────
/**
* Read the JWT the auth fixture stored in `localStorage.AUTH_TOKEN`. The
* page must be on the SigNoz origin first; if not, this navigates to the
* pricing page to populate localStorage from the context's storageState.
*/
export async function authToken(page: Page): Promise<string> {
if (!page.url().startsWith('http')) {
await page.goto(LLM_PRICING_PATH);
}
return page.evaluate(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
);
}
// ─── Navigation ──────────────────────────────────────────────────────────────
/**
* Navigate to the LLM pricing page and wait for the root container.
*/
export async function gotoLlmPricingPage(page: Page): Promise<void> {
await page.goto(LLM_PRICING_PATH);
await page
.getByTestId('llm-observability-model-pricing-page')
.waitFor({ state: 'visible' });
}
// ─── API helpers ─────────────────────────────────────────────────────────────
interface SeedOptions {
modelName?: string;
provider?: string;
isOverride?: boolean;
inputCost?: number;
outputCost?: number;
}
/**
* Seed one pricing rule via `PUT /api/v1/llm_pricing_rules` and return its id.
* The PUT response is void (HTTP 200, no body), so a follow-up GET by modelName
* is required to obtain the id.
*/
export async function createPricingRuleViaApi(
page: Page,
overrides: SeedOptions = {},
): Promise<string> {
const {
modelName = 'e2e-test-model',
provider = 'OpenAI',
isOverride = true,
inputCost = 10.0,
outputCost = 30.0,
} = overrides;
const token = await authToken(page);
const putRes = await page.request.put('/api/v1/llm_pricing_rules', {
data: {
rules: [
{
modelName,
provider,
modelPattern: [modelName],
isOverride,
enabled: true,
unit: 'per_million_tokens',
pricing: { input: inputCost, output: outputCost },
},
],
},
headers: { Authorization: `Bearer ${token}` },
});
if (!putRes.ok()) {
throw new Error(
`PUT /api/v1/llm_pricing_rules ${putRes.status()}: ${await putRes.text()}`,
);
}
// PUT returns void — fetch the list to find the newly-created rule's id.
const getRes = await page.request.get('/api/v1/llm_pricing_rules?offset=0&limit=200', {
headers: { Authorization: `Bearer ${token}` },
});
if (!getRes.ok()) {
throw new Error(
`GET /api/v1/llm_pricing_rules ${getRes.status()}: ${await getRes.text()}`,
);
}
const body = (await getRes.json()) as {
data: { items: Array<{ id: string; modelName: string }> };
};
const rule = body.data.items.find((r) => r.modelName === modelName);
if (!rule) {
throw new Error(`Could not find seeded rule "${modelName}" in GET response`);
}
return rule.id;
}
/**
* Best-effort delete via API. Errors are swallowed so suite-level cleanup
* stays resilient when a UI flow already deleted the resource (404) or the
* stack is mid-shutdown.
*/
export async function deletePricingRuleViaApi(
request: APIRequestContext,
id: string,
token: string,
): Promise<void> {
await request
.delete(`/api/v1/llm_pricing_rules/${id}`, {
headers: { Authorization: `Bearer ${token}` },
})
.catch(() => undefined);
}

View File

@@ -0,0 +1,123 @@
import { expect, test } from '../../fixtures/auth';
import {
authToken,
createPricingRuleViaApi,
deletePricingRuleViaApi,
gotoLlmPricingPage,
} from '../../helpers/llm-pricing';
test.describe.configure({ mode: 'serial' });
test.describe('LLM Pricing — Delete rule', () => {
test('TC-20 Delete button removes the rule from the list', async ({
authedPage: page,
}) => {
const ruleId = await createPricingRuleViaApi(page, {
modelName: 'e2e-delete-target',
provider: 'OpenAI',
isOverride: true,
inputCost: 5.0,
outputCost: 15.0,
});
await gotoLlmPricingPage(page);
await page.getByTestId(`edit-rule-${ruleId}`).click();
await expect(page.getByText('Edit model cost').first()).toBeVisible();
const deleteResponse = page.waitForResponse(
(r) =>
r.url().includes('llm_pricing_rules') && r.request().method() === 'DELETE',
);
const listRefresh = page.waitForResponse(
(r) =>
r.url().includes('llm_pricing_rules') && r.request().method() === 'GET',
);
await page.getByTestId('drawer-delete-btn').click();
const del = await deleteResponse;
// DELETE returns 200 or 204
expect([200, 204]).toContain(del.status());
await listRefresh;
await expect(page.getByText('Edit model cost').first()).not.toBeVisible();
await expect(page.getByTestId(`model-cell-name-${ruleId}`)).not.toBeVisible();
// Rule was deleted by the UI; best-effort API call swallows any 404
const token = await authToken(page);
await deletePricingRuleViaApi(page.request, ruleId, token);
});
test('TC-21 delete error is surfaced in the drawer', async ({
authedPage: page,
}) => {
const ruleId = await createPricingRuleViaApi(page, {
modelName: 'e2e-delete-error',
provider: 'OpenAI',
isOverride: true,
inputCost: 3.0,
outputCost: 9.0,
});
try {
await gotoLlmPricingPage(page);
await page.getByTestId(`edit-rule-${ruleId}`).click();
await expect(page.getByText('Edit model cost').first()).toBeVisible();
// Intercept the DELETE and return 500
await page.route(`**/api/v1/llm_pricing_rules/${ruleId}`, async (route) => {
if (route.request().method() === 'DELETE') {
await route.fulfill({ status: 500, body: '{"message":"internal error"}' });
} else {
await route.continue();
}
});
await page.getByTestId('drawer-delete-btn').click();
// Error alert must appear inside the open drawer
await expect(page.locator('[role="alert"].drawer-error')).toBeVisible();
// Drawer stays open
await expect(page.getByText('Edit model cost').first()).toBeVisible();
// Remove the route intercept so cleanup can proceed
await page.unrouteAll();
} finally {
await page.getByTestId('drawer-cancel-btn').click().catch(() => undefined);
const token = await authToken(page);
await deletePricingRuleViaApi(page.request, ruleId, token);
}
});
test('TC-22 save error is surfaced in the drawer', async ({
authedPage: page,
}) => {
await gotoLlmPricingPage(page);
await page.getByTestId('add-model-cost-btn').click();
await expect(page.getByText('Add model cost').first()).toBeVisible();
await page.getByTestId('drawer-model-id-input').fill('e2e-err-test');
// Intercept PUT and return 422
await page.route('**/api/v1/llm_pricing_rules', async (route) => {
if (route.request().method() === 'PUT') {
await route.fulfill({
status: 422,
contentType: 'application/json',
body: JSON.stringify({ message: 'duplicate key' }),
});
} else {
await route.continue();
}
});
await page.getByTestId('drawer-save-btn').click();
// Error alert appears inside the drawer
await expect(page.locator('[role="alert"].drawer-error')).toBeVisible();
// Drawer remains open
await expect(page.getByText('Add model cost').first()).toBeVisible();
await page.unrouteAll();
await page.getByTestId('drawer-cancel-btn').click();
});
});

View File

@@ -0,0 +1,232 @@
import { expect, test } from '../../fixtures/auth';
import {
authToken,
deletePricingRuleViaApi,
gotoLlmPricingPage,
} from '../../helpers/llm-pricing';
test.describe.configure({ mode: 'serial' });
test.describe('LLM Pricing — Add drawer', () => {
test('TC-06 opening the drawer shows correct default state', async ({
authedPage: page,
}) => {
await gotoLlmPricingPage(page);
await page.getByTestId('add-model-cost-btn').click();
// Drawer must be visible before asserting field values
await expect(page.getByText('Add model cost').first()).toBeVisible();
await expect(page.getByTestId('drawer-model-id-input')).toBeVisible();
// Model ID field is empty by default
await expect(page.getByTestId('drawer-model-id-input')).toHaveValue('');
// Provider defaults to OpenAI
await expect(page.getByTestId('drawer-provider-select')).toContainText('OpenAI');
// Source radio: Override is checked, Auto is unchecked
await expect(page.getByTestId('drawer-source-override')).toHaveAttribute(
'data-state',
'checked',
);
await expect(page.getByTestId('drawer-source-auto')).toHaveAttribute(
'data-state',
'unchecked',
);
// Pricing defaults to 0 (Ant InputNumber wraps a real <input>)
await expect(
page.locator('[data-testid="drawer-input-cost"] input'),
).toHaveValue('0');
await expect(
page.locator('[data-testid="drawer-output-cost"] input'),
).toHaveValue('0');
// Cache mode is hidden when no cache bucket value
await expect(page.getByTestId('drawer-cache-mode')).not.toBeVisible();
// Save is disabled (empty model name); Delete is absent in add mode
await expect(page.getByTestId('drawer-save-btn')).toBeDisabled();
await expect(page.getByTestId('drawer-cancel-btn')).toBeVisible();
await expect(page.getByTestId('drawer-delete-btn')).not.toBeVisible();
await page.getByTestId('drawer-cancel-btn').click();
await expect(page.getByText('Add model cost').first()).not.toBeVisible();
});
test('TC-07 Save button tooltip shows validation message when model name is empty', async ({
authedPage: page,
}) => {
await gotoLlmPricingPage(page);
await page.getByTestId('add-model-cost-btn').click();
await expect(page.getByText('Add model cost').first()).toBeVisible();
// Save is disabled; hover triggers tooltip
const saveBtn = page.getByTestId('drawer-save-btn');
await expect(saveBtn).toBeDisabled();
await saveBtn.hover();
await expect(page.getByText('Billing model ID is required.')).toBeVisible();
await page.getByTestId('drawer-cancel-btn').click();
});
test('TC-08 adding a pattern chip via Add button and Enter key', async ({
authedPage: page,
}) => {
await gotoLlmPricingPage(page);
await page.getByTestId('add-model-cost-btn').click();
await expect(page.getByText('Add model cost').first()).toBeVisible();
await page.getByTestId('drawer-model-id-input').fill('e2e-add-test');
// Add via button
await page.getByTestId('drawer-pattern-input').fill('e2e-add');
await page.getByTestId('drawer-pattern-add-btn').click();
await expect(page.getByText('e2e-add*')).toBeVisible();
await expect(page.getByTestId('drawer-pattern-input')).toHaveValue('');
// Add via Enter key
await page.getByTestId('drawer-pattern-input').fill('e2e-add-v2');
await page.getByTestId('drawer-pattern-input').press('Enter');
await expect(page.getByText('e2e-add-v2*')).toBeVisible();
// Deduplication: adding e2e-add again produces no second chip
await page.getByTestId('drawer-pattern-input').fill('e2e-add');
await page.getByTestId('drawer-pattern-add-btn').click();
const chips = page.locator('.pattern-chip');
await expect(chips).toHaveCount(2);
// Remove first chip
await page.getByRole('button', { name: 'Remove pattern e2e-add' }).click();
await expect(page.getByText('e2e-add*')).not.toBeVisible();
await expect(page.getByText('e2e-add-v2*')).toBeVisible();
await page.getByTestId('drawer-cancel-btn').click();
});
test('TC-09 save a new user-override rule end-to-end', async ({
authedPage: page,
}) => {
await gotoLlmPricingPage(page);
await page.getByTestId('add-model-cost-btn').click();
await expect(page.getByText('Add model cost').first()).toBeVisible();
await page.getByTestId('drawer-model-id-input').fill('e2e-save-model');
// Select Anthropic provider
await page.getByTestId('drawer-provider-select').click();
await page.getByText('Anthropic').click();
// Add pattern
await page.getByTestId('drawer-pattern-input').fill('e2e-save');
await page.getByTestId('drawer-pattern-add-btn').click();
// Override radio is the default
await expect(page.getByTestId('drawer-source-override')).toHaveAttribute(
'data-state',
'checked',
);
// Set pricing
await page.locator('[data-testid="drawer-input-cost"] input').fill('5.50');
await page.locator('[data-testid="drawer-output-cost"] input').fill('17.00');
// Wait for PUT before clicking Save to avoid a race
const putResponse = page.waitForResponse(
(r) =>
r.url().includes('llm_pricing_rules') && r.request().method() === 'PUT',
);
const listRefresh = page.waitForResponse(
(r) =>
r.url().includes('llm_pricing_rules') && r.request().method() === 'GET',
);
await page.getByTestId('drawer-save-btn').click();
const put = await putResponse;
expect(put.status()).toBe(200);
await listRefresh;
// Drawer closes
await expect(page.getByText('Add model cost').first()).not.toBeVisible();
// Row appears in table — locate by model name text since we don't know the id yet
await expect(page.getByText('e2e-save-model')).toBeVisible();
// Source badge says "User override"
await expect(page.getByText('User override').first()).toBeVisible();
// Cleanup: resolve the rule id and delete via API (deletePricingRuleViaApi swallows errors)
const token = await authToken(page);
const getRes = await page.request.get(
'/api/v1/llm_pricing_rules?offset=0&limit=200',
{ headers: { Authorization: `Bearer ${token}` } },
);
const body = (await getRes.json()) as {
data: { items: Array<{ id: string; modelName: string }> };
};
const createdId = body.data.items.find((r) => r.modelName === 'e2e-save-model')?.id ?? '';
await deletePricingRuleViaApi(page.request, createdId, token);
});
test('TC-10 cache-mode select appears only when cache bucket has a value', async ({
authedPage: page,
}) => {
await gotoLlmPricingPage(page);
await page.getByTestId('add-model-cost-btn').click();
await expect(page.getByText('Add model cost').first()).toBeVisible();
await page.getByTestId('drawer-model-id-input').fill('e2e-cache-test');
// Cache mode is hidden before any cache bucket value is set
await expect(page.getByTestId('drawer-cache-mode')).not.toBeVisible();
// Set cache_read → cache-mode select must appear
await page.locator('[data-testid="drawer-cache-read-cost"] input').fill('0.30');
await page.locator('[data-testid="drawer-cache-read-cost"] input').press('Tab');
await expect(page.getByTestId('drawer-cache-mode')).toBeVisible();
// Cache mode defaults to "Unknown"
await expect(page.getByTestId('drawer-cache-mode')).toContainText('Unknown');
// Change to Subtract
await page.getByTestId('drawer-cache-mode').click();
await page.getByText('Subtract (OpenAI style)').click();
await expect(page.getByTestId('drawer-cache-mode')).toContainText('Subtract');
// Clearing cache_read hides the select again (Ant InputNumber: Ctrl+A then Delete)
const cacheInput = page.locator('[data-testid="drawer-cache-read-cost"] input');
await cacheInput.click();
await cacheInput.press('Control+a');
await cacheInput.press('Delete');
// Ant InputNumber fires onChange with null on clear — click elsewhere to commit
await page.locator('[data-testid="drawer-output-cost"] input').click();
await expect(page.getByTestId('drawer-cache-mode')).not.toBeVisible();
await page.getByTestId('drawer-cancel-btn').click();
});
test('TC-11 cancel closes the drawer without persisting data', async ({
authedPage: page,
}) => {
await gotoLlmPricingPage(page);
await page.getByTestId('add-model-cost-btn').click();
await expect(page.getByText('Add model cost').first()).toBeVisible();
await page.getByTestId('drawer-model-id-input').fill('e2e-cancel-test');
await page.locator('[data-testid="drawer-input-cost"] input').fill('99');
await page.getByTestId('drawer-cancel-btn').click();
await expect(page.getByText('Add model cost').first()).not.toBeVisible();
// No row created
await expect(page.getByText('e2e-cancel-test')).not.toBeVisible();
// Re-opening resets all fields to defaults
await page.getByTestId('add-model-cost-btn').click();
await expect(page.getByText('Add model cost').first()).toBeVisible();
await expect(page.getByTestId('drawer-model-id-input')).toHaveValue('');
await expect(
page.locator('[data-testid="drawer-input-cost"] input'),
).toHaveValue('0');
await page.getByTestId('drawer-cancel-btn').click();
});
});

View File

@@ -0,0 +1,165 @@
import { expect, test } from '../../fixtures/auth';
import {
authToken,
createPricingRuleViaApi,
deletePricingRuleViaApi,
gotoLlmPricingPage,
} from '../../helpers/llm-pricing';
test.describe.configure({ mode: 'serial' });
test.describe('LLM Pricing — Edit drawer', () => {
test('TC-12 Edit button opens drawer pre-filled with rule data', async ({
authedPage: page,
}) => {
const ruleId = await createPricingRuleViaApi(page, {
modelName: 'e2e-edit-subject',
provider: 'Google',
isOverride: true,
inputCost: 3.0,
outputCost: 12.0,
});
try {
await gotoLlmPricingPage(page);
await page.getByTestId(`edit-rule-${ruleId}`).click();
await expect(page.getByText('Edit model cost').first()).toBeVisible();
// Model ID is locked in edit mode
const modelInput = page.getByTestId('drawer-model-id-input');
await expect(modelInput).toBeDisabled();
await expect(modelInput).toHaveValue('e2e-edit-subject');
// Provider is locked and pre-filled
await expect(page.getByTestId('drawer-provider-select')).toBeDisabled();
await expect(page.getByTestId('drawer-provider-select')).toContainText('Google');
// Source radio
await expect(page.getByTestId('drawer-source-override')).toHaveAttribute(
'data-state',
'checked',
);
// Pricing values
await expect(
page.locator('[data-testid="drawer-input-cost"] input'),
).toHaveValue('3');
await expect(
page.locator('[data-testid="drawer-output-cost"] input'),
).toHaveValue('12');
// Delete button present in edit mode
await expect(page.getByTestId('drawer-delete-btn')).toBeVisible();
} finally {
await page.getByTestId('drawer-cancel-btn').click().catch(() => undefined);
const token = await authToken(page);
await deletePricingRuleViaApi(page.request, ruleId, token);
}
});
test('TC-13 editing pricing values and saving updates the row', async ({
authedPage: page,
}) => {
const ruleId = await createPricingRuleViaApi(page, {
modelName: 'e2e-edit-subject',
provider: 'Google',
isOverride: true,
inputCost: 3.0,
outputCost: 12.0,
});
try {
await gotoLlmPricingPage(page);
await page.getByTestId(`edit-rule-${ruleId}`).click();
await expect(page.getByText('Edit model cost').first()).toBeVisible();
// Update pricing
const inputField = page.locator('[data-testid="drawer-input-cost"] input');
const outputField = page.locator('[data-testid="drawer-output-cost"] input');
await inputField.click();
await inputField.press('Control+a');
await inputField.fill('7.00');
await outputField.click();
await outputField.press('Control+a');
await outputField.fill('21.00');
const putResponse = page.waitForResponse(
(r) =>
r.url().includes('llm_pricing_rules') && r.request().method() === 'PUT',
);
const listRefresh = page.waitForResponse(
(r) =>
r.url().includes('llm_pricing_rules') && r.request().method() === 'GET',
);
await page.getByTestId('drawer-save-btn').click();
const put = await putResponse;
expect(put.status()).toBe(200);
await listRefresh;
await expect(page.getByText('Edit model cost').first()).not.toBeVisible();
// Updated values visible in the row
await expect(page.getByTestId(`price-cell-input-${ruleId}`)).toHaveText('$7.00');
await expect(page.getByTestId(`price-cell-output-${ruleId}`)).toHaveText('$21.00');
} finally {
const token = await authToken(page);
await deletePricingRuleViaApi(page.request, ruleId, token);
}
});
test('TC-14 read-only mode — auto rule opens with Managed-by-SigNoz and Read-only badges', async ({
authedPage: page,
}) => {
const ruleId = await createPricingRuleViaApi(page, {
modelName: 'e2e-auto-rule',
provider: 'OpenAI',
isOverride: false,
inputCost: 2.0,
outputCost: 8.0,
});
try {
await gotoLlmPricingPage(page);
await page.getByTestId(`edit-rule-${ruleId}`).click();
await expect(page.getByText('Edit model cost').first()).toBeVisible();
// Source section lock badge
await expect(page.getByTestId('drawer-managed-label')).toBeVisible();
await expect(page.getByTestId('drawer-managed-label')).toContainText(
'Managed by SigNoz',
);
// Pricing section lock badge
await expect(page.getByTestId('drawer-readonly-label')).toBeVisible();
await expect(page.getByTestId('drawer-readonly-label')).toContainText('Read-only');
// Fields are locked
await expect(page.getByTestId('drawer-model-id-input')).toBeDisabled();
// Radio: auto is checked
await expect(page.getByTestId('drawer-source-auto')).toHaveAttribute(
'data-state',
'checked',
);
await expect(page.getByTestId('drawer-source-override')).toHaveAttribute(
'data-state',
'unchecked',
);
// Pricing inputs are disabled
await expect(
page.locator('[data-testid="drawer-input-cost"] input'),
).toBeDisabled();
await expect(
page.locator('[data-testid="drawer-output-cost"] input'),
).toBeDisabled();
// Pattern input is hidden when read-only
await expect(page.getByTestId('drawer-pattern-input')).not.toBeVisible();
} finally {
await page.getByTestId('drawer-cancel-btn').click().catch(() => undefined);
const token = await authToken(page);
await deletePricingRuleViaApi(page.request, ruleId, token);
}
});
});

View File

@@ -0,0 +1,192 @@
import { expect, test } from '../../fixtures/auth';
import { newAdminContext } from '../../helpers/auth';
import {
authToken,
createPricingRuleViaApi,
deletePricingRuleViaApi,
gotoLlmPricingPage,
LLM_PRICING_PATH,
} from '../../helpers/llm-pricing';
// Tests mutate the pricing rule list — run serially within the worker.
test.describe.configure({ mode: 'serial' });
// ─── Suite-level seed registry ────────────────────────────────────────────────
//
// TC-01 through TC-04 share two seeded rules (ruleA and ruleB) seeded in
// beforeAll. TC-05 runs against an empty workspace and must not seed.
const seedIds = new Set<string>();
let ruleAId = '';
let ruleBId = '';
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
ruleAId = await createPricingRuleViaApi(page, {
modelName: 'e2e-gpt-4o',
provider: 'OpenAI',
isOverride: false,
inputCost: 5.0,
outputCost: 15.0,
});
seedIds.add(ruleAId);
ruleBId = await createPricingRuleViaApi(page, {
modelName: 'e2e-claude-3',
provider: 'Anthropic',
isOverride: true,
inputCost: 8.0,
outputCost: 24.0,
});
seedIds.add(ruleBId);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of seedIds) {
await deletePricingRuleViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
test.describe('LLM Pricing — Listing', () => {
test('TC-01 listing page renders with data', async ({ authedPage: page }) => {
await gotoLlmPricingPage(page);
await expect(page.getByTestId('llm-observability-model-pricing-page')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Configuration', level: 1 })).toBeVisible();
// Tabs
const modelCostsTab = page.getByRole('tab', { name: 'Model costs' });
await expect(modelCostsTab).toBeVisible();
await expect(modelCostsTab).toHaveAttribute('aria-selected', 'true');
const unpricedTab = page.getByRole('tab', { name: 'Unpriced models' });
await expect(unpricedTab).toBeVisible();
await expect(unpricedTab).toHaveAttribute('aria-disabled', 'true');
// Filter bar
await expect(page.getByTestId('search-input')).toBeVisible();
await expect(page.getByTestId('source-select')).toBeVisible();
await expect(page.getByTestId('add-model-cost-btn')).toBeVisible();
// Table and rows
await expect(page.getByTestId('model-costs-table')).toBeVisible();
await expect(page.getByTestId(`model-cell-name-${ruleAId}`)).toBeVisible();
await expect(page.getByTestId(`model-cell-name-${ruleBId}`)).toBeVisible();
// Footer
await expect(page.getByText(/Showing 2 models/)).toBeVisible();
});
test('TC-02 table columns and source badge render correctly', async ({
authedPage: page,
}) => {
await gotoLlmPricingPage(page);
// Rule A — auto (isOverride: false)
await expect(page.getByTestId(`model-cell-name-${ruleAId}`)).toHaveText('e2e-gpt-4o');
await expect(page.getByTestId(`model-cell-canonical-id-${ruleAId}`)).toHaveText(
'openai:e2e-gpt-4o',
);
const badgeA = page.getByTestId(`source-badge-${ruleAId}`);
await expect(badgeA).toHaveText('Auto');
// Rule B — user override (isOverride: true)
await expect(page.getByTestId(`model-cell-name-${ruleBId}`)).toHaveText('e2e-claude-3');
const badgeB = page.getByTestId(`source-badge-${ruleBId}`);
await expect(badgeB).toHaveText('User override');
// Price cells show $ prefix
await expect(page.getByTestId(`price-cell-input-${ruleAId}`)).toHaveText('$5.00');
await expect(page.getByTestId(`price-cell-output-${ruleAId}`)).toHaveText('$15.00');
// Edit button present for each row
await expect(page.getByTestId(`edit-rule-${ruleAId}`)).toBeVisible();
await expect(page.getByTestId(`edit-rule-${ruleBId}`)).toBeVisible();
});
test('TC-03 search filters rows by model name and provider', async ({
authedPage: page,
}) => {
await gotoLlmPricingPage(page);
// Search by model name prefix
await page.getByTestId('search-input').fill('e2e-gpt');
await expect(page.getByTestId(`model-cell-name-${ruleAId}`)).toBeVisible();
await expect(page.getByTestId(`model-cell-name-${ruleBId}`)).not.toBeVisible();
await expect(page.getByText(/Showing 1 model[^s]/)).toBeVisible();
// Search by provider (case-insensitive)
await page.getByTestId('search-input').fill('anthropic');
await expect(page.getByTestId(`model-cell-name-${ruleBId}`)).toBeVisible();
await expect(page.getByTestId(`model-cell-name-${ruleAId}`)).not.toBeVisible();
await expect(page.getByText(/Showing 1 model[^s]/)).toBeVisible();
// Clear search restores both rows
await page.getByTestId('search-input').fill('');
await expect(page.getByTestId(`model-cell-name-${ruleAId}`)).toBeVisible();
await expect(page.getByTestId(`model-cell-name-${ruleBId}`)).toBeVisible();
await expect(page.getByText(/Showing 2 models/)).toBeVisible();
});
test('TC-04 source filter narrows the table to auto-only or override-only', async ({
authedPage: page,
}) => {
await gotoLlmPricingPage(page);
// Filter to auto-populated only
await page.getByTestId('source-select').click();
await page.getByText('Auto-populated').click();
await expect(page.getByTestId(`model-cell-name-${ruleAId}`)).toBeVisible();
await expect(page.getByTestId(`model-cell-name-${ruleBId}`)).not.toBeVisible();
await expect(page.getByText(/Showing 1 model[^s]/)).toBeVisible();
// Filter to user override only
await page.getByTestId('source-select').click();
await page.getByText('User override').click();
await expect(page.getByTestId(`model-cell-name-${ruleBId}`)).toBeVisible();
await expect(page.getByTestId(`model-cell-name-${ruleAId}`)).not.toBeVisible();
await expect(page.getByText(/Showing 1 model[^s]/)).toBeVisible();
// Reset to all
await page.getByTestId('source-select').click();
await page.getByText('Source: All').click();
await expect(page.getByTestId(`model-cell-name-${ruleAId}`)).toBeVisible();
await expect(page.getByTestId(`model-cell-name-${ruleBId}`)).toBeVisible();
});
});
// TC-05 is isolated — no seeded data; runs in a standalone describe so it
// does not inherit the shared beforeAll seed.
test.describe('LLM Pricing — Empty state', () => {
test('TC-05 empty state — zero rules returns empty table', async ({
authedPage: page,
}) => {
// Wait for the list request to complete before asserting the empty state
// so the test does not race against the initial fetch.
const listResponse = page.waitForResponse(
(r) =>
r.url().includes('llm_pricing_rules') && r.request().method() === 'GET',
);
await page.goto(LLM_PRICING_PATH);
await page.getByTestId('llm-observability-model-pricing-page').waitFor({ state: 'visible' });
await listResponse;
await expect(page.getByTestId('model-costs-table')).toBeVisible();
// No data rows — Ant Table renders its empty placeholder
await expect(page.getByText(/Showing 0 models/)).toBeVisible();
});
});

View File

@@ -0,0 +1,246 @@
import { expect, test } from '../../fixtures/auth';
import {
authToken,
createPricingRuleViaApi,
deletePricingRuleViaApi,
gotoLlmPricingPage,
} from '../../helpers/llm-pricing';
test.describe.configure({ mode: 'serial' });
// Helper — executes in the browser context (Playwright evaluate). TypeScript
// cannot see browser globals from Node, so we use `any` to avoid DOM-lib deps.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getBorderColor = (el: any): string =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).getComputedStyle(el).borderColor;
test.describe('LLM Pricing — Source radio behaviour', () => {
test('TC-15 Override → Auto triggers reset-confirm; Keep cancels it', async ({
authedPage: page,
}) => {
await gotoLlmPricingPage(page);
await page.getByTestId('add-model-cost-btn').click();
await expect(page.getByText('Add model cost').first()).toBeVisible();
await page.getByTestId('drawer-model-id-input').fill('e2e-src-switch');
await page.locator('[data-testid="drawer-input-cost"] input').fill('25');
await page.locator('[data-testid="drawer-output-cost"] input').fill('75');
// Confirm starting state: Override checked
await expect(page.getByTestId('drawer-source-override')).toHaveAttribute(
'data-state',
'checked',
);
// Click Auto — should trigger the reset-confirm dialog
await page.getByTestId('drawer-source-auto').click();
const resetDialog = page.locator(
'[role="dialog"][aria-label="Reset to default pricing"]',
);
await expect(resetDialog).toBeVisible();
(await expect(
resetDialog
.getByText(
'Reset to default pricing? Custom values will be discarded. it might take 24 hours for changes to take effect..',
)
.toBeVisible(),
),
await expect(page.getByTestId('drawer-reset-keep-btn')).toBeVisible());
await expect(page.getByTestId('drawer-reset-confirm-btn')).toBeVisible();
// While confirm is showing, the switch has NOT been applied yet
await expect(page.getByTestId('drawer-source-auto')).toHaveAttribute(
'data-state',
'unchecked',
);
await expect(page.getByTestId('drawer-source-override')).toHaveAttribute(
'data-state',
'checked',
);
// Click Keep — dialog dismisses without changing the radio
await page.getByTestId('drawer-reset-keep-btn').click();
await expect(resetDialog).not.toBeVisible();
await expect(page.getByTestId('drawer-source-override')).toHaveAttribute(
'data-state',
'checked',
);
await expect(
page.locator('[data-testid="drawer-input-cost"] input'),
).toHaveValue('25');
await expect(
page.locator('[data-testid="drawer-output-cost"] input'),
).toHaveValue('75');
await page.getByTestId('drawer-cancel-btn').click();
});
test('TC-16 Override → Auto then Reset clears values and switches to auto mode', async ({
authedPage: page,
}) => {
await gotoLlmPricingPage(page);
await page.getByTestId('add-model-cost-btn').click();
await expect(page.getByText('Add model cost').first()).toBeVisible();
await page.getByTestId('drawer-model-id-input').fill('e2e-src-reset');
await page.locator('[data-testid="drawer-input-cost"] input').fill('25');
await page.getByTestId('drawer-source-auto').click();
const resetDialog = page.locator(
'[role="dialog"][aria-label="Reset to default pricing"]',
);
await expect(resetDialog).toBeVisible();
await page.getByTestId('drawer-reset-confirm-btn').click();
await expect(resetDialog).not.toBeVisible();
// Radio has switched to Auto
await expect(page.getByTestId('drawer-source-auto')).toHaveAttribute(
'data-state',
'checked',
);
await expect(page.getByTestId('drawer-source-override')).toHaveAttribute(
'data-state',
'unchecked',
);
// Pricing inputs are now read-only
await expect(
page.locator('[data-testid="drawer-input-cost"] input'),
).toBeDisabled();
await expect(
page.locator('[data-testid="drawer-output-cost"] input'),
).toBeDisabled();
await page.getByTestId('drawer-cancel-btn').click();
});
test('TC-17 Auto → Override switching works without confirm', async ({
authedPage: page,
}) => {
const ruleId = await createPricingRuleViaApi(page, {
modelName: 'e2e-auto-switch',
provider: 'OpenAI',
isOverride: false,
inputCost: 1.0,
outputCost: 4.0,
});
try {
await gotoLlmPricingPage(page);
await page.getByTestId(`edit-rule-${ruleId}`).click();
await expect(page.getByText('Edit model cost').first()).toBeVisible();
await expect(page.getByTestId('drawer-source-auto')).toHaveAttribute(
'data-state',
'checked',
);
await expect(
page.locator('[data-testid="drawer-input-cost"] input'),
).toBeDisabled();
// Click Override — no reset-confirm should appear
await page.getByTestId('drawer-source-override').click();
await expect(
page.locator('[role="dialog"][aria-label="Reset to default pricing"]'),
).not.toBeVisible();
await expect(page.getByTestId('drawer-source-override')).toHaveAttribute(
'data-state',
'checked',
);
await expect(
page.locator('[data-testid="drawer-input-cost"] input'),
).toBeEnabled();
} finally {
await page
.getByTestId('drawer-cancel-btn')
.click()
.catch(() => undefined);
const token = await authToken(page);
await deletePricingRuleViaApi(page.request, ruleId, token);
}
});
test('TC-18 source radio card highlight — checked state gets tinted border', async ({
authedPage: page,
}) => {
const ruleId = await createPricingRuleViaApi(page, {
modelName: 'e2e-border-check',
provider: 'OpenAI',
isOverride: true,
inputCost: 1.0,
outputCost: 4.0,
});
try {
await gotoLlmPricingPage(page);
await page.getByTestId(`edit-rule-${ruleId}`).click();
await expect(page.getByText('Edit model cost').first()).toBeVisible();
// Override card is checked — its border-color must be non-transparent
const overrideCard = page.locator('.source-radio--override');
await expect(
overrideCard.locator('button[data-state="checked"]'),
).toBeVisible();
const overrideBorder = await overrideCard.evaluate(getBorderColor);
expect(overrideBorder).not.toBe('transparent');
expect(overrideBorder).not.toBe('rgba(0, 0, 0, 0)');
// Auto card is unchecked — its border-color should be transparent
const autoCard = page.locator('.source-radio--auto');
await expect(
autoCard.locator('button[data-state="unchecked"]'),
).toBeVisible();
const autoBorder = await autoCard.evaluate(getBorderColor);
expect(autoBorder).toBe('rgba(0, 0, 0, 0)');
// Switch to Auto and confirm reset
await page.getByTestId('drawer-source-auto').click();
const resetDialog = page.locator(
'[role="dialog"][aria-label="Reset to default pricing"]',
);
await expect(resetDialog).toBeVisible();
await page.getByTestId('drawer-reset-confirm-btn').click();
await expect(resetDialog).not.toBeVisible();
// Now Auto card has the tinted border; Override card is transparent
const autoBorderAfter = await autoCard.evaluate(getBorderColor);
expect(autoBorderAfter).not.toBe('transparent');
expect(autoBorderAfter).not.toBe('rgba(0, 0, 0, 0)');
const overrideBorderAfter = await overrideCard.evaluate(getBorderColor);
expect(overrideBorderAfter).toBe('rgba(0, 0, 0, 0)');
} finally {
await page
.getByTestId('drawer-cancel-btn')
.click()
.catch(() => undefined);
const token = await authToken(page);
await deletePricingRuleViaApi(page.request, ruleId, token);
}
});
test('TC-19 unchecked radio dot is visible — border not invisible against drawer surface', async ({
authedPage: page,
}) => {
await gotoLlmPricingPage(page);
await page.getByTestId('add-model-cost-btn').click();
await expect(page.getByText('Add model cost').first()).toBeVisible();
// In add mode, Override is checked and Auto is unchecked
const autoBtn = page.getByTestId('drawer-source-auto');
await expect(autoBtn).toHaveAttribute('data-state', 'unchecked');
// The CSS fix sets --radio-group-item-border-color: var(--bg-slate-200)
// on .source-radio-group; assert the computed border is not invisible.
const borderColor = await autoBtn.evaluate(getBorderColor);
expect(borderColor).not.toBe('transparent');
expect(borderColor).not.toBe('rgba(0, 0, 0, 0)');
// Auto label text is visible alongside the dot
await expect(page.getByText('Auto-populated').first()).toBeVisible();
await page.getByTestId('drawer-cancel-btn').click();
});
});