perf(model-pricing): improve model pricing editor UX (#5275)
Merge pull request #5275 from QuantumNous/fix/model-pricing-draft-save
This commit is contained in:
+284
@@ -0,0 +1,284 @@
|
||||
/*
|
||||
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 {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type KeyboardEvent,
|
||||
} from 'react'
|
||||
import { AlertCircle, Braces, CheckCircle2, Code2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
export type JsonCodeEditorProps = Omit<ComponentProps<'div'>, 'onChange'> & {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
heightClassName?: string
|
||||
}
|
||||
|
||||
export function JsonCodeEditor({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
heightClassName = 'h-56 min-h-56 max-h-56',
|
||||
className,
|
||||
id,
|
||||
'aria-describedby': ariaDescribedBy,
|
||||
'aria-invalid': ariaInvalid,
|
||||
...rootProps
|
||||
}: JsonCodeEditorProps) {
|
||||
const { t } = useTranslation()
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [scrollTop, setScrollTop] = useState(0)
|
||||
const lineNumbers = useMemo(() => {
|
||||
const count = Math.max(1, value.split('\n').length)
|
||||
return Array.from({ length: count }, (_, index) => index + 1)
|
||||
}, [value])
|
||||
const jsonStatus = useMemo(() => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return { valid: true, message: t('JSON') }
|
||||
try {
|
||||
JSON.parse(trimmed)
|
||||
return { valid: true, message: t('JSON') }
|
||||
} catch {
|
||||
return { valid: false, message: t('Invalid JSON') }
|
||||
}
|
||||
}, [value, t])
|
||||
|
||||
const formatJson = () => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return
|
||||
try {
|
||||
onChange(JSON.stringify(JSON.parse(trimmed), null, 2))
|
||||
} catch {
|
||||
// Keep invalid drafts untouched; validation feedback remains visible.
|
||||
}
|
||||
}
|
||||
|
||||
const updateValueWithSelection = (
|
||||
nextValue: string,
|
||||
selectionStart: number,
|
||||
selectionEnd = selectionStart
|
||||
) => {
|
||||
onChange(nextValue)
|
||||
window.requestAnimationFrame(() => {
|
||||
textareaRef.current?.setSelectionRange(selectionStart, selectionEnd)
|
||||
})
|
||||
}
|
||||
|
||||
const getLineIndent = (text: string, cursor: number) => {
|
||||
const lineStart = text.lastIndexOf('\n', cursor - 1) + 1
|
||||
return text.slice(lineStart, cursor).match(/^\s*/)?.[0] ?? ''
|
||||
}
|
||||
|
||||
const handleEditorKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const target = event.currentTarget
|
||||
const start = target.selectionStart
|
||||
const end = target.selectionEnd
|
||||
const selected = value.slice(start, end)
|
||||
const before = value.slice(0, start)
|
||||
const after = value.slice(end)
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
|
||||
if (start !== end && selected.includes('\n')) {
|
||||
const selectionLineStart = value.lastIndexOf('\n', start - 1) + 1
|
||||
const selectedBlock = value.slice(selectionLineStart, end)
|
||||
const lines = selectedBlock.split('\n')
|
||||
const nextBlock = event.shiftKey
|
||||
? lines
|
||||
.map((line) =>
|
||||
line.startsWith(' ')
|
||||
? line.slice(2)
|
||||
: line.startsWith('\t')
|
||||
? line.slice(1)
|
||||
: line
|
||||
)
|
||||
.join('\n')
|
||||
: lines.map((line) => ` ${line}`).join('\n')
|
||||
const nextValue =
|
||||
value.slice(0, selectionLineStart) + nextBlock + value.slice(end)
|
||||
updateValueWithSelection(
|
||||
nextValue,
|
||||
selectionLineStart,
|
||||
selectionLineStart + nextBlock.length
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.shiftKey) {
|
||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1
|
||||
const removable = value.slice(lineStart, lineStart + 2)
|
||||
if (removable === ' ') {
|
||||
updateValueWithSelection(
|
||||
value.slice(0, lineStart) + value.slice(lineStart + 2),
|
||||
Math.max(lineStart, start - 2),
|
||||
Math.max(lineStart, end - 2)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
updateValueWithSelection(`${before} ${after}`, start + 2)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const indent = getLineIndent(value, start)
|
||||
const previousChar = before.trimEnd().at(-1)
|
||||
const nextChar = after.trimStart().at(0)
|
||||
const shouldNest = previousChar === '{' || previousChar === '['
|
||||
const shouldClose =
|
||||
(previousChar === '{' && nextChar === '}') ||
|
||||
(previousChar === '[' && nextChar === ']')
|
||||
|
||||
if (shouldNest && shouldClose) {
|
||||
const innerIndent = `${indent} `
|
||||
const insert = `\n${innerIndent}\n${indent}`
|
||||
updateValueWithSelection(
|
||||
`${before}${insert}${after}`,
|
||||
start + 1 + innerIndent.length
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const nextIndent = shouldNest ? `${indent} ` : indent
|
||||
const insert = `\n${nextIndent}`
|
||||
updateValueWithSelection(
|
||||
`${before}${insert}${after}`,
|
||||
start + insert.length
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const pairs: Record<string, string> = {
|
||||
'"': '"',
|
||||
'{': '}',
|
||||
'[': ']',
|
||||
}
|
||||
const closingChars = new Set(Object.values(pairs))
|
||||
|
||||
if (closingChars.has(event.key) && value[start] === event.key) {
|
||||
event.preventDefault()
|
||||
textareaRef.current?.setSelectionRange(start + 1, start + 1)
|
||||
return
|
||||
}
|
||||
|
||||
if (pairs[event.key]) {
|
||||
event.preventDefault()
|
||||
const close = pairs[event.key]
|
||||
const wrapped = `${event.key}${selected}${close}`
|
||||
updateValueWithSelection(
|
||||
`${before}${wrapped}${after}`,
|
||||
start + 1,
|
||||
start + 1 + selected.length
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace' && start === end && start > 0) {
|
||||
const previousChar = value[start - 1]
|
||||
const nextChar = value[start]
|
||||
if (pairs[previousChar] === nextChar) {
|
||||
event.preventDefault()
|
||||
updateValueWithSelection(
|
||||
value.slice(0, start - 1) + value.slice(start + 1),
|
||||
start - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-input bg-background focus-within:border-ring focus-within:ring-ring/50 overflow-hidden rounded-lg border transition-colors focus-within:ring-3',
|
||||
className
|
||||
)}
|
||||
{...rootProps}
|
||||
>
|
||||
<div className='bg-muted/30 flex h-8 items-center justify-between border-b px-2'>
|
||||
<div className='text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs font-medium'>
|
||||
<Braces className='h-3.5 w-3.5' />
|
||||
<span>{t('JSON')}</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-xs',
|
||||
jsonStatus.valid ? 'text-emerald-600' : 'text-destructive'
|
||||
)}
|
||||
>
|
||||
{jsonStatus.valid ? (
|
||||
<CheckCircle2 className='h-3.5 w-3.5' />
|
||||
) : (
|
||||
<AlertCircle className='h-3.5 w-3.5' />
|
||||
)}
|
||||
{jsonStatus.message}
|
||||
</span>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={formatJson}
|
||||
disabled={disabled || !jsonStatus.valid || !value.trim()}
|
||||
>
|
||||
<Code2 className='mr-1 h-3.5 w-3.5' />
|
||||
{t('Format JSON')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('relative flex overflow-hidden', heightClassName)}>
|
||||
<div className='bg-muted/20 text-muted-foreground/70 relative w-10 shrink-0 overflow-hidden border-r font-mono text-xs leading-5 select-none'>
|
||||
<div
|
||||
className='px-2 py-2 text-right'
|
||||
style={{ transform: `translateY(-${scrollTop}px)` }}
|
||||
>
|
||||
{lineNumbers.map((lineNumber) => (
|
||||
<div key={lineNumber}>{lineNumber}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
id={id}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
aria-invalid={ariaInvalid}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
|
||||
className={cn(
|
||||
'[field-sizing:fixed] resize-none overflow-auto rounded-none border-0 bg-transparent px-3 py-2 font-mono text-xs leading-5 shadow-none ring-0 outline-none focus-visible:ring-0',
|
||||
heightClassName
|
||||
)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -70,7 +70,7 @@ function SettingsPageFrame(props: SettingsPageFrameProps) {
|
||||
<span className='truncate'>{props.title}</span>
|
||||
<span
|
||||
ref={setTitleStatusContainer}
|
||||
className='inline-flex shrink-0'
|
||||
className='inline-flex min-w-0 shrink-0 items-center'
|
||||
/>
|
||||
</span>
|
||||
</SectionPageLayout.Title>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+128
-474
@@ -16,21 +16,21 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import * as z from 'zod'
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { AlertTriangle, ChevronDown } from 'lucide-react'
|
||||
import { AlertTriangle, Save } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
@@ -56,71 +56,39 @@ import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { sideDrawerContentClassName } from '@/components/drawer-layout'
|
||||
import {
|
||||
sideDrawerContentClassName,
|
||||
sideDrawerFooterClassName,
|
||||
} from '@/components/drawer-layout'
|
||||
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
|
||||
import {
|
||||
SettingsControlGroup,
|
||||
SettingsSwitchField,
|
||||
} from '../components/settings-form-layout'
|
||||
EMPTY_LANE_ENABLED,
|
||||
EMPTY_LANE_PRICES,
|
||||
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 { TieredPricingEditor } from './tiered-pricing-editor'
|
||||
|
||||
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(),
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
export type { ModelRatioData } from './model-pricing-core'
|
||||
|
||||
type ModelPricingSheetProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (data: ModelRatioData) => void
|
||||
onCancel?: () => void
|
||||
editData?: ModelRatioData | null
|
||||
selectedTargetCount?: number
|
||||
onSave?: () => void | Promise<void>
|
||||
isSaving?: boolean
|
||||
}
|
||||
|
||||
type ModelPricingEditorPanelProps = Omit<
|
||||
@@ -130,261 +98,17 @@ type ModelPricingEditorPanelProps = Omit<
|
||||
className?: string
|
||||
}
|
||||
|
||||
type PreviewRow = {
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
multiline?: boolean
|
||||
export type ModelPricingEditorPanelHandle = {
|
||||
commitDraft: () => Promise<ModelRatioData | null>
|
||||
}
|
||||
|
||||
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 getModeLabel(mode: PricingMode) {
|
||||
if (mode === 'per-request') return 'Per-request'
|
||||
if (mode === 'tiered_expr') return 'Expression'
|
||||
return 'Per-token'
|
||||
}
|
||||
|
||||
function getModeBadgeVariant(
|
||||
mode: PricingMode
|
||||
): 'default' | 'secondary' | 'outline' {
|
||||
if (mode === 'per-request') return 'secondary'
|
||||
if (mode === 'tiered_expr') return 'default'
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
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 function ModelPricingSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
onCancel,
|
||||
editData,
|
||||
selectedTargetCount = 0,
|
||||
}: ModelPricingSheetProps) {
|
||||
export const ModelPricingSheet = forwardRef<
|
||||
ModelPricingEditorPanelHandle,
|
||||
ModelPricingSheetProps
|
||||
>(function ModelPricingSheet(
|
||||
{ open, onOpenChange, editData, onSave, isSaving },
|
||||
ref
|
||||
) {
|
||||
const { t } = useTranslation()
|
||||
const title = editData ? t('Edit model pricing') : t('Add model pricing')
|
||||
const description = editData?.name || t('New model')
|
||||
@@ -400,27 +124,24 @@ export function ModelPricingSheet({
|
||||
<SheetDescription>{description}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<ModelPricingEditorPanel
|
||||
onSave={onSave}
|
||||
ref={ref}
|
||||
editData={editData}
|
||||
selectedTargetCount={selectedTargetCount}
|
||||
onCancel={() => {
|
||||
onCancel?.()
|
||||
onOpenChange(false)
|
||||
}}
|
||||
onSave={onSave}
|
||||
isSaving={isSaving}
|
||||
className='h-full rounded-none border-0'
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export function ModelPricingEditorPanel({
|
||||
onSave,
|
||||
editData,
|
||||
selectedTargetCount = 0,
|
||||
onCancel,
|
||||
className,
|
||||
}: ModelPricingEditorPanelProps) {
|
||||
export const ModelPricingEditorPanel = forwardRef<
|
||||
ModelPricingEditorPanelHandle,
|
||||
ModelPricingEditorPanelProps
|
||||
>(function ModelPricingEditorPanel(
|
||||
{ editData, className, onSave, isSaving },
|
||||
ref
|
||||
) {
|
||||
const { t } = useTranslation()
|
||||
const [pricingMode, setPricingMode] = useState<PricingMode>('per-token')
|
||||
const [promptPrice, setPromptPrice] = useState('')
|
||||
@@ -432,7 +153,6 @@ export function ModelPricingEditorPanel({
|
||||
})
|
||||
const [billingExpr, setBillingExpr] = useState('')
|
||||
const [requestRuleExpr, setRequestRuleExpr] = useState('')
|
||||
const [previewOpen, setPreviewOpen] = useState(true)
|
||||
const isEditMode = !!editData
|
||||
|
||||
const form = useForm<ModelPricingFormValues>({
|
||||
@@ -494,7 +214,6 @@ export function ModelPricingEditorPanel({
|
||||
setPromptPrice(nextLaneState.promptPrice)
|
||||
setLanePrices(nextLaneState.prices)
|
||||
setLaneEnabled(nextLaneState.enabled)
|
||||
setPreviewOpen(true)
|
||||
}, [editData, form])
|
||||
|
||||
const setFormValue = (field: keyof ModelPricingFormValues, value: string) => {
|
||||
@@ -687,7 +406,7 @@ export function ModelPricingEditorPanel({
|
||||
return nextWarnings
|
||||
}, [editData, laneEnabled, lanePrices, pricingMode, promptPrice, t])
|
||||
|
||||
const handleSubmit = (values: ModelPricingFormValues) => {
|
||||
const validatePricingValues = useCallback(() => {
|
||||
if (
|
||||
pricingMode === 'per-token' &&
|
||||
toNumberOrNull(promptPrice) === null &&
|
||||
@@ -698,7 +417,7 @@ export function ModelPricingEditorPanel({
|
||||
form.setError('ratio', {
|
||||
message: t('Input price is required before saving dependent prices.'),
|
||||
})
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -709,9 +428,14 @@ export function ModelPricingEditorPanel({
|
||||
form.setError('audioRatio', {
|
||||
message: t('Audio output price requires an audio input price.'),
|
||||
})
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}, [form, laneEnabled, lanePrices, pricingMode, promptPrice, t])
|
||||
|
||||
const buildSubmitData = useCallback(
|
||||
(values: ModelPricingFormValues) => {
|
||||
const data: ModelRatioData = {
|
||||
name: values.name.trim(),
|
||||
billingMode: pricingMode,
|
||||
@@ -730,12 +454,24 @@ export function ModelPricingEditorPanel({
|
||||
data.requestRuleExpr = requestRuleExpr
|
||||
}
|
||||
|
||||
onSave(data)
|
||||
form.reset()
|
||||
onCancel?.()
|
||||
}
|
||||
return data
|
||||
},
|
||||
[billingExpr, pricingMode, requestRuleExpr]
|
||||
)
|
||||
|
||||
const activeName = watchedValues.name || editData?.name || t('New model')
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
commitDraft: async () => {
|
||||
const isValid = await form.trigger()
|
||||
if (!isValid || !validatePricingValues()) return null
|
||||
return buildSubmitData(form.getValues())
|
||||
},
|
||||
}),
|
||||
[form, validatePricingValues, buildSubmitData]
|
||||
)
|
||||
|
||||
const showActions = Boolean(onSave)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -750,23 +486,18 @@ export function ModelPricingEditorPanel({
|
||||
<h3 className='truncate text-base font-medium'>
|
||||
{isEditMode ? t('Edit model pricing') : t('Add model pricing')}
|
||||
</h3>
|
||||
<p className='text-muted-foreground truncate text-sm'>
|
||||
{activeName}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={getModeBadgeVariant(pricingMode)}>
|
||||
{t(getModeLabel(pricingMode))}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
onSubmit={(event) => event.preventDefault()}
|
||||
className='flex min-h-0 flex-1 flex-col'
|
||||
autoComplete='off'
|
||||
>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto p-4'>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto p-4 pb-6'>
|
||||
<div className='grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(220px,260px)]'>
|
||||
<FieldGroup>
|
||||
{warnings.length > 0 && (
|
||||
<Alert variant='destructive'>
|
||||
@@ -795,16 +526,24 @@ export function ModelPricingEditorPanel({
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('The exact model identifier as used in API requests.')}
|
||||
{t(
|
||||
'The exact model identifier as used in API requests.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Tabs value={pricingMode} onValueChange={handleModeChange}>
|
||||
<Tabs
|
||||
value={pricingMode}
|
||||
onValueChange={handleModeChange}
|
||||
className='gap-4'
|
||||
>
|
||||
<TabsList className='grid w-full grid-cols-3'>
|
||||
<TabsTrigger value='per-token'>{t('Per-token')}</TabsTrigger>
|
||||
<TabsTrigger value='per-token'>
|
||||
{t('Per-token')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='per-request'>
|
||||
{t('Per-request')}
|
||||
</TabsTrigger>
|
||||
@@ -813,8 +552,8 @@ export function ModelPricingEditorPanel({
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='per-token' className='flex flex-col gap-5'>
|
||||
<FieldGroup>
|
||||
<TabsContent value='per-token' className='pt-0'>
|
||||
<FieldGroup className='gap-5'>
|
||||
<Field>
|
||||
<FieldLabel>{t('Input price')}</FieldLabel>
|
||||
<PriceInput
|
||||
@@ -827,7 +566,7 @@ export function ModelPricingEditorPanel({
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<div className='grid gap-3 sm:grid-cols-[repeat(auto-fit,minmax(400px,1fr))]'>
|
||||
{laneConfigs.map((lane) => {
|
||||
const disabled =
|
||||
lane.key === 'audioOutput' &&
|
||||
@@ -855,16 +594,15 @@ export function ModelPricingEditorPanel({
|
||||
</FieldGroup>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value='per-request'
|
||||
className='flex flex-col gap-5'
|
||||
>
|
||||
<TabsContent value='per-request' className='pt-0'>
|
||||
<FieldGroup className='gap-5'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='price'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Fixed price')}</FormLabel>
|
||||
<FormItem className='contents'>
|
||||
<Field>
|
||||
<FieldLabel>{t('Fixed price')}</FieldLabel>
|
||||
<FormControl>
|
||||
<InputGroup>
|
||||
<InputGroupAddon>$</InputGroupAddon>
|
||||
@@ -884,21 +622,21 @@ export function ModelPricingEditorPanel({
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<FieldDescription>
|
||||
{t(
|
||||
'Cost in USD per request, regardless of tokens used.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</FieldDescription>
|
||||
<FormMessage />
|
||||
</Field>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value='tiered_expr'
|
||||
className='flex flex-col gap-5'
|
||||
>
|
||||
<TabsContent value='tiered_expr' className='pt-0'>
|
||||
<FieldGroup className='gap-5'>
|
||||
<TieredPricingEditor
|
||||
modelName={watchedValues.name}
|
||||
billingExpr={billingExpr}
|
||||
@@ -906,40 +644,24 @@ export function ModelPricingEditorPanel({
|
||||
onBillingExprChange={setBillingExpr}
|
||||
onRequestRuleExprChange={setRequestRuleExpr}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</FieldGroup>
|
||||
|
||||
<Collapsible open={previewOpen} onOpenChange={setPreviewOpen}>
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
className='flex w-full justify-between'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span>{t('Save preview')}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'transition-transform',
|
||||
previewOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className='pt-3'>
|
||||
<div className='rounded-lg border'>
|
||||
<aside className='bg-muted/20 sticky top-0 rounded-lg border'>
|
||||
<div className='border-b px-3 py-2'>
|
||||
<div className='text-sm font-medium'>{t('Preview')}</div>
|
||||
</div>
|
||||
<div className='divide-y'>
|
||||
{previewRows.map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className='grid grid-cols-[140px_1fr] gap-3 border-b px-3 py-2 text-sm last:border-b-0'
|
||||
>
|
||||
<div key={row.key} className='grid gap-1 px-3 py-2.5'>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{row.label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0',
|
||||
'min-w-0 text-sm',
|
||||
row.multiline
|
||||
? 'font-mono text-xs leading-5 break-words whitespace-pre-wrap'
|
||||
: 'truncate'
|
||||
@@ -950,96 +672,28 @@ export function ModelPricingEditorPanel({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</FieldGroup>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<SheetFooter
|
||||
className={sideDrawerFooterClassName(
|
||||
'grid-cols-1 sm:items-center sm:justify-between'
|
||||
)}
|
||||
</div>
|
||||
{showActions && (
|
||||
<div className='bg-background/95 supports-[backdrop-filter]:bg-background/80 shrink-0 border-t p-3 backdrop-blur'>
|
||||
<div className='flex flex-col-reverse gap-2 sm:flex-row sm:justify-end'>
|
||||
{onSave && (
|
||||
<Button
|
||||
type='button'
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{selectedTargetCount > 0
|
||||
? t('{{count}} selected targets available for bulk copy.', {
|
||||
count: selectedTargetCount,
|
||||
})
|
||||
: t('Changes are written to the settings draft on save.')}
|
||||
</div>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button type='button' variant='outline' onClick={onCancel}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit'>
|
||||
{isEditMode ? t('Update') : t('Add')}
|
||||
<Save data-icon='inline-start' />
|
||||
{isSaving ? t('Saving...') : t('Save model prices')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</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 || '',
|
||||
})
|
||||
}
|
||||
+154
-173
@@ -16,9 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { memo, useCallback, useRef, useState } from 'react'
|
||||
import { type UseFormReturn } from 'react-hook-form'
|
||||
import { Code2, Eye } from 'lucide-react'
|
||||
import { Code2, Eye, RotateCcw, Save } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -31,14 +31,16 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import {
|
||||
SettingsForm,
|
||||
SettingsSwitchContent,
|
||||
SettingsSwitchItem,
|
||||
} from '../components/settings-form-layout'
|
||||
import { SettingsPageActionsPortal } from '../components/settings-page-context'
|
||||
import { ModelRatioVisualEditor } from './model-ratio-visual-editor'
|
||||
import {
|
||||
ModelRatioVisualEditor,
|
||||
type ModelRatioVisualEditorHandle,
|
||||
} from './model-ratio-visual-editor'
|
||||
|
||||
type ModelFormValues = {
|
||||
ModelPrice: string
|
||||
@@ -56,14 +58,106 @@ type ModelFormValues = {
|
||||
|
||||
type ModelRatioFormProps = {
|
||||
form: UseFormReturn<ModelFormValues>
|
||||
savedValues: ModelFormValues
|
||||
onSave: (values: ModelFormValues) => Promise<void>
|
||||
onReset: () => void
|
||||
isSaving: boolean
|
||||
isResetting: boolean
|
||||
}
|
||||
|
||||
type ModelJsonFieldName =
|
||||
| 'ModelPrice'
|
||||
| 'ModelRatio'
|
||||
| 'CacheRatio'
|
||||
| 'CreateCacheRatio'
|
||||
| 'CompletionRatio'
|
||||
| 'ImageRatio'
|
||||
| 'AudioRatio'
|
||||
| 'AudioCompletionRatio'
|
||||
|
||||
const modelJsonFields: Array<{
|
||||
name: ModelJsonFieldName
|
||||
labelKey: string
|
||||
descriptionKey: string
|
||||
}> = [
|
||||
{
|
||||
name: 'ModelPrice',
|
||||
labelKey: 'Model fixed pricing',
|
||||
descriptionKey:
|
||||
'JSON map of model → USD cost per request. Takes precedence over ratio based billing.',
|
||||
},
|
||||
{
|
||||
name: 'ModelRatio',
|
||||
labelKey: 'Model ratio',
|
||||
descriptionKey: 'JSON map of model → multiplier applied to quota billing.',
|
||||
},
|
||||
{
|
||||
name: 'CacheRatio',
|
||||
labelKey: 'Prompt cache ratio',
|
||||
descriptionKey: 'Optional ratio used when upstream cache hits occur.',
|
||||
},
|
||||
{
|
||||
name: 'CreateCacheRatio',
|
||||
labelKey: 'Create cache ratio',
|
||||
descriptionKey:
|
||||
'Ratio applied when creating cache entries for supported models.',
|
||||
},
|
||||
{
|
||||
name: 'CompletionRatio',
|
||||
labelKey: 'Completion ratio',
|
||||
descriptionKey:
|
||||
'Applies to custom completion endpoints. JSON map of model → ratio.',
|
||||
},
|
||||
{
|
||||
name: 'ImageRatio',
|
||||
labelKey: 'Image ratio',
|
||||
descriptionKey: 'Configure per-model ratio for image inputs or outputs.',
|
||||
},
|
||||
{
|
||||
name: 'AudioRatio',
|
||||
labelKey: 'Audio ratio',
|
||||
descriptionKey:
|
||||
'Ratio applied to audio inputs where supported by the upstream model.',
|
||||
},
|
||||
{
|
||||
name: 'AudioCompletionRatio',
|
||||
labelKey: 'Audio completion ratio',
|
||||
descriptionKey: 'Ratio applied to audio completions for streaming models.',
|
||||
},
|
||||
]
|
||||
|
||||
function ModelJsonTextareaField(props: {
|
||||
form: UseFormReturn<ModelFormValues>
|
||||
name: ModelJsonFieldName
|
||||
label: string
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<FormField
|
||||
control={props.form.control}
|
||||
name={props.name}
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex min-w-0 flex-col gap-2'>
|
||||
<FormLabel>{props.label}</FormLabel>
|
||||
<FormControl>
|
||||
<JsonCodeEditor
|
||||
value={field.value}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className='text-xs leading-5'>
|
||||
{props.description}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const ModelRatioForm = memo(function ModelRatioForm({
|
||||
form,
|
||||
savedValues,
|
||||
onSave,
|
||||
onReset,
|
||||
isSaving,
|
||||
@@ -71,6 +165,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
||||
}: ModelRatioFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
|
||||
const visualEditorRef = useRef<ModelRatioVisualEditorHandle>(null)
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
(field: keyof ModelFormValues, value: string) => {
|
||||
@@ -86,9 +181,39 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
||||
setEditMode((prev) => (prev === 'visual' ? 'json' : 'visual'))
|
||||
}, [])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (editMode === 'visual') {
|
||||
const committed = await visualEditorRef.current?.commitOpenEditor()
|
||||
if (committed === false) return
|
||||
}
|
||||
|
||||
await form.handleSubmit(onSave)()
|
||||
}, [editMode, form, onSave])
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='flex justify-end'>
|
||||
<div className='flex flex-wrap justify-end gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={onReset}
|
||||
disabled={isResetting}
|
||||
>
|
||||
<RotateCcw data-icon='inline-start' />
|
||||
{t('Reset prices')}
|
||||
</Button>
|
||||
{editMode === 'json' && (
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<Save data-icon='inline-start' />
|
||||
{isSaving ? t('Saving...') : t('Save model prices')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant='outline' size='sm' onClick={toggleEditMode}>
|
||||
{editMode === 'visual' ? (
|
||||
<>
|
||||
@@ -105,28 +230,20 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<SettingsPageActionsPortal>
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={onReset}
|
||||
disabled={isResetting}
|
||||
>
|
||||
{t('Reset prices')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
onClick={form.handleSubmit(onSave)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? t('Saving...') : t('Save model prices')}
|
||||
</Button>
|
||||
</SettingsPageActionsPortal>
|
||||
{editMode === 'visual' ? (
|
||||
<div className='space-y-6'>
|
||||
<ModelRatioVisualEditor
|
||||
ref={visualEditorRef}
|
||||
savedModelPrice={savedValues.ModelPrice}
|
||||
savedModelRatio={savedValues.ModelRatio}
|
||||
savedCacheRatio={savedValues.CacheRatio}
|
||||
savedCreateCacheRatio={savedValues.CreateCacheRatio}
|
||||
savedCompletionRatio={savedValues.CompletionRatio}
|
||||
savedImageRatio={savedValues.ImageRatio}
|
||||
savedAudioRatio={savedValues.AudioRatio}
|
||||
savedAudioCompletionRatio={savedValues.AudioCompletionRatio}
|
||||
savedBillingMode={savedValues.BillingMode}
|
||||
savedBillingExpr={savedValues.BillingExpr}
|
||||
modelPrice={form.watch('ModelPrice')}
|
||||
modelRatio={form.watch('ModelRatio')}
|
||||
cacheRatio={form.watch('CacheRatio')}
|
||||
@@ -137,6 +254,8 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
||||
audioCompletionRatio={form.watch('AudioCompletionRatio')}
|
||||
billingMode={form.watch('BillingMode')}
|
||||
billingExpr={form.watch('BillingExpr')}
|
||||
onSave={handleSave}
|
||||
isSaving={isSaving}
|
||||
onChange={(field, value) => {
|
||||
const fieldMap: Record<string, keyof ModelFormValues> = {
|
||||
'billing_setting.billing_mode': 'BillingMode',
|
||||
@@ -173,155 +292,17 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
||||
</div>
|
||||
) : (
|
||||
<SettingsForm onSubmit={form.handleSubmit(onSave)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ModelPrice'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Model fixed pricing')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'JSON map of model → USD cost per request. Takes precedence over ratio based billing.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ModelRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Model ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'JSON map of model → multiplier applied to quota billing.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='CacheRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Prompt cache ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Optional ratio used when upstream cache hits occur.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='CreateCacheRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Create cache ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Ratio applied when creating cache entries for supported models.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='CompletionRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Completion ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Applies to custom completion endpoints. JSON map of model → ratio.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ImageRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Image ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={6} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Configure per-model ratio for image inputs or outputs.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='AudioRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Audio ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={6} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Ratio applied to audio inputs where supported by the upstream model.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='AudioCompletionRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Audio completion ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={6} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Ratio applied to audio completions for streaming models.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
<div className='grid min-w-0 gap-x-5 gap-y-8 lg:grid-cols-2 2xl:grid-cols-3'>
|
||||
{modelJsonFields.map((config) => (
|
||||
<ModelJsonTextareaField
|
||||
key={config.name}
|
||||
form={form}
|
||||
name={config.name}
|
||||
label={t(config.labelKey)}
|
||||
description={t(config.descriptionKey)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
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',
|
||||
header: () => <div className='text-right'>{t('Actions')}</div>,
|
||||
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,
|
||||
},
|
||||
]
|
||||
}
|
||||
+196
-425
@@ -16,9 +16,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useState, useMemo, memo, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
type ColumnDef,
|
||||
useState,
|
||||
useMemo,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import {
|
||||
type ColumnFiltersState,
|
||||
type OnChangeFn,
|
||||
type PaginationState,
|
||||
@@ -35,39 +43,42 @@ import {
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { Copy, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { Copy, Plus } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
DataTableBulkActions,
|
||||
DataTableColumnHeader,
|
||||
DataTableToolbar,
|
||||
DataTablePagination,
|
||||
} from '@/components/data-table'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
combineBillingExpr,
|
||||
splitBillingExprAndRequestRules,
|
||||
} from '@/features/pricing/lib/billing-expr'
|
||||
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
|
||||
import { safeJsonParse } from '../utils/json-parser'
|
||||
import {
|
||||
ModelPricingEditorPanel,
|
||||
type ModelPricingEditorPanelHandle,
|
||||
ModelPricingSheet,
|
||||
type ModelRatioData,
|
||||
} 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 = {
|
||||
savedModelPrice: string
|
||||
savedModelRatio: string
|
||||
savedCacheRatio: string
|
||||
savedCreateCacheRatio: string
|
||||
savedCompletionRatio: string
|
||||
savedImageRatio: string
|
||||
savedAudioRatio: string
|
||||
savedAudioCompletionRatio: string
|
||||
savedBillingMode: string
|
||||
savedBillingExpr: string
|
||||
modelPrice: string
|
||||
modelRatio: string
|
||||
cacheRatio: string
|
||||
@@ -79,121 +90,31 @@ type ModelRatioVisualEditorProps = {
|
||||
billingMode: string
|
||||
billingExpr: string
|
||||
onChange: (field: string, value: string) => void
|
||||
onSave: () => void | Promise<void>
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
type ModelRow = {
|
||||
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 ModelRatioVisualEditorHandle = {
|
||||
commitOpenEditor: () => Promise<boolean>
|
||||
}
|
||||
|
||||
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: ModelRow, 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: ModelRow, 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: ModelRow, 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')
|
||||
}
|
||||
|
||||
export const ModelRatioVisualEditor = memo(
|
||||
function ModelRatioVisualEditor({
|
||||
const ModelRatioVisualEditorComponent = forwardRef<
|
||||
ModelRatioVisualEditorHandle,
|
||||
ModelRatioVisualEditorProps
|
||||
>(function ModelRatioVisualEditor(
|
||||
{
|
||||
savedModelPrice,
|
||||
savedModelRatio,
|
||||
savedCacheRatio,
|
||||
savedCreateCacheRatio,
|
||||
savedCompletionRatio,
|
||||
savedImageRatio,
|
||||
savedAudioRatio,
|
||||
savedAudioCompletionRatio,
|
||||
savedBillingMode,
|
||||
savedBillingExpr,
|
||||
modelPrice,
|
||||
modelRatio,
|
||||
cacheRatio,
|
||||
@@ -205,7 +126,11 @@ export const ModelRatioVisualEditor = memo(
|
||||
billingMode,
|
||||
billingExpr,
|
||||
onChange,
|
||||
}: ModelRatioVisualEditorProps) {
|
||||
onSave,
|
||||
isSaving,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery('(max-width: 767px)')
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
@@ -215,6 +140,7 @@ export const ModelRatioVisualEditor = memo(
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [globalFilter, setGlobalFilter] = useState('')
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const editorPanelRef = useRef<ModelPricingEditorPanelHandle>(null)
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 20,
|
||||
@@ -259,126 +185,64 @@ export const ModelRatioVisualEditor = memo(
|
||||
}, [columnVisibility])
|
||||
|
||||
const models = useMemo(() => {
|
||||
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
|
||||
fallback: {},
|
||||
context: 'model prices',
|
||||
const savedRows = buildModelSnapshots({
|
||||
modelPrice: savedModelPrice,
|
||||
modelRatio: savedModelRatio,
|
||||
cacheRatio: savedCacheRatio,
|
||||
createCacheRatio: savedCreateCacheRatio,
|
||||
completionRatio: savedCompletionRatio,
|
||||
imageRatio: savedImageRatio,
|
||||
audioRatio: savedAudioRatio,
|
||||
audioCompletionRatio: savedAudioCompletionRatio,
|
||||
billingMode: savedBillingMode,
|
||||
billingExpr: savedBillingExpr,
|
||||
})
|
||||
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>>(
|
||||
const draftRows = buildModelSnapshots({
|
||||
modelPrice,
|
||||
modelRatio,
|
||||
cacheRatio,
|
||||
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>>(
|
||||
imageRatio,
|
||||
audioRatio,
|
||||
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),
|
||||
])
|
||||
|
||||
const modelData: ModelRow[] = 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') {
|
||||
// Tiered_expr models may also retain ratio/price values as fallback
|
||||
// during multi-instance sync delays. We preserve them in the row so
|
||||
// the edit dialog round-trip and the next save don't drop them.
|
||||
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 !== ''),
|
||||
}
|
||||
})
|
||||
|
||||
return modelData.sort((a, b) => a.name.localeCompare(b.name))
|
||||
const savedByName = new Map(savedRows.map((row) => [row.name, row]))
|
||||
const draftByName = new Map(draftRows.map((row) => [row.name, row]))
|
||||
const modelNames = new Set([...savedByName.keys(), ...draftByName.keys()])
|
||||
|
||||
return Array.from(modelNames)
|
||||
.map((name) => {
|
||||
const saved = savedByName.get(name)
|
||||
const draft = draftByName.get(name)
|
||||
const displayed = saved ?? draft
|
||||
const savedSignature = getSnapshotSignature(saved)
|
||||
const draftSignature = getSnapshotSignature(draft)
|
||||
|
||||
return {
|
||||
...displayed!,
|
||||
saved,
|
||||
draft,
|
||||
isDraftChanged: savedSignature !== draftSignature,
|
||||
isDraftDeleted: Boolean(saved && !draft),
|
||||
isDraftNew: Boolean(!saved && draft),
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [
|
||||
savedModelPrice,
|
||||
savedModelRatio,
|
||||
savedCacheRatio,
|
||||
savedCreateCacheRatio,
|
||||
savedCompletionRatio,
|
||||
savedImageRatio,
|
||||
savedAudioRatio,
|
||||
savedAudioCompletionRatio,
|
||||
savedBillingMode,
|
||||
savedBillingExpr,
|
||||
modelPrice,
|
||||
modelRatio,
|
||||
cacheRatio,
|
||||
@@ -414,24 +278,25 @@ export const ModelRatioVisualEditor = memo(
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(model: ModelRow) => {
|
||||
const editableModel = model.draft ?? model.saved ?? model
|
||||
setEditData({
|
||||
name: model.name,
|
||||
price: model.price,
|
||||
ratio: model.ratio,
|
||||
cacheRatio: model.cacheRatio,
|
||||
createCacheRatio: model.createCacheRatio,
|
||||
completionRatio: model.completionRatio,
|
||||
imageRatio: model.imageRatio,
|
||||
audioRatio: model.audioRatio,
|
||||
audioCompletionRatio: model.audioCompletionRatio,
|
||||
name: editableModel.name,
|
||||
price: editableModel.price,
|
||||
ratio: editableModel.ratio,
|
||||
cacheRatio: editableModel.cacheRatio,
|
||||
createCacheRatio: editableModel.createCacheRatio,
|
||||
completionRatio: editableModel.completionRatio,
|
||||
imageRatio: editableModel.imageRatio,
|
||||
audioRatio: editableModel.audioRatio,
|
||||
audioCompletionRatio: editableModel.audioCompletionRatio,
|
||||
billingMode:
|
||||
model.billingMode === 'tiered_expr'
|
||||
editableModel.billingMode === 'tiered_expr'
|
||||
? 'tiered_expr'
|
||||
: model.price && model.price !== ''
|
||||
: editableModel.price && editableModel.price !== ''
|
||||
? 'per-request'
|
||||
: 'per-token',
|
||||
billingExpr: model.billingExpr,
|
||||
requestRuleExpr: model.requestRuleExpr,
|
||||
billingExpr: editableModel.billingExpr,
|
||||
requestRuleExpr: editableModel.requestRuleExpr,
|
||||
})
|
||||
setEditorOpen(true)
|
||||
if (isMobile) setSheetOpen(true)
|
||||
@@ -445,17 +310,10 @@ export const ModelRatioVisualEditor = memo(
|
||||
if (isMobile) setSheetOpen(true)
|
||||
}, [isMobile])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setEditData(null)
|
||||
setEditorOpen(false)
|
||||
setSheetOpen(false)
|
||||
}, [])
|
||||
|
||||
const handleGlobalFilterChange = useCallback<OnChangeFn<string>>(
|
||||
(updater) => {
|
||||
setGlobalFilter((previous) => {
|
||||
const next =
|
||||
typeof updater === 'function' ? updater(previous) : updater
|
||||
const next = typeof updater === 'function' ? updater(previous) : updater
|
||||
if (next !== previous) {
|
||||
setEditData(null)
|
||||
setEditorOpen(false)
|
||||
@@ -556,120 +414,15 @@ export const ModelRatioVisualEditor = memo(
|
||||
]
|
||||
)
|
||||
|
||||
const columns = useMemo<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}
|
||||
/>
|
||||
),
|
||||
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={() => handleEdit(row.original)}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleDelete(row.original.name)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
]
|
||||
}, [handleEdit, handleDelete, t])
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
buildModelRatioColumns({
|
||||
onDelete: handleDelete,
|
||||
onEdit: handleEdit,
|
||||
t,
|
||||
}),
|
||||
[handleEdit, handleDelete, t]
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: models,
|
||||
@@ -836,20 +589,6 @@ export const ModelRatioVisualEditor = memo(
|
||||
]
|
||||
)
|
||||
|
||||
const handleSave = useCallback(
|
||||
(data: ModelRatioData) => {
|
||||
persistPricingData(data)
|
||||
setEditData(data)
|
||||
setEditorOpen(true)
|
||||
toast.success(
|
||||
t(
|
||||
'Pricing changes saved to draft. Click "Save model prices" to apply.'
|
||||
)
|
||||
)
|
||||
},
|
||||
[persistPricingData, t]
|
||||
)
|
||||
|
||||
const handleBatchCopy = useCallback(() => {
|
||||
if (!editData) {
|
||||
toast.error(t('Open a source model first'))
|
||||
@@ -875,12 +614,25 @@ export const ModelRatioVisualEditor = memo(
|
||||
)
|
||||
}, [editData, persistPricingData, t, table])
|
||||
|
||||
const selectedTargetCount = table.getFilteredSelectedRowModel().rows.length
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
commitOpenEditor: async () => {
|
||||
if (!editorOpen || !editorPanelRef.current) return true
|
||||
const data = await editorPanelRef.current.commitDraft()
|
||||
if (!data) return false
|
||||
persistPricingData(data)
|
||||
setEditData(data)
|
||||
return true
|
||||
},
|
||||
}),
|
||||
[editorOpen, persistPricingData]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid min-h-0 gap-4 md:grid-cols-[minmax(0,1fr)_minmax(420px,0.82fr)] xl:grid-cols-[minmax(0,1.1fr)_minmax(520px,0.9fr)]'>
|
||||
<div className='flex min-w-0 flex-col gap-4'>
|
||||
<div className='grid h-[clamp(720px,calc(100vh-12rem),900px)] min-h-0 gap-4 md:grid-cols-[minmax(300px,0.72fr)_minmax(520px,1.28fr)] xl:grid-cols-[minmax(320px,0.68fr)_minmax(640px,1.32fr)]'>
|
||||
<div className='flex min-h-0 min-w-0 flex-col gap-3'>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchPlaceholder={t('Search models...')}
|
||||
@@ -922,55 +674,69 @@ export const ModelRatioVisualEditor = memo(
|
||||
: t('No models configured. Use Add model to get started.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='overflow-hidden rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
|
||||
<table className='w-full caption-bottom text-sm tabular-nums'>
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
<tr key={headerGroup.id} className='border-b'>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
<th
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
className={cn(
|
||||
'bg-background text-foreground sticky top-0 z-10 h-10 px-2 text-left align-middle text-sm font-medium whitespace-nowrap',
|
||||
header.column.id === 'actions' &&
|
||||
'right-0 z-30 w-24 min-w-24 shadow-[-10px_0_14px_-14px_hsl(var(--foreground))]'
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
</th>
|
||||
))}
|
||||
</TableRow>
|
||||
</tr>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
<tr
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() ? 'selected' : undefined
|
||||
}
|
||||
data-state={row.getIsSelected() ? 'selected' : undefined}
|
||||
className={
|
||||
editData?.name === row.original.name
|
||||
? 'bg-muted/45'
|
||||
: undefined
|
||||
? '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 group border-b transition-colors'
|
||||
}
|
||||
onClick={(event) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('button, [role="checkbox"]'))
|
||||
return
|
||||
if (target.closest('button, [role="checkbox"]')) return
|
||||
handleEdit(row.original)
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
<td
|
||||
key={cell.id}
|
||||
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(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
</td>
|
||||
))}
|
||||
</TableRow>
|
||||
</tr>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -979,17 +745,17 @@ export const ModelRatioVisualEditor = memo(
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='hidden min-w-0 md:block'>
|
||||
<div className='hidden min-h-0 min-w-0 md:block'>
|
||||
{editorOpen ? (
|
||||
<ModelPricingEditorPanel
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
ref={editorPanelRef}
|
||||
editData={editData}
|
||||
selectedTargetCount={selectedTargetCount}
|
||||
className='sticky top-4 h-[calc(100vh-8rem)] min-h-[620px]'
|
||||
onSave={onSave}
|
||||
isSaving={isSaving}
|
||||
className='h-full min-h-0'
|
||||
/>
|
||||
) : (
|
||||
<div className='bg-card text-muted-foreground sticky top-4 flex h-[calc(100vh-8rem)] min-h-[420px] flex-col items-center justify-center gap-3 rounded-xl border border-dashed p-6 text-center'>
|
||||
<div className='bg-card text-muted-foreground flex h-full min-h-0 flex-col items-center justify-center gap-3 rounded-xl border border-dashed p-6 text-center'>
|
||||
<div className='text-foreground text-base font-medium'>
|
||||
{t('Select a model to edit pricing')}
|
||||
</div>
|
||||
@@ -1018,17 +784,20 @@ export const ModelRatioVisualEditor = memo(
|
||||
|
||||
{isMobile && (
|
||||
<ModelPricingSheet
|
||||
ref={editorPanelRef}
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
editData={editData}
|
||||
selectedTargetCount={selectedTargetCount}
|
||||
onSave={onSave}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
export const ModelRatioVisualEditor = memo(
|
||||
ModelRatioVisualEditorComponent,
|
||||
// Custom equality check - only re-render if JSON props actually changed
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
@@ -1042,7 +811,9 @@ export const ModelRatioVisualEditor = memo(
|
||||
prevProps.audioCompletionRatio === nextProps.audioCompletionRatio &&
|
||||
prevProps.billingMode === nextProps.billingMode &&
|
||||
prevProps.billingExpr === nextProps.billingExpr &&
|
||||
prevProps.onChange === nextProps.onChange
|
||||
prevProps.onChange === nextProps.onChange &&
|
||||
prevProps.onSave === nextProps.onSave &&
|
||||
prevProps.isSaving === nextProps.isSaving
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
+103
-152
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
@@ -26,6 +26,7 @@ import { toast } from 'sonner'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { resetModelRatios } from '../api'
|
||||
import { SettingsPageTitleStatusPortal } from '../components/settings-page-context'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
import { GroupRatioForm } from './group-ratio-form'
|
||||
@@ -34,169 +35,99 @@ import { ToolPriceSettings } from './tool-price-settings'
|
||||
import { UpstreamRatioSync } from './upstream-ratio-sync'
|
||||
import {
|
||||
formatJsonForTextarea,
|
||||
type JsonValidationError,
|
||||
normalizeJsonString,
|
||||
validateJsonString,
|
||||
} from './utils'
|
||||
|
||||
const modelSchema = z.object({
|
||||
ModelPrice: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
ModelRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
CacheRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
CreateCacheRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
CompletionRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
ImageRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
AudioRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
AudioCompletionRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
ExposeRatioEnabled: z.boolean(),
|
||||
BillingMode: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
BillingExpr: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
})
|
||||
type Translate = (key: string, options?: Record<string, unknown>) => string
|
||||
|
||||
const groupSchema = z.object({
|
||||
GroupRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
function formatJsonValidationError(
|
||||
t: Translate,
|
||||
error?: JsonValidationError,
|
||||
fallback = 'Invalid JSON'
|
||||
) {
|
||||
if (!error) return t(fallback)
|
||||
|
||||
if (error.type === 'required') return t('Value is required')
|
||||
if (error.type === 'structure') {
|
||||
return t(
|
||||
fallback === 'Invalid JSON' ? 'JSON structure is invalid' : fallback
|
||||
)
|
||||
}
|
||||
|
||||
const parts = [
|
||||
error.line && error.column
|
||||
? t('JSON is invalid at line {{line}}, column {{column}}.', {
|
||||
line: error.line,
|
||||
column: error.column,
|
||||
})
|
||||
: error.position !== undefined
|
||||
? t('JSON is invalid at position {{position}}.', {
|
||||
position: error.position,
|
||||
})
|
||||
: t('JSON is invalid. Please check the syntax.'),
|
||||
]
|
||||
|
||||
if (error.missingCommaLine) {
|
||||
parts.push(
|
||||
t('Check line {{line}} for a missing comma.', {
|
||||
line: error.missingCommaLine,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
function createJsonStringField(
|
||||
t: Translate,
|
||||
options?: Parameters<typeof validateJsonString>[1]
|
||||
) {
|
||||
return z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value, options)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
message: formatJsonValidationError(t, result.error, result.message),
|
||||
})
|
||||
}
|
||||
}),
|
||||
TopupGroupRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
UserUsableGroups: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
}
|
||||
|
||||
const createModelSchema = (t: Translate) =>
|
||||
z.object({
|
||||
ModelPrice: createJsonStringField(t),
|
||||
ModelRatio: createJsonStringField(t),
|
||||
CacheRatio: createJsonStringField(t),
|
||||
CreateCacheRatio: createJsonStringField(t),
|
||||
CompletionRatio: createJsonStringField(t),
|
||||
ImageRatio: createJsonStringField(t),
|
||||
AudioRatio: createJsonStringField(t),
|
||||
AudioCompletionRatio: createJsonStringField(t),
|
||||
ExposeRatioEnabled: z.boolean(),
|
||||
BillingMode: createJsonStringField(t),
|
||||
BillingExpr: createJsonStringField(t),
|
||||
})
|
||||
}
|
||||
}),
|
||||
GroupGroupRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
AutoGroups: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value, {
|
||||
|
||||
const createGroupSchema = (t: Translate) =>
|
||||
z.object({
|
||||
GroupRatio: createJsonStringField(t),
|
||||
TopupGroupRatio: createJsonStringField(t),
|
||||
UserUsableGroups: createJsonStringField(t),
|
||||
GroupGroupRatio: createJsonStringField(t),
|
||||
AutoGroups: createJsonStringField(t, {
|
||||
predicate: (parsed) =>
|
||||
Array.isArray(parsed) &&
|
||||
parsed.every((item) => typeof item === 'string'),
|
||||
predicateMessage: 'Expected a JSON array of group identifiers',
|
||||
})
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON array',
|
||||
})
|
||||
}
|
||||
}),
|
||||
DefaultUseAutoGroup: z.boolean(),
|
||||
GroupSpecialUsableGroup: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
GroupSpecialUsableGroup: createJsonStringField(t),
|
||||
})
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
type ModelFormValues = z.infer<typeof modelSchema>
|
||||
type GroupFormValues = z.infer<typeof groupSchema>
|
||||
type ModelFormValues = z.infer<ReturnType<typeof createModelSchema>>
|
||||
type GroupFormValues = z.infer<ReturnType<typeof createGroupSchema>>
|
||||
type RatioTabId = 'models' | 'groups' | 'tool-prices' | 'upstream-sync'
|
||||
|
||||
type RatioSettingsCardProps = {
|
||||
@@ -250,6 +181,9 @@ export function RatioSettingsCard({
|
||||
BillingMode: normalizeJsonString(modelDefaults.BillingMode),
|
||||
BillingExpr: normalizeJsonString(modelDefaults.BillingExpr),
|
||||
})
|
||||
const [savedModelValues, setSavedModelValues] = useState(
|
||||
modelNormalizedDefaults.current
|
||||
)
|
||||
|
||||
const groupNormalizedDefaults = useRef({
|
||||
GroupRatio: normalizeJsonString(groupDefaults.GroupRatio),
|
||||
@@ -262,6 +196,8 @@ export function RatioSettingsCard({
|
||||
groupDefaults.GroupSpecialUsableGroup
|
||||
),
|
||||
})
|
||||
const modelSchema = useMemo(() => createModelSchema(t), [t])
|
||||
const groupSchema = useMemo(() => createGroupSchema(t), [t])
|
||||
|
||||
const modelForm = useForm<ModelFormValues>({
|
||||
resolver: zodResolver(modelSchema),
|
||||
@@ -315,6 +251,7 @@ export function RatioSettingsCard({
|
||||
BillingMode: normalizeJsonString(modelDefaults.BillingMode),
|
||||
BillingExpr: normalizeJsonString(modelDefaults.BillingExpr),
|
||||
}
|
||||
setSavedModelValues(modelNormalizedDefaults.current)
|
||||
|
||||
modelForm.reset({
|
||||
...modelDefaults,
|
||||
@@ -395,6 +332,9 @@ export function RatioSettingsCard({
|
||||
const apiKey = apiKeyMap[key as string] || (key as string)
|
||||
await updateOption.mutateAsync({ key: apiKey, value: normalized[key] })
|
||||
}
|
||||
|
||||
modelNormalizedDefaults.current = normalized
|
||||
setSavedModelValues(normalized)
|
||||
},
|
||||
[t, updateOption]
|
||||
)
|
||||
@@ -462,6 +402,7 @@ export function RatioSettingsCard({
|
||||
return (
|
||||
<ModelRatioForm
|
||||
form={modelForm}
|
||||
savedValues={savedModelValues}
|
||||
onSave={saveModelRatios}
|
||||
onReset={handleResetRatios}
|
||||
isSaving={updateOption.isPending}
|
||||
@@ -499,25 +440,35 @@ export function RatioSettingsCard({
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection title={t(titleKey)}>
|
||||
{visibleTabs.length === 1 ? (
|
||||
renderTabContent(defaultTab)
|
||||
) : (
|
||||
<Tabs defaultValue={defaultTab} className='space-y-6'>
|
||||
<TabsList className={`grid w-full ${tabsGridClass}`}>
|
||||
const renderTabSwitcher = () => (
|
||||
<TabsList className={`grid w-fit max-w-full ${tabsGridClass}`}>
|
||||
{visibleTabs.map((tab) => (
|
||||
<TabsTrigger key={tab} value={tab}>
|
||||
{t(tabLabels[tab])}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleTabs.length === 1 ? (
|
||||
<SettingsSection title={t(titleKey)}>
|
||||
{renderTabContent(defaultTab)}
|
||||
</SettingsSection>
|
||||
) : (
|
||||
<Tabs defaultValue={defaultTab} className='space-y-6'>
|
||||
<SettingsPageTitleStatusPortal>
|
||||
{renderTabSwitcher()}
|
||||
</SettingsPageTitleStatusPortal>
|
||||
|
||||
<SettingsSection title={t(titleKey)}>
|
||||
{visibleTabs.map((tab) => (
|
||||
<TabsContent key={tab} value={tab}>
|
||||
{renderTabContent(tab)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</SettingsSection>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
@@ -533,6 +484,6 @@ export function RatioSettingsCard({
|
||||
handleConfirm={handleConfirmReset}
|
||||
confirmText={t('Reset')}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+13
-12
@@ -40,6 +40,7 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
@@ -1309,9 +1310,7 @@ function PresetSection({ applyPreset }: PresetSectionProps) {
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Preset templates')}
|
||||
</span>
|
||||
<span className='text-sm font-medium'>{t('Preset templates')}</span>
|
||||
{hasMore && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
@@ -1770,9 +1769,10 @@ export const TieredPricingEditor = memo(function TieredPricingEditor({
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<Label className='text-xs'>{t('Editor mode')}</Label>
|
||||
<div className='space-y-5'>
|
||||
<div className='grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-end'>
|
||||
<Field className='gap-2'>
|
||||
<FieldLabel>{t('Editor mode')}</FieldLabel>
|
||||
<Select
|
||||
items={[
|
||||
{ value: 'visual', label: t('Visual editor') },
|
||||
@@ -1781,7 +1781,7 @@ export const TieredPricingEditor = memo(function TieredPricingEditor({
|
||||
value={editorMode}
|
||||
onValueChange={(value) => handleModeChange(value as EditorMode)}
|
||||
>
|
||||
<SelectTrigger className='w-44' size='sm'>
|
||||
<SelectTrigger className='w-full sm:w-56' size='sm'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
@@ -1791,14 +1791,15 @@ export const TieredPricingEditor = memo(function TieredPricingEditor({
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{editorMode === 'raw' && (
|
||||
<div className='sm:pb-0.5'>
|
||||
<LlmPromptHelper modelName={modelName} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap items-start gap-x-4 gap-y-1'>
|
||||
<div className='flex-1'>
|
||||
<PresetSection applyPreset={applyPreset} />
|
||||
</div>
|
||||
{editorMode === 'raw' && <LlmPromptHelper modelName={modelName} />}
|
||||
</div>
|
||||
|
||||
<div className='bg-muted/30 space-y-3 rounded-md border p-3'>
|
||||
{editorMode === 'visual' ? (
|
||||
|
||||
+47
-4
@@ -49,6 +49,14 @@ type JsonValidationOptions = {
|
||||
predicateMessage?: string
|
||||
}
|
||||
|
||||
export type JsonValidationError = {
|
||||
type: 'required' | 'structure' | 'syntax'
|
||||
line?: number
|
||||
column?: number
|
||||
position?: number
|
||||
missingCommaLine?: number
|
||||
}
|
||||
|
||||
function extractErrorPosition(
|
||||
error: unknown,
|
||||
jsonString: string
|
||||
@@ -81,8 +89,15 @@ function extractErrorPosition(
|
||||
return {}
|
||||
}
|
||||
|
||||
function formatErrorMessage(error: unknown, jsonString: string): string {
|
||||
if (!(error instanceof Error)) return 'Invalid JSON'
|
||||
function buildSyntaxError(
|
||||
error: unknown,
|
||||
jsonString: string
|
||||
): JsonValidationError {
|
||||
if (!(error instanceof Error)) {
|
||||
return {
|
||||
type: 'syntax',
|
||||
} satisfies JsonValidationError
|
||||
}
|
||||
|
||||
const position = extractErrorPosition(error, jsonString)
|
||||
const message = error.message
|
||||
@@ -93,10 +108,29 @@ function formatErrorMessage(error: unknown, jsonString: string): string {
|
||||
message.includes('Expected property name') ||
|
||||
message.includes('Unexpected string')
|
||||
|
||||
const missingCommaLine =
|
||||
isMissingCommaError && position.line && position.line > 1
|
||||
? position.line - 1
|
||||
: undefined
|
||||
|
||||
return {
|
||||
type: 'syntax',
|
||||
...position,
|
||||
missingCommaLine,
|
||||
} satisfies JsonValidationError
|
||||
}
|
||||
|
||||
function formatErrorMessage(error: unknown, jsonString: string): string {
|
||||
if (!(error instanceof Error)) return 'Invalid JSON'
|
||||
|
||||
const position = extractErrorPosition(error, jsonString)
|
||||
const message = error.message
|
||||
const syntaxError = buildSyntaxError(error, jsonString)
|
||||
|
||||
if (position.line && position.column) {
|
||||
let hint = ''
|
||||
if (isMissingCommaError && position.line > 1) {
|
||||
hint = ` (check line ${position.line - 1} for missing comma)`
|
||||
if (syntaxError.missingCommaLine) {
|
||||
hint = ` (check line ${syntaxError.missingCommaLine} for missing comma)`
|
||||
}
|
||||
return `Error at line ${position.line}, column ${position.column}: ${message}${hint}`
|
||||
}
|
||||
@@ -119,6 +153,11 @@ export function validateJsonString(
|
||||
return {
|
||||
valid: allowEmpty,
|
||||
message: allowEmpty ? undefined : 'Value is required',
|
||||
error: allowEmpty
|
||||
? undefined
|
||||
: ({
|
||||
type: 'required',
|
||||
} satisfies JsonValidationError),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +167,9 @@ export function validateJsonString(
|
||||
return {
|
||||
valid: false,
|
||||
message: predicateMessage || 'JSON structure is invalid',
|
||||
error: {
|
||||
type: 'structure',
|
||||
} satisfies JsonValidationError,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +178,7 @@ export function validateJsonString(
|
||||
return {
|
||||
valid: false,
|
||||
message: formatErrorMessage(error, trimmed),
|
||||
error: buildSyntaxError(error, trimmed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+7
@@ -681,6 +681,7 @@
|
||||
"Check for updates": "Check for updates",
|
||||
"Check in daily to receive random quota rewards": "Check in daily to receive random quota rewards",
|
||||
"Check in now": "Check in now",
|
||||
"Check line {{line}} for a missing comma.": "Check line {{line}} for a missing comma.",
|
||||
"Check out the Quick Start": "Check out the Quick Start",
|
||||
"Check resolved IPs against IP filters even when accessing by domain": "Check resolved IPs against IP filters even when accessing by domain",
|
||||
"Check-in failed": "Check-in failed",
|
||||
@@ -1528,6 +1529,7 @@
|
||||
"Expand": "Expand",
|
||||
"Expand All": "Expand All",
|
||||
"Expected a JSON array.": "Expected a JSON array.",
|
||||
"Expected a JSON array of group identifiers": "Expected a JSON array of group identifiers",
|
||||
"Experiment with prompts and models in real time.": "Experiment with prompts and models in real time.",
|
||||
"Expiration Time": "Expiration Time",
|
||||
"expired": "expired",
|
||||
@@ -2095,6 +2097,9 @@
|
||||
"JSON Editor": "JSON Editor",
|
||||
"JSON format error": "JSON format error",
|
||||
"JSON format supports service account JSON files": "JSON format supports service account JSON files",
|
||||
"JSON is invalid at line {{line}}, column {{column}}.": "JSON is invalid at line {{line}}, column {{column}}.",
|
||||
"JSON is invalid at position {{position}}.": "JSON is invalid at position {{position}}.",
|
||||
"JSON is invalid. Please check the syntax.": "JSON is invalid. Please check the syntax.",
|
||||
"JSON map of group → description exposed when users create API keys.": "JSON map of group → description exposed when users create API keys.",
|
||||
"JSON map of group → ratio applied when the user selects the group explicitly.": "JSON map of group → ratio applied when the user selects the group explicitly.",
|
||||
"JSON map of model → multiplier applied to quota billing.": "JSON map of model → multiplier applied to quota billing.",
|
||||
@@ -2103,6 +2108,7 @@
|
||||
"JSON Mode": "JSON Mode",
|
||||
"JSON must be an object": "JSON must be an object",
|
||||
"JSON object:": "JSON object:",
|
||||
"JSON structure is invalid": "JSON structure is invalid",
|
||||
"JSON Text": "JSON Text",
|
||||
"JSON-based access control rules. Leave empty to allow all users.": "JSON-based access control rules. Leave empty to allow all users.",
|
||||
"Just now": "Just now",
|
||||
@@ -4315,6 +4321,7 @@
|
||||
"Validity Period": "Validity Period",
|
||||
"Value": "Value",
|
||||
"Value (supports JSON or plain text)": "Value (supports JSON or plain text)",
|
||||
"Value is required": "Value is required",
|
||||
"Value must be at least 0": "Value must be at least 0",
|
||||
"Value Regex": "Value Regex",
|
||||
"variable": "variable",
|
||||
|
||||
Vendored
+7
@@ -681,6 +681,7 @@
|
||||
"Check for updates": "Vérifier les mises à jour",
|
||||
"Check in daily to receive random quota rewards": "Connectez-vous quotidiennement pour recevoir des récompenses de quota aléatoires",
|
||||
"Check in now": "Se connecter maintenant",
|
||||
"Check line {{line}} for a missing comma.": "Vérifiez la ligne {{line}} pour une virgule manquante.",
|
||||
"Check out the Quick Start": "Consultez le démarrage rapide",
|
||||
"Check resolved IPs against IP filters even when accessing by domain": "Vérifier les adresses IP résolues par rapport aux filtres IP même lors de l'accès par domaine",
|
||||
"Check-in failed": "Échec de la connexion",
|
||||
@@ -1528,6 +1529,7 @@
|
||||
"Expand": "Développer",
|
||||
"Expand All": "Tout développer",
|
||||
"Expected a JSON array.": "Un tableau JSON est attendu.",
|
||||
"Expected a JSON array of group identifiers": "Un tableau JSON d'identifiants de groupe est attendu",
|
||||
"Experiment with prompts and models in real time.": "Expérimentez avec des prompts et des modèles en temps réel.",
|
||||
"Expiration Time": "Heure d'expiration",
|
||||
"expired": "expiré",
|
||||
@@ -2095,6 +2097,9 @@
|
||||
"JSON Editor": "Édition JSON",
|
||||
"JSON format error": "Erreur de format JSON",
|
||||
"JSON format supports service account JSON files": "Le format JSON prend en charge les fichiers JSON de compte de service",
|
||||
"JSON is invalid at line {{line}}, column {{column}}.": "Le JSON est invalide à la ligne {{line}}, colonne {{column}}.",
|
||||
"JSON is invalid at position {{position}}.": "Le JSON est invalide à la position {{position}}.",
|
||||
"JSON is invalid. Please check the syntax.": "Le JSON est invalide. Veuillez vérifier la syntaxe.",
|
||||
"JSON map of group → description exposed when users create API keys.": "Carte JSON de groupe → description exposée lorsque les utilisateurs créent des clés API.",
|
||||
"JSON map of group → ratio applied when the user selects the group explicitly.": "Carte JSON de groupe → ratio appliqué lorsque l'utilisateur sélectionne explicitement le groupe.",
|
||||
"JSON map of model → multiplier applied to quota billing.": "Carte JSON de modèle → multiplicateur appliqué à la facturation par quota.",
|
||||
@@ -2103,6 +2108,7 @@
|
||||
"JSON Mode": "Mode JSON",
|
||||
"JSON must be an object": "Le JSON doit être un objet",
|
||||
"JSON object:": "Objet JSON :",
|
||||
"JSON structure is invalid": "La structure JSON est invalide",
|
||||
"JSON Text": "Texte JSON",
|
||||
"JSON-based access control rules. Leave empty to allow all users.": "Règles de contrôle d'accès basées sur JSON. Laisser vide pour autoriser tous les utilisateurs.",
|
||||
"Just now": "À l'instant",
|
||||
@@ -4315,6 +4321,7 @@
|
||||
"Validity Period": "Période de validité",
|
||||
"Value": "Valeur",
|
||||
"Value (supports JSON or plain text)": "Valeur (JSON ou texte brut)",
|
||||
"Value is required": "La valeur est obligatoire",
|
||||
"Value must be at least 0": "La valeur doit être au moins 0",
|
||||
"Value Regex": "Regex de valeur",
|
||||
"variable": "variable",
|
||||
|
||||
Vendored
+7
@@ -681,6 +681,7 @@
|
||||
"Check for updates": "更新を確認",
|
||||
"Check in daily to receive random quota rewards": "毎日チェックインして、ランダムなノルマ報酬を受け取りましょう",
|
||||
"Check in now": "今すぐチェックイン",
|
||||
"Check line {{line}} for a missing comma.": "{{line}} 行目にカンマの抜けがないか確認してください。",
|
||||
"Check out the Quick Start": "クイックスタートをご確認ください",
|
||||
"Check resolved IPs against IP filters even when accessing by domain": "ドメインによるアクセスであっても、解決されたIPをIPフィルターと照合してチェックします",
|
||||
"Check-in failed": "チェックインできませんでした",
|
||||
@@ -1528,6 +1529,7 @@
|
||||
"Expand": "展開",
|
||||
"Expand All": "すべて展開",
|
||||
"Expected a JSON array.": "JSON 配列が必要です。",
|
||||
"Expected a JSON array of group identifiers": "グループ識別子の JSON 配列が必要です",
|
||||
"Experiment with prompts and models in real time.": "プロンプトとモデルをリアルタイムで実験する。",
|
||||
"Expiration Time": "有効期限",
|
||||
"expired": "期限切れ",
|
||||
@@ -2095,6 +2097,9 @@
|
||||
"JSON Editor": "JSON編集",
|
||||
"JSON format error": "JSONフォーマットエラー",
|
||||
"JSON format supports service account JSON files": "JSON形式はサービスアカウントJSONファイルをサポートします",
|
||||
"JSON is invalid at line {{line}}, column {{column}}.": "JSON は {{line}} 行目、{{column}} 列目で無効です。",
|
||||
"JSON is invalid at position {{position}}.": "JSON は位置 {{position}} で無効です。",
|
||||
"JSON is invalid. Please check the syntax.": "JSON が無効です。構文を確認してください。",
|
||||
"JSON map of group → description exposed when users create API keys.": "ユーザーがAPIキーを作成する際に公開される、グループ → 説明のJSONマップ。",
|
||||
"JSON map of group → ratio applied when the user selects the group explicitly.": "ユーザーがグループを明示的に選択したときに適用される、グループ → 比率のJSONマップ。",
|
||||
"JSON map of model → multiplier applied to quota billing.": "モデル → クォータ請求に適用される乗数のJSONマップ。",
|
||||
@@ -2103,6 +2108,7 @@
|
||||
"JSON Mode": "JSONモード",
|
||||
"JSON must be an object": "JSON はオブジェクトである必要があります",
|
||||
"JSON object:": "JSONオブジェクト:",
|
||||
"JSON structure is invalid": "JSON 構造が無効です",
|
||||
"JSON Text": "JSONテキスト",
|
||||
"JSON-based access control rules. Leave empty to allow all users.": "JSONベースのアクセス制御ルール。すべてのユーザーを許可する場合は空のままにしてください。",
|
||||
"Just now": "たった今",
|
||||
@@ -4315,6 +4321,7 @@
|
||||
"Validity Period": "有効期間",
|
||||
"Value": "値",
|
||||
"Value (supports JSON or plain text)": "値(JSONまたはプレーンテキスト対応)",
|
||||
"Value is required": "値は必須です",
|
||||
"Value must be at least 0": "値は 0 以上である必要があります",
|
||||
"Value Regex": "Value 正規表現",
|
||||
"variable": "変数",
|
||||
|
||||
Vendored
+7
@@ -681,6 +681,7 @@
|
||||
"Check for updates": "Проверить обновления",
|
||||
"Check in daily to receive random quota rewards": "Регистрируйтесь ежедневно, чтобы получать случайные вознаграждения по квоте",
|
||||
"Check in now": "Войдите сейчас",
|
||||
"Check line {{line}} for a missing comma.": "Проверьте строку {{line}} на пропущенную запятую.",
|
||||
"Check out the Quick Start": "Ознакомьтесь с быстрым стартом",
|
||||
"Check resolved IPs against IP filters even when accessing by domain": "Проверять разрешенные IP-адреса по IP-фильтрам даже при доступе по домену",
|
||||
"Check-in failed": "Регистрация не удалась.",
|
||||
@@ -1528,6 +1529,7 @@
|
||||
"Expand": "Развернуть",
|
||||
"Expand All": "Развернуть все",
|
||||
"Expected a JSON array.": "Ожидается JSON-массив.",
|
||||
"Expected a JSON array of group identifiers": "Ожидается JSON-массив идентификаторов групп",
|
||||
"Experiment with prompts and models in real time.": "Экспериментируйте с промптами и моделями в реальном времени.",
|
||||
"Expiration Time": "Время истечения срока действия",
|
||||
"expired": "истек",
|
||||
@@ -2095,6 +2097,9 @@
|
||||
"JSON Editor": "Редактирование JSON",
|
||||
"JSON format error": "Ошибка формата JSON",
|
||||
"JSON format supports service account JSON files": "Формат JSON поддерживает JSON-файлы сервисного аккаунта",
|
||||
"JSON is invalid at line {{line}}, column {{column}}.": "JSON недействителен в строке {{line}}, столбце {{column}}.",
|
||||
"JSON is invalid at position {{position}}.": "JSON недействителен в позиции {{position}}.",
|
||||
"JSON is invalid. Please check the syntax.": "JSON недействителен. Проверьте синтаксис.",
|
||||
"JSON map of group → description exposed when users create API keys.": "JSON-карта группы → описание, отображаемое при создании пользователями ключей API.",
|
||||
"JSON map of group → ratio applied when the user selects the group explicitly.": "JSON-карта группы → соотношение, применяемое, когда пользователь явно выбирает группу.",
|
||||
"JSON map of model → multiplier applied to quota billing.": "JSON-карта модели → множитель, применяемый к тарификации по квоте.",
|
||||
@@ -2103,6 +2108,7 @@
|
||||
"JSON Mode": "Режим JSON",
|
||||
"JSON must be an object": "JSON должен быть объектом",
|
||||
"JSON object:": "Объект JSON:",
|
||||
"JSON structure is invalid": "Структура JSON недействительна",
|
||||
"JSON Text": "JSON текст",
|
||||
"JSON-based access control rules. Leave empty to allow all users.": "Правила контроля доступа на основе JSON. Оставьте пустым, чтобы разрешить всем пользователям.",
|
||||
"Just now": "Только что",
|
||||
@@ -4315,6 +4321,7 @@
|
||||
"Validity Period": "Срок действия",
|
||||
"Value": "Значение",
|
||||
"Value (supports JSON or plain text)": "Значение (JSON или текст)",
|
||||
"Value is required": "Значение обязательно",
|
||||
"Value must be at least 0": "Значение должно быть не менее 0",
|
||||
"Value Regex": "Регулярное выражение значения",
|
||||
"variable": "переменная",
|
||||
|
||||
Vendored
+7
@@ -681,6 +681,7 @@
|
||||
"Check for updates": "Kiểm tra cập nhật",
|
||||
"Check in daily to receive random quota rewards": "Nhận phòng hàng ngày để nhận phần thưởng theo hạn ngạch ngẫu nhiên",
|
||||
"Check in now": "Điểm danh ngay",
|
||||
"Check line {{line}} for a missing comma.": "Kiểm tra dòng {{line}} xem có thiếu dấu phẩy không.",
|
||||
"Check out the Quick Start": "Xem hướng dẫn bắt đầu nhanh",
|
||||
"Check resolved IPs against IP filters even when accessing by domain": "Kiểm tra các IP đã phân giải đối chiếu với các bộ lọc IP ngay cả khi truy cập bằng tên miền",
|
||||
"Check-in failed": "Điểm danh thất bại",
|
||||
@@ -1528,6 +1529,7 @@
|
||||
"Expand": "Mở rộng",
|
||||
"Expand All": "Mở rộng tất cả",
|
||||
"Expected a JSON array.": "Cần là một mảng JSON.",
|
||||
"Expected a JSON array of group identifiers": "Cần là một mảng JSON gồm các định danh nhóm",
|
||||
"Experiment with prompts and models in real time.": "Thử nghiệm với prompt và mô hình theo thời gian thực.",
|
||||
"Expiration Time": "Thời gian hết hạn",
|
||||
"expired": "Đã hết hạn",
|
||||
@@ -2095,6 +2097,9 @@
|
||||
"JSON Editor": "Trình chỉnh sửa JSON",
|
||||
"JSON format error": "Lỗi định dạng JSON",
|
||||
"JSON format supports service account JSON files": "Định dạng JSON hỗ trợ các tệp JSON tài khoản dịch vụ",
|
||||
"JSON is invalid at line {{line}}, column {{column}}.": "JSON không hợp lệ tại dòng {{line}}, cột {{column}}.",
|
||||
"JSON is invalid at position {{position}}.": "JSON không hợp lệ tại vị trí {{position}}.",
|
||||
"JSON is invalid. Please check the syntax.": "JSON không hợp lệ. Vui lòng kiểm tra cú pháp.",
|
||||
"JSON map of group → description exposed when users create API keys.": "Ánh xạ JSON của nhóm → mô tả được hiển thị khi người dùng tạo khóa API.",
|
||||
"JSON map of group → ratio applied when the user selects the group explicitly.": "Bản đồ JSON của nhóm → tỷ lệ được áp dụng khi người dùng chọn nhóm đó một cách rõ ràng.",
|
||||
"JSON map of model → multiplier applied to quota billing.": "Bản đồ JSON của mô hình → hệ số nhân áp dụng cho thanh toán hạn mức.",
|
||||
@@ -2103,6 +2108,7 @@
|
||||
"JSON Mode": "Chế độ JSON",
|
||||
"JSON must be an object": "JSON phải là object",
|
||||
"JSON object:": "Đối tượng JSON:",
|
||||
"JSON structure is invalid": "Cấu trúc JSON không hợp lệ",
|
||||
"JSON Text": "Văn bản JSON",
|
||||
"JSON-based access control rules. Leave empty to allow all users.": "Quy tắc kiểm soát truy cập dựa trên JSON. Để trống để cho phép tất cả người dùng.",
|
||||
"Just now": "Vừa nãy",
|
||||
@@ -4315,6 +4321,7 @@
|
||||
"Validity Period": "Thời hạn hiệu lực",
|
||||
"Value": "Giá trị",
|
||||
"Value (supports JSON or plain text)": "Giá trị (hỗ trợ JSON hoặc văn bản thuần)",
|
||||
"Value is required": "Giá trị là bắt buộc",
|
||||
"Value must be at least 0": "Giá trị phải ít nhất là 0",
|
||||
"Value Regex": "Regex giá trị",
|
||||
"variable": "biến",
|
||||
|
||||
Vendored
+7
@@ -681,6 +681,7 @@
|
||||
"Check for updates": "检查更新",
|
||||
"Check in daily to receive random quota rewards": "每日签到可获得随机额度奖励",
|
||||
"Check in now": "立即签到",
|
||||
"Check line {{line}} for a missing comma.": "请检查第 {{line}} 行是否缺少逗号。",
|
||||
"Check out the Quick Start": "请查看 新手入门",
|
||||
"Check resolved IPs against IP filters even when accessing by domain": "即使通过域名访问,也对照 IP 过滤器检查解析的 IP",
|
||||
"Check-in failed": "签到失败",
|
||||
@@ -1528,6 +1529,7 @@
|
||||
"Expand": "展开",
|
||||
"Expand All": "全部展开",
|
||||
"Expected a JSON array.": "应为 JSON 数组。",
|
||||
"Expected a JSON array of group identifiers": "应为分组标识符的 JSON 数组",
|
||||
"Experiment with prompts and models in real time.": "实时实验提示词和模型。",
|
||||
"Expiration Time": "过期时间",
|
||||
"expired": "已过期",
|
||||
@@ -2095,6 +2097,9 @@
|
||||
"JSON Editor": "JSON 编辑",
|
||||
"JSON format error": "JSON 格式错误",
|
||||
"JSON format supports service account JSON files": "JSON 格式支持服务账户 JSON 文件",
|
||||
"JSON is invalid at line {{line}}, column {{column}}.": "JSON 在第 {{line}} 行、第 {{column}} 列无效。",
|
||||
"JSON is invalid at position {{position}}.": "JSON 在位置 {{position}} 无效。",
|
||||
"JSON is invalid. Please check the syntax.": "JSON 无效,请检查语法。",
|
||||
"JSON map of group → description exposed when users create API keys.": "分组 → 描述的 JSON 映射,在用户创建 API 密钥时公开。",
|
||||
"JSON map of group → ratio applied when the user selects the group explicitly.": "分组 → 比率的 JSON 映射,当用户明确选择该分组时应用此比率。",
|
||||
"JSON map of model → multiplier applied to quota billing.": "模型 → 应用于配额计费的乘数的 JSON 映射。",
|
||||
@@ -2103,6 +2108,7 @@
|
||||
"JSON Mode": "JSON 模式",
|
||||
"JSON must be an object": "JSON 必须是对象",
|
||||
"JSON object:": "JSON 对象:",
|
||||
"JSON structure is invalid": "JSON 结构无效",
|
||||
"JSON Text": "JSON 文本",
|
||||
"JSON-based access control rules. Leave empty to allow all users.": "基于 JSON 的访问控制规则。留空以允许所有用户。",
|
||||
"Just now": "刚刚",
|
||||
@@ -4315,6 +4321,7 @@
|
||||
"Validity Period": "有效期",
|
||||
"Value": "值",
|
||||
"Value (supports JSON or plain text)": "值(支持 JSON 或普通文本)",
|
||||
"Value is required": "值为必填项",
|
||||
"Value must be at least 0": "值必须至少为 0",
|
||||
"Value Regex": "Value 正则",
|
||||
"variable": "变量",
|
||||
|
||||
Vendored
+1
@@ -88,6 +88,7 @@ export const STATIC_I18N_KEYS = [
|
||||
'Failed to delete API key',
|
||||
'Failed to delete API keys',
|
||||
'Failed to update API key status',
|
||||
'Expected a JSON array of group identifiers',
|
||||
'Successfully created {{count}} API Key(s)',
|
||||
'Successfully deleted {{count}} API key(s)',
|
||||
'Enter API key for this channel',
|
||||
|
||||
Reference in New Issue
Block a user