mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-06 22:50:38 +01:00
Compare commits
47 Commits
fix/query-
...
feat/llm-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9d92c5ae9 | ||
|
|
1e982261a7 | ||
|
|
d2e89789fa | ||
|
|
2d1d8b2df7 | ||
|
|
2d3359c97e | ||
|
|
f10cd84147 | ||
|
|
6e25f09677 | ||
|
|
bcc1ff2444 | ||
|
|
3babbae36d | ||
|
|
7a93fe8192 | ||
|
|
da82731195 | ||
|
|
63e46ad947 | ||
|
|
39f8419a9e | ||
|
|
64a9725e12 | ||
|
|
08bc6ec75b | ||
|
|
e47a0cd954 | ||
|
|
2026349f6d | ||
|
|
836a00cdfa | ||
|
|
ad73f98d0f | ||
|
|
0b9a0c4ac8 | ||
|
|
2323495c4a | ||
|
|
e1a18d36fe | ||
|
|
1b16aea459 | ||
|
|
9bd5d233ba | ||
|
|
b992f38bca | ||
|
|
5d89b1a348 | ||
|
|
e6daf07a06 | ||
|
|
fda1bf17dc | ||
|
|
5c062be23a | ||
|
|
585894b382 | ||
|
|
74e560409c | ||
|
|
79a7efe8e2 | ||
|
|
75a2208e9f | ||
|
|
86db724069 | ||
|
|
22a8f295f0 | ||
|
|
34e712d212 | ||
|
|
a17cfcc131 | ||
|
|
8697461476 | ||
|
|
47e33e0610 | ||
|
|
0829155247 | ||
|
|
92b8fa86e7 | ||
|
|
31e1b21740 | ||
|
|
dcad825615 | ||
|
|
6b508655bd | ||
|
|
c3f1df3ed0 | ||
|
|
a19c8f3cc6 | ||
|
|
b1aaf3ec9a |
@@ -7,6 +7,7 @@ import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useFeatureGatedRouteRedirect } from 'hooks/useFeatureGatedRouteRedirect';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
@@ -42,6 +43,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
|
||||
const isAdmin = user.role === USER_ROLES.ADMIN;
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const featureGatedRedirect = useFeatureGatedRouteRedirect(pathname);
|
||||
|
||||
const mapRoutes = useMemo(
|
||||
() =>
|
||||
@@ -133,6 +135,11 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
return <Redirect to={ROUTES.HOME} />;
|
||||
}
|
||||
|
||||
// Redirect away from feature-flag-gated routes whose flag is disabled
|
||||
if (featureGatedRedirect) {
|
||||
return <Redirect to={featureGatedRedirect} />;
|
||||
}
|
||||
|
||||
// Check for workspace access restriction (cloud only)
|
||||
const isCloudPlatform = activeLicense?.platform === LicensePlatform.CLOUD;
|
||||
|
||||
|
||||
@@ -330,3 +330,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'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
IntegrationsDetailsPage,
|
||||
LicensePage,
|
||||
ListAllALertsPage,
|
||||
LLMObservabilityModelPricingPage,
|
||||
LiveLogs,
|
||||
Login,
|
||||
Logs,
|
||||
@@ -505,6 +506,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 = {
|
||||
|
||||
@@ -12,4 +12,5 @@ export enum FeatureKeys {
|
||||
USE_JSON_BODY = 'use_json_body',
|
||||
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
|
||||
USE_DASHBOARD_V2 = 'use_dashboard_v2',
|
||||
AI_OBSERVABILITY = 'ai_observability',
|
||||
}
|
||||
|
||||
@@ -92,6 +92,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/settings/model-pricing',
|
||||
} as const;
|
||||
|
||||
export default ROUTES;
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Plus, Trash2 } from '@signozhq/icons';
|
||||
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { CACHE_BUCKETS, CACHE_MODE_OPTIONS } from './constants';
|
||||
import styles from './ModelCostDrawer.module.scss';
|
||||
import { parsePricingAmount } from './utils';
|
||||
import type { CacheBucketKey, DrawerDraft } from './types';
|
||||
|
||||
type Pricing = DrawerDraft['pricing'];
|
||||
|
||||
interface ExtraPricingBucketsProps {
|
||||
pricing: Pricing;
|
||||
isReadOnly: boolean;
|
||||
onChange: (patch: Partial<Pricing>) => void;
|
||||
}
|
||||
|
||||
// Optional, add-on-demand pricing buckets. A bucket is "added" once its value
|
||||
// is non-null; adding seeds it at 0 and removing clears it back to null. Only
|
||||
// the cache buckets are backed by the API today (pricing.cache.read/write).
|
||||
function ExtraPricingBuckets({
|
||||
pricing,
|
||||
isReadOnly,
|
||||
onChange,
|
||||
}: ExtraPricingBucketsProps): JSX.Element {
|
||||
const [isExtraPricingBucketOpen, setIsExtraPricingBucketOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
// Track which buckets are shown separately from their value, so a freshly
|
||||
// added bucket can start blank (value null) instead of being seeded to 0.
|
||||
// Seeded from buckets that already carry a value (edit mode).
|
||||
const [addedKeys, setAddedKeys] = useState<Set<CacheBucketKey>>(
|
||||
() =>
|
||||
new Set(
|
||||
CACHE_BUCKETS.filter((b) => pricing[b.key] !== null).map((b) => b.key),
|
||||
),
|
||||
);
|
||||
|
||||
const addedBuckets = CACHE_BUCKETS.filter((b) => addedKeys.has(b.key));
|
||||
const availableBuckets = CACHE_BUCKETS.filter((b) => !addedKeys.has(b.key));
|
||||
const patchBucket = (key: CacheBucketKey, value: number | null): void => {
|
||||
const patch: Partial<Pricing> = { [key]: value };
|
||||
onChange(patch);
|
||||
};
|
||||
|
||||
const addBucket = (key: CacheBucketKey): void => {
|
||||
// Leave the value null so the field renders blank until the user types.
|
||||
setAddedKeys((prev) => new Set(prev).add(key));
|
||||
// Close the picker once nothing is left to add.
|
||||
if (availableBuckets.length <= 1) {
|
||||
setIsExtraPricingBucketOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeBucket = (key: CacheBucketKey): void => {
|
||||
setAddedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
patchBucket(key, null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cx(styles.extraBucketsSection, styles.drawerSection)}>
|
||||
<div className={styles.extraBucketsSectionHead}>
|
||||
<span className={styles.fieldLabel}>Extra pricing buckets</span>
|
||||
<span className={styles.optionalLabel}>optional</span>
|
||||
</div>
|
||||
|
||||
{addedBuckets.map((bucket) => (
|
||||
<div className={styles.bucketRow} key={bucket.key}>
|
||||
<span className={styles.bucketRowName}>{bucket.label}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={pricing[bucket.key] ?? ''}
|
||||
disabled={isReadOnly}
|
||||
onChange={(e): void =>
|
||||
// Clearing the field is allowed — the row stays mounted because
|
||||
// presence is tracked in `addedKeys`, not the value. Removal is
|
||||
// explicit via the trash button.
|
||||
patchBucket(bucket.key, parsePricingAmount(e.target.value))
|
||||
}
|
||||
testId={`drawer-${bucket.testId}-cost`}
|
||||
/>
|
||||
<span className={styles.bucketRowUnit}>/ 1M</span>
|
||||
{!isReadOnly && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.bucketRowRemove}
|
||||
onClick={(): void => removeBucket(bucket.key)}
|
||||
aria-label={`Remove ${bucket.label}`}
|
||||
data-testid={`drawer-remove-${bucket.testId}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{addedBuckets.length > 0 && (
|
||||
<div className={cx(styles.pricingField, styles.cacheModeField)}>
|
||||
<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={styles.fullWidth}
|
||||
withPortal={false}
|
||||
testId="drawer-cache-mode"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isReadOnly && !isExtraPricingBucketOpen && availableBuckets.length > 0 && (
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
className={styles.bucketAddBtn}
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={(): void => setIsExtraPricingBucketOpen(true)}
|
||||
testId="drawer-add-bucket-btn"
|
||||
>
|
||||
Add pricing bucket
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isReadOnly && isExtraPricingBucketOpen && (
|
||||
<div className={styles.bucketPicker} data-testid="drawer-bucket-picker">
|
||||
<div className={styles.bucketPickerTitle}>Add a pricing bucket</div>
|
||||
<div className={styles.bucketPickerChips}>
|
||||
{availableBuckets.map((bucket) => (
|
||||
<Button
|
||||
key={bucket.key}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Plus size={12} />}
|
||||
onClick={(): void => addBucket(bucket.key)}
|
||||
testId={`drawer-add-bucket-${bucket.testId}`}
|
||||
>
|
||||
{bucket.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={(): void => setIsExtraPricingBucketOpen(false)}
|
||||
testId="drawer-add-bucket-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExtraPricingBuckets;
|
||||
@@ -0,0 +1,151 @@
|
||||
.llmObservabilityModelPricing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 24px 32px;
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.filtersBar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.filtersBarLeft {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filtersBarSearch {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.filtersBarSource {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.filtersBarSearchClear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--bg-vanilla-400);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
|
||||
.filtersBarCurrency {
|
||||
// Fixed width — the trigger otherwise stretches to fill the bar.
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.pageError {
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--bg-cherry-400) 8%, transparent);
|
||||
color: var(--bg-cherry-400);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
// Space the table away from the filters bar above it.
|
||||
.modelCostsTable {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.pageFooter {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modelCell {
|
||||
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;
|
||||
}
|
||||
|
||||
.modelCellName {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modelCellName,
|
||||
.modelCellCanonicalId {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.modelCellCanonicalId {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: var(--code-font-family, monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.priceCell {
|
||||
font-family: var(--code-font-family, monospace);
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.extraBuckets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.extraBucketsChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.extraBucketsKey {
|
||||
font-family: var(--code-font-family, monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.extraBucketsPrice {
|
||||
font-family: var(--code-font-family, monospace);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.sourceBadge {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
|
||||
import ModelCostsTab from './ModelCostsTab';
|
||||
import styles from './LLMObservabilityModelPricing.module.scss';
|
||||
|
||||
function LLMObservabilityModelPricing(): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.llmObservabilityModelPricing}
|
||||
data-testid="llm-observability-model-pricing-page"
|
||||
>
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<h1>Configuration</h1>
|
||||
<p>Model pricing and cost estimation settings</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Tabs
|
||||
// Model costs is the only enabled tab for now, so default to it. When
|
||||
// the unpriced-models tab lands, this can become a URL-backed param.
|
||||
defaultValue="model-costs"
|
||||
items={[
|
||||
{
|
||||
key: 'model-costs',
|
||||
label: 'Model costs',
|
||||
children: <ModelCostsTab />,
|
||||
},
|
||||
{
|
||||
// Unpriced-models tab lands in a later PR.
|
||||
key: 'unpriced-models',
|
||||
label: 'Unpriced models',
|
||||
disabled: true,
|
||||
children: null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LLMObservabilityModelPricing;
|
||||
@@ -0,0 +1,395 @@
|
||||
.modelCostDrawer {
|
||||
// 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;
|
||||
|
||||
display: flex;
|
||||
overflow-y: auto;
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
.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%;
|
||||
}
|
||||
|
||||
.footerRight {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.drawerSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
label,
|
||||
.fieldLabel {
|
||||
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);
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.drawerSurface {
|
||||
padding: 14px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--bg-vanilla-100) 2%, transparent);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
}
|
||||
|
||||
.drawerSurfaceHead {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.managedLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.patternBox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--bg-vanilla-100) 2%, transparent);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
}
|
||||
|
||||
.patternChips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.patternChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.patternChipRemove {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.patternAdd {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
// Inline <code> styling for help text rendered anywhere in the drawer.
|
||||
.help {
|
||||
code {
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--bg-vanilla-100) 6%, transparent);
|
||||
font-family: var(--code-font-family, monospace);
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.sourceRadioGroup {
|
||||
// @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.
|
||||
.sourceRadio {
|
||||
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: color-mix(in srgb, var(--bg-vanilla-100) 2%, transparent);
|
||||
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 .drawerSection 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;
|
||||
}
|
||||
|
||||
// Radix RadioGroupItem renders <button data-state="checked|unchecked">.
|
||||
// Use :has() to highlight the wrapper card when its inner button is checked.
|
||||
&.sourceRadioAuto:has(button[data-state='checked']) {
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--bg-robin-400) 30%, transparent);
|
||||
}
|
||||
|
||||
&.sourceRadioOverride:has(button[data-state='checked']) {
|
||||
background: color-mix(in srgb, var(--bg-amber-400) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--bg-amber-400) 30%, transparent);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--bg-vanilla-100) 4%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sourceRadioTitle {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
.sourceRadioDesc {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.resetConfirm {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 6%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--bg-robin-400) 20%, transparent);
|
||||
|
||||
p {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.resetConfirmActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pricingGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pricingField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.cacheModeField {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.extraBucketsSection {
|
||||
margin-top: 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.extraBucketsSectionHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.optionalLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.bucketRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.bucketRowName {
|
||||
flex: 0 0 110px;
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-200);
|
||||
font-family: var(--code-font-family, monospace);
|
||||
}
|
||||
|
||||
.bucketRowUnit {
|
||||
flex: 0 0 auto;
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.bucketRowRemove {
|
||||
flex: 0 0 auto;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--bg-vanilla-400);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-cherry-400);
|
||||
}
|
||||
}
|
||||
|
||||
.bucketAddBtn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bucketPicker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--bg-vanilla-100) 2%, transparent);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
}
|
||||
|
||||
.bucketPickerTitle {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.bucketPickerChips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.drawerError {
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--bg-cherry-400) 8%, transparent);
|
||||
color: var(--bg-cherry-400);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// Inline validation message rendered under an invalid field.
|
||||
.fieldError {
|
||||
margin: 4px 0 0;
|
||||
color: var(--bg-cherry-400);
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
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 { Trash2 } from '@signozhq/icons';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import PatternEditor from './PatternEditor';
|
||||
import PricingFields from './PricingFields';
|
||||
import SourceSelector from './SourceSelector';
|
||||
import { PROVIDER_OPTIONS } from './constants';
|
||||
import styles from './ModelCostDrawer.module.scss';
|
||||
import { validateModelName, validatePricing, validateProvider } from './utils';
|
||||
import type { DrawerDraft, DrawerMode } from './types';
|
||||
|
||||
interface ModelCostDrawerProps {
|
||||
isOpen: boolean;
|
||||
mode: DrawerMode;
|
||||
initialDraft: DrawerDraft;
|
||||
onClose: () => void;
|
||||
onSave: (draft: DrawerDraft) => void;
|
||||
onDelete: () => void;
|
||||
isSaving: boolean;
|
||||
isDeleting: boolean;
|
||||
saveError: string | null;
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
function ModelCostDrawer({
|
||||
isOpen,
|
||||
mode,
|
||||
initialDraft,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
saveError,
|
||||
canManage,
|
||||
}: ModelCostDrawerProps): JSX.Element {
|
||||
// Default mode validates on submit, then re-validates on change — so we don't
|
||||
// flag empty fields before the user has tried to save, but errors clear live
|
||||
// once they start fixing them.
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { isDirty },
|
||||
} = useForm<DrawerDraft>({
|
||||
defaultValues: initialDraft,
|
||||
});
|
||||
|
||||
const isOverride = watch('isOverride');
|
||||
|
||||
// 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 || !isOverride;
|
||||
|
||||
// Non-managers can only view (write APIs are Admin-only), so the drawer is a
|
||||
// read-only "View" rather than "Edit"/"Add".
|
||||
let drawerTitle = 'Add model cost';
|
||||
if (!canManage) {
|
||||
drawerTitle = 'View model cost';
|
||||
} else if (mode === 'edit') {
|
||||
drawerTitle = 'Edit model cost';
|
||||
}
|
||||
|
||||
const footer = (
|
||||
<div className={styles.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={styles.footerRight}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
testId="drawer-cancel-btn"
|
||||
>
|
||||
{canManage ? 'Cancel' : 'Close'}
|
||||
</Button>
|
||||
{canManage && (
|
||||
// Disabled until the form is dirty, so saving an untouched draft
|
||||
// (no real change) is impossible. Once dirty it stays enabled even
|
||||
// when invalid, so clicking surfaces inline field errors via
|
||||
// handleSubmit rather than silently blocking Save.
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSubmit(onSave)}
|
||||
disabled={!isDirty}
|
||||
loading={isSaving}
|
||||
testId="drawer-save-btn"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={isOpen}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
width="base"
|
||||
className={styles.modelCostDrawer}
|
||||
footer={footer}
|
||||
title={drawerTitle}
|
||||
drawerHeaderProps={{ className: styles.title }}
|
||||
>
|
||||
<div className={styles.drawerSection}>
|
||||
<label htmlFor="billing-model-id">
|
||||
Billing model ID <span className={styles.required}>*</span>
|
||||
</label>
|
||||
<Controller
|
||||
name="modelName"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value): true | string => validateModelName(value, mode),
|
||||
}}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<Input
|
||||
id="billing-model-id"
|
||||
placeholder="e.g. openai:gpt-4o"
|
||||
value={field.value}
|
||||
disabled={mode === 'edit' || metadataReadOnly}
|
||||
aria-invalid={!!fieldState.error}
|
||||
onChange={(e): void => field.onChange(e.target.value)}
|
||||
testId="drawer-model-id-input"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<p className={styles.fieldError} role="alert">
|
||||
{fieldState.error.message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.drawerSection}>
|
||||
<label htmlFor="provider-select">Provider</label>
|
||||
<Controller
|
||||
name="provider"
|
||||
control={control}
|
||||
rules={{ validate: validateProvider }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<SelectSimple
|
||||
id="provider-select"
|
||||
value={field.value}
|
||||
onChange={(value): void => field.onChange(value as string)}
|
||||
items={PROVIDER_OPTIONS}
|
||||
disabled={mode === 'edit' || metadataReadOnly}
|
||||
className={styles.fullWidth}
|
||||
withPortal={false}
|
||||
testId="drawer-provider-select"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<p className={styles.fieldError} role="alert">
|
||||
{fieldState.error.message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
name="patterns"
|
||||
control={control}
|
||||
render={({ field }): JSX.Element => (
|
||||
<PatternEditor
|
||||
patterns={field.value}
|
||||
isReadOnly={metadataReadOnly}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Source is auto vs. override — a choice only a manager can make, so
|
||||
there's nothing to show a read-only viewer. */}
|
||||
{canManage && (
|
||||
<Controller
|
||||
name="isOverride"
|
||||
control={control}
|
||||
// Pricing requirements depend on this toggle, so re-validate pricing
|
||||
// whenever the source changes (clears/sets the pricing error).
|
||||
rules={{ deps: ['pricing'] }}
|
||||
render={({ field }): JSX.Element => (
|
||||
<SourceSelector
|
||||
isOverride={field.value}
|
||||
isReadOnly={metadataReadOnly}
|
||||
disableAuto={mode === 'add'}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
name="pricing"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value, values): true | string =>
|
||||
validatePricing(value, values.isOverride),
|
||||
}}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<PricingFields
|
||||
pricing={field.value}
|
||||
isReadOnly={pricingReadOnly}
|
||||
onChange={(patch): void => field.onChange({ ...field.value, ...patch })}
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<p className={styles.fieldError} role="alert">
|
||||
{fieldState.error.message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{saveError && (
|
||||
<div className={styles.drawerError} role="alert">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelCostDrawer;
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Plus, Search, X } from '@signozhq/icons';
|
||||
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
|
||||
import { type ListLLMPricingRulesParams } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useTableParams } from 'components/TanStackTableView';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { parseAsString, parseAsStringEnum, useQueryState } from 'nuqs';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import {
|
||||
LIMIT_KEY,
|
||||
PAGE_KEY,
|
||||
PAGE_SIZE,
|
||||
SEARCH_DEBOUNCE_MS,
|
||||
SEARCH_KEY,
|
||||
SOURCE_FILTER_OPTIONS,
|
||||
SOURCE_FILTER_TO_IS_OVERRIDE,
|
||||
SOURCE_KEY,
|
||||
type SourceFilter,
|
||||
} from './constants';
|
||||
import styles from './LLMObservabilityModelPricing.module.scss';
|
||||
import ModelCostDrawer from './ModelCostDrawer';
|
||||
import ModelCostsTable from './ModelCostsTable';
|
||||
import { useModelCostDrawer } from './useModelCostDrawer';
|
||||
import type { PricingRule } from './types';
|
||||
|
||||
// "Model costs" tab: the priced-model listing, its currency control, the add/
|
||||
// edit drawer, and pagination. Page and page size live in the URL (shareable/
|
||||
// reload-safe) and are owned by TanStackTable via enableQueryParams — this tab
|
||||
// reads them back through the same useTableParams hook so the two stay in lockstep.
|
||||
function ModelCostsTab(): JSX.Element {
|
||||
const { page, limit, setPage } = useTableParams(
|
||||
{ page: PAGE_KEY, limit: LIMIT_KEY },
|
||||
{ page: 1, limit: PAGE_SIZE },
|
||||
);
|
||||
|
||||
// Search term lives in the URL (shareable/reload-safe) alongside page/limit.
|
||||
// It's debounced before reaching the list API so typing doesn't spam requests.
|
||||
const [search, setSearch] = useQueryState(
|
||||
SEARCH_KEY,
|
||||
parseAsString.withDefault(''),
|
||||
);
|
||||
const debouncedSearch = useDebounce(search, SEARCH_DEBOUNCE_MS);
|
||||
|
||||
// Source filter (All / User override / Auto) → API's nullable isOverride.
|
||||
const [source, setSource] = useQueryState(
|
||||
SOURCE_KEY,
|
||||
parseAsStringEnum<SourceFilter>(
|
||||
SOURCE_FILTER_OPTIONS.map((option) => option.value),
|
||||
).withDefault('all'),
|
||||
);
|
||||
|
||||
const handleSearchChange = (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
): void => {
|
||||
void setSearch(event.target.value || null);
|
||||
// Any new search collapses the result set — jump back to the first page so
|
||||
// the user isn't stranded on an out-of-range page.
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const clearSearch = (): void => {
|
||||
void setSearch(null);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSourceChange = (value: string | string[]): void => {
|
||||
void setSource(value as SourceFilter);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const isOverride = SOURCE_FILTER_TO_IS_OVERRIDE[source];
|
||||
|
||||
const listParams: ListLLMPricingRulesParams = {
|
||||
offset: (page - 1) * limit,
|
||||
limit,
|
||||
// Omit each filter entirely when unset so the API treats it as "no filter".
|
||||
...(debouncedSearch ? { q: debouncedSearch } : {}),
|
||||
...(isOverride !== undefined ? { isOverride } : {}),
|
||||
};
|
||||
|
||||
const { data, isLoading, isError } = useListLLMPricingRules(listParams);
|
||||
|
||||
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 drawer = useModelCostDrawer();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.filtersBar}>
|
||||
<div className={styles.filtersBarLeft}>
|
||||
<Input
|
||||
className={styles.filtersBarSearch}
|
||||
placeholder="Search by model or provider"
|
||||
value={search}
|
||||
onChange={handleSearchChange}
|
||||
prefix={<Search size={14} />}
|
||||
suffix={
|
||||
search ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.filtersBarSearchClear}
|
||||
onClick={clearSearch}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
testId="model-cost-search"
|
||||
/>
|
||||
<SelectSimple
|
||||
className={styles.filtersBarSource}
|
||||
items={SOURCE_FILTER_OPTIONS}
|
||||
value={source}
|
||||
onChange={handleSourceChange}
|
||||
testId="source-filter"
|
||||
/>
|
||||
{/* Only USD is priced today — disabled until other currencies land. */}
|
||||
<SelectSimple
|
||||
className={styles.filtersBarCurrency}
|
||||
value="USD"
|
||||
disabled
|
||||
testId="currency-select"
|
||||
/>
|
||||
</div>
|
||||
{canManagePricing && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={(): void => drawer.openForAdd()}
|
||||
testId="add-model-cost-btn"
|
||||
>
|
||||
Add model cost
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<div className={styles.pageError} role="alert">
|
||||
Failed to load pricing rules. Please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelCostsTable
|
||||
rules={rules}
|
||||
isLoading={isLoading}
|
||||
total={total}
|
||||
selectedRuleId={drawer.selectedRuleId}
|
||||
canManage={canManagePricing}
|
||||
onEdit={drawer.openForEdit}
|
||||
/>
|
||||
|
||||
<footer className={styles.pageFooter}>All prices per 1M tokens (USD)</footer>
|
||||
{drawer.isOpen && (
|
||||
<ModelCostDrawer
|
||||
isOpen={drawer.isOpen}
|
||||
mode={drawer.mode}
|
||||
initialDraft={drawer.initialDraft}
|
||||
onClose={drawer.close}
|
||||
onSave={drawer.save}
|
||||
onDelete={drawer.deleteRule}
|
||||
isSaving={drawer.isSaving}
|
||||
isDeleting={drawer.isDeleting}
|
||||
saveError={drawer.saveError}
|
||||
canManage={canManagePricing}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelCostsTab;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useMemo } from 'react';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
|
||||
import {
|
||||
LIMIT_KEY,
|
||||
PAGE_KEY,
|
||||
PAGE_SIZE,
|
||||
SKELETON_ROW_COUNT,
|
||||
} from './constants';
|
||||
import styles from './LLMObservabilityModelPricing.module.scss';
|
||||
import { getModelCostsColumns } from './table.config';
|
||||
import type { PricingRule } from './types';
|
||||
|
||||
interface ModelCostsTableProps {
|
||||
rules: PricingRule[];
|
||||
isLoading: boolean;
|
||||
total: number;
|
||||
selectedRuleId: string | null;
|
||||
canManage: boolean;
|
||||
onEdit: (rule: PricingRule) => void;
|
||||
}
|
||||
|
||||
// The table owns its own pagination URL state (page/limit) via enableQueryParams;
|
||||
// ModelCostsTab reads the same keys to build the list request. Virtual scroll is
|
||||
// disabled because this is a small, server-paginated page that lives in a
|
||||
// content-height container rather than a fixed-height viewport.
|
||||
function ModelCostsTable({
|
||||
rules,
|
||||
isLoading,
|
||||
total,
|
||||
selectedRuleId,
|
||||
canManage,
|
||||
onEdit,
|
||||
}: ModelCostsTableProps): JSX.Element {
|
||||
const columns = useMemo(
|
||||
() => getModelCostsColumns({ canManage, onEdit }),
|
||||
[canManage, onEdit],
|
||||
);
|
||||
|
||||
if (!isLoading && rules.length === 0) {
|
||||
return (
|
||||
<div className={styles.modelCostsTable} data-testid="model-costs-empty">
|
||||
No model costs yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TanStackTable<PricingRule>
|
||||
className={styles.modelCostsTable}
|
||||
data={rules}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
skeletonRowCount={SKELETON_ROW_COUNT}
|
||||
getRowKey={(row): string => row.id}
|
||||
isRowActive={(row): boolean => row.id === selectedRuleId}
|
||||
disableVirtualScroll
|
||||
testId="model-costs-table"
|
||||
enableQueryParams={{ page: PAGE_KEY, limit: LIMIT_KEY }}
|
||||
pagination={{
|
||||
total,
|
||||
defaultLimit: PAGE_SIZE,
|
||||
showTotalCount: true,
|
||||
totalCountLabel: 'models',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelCostsTable;
|
||||
@@ -0,0 +1,99 @@
|
||||
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';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './ModelCostDrawer.module.scss';
|
||||
|
||||
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={styles.drawerSection}>
|
||||
<span className={styles.fieldLabel}>
|
||||
Model name patterns <span className={styles.muted}>(prefix match)</span>
|
||||
</span>
|
||||
<div className={styles.patternBox}>
|
||||
<div className={styles.patternChips}>
|
||||
{patterns.map((pattern) => (
|
||||
<Badge
|
||||
key={pattern}
|
||||
color="vanilla"
|
||||
variant="outline"
|
||||
className={styles.patternChip}
|
||||
>
|
||||
{pattern}*
|
||||
{!isReadOnly && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove pattern ${pattern}`}
|
||||
className={styles.patternChipRemove}
|
||||
onClick={(): void => removePattern(pattern)}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{!isReadOnly && (
|
||||
<div className={styles.patternAdd}>
|
||||
<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={cx(styles.muted, styles.help)}>
|
||||
Each pattern uses <strong>prefix matching</strong> against{' '}
|
||||
<code>gen_ai.request.model</code>.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PatternEditor;
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Lock } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import ExtraPricingBuckets from './ExtraPricingBuckets';
|
||||
import styles from './ModelCostDrawer.module.scss';
|
||||
import { parsePricingAmount } from './utils';
|
||||
import type { DrawerDraft } from './types';
|
||||
|
||||
type Pricing = DrawerDraft['pricing'];
|
||||
|
||||
interface PricingFieldsProps {
|
||||
pricing: Pricing;
|
||||
isReadOnly: boolean;
|
||||
onChange: (patch: Partial<Pricing>) => void;
|
||||
}
|
||||
|
||||
function PricingFields({
|
||||
pricing,
|
||||
isReadOnly,
|
||||
onChange,
|
||||
}: PricingFieldsProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
|
||||
<div className={styles.drawerSurfaceHead}>
|
||||
<h4>Pricing (per 1M tokens, USD)</h4>
|
||||
{isReadOnly && (
|
||||
<span className={styles.managedLabel} data-testid="drawer-readonly-label">
|
||||
<Lock size={12} />
|
||||
Read-only
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.pricingGrid}>
|
||||
<div className={styles.pricingField}>
|
||||
<label htmlFor="input-cost">
|
||||
Input cost <span className={styles.required}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="input-cost"
|
||||
type="number"
|
||||
step={0.01}
|
||||
value={pricing.input ?? ''}
|
||||
disabled={isReadOnly}
|
||||
onChange={(e): void =>
|
||||
onChange({ input: parsePricingAmount(e.target.value) })
|
||||
}
|
||||
testId="drawer-input-cost"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.pricingField}>
|
||||
<label htmlFor="output-cost">
|
||||
Output cost <span className={styles.required}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="output-cost"
|
||||
type="number"
|
||||
step={0.01}
|
||||
value={pricing.output ?? ''}
|
||||
disabled={isReadOnly}
|
||||
onChange={(e): void =>
|
||||
onChange({ output: parsePricingAmount(e.target.value) })
|
||||
}
|
||||
testId="drawer-output-cost"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ExtraPricingBuckets
|
||||
pricing={pricing}
|
||||
isReadOnly={isReadOnly}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PricingFields;
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { Lock } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './ModelCostDrawer.module.scss';
|
||||
|
||||
interface SourceSelectorProps {
|
||||
isOverride: boolean;
|
||||
isReadOnly: boolean;
|
||||
disableAuto?: 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,
|
||||
disableAuto = false,
|
||||
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={cx(styles.drawerSection, styles.drawerSurface)}>
|
||||
<div className={styles.drawerSurfaceHead}>
|
||||
<h4>Source</h4>
|
||||
{isReadOnly && (
|
||||
<span className={styles.managedLabel} 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={styles.sourceRadioGroup}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value="auto"
|
||||
containerClassName={cx(styles.sourceRadio, styles.sourceRadioAuto)}
|
||||
testId="drawer-source-auto"
|
||||
disabled={disableAuto}
|
||||
>
|
||||
<div className={styles.sourceRadioTitle}>Auto-populated</div>
|
||||
<div className={styles.sourceRadioDesc}>
|
||||
{disableAuto
|
||||
? 'Available once SigNoz has default pricing for this model.'
|
||||
: 'Default pricing from SigNoz.'}
|
||||
</div>
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem
|
||||
value="override"
|
||||
containerClassName={cx(styles.sourceRadio, styles.sourceRadioOverride)}
|
||||
testId="drawer-source-override"
|
||||
>
|
||||
<div className={styles.sourceRadioTitle}>User override</div>
|
||||
<div className={styles.sourceRadioDesc}>
|
||||
Custom pricing. Takes precedence.
|
||||
</div>
|
||||
</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
{showResetConfirm && (
|
||||
<div
|
||||
className={styles.resetConfirm}
|
||||
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={styles.resetConfirmActions}>
|
||||
<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;
|
||||
@@ -0,0 +1,85 @@
|
||||
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { CacheBucketDef, DrawerDraft } from './types';
|
||||
|
||||
// Default page size for the model-cost list request / pagination.
|
||||
export const PAGE_SIZE = 20;
|
||||
|
||||
// URL query-param keys backing the current page and page size. These match the
|
||||
// keys passed to TanStackTable's `enableQueryParams`, so the table owns the
|
||||
// writes while the tab reads them to build the list request.
|
||||
export const PAGE_KEY = 'page';
|
||||
export const LIMIT_KEY = 'limit';
|
||||
|
||||
// URL query-param key backing the model/provider search box.
|
||||
export const SEARCH_KEY = 'search';
|
||||
|
||||
// Debounce applied to the search box before it hits the list API, so typing
|
||||
// doesn't fire a request per keystroke.
|
||||
export const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
// URL query-param key + options for the Source filter, which maps onto the
|
||||
// list API's nullable `isOverride` flag.
|
||||
export const SOURCE_KEY = 'source';
|
||||
|
||||
export type SourceFilter = 'all' | 'override' | 'auto';
|
||||
|
||||
// Labels mirror the Source column badge (see getSourceLabel) so the filter and
|
||||
// the table speak the same language.
|
||||
export const SOURCE_FILTER_OPTIONS: { value: SourceFilter; label: string }[] = [
|
||||
{ value: 'all', label: 'All sources' },
|
||||
{ value: 'override', label: 'User override' },
|
||||
{ value: 'auto', label: 'Auto' },
|
||||
];
|
||||
|
||||
// `undefined` => omit isOverride entirely (no filter); true/false map to the
|
||||
// API's boolean flag.
|
||||
export const SOURCE_FILTER_TO_IS_OVERRIDE: Record<
|
||||
SourceFilter,
|
||||
boolean | undefined
|
||||
> = {
|
||||
all: undefined,
|
||||
override: true,
|
||||
auto: false,
|
||||
};
|
||||
|
||||
// Number of skeleton rows shown while the first page is loading.
|
||||
export const SKELETON_ROW_COUNT = 10;
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
// Optional buckets offered in the "Add pricing bucket" picker. Only the cache
|
||||
// buckets are persisted by the API today (pricing.cache.read/write).
|
||||
export const CACHE_BUCKETS: CacheBucketDef[] = [
|
||||
{ key: 'cacheRead', label: 'cache_read', testId: 'cache-read' },
|
||||
{ key: 'cacheWrite', label: 'cache_write', testId: 'cache-write' },
|
||||
];
|
||||
|
||||
export const EMPTY_DRAFT: DrawerDraft = {
|
||||
id: null,
|
||||
sourceId: null,
|
||||
modelName: '',
|
||||
provider: 'OpenAI',
|
||||
patterns: [],
|
||||
isOverride: true,
|
||||
pricing: {
|
||||
input: null,
|
||||
output: null,
|
||||
cacheMode: CacheModeDTO.unknown,
|
||||
cacheRead: null,
|
||||
cacheWrite: null,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { ChevronDown } from '@signozhq/icons';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
import { startCase } from 'lodash-es';
|
||||
|
||||
import styles from './LLMObservabilityModelPricing.module.scss';
|
||||
import type { PricingRule } from './types';
|
||||
import {
|
||||
formatPricePerMillion,
|
||||
getCanonicalId,
|
||||
getExtraBuckets,
|
||||
getRelativeLastSeen,
|
||||
getSourceLabel,
|
||||
} from './utils';
|
||||
|
||||
interface ColumnsConfig {
|
||||
canManage: boolean;
|
||||
onEdit: (rule: PricingRule) => void;
|
||||
}
|
||||
|
||||
// Column definitions for the model-costs TanStackTable. Sorting is intentionally
|
||||
// off across the board — the list API only accepts offset/limit, so there's no
|
||||
// server-side ordering to back a sortable header yet.
|
||||
export function getModelCostsColumns({
|
||||
canManage,
|
||||
onEdit,
|
||||
}: ColumnsConfig): TableColumnDef<PricingRule>[] {
|
||||
return [
|
||||
{
|
||||
id: 'model',
|
||||
header: 'Model',
|
||||
accessorFn: (row): string => row.modelName ?? '',
|
||||
// Flexes to absorb spare width alongside Extra buckets so the row fills
|
||||
// the container instead of leaving a gap on the right.
|
||||
width: { min: 240, default: '100%' },
|
||||
enableMove: false,
|
||||
enableRemove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<div className={styles.modelCell}>
|
||||
<div
|
||||
className={styles.modelCellName}
|
||||
data-testid={`model-cell-name-${row.id}`}
|
||||
>
|
||||
{row.modelName}
|
||||
</div>
|
||||
<div
|
||||
className={styles.modelCellCanonicalId}
|
||||
data-testid={`model-cell-canonical-id-${row.id}`}
|
||||
>
|
||||
{getCanonicalId(row)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'provider',
|
||||
header: 'Provider',
|
||||
accessorKey: 'provider',
|
||||
width: { min: 140 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): string => row.provider ?? '',
|
||||
},
|
||||
{
|
||||
id: 'input',
|
||||
header: 'Input / 1M',
|
||||
width: { min: 120 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<span
|
||||
className={styles.priceCell}
|
||||
data-testid={`price-cell-input-${row.id}`}
|
||||
>
|
||||
{formatPricePerMillion(row.pricing?.input)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'output',
|
||||
header: 'Output / 1M',
|
||||
width: { min: 120 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<span
|
||||
className={styles.priceCell}
|
||||
data-testid={`price-cell-output-${row.id}`}
|
||||
>
|
||||
{formatPricePerMillion(row.pricing?.output)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'extraBuckets',
|
||||
header: 'Extra buckets',
|
||||
width: { min: 200, default: '100%' },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => {
|
||||
const buckets = getExtraBuckets(row);
|
||||
if (buckets.length === 0) {
|
||||
return <span className={styles.muted}>—</span>;
|
||||
}
|
||||
return (
|
||||
<div className={styles.extraBuckets}>
|
||||
{buckets.map((bucket) => (
|
||||
<Badge
|
||||
key={bucket.key}
|
||||
color="vanilla"
|
||||
variant="outline"
|
||||
className={styles.extraBucketsChip}
|
||||
>
|
||||
<span className={styles.extraBucketsKey}>{startCase(bucket.key)}</span>
|
||||
<span className={styles.extraBucketsPrice}>
|
||||
{formatPricePerMillion(bucket.pricePerMillion)}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
header: 'Source',
|
||||
width: { min: 130 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<Badge
|
||||
color={row.isOverride ? 'amber' : 'robin'}
|
||||
variant="outline"
|
||||
className={styles.sourceBadge}
|
||||
data-testid={`source-badge-${row.id}`}
|
||||
>
|
||||
{getSourceLabel(row)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'lastSeen',
|
||||
header: 'Last seen',
|
||||
width: { min: 120 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): string => getRelativeLastSeen(row),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
// ignoreLastColumnFill keeps the pinned actions cell compact — without it
|
||||
// the table's "last column fills 100%" rule stretches it and left-aligns
|
||||
// the button against a large empty gap.
|
||||
width: { fixed: '88px', ignoreLastColumnFill: true },
|
||||
pin: 'right',
|
||||
enableMove: false,
|
||||
enableRemove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
suffix={<ChevronDown size={14} />}
|
||||
testId={`edit-rule-${row.id}`}
|
||||
onClick={(): void => onEdit(row)}
|
||||
>
|
||||
{canManage ? 'Edit' : 'View'}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
39
frontend/src/container/LLMObservabilityModelPricing/types.ts
Normal file
39
frontend/src/container/LLMObservabilityModelPricing/types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
|
||||
type LlmpricingruletypesLLMPricingRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type PricingRule = LlmpricingruletypesLLMPricingRuleDTO;
|
||||
|
||||
export interface ExtraBucket {
|
||||
key: string;
|
||||
pricePerMillion: number;
|
||||
}
|
||||
|
||||
export type DrawerMode = 'add' | 'edit';
|
||||
|
||||
// Optional pricing buckets the user can add/remove. Keyed by the matching
|
||||
// DrawerDraft['pricing'] field.
|
||||
export type CacheBucketKey = 'cacheRead' | 'cacheWrite';
|
||||
|
||||
export interface CacheBucketDef {
|
||||
key: CacheBucketKey;
|
||||
label: string;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
export interface DrawerDraft {
|
||||
id: string | null;
|
||||
sourceId: string | null;
|
||||
modelName: string;
|
||||
provider: string;
|
||||
patterns: string[];
|
||||
isOverride: boolean;
|
||||
pricing: {
|
||||
input: number | null;
|
||||
output: number | null;
|
||||
cacheMode: CacheModeDTO;
|
||||
cacheRead: number | null;
|
||||
cacheWrite: number | null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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 { EMPTY_DRAFT } from './constants';
|
||||
import type { DrawerDraft, DrawerMode, PricingRule } from './types';
|
||||
import { buildRulePayload, draftFromRule } from './utils';
|
||||
|
||||
interface UseModelCostDrawerResult {
|
||||
isOpen: boolean;
|
||||
mode: DrawerMode;
|
||||
// Seeds the drawer form on open; the form owns its own working copy from
|
||||
// here on (react-hook-form), so there's no live `draft`/`setDraft` to lift.
|
||||
initialDraft: DrawerDraft;
|
||||
openForAdd: (prefillModelName?: string) => void;
|
||||
openForEdit: (rule: PricingRule) => void;
|
||||
close: () => void;
|
||||
save: (draft: DrawerDraft) => 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 [initialDraft, setInitialDraft] = 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');
|
||||
setInitialDraft({
|
||||
...EMPTY_DRAFT,
|
||||
modelName: prefillModelName || '',
|
||||
patterns: prefillModelName ? [prefillModelName] : [],
|
||||
});
|
||||
setSelectedRuleId(null);
|
||||
setSaveError(null);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const openForEdit = useCallback((rule: PricingRule): void => {
|
||||
setMode('edit');
|
||||
setInitialDraft(draftFromRule(rule));
|
||||
setSelectedRuleId(rule.id);
|
||||
setSaveError(null);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback((): void => {
|
||||
setIsOpen(false);
|
||||
setSelectedRuleId(null);
|
||||
setSaveError(null);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(
|
||||
async (draft: DrawerDraft): 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, invalidateList, mode],
|
||||
);
|
||||
|
||||
const deleteRule = useCallback(async (): Promise<void> => {
|
||||
if (!selectedRuleId) {
|
||||
return;
|
||||
}
|
||||
setSaveError(null);
|
||||
try {
|
||||
await deleteRuleApi({ pathParams: { id: selectedRuleId } });
|
||||
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, selectedRuleId, invalidateList]);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
mode,
|
||||
initialDraft,
|
||||
openForAdd,
|
||||
openForEdit,
|
||||
close,
|
||||
save,
|
||||
deleteRule,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
saveError,
|
||||
selectedRuleId,
|
||||
};
|
||||
}
|
||||
180
frontend/src/container/LLMObservabilityModelPricing/utils.ts
Normal file
180
frontend/src/container/LLMObservabilityModelPricing/utils.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
|
||||
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
|
||||
type LlmpricingruletypesLLMPricingCacheCostsDTO,
|
||||
type LlmpricingruletypesLLMRulePricingDTO,
|
||||
type LlmpricingruletypesUpdatableLLMPricingRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
import type {
|
||||
DrawerDraft,
|
||||
DrawerMode,
|
||||
ExtraBucket,
|
||||
PricingRule,
|
||||
} from './types';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
// Relative, human-readable distance from now (e.g. "2 days ago"); "—" for
|
||||
// missing/invalid timestamps.
|
||||
const getRelativeTime = (
|
||||
timestamp: string | number | Date | null | undefined,
|
||||
): string => {
|
||||
const parsed = timestamp != null ? dayjs(timestamp) : null;
|
||||
return parsed?.isValid() ? parsed.fromNow() : '—';
|
||||
};
|
||||
|
||||
// Type guard so callers narrow `number | null | undefined` down to `number`
|
||||
// in the truthy branch — no casts needed at the call sites.
|
||||
const hasCacheValue = (value: number | null | undefined): value is number =>
|
||||
typeof value === 'number' && value > 0;
|
||||
|
||||
// ─── Input helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
// Parses a price input's raw string. Empty → null (used by optional buckets),
|
||||
// otherwise a finite number (NaN coerced to 0).
|
||||
export const parsePricingAmount = (raw: string): number | null => {
|
||||
if (raw.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
};
|
||||
|
||||
// ─── Display helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
// Pricing fields are typed as required numbers, but guard null/undefined
|
||||
// anyway — API responses don't always honour the spec, and toFixed() on a
|
||||
// missing value would crash the row instead of showing "—".
|
||||
export const formatPricePerMillion = (value: number | undefined): string => {
|
||||
if (value === undefined || value === null) {
|
||||
return '—';
|
||||
}
|
||||
// 2dp is enough for per-1M pricing; revisit precision when real sub-cent data lands.
|
||||
return `$${value.toFixed(2)}`;
|
||||
};
|
||||
|
||||
export const getExtraBuckets = (rule: PricingRule): ExtraBucket[] => {
|
||||
const cache = rule.pricing?.cache;
|
||||
if (!cache) {
|
||||
return [];
|
||||
}
|
||||
const buckets: ExtraBucket[] = [];
|
||||
if (hasCacheValue(cache.read)) {
|
||||
buckets.push({ key: 'cache_read', pricePerMillion: cache.read });
|
||||
}
|
||||
if (hasCacheValue(cache.write)) {
|
||||
buckets.push({ key: 'cache_write', pricePerMillion: cache.write });
|
||||
}
|
||||
return buckets;
|
||||
};
|
||||
|
||||
export const getSourceLabel = (rule: PricingRule): 'Auto' | 'User override' =>
|
||||
rule.isOverride ? 'User override' : 'Auto';
|
||||
|
||||
export const getRelativeLastSeen = (rule: PricingRule): string =>
|
||||
getRelativeTime(rule.updatedAt || rule.syncedAt || rule.createdAt);
|
||||
|
||||
// Canonical id shown under the model name, e.g. "openai:gpt-4o". Both segments
|
||||
// are lower-cased so the id is consistently normalised (providers/models can
|
||||
// arrive with mixed casing).
|
||||
export const getCanonicalId = (rule: PricingRule): string => {
|
||||
const provider = rule.provider?.trim().toLowerCase() || 'unknown';
|
||||
const model = rule.modelName?.trim().toLowerCase() || 'unknown';
|
||||
return `${provider}:${model}`;
|
||||
};
|
||||
|
||||
// ─── Drawer draft <-> API helpers ────────────────────────────────────────────
|
||||
|
||||
export const draftFromRule = (rule: PricingRule): DrawerDraft => ({
|
||||
id: rule.id,
|
||||
sourceId: rule.sourceId ?? null,
|
||||
modelName: rule.modelName,
|
||||
provider: rule.provider,
|
||||
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 buildCacheCosts = (
|
||||
pricing: DrawerDraft['pricing'],
|
||||
): LlmpricingruletypesLLMPricingCacheCostsDTO | undefined => {
|
||||
const { cacheMode, cacheRead, cacheWrite } = pricing;
|
||||
if (!hasCacheValue(cacheRead) && !hasCacheValue(cacheWrite)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
mode: cacheMode,
|
||||
...(hasCacheValue(cacheRead) && { read: cacheRead }),
|
||||
...(hasCacheValue(cacheWrite) && { write: cacheWrite }),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildPricingPayload = (
|
||||
draft: DrawerDraft,
|
||||
): LlmpricingruletypesLLMRulePricingDTO => {
|
||||
const cache = buildCacheCosts(draft.pricing);
|
||||
return {
|
||||
input: draft.pricing.input ?? 0,
|
||||
output: draft.pricing.output ?? 0,
|
||||
...(cache && { cache }),
|
||||
};
|
||||
};
|
||||
|
||||
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,
|
||||
isOverride: draft.isOverride,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: buildPricingPayload(draft),
|
||||
});
|
||||
|
||||
// Field validators follow react-hook-form's `validate` convention: return
|
||||
// `true` when valid, or the error message string when invalid.
|
||||
|
||||
// Billing model ID is only user-entered when adding; in edit mode it's
|
||||
// immutable, so there's nothing to require.
|
||||
export const validateModelName = (
|
||||
modelName: string,
|
||||
mode: DrawerMode,
|
||||
): true | string =>
|
||||
mode === 'add' && !modelName.trim() ? 'Billing model ID is required.' : true;
|
||||
|
||||
export const validateProvider = (provider: string): true | string =>
|
||||
provider.trim() ? true : '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). `!(x > 0)`
|
||||
// (rather than `x <= 0`) so NaN counts as invalid.
|
||||
export const validatePricing = (
|
||||
pricing: DrawerDraft['pricing'],
|
||||
isOverride: boolean,
|
||||
): true | string => {
|
||||
if (!isOverride) {
|
||||
return true;
|
||||
}
|
||||
if (pricing.input === null || pricing.input <= 0) {
|
||||
return 'Input cost must be greater than 0.';
|
||||
}
|
||||
if (pricing.output === null || pricing.output <= 0) {
|
||||
return 'Output cost must be greater than 0.';
|
||||
}
|
||||
if ((pricing.cacheRead ?? 0) < 0 || (pricing.cacheWrite ?? 0) < 0) {
|
||||
return 'Cache costs must be non-negative.';
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -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];
|
||||
|
||||
27
frontend/src/hooks/useFeatureGatedRouteRedirect.ts
Normal file
27
frontend/src/hooks/useFeatureGatedRouteRedirect.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { matchPath } from 'react-router-dom';
|
||||
|
||||
import { useIsAIObservabilityEnabled } from './useIsAIObservabilityEnabled';
|
||||
|
||||
interface FeatureGatedRoute {
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export function useFeatureGatedRouteRedirect(pathname: string): string | null {
|
||||
const isAIObservabilityEnabled = useIsAIObservabilityEnabled();
|
||||
|
||||
const gatedRoutes: FeatureGatedRoute[] = [
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
|
||||
enabled: isAIObservabilityEnabled,
|
||||
},
|
||||
];
|
||||
|
||||
const blockedRoute = gatedRoutes.find(
|
||||
(route) =>
|
||||
matchPath(pathname, { path: route.path }) !== null && !route.enabled,
|
||||
);
|
||||
|
||||
return blockedRoute ? ROUTES.HOME : null;
|
||||
}
|
||||
11
frontend/src/hooks/useIsAIObservabilityEnabled.ts
Normal file
11
frontend/src/hooks/useIsAIObservabilityEnabled.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
export function useIsAIObservabilityEnabled(): boolean {
|
||||
const { featureFlags } = useAppContext();
|
||||
|
||||
return (
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.AI_OBSERVABILITY)
|
||||
?.active || false
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import LLMObservabilityModelPricing from 'container/LLMObservabilityModelPricing/LLMObservabilityModelPricing';
|
||||
|
||||
function LLMObservabilityModelPricingPage(): JSX.Element {
|
||||
return <LLMObservabilityModelPricing />;
|
||||
}
|
||||
|
||||
export default LLMObservabilityModelPricingPage;
|
||||
@@ -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'],
|
||||
};
|
||||
|
||||
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'],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user