refactor(model-pricing): split visual pricing editor modules
- extract pricing form primitives, snapshot helpers, and table column setup to keep the editor components smaller. - remove draft comparison UI now that switching models discards unsaved edits. - refine the model list with a fixed actions column and tighter mode and price summary display.
This commit is contained in:
@@ -0,0 +1,296 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2023-2026 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
import * as z from 'zod'
|
||||||
|
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
|
||||||
|
import { formatPricingNumber } from './pricing-format'
|
||||||
|
|
||||||
|
export const createModelPricingSchema = (t: (key: string) => string) =>
|
||||||
|
z.object({
|
||||||
|
name: z.string().min(1, t('Model name is required')),
|
||||||
|
price: z.string().optional(),
|
||||||
|
ratio: z.string().optional(),
|
||||||
|
cacheRatio: z.string().optional(),
|
||||||
|
createCacheRatio: z.string().optional(),
|
||||||
|
completionRatio: z.string().optional(),
|
||||||
|
imageRatio: z.string().optional(),
|
||||||
|
audioRatio: z.string().optional(),
|
||||||
|
audioCompletionRatio: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ModelPricingFormValues = z.infer<
|
||||||
|
ReturnType<typeof createModelPricingSchema>
|
||||||
|
>
|
||||||
|
|
||||||
|
export type PricingMode = 'per-token' | 'per-request' | 'tiered_expr'
|
||||||
|
|
||||||
|
export type LaneKey =
|
||||||
|
| 'completion'
|
||||||
|
| 'cache'
|
||||||
|
| 'createCache'
|
||||||
|
| 'image'
|
||||||
|
| 'audioInput'
|
||||||
|
| 'audioOutput'
|
||||||
|
|
||||||
|
export type ModelRatioData = {
|
||||||
|
name: string
|
||||||
|
price?: string
|
||||||
|
ratio?: string
|
||||||
|
cacheRatio?: string
|
||||||
|
createCacheRatio?: string
|
||||||
|
completionRatio?: string
|
||||||
|
imageRatio?: string
|
||||||
|
audioRatio?: string
|
||||||
|
audioCompletionRatio?: string
|
||||||
|
billingMode?: PricingMode
|
||||||
|
billingExpr?: string
|
||||||
|
requestRuleExpr?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PreviewRow = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
multiline?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const numericDraftRegex = /^(\d+(\.\d*)?|\.\d*)?$/
|
||||||
|
|
||||||
|
export const EMPTY_LANE_PRICES: Record<LaneKey, string> = {
|
||||||
|
completion: '',
|
||||||
|
cache: '',
|
||||||
|
createCache: '',
|
||||||
|
image: '',
|
||||||
|
audioInput: '',
|
||||||
|
audioOutput: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_LANE_ENABLED: Record<LaneKey, boolean> = {
|
||||||
|
completion: false,
|
||||||
|
cache: false,
|
||||||
|
createCache: false,
|
||||||
|
image: false,
|
||||||
|
audioInput: false,
|
||||||
|
audioOutput: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ratioFieldByLane: Record<LaneKey, keyof ModelPricingFormValues> = {
|
||||||
|
completion: 'completionRatio',
|
||||||
|
cache: 'cacheRatio',
|
||||||
|
createCache: 'createCacheRatio',
|
||||||
|
image: 'imageRatio',
|
||||||
|
audioInput: 'audioRatio',
|
||||||
|
audioOutput: 'audioCompletionRatio',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const laneConfigs: Array<{
|
||||||
|
key: LaneKey
|
||||||
|
titleKey: string
|
||||||
|
descriptionKey: string
|
||||||
|
placeholder: string
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
key: 'completion',
|
||||||
|
titleKey: 'Completion price',
|
||||||
|
descriptionKey: 'Output token price for generated tokens.',
|
||||||
|
placeholder: '15',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cache',
|
||||||
|
titleKey: 'Cache read price',
|
||||||
|
descriptionKey: 'Token price for cache reads.',
|
||||||
|
placeholder: '0.3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'createCache',
|
||||||
|
titleKey: 'Cache write price',
|
||||||
|
descriptionKey: 'Token price for creating cache entries.',
|
||||||
|
placeholder: '3.75',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'image',
|
||||||
|
titleKey: 'Image input price',
|
||||||
|
descriptionKey: 'Token price for image input.',
|
||||||
|
placeholder: '2.5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'audioInput',
|
||||||
|
titleKey: 'Audio input price',
|
||||||
|
descriptionKey: 'Token price for audio input.',
|
||||||
|
placeholder: '3.81',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'audioOutput',
|
||||||
|
titleKey: 'Audio output price',
|
||||||
|
descriptionKey: 'Token price for audio output.',
|
||||||
|
placeholder: '15.11',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function hasValue(value: unknown): boolean {
|
||||||
|
return (
|
||||||
|
value !== '' && value !== null && value !== undefined && value !== false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toNumberOrNull(value: unknown): number | null {
|
||||||
|
if (!hasValue(value) && value !== 0) return null
|
||||||
|
const num = Number(value)
|
||||||
|
return Number.isFinite(num) ? num : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioToBasePrice(ratio: unknown): string {
|
||||||
|
const num = toNumberOrNull(ratio)
|
||||||
|
if (num === null) return ''
|
||||||
|
return formatPricingNumber(num * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveLanePrice(
|
||||||
|
ratio: unknown,
|
||||||
|
denominator: unknown,
|
||||||
|
fallback = ''
|
||||||
|
): string {
|
||||||
|
const ratioNumber = toNumberOrNull(ratio)
|
||||||
|
const denominatorNumber = toNumberOrNull(denominator)
|
||||||
|
if (ratioNumber === null || denominatorNumber === null) return fallback
|
||||||
|
return formatPricingNumber(ratioNumber * denominatorNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitialLaneState(data?: ModelRatioData | null) {
|
||||||
|
if (!data) {
|
||||||
|
return {
|
||||||
|
promptPrice: '',
|
||||||
|
prices: { ...EMPTY_LANE_PRICES },
|
||||||
|
enabled: { ...EMPTY_LANE_ENABLED },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptPrice = ratioToBasePrice(data.ratio)
|
||||||
|
const audioInputPrice = deriveLanePrice(data.audioRatio, promptPrice)
|
||||||
|
const prices: Record<LaneKey, string> = {
|
||||||
|
completion: deriveLanePrice(data.completionRatio, promptPrice),
|
||||||
|
cache: deriveLanePrice(data.cacheRatio, promptPrice),
|
||||||
|
createCache: deriveLanePrice(data.createCacheRatio, promptPrice),
|
||||||
|
image: deriveLanePrice(data.imageRatio, promptPrice),
|
||||||
|
audioInput: audioInputPrice,
|
||||||
|
audioOutput: deriveLanePrice(data.audioCompletionRatio, audioInputPrice),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
promptPrice,
|
||||||
|
prices,
|
||||||
|
enabled: {
|
||||||
|
completion: hasValue(data.completionRatio),
|
||||||
|
cache: hasValue(data.cacheRatio),
|
||||||
|
createCache: hasValue(data.createCacheRatio),
|
||||||
|
image: hasValue(data.imageRatio),
|
||||||
|
audioInput: hasValue(data.audioRatio),
|
||||||
|
audioOutput: hasValue(data.audioCompletionRatio),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPreviewRows(
|
||||||
|
values: ModelPricingFormValues,
|
||||||
|
mode: PricingMode,
|
||||||
|
billingExpr: string,
|
||||||
|
requestRuleExpr: string,
|
||||||
|
promptPrice: string,
|
||||||
|
lanePrices: Record<LaneKey, string>,
|
||||||
|
laneEnabled: Record<LaneKey, boolean>,
|
||||||
|
t: (key: string) => string
|
||||||
|
): PreviewRow[] {
|
||||||
|
if (mode === 'tiered_expr') {
|
||||||
|
const effectiveExpr = combineBillingExpr(billingExpr, requestRuleExpr)
|
||||||
|
return [
|
||||||
|
{ key: 'mode', label: 'BillingMode', value: 'tiered_expr' },
|
||||||
|
{
|
||||||
|
key: 'expr',
|
||||||
|
label: t('Expression'),
|
||||||
|
value: effectiveExpr || t('Empty'),
|
||||||
|
multiline: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'per-request') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'price',
|
||||||
|
label: 'ModelPrice',
|
||||||
|
value: values.price || t('Empty'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'inputPrice',
|
||||||
|
label: t('Input price'),
|
||||||
|
value: promptPrice ? `$${promptPrice}` : t('Empty'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'completion',
|
||||||
|
label: t('Completion price'),
|
||||||
|
value:
|
||||||
|
laneEnabled.completion && lanePrices.completion
|
||||||
|
? `$${lanePrices.completion}`
|
||||||
|
: t('Empty'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cache',
|
||||||
|
label: t('Cache read price'),
|
||||||
|
value:
|
||||||
|
laneEnabled.cache && lanePrices.cache
|
||||||
|
? `$${lanePrices.cache}`
|
||||||
|
: t('Empty'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'createCache',
|
||||||
|
label: t('Cache write price'),
|
||||||
|
value:
|
||||||
|
laneEnabled.createCache && lanePrices.createCache
|
||||||
|
? `$${lanePrices.createCache}`
|
||||||
|
: t('Empty'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'image',
|
||||||
|
label: t('Image input price'),
|
||||||
|
value:
|
||||||
|
laneEnabled.image && lanePrices.image
|
||||||
|
? `$${lanePrices.image}`
|
||||||
|
: t('Empty'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'audio',
|
||||||
|
label: t('Audio input price'),
|
||||||
|
value:
|
||||||
|
laneEnabled.audioInput && lanePrices.audioInput
|
||||||
|
? `$${lanePrices.audioInput}`
|
||||||
|
: t('Empty'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'audioCompletion',
|
||||||
|
label: t('Audio output price'),
|
||||||
|
value:
|
||||||
|
laneEnabled.audioOutput && lanePrices.audioOutput
|
||||||
|
? `$${lanePrices.audioOutput}`
|
||||||
|
: t('Empty'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2023-2026 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
} from '@/components/ui/input-group'
|
||||||
|
import {
|
||||||
|
SettingsControlGroup,
|
||||||
|
SettingsSwitchField,
|
||||||
|
} from '../components/settings-form-layout'
|
||||||
|
|
||||||
|
export function PriceInput(props: {
|
||||||
|
value: string
|
||||||
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupAddon>$</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
inputMode='decimal'
|
||||||
|
value={props.value}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
disabled={props.disabled}
|
||||||
|
onChange={(event) => props.onChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon align='inline-end'>$/1M</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriceLane(props: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
placeholder: string
|
||||||
|
value: string
|
||||||
|
enabled: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
onEnabledChange: (checked: boolean) => void
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const effectiveDisabled = props.disabled || !props.enabled
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsControlGroup
|
||||||
|
className={cn('space-y-3', effectiveDisabled && 'opacity-75')}
|
||||||
|
data-disabled={effectiveDisabled || undefined}
|
||||||
|
>
|
||||||
|
<SettingsSwitchField
|
||||||
|
checked={props.enabled}
|
||||||
|
disabled={props.disabled}
|
||||||
|
onCheckedChange={props.onEnabledChange}
|
||||||
|
label={props.title}
|
||||||
|
description={props.description}
|
||||||
|
aria-label={props.title}
|
||||||
|
/>
|
||||||
|
<PriceInput
|
||||||
|
value={props.value}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
disabled={effectiveDisabled}
|
||||||
|
onChange={props.onChange}
|
||||||
|
/>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
{props.enabled
|
||||||
|
? t('USD price per 1M tokens.')
|
||||||
|
: t('Disabled lanes are omitted on save.')}
|
||||||
|
</p>
|
||||||
|
</SettingsControlGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import * as z from 'zod'
|
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { AlertTriangle } from 'lucide-react'
|
import { AlertTriangle } from 'lucide-react'
|
||||||
@@ -61,54 +60,27 @@ import {
|
|||||||
} from '@/components/ui/sheet'
|
} from '@/components/ui/sheet'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { sideDrawerContentClassName } from '@/components/drawer-layout'
|
import { sideDrawerContentClassName } from '@/components/drawer-layout'
|
||||||
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
|
|
||||||
import {
|
import {
|
||||||
SettingsControlGroup,
|
EMPTY_LANE_ENABLED,
|
||||||
SettingsSwitchField,
|
EMPTY_LANE_PRICES,
|
||||||
} from '../components/settings-form-layout'
|
buildPreviewRows,
|
||||||
|
createInitialLaneState,
|
||||||
|
createModelPricingSchema,
|
||||||
|
hasValue,
|
||||||
|
laneConfigs,
|
||||||
|
numericDraftRegex,
|
||||||
|
ratioFieldByLane,
|
||||||
|
toNumberOrNull,
|
||||||
|
type LaneKey,
|
||||||
|
type ModelPricingFormValues,
|
||||||
|
type ModelRatioData,
|
||||||
|
type PricingMode,
|
||||||
|
} from './model-pricing-core'
|
||||||
|
import { PriceInput, PriceLane } from './model-pricing-inputs'
|
||||||
import { formatPricingNumber } from './pricing-format'
|
import { formatPricingNumber } from './pricing-format'
|
||||||
import { TieredPricingEditor } from './tiered-pricing-editor'
|
import { TieredPricingEditor } from './tiered-pricing-editor'
|
||||||
|
|
||||||
const createModelPricingSchema = (t: (key: string) => string) =>
|
export type { ModelRatioData } from './model-pricing-core'
|
||||||
z.object({
|
|
||||||
name: z.string().min(1, t('Model name is required')),
|
|
||||||
price: z.string().optional(),
|
|
||||||
ratio: z.string().optional(),
|
|
||||||
cacheRatio: z.string().optional(),
|
|
||||||
createCacheRatio: z.string().optional(),
|
|
||||||
completionRatio: z.string().optional(),
|
|
||||||
imageRatio: z.string().optional(),
|
|
||||||
audioRatio: z.string().optional(),
|
|
||||||
audioCompletionRatio: z.string().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
type ModelPricingFormValues = z.infer<
|
|
||||||
ReturnType<typeof createModelPricingSchema>
|
|
||||||
>
|
|
||||||
|
|
||||||
type PricingMode = 'per-token' | 'per-request' | 'tiered_expr'
|
|
||||||
type LaneKey =
|
|
||||||
| 'completion'
|
|
||||||
| 'cache'
|
|
||||||
| 'createCache'
|
|
||||||
| 'image'
|
|
||||||
| 'audioInput'
|
|
||||||
| 'audioOutput'
|
|
||||||
|
|
||||||
export type ModelRatioData = {
|
|
||||||
name: string
|
|
||||||
price?: string
|
|
||||||
ratio?: string
|
|
||||||
cacheRatio?: string
|
|
||||||
createCacheRatio?: string
|
|
||||||
completionRatio?: string
|
|
||||||
imageRatio?: string
|
|
||||||
audioRatio?: string
|
|
||||||
audioCompletionRatio?: string
|
|
||||||
billingMode?: PricingMode
|
|
||||||
billingExpr?: string
|
|
||||||
requestRuleExpr?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModelPricingSheetProps = {
|
type ModelPricingSheetProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -127,239 +99,6 @@ export type ModelPricingEditorPanelHandle = {
|
|||||||
commitDraft: () => Promise<ModelRatioData | null>
|
commitDraft: () => Promise<ModelRatioData | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
type PreviewRow = {
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
multiline?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const numericDraftRegex = /^(\d+(\.\d*)?|\.\d*)?$/
|
|
||||||
|
|
||||||
const EMPTY_LANE_PRICES: Record<LaneKey, string> = {
|
|
||||||
completion: '',
|
|
||||||
cache: '',
|
|
||||||
createCache: '',
|
|
||||||
image: '',
|
|
||||||
audioInput: '',
|
|
||||||
audioOutput: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY_LANE_ENABLED: Record<LaneKey, boolean> = {
|
|
||||||
completion: false,
|
|
||||||
cache: false,
|
|
||||||
createCache: false,
|
|
||||||
image: false,
|
|
||||||
audioInput: false,
|
|
||||||
audioOutput: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
const ratioFieldByLane: Record<LaneKey, keyof ModelPricingFormValues> = {
|
|
||||||
completion: 'completionRatio',
|
|
||||||
cache: 'cacheRatio',
|
|
||||||
createCache: 'createCacheRatio',
|
|
||||||
image: 'imageRatio',
|
|
||||||
audioInput: 'audioRatio',
|
|
||||||
audioOutput: 'audioCompletionRatio',
|
|
||||||
}
|
|
||||||
|
|
||||||
const laneConfigs: Array<{
|
|
||||||
key: LaneKey
|
|
||||||
titleKey: string
|
|
||||||
descriptionKey: string
|
|
||||||
placeholder: string
|
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
key: 'completion',
|
|
||||||
titleKey: 'Completion price',
|
|
||||||
descriptionKey: 'Output token price for generated tokens.',
|
|
||||||
placeholder: '15',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'cache',
|
|
||||||
titleKey: 'Cache read price',
|
|
||||||
descriptionKey: 'Token price for cache reads.',
|
|
||||||
placeholder: '0.3',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'createCache',
|
|
||||||
titleKey: 'Cache write price',
|
|
||||||
descriptionKey: 'Token price for creating cache entries.',
|
|
||||||
placeholder: '3.75',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'image',
|
|
||||||
titleKey: 'Image input price',
|
|
||||||
descriptionKey: 'Token price for image input.',
|
|
||||||
placeholder: '2.5',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'audioInput',
|
|
||||||
titleKey: 'Audio input price',
|
|
||||||
descriptionKey: 'Token price for audio input.',
|
|
||||||
placeholder: '3.81',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'audioOutput',
|
|
||||||
titleKey: 'Audio output price',
|
|
||||||
descriptionKey: 'Token price for audio output.',
|
|
||||||
placeholder: '15.11',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function hasValue(value: unknown): boolean {
|
|
||||||
return (
|
|
||||||
value !== '' && value !== null && value !== undefined && value !== false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toNumberOrNull(value: unknown): number | null {
|
|
||||||
if (!hasValue(value) && value !== 0) return null
|
|
||||||
const num = Number(value)
|
|
||||||
return Number.isFinite(num) ? num : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function ratioToBasePrice(ratio: unknown): string {
|
|
||||||
const num = toNumberOrNull(ratio)
|
|
||||||
if (num === null) return ''
|
|
||||||
return formatPricingNumber(num * 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
function deriveLanePrice(
|
|
||||||
ratio: unknown,
|
|
||||||
denominator: unknown,
|
|
||||||
fallback = ''
|
|
||||||
): string {
|
|
||||||
const ratioNumber = toNumberOrNull(ratio)
|
|
||||||
const denominatorNumber = toNumberOrNull(denominator)
|
|
||||||
if (ratioNumber === null || denominatorNumber === null) return fallback
|
|
||||||
return formatPricingNumber(ratioNumber * denominatorNumber)
|
|
||||||
}
|
|
||||||
|
|
||||||
function createInitialLaneState(data?: ModelRatioData | null) {
|
|
||||||
if (!data) {
|
|
||||||
return {
|
|
||||||
promptPrice: '',
|
|
||||||
prices: { ...EMPTY_LANE_PRICES },
|
|
||||||
enabled: { ...EMPTY_LANE_ENABLED },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const promptPrice = ratioToBasePrice(data.ratio)
|
|
||||||
const audioInputPrice = deriveLanePrice(data.audioRatio, promptPrice)
|
|
||||||
const prices: Record<LaneKey, string> = {
|
|
||||||
completion: deriveLanePrice(data.completionRatio, promptPrice),
|
|
||||||
cache: deriveLanePrice(data.cacheRatio, promptPrice),
|
|
||||||
createCache: deriveLanePrice(data.createCacheRatio, promptPrice),
|
|
||||||
image: deriveLanePrice(data.imageRatio, promptPrice),
|
|
||||||
audioInput: audioInputPrice,
|
|
||||||
audioOutput: deriveLanePrice(data.audioCompletionRatio, audioInputPrice),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
promptPrice,
|
|
||||||
prices,
|
|
||||||
enabled: {
|
|
||||||
completion: hasValue(data.completionRatio),
|
|
||||||
cache: hasValue(data.cacheRatio),
|
|
||||||
createCache: hasValue(data.createCacheRatio),
|
|
||||||
image: hasValue(data.imageRatio),
|
|
||||||
audioInput: hasValue(data.audioRatio),
|
|
||||||
audioOutput: hasValue(data.audioCompletionRatio),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPreviewRows(
|
|
||||||
values: ModelPricingFormValues,
|
|
||||||
mode: PricingMode,
|
|
||||||
billingExpr: string,
|
|
||||||
requestRuleExpr: string,
|
|
||||||
promptPrice: string,
|
|
||||||
lanePrices: Record<LaneKey, string>,
|
|
||||||
laneEnabled: Record<LaneKey, boolean>,
|
|
||||||
t: (key: string) => string
|
|
||||||
): PreviewRow[] {
|
|
||||||
if (mode === 'tiered_expr') {
|
|
||||||
const effectiveExpr = combineBillingExpr(billingExpr, requestRuleExpr)
|
|
||||||
return [
|
|
||||||
{ key: 'mode', label: 'BillingMode', value: 'tiered_expr' },
|
|
||||||
{
|
|
||||||
key: 'expr',
|
|
||||||
label: t('Expression'),
|
|
||||||
value: effectiveExpr || t('Empty'),
|
|
||||||
multiline: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === 'per-request') {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
key: 'price',
|
|
||||||
label: 'ModelPrice',
|
|
||||||
value: values.price || t('Empty'),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
key: 'inputPrice',
|
|
||||||
label: t('Input price'),
|
|
||||||
value: promptPrice ? `$${promptPrice}` : t('Empty'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'completion',
|
|
||||||
label: t('Completion price'),
|
|
||||||
value:
|
|
||||||
laneEnabled.completion && lanePrices.completion
|
|
||||||
? `$${lanePrices.completion}`
|
|
||||||
: t('Empty'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'cache',
|
|
||||||
label: t('Cache read price'),
|
|
||||||
value:
|
|
||||||
laneEnabled.cache && lanePrices.cache
|
|
||||||
? `$${lanePrices.cache}`
|
|
||||||
: t('Empty'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'createCache',
|
|
||||||
label: t('Cache write price'),
|
|
||||||
value:
|
|
||||||
laneEnabled.createCache && lanePrices.createCache
|
|
||||||
? `$${lanePrices.createCache}`
|
|
||||||
: t('Empty'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'image',
|
|
||||||
label: t('Image input price'),
|
|
||||||
value:
|
|
||||||
laneEnabled.image && lanePrices.image
|
|
||||||
? `$${lanePrices.image}`
|
|
||||||
: t('Empty'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'audio',
|
|
||||||
label: t('Audio input price'),
|
|
||||||
value:
|
|
||||||
laneEnabled.audioInput && lanePrices.audioInput
|
|
||||||
? `$${lanePrices.audioInput}`
|
|
||||||
: t('Empty'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'audioCompletion',
|
|
||||||
label: t('Audio output price'),
|
|
||||||
value:
|
|
||||||
laneEnabled.audioOutput && lanePrices.audioOutput
|
|
||||||
? `$${lanePrices.audioOutput}`
|
|
||||||
: t('Empty'),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ModelPricingSheet = forwardRef<
|
export const ModelPricingSheet = forwardRef<
|
||||||
ModelPricingEditorPanelHandle,
|
ModelPricingEditorPanelHandle,
|
||||||
ModelPricingSheetProps
|
ModelPricingSheetProps
|
||||||
@@ -936,65 +675,3 @@ export const ModelPricingEditorPanel = forwardRef<
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
function PriceInput(props: {
|
|
||||||
value: string
|
|
||||||
placeholder?: string
|
|
||||||
disabled?: boolean
|
|
||||||
onChange: (value: string) => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<InputGroup>
|
|
||||||
<InputGroupAddon>$</InputGroupAddon>
|
|
||||||
<InputGroupInput
|
|
||||||
inputMode='decimal'
|
|
||||||
value={props.value}
|
|
||||||
placeholder={props.placeholder}
|
|
||||||
disabled={props.disabled}
|
|
||||||
onChange={(event) => props.onChange(event.target.value)}
|
|
||||||
/>
|
|
||||||
<InputGroupAddon align='inline-end'>$/1M</InputGroupAddon>
|
|
||||||
</InputGroup>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PriceLane(props: {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
placeholder: string
|
|
||||||
value: string
|
|
||||||
enabled: boolean
|
|
||||||
disabled?: boolean
|
|
||||||
onEnabledChange: (checked: boolean) => void
|
|
||||||
onChange: (value: string) => void
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const effectiveDisabled = props.disabled || !props.enabled
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsControlGroup
|
|
||||||
className={cn('space-y-3', effectiveDisabled && 'opacity-75')}
|
|
||||||
data-disabled={effectiveDisabled || undefined}
|
|
||||||
>
|
|
||||||
<SettingsSwitchField
|
|
||||||
checked={props.enabled}
|
|
||||||
disabled={props.disabled}
|
|
||||||
onCheckedChange={props.onEnabledChange}
|
|
||||||
label={props.title}
|
|
||||||
description={props.description}
|
|
||||||
aria-label={props.title}
|
|
||||||
/>
|
|
||||||
<PriceInput
|
|
||||||
value={props.value}
|
|
||||||
placeholder={props.placeholder}
|
|
||||||
disabled={effectiveDisabled}
|
|
||||||
onChange={props.onChange}
|
|
||||||
/>
|
|
||||||
<p className='text-muted-foreground text-xs'>
|
|
||||||
{props.enabled
|
|
||||||
? t('USD price per 1M tokens.')
|
|
||||||
: t('Disabled lanes are omitted on save.')}
|
|
||||||
</p>
|
|
||||||
</SettingsControlGroup>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2023-2026 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
import { splitBillingExprAndRequestRules } from '@/features/pricing/lib/billing-expr'
|
||||||
|
import { safeJsonParse } from '../utils/json-parser'
|
||||||
|
import { formatPricingNumber } from './pricing-format'
|
||||||
|
|
||||||
|
export type ModelPricingSnapshotInput = {
|
||||||
|
modelPrice: string
|
||||||
|
modelRatio: string
|
||||||
|
cacheRatio: string
|
||||||
|
createCacheRatio: string
|
||||||
|
completionRatio: string
|
||||||
|
imageRatio: string
|
||||||
|
audioRatio: string
|
||||||
|
audioCompletionRatio: string
|
||||||
|
billingMode: string
|
||||||
|
billingExpr: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelPricingSnapshot = {
|
||||||
|
name: string
|
||||||
|
price?: string
|
||||||
|
ratio?: string
|
||||||
|
cacheRatio?: string
|
||||||
|
createCacheRatio?: string
|
||||||
|
completionRatio?: string
|
||||||
|
imageRatio?: string
|
||||||
|
audioRatio?: string
|
||||||
|
audioCompletionRatio?: string
|
||||||
|
billingMode?: string
|
||||||
|
billingExpr?: string
|
||||||
|
requestRuleExpr?: string
|
||||||
|
hasConflict: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelRow = ModelPricingSnapshot & {
|
||||||
|
saved?: ModelPricingSnapshot
|
||||||
|
draft?: ModelPricingSnapshot
|
||||||
|
isDraftChanged: boolean
|
||||||
|
isDraftDeleted: boolean
|
||||||
|
isDraftNew: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasPricingValue = (value?: string) =>
|
||||||
|
value !== undefined && value !== ''
|
||||||
|
|
||||||
|
const toNumberOrNull = (value?: string) => {
|
||||||
|
if (!hasPricingValue(value)) return null
|
||||||
|
const num = Number(value)
|
||||||
|
return Number.isFinite(num) ? num : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratioToPrice = (ratio?: string, denominator?: string) => {
|
||||||
|
const ratioNumber = toNumberOrNull(ratio)
|
||||||
|
const denominatorNumber = denominator ? toNumberOrNull(denominator) : 2
|
||||||
|
if (ratioNumber === null || denominatorNumber === null) return ''
|
||||||
|
return formatPricingNumber(ratioNumber * denominatorNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getModeLabel = (mode?: string) => {
|
||||||
|
if (mode === 'per-request') return 'Per-request'
|
||||||
|
if (mode === 'tiered_expr') return 'Expression'
|
||||||
|
return 'Per-token'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getModeVariant = (
|
||||||
|
mode?: string
|
||||||
|
): 'warning' | 'info' | 'success' => {
|
||||||
|
if (mode === 'per-request') return 'warning'
|
||||||
|
if (mode === 'tiered_expr') return 'info'
|
||||||
|
return 'success'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getExpressionSummary = (
|
||||||
|
row: ModelPricingSnapshot,
|
||||||
|
t: (key: string) => string
|
||||||
|
) => {
|
||||||
|
const tierCount = (row.billingExpr?.match(/tier\(/g) || []).length
|
||||||
|
if (tierCount > 0) {
|
||||||
|
return `${t('Tiered pricing')} · ${tierCount} ${t('tiers')}`
|
||||||
|
}
|
||||||
|
return t('Expression pricing')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPriceSummary = (
|
||||||
|
row: ModelPricingSnapshot,
|
||||||
|
t: (key: string) => string
|
||||||
|
) => {
|
||||||
|
if (row.billingMode === 'tiered_expr') {
|
||||||
|
return getExpressionSummary(row, t)
|
||||||
|
}
|
||||||
|
if (row.billingMode === 'per-request') {
|
||||||
|
return row.price ? `$${row.price} / ${t('request')}` : t('Unset price')
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputPrice = ratioToPrice(row.ratio)
|
||||||
|
if (!inputPrice) return t('Unset price')
|
||||||
|
|
||||||
|
const extraCount = [
|
||||||
|
row.completionRatio,
|
||||||
|
row.cacheRatio,
|
||||||
|
row.createCacheRatio,
|
||||||
|
row.imageRatio,
|
||||||
|
row.audioRatio,
|
||||||
|
row.audioCompletionRatio,
|
||||||
|
].filter(hasPricingValue).length
|
||||||
|
|
||||||
|
return extraCount > 0
|
||||||
|
? `${t('Input')} $${inputPrice} · ${extraCount} ${t('extras')}`
|
||||||
|
: `${t('Input')} $${inputPrice}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPriceDetail = (
|
||||||
|
row: ModelPricingSnapshot,
|
||||||
|
t: (key: string) => string
|
||||||
|
) => {
|
||||||
|
if (row.billingMode === 'tiered_expr') {
|
||||||
|
return row.requestRuleExpr
|
||||||
|
? t('Includes request rules')
|
||||||
|
: t('Expression based')
|
||||||
|
}
|
||||||
|
if (row.billingMode === 'per-request') {
|
||||||
|
return t('Fixed request price')
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputPrice = ratioToPrice(row.ratio)
|
||||||
|
if (!inputPrice) return t('No base input price')
|
||||||
|
|
||||||
|
const details = [
|
||||||
|
row.completionRatio &&
|
||||||
|
`${t('Output')} $${ratioToPrice(row.completionRatio, inputPrice)}`,
|
||||||
|
row.cacheRatio &&
|
||||||
|
`${t('Cache')} $${ratioToPrice(row.cacheRatio, inputPrice)}`,
|
||||||
|
row.createCacheRatio &&
|
||||||
|
`${t('Cache write')} $${ratioToPrice(row.createCacheRatio, inputPrice)}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
|
||||||
|
return details.length > 0 ? details.join(' · ') : t('Base input price only')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildModelSnapshots = ({
|
||||||
|
modelPrice,
|
||||||
|
modelRatio,
|
||||||
|
cacheRatio,
|
||||||
|
createCacheRatio,
|
||||||
|
completionRatio,
|
||||||
|
imageRatio,
|
||||||
|
audioRatio,
|
||||||
|
audioCompletionRatio,
|
||||||
|
billingMode,
|
||||||
|
billingExpr,
|
||||||
|
}: ModelPricingSnapshotInput): ModelPricingSnapshot[] => {
|
||||||
|
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
|
||||||
|
fallback: {},
|
||||||
|
context: 'model prices',
|
||||||
|
})
|
||||||
|
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
|
||||||
|
fallback: {},
|
||||||
|
context: 'model ratios',
|
||||||
|
})
|
||||||
|
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
|
||||||
|
fallback: {},
|
||||||
|
context: 'cache ratios',
|
||||||
|
})
|
||||||
|
const createCacheMap = safeJsonParse<Record<string, number>>(
|
||||||
|
createCacheRatio,
|
||||||
|
{ fallback: {}, context: 'create cache ratios' }
|
||||||
|
)
|
||||||
|
const completionMap = safeJsonParse<Record<string, number>>(completionRatio, {
|
||||||
|
fallback: {},
|
||||||
|
context: 'completion ratios',
|
||||||
|
})
|
||||||
|
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
|
||||||
|
fallback: {},
|
||||||
|
context: 'image ratios',
|
||||||
|
})
|
||||||
|
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
|
||||||
|
fallback: {},
|
||||||
|
context: 'audio ratios',
|
||||||
|
})
|
||||||
|
const audioCompletionMap = safeJsonParse<Record<string, number>>(
|
||||||
|
audioCompletionRatio,
|
||||||
|
{ fallback: {}, context: 'audio completion ratios' }
|
||||||
|
)
|
||||||
|
const billingModeMap = safeJsonParse<Record<string, string>>(billingMode, {
|
||||||
|
fallback: {},
|
||||||
|
context: 'billing mode',
|
||||||
|
})
|
||||||
|
const billingExprMap = safeJsonParse<Record<string, string>>(billingExpr, {
|
||||||
|
fallback: {},
|
||||||
|
context: 'billing expression',
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelNames = new Set([
|
||||||
|
...Object.keys(priceMap),
|
||||||
|
...Object.keys(ratioMap),
|
||||||
|
...Object.keys(cacheMap),
|
||||||
|
...Object.keys(createCacheMap),
|
||||||
|
...Object.keys(completionMap),
|
||||||
|
...Object.keys(imageMap),
|
||||||
|
...Object.keys(audioMap),
|
||||||
|
...Object.keys(audioCompletionMap),
|
||||||
|
...Object.keys(billingModeMap),
|
||||||
|
...Object.keys(billingExprMap),
|
||||||
|
])
|
||||||
|
|
||||||
|
return Array.from(modelNames).map((name) => {
|
||||||
|
const price = priceMap[name]?.toString() || ''
|
||||||
|
const ratio = ratioMap[name]?.toString() || ''
|
||||||
|
const cache = cacheMap[name]?.toString() || ''
|
||||||
|
const createCache = createCacheMap[name]?.toString() || ''
|
||||||
|
const completion = completionMap[name]?.toString() || ''
|
||||||
|
const image = imageMap[name]?.toString() || ''
|
||||||
|
const audio = audioMap[name]?.toString() || ''
|
||||||
|
const audioCompletion = audioCompletionMap[name]?.toString() || ''
|
||||||
|
|
||||||
|
const modeForModel = billingModeMap[name]
|
||||||
|
if (modeForModel === 'tiered_expr') {
|
||||||
|
const fullExpr = billingExprMap[name] || ''
|
||||||
|
const { billingExpr: pureExpr, requestRuleExpr } =
|
||||||
|
splitBillingExprAndRequestRules(fullExpr)
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
billingMode: 'tiered_expr',
|
||||||
|
billingExpr: pureExpr,
|
||||||
|
requestRuleExpr,
|
||||||
|
price,
|
||||||
|
ratio,
|
||||||
|
cacheRatio: cache,
|
||||||
|
createCacheRatio: createCache,
|
||||||
|
completionRatio: completion,
|
||||||
|
imageRatio: image,
|
||||||
|
audioRatio: audio,
|
||||||
|
audioCompletionRatio: audioCompletion,
|
||||||
|
hasConflict: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
price,
|
||||||
|
ratio,
|
||||||
|
cacheRatio: cache,
|
||||||
|
createCacheRatio: createCache,
|
||||||
|
completionRatio: completion,
|
||||||
|
imageRatio: image,
|
||||||
|
audioRatio: audio,
|
||||||
|
audioCompletionRatio: audioCompletion,
|
||||||
|
billingMode: price !== '' ? 'per-request' : 'per-token',
|
||||||
|
hasConflict:
|
||||||
|
price !== '' &&
|
||||||
|
(ratio !== '' ||
|
||||||
|
completion !== '' ||
|
||||||
|
cache !== '' ||
|
||||||
|
createCache !== '' ||
|
||||||
|
image !== '' ||
|
||||||
|
audio !== '' ||
|
||||||
|
audioCompletion !== ''),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSnapshotSignature = (snapshot?: ModelPricingSnapshot) => {
|
||||||
|
if (!snapshot) return ''
|
||||||
|
return JSON.stringify({
|
||||||
|
price: snapshot.price || '',
|
||||||
|
ratio: snapshot.ratio || '',
|
||||||
|
cacheRatio: snapshot.cacheRatio || '',
|
||||||
|
createCacheRatio: snapshot.createCacheRatio || '',
|
||||||
|
completionRatio: snapshot.completionRatio || '',
|
||||||
|
imageRatio: snapshot.imageRatio || '',
|
||||||
|
audioRatio: snapshot.audioRatio || '',
|
||||||
|
audioCompletionRatio: snapshot.audioCompletionRatio || '',
|
||||||
|
billingMode: snapshot.billingMode || 'per-token',
|
||||||
|
billingExpr: snapshot.billingExpr || '',
|
||||||
|
requestRuleExpr: snapshot.requestRuleExpr || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2023-2026 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
import { type ColumnDef } from '@tanstack/react-table'
|
||||||
|
import { Pencil, Trash2 } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { DataTableColumnHeader } from '@/components/data-table'
|
||||||
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import {
|
||||||
|
getModeLabel,
|
||||||
|
getModeVariant,
|
||||||
|
getPriceDetail,
|
||||||
|
getPriceSummary,
|
||||||
|
type ModelRow,
|
||||||
|
} from './model-pricing-snapshots'
|
||||||
|
|
||||||
|
const filterBySelectedValues = (
|
||||||
|
rowValue: unknown,
|
||||||
|
filterValue: unknown
|
||||||
|
): boolean => {
|
||||||
|
if (!Array.isArray(filterValue) || filterValue.length === 0) return true
|
||||||
|
return filterValue.includes(String(rowValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
type BuildModelRatioColumnsOptions = {
|
||||||
|
onDelete: (name: string) => void
|
||||||
|
onEdit: (model: ModelRow) => void
|
||||||
|
t: (key: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildModelRatioColumns({
|
||||||
|
onDelete,
|
||||||
|
onEdit,
|
||||||
|
t,
|
||||||
|
}: BuildModelRatioColumnsOptions): ColumnDef<ModelRow>[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={table.getIsAllPageRowsSelected()}
|
||||||
|
indeterminate={table.getIsSomePageRowsSelected()}
|
||||||
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
|
aria-label={t('Select all')}
|
||||||
|
className='translate-y-[2px]'
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
aria-label={t('Select row')}
|
||||||
|
className='translate-y-[2px]'
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
meta: { label: t('Select') },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title={t('Model name')} />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className='flex items-center gap-2 font-medium'>
|
||||||
|
{row.getValue('name')}
|
||||||
|
{row.original.billingMode === 'tiered_expr' && (
|
||||||
|
<StatusBadge label={t('Tiered')} variant='info' copyable={false} />
|
||||||
|
)}
|
||||||
|
{row.original.hasConflict && (
|
||||||
|
<StatusBadge
|
||||||
|
label={t('Conflict')}
|
||||||
|
variant='danger'
|
||||||
|
copyable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'billingMode',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title={t('Mode')} />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<StatusBadge
|
||||||
|
label={t(getModeLabel(row.original.billingMode))}
|
||||||
|
variant={getModeVariant(row.original.billingMode)}
|
||||||
|
copyable={false}
|
||||||
|
showDot={false}
|
||||||
|
className='px-0'
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
filterFn: (row, id, value) =>
|
||||||
|
filterBySelectedValues(row.getValue(id), value),
|
||||||
|
meta: { label: t('Mode') },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'priceSummary',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title={t('Price summary')} />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className='flex min-w-[180px] flex-col gap-1'>
|
||||||
|
<span className='font-medium'>
|
||||||
|
{getPriceSummary(row.original, t)}
|
||||||
|
</span>
|
||||||
|
<span className='text-muted-foreground max-w-[320px] truncate text-xs'>
|
||||||
|
{getPriceDetail(row.original, t)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
sortingFn: (rowA, rowB) =>
|
||||||
|
getPriceSummary(rowA.original, t).localeCompare(
|
||||||
|
getPriceSummary(rowB.original, t)
|
||||||
|
),
|
||||||
|
meta: { label: t('Price summary') },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className='flex justify-end gap-2'>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => onEdit(row.original)}
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => onDelete(row.original.name)}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
+32
-444
@@ -27,7 +27,6 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import {
|
import {
|
||||||
type ColumnDef,
|
|
||||||
type ColumnFiltersState,
|
type ColumnFiltersState,
|
||||||
type OnChangeFn,
|
type OnChangeFn,
|
||||||
type PaginationState,
|
type PaginationState,
|
||||||
@@ -44,22 +43,17 @@ import {
|
|||||||
useReactTable,
|
useReactTable,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table'
|
||||||
import { useMediaQuery } from '@/hooks'
|
import { useMediaQuery } from '@/hooks'
|
||||||
import { Copy, Pencil, Plus, Trash2 } from 'lucide-react'
|
import { Copy, Plus } from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
|
||||||
import {
|
import {
|
||||||
DataTableBulkActions,
|
DataTableBulkActions,
|
||||||
DataTableColumnHeader,
|
|
||||||
DataTableToolbar,
|
DataTableToolbar,
|
||||||
DataTablePagination,
|
DataTablePagination,
|
||||||
} from '@/components/data-table'
|
} from '@/components/data-table'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
|
||||||
import {
|
|
||||||
combineBillingExpr,
|
|
||||||
splitBillingExprAndRequestRules,
|
|
||||||
} from '@/features/pricing/lib/billing-expr'
|
|
||||||
import { safeJsonParse } from '../utils/json-parser'
|
import { safeJsonParse } from '../utils/json-parser'
|
||||||
import {
|
import {
|
||||||
ModelPricingEditorPanel,
|
ModelPricingEditorPanel,
|
||||||
@@ -67,7 +61,12 @@ import {
|
|||||||
ModelPricingSheet,
|
ModelPricingSheet,
|
||||||
type ModelRatioData,
|
type ModelRatioData,
|
||||||
} from './model-pricing-sheet'
|
} from './model-pricing-sheet'
|
||||||
import { formatPricingNumber } from './pricing-format'
|
import {
|
||||||
|
buildModelSnapshots,
|
||||||
|
getSnapshotSignature,
|
||||||
|
type ModelRow,
|
||||||
|
} from './model-pricing-snapshots'
|
||||||
|
import { buildModelRatioColumns } from './model-ratio-table-columns'
|
||||||
|
|
||||||
type ModelRatioVisualEditorProps = {
|
type ModelRatioVisualEditorProps = {
|
||||||
savedModelPrice: string
|
savedModelPrice: string
|
||||||
@@ -93,289 +92,12 @@ type ModelRatioVisualEditorProps = {
|
|||||||
onChange: (field: string, value: string) => void
|
onChange: (field: string, value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelPricingSnapshot = {
|
|
||||||
name: string
|
|
||||||
price?: string
|
|
||||||
ratio?: string
|
|
||||||
cacheRatio?: string
|
|
||||||
createCacheRatio?: string
|
|
||||||
completionRatio?: string
|
|
||||||
imageRatio?: string
|
|
||||||
audioRatio?: string
|
|
||||||
audioCompletionRatio?: string
|
|
||||||
billingMode?: string
|
|
||||||
billingExpr?: string
|
|
||||||
requestRuleExpr?: string
|
|
||||||
hasConflict: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModelRow = ModelPricingSnapshot & {
|
|
||||||
saved?: ModelPricingSnapshot
|
|
||||||
draft?: ModelPricingSnapshot
|
|
||||||
isDraftChanged: boolean
|
|
||||||
isDraftDeleted: boolean
|
|
||||||
isDraftNew: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ModelRatioVisualEditorHandle = {
|
export type ModelRatioVisualEditorHandle = {
|
||||||
commitOpenEditor: () => Promise<boolean>
|
commitOpenEditor: () => Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'model-ratio-column-visibility'
|
const STORAGE_KEY = 'model-ratio-column-visibility'
|
||||||
|
|
||||||
const hasValue = (value?: string) => value !== undefined && value !== ''
|
|
||||||
|
|
||||||
const toNumberOrNull = (value?: string) => {
|
|
||||||
if (!hasValue(value)) return null
|
|
||||||
const num = Number(value)
|
|
||||||
return Number.isFinite(num) ? num : null
|
|
||||||
}
|
|
||||||
|
|
||||||
const ratioToPrice = (ratio?: string, denominator?: string) => {
|
|
||||||
const ratioNumber = toNumberOrNull(ratio)
|
|
||||||
const denominatorNumber = denominator ? toNumberOrNull(denominator) : 2
|
|
||||||
if (ratioNumber === null || denominatorNumber === null) return ''
|
|
||||||
return formatPricingNumber(ratioNumber * denominatorNumber)
|
|
||||||
}
|
|
||||||
|
|
||||||
const filterBySelectedValues = (
|
|
||||||
rowValue: unknown,
|
|
||||||
filterValue: unknown
|
|
||||||
): boolean => {
|
|
||||||
if (!Array.isArray(filterValue) || filterValue.length === 0) return true
|
|
||||||
return filterValue.includes(String(rowValue))
|
|
||||||
}
|
|
||||||
|
|
||||||
const getModeLabel = (mode?: string) => {
|
|
||||||
if (mode === 'per-request') return 'Per-request'
|
|
||||||
if (mode === 'tiered_expr') return 'Expression'
|
|
||||||
return 'Per-token'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getModeVariant = (mode?: string): 'warning' | 'info' | 'success' => {
|
|
||||||
if (mode === 'per-request') return 'warning'
|
|
||||||
if (mode === 'tiered_expr') return 'info'
|
|
||||||
return 'success'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getExpressionSummary = (
|
|
||||||
row: ModelPricingSnapshot,
|
|
||||||
t: (key: string) => string
|
|
||||||
) => {
|
|
||||||
const tierCount = (row.billingExpr?.match(/tier\(/g) || []).length
|
|
||||||
if (tierCount > 0) {
|
|
||||||
return `${t('Tiered pricing')} · ${tierCount} ${t('tiers')}`
|
|
||||||
}
|
|
||||||
return t('Expression pricing')
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPriceSummary = (
|
|
||||||
row: ModelPricingSnapshot,
|
|
||||||
t: (key: string) => string
|
|
||||||
) => {
|
|
||||||
if (row.billingMode === 'tiered_expr') {
|
|
||||||
return getExpressionSummary(row, t)
|
|
||||||
}
|
|
||||||
if (row.billingMode === 'per-request') {
|
|
||||||
return row.price ? `$${row.price} / ${t('request')}` : t('Unset price')
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputPrice = ratioToPrice(row.ratio)
|
|
||||||
if (!inputPrice) return t('Unset price')
|
|
||||||
|
|
||||||
const extraCount = [
|
|
||||||
row.completionRatio,
|
|
||||||
row.cacheRatio,
|
|
||||||
row.createCacheRatio,
|
|
||||||
row.imageRatio,
|
|
||||||
row.audioRatio,
|
|
||||||
row.audioCompletionRatio,
|
|
||||||
].filter(hasValue).length
|
|
||||||
|
|
||||||
return extraCount > 0
|
|
||||||
? `${t('Input')} $${inputPrice} · ${extraCount} ${t('extras')}`
|
|
||||||
: `${t('Input')} $${inputPrice}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPriceDetail = (
|
|
||||||
row: ModelPricingSnapshot,
|
|
||||||
t: (key: string) => string
|
|
||||||
) => {
|
|
||||||
if (row.billingMode === 'tiered_expr') {
|
|
||||||
return row.requestRuleExpr
|
|
||||||
? t('Includes request rules')
|
|
||||||
: t('Expression based')
|
|
||||||
}
|
|
||||||
if (row.billingMode === 'per-request') {
|
|
||||||
return t('Fixed request price')
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputPrice = ratioToPrice(row.ratio)
|
|
||||||
if (!inputPrice) return t('No base input price')
|
|
||||||
|
|
||||||
const details = [
|
|
||||||
row.completionRatio &&
|
|
||||||
`${t('Output')} $${ratioToPrice(row.completionRatio, inputPrice)}`,
|
|
||||||
row.cacheRatio &&
|
|
||||||
`${t('Cache')} $${ratioToPrice(row.cacheRatio, inputPrice)}`,
|
|
||||||
row.createCacheRatio &&
|
|
||||||
`${t('Cache write')} $${ratioToPrice(row.createCacheRatio, inputPrice)}`,
|
|
||||||
].filter(Boolean)
|
|
||||||
|
|
||||||
return details.length > 0 ? details.join(' · ') : t('Base input price only')
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildModelSnapshots = ({
|
|
||||||
modelPrice,
|
|
||||||
modelRatio,
|
|
||||||
cacheRatio,
|
|
||||||
createCacheRatio,
|
|
||||||
completionRatio,
|
|
||||||
imageRatio,
|
|
||||||
audioRatio,
|
|
||||||
audioCompletionRatio,
|
|
||||||
billingMode,
|
|
||||||
billingExpr,
|
|
||||||
}: Pick<
|
|
||||||
ModelRatioVisualEditorProps,
|
|
||||||
| 'modelPrice'
|
|
||||||
| 'modelRatio'
|
|
||||||
| 'cacheRatio'
|
|
||||||
| 'createCacheRatio'
|
|
||||||
| 'completionRatio'
|
|
||||||
| 'imageRatio'
|
|
||||||
| 'audioRatio'
|
|
||||||
| 'audioCompletionRatio'
|
|
||||||
| 'billingMode'
|
|
||||||
| 'billingExpr'
|
|
||||||
>): ModelPricingSnapshot[] => {
|
|
||||||
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
|
|
||||||
fallback: {},
|
|
||||||
context: 'model prices',
|
|
||||||
})
|
|
||||||
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
|
|
||||||
fallback: {},
|
|
||||||
context: 'model ratios',
|
|
||||||
})
|
|
||||||
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
|
|
||||||
fallback: {},
|
|
||||||
context: 'cache ratios',
|
|
||||||
})
|
|
||||||
const createCacheMap = safeJsonParse<Record<string, number>>(
|
|
||||||
createCacheRatio,
|
|
||||||
{ fallback: {}, context: 'create cache ratios' }
|
|
||||||
)
|
|
||||||
const completionMap = safeJsonParse<Record<string, number>>(completionRatio, {
|
|
||||||
fallback: {},
|
|
||||||
context: 'completion ratios',
|
|
||||||
})
|
|
||||||
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
|
|
||||||
fallback: {},
|
|
||||||
context: 'image ratios',
|
|
||||||
})
|
|
||||||
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
|
|
||||||
fallback: {},
|
|
||||||
context: 'audio ratios',
|
|
||||||
})
|
|
||||||
const audioCompletionMap = safeJsonParse<Record<string, number>>(
|
|
||||||
audioCompletionRatio,
|
|
||||||
{ fallback: {}, context: 'audio completion ratios' }
|
|
||||||
)
|
|
||||||
const billingModeMap = safeJsonParse<Record<string, string>>(billingMode, {
|
|
||||||
fallback: {},
|
|
||||||
context: 'billing mode',
|
|
||||||
})
|
|
||||||
const billingExprMap = safeJsonParse<Record<string, string>>(billingExpr, {
|
|
||||||
fallback: {},
|
|
||||||
context: 'billing expression',
|
|
||||||
})
|
|
||||||
|
|
||||||
const modelNames = new Set([
|
|
||||||
...Object.keys(priceMap),
|
|
||||||
...Object.keys(ratioMap),
|
|
||||||
...Object.keys(cacheMap),
|
|
||||||
...Object.keys(createCacheMap),
|
|
||||||
...Object.keys(completionMap),
|
|
||||||
...Object.keys(imageMap),
|
|
||||||
...Object.keys(audioMap),
|
|
||||||
...Object.keys(audioCompletionMap),
|
|
||||||
...Object.keys(billingModeMap),
|
|
||||||
...Object.keys(billingExprMap),
|
|
||||||
])
|
|
||||||
|
|
||||||
return Array.from(modelNames).map((name) => {
|
|
||||||
const price = priceMap[name]?.toString() || ''
|
|
||||||
const ratio = ratioMap[name]?.toString() || ''
|
|
||||||
const cache = cacheMap[name]?.toString() || ''
|
|
||||||
const createCache = createCacheMap[name]?.toString() || ''
|
|
||||||
const completion = completionMap[name]?.toString() || ''
|
|
||||||
const image = imageMap[name]?.toString() || ''
|
|
||||||
const audio = audioMap[name]?.toString() || ''
|
|
||||||
const audioCompletion = audioCompletionMap[name]?.toString() || ''
|
|
||||||
|
|
||||||
const modeForModel = billingModeMap[name]
|
|
||||||
if (modeForModel === 'tiered_expr') {
|
|
||||||
const fullExpr = billingExprMap[name] || ''
|
|
||||||
const { billingExpr: pureExpr, requestRuleExpr } =
|
|
||||||
splitBillingExprAndRequestRules(fullExpr)
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
billingMode: 'tiered_expr',
|
|
||||||
billingExpr: pureExpr,
|
|
||||||
requestRuleExpr,
|
|
||||||
price,
|
|
||||||
ratio,
|
|
||||||
cacheRatio: cache,
|
|
||||||
createCacheRatio: createCache,
|
|
||||||
completionRatio: completion,
|
|
||||||
imageRatio: image,
|
|
||||||
audioRatio: audio,
|
|
||||||
audioCompletionRatio: audioCompletion,
|
|
||||||
hasConflict: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
price,
|
|
||||||
ratio,
|
|
||||||
cacheRatio: cache,
|
|
||||||
createCacheRatio: createCache,
|
|
||||||
completionRatio: completion,
|
|
||||||
imageRatio: image,
|
|
||||||
audioRatio: audio,
|
|
||||||
audioCompletionRatio: audioCompletion,
|
|
||||||
billingMode: price !== '' ? 'per-request' : 'per-token',
|
|
||||||
hasConflict:
|
|
||||||
price !== '' &&
|
|
||||||
(ratio !== '' ||
|
|
||||||
completion !== '' ||
|
|
||||||
cache !== '' ||
|
|
||||||
createCache !== '' ||
|
|
||||||
image !== '' ||
|
|
||||||
audio !== '' ||
|
|
||||||
audioCompletion !== ''),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const getSnapshotSignature = (snapshot?: ModelPricingSnapshot) => {
|
|
||||||
if (!snapshot) return ''
|
|
||||||
return JSON.stringify({
|
|
||||||
price: snapshot.price || '',
|
|
||||||
ratio: snapshot.ratio || '',
|
|
||||||
cacheRatio: snapshot.cacheRatio || '',
|
|
||||||
createCacheRatio: snapshot.createCacheRatio || '',
|
|
||||||
completionRatio: snapshot.completionRatio || '',
|
|
||||||
imageRatio: snapshot.imageRatio || '',
|
|
||||||
audioRatio: snapshot.audioRatio || '',
|
|
||||||
audioCompletionRatio: snapshot.audioCompletionRatio || '',
|
|
||||||
billingMode: snapshot.billingMode || 'per-token',
|
|
||||||
billingExpr: snapshot.billingExpr || '',
|
|
||||||
requestRuleExpr: snapshot.requestRuleExpr || '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const ModelRatioVisualEditorComponent = forwardRef<
|
const ModelRatioVisualEditorComponent = forwardRef<
|
||||||
ModelRatioVisualEditorHandle,
|
ModelRatioVisualEditorHandle,
|
||||||
ModelRatioVisualEditorProps
|
ModelRatioVisualEditorProps
|
||||||
@@ -688,159 +410,15 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<ModelRow>[]>(() => {
|
const columns = useMemo(
|
||||||
return [
|
() =>
|
||||||
{
|
buildModelRatioColumns({
|
||||||
id: 'select',
|
onDelete: handleDelete,
|
||||||
header: ({ table }) => (
|
onEdit: handleEdit,
|
||||||
<Checkbox
|
t,
|
||||||
checked={table.getIsAllPageRowsSelected()}
|
}),
|
||||||
indeterminate={table.getIsSomePageRowsSelected()}
|
[handleEdit, handleDelete, t]
|
||||||
onCheckedChange={(value) =>
|
)
|
||||||
table.toggleAllPageRowsSelected(!!value)
|
|
||||||
}
|
|
||||||
aria-label={t('Select all')}
|
|
||||||
className='translate-y-[2px]'
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Checkbox
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
|
||||||
aria-label={t('Select row')}
|
|
||||||
className='translate-y-[2px]'
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
|
||||||
meta: { label: t('Select') },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'name',
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataTableColumnHeader column={column} title={t('Model name')} />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className='flex items-center gap-2 font-medium'>
|
|
||||||
{row.getValue('name')}
|
|
||||||
{row.original.isDraftChanged && (
|
|
||||||
<StatusBadge
|
|
||||||
label={t('Draft')}
|
|
||||||
variant={row.original.isDraftDeleted ? 'danger' : 'warning'}
|
|
||||||
copyable={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{row.original.billingMode === 'tiered_expr' && (
|
|
||||||
<StatusBadge
|
|
||||||
label={t('Tiered')}
|
|
||||||
variant='info'
|
|
||||||
copyable={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{row.original.hasConflict && (
|
|
||||||
<StatusBadge
|
|
||||||
label={t('Conflict')}
|
|
||||||
variant='danger'
|
|
||||||
copyable={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
enableHiding: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'billingMode',
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataTableColumnHeader column={column} title={t('Mode')} />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<StatusBadge
|
|
||||||
label={t(getModeLabel(row.original.billingMode))}
|
|
||||||
variant={getModeVariant(row.original.billingMode)}
|
|
||||||
copyable={false}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
filterFn: (row, id, value) =>
|
|
||||||
filterBySelectedValues(row.getValue(id), value),
|
|
||||||
meta: { label: t('Mode') },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'priceSummary',
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataTableColumnHeader column={column} title={t('Price summary')} />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className='flex min-w-[180px] flex-col gap-2'>
|
|
||||||
<div className='flex flex-col gap-1'>
|
|
||||||
<span className='font-medium'>
|
|
||||||
{getPriceSummary(row.original, t)}
|
|
||||||
</span>
|
|
||||||
<span className='text-muted-foreground max-w-[320px] truncate text-xs'>
|
|
||||||
{getPriceDetail(row.original, t)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{row.original.isDraftChanged && (
|
|
||||||
<div className='border-warning/45 bg-warning/10 text-foreground flex max-w-[360px] flex-col gap-1 rounded-md border px-2.5 py-2 shadow-sm'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<StatusBadge
|
|
||||||
label={t('Draft')}
|
|
||||||
variant={row.original.isDraftDeleted ? 'danger' : 'warning'}
|
|
||||||
copyable={false}
|
|
||||||
className='bg-background/70'
|
|
||||||
/>
|
|
||||||
{!row.original.isDraftDeleted && row.original.draft && (
|
|
||||||
<StatusBadge
|
|
||||||
label={t(getModeLabel(row.original.draft.billingMode))}
|
|
||||||
variant={getModeVariant(row.original.draft.billingMode)}
|
|
||||||
copyable={false}
|
|
||||||
className='bg-background/70'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className='truncate text-sm font-medium'>
|
|
||||||
{row.original.isDraftDeleted
|
|
||||||
? t('Will be removed')
|
|
||||||
: getPriceSummary(row.original.draft ?? row.original, t)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{!row.original.isDraftDeleted && row.original.draft && (
|
|
||||||
<span className='text-muted-foreground truncate text-xs'>
|
|
||||||
{getPriceDetail(row.original.draft, t)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
sortingFn: (rowA, rowB) =>
|
|
||||||
getPriceSummary(rowA.original, t).localeCompare(
|
|
||||||
getPriceSummary(rowB.original, t)
|
|
||||||
),
|
|
||||||
meta: { label: t('Price summary') },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className='flex justify-end gap-2'>
|
|
||||||
<Button
|
|
||||||
variant='ghost'
|
|
||||||
size='sm'
|
|
||||||
onClick={() => handleEdit(row.original)}
|
|
||||||
>
|
|
||||||
<Pencil />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant='ghost'
|
|
||||||
size='sm'
|
|
||||||
onClick={() => handleDelete(row.original.name)}
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
enableHiding: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}, [handleEdit, handleDelete, t])
|
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: models,
|
data: models,
|
||||||
@@ -1101,7 +679,11 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
|||||||
<th
|
<th
|
||||||
key={header.id}
|
key={header.id}
|
||||||
colSpan={header.colSpan}
|
colSpan={header.colSpan}
|
||||||
className='text-foreground h-10 px-2 text-left align-middle text-sm font-medium whitespace-nowrap'
|
className={cn(
|
||||||
|
'text-foreground h-10 px-2 text-left align-middle text-sm font-medium whitespace-nowrap',
|
||||||
|
header.column.id === 'actions' &&
|
||||||
|
'bg-background sticky right-0 z-20 w-24 min-w-24 shadow-[-10px_0_14px_-14px_hsl(var(--foreground))]'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
@@ -1121,8 +703,8 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
|||||||
data-state={row.getIsSelected() ? 'selected' : undefined}
|
data-state={row.getIsSelected() ? 'selected' : undefined}
|
||||||
className={
|
className={
|
||||||
editData?.name === row.original.name
|
editData?.name === row.original.name
|
||||||
? 'bg-muted/45 hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors'
|
? 'bg-muted/45 hover:bg-muted/50 data-[state=selected]:bg-muted group border-b transition-colors'
|
||||||
: 'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors'
|
: 'hover:bg-muted/50 data-[state=selected]:bg-muted group border-b transition-colors'
|
||||||
}
|
}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
@@ -1133,7 +715,13 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
|||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<td
|
<td
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
className='p-2 align-middle text-sm whitespace-nowrap'
|
className={cn(
|
||||||
|
'p-2 align-middle text-sm whitespace-nowrap',
|
||||||
|
cell.column.id === 'actions' &&
|
||||||
|
(editData?.name === row.original.name
|
||||||
|
? 'bg-muted/45 group-hover:bg-muted/50 group-data-[state=selected]:bg-muted sticky right-0 z-10 w-24 min-w-24 shadow-[-10px_0_14px_-14px_hsl(var(--foreground))]'
|
||||||
|
: 'bg-background group-hover:bg-muted/50 group-data-[state=selected]:bg-muted sticky right-0 z-10 w-24 min-w-24 shadow-[-10px_0_14px_-14px_hsl(var(--foreground))]')
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
cell.column.columnDef.cell,
|
cell.column.columnDef.cell,
|
||||||
|
|||||||
Vendored
-2
@@ -1253,7 +1253,6 @@
|
|||||||
"DoubaoVideo": "DoubaoVideo",
|
"DoubaoVideo": "DoubaoVideo",
|
||||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "Double check the configuration below. Your system will be locked until initialization is complete.",
|
"Double check the configuration below. Your system will be locked until initialization is complete.": "Double check the configuration below. Your system will be locked until initialization is complete.",
|
||||||
"Download": "Download",
|
"Download": "Download",
|
||||||
"Draft": "Draft",
|
|
||||||
"Draw": "Draw",
|
"Draw": "Draw",
|
||||||
"Drawing": "Drawing",
|
"Drawing": "Drawing",
|
||||||
"Drawing logs": "Drawing logs",
|
"Drawing logs": "Drawing logs",
|
||||||
@@ -4415,7 +4414,6 @@
|
|||||||
"We could not load the setup status.": "We could not load the setup status.",
|
"We could not load the setup status.": "We could not load the setup status.",
|
||||||
"We will prompt your device to confirm using biometrics or your hardware key.": "We will prompt your device to confirm using biometrics or your hardware key.",
|
"We will prompt your device to confirm using biometrics or your hardware key.": "We will prompt your device to confirm using biometrics or your hardware key.",
|
||||||
"We'll be back online shortly.": "We'll be back online shortly.",
|
"We'll be back online shortly.": "We'll be back online shortly.",
|
||||||
"Will be removed": "Will be removed",
|
|
||||||
"Web search": "Web search",
|
"Web search": "Web search",
|
||||||
"Web Search": "Web Search",
|
"Web Search": "Web Search",
|
||||||
"Webhook Configuration:": "Webhook Configuration:",
|
"Webhook Configuration:": "Webhook Configuration:",
|
||||||
|
|||||||
Vendored
-2
@@ -1253,7 +1253,6 @@
|
|||||||
"DoubaoVideo": "DoubaoVideo",
|
"DoubaoVideo": "DoubaoVideo",
|
||||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "Vérifiez la configuration ci-dessous. Votre système sera verrouillé jusqu'à ce que l'initialisation soit terminée.",
|
"Double check the configuration below. Your system will be locked until initialization is complete.": "Vérifiez la configuration ci-dessous. Votre système sera verrouillé jusqu'à ce que l'initialisation soit terminée.",
|
||||||
"Download": "Télécharger",
|
"Download": "Télécharger",
|
||||||
"Draft": "Brouillon",
|
|
||||||
"Draw": "Dessin",
|
"Draw": "Dessin",
|
||||||
"Drawing": "Dessin",
|
"Drawing": "Dessin",
|
||||||
"Drawing logs": "Journaux de dessin",
|
"Drawing logs": "Journaux de dessin",
|
||||||
@@ -4415,7 +4414,6 @@
|
|||||||
"We could not load the setup status.": "Nous n'avons pas pu charger l'état de la configuration.",
|
"We could not load the setup status.": "Nous n'avons pas pu charger l'état de la configuration.",
|
||||||
"We will prompt your device to confirm using biometrics or your hardware key.": "Nous allons demander à votre appareil de confirmer en utilisant la biométrie ou votre clé matérielle.",
|
"We will prompt your device to confirm using biometrics or your hardware key.": "Nous allons demander à votre appareil de confirmer en utilisant la biométrie ou votre clé matérielle.",
|
||||||
"We'll be back online shortly.": "Nous serons de retour en ligne sous peu.",
|
"We'll be back online shortly.": "Nous serons de retour en ligne sous peu.",
|
||||||
"Will be removed": "Sera supprimé",
|
|
||||||
"Web search": "Recherche web",
|
"Web search": "Recherche web",
|
||||||
"Web Search": "Recherche web",
|
"Web Search": "Recherche web",
|
||||||
"Webhook Configuration:": "Configuration du Webhook :",
|
"Webhook Configuration:": "Configuration du Webhook :",
|
||||||
|
|||||||
Vendored
-2
@@ -1253,7 +1253,6 @@
|
|||||||
"DoubaoVideo": "DoubaoVideo",
|
"DoubaoVideo": "DoubaoVideo",
|
||||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "下記の設定を再確認してください。初期化が完了するまでシステムはロックされます。",
|
"Double check the configuration below. Your system will be locked until initialization is complete.": "下記の設定を再確認してください。初期化が完了するまでシステムはロックされます。",
|
||||||
"Download": "ダウンロード",
|
"Download": "ダウンロード",
|
||||||
"Draft": "下書き",
|
|
||||||
"Draw": "描画",
|
"Draw": "描画",
|
||||||
"Drawing": "画像生成",
|
"Drawing": "画像生成",
|
||||||
"Drawing logs": "描画ログ",
|
"Drawing logs": "描画ログ",
|
||||||
@@ -4415,7 +4414,6 @@
|
|||||||
"We could not load the setup status.": "セットアップステータスを読み込めませんでした。",
|
"We could not load the setup status.": "セットアップステータスを読み込めませんでした。",
|
||||||
"We will prompt your device to confirm using biometrics or your hardware key.": "生体認証またはハードウェアキーを使用して確認するよう、デバイスにプロンプトが表示されます。",
|
"We will prompt your device to confirm using biometrics or your hardware key.": "生体認証またはハードウェアキーを使用して確認するよう、デバイスにプロンプトが表示されます。",
|
||||||
"We'll be back online shortly.": "まもなくオンラインに戻ります。",
|
"We'll be back online shortly.": "まもなくオンラインに戻ります。",
|
||||||
"Will be removed": "削除予定",
|
|
||||||
"Web search": "ウェブ検索",
|
"Web search": "ウェブ検索",
|
||||||
"Web Search": "Web 検索",
|
"Web Search": "Web 検索",
|
||||||
"Webhook Configuration:": "Webhook設定:",
|
"Webhook Configuration:": "Webhook設定:",
|
||||||
|
|||||||
Vendored
-2
@@ -1253,7 +1253,6 @@
|
|||||||
"DoubaoVideo": "DoubaoVideo",
|
"DoubaoVideo": "DoubaoVideo",
|
||||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "Дважды проверьте конфигурацию ниже. Ваша система будет заблокирована до завершения инициализации.",
|
"Double check the configuration below. Your system will be locked until initialization is complete.": "Дважды проверьте конфигурацию ниже. Ваша система будет заблокирована до завершения инициализации.",
|
||||||
"Download": "Скачать",
|
"Download": "Скачать",
|
||||||
"Draft": "Черновик",
|
|
||||||
"Draw": "Рисование",
|
"Draw": "Рисование",
|
||||||
"Drawing": "Рисование",
|
"Drawing": "Рисование",
|
||||||
"Drawing logs": "Журналы рисования",
|
"Drawing logs": "Журналы рисования",
|
||||||
@@ -4415,7 +4414,6 @@
|
|||||||
"We could not load the setup status.": "Не удалось загрузить статус настройки.",
|
"We could not load the setup status.": "Не удалось загрузить статус настройки.",
|
||||||
"We will prompt your device to confirm using biometrics or your hardware key.": "Мы предложим вашему устройству подтвердить действие с помощью биометрии или аппаратного ключа.",
|
"We will prompt your device to confirm using biometrics or your hardware key.": "Мы предложим вашему устройству подтвердить действие с помощью биометрии или аппаратного ключа.",
|
||||||
"We'll be back online shortly.": "Мы скоро вернемся в сеть.",
|
"We'll be back online shortly.": "Мы скоро вернемся в сеть.",
|
||||||
"Will be removed": "Будет удалено",
|
|
||||||
"Web search": "Веб-поиск",
|
"Web search": "Веб-поиск",
|
||||||
"Web Search": "Веб-поиск",
|
"Web Search": "Веб-поиск",
|
||||||
"Webhook Configuration:": "Конфигурация веб-хука:",
|
"Webhook Configuration:": "Конфигурация веб-хука:",
|
||||||
|
|||||||
Vendored
-2
@@ -1253,7 +1253,6 @@
|
|||||||
"DoubaoVideo": "DoubaoVideo",
|
"DoubaoVideo": "DoubaoVideo",
|
||||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "Kiểm tra kỹ lại cấu hình bên dưới. Hệ thống của bạn sẽ bị khóa cho đến khi quá trình khởi tạo hoàn tất.",
|
"Double check the configuration below. Your system will be locked until initialization is complete.": "Kiểm tra kỹ lại cấu hình bên dưới. Hệ thống của bạn sẽ bị khóa cho đến khi quá trình khởi tạo hoàn tất.",
|
||||||
"Download": "Tải xuống",
|
"Download": "Tải xuống",
|
||||||
"Draft": "Bản nháp",
|
|
||||||
"Draw": "Vẽ",
|
"Draw": "Vẽ",
|
||||||
"Drawing": "Vẽ",
|
"Drawing": "Vẽ",
|
||||||
"Drawing logs": "Nhật ký vẽ",
|
"Drawing logs": "Nhật ký vẽ",
|
||||||
@@ -4415,7 +4414,6 @@
|
|||||||
"We could not load the setup status.": "Chúng tôi không thể tải trạng thái thiết lập.",
|
"We could not load the setup status.": "Chúng tôi không thể tải trạng thái thiết lập.",
|
||||||
"We will prompt your device to confirm using biometrics or your hardware key.": "Chúng tôi sẽ yêu cầu thiết bị của bạn xác nhận bằng cách sử dụng sinh trắc học hoặc khóa bảo mật phần cứng của bạn.",
|
"We will prompt your device to confirm using biometrics or your hardware key.": "Chúng tôi sẽ yêu cầu thiết bị của bạn xác nhận bằng cách sử dụng sinh trắc học hoặc khóa bảo mật phần cứng của bạn.",
|
||||||
"We'll be back online shortly.": "Chúng tôi sẽ sớm trực tuyến trở lại.",
|
"We'll be back online shortly.": "Chúng tôi sẽ sớm trực tuyến trở lại.",
|
||||||
"Will be removed": "Sẽ bị xóa",
|
|
||||||
"Web search": "Tìm kiếm web",
|
"Web search": "Tìm kiếm web",
|
||||||
"Web Search": "Tìm kiếm web",
|
"Web Search": "Tìm kiếm web",
|
||||||
"Webhook Configuration:": "Cấu hình Webhook:",
|
"Webhook Configuration:": "Cấu hình Webhook:",
|
||||||
|
|||||||
Vendored
-2
@@ -1253,7 +1253,6 @@
|
|||||||
"DoubaoVideo": "DoubaoVideo",
|
"DoubaoVideo": "DoubaoVideo",
|
||||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "仔细检查以下配置。您的系统将在初始化完成前保持锁定状态。",
|
"Double check the configuration below. Your system will be locked until initialization is complete.": "仔细检查以下配置。您的系统将在初始化完成前保持锁定状态。",
|
||||||
"Download": "下载",
|
"Download": "下载",
|
||||||
"Draft": "草稿",
|
|
||||||
"Draw": "绘图",
|
"Draw": "绘图",
|
||||||
"Drawing": "绘图",
|
"Drawing": "绘图",
|
||||||
"Drawing logs": "绘制日志",
|
"Drawing logs": "绘制日志",
|
||||||
@@ -4415,7 +4414,6 @@
|
|||||||
"We could not load the setup status.": "我们无法加载设置状态。",
|
"We could not load the setup status.": "我们无法加载设置状态。",
|
||||||
"We will prompt your device to confirm using biometrics or your hardware key.": "我们将提示您的设备使用生物识别或硬件密钥进行确认。",
|
"We will prompt your device to confirm using biometrics or your hardware key.": "我们将提示您的设备使用生物识别或硬件密钥进行确认。",
|
||||||
"We'll be back online shortly.": "我们将很快恢复在线。",
|
"We'll be back online shortly.": "我们将很快恢复在线。",
|
||||||
"Will be removed": "将被移除",
|
|
||||||
"Web search": "网络搜索",
|
"Web search": "网络搜索",
|
||||||
"Web Search": "网页搜索",
|
"Web Search": "网页搜索",
|
||||||
"Webhook Configuration:": "Webhook 配置:",
|
"Webhook Configuration:": "Webhook 配置:",
|
||||||
|
|||||||
Reference in New Issue
Block a user