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 className='truncate'>{props.title}</span>
|
||||||
<span
|
<span
|
||||||
ref={setTitleStatusContainer}
|
ref={setTitleStatusContainer}
|
||||||
className='inline-flex shrink-0'
|
className='inline-flex min-w-0 shrink-0 items-center'
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</SectionPageLayout.Title>
|
</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
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import {
|
||||||
import * as z from 'zod'
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { AlertTriangle, ChevronDown } from 'lucide-react'
|
import { AlertTriangle, Save } from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from '@/components/ui/collapsible'
|
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
FieldDescription,
|
FieldDescription,
|
||||||
@@ -56,71 +56,39 @@ import {
|
|||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
SheetDescription,
|
SheetDescription,
|
||||||
SheetFooter,
|
|
||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from '@/components/ui/sheet'
|
} from '@/components/ui/sheet'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { sideDrawerContentClassName } from '@/components/drawer-layout'
|
||||||
import {
|
import {
|
||||||
sideDrawerContentClassName,
|
EMPTY_LANE_ENABLED,
|
||||||
sideDrawerFooterClassName,
|
EMPTY_LANE_PRICES,
|
||||||
} from '@/components/drawer-layout'
|
buildPreviewRows,
|
||||||
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
|
createInitialLaneState,
|
||||||
import {
|
createModelPricingSchema,
|
||||||
SettingsControlGroup,
|
hasValue,
|
||||||
SettingsSwitchField,
|
laneConfigs,
|
||||||
} from '../components/settings-form-layout'
|
numericDraftRegex,
|
||||||
|
ratioFieldByLane,
|
||||||
|
toNumberOrNull,
|
||||||
|
type LaneKey,
|
||||||
|
type ModelPricingFormValues,
|
||||||
|
type ModelRatioData,
|
||||||
|
type PricingMode,
|
||||||
|
} from './model-pricing-core'
|
||||||
|
import { PriceInput, PriceLane } from './model-pricing-inputs'
|
||||||
import { formatPricingNumber } from './pricing-format'
|
import { formatPricingNumber } from './pricing-format'
|
||||||
import { TieredPricingEditor } from './tiered-pricing-editor'
|
import { TieredPricingEditor } from './tiered-pricing-editor'
|
||||||
|
|
||||||
const createModelPricingSchema = (t: (key: string) => string) =>
|
export type { ModelRatioData } from './model-pricing-core'
|
||||||
z.object({
|
|
||||||
name: z.string().min(1, t('Model name is required')),
|
|
||||||
price: z.string().optional(),
|
|
||||||
ratio: z.string().optional(),
|
|
||||||
cacheRatio: z.string().optional(),
|
|
||||||
createCacheRatio: z.string().optional(),
|
|
||||||
completionRatio: z.string().optional(),
|
|
||||||
imageRatio: z.string().optional(),
|
|
||||||
audioRatio: z.string().optional(),
|
|
||||||
audioCompletionRatio: z.string().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
type ModelPricingFormValues = z.infer<
|
|
||||||
ReturnType<typeof createModelPricingSchema>
|
|
||||||
>
|
|
||||||
|
|
||||||
type PricingMode = 'per-token' | 'per-request' | 'tiered_expr'
|
|
||||||
type LaneKey =
|
|
||||||
| 'completion'
|
|
||||||
| 'cache'
|
|
||||||
| 'createCache'
|
|
||||||
| 'image'
|
|
||||||
| 'audioInput'
|
|
||||||
| 'audioOutput'
|
|
||||||
|
|
||||||
export type ModelRatioData = {
|
|
||||||
name: string
|
|
||||||
price?: string
|
|
||||||
ratio?: string
|
|
||||||
cacheRatio?: string
|
|
||||||
createCacheRatio?: string
|
|
||||||
completionRatio?: string
|
|
||||||
imageRatio?: string
|
|
||||||
audioRatio?: string
|
|
||||||
audioCompletionRatio?: string
|
|
||||||
billingMode?: PricingMode
|
|
||||||
billingExpr?: string
|
|
||||||
requestRuleExpr?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModelPricingSheetProps = {
|
type ModelPricingSheetProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
onSave: (data: ModelRatioData) => void
|
|
||||||
onCancel?: () => void
|
|
||||||
editData?: ModelRatioData | null
|
editData?: ModelRatioData | null
|
||||||
selectedTargetCount?: number
|
onSave?: () => void | Promise<void>
|
||||||
|
isSaving?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelPricingEditorPanelProps = Omit<
|
type ModelPricingEditorPanelProps = Omit<
|
||||||
@@ -130,261 +98,17 @@ type ModelPricingEditorPanelProps = Omit<
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PreviewRow = {
|
export type ModelPricingEditorPanelHandle = {
|
||||||
key: string
|
commitDraft: () => Promise<ModelRatioData | null>
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
multiline?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const numericDraftRegex = /^(\d+(\.\d*)?|\.\d*)?$/
|
export const ModelPricingSheet = forwardRef<
|
||||||
|
ModelPricingEditorPanelHandle,
|
||||||
const EMPTY_LANE_PRICES: Record<LaneKey, string> = {
|
ModelPricingSheetProps
|
||||||
completion: '',
|
>(function ModelPricingSheet(
|
||||||
cache: '',
|
{ open, onOpenChange, editData, onSave, isSaving },
|
||||||
createCache: '',
|
ref
|
||||||
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) {
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const title = editData ? t('Edit model pricing') : t('Add model pricing')
|
const title = editData ? t('Edit model pricing') : t('Add model pricing')
|
||||||
const description = editData?.name || t('New model')
|
const description = editData?.name || t('New model')
|
||||||
@@ -400,27 +124,24 @@ export function ModelPricingSheet({
|
|||||||
<SheetDescription>{description}</SheetDescription>
|
<SheetDescription>{description}</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<ModelPricingEditorPanel
|
<ModelPricingEditorPanel
|
||||||
onSave={onSave}
|
ref={ref}
|
||||||
editData={editData}
|
editData={editData}
|
||||||
selectedTargetCount={selectedTargetCount}
|
onSave={onSave}
|
||||||
onCancel={() => {
|
isSaving={isSaving}
|
||||||
onCancel?.()
|
|
||||||
onOpenChange(false)
|
|
||||||
}}
|
|
||||||
className='h-full rounded-none border-0'
|
className='h-full rounded-none border-0'
|
||||||
/>
|
/>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
export function ModelPricingEditorPanel({
|
export const ModelPricingEditorPanel = forwardRef<
|
||||||
onSave,
|
ModelPricingEditorPanelHandle,
|
||||||
editData,
|
ModelPricingEditorPanelProps
|
||||||
selectedTargetCount = 0,
|
>(function ModelPricingEditorPanel(
|
||||||
onCancel,
|
{ editData, className, onSave, isSaving },
|
||||||
className,
|
ref
|
||||||
}: ModelPricingEditorPanelProps) {
|
) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [pricingMode, setPricingMode] = useState<PricingMode>('per-token')
|
const [pricingMode, setPricingMode] = useState<PricingMode>('per-token')
|
||||||
const [promptPrice, setPromptPrice] = useState('')
|
const [promptPrice, setPromptPrice] = useState('')
|
||||||
@@ -432,7 +153,6 @@ export function ModelPricingEditorPanel({
|
|||||||
})
|
})
|
||||||
const [billingExpr, setBillingExpr] = useState('')
|
const [billingExpr, setBillingExpr] = useState('')
|
||||||
const [requestRuleExpr, setRequestRuleExpr] = useState('')
|
const [requestRuleExpr, setRequestRuleExpr] = useState('')
|
||||||
const [previewOpen, setPreviewOpen] = useState(true)
|
|
||||||
const isEditMode = !!editData
|
const isEditMode = !!editData
|
||||||
|
|
||||||
const form = useForm<ModelPricingFormValues>({
|
const form = useForm<ModelPricingFormValues>({
|
||||||
@@ -494,7 +214,6 @@ export function ModelPricingEditorPanel({
|
|||||||
setPromptPrice(nextLaneState.promptPrice)
|
setPromptPrice(nextLaneState.promptPrice)
|
||||||
setLanePrices(nextLaneState.prices)
|
setLanePrices(nextLaneState.prices)
|
||||||
setLaneEnabled(nextLaneState.enabled)
|
setLaneEnabled(nextLaneState.enabled)
|
||||||
setPreviewOpen(true)
|
|
||||||
}, [editData, form])
|
}, [editData, form])
|
||||||
|
|
||||||
const setFormValue = (field: keyof ModelPricingFormValues, value: string) => {
|
const setFormValue = (field: keyof ModelPricingFormValues, value: string) => {
|
||||||
@@ -687,7 +406,7 @@ export function ModelPricingEditorPanel({
|
|||||||
return nextWarnings
|
return nextWarnings
|
||||||
}, [editData, laneEnabled, lanePrices, pricingMode, promptPrice, t])
|
}, [editData, laneEnabled, lanePrices, pricingMode, promptPrice, t])
|
||||||
|
|
||||||
const handleSubmit = (values: ModelPricingFormValues) => {
|
const validatePricingValues = useCallback(() => {
|
||||||
if (
|
if (
|
||||||
pricingMode === 'per-token' &&
|
pricingMode === 'per-token' &&
|
||||||
toNumberOrNull(promptPrice) === null &&
|
toNumberOrNull(promptPrice) === null &&
|
||||||
@@ -698,7 +417,7 @@ export function ModelPricingEditorPanel({
|
|||||||
form.setError('ratio', {
|
form.setError('ratio', {
|
||||||
message: t('Input price is required before saving dependent prices.'),
|
message: t('Input price is required before saving dependent prices.'),
|
||||||
})
|
})
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -709,9 +428,14 @@ export function ModelPricingEditorPanel({
|
|||||||
form.setError('audioRatio', {
|
form.setError('audioRatio', {
|
||||||
message: t('Audio output price requires an audio input price.'),
|
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 = {
|
const data: ModelRatioData = {
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
billingMode: pricingMode,
|
billingMode: pricingMode,
|
||||||
@@ -730,12 +454,24 @@ export function ModelPricingEditorPanel({
|
|||||||
data.requestRuleExpr = requestRuleExpr
|
data.requestRuleExpr = requestRuleExpr
|
||||||
}
|
}
|
||||||
|
|
||||||
onSave(data)
|
return data
|
||||||
form.reset()
|
},
|
||||||
onCancel?.()
|
[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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -750,23 +486,18 @@ export function ModelPricingEditorPanel({
|
|||||||
<h3 className='truncate text-base font-medium'>
|
<h3 className='truncate text-base font-medium'>
|
||||||
{isEditMode ? t('Edit model pricing') : t('Add model pricing')}
|
{isEditMode ? t('Edit model pricing') : t('Add model pricing')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className='text-muted-foreground truncate text-sm'>
|
|
||||||
{activeName}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={getModeBadgeVariant(pricingMode)}>
|
|
||||||
{t(getModeLabel(pricingMode))}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(handleSubmit)}
|
onSubmit={(event) => event.preventDefault()}
|
||||||
className='flex min-h-0 flex-1 flex-col'
|
className='flex min-h-0 flex-1 flex-col'
|
||||||
autoComplete='off'
|
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>
|
<FieldGroup>
|
||||||
{warnings.length > 0 && (
|
{warnings.length > 0 && (
|
||||||
<Alert variant='destructive'>
|
<Alert variant='destructive'>
|
||||||
@@ -795,16 +526,24 @@ export function ModelPricingEditorPanel({
|
|||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t('The exact model identifier as used in API requests.')}
|
{t(
|
||||||
|
'The exact model identifier as used in API requests.'
|
||||||
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs value={pricingMode} onValueChange={handleModeChange}>
|
<Tabs
|
||||||
|
value={pricingMode}
|
||||||
|
onValueChange={handleModeChange}
|
||||||
|
className='gap-4'
|
||||||
|
>
|
||||||
<TabsList className='grid w-full grid-cols-3'>
|
<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'>
|
<TabsTrigger value='per-request'>
|
||||||
{t('Per-request')}
|
{t('Per-request')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -813,8 +552,8 @@ export function ModelPricingEditorPanel({
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value='per-token' className='flex flex-col gap-5'>
|
<TabsContent value='per-token' className='pt-0'>
|
||||||
<FieldGroup>
|
<FieldGroup className='gap-5'>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>{t('Input price')}</FieldLabel>
|
<FieldLabel>{t('Input price')}</FieldLabel>
|
||||||
<PriceInput
|
<PriceInput
|
||||||
@@ -827,7 +566,7 @@ export function ModelPricingEditorPanel({
|
|||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
</Field>
|
</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) => {
|
{laneConfigs.map((lane) => {
|
||||||
const disabled =
|
const disabled =
|
||||||
lane.key === 'audioOutput' &&
|
lane.key === 'audioOutput' &&
|
||||||
@@ -855,16 +594,15 @@ export function ModelPricingEditorPanel({
|
|||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent
|
<TabsContent value='per-request' className='pt-0'>
|
||||||
value='per-request'
|
<FieldGroup className='gap-5'>
|
||||||
className='flex flex-col gap-5'
|
|
||||||
>
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='price'
|
name='price'
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem className='contents'>
|
||||||
<FormLabel>{t('Fixed price')}</FormLabel>
|
<Field>
|
||||||
|
<FieldLabel>{t('Fixed price')}</FieldLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<InputGroupAddon>$</InputGroupAddon>
|
<InputGroupAddon>$</InputGroupAddon>
|
||||||
@@ -884,21 +622,21 @@ export function ModelPricingEditorPanel({
|
|||||||
</InputGroupAddon>
|
</InputGroupAddon>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FieldDescription>
|
||||||
{t(
|
{t(
|
||||||
'Cost in USD per request, regardless of tokens used.'
|
'Cost in USD per request, regardless of tokens used.'
|
||||||
)}
|
)}
|
||||||
</FormDescription>
|
</FieldDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
</Field>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</FieldGroup>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent
|
<TabsContent value='tiered_expr' className='pt-0'>
|
||||||
value='tiered_expr'
|
<FieldGroup className='gap-5'>
|
||||||
className='flex flex-col gap-5'
|
|
||||||
>
|
|
||||||
<TieredPricingEditor
|
<TieredPricingEditor
|
||||||
modelName={watchedValues.name}
|
modelName={watchedValues.name}
|
||||||
billingExpr={billingExpr}
|
billingExpr={billingExpr}
|
||||||
@@ -906,40 +644,24 @@ export function ModelPricingEditorPanel({
|
|||||||
onBillingExprChange={setBillingExpr}
|
onBillingExprChange={setBillingExpr}
|
||||||
onRequestRuleExprChange={setRequestRuleExpr}
|
onRequestRuleExprChange={setRequestRuleExpr}
|
||||||
/>
|
/>
|
||||||
|
</FieldGroup>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
<Collapsible open={previewOpen} onOpenChange={setPreviewOpen}>
|
<aside className='bg-muted/20 sticky top-0 rounded-lg border'>
|
||||||
<CollapsibleTrigger
|
<div className='border-b px-3 py-2'>
|
||||||
render={
|
<div className='text-sm font-medium'>{t('Preview')}</div>
|
||||||
<Button
|
</div>
|
||||||
type='button'
|
<div className='divide-y'>
|
||||||
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'>
|
|
||||||
{previewRows.map((row) => (
|
{previewRows.map((row) => (
|
||||||
<div
|
<div key={row.key} className='grid gap-1 px-3 py-2.5'>
|
||||||
key={row.key}
|
|
||||||
className='grid grid-cols-[140px_1fr] gap-3 border-b px-3 py-2 text-sm last:border-b-0'
|
|
||||||
>
|
|
||||||
<span className='text-muted-foreground text-xs'>
|
<span className='text-muted-foreground text-xs'>
|
||||||
{row.label}
|
{row.label}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'min-w-0',
|
'min-w-0 text-sm',
|
||||||
row.multiline
|
row.multiline
|
||||||
? 'font-mono text-xs leading-5 break-words whitespace-pre-wrap'
|
? 'font-mono text-xs leading-5 break-words whitespace-pre-wrap'
|
||||||
: 'truncate'
|
: 'truncate'
|
||||||
@@ -950,96 +672,28 @@ export function ModelPricingEditorPanel({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent>
|
</aside>
|
||||||
</Collapsible>
|
|
||||||
</FieldGroup>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<SheetFooter
|
{showActions && (
|
||||||
className={sideDrawerFooterClassName(
|
<div className='bg-background/95 supports-[backdrop-filter]:bg-background/80 shrink-0 border-t p-3 backdrop-blur'>
|
||||||
'grid-cols-1 sm:items-center sm:justify-between'
|
<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'>
|
<Save data-icon='inline-start' />
|
||||||
{selectedTargetCount > 0
|
{isSaving ? t('Saving...') : t('Save model prices')}
|
||||||
? 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')}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SheetFooter>
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
function PriceInput(props: {
|
|
||||||
value: string
|
|
||||||
placeholder?: string
|
|
||||||
disabled?: boolean
|
|
||||||
onChange: (value: string) => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<InputGroup>
|
|
||||||
<InputGroupAddon>$</InputGroupAddon>
|
|
||||||
<InputGroupInput
|
|
||||||
inputMode='decimal'
|
|
||||||
value={props.value}
|
|
||||||
placeholder={props.placeholder}
|
|
||||||
disabled={props.disabled}
|
|
||||||
onChange={(event) => props.onChange(event.target.value)}
|
|
||||||
/>
|
|
||||||
<InputGroupAddon align='inline-end'>$/1M</InputGroupAddon>
|
|
||||||
</InputGroup>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PriceLane(props: {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
placeholder: string
|
|
||||||
value: string
|
|
||||||
enabled: boolean
|
|
||||||
disabled?: boolean
|
|
||||||
onEnabledChange: (checked: boolean) => void
|
|
||||||
onChange: (value: string) => void
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const effectiveDisabled = props.disabled || !props.enabled
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsControlGroup
|
|
||||||
className={cn('space-y-3', effectiveDisabled && 'opacity-75')}
|
|
||||||
data-disabled={effectiveDisabled || undefined}
|
|
||||||
>
|
|
||||||
<SettingsSwitchField
|
|
||||||
checked={props.enabled}
|
|
||||||
disabled={props.disabled}
|
|
||||||
onCheckedChange={props.onEnabledChange}
|
|
||||||
label={props.title}
|
|
||||||
description={props.description}
|
|
||||||
aria-label={props.title}
|
|
||||||
/>
|
|
||||||
<PriceInput
|
|
||||||
value={props.value}
|
|
||||||
placeholder={props.placeholder}
|
|
||||||
disabled={effectiveDisabled}
|
|
||||||
onChange={props.onChange}
|
|
||||||
/>
|
|
||||||
<p className='text-muted-foreground text-xs'>
|
|
||||||
{props.enabled
|
|
||||||
? t('USD price per 1M tokens.')
|
|
||||||
: t('Disabled lanes are omitted on save.')}
|
|
||||||
</p>
|
|
||||||
</SettingsControlGroup>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2023-2026 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
import { splitBillingExprAndRequestRules } from '@/features/pricing/lib/billing-expr'
|
||||||
|
import { safeJsonParse } from '../utils/json-parser'
|
||||||
|
import { formatPricingNumber } from './pricing-format'
|
||||||
|
|
||||||
|
export type ModelPricingSnapshotInput = {
|
||||||
|
modelPrice: string
|
||||||
|
modelRatio: string
|
||||||
|
cacheRatio: string
|
||||||
|
createCacheRatio: string
|
||||||
|
completionRatio: string
|
||||||
|
imageRatio: string
|
||||||
|
audioRatio: string
|
||||||
|
audioCompletionRatio: string
|
||||||
|
billingMode: string
|
||||||
|
billingExpr: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelPricingSnapshot = {
|
||||||
|
name: string
|
||||||
|
price?: string
|
||||||
|
ratio?: string
|
||||||
|
cacheRatio?: string
|
||||||
|
createCacheRatio?: string
|
||||||
|
completionRatio?: string
|
||||||
|
imageRatio?: string
|
||||||
|
audioRatio?: string
|
||||||
|
audioCompletionRatio?: string
|
||||||
|
billingMode?: string
|
||||||
|
billingExpr?: string
|
||||||
|
requestRuleExpr?: string
|
||||||
|
hasConflict: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelRow = ModelPricingSnapshot & {
|
||||||
|
saved?: ModelPricingSnapshot
|
||||||
|
draft?: ModelPricingSnapshot
|
||||||
|
isDraftChanged: boolean
|
||||||
|
isDraftDeleted: boolean
|
||||||
|
isDraftNew: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasPricingValue = (value?: string) =>
|
||||||
|
value !== undefined && value !== ''
|
||||||
|
|
||||||
|
const toNumberOrNull = (value?: string) => {
|
||||||
|
if (!hasPricingValue(value)) return null
|
||||||
|
const num = Number(value)
|
||||||
|
return Number.isFinite(num) ? num : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratioToPrice = (ratio?: string, denominator?: string) => {
|
||||||
|
const ratioNumber = toNumberOrNull(ratio)
|
||||||
|
const denominatorNumber = denominator ? toNumberOrNull(denominator) : 2
|
||||||
|
if (ratioNumber === null || denominatorNumber === null) return ''
|
||||||
|
return formatPricingNumber(ratioNumber * denominatorNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getModeLabel = (mode?: string) => {
|
||||||
|
if (mode === 'per-request') return 'Per-request'
|
||||||
|
if (mode === 'tiered_expr') return 'Expression'
|
||||||
|
return 'Per-token'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getModeVariant = (
|
||||||
|
mode?: string
|
||||||
|
): 'warning' | 'info' | 'success' => {
|
||||||
|
if (mode === 'per-request') return 'warning'
|
||||||
|
if (mode === 'tiered_expr') return 'info'
|
||||||
|
return 'success'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getExpressionSummary = (
|
||||||
|
row: ModelPricingSnapshot,
|
||||||
|
t: (key: string) => string
|
||||||
|
) => {
|
||||||
|
const tierCount = (row.billingExpr?.match(/tier\(/g) || []).length
|
||||||
|
if (tierCount > 0) {
|
||||||
|
return `${t('Tiered pricing')} · ${tierCount} ${t('tiers')}`
|
||||||
|
}
|
||||||
|
return t('Expression pricing')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPriceSummary = (
|
||||||
|
row: ModelPricingSnapshot,
|
||||||
|
t: (key: string) => string
|
||||||
|
) => {
|
||||||
|
if (row.billingMode === 'tiered_expr') {
|
||||||
|
return getExpressionSummary(row, t)
|
||||||
|
}
|
||||||
|
if (row.billingMode === 'per-request') {
|
||||||
|
return row.price ? `$${row.price} / ${t('request')}` : t('Unset price')
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputPrice = ratioToPrice(row.ratio)
|
||||||
|
if (!inputPrice) return t('Unset price')
|
||||||
|
|
||||||
|
const extraCount = [
|
||||||
|
row.completionRatio,
|
||||||
|
row.cacheRatio,
|
||||||
|
row.createCacheRatio,
|
||||||
|
row.imageRatio,
|
||||||
|
row.audioRatio,
|
||||||
|
row.audioCompletionRatio,
|
||||||
|
].filter(hasPricingValue).length
|
||||||
|
|
||||||
|
return extraCount > 0
|
||||||
|
? `${t('Input')} $${inputPrice} · ${extraCount} ${t('extras')}`
|
||||||
|
: `${t('Input')} $${inputPrice}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPriceDetail = (
|
||||||
|
row: ModelPricingSnapshot,
|
||||||
|
t: (key: string) => string
|
||||||
|
) => {
|
||||||
|
if (row.billingMode === 'tiered_expr') {
|
||||||
|
return row.requestRuleExpr
|
||||||
|
? t('Includes request rules')
|
||||||
|
: t('Expression based')
|
||||||
|
}
|
||||||
|
if (row.billingMode === 'per-request') {
|
||||||
|
return t('Fixed request price')
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputPrice = ratioToPrice(row.ratio)
|
||||||
|
if (!inputPrice) return t('No base input price')
|
||||||
|
|
||||||
|
const details = [
|
||||||
|
row.completionRatio &&
|
||||||
|
`${t('Output')} $${ratioToPrice(row.completionRatio, inputPrice)}`,
|
||||||
|
row.cacheRatio &&
|
||||||
|
`${t('Cache')} $${ratioToPrice(row.cacheRatio, inputPrice)}`,
|
||||||
|
row.createCacheRatio &&
|
||||||
|
`${t('Cache write')} $${ratioToPrice(row.createCacheRatio, inputPrice)}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
|
||||||
|
return details.length > 0 ? details.join(' · ') : t('Base input price only')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildModelSnapshots = ({
|
||||||
|
modelPrice,
|
||||||
|
modelRatio,
|
||||||
|
cacheRatio,
|
||||||
|
createCacheRatio,
|
||||||
|
completionRatio,
|
||||||
|
imageRatio,
|
||||||
|
audioRatio,
|
||||||
|
audioCompletionRatio,
|
||||||
|
billingMode,
|
||||||
|
billingExpr,
|
||||||
|
}: ModelPricingSnapshotInput): ModelPricingSnapshot[] => {
|
||||||
|
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
|
||||||
|
fallback: {},
|
||||||
|
context: 'model prices',
|
||||||
|
})
|
||||||
|
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
|
||||||
|
fallback: {},
|
||||||
|
context: 'model ratios',
|
||||||
|
})
|
||||||
|
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
|
||||||
|
fallback: {},
|
||||||
|
context: 'cache ratios',
|
||||||
|
})
|
||||||
|
const createCacheMap = safeJsonParse<Record<string, number>>(
|
||||||
|
createCacheRatio,
|
||||||
|
{ fallback: {}, context: 'create cache ratios' }
|
||||||
|
)
|
||||||
|
const completionMap = safeJsonParse<Record<string, number>>(completionRatio, {
|
||||||
|
fallback: {},
|
||||||
|
context: 'completion ratios',
|
||||||
|
})
|
||||||
|
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
|
||||||
|
fallback: {},
|
||||||
|
context: 'image ratios',
|
||||||
|
})
|
||||||
|
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
|
||||||
|
fallback: {},
|
||||||
|
context: 'audio ratios',
|
||||||
|
})
|
||||||
|
const audioCompletionMap = safeJsonParse<Record<string, number>>(
|
||||||
|
audioCompletionRatio,
|
||||||
|
{ fallback: {}, context: 'audio completion ratios' }
|
||||||
|
)
|
||||||
|
const billingModeMap = safeJsonParse<Record<string, string>>(billingMode, {
|
||||||
|
fallback: {},
|
||||||
|
context: 'billing mode',
|
||||||
|
})
|
||||||
|
const billingExprMap = safeJsonParse<Record<string, string>>(billingExpr, {
|
||||||
|
fallback: {},
|
||||||
|
context: 'billing expression',
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelNames = new Set([
|
||||||
|
...Object.keys(priceMap),
|
||||||
|
...Object.keys(ratioMap),
|
||||||
|
...Object.keys(cacheMap),
|
||||||
|
...Object.keys(createCacheMap),
|
||||||
|
...Object.keys(completionMap),
|
||||||
|
...Object.keys(imageMap),
|
||||||
|
...Object.keys(audioMap),
|
||||||
|
...Object.keys(audioCompletionMap),
|
||||||
|
...Object.keys(billingModeMap),
|
||||||
|
...Object.keys(billingExprMap),
|
||||||
|
])
|
||||||
|
|
||||||
|
return Array.from(modelNames).map((name) => {
|
||||||
|
const price = priceMap[name]?.toString() || ''
|
||||||
|
const ratio = ratioMap[name]?.toString() || ''
|
||||||
|
const cache = cacheMap[name]?.toString() || ''
|
||||||
|
const createCache = createCacheMap[name]?.toString() || ''
|
||||||
|
const completion = completionMap[name]?.toString() || ''
|
||||||
|
const image = imageMap[name]?.toString() || ''
|
||||||
|
const audio = audioMap[name]?.toString() || ''
|
||||||
|
const audioCompletion = audioCompletionMap[name]?.toString() || ''
|
||||||
|
|
||||||
|
const modeForModel = billingModeMap[name]
|
||||||
|
if (modeForModel === 'tiered_expr') {
|
||||||
|
const fullExpr = billingExprMap[name] || ''
|
||||||
|
const { billingExpr: pureExpr, requestRuleExpr } =
|
||||||
|
splitBillingExprAndRequestRules(fullExpr)
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
billingMode: 'tiered_expr',
|
||||||
|
billingExpr: pureExpr,
|
||||||
|
requestRuleExpr,
|
||||||
|
price,
|
||||||
|
ratio,
|
||||||
|
cacheRatio: cache,
|
||||||
|
createCacheRatio: createCache,
|
||||||
|
completionRatio: completion,
|
||||||
|
imageRatio: image,
|
||||||
|
audioRatio: audio,
|
||||||
|
audioCompletionRatio: audioCompletion,
|
||||||
|
hasConflict: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
price,
|
||||||
|
ratio,
|
||||||
|
cacheRatio: cache,
|
||||||
|
createCacheRatio: createCache,
|
||||||
|
completionRatio: completion,
|
||||||
|
imageRatio: image,
|
||||||
|
audioRatio: audio,
|
||||||
|
audioCompletionRatio: audioCompletion,
|
||||||
|
billingMode: price !== '' ? 'per-request' : 'per-token',
|
||||||
|
hasConflict:
|
||||||
|
price !== '' &&
|
||||||
|
(ratio !== '' ||
|
||||||
|
completion !== '' ||
|
||||||
|
cache !== '' ||
|
||||||
|
createCache !== '' ||
|
||||||
|
image !== '' ||
|
||||||
|
audio !== '' ||
|
||||||
|
audioCompletion !== ''),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSnapshotSignature = (snapshot?: ModelPricingSnapshot) => {
|
||||||
|
if (!snapshot) return ''
|
||||||
|
return JSON.stringify({
|
||||||
|
price: snapshot.price || '',
|
||||||
|
ratio: snapshot.ratio || '',
|
||||||
|
cacheRatio: snapshot.cacheRatio || '',
|
||||||
|
createCacheRatio: snapshot.createCacheRatio || '',
|
||||||
|
completionRatio: snapshot.completionRatio || '',
|
||||||
|
imageRatio: snapshot.imageRatio || '',
|
||||||
|
audioRatio: snapshot.audioRatio || '',
|
||||||
|
audioCompletionRatio: snapshot.audioCompletionRatio || '',
|
||||||
|
billingMode: snapshot.billingMode || 'per-token',
|
||||||
|
billingExpr: snapshot.billingExpr || '',
|
||||||
|
requestRuleExpr: snapshot.requestRuleExpr || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
+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
|
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 { 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 { useTranslation } from 'react-i18next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
@@ -31,14 +31,16 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form'
|
} from '@/components/ui/form'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||||
import {
|
import {
|
||||||
SettingsForm,
|
SettingsForm,
|
||||||
SettingsSwitchContent,
|
SettingsSwitchContent,
|
||||||
SettingsSwitchItem,
|
SettingsSwitchItem,
|
||||||
} from '../components/settings-form-layout'
|
} from '../components/settings-form-layout'
|
||||||
import { SettingsPageActionsPortal } from '../components/settings-page-context'
|
import {
|
||||||
import { ModelRatioVisualEditor } from './model-ratio-visual-editor'
|
ModelRatioVisualEditor,
|
||||||
|
type ModelRatioVisualEditorHandle,
|
||||||
|
} from './model-ratio-visual-editor'
|
||||||
|
|
||||||
type ModelFormValues = {
|
type ModelFormValues = {
|
||||||
ModelPrice: string
|
ModelPrice: string
|
||||||
@@ -56,14 +58,106 @@ type ModelFormValues = {
|
|||||||
|
|
||||||
type ModelRatioFormProps = {
|
type ModelRatioFormProps = {
|
||||||
form: UseFormReturn<ModelFormValues>
|
form: UseFormReturn<ModelFormValues>
|
||||||
|
savedValues: ModelFormValues
|
||||||
onSave: (values: ModelFormValues) => Promise<void>
|
onSave: (values: ModelFormValues) => Promise<void>
|
||||||
onReset: () => void
|
onReset: () => void
|
||||||
isSaving: boolean
|
isSaving: boolean
|
||||||
isResetting: 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({
|
export const ModelRatioForm = memo(function ModelRatioForm({
|
||||||
form,
|
form,
|
||||||
|
savedValues,
|
||||||
onSave,
|
onSave,
|
||||||
onReset,
|
onReset,
|
||||||
isSaving,
|
isSaving,
|
||||||
@@ -71,6 +165,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
|||||||
}: ModelRatioFormProps) {
|
}: ModelRatioFormProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
|
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
|
||||||
|
const visualEditorRef = useRef<ModelRatioVisualEditorHandle>(null)
|
||||||
|
|
||||||
const handleFieldChange = useCallback(
|
const handleFieldChange = useCallback(
|
||||||
(field: keyof ModelFormValues, value: string) => {
|
(field: keyof ModelFormValues, value: string) => {
|
||||||
@@ -86,9 +181,39 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
|||||||
setEditMode((prev) => (prev === 'visual' ? 'json' : 'visual'))
|
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 (
|
return (
|
||||||
<div className='space-y-6'>
|
<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}>
|
<Button variant='outline' size='sm' onClick={toggleEditMode}>
|
||||||
{editMode === 'visual' ? (
|
{editMode === 'visual' ? (
|
||||||
<>
|
<>
|
||||||
@@ -105,28 +230,20 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Form {...form}>
|
<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' ? (
|
{editMode === 'visual' ? (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<ModelRatioVisualEditor
|
<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')}
|
modelPrice={form.watch('ModelPrice')}
|
||||||
modelRatio={form.watch('ModelRatio')}
|
modelRatio={form.watch('ModelRatio')}
|
||||||
cacheRatio={form.watch('CacheRatio')}
|
cacheRatio={form.watch('CacheRatio')}
|
||||||
@@ -137,6 +254,8 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
|||||||
audioCompletionRatio={form.watch('AudioCompletionRatio')}
|
audioCompletionRatio={form.watch('AudioCompletionRatio')}
|
||||||
billingMode={form.watch('BillingMode')}
|
billingMode={form.watch('BillingMode')}
|
||||||
billingExpr={form.watch('BillingExpr')}
|
billingExpr={form.watch('BillingExpr')}
|
||||||
|
onSave={handleSave}
|
||||||
|
isSaving={isSaving}
|
||||||
onChange={(field, value) => {
|
onChange={(field, value) => {
|
||||||
const fieldMap: Record<string, keyof ModelFormValues> = {
|
const fieldMap: Record<string, keyof ModelFormValues> = {
|
||||||
'billing_setting.billing_mode': 'BillingMode',
|
'billing_setting.billing_mode': 'BillingMode',
|
||||||
@@ -173,155 +292,17 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SettingsForm onSubmit={form.handleSubmit(onSave)}>
|
<SettingsForm onSubmit={form.handleSubmit(onSave)}>
|
||||||
<FormField
|
<div className='grid min-w-0 gap-x-5 gap-y-8 lg:grid-cols-2 2xl:grid-cols-3'>
|
||||||
control={form.control}
|
{modelJsonFields.map((config) => (
|
||||||
name='ModelPrice'
|
<ModelJsonTextareaField
|
||||||
render={({ field }) => (
|
key={config.name}
|
||||||
<FormItem>
|
form={form}
|
||||||
<FormLabel>{t('Model fixed pricing')}</FormLabel>
|
name={config.name}
|
||||||
<FormControl>
|
label={t(config.labelKey)}
|
||||||
<Textarea rows={8} {...field} />
|
description={t(config.descriptionKey)}
|
||||||
</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>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
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
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
import { useState, useMemo, memo, useCallback, useEffect } from 'react'
|
|
||||||
import {
|
import {
|
||||||
type ColumnDef,
|
useState,
|
||||||
|
useMemo,
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
forwardRef,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
} from 'react'
|
||||||
|
import {
|
||||||
type ColumnFiltersState,
|
type ColumnFiltersState,
|
||||||
type OnChangeFn,
|
type OnChangeFn,
|
||||||
type PaginationState,
|
type PaginationState,
|
||||||
@@ -35,39 +43,42 @@ import {
|
|||||||
useReactTable,
|
useReactTable,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table'
|
||||||
import { useMediaQuery } from '@/hooks'
|
import { useMediaQuery } from '@/hooks'
|
||||||
import { Copy, Pencil, Plus, Trash2 } from 'lucide-react'
|
import { Copy, Plus } from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table'
|
|
||||||
import {
|
import {
|
||||||
DataTableBulkActions,
|
DataTableBulkActions,
|
||||||
DataTableColumnHeader,
|
|
||||||
DataTableToolbar,
|
DataTableToolbar,
|
||||||
DataTablePagination,
|
DataTablePagination,
|
||||||
} from '@/components/data-table'
|
} from '@/components/data-table'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
|
||||||
import {
|
|
||||||
combineBillingExpr,
|
|
||||||
splitBillingExprAndRequestRules,
|
|
||||||
} from '@/features/pricing/lib/billing-expr'
|
|
||||||
import { safeJsonParse } from '../utils/json-parser'
|
import { safeJsonParse } from '../utils/json-parser'
|
||||||
import {
|
import {
|
||||||
ModelPricingEditorPanel,
|
ModelPricingEditorPanel,
|
||||||
|
type ModelPricingEditorPanelHandle,
|
||||||
ModelPricingSheet,
|
ModelPricingSheet,
|
||||||
type ModelRatioData,
|
type ModelRatioData,
|
||||||
} from './model-pricing-sheet'
|
} from './model-pricing-sheet'
|
||||||
import { formatPricingNumber } from './pricing-format'
|
import {
|
||||||
|
buildModelSnapshots,
|
||||||
|
getSnapshotSignature,
|
||||||
|
type ModelRow,
|
||||||
|
} from './model-pricing-snapshots'
|
||||||
|
import { buildModelRatioColumns } from './model-ratio-table-columns'
|
||||||
|
|
||||||
type ModelRatioVisualEditorProps = {
|
type ModelRatioVisualEditorProps = {
|
||||||
|
savedModelPrice: string
|
||||||
|
savedModelRatio: string
|
||||||
|
savedCacheRatio: string
|
||||||
|
savedCreateCacheRatio: string
|
||||||
|
savedCompletionRatio: string
|
||||||
|
savedImageRatio: string
|
||||||
|
savedAudioRatio: string
|
||||||
|
savedAudioCompletionRatio: string
|
||||||
|
savedBillingMode: string
|
||||||
|
savedBillingExpr: string
|
||||||
modelPrice: string
|
modelPrice: string
|
||||||
modelRatio: string
|
modelRatio: string
|
||||||
cacheRatio: string
|
cacheRatio: string
|
||||||
@@ -79,121 +90,31 @@ type ModelRatioVisualEditorProps = {
|
|||||||
billingMode: string
|
billingMode: string
|
||||||
billingExpr: string
|
billingExpr: string
|
||||||
onChange: (field: string, value: string) => void
|
onChange: (field: string, value: string) => void
|
||||||
|
onSave: () => void | Promise<void>
|
||||||
|
isSaving: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelRow = {
|
export type ModelRatioVisualEditorHandle = {
|
||||||
name: string
|
commitOpenEditor: () => Promise<boolean>
|
||||||
price?: string
|
|
||||||
ratio?: string
|
|
||||||
cacheRatio?: string
|
|
||||||
createCacheRatio?: string
|
|
||||||
completionRatio?: string
|
|
||||||
imageRatio?: string
|
|
||||||
audioRatio?: string
|
|
||||||
audioCompletionRatio?: string
|
|
||||||
billingMode?: string
|
|
||||||
billingExpr?: string
|
|
||||||
requestRuleExpr?: string
|
|
||||||
hasConflict: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'model-ratio-column-visibility'
|
const STORAGE_KEY = 'model-ratio-column-visibility'
|
||||||
|
|
||||||
const hasValue = (value?: string) => value !== undefined && value !== ''
|
const ModelRatioVisualEditorComponent = forwardRef<
|
||||||
|
ModelRatioVisualEditorHandle,
|
||||||
const toNumberOrNull = (value?: string) => {
|
ModelRatioVisualEditorProps
|
||||||
if (!hasValue(value)) return null
|
>(function ModelRatioVisualEditor(
|
||||||
const num = Number(value)
|
{
|
||||||
return Number.isFinite(num) ? num : null
|
savedModelPrice,
|
||||||
}
|
savedModelRatio,
|
||||||
|
savedCacheRatio,
|
||||||
const ratioToPrice = (ratio?: string, denominator?: string) => {
|
savedCreateCacheRatio,
|
||||||
const ratioNumber = toNumberOrNull(ratio)
|
savedCompletionRatio,
|
||||||
const denominatorNumber = denominator ? toNumberOrNull(denominator) : 2
|
savedImageRatio,
|
||||||
if (ratioNumber === null || denominatorNumber === null) return ''
|
savedAudioRatio,
|
||||||
return formatPricingNumber(ratioNumber * denominatorNumber)
|
savedAudioCompletionRatio,
|
||||||
}
|
savedBillingMode,
|
||||||
|
savedBillingExpr,
|
||||||
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({
|
|
||||||
modelPrice,
|
modelPrice,
|
||||||
modelRatio,
|
modelRatio,
|
||||||
cacheRatio,
|
cacheRatio,
|
||||||
@@ -205,7 +126,11 @@ export const ModelRatioVisualEditor = memo(
|
|||||||
billingMode,
|
billingMode,
|
||||||
billingExpr,
|
billingExpr,
|
||||||
onChange,
|
onChange,
|
||||||
}: ModelRatioVisualEditorProps) {
|
onSave,
|
||||||
|
isSaving,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const isMobile = useMediaQuery('(max-width: 767px)')
|
const isMobile = useMediaQuery('(max-width: 767px)')
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
@@ -215,6 +140,7 @@ export const ModelRatioVisualEditor = memo(
|
|||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||||
const [globalFilter, setGlobalFilter] = useState('')
|
const [globalFilter, setGlobalFilter] = useState('')
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
const editorPanelRef = useRef<ModelPricingEditorPanelHandle>(null)
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
@@ -259,126 +185,64 @@ export const ModelRatioVisualEditor = memo(
|
|||||||
}, [columnVisibility])
|
}, [columnVisibility])
|
||||||
|
|
||||||
const models = useMemo(() => {
|
const models = useMemo(() => {
|
||||||
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
|
const savedRows = buildModelSnapshots({
|
||||||
fallback: {},
|
modelPrice: savedModelPrice,
|
||||||
context: 'model prices',
|
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, {
|
const draftRows = buildModelSnapshots({
|
||||||
fallback: {},
|
modelPrice,
|
||||||
context: 'model ratios',
|
modelRatio,
|
||||||
})
|
cacheRatio,
|
||||||
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
|
|
||||||
fallback: {},
|
|
||||||
context: 'cache ratios',
|
|
||||||
})
|
|
||||||
const createCacheMap = safeJsonParse<Record<string, number>>(
|
|
||||||
createCacheRatio,
|
createCacheRatio,
|
||||||
{ fallback: {}, context: 'create cache ratios' }
|
|
||||||
)
|
|
||||||
const completionMap = safeJsonParse<Record<string, number>>(
|
|
||||||
completionRatio,
|
completionRatio,
|
||||||
{ fallback: {}, context: 'completion ratios' }
|
imageRatio,
|
||||||
)
|
audioRatio,
|
||||||
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,
|
audioCompletionRatio,
|
||||||
{ fallback: {}, context: 'audio completion ratios' }
|
|
||||||
)
|
|
||||||
const billingModeMap = safeJsonParse<Record<string, string>>(
|
|
||||||
billingMode,
|
billingMode,
|
||||||
{
|
|
||||||
fallback: {},
|
|
||||||
context: 'billing mode',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
const billingExprMap = safeJsonParse<Record<string, string>>(
|
|
||||||
billingExpr,
|
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,
|
modelPrice,
|
||||||
modelRatio,
|
modelRatio,
|
||||||
cacheRatio,
|
cacheRatio,
|
||||||
@@ -414,24 +278,25 @@ export const ModelRatioVisualEditor = memo(
|
|||||||
|
|
||||||
const handleEdit = useCallback(
|
const handleEdit = useCallback(
|
||||||
(model: ModelRow) => {
|
(model: ModelRow) => {
|
||||||
|
const editableModel = model.draft ?? model.saved ?? model
|
||||||
setEditData({
|
setEditData({
|
||||||
name: model.name,
|
name: editableModel.name,
|
||||||
price: model.price,
|
price: editableModel.price,
|
||||||
ratio: model.ratio,
|
ratio: editableModel.ratio,
|
||||||
cacheRatio: model.cacheRatio,
|
cacheRatio: editableModel.cacheRatio,
|
||||||
createCacheRatio: model.createCacheRatio,
|
createCacheRatio: editableModel.createCacheRatio,
|
||||||
completionRatio: model.completionRatio,
|
completionRatio: editableModel.completionRatio,
|
||||||
imageRatio: model.imageRatio,
|
imageRatio: editableModel.imageRatio,
|
||||||
audioRatio: model.audioRatio,
|
audioRatio: editableModel.audioRatio,
|
||||||
audioCompletionRatio: model.audioCompletionRatio,
|
audioCompletionRatio: editableModel.audioCompletionRatio,
|
||||||
billingMode:
|
billingMode:
|
||||||
model.billingMode === 'tiered_expr'
|
editableModel.billingMode === 'tiered_expr'
|
||||||
? 'tiered_expr'
|
? 'tiered_expr'
|
||||||
: model.price && model.price !== ''
|
: editableModel.price && editableModel.price !== ''
|
||||||
? 'per-request'
|
? 'per-request'
|
||||||
: 'per-token',
|
: 'per-token',
|
||||||
billingExpr: model.billingExpr,
|
billingExpr: editableModel.billingExpr,
|
||||||
requestRuleExpr: model.requestRuleExpr,
|
requestRuleExpr: editableModel.requestRuleExpr,
|
||||||
})
|
})
|
||||||
setEditorOpen(true)
|
setEditorOpen(true)
|
||||||
if (isMobile) setSheetOpen(true)
|
if (isMobile) setSheetOpen(true)
|
||||||
@@ -445,17 +310,10 @@ export const ModelRatioVisualEditor = memo(
|
|||||||
if (isMobile) setSheetOpen(true)
|
if (isMobile) setSheetOpen(true)
|
||||||
}, [isMobile])
|
}, [isMobile])
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
|
||||||
setEditData(null)
|
|
||||||
setEditorOpen(false)
|
|
||||||
setSheetOpen(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleGlobalFilterChange = useCallback<OnChangeFn<string>>(
|
const handleGlobalFilterChange = useCallback<OnChangeFn<string>>(
|
||||||
(updater) => {
|
(updater) => {
|
||||||
setGlobalFilter((previous) => {
|
setGlobalFilter((previous) => {
|
||||||
const next =
|
const next = typeof updater === 'function' ? updater(previous) : updater
|
||||||
typeof updater === 'function' ? updater(previous) : updater
|
|
||||||
if (next !== previous) {
|
if (next !== previous) {
|
||||||
setEditData(null)
|
setEditData(null)
|
||||||
setEditorOpen(false)
|
setEditorOpen(false)
|
||||||
@@ -556,120 +414,15 @@ export const ModelRatioVisualEditor = memo(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<ModelRow>[]>(() => {
|
const columns = useMemo(
|
||||||
return [
|
() =>
|
||||||
{
|
buildModelRatioColumns({
|
||||||
id: 'select',
|
onDelete: handleDelete,
|
||||||
header: ({ table }) => (
|
onEdit: handleEdit,
|
||||||
<Checkbox
|
t,
|
||||||
checked={table.getIsAllPageRowsSelected()}
|
}),
|
||||||
indeterminate={table.getIsSomePageRowsSelected()}
|
[handleEdit, handleDelete, t]
|
||||||
onCheckedChange={(value) =>
|
)
|
||||||
table.toggleAllPageRowsSelected(!!value)
|
|
||||||
}
|
|
||||||
aria-label={t('Select all')}
|
|
||||||
className='translate-y-[2px]'
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Checkbox
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
|
||||||
aria-label={t('Select row')}
|
|
||||||
className='translate-y-[2px]'
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
|
||||||
meta: { label: t('Select') },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'name',
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataTableColumnHeader column={column} title={t('Model name')} />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className='flex items-center gap-2 font-medium'>
|
|
||||||
{row.getValue('name')}
|
|
||||||
{row.original.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 table = useReactTable({
|
const table = useReactTable({
|
||||||
data: models,
|
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(() => {
|
const handleBatchCopy = useCallback(() => {
|
||||||
if (!editData) {
|
if (!editData) {
|
||||||
toast.error(t('Open a source model first'))
|
toast.error(t('Open a source model first'))
|
||||||
@@ -875,12 +614,25 @@ export const ModelRatioVisualEditor = memo(
|
|||||||
)
|
)
|
||||||
}, [editData, persistPricingData, t, table])
|
}, [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 (
|
return (
|
||||||
<div className='flex flex-col gap-4'>
|
<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='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-w-0 flex-col gap-4'>
|
<div className='flex min-h-0 min-w-0 flex-col gap-3'>
|
||||||
<DataTableToolbar
|
<DataTableToolbar
|
||||||
table={table}
|
table={table}
|
||||||
searchPlaceholder={t('Search models...')}
|
searchPlaceholder={t('Search models...')}
|
||||||
@@ -922,55 +674,69 @@ export const ModelRatioVisualEditor = memo(
|
|||||||
: t('No models configured. Use Add model to get started.')}
|
: t('No models configured. Use Add model to get started.')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className='overflow-hidden rounded-md border'>
|
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
|
||||||
<Table>
|
<table className='w-full caption-bottom text-sm tabular-nums'>
|
||||||
<TableHeader>
|
<thead>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<tr key={headerGroup.id} className='border-b'>
|
||||||
{headerGroup.headers.map((header) => (
|
{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
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext()
|
header.getContext()
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</th>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</TableHeader>
|
</thead>
|
||||||
<TableBody>
|
<tbody>
|
||||||
{table.getRowModel().rows.map((row) => (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<TableRow
|
<tr
|
||||||
key={row.id}
|
key={row.id}
|
||||||
data-state={
|
data-state={row.getIsSelected() ? 'selected' : undefined}
|
||||||
row.getIsSelected() ? 'selected' : undefined
|
|
||||||
}
|
|
||||||
className={
|
className={
|
||||||
editData?.name === row.original.name
|
editData?.name === row.original.name
|
||||||
? 'bg-muted/45'
|
? 'bg-muted/45 hover:bg-muted/50 data-[state=selected]:bg-muted group border-b transition-colors'
|
||||||
: undefined
|
: 'hover:bg-muted/50 data-[state=selected]:bg-muted group border-b transition-colors'
|
||||||
}
|
}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
if (target.closest('button, [role="checkbox"]'))
|
if (target.closest('button, [role="checkbox"]')) return
|
||||||
return
|
|
||||||
handleEdit(row.original)
|
handleEdit(row.original)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{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(
|
{flexRender(
|
||||||
cell.column.columnDef.cell,
|
cell.column.columnDef.cell,
|
||||||
cell.getContext()
|
cell.getContext()
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</td>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</tbody>
|
||||||
</Table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -979,17 +745,17 @@ export const ModelRatioVisualEditor = memo(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='hidden min-w-0 md:block'>
|
<div className='hidden min-h-0 min-w-0 md:block'>
|
||||||
{editorOpen ? (
|
{editorOpen ? (
|
||||||
<ModelPricingEditorPanel
|
<ModelPricingEditorPanel
|
||||||
onSave={handleSave}
|
ref={editorPanelRef}
|
||||||
onCancel={handleCancel}
|
|
||||||
editData={editData}
|
editData={editData}
|
||||||
selectedTargetCount={selectedTargetCount}
|
onSave={onSave}
|
||||||
className='sticky top-4 h-[calc(100vh-8rem)] min-h-[620px]'
|
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'>
|
<div className='text-foreground text-base font-medium'>
|
||||||
{t('Select a model to edit pricing')}
|
{t('Select a model to edit pricing')}
|
||||||
</div>
|
</div>
|
||||||
@@ -1018,17 +784,20 @@ export const ModelRatioVisualEditor = memo(
|
|||||||
|
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<ModelPricingSheet
|
<ModelPricingSheet
|
||||||
|
ref={editorPanelRef}
|
||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
onOpenChange={setSheetOpen}
|
onOpenChange={setSheetOpen}
|
||||||
onSave={handleSave}
|
|
||||||
onCancel={handleCancel}
|
|
||||||
editData={editData}
|
editData={editData}
|
||||||
selectedTargetCount={selectedTargetCount}
|
onSave={onSave}
|
||||||
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
})
|
||||||
|
|
||||||
|
export const ModelRatioVisualEditor = memo(
|
||||||
|
ModelRatioVisualEditorComponent,
|
||||||
// Custom equality check - only re-render if JSON props actually changed
|
// Custom equality check - only re-render if JSON props actually changed
|
||||||
(prevProps, nextProps) => {
|
(prevProps, nextProps) => {
|
||||||
return (
|
return (
|
||||||
@@ -1042,7 +811,9 @@ export const ModelRatioVisualEditor = memo(
|
|||||||
prevProps.audioCompletionRatio === nextProps.audioCompletionRatio &&
|
prevProps.audioCompletionRatio === nextProps.audioCompletionRatio &&
|
||||||
prevProps.billingMode === nextProps.billingMode &&
|
prevProps.billingMode === nextProps.billingMode &&
|
||||||
prevProps.billingExpr === nextProps.billingExpr &&
|
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
|
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 * as z from 'zod'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
@@ -26,6 +26,7 @@ import { toast } from 'sonner'
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { resetModelRatios } from '../api'
|
import { resetModelRatios } from '../api'
|
||||||
|
import { SettingsPageTitleStatusPortal } from '../components/settings-page-context'
|
||||||
import { SettingsSection } from '../components/settings-section'
|
import { SettingsSection } from '../components/settings-section'
|
||||||
import { useUpdateOption } from '../hooks/use-update-option'
|
import { useUpdateOption } from '../hooks/use-update-option'
|
||||||
import { GroupRatioForm } from './group-ratio-form'
|
import { GroupRatioForm } from './group-ratio-form'
|
||||||
@@ -34,169 +35,99 @@ import { ToolPriceSettings } from './tool-price-settings'
|
|||||||
import { UpstreamRatioSync } from './upstream-ratio-sync'
|
import { UpstreamRatioSync } from './upstream-ratio-sync'
|
||||||
import {
|
import {
|
||||||
formatJsonForTextarea,
|
formatJsonForTextarea,
|
||||||
|
type JsonValidationError,
|
||||||
normalizeJsonString,
|
normalizeJsonString,
|
||||||
validateJsonString,
|
validateJsonString,
|
||||||
} from './utils'
|
} from './utils'
|
||||||
|
|
||||||
const modelSchema = z.object({
|
type Translate = (key: string, options?: Record<string, unknown>) => string
|
||||||
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',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const groupSchema = z.object({
|
function formatJsonValidationError(
|
||||||
GroupRatio: z.string().superRefine((value, ctx) => {
|
t: Translate,
|
||||||
const result = validateJsonString(value)
|
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) {
|
if (!result.valid) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
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 createModelSchema = (t: Translate) =>
|
||||||
const result = validateJsonString(value)
|
z.object({
|
||||||
if (!result.valid) {
|
ModelPrice: createJsonStringField(t),
|
||||||
ctx.addIssue({
|
ModelRatio: createJsonStringField(t),
|
||||||
code: z.ZodIssueCode.custom,
|
CacheRatio: createJsonStringField(t),
|
||||||
message: result.message || 'Invalid JSON',
|
CreateCacheRatio: createJsonStringField(t),
|
||||||
|
CompletionRatio: createJsonStringField(t),
|
||||||
|
ImageRatio: createJsonStringField(t),
|
||||||
|
AudioRatio: createJsonStringField(t),
|
||||||
|
AudioCompletionRatio: createJsonStringField(t),
|
||||||
|
ExposeRatioEnabled: z.boolean(),
|
||||||
|
BillingMode: createJsonStringField(t),
|
||||||
|
BillingExpr: createJsonStringField(t),
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}),
|
const createGroupSchema = (t: Translate) =>
|
||||||
GroupGroupRatio: z.string().superRefine((value, ctx) => {
|
z.object({
|
||||||
const result = validateJsonString(value)
|
GroupRatio: createJsonStringField(t),
|
||||||
if (!result.valid) {
|
TopupGroupRatio: createJsonStringField(t),
|
||||||
ctx.addIssue({
|
UserUsableGroups: createJsonStringField(t),
|
||||||
code: z.ZodIssueCode.custom,
|
GroupGroupRatio: createJsonStringField(t),
|
||||||
message: result.message || 'Invalid JSON',
|
AutoGroups: createJsonStringField(t, {
|
||||||
})
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
AutoGroups: z.string().superRefine((value, ctx) => {
|
|
||||||
const result = validateJsonString(value, {
|
|
||||||
predicate: (parsed) =>
|
predicate: (parsed) =>
|
||||||
Array.isArray(parsed) &&
|
Array.isArray(parsed) &&
|
||||||
parsed.every((item) => typeof item === 'string'),
|
parsed.every((item) => typeof item === 'string'),
|
||||||
predicateMessage: 'Expected a JSON array of group identifiers',
|
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(),
|
DefaultUseAutoGroup: z.boolean(),
|
||||||
GroupSpecialUsableGroup: z.string().superRefine((value, ctx) => {
|
GroupSpecialUsableGroup: createJsonStringField(t),
|
||||||
const result = validateJsonString(value)
|
|
||||||
if (!result.valid) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: result.message || 'Invalid JSON',
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
type ModelFormValues = z.infer<typeof modelSchema>
|
type ModelFormValues = z.infer<ReturnType<typeof createModelSchema>>
|
||||||
type GroupFormValues = z.infer<typeof groupSchema>
|
type GroupFormValues = z.infer<ReturnType<typeof createGroupSchema>>
|
||||||
type RatioTabId = 'models' | 'groups' | 'tool-prices' | 'upstream-sync'
|
type RatioTabId = 'models' | 'groups' | 'tool-prices' | 'upstream-sync'
|
||||||
|
|
||||||
type RatioSettingsCardProps = {
|
type RatioSettingsCardProps = {
|
||||||
@@ -250,6 +181,9 @@ export function RatioSettingsCard({
|
|||||||
BillingMode: normalizeJsonString(modelDefaults.BillingMode),
|
BillingMode: normalizeJsonString(modelDefaults.BillingMode),
|
||||||
BillingExpr: normalizeJsonString(modelDefaults.BillingExpr),
|
BillingExpr: normalizeJsonString(modelDefaults.BillingExpr),
|
||||||
})
|
})
|
||||||
|
const [savedModelValues, setSavedModelValues] = useState(
|
||||||
|
modelNormalizedDefaults.current
|
||||||
|
)
|
||||||
|
|
||||||
const groupNormalizedDefaults = useRef({
|
const groupNormalizedDefaults = useRef({
|
||||||
GroupRatio: normalizeJsonString(groupDefaults.GroupRatio),
|
GroupRatio: normalizeJsonString(groupDefaults.GroupRatio),
|
||||||
@@ -262,6 +196,8 @@ export function RatioSettingsCard({
|
|||||||
groupDefaults.GroupSpecialUsableGroup
|
groupDefaults.GroupSpecialUsableGroup
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
const modelSchema = useMemo(() => createModelSchema(t), [t])
|
||||||
|
const groupSchema = useMemo(() => createGroupSchema(t), [t])
|
||||||
|
|
||||||
const modelForm = useForm<ModelFormValues>({
|
const modelForm = useForm<ModelFormValues>({
|
||||||
resolver: zodResolver(modelSchema),
|
resolver: zodResolver(modelSchema),
|
||||||
@@ -315,6 +251,7 @@ export function RatioSettingsCard({
|
|||||||
BillingMode: normalizeJsonString(modelDefaults.BillingMode),
|
BillingMode: normalizeJsonString(modelDefaults.BillingMode),
|
||||||
BillingExpr: normalizeJsonString(modelDefaults.BillingExpr),
|
BillingExpr: normalizeJsonString(modelDefaults.BillingExpr),
|
||||||
}
|
}
|
||||||
|
setSavedModelValues(modelNormalizedDefaults.current)
|
||||||
|
|
||||||
modelForm.reset({
|
modelForm.reset({
|
||||||
...modelDefaults,
|
...modelDefaults,
|
||||||
@@ -395,6 +332,9 @@ export function RatioSettingsCard({
|
|||||||
const apiKey = apiKeyMap[key as string] || (key as string)
|
const apiKey = apiKeyMap[key as string] || (key as string)
|
||||||
await updateOption.mutateAsync({ key: apiKey, value: normalized[key] })
|
await updateOption.mutateAsync({ key: apiKey, value: normalized[key] })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
modelNormalizedDefaults.current = normalized
|
||||||
|
setSavedModelValues(normalized)
|
||||||
},
|
},
|
||||||
[t, updateOption]
|
[t, updateOption]
|
||||||
)
|
)
|
||||||
@@ -462,6 +402,7 @@ export function RatioSettingsCard({
|
|||||||
return (
|
return (
|
||||||
<ModelRatioForm
|
<ModelRatioForm
|
||||||
form={modelForm}
|
form={modelForm}
|
||||||
|
savedValues={savedModelValues}
|
||||||
onSave={saveModelRatios}
|
onSave={saveModelRatios}
|
||||||
onReset={handleResetRatios}
|
onReset={handleResetRatios}
|
||||||
isSaving={updateOption.isPending}
|
isSaving={updateOption.isPending}
|
||||||
@@ -499,25 +440,35 @@ export function RatioSettingsCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const renderTabSwitcher = () => (
|
||||||
<SettingsSection title={t(titleKey)}>
|
<TabsList className={`grid w-fit max-w-full ${tabsGridClass}`}>
|
||||||
{visibleTabs.length === 1 ? (
|
|
||||||
renderTabContent(defaultTab)
|
|
||||||
) : (
|
|
||||||
<Tabs defaultValue={defaultTab} className='space-y-6'>
|
|
||||||
<TabsList className={`grid w-full ${tabsGridClass}`}>
|
|
||||||
{visibleTabs.map((tab) => (
|
{visibleTabs.map((tab) => (
|
||||||
<TabsTrigger key={tab} value={tab}>
|
<TabsTrigger key={tab} value={tab}>
|
||||||
{t(tabLabels[tab])}
|
{t(tabLabels[tab])}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</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) => (
|
{visibleTabs.map((tab) => (
|
||||||
<TabsContent key={tab} value={tab}>
|
<TabsContent key={tab} value={tab}>
|
||||||
{renderTabContent(tab)}
|
{renderTabContent(tab)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
))}
|
))}
|
||||||
|
</SettingsSection>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -533,6 +484,6 @@ export function RatioSettingsCard({
|
|||||||
handleConfirm={handleConfirmReset}
|
handleConfirm={handleConfirmReset}
|
||||||
confirmText={t('Reset')}
|
confirmText={t('Reset')}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-12
@@ -40,6 +40,7 @@ import {
|
|||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from '@/components/ui/collapsible'
|
} from '@/components/ui/collapsible'
|
||||||
|
import { Field, FieldLabel } from '@/components/ui/field'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import {
|
import {
|
||||||
@@ -1309,9 +1310,7 @@ function PresetSection({ applyPreset }: PresetSectionProps) {
|
|||||||
return (
|
return (
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<span className='text-muted-foreground text-xs'>
|
<span className='text-sm font-medium'>{t('Preset templates')}</span>
|
||||||
{t('Preset templates')}
|
|
||||||
</span>
|
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
@@ -1770,9 +1769,10 @@ export const TieredPricingEditor = memo(function TieredPricingEditor({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-4'>
|
<div className='space-y-5'>
|
||||||
<div className='flex items-center justify-between gap-2'>
|
<div className='grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-end'>
|
||||||
<Label className='text-xs'>{t('Editor mode')}</Label>
|
<Field className='gap-2'>
|
||||||
|
<FieldLabel>{t('Editor mode')}</FieldLabel>
|
||||||
<Select
|
<Select
|
||||||
items={[
|
items={[
|
||||||
{ value: 'visual', label: t('Visual editor') },
|
{ value: 'visual', label: t('Visual editor') },
|
||||||
@@ -1781,7 +1781,7 @@ export const TieredPricingEditor = memo(function TieredPricingEditor({
|
|||||||
value={editorMode}
|
value={editorMode}
|
||||||
onValueChange={(value) => handleModeChange(value as EditorMode)}
|
onValueChange={(value) => handleModeChange(value as EditorMode)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className='w-44' size='sm'>
|
<SelectTrigger className='w-full sm:w-56' size='sm'>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent alignItemWithTrigger={false}>
|
<SelectContent alignItemWithTrigger={false}>
|
||||||
@@ -1791,14 +1791,15 @@ export const TieredPricingEditor = memo(function TieredPricingEditor({
|
|||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
</Field>
|
||||||
|
{editorMode === 'raw' && (
|
||||||
|
<div className='sm:pb-0.5'>
|
||||||
|
<LlmPromptHelper modelName={modelName} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex flex-wrap items-start gap-x-4 gap-y-1'>
|
|
||||||
<div className='flex-1'>
|
|
||||||
<PresetSection applyPreset={applyPreset} />
|
<PresetSection applyPreset={applyPreset} />
|
||||||
</div>
|
|
||||||
{editorMode === 'raw' && <LlmPromptHelper modelName={modelName} />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='bg-muted/30 space-y-3 rounded-md border p-3'>
|
<div className='bg-muted/30 space-y-3 rounded-md border p-3'>
|
||||||
{editorMode === 'visual' ? (
|
{editorMode === 'visual' ? (
|
||||||
|
|||||||
+47
-4
@@ -49,6 +49,14 @@ type JsonValidationOptions = {
|
|||||||
predicateMessage?: string
|
predicateMessage?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type JsonValidationError = {
|
||||||
|
type: 'required' | 'structure' | 'syntax'
|
||||||
|
line?: number
|
||||||
|
column?: number
|
||||||
|
position?: number
|
||||||
|
missingCommaLine?: number
|
||||||
|
}
|
||||||
|
|
||||||
function extractErrorPosition(
|
function extractErrorPosition(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
jsonString: string
|
jsonString: string
|
||||||
@@ -81,8 +89,15 @@ function extractErrorPosition(
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatErrorMessage(error: unknown, jsonString: string): string {
|
function buildSyntaxError(
|
||||||
if (!(error instanceof Error)) return 'Invalid JSON'
|
error: unknown,
|
||||||
|
jsonString: string
|
||||||
|
): JsonValidationError {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return {
|
||||||
|
type: 'syntax',
|
||||||
|
} satisfies JsonValidationError
|
||||||
|
}
|
||||||
|
|
||||||
const position = extractErrorPosition(error, jsonString)
|
const position = extractErrorPosition(error, jsonString)
|
||||||
const message = error.message
|
const message = error.message
|
||||||
@@ -93,10 +108,29 @@ function formatErrorMessage(error: unknown, jsonString: string): string {
|
|||||||
message.includes('Expected property name') ||
|
message.includes('Expected property name') ||
|
||||||
message.includes('Unexpected string')
|
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) {
|
if (position.line && position.column) {
|
||||||
let hint = ''
|
let hint = ''
|
||||||
if (isMissingCommaError && position.line > 1) {
|
if (syntaxError.missingCommaLine) {
|
||||||
hint = ` (check line ${position.line - 1} for missing comma)`
|
hint = ` (check line ${syntaxError.missingCommaLine} for missing comma)`
|
||||||
}
|
}
|
||||||
return `Error at line ${position.line}, column ${position.column}: ${message}${hint}`
|
return `Error at line ${position.line}, column ${position.column}: ${message}${hint}`
|
||||||
}
|
}
|
||||||
@@ -119,6 +153,11 @@ export function validateJsonString(
|
|||||||
return {
|
return {
|
||||||
valid: allowEmpty,
|
valid: allowEmpty,
|
||||||
message: allowEmpty ? undefined : 'Value is required',
|
message: allowEmpty ? undefined : 'Value is required',
|
||||||
|
error: allowEmpty
|
||||||
|
? undefined
|
||||||
|
: ({
|
||||||
|
type: 'required',
|
||||||
|
} satisfies JsonValidationError),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +167,9 @@ export function validateJsonString(
|
|||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
message: predicateMessage || 'JSON structure is invalid',
|
message: predicateMessage || 'JSON structure is invalid',
|
||||||
|
error: {
|
||||||
|
type: 'structure',
|
||||||
|
} satisfies JsonValidationError,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +178,7 @@ export function validateJsonString(
|
|||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
message: formatErrorMessage(error, trimmed),
|
message: formatErrorMessage(error, trimmed),
|
||||||
|
error: buildSyntaxError(error, trimmed),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+7
@@ -681,6 +681,7 @@
|
|||||||
"Check for updates": "Check for updates",
|
"Check for updates": "Check for updates",
|
||||||
"Check in daily to receive random quota rewards": "Check in daily to receive random quota rewards",
|
"Check in daily to receive random quota rewards": "Check in daily to receive random quota rewards",
|
||||||
"Check in now": "Check in now",
|
"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 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 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",
|
"Check-in failed": "Check-in failed",
|
||||||
@@ -1528,6 +1529,7 @@
|
|||||||
"Expand": "Expand",
|
"Expand": "Expand",
|
||||||
"Expand All": "Expand All",
|
"Expand All": "Expand All",
|
||||||
"Expected a JSON array.": "Expected a JSON array.",
|
"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.",
|
"Experiment with prompts and models in real time.": "Experiment with prompts and models in real time.",
|
||||||
"Expiration Time": "Expiration Time",
|
"Expiration Time": "Expiration Time",
|
||||||
"expired": "expired",
|
"expired": "expired",
|
||||||
@@ -2095,6 +2097,9 @@
|
|||||||
"JSON Editor": "JSON Editor",
|
"JSON Editor": "JSON Editor",
|
||||||
"JSON format error": "JSON format error",
|
"JSON format error": "JSON format error",
|
||||||
"JSON format supports service account JSON files": "JSON format supports service account JSON files",
|
"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 → 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 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.",
|
"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 Mode": "JSON Mode",
|
||||||
"JSON must be an object": "JSON must be an object",
|
"JSON must be an object": "JSON must be an object",
|
||||||
"JSON object:": "JSON object:",
|
"JSON object:": "JSON object:",
|
||||||
|
"JSON structure is invalid": "JSON structure is invalid",
|
||||||
"JSON Text": "JSON Text",
|
"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.",
|
"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",
|
"Just now": "Just now",
|
||||||
@@ -4315,6 +4321,7 @@
|
|||||||
"Validity Period": "Validity Period",
|
"Validity Period": "Validity Period",
|
||||||
"Value": "Value",
|
"Value": "Value",
|
||||||
"Value (supports JSON or plain text)": "Value (supports JSON or plain text)",
|
"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 must be at least 0": "Value must be at least 0",
|
||||||
"Value Regex": "Value Regex",
|
"Value Regex": "Value Regex",
|
||||||
"variable": "variable",
|
"variable": "variable",
|
||||||
|
|||||||
Vendored
+7
@@ -681,6 +681,7 @@
|
|||||||
"Check for updates": "Vérifier les mises à jour",
|
"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 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 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 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 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",
|
"Check-in failed": "Échec de la connexion",
|
||||||
@@ -1528,6 +1529,7 @@
|
|||||||
"Expand": "Développer",
|
"Expand": "Développer",
|
||||||
"Expand All": "Tout développer",
|
"Expand All": "Tout développer",
|
||||||
"Expected a JSON array.": "Un tableau JSON est attendu.",
|
"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.",
|
"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",
|
"Expiration Time": "Heure d'expiration",
|
||||||
"expired": "expiré",
|
"expired": "expiré",
|
||||||
@@ -2095,6 +2097,9 @@
|
|||||||
"JSON Editor": "Édition JSON",
|
"JSON Editor": "Édition JSON",
|
||||||
"JSON format error": "Erreur de format 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 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 → 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 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.",
|
"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 Mode": "Mode JSON",
|
||||||
"JSON must be an object": "Le JSON doit être un objet",
|
"JSON must be an object": "Le JSON doit être un objet",
|
||||||
"JSON object:": "Objet JSON :",
|
"JSON object:": "Objet JSON :",
|
||||||
|
"JSON structure is invalid": "La structure JSON est invalide",
|
||||||
"JSON Text": "Texte JSON",
|
"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.",
|
"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",
|
"Just now": "À l'instant",
|
||||||
@@ -4315,6 +4321,7 @@
|
|||||||
"Validity Period": "Période de validité",
|
"Validity Period": "Période de validité",
|
||||||
"Value": "Valeur",
|
"Value": "Valeur",
|
||||||
"Value (supports JSON or plain text)": "Valeur (JSON ou texte brut)",
|
"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 must be at least 0": "La valeur doit être au moins 0",
|
||||||
"Value Regex": "Regex de valeur",
|
"Value Regex": "Regex de valeur",
|
||||||
"variable": "variable",
|
"variable": "variable",
|
||||||
|
|||||||
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.": "{{line}} 行目にカンマの抜けがないか確認してください。",
|
||||||
"Check out the Quick Start": "クイックスタートをご確認ください",
|
"Check out the Quick Start": "クイックスタートをご確認ください",
|
||||||
"Check resolved IPs against IP filters even when accessing by domain": "ドメインによるアクセスであっても、解決されたIPをIPフィルターと照合してチェックします",
|
"Check resolved IPs against IP filters even when accessing by domain": "ドメインによるアクセスであっても、解決されたIPをIPフィルターと照合してチェックします",
|
||||||
"Check-in failed": "チェックインできませんでした",
|
"Check-in failed": "チェックインできませんでした",
|
||||||
@@ -1528,6 +1529,7 @@
|
|||||||
"Expand": "展開",
|
"Expand": "展開",
|
||||||
"Expand All": "すべて展開",
|
"Expand All": "すべて展開",
|
||||||
"Expected a JSON array.": "JSON 配列が必要です。",
|
"Expected a JSON array.": "JSON 配列が必要です。",
|
||||||
|
"Expected a JSON array of group identifiers": "グループ識別子の JSON 配列が必要です",
|
||||||
"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編集",
|
"JSON Editor": "JSON編集",
|
||||||
"JSON format error": "JSONフォーマットエラー",
|
"JSON format error": "JSONフォーマットエラー",
|
||||||
"JSON format supports service account JSON files": "JSON形式はサービスアカウント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 → 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 group → ratio applied when the user selects the group explicitly.": "ユーザーがグループを明示的に選択したときに適用される、グループ → 比率のJSONマップ。",
|
||||||
"JSON map of model → multiplier applied to quota billing.": "モデル → クォータ請求に適用される乗数のJSONマップ。",
|
"JSON map of model → multiplier applied to quota billing.": "モデル → クォータ請求に適用される乗数のJSONマップ。",
|
||||||
@@ -2103,6 +2108,7 @@
|
|||||||
"JSON Mode": "JSONモード",
|
"JSON Mode": "JSONモード",
|
||||||
"JSON must be an object": "JSON はオブジェクトである必要があります",
|
"JSON must be an object": "JSON はオブジェクトである必要があります",
|
||||||
"JSON object:": "JSONオブジェクト:",
|
"JSON object:": "JSONオブジェクト:",
|
||||||
|
"JSON structure is invalid": "JSON 構造が無効です",
|
||||||
"JSON Text": "JSONテキスト",
|
"JSON Text": "JSONテキスト",
|
||||||
"JSON-based access control rules. Leave empty to allow all users.": "JSONベースのアクセス制御ルール。すべてのユーザーを許可する場合は空のままにしてください。",
|
"JSON-based access control rules. Leave empty to allow all users.": "JSONベースのアクセス制御ルール。すべてのユーザーを許可する場合は空のままにしてください。",
|
||||||
"Just now": "たった今",
|
"Just now": "たった今",
|
||||||
@@ -4315,6 +4321,7 @@
|
|||||||
"Validity Period": "有効期間",
|
"Validity Period": "有効期間",
|
||||||
"Value": "値",
|
"Value": "値",
|
||||||
"Value (supports JSON or plain text)": "値(JSONまたはプレーンテキスト対応)",
|
"Value (supports JSON or plain text)": "値(JSONまたはプレーンテキスト対応)",
|
||||||
|
"Value is required": "値は必須です",
|
||||||
"Value must be at least 0": "値は 0 以上である必要があります",
|
"Value must be at least 0": "値は 0 以上である必要があります",
|
||||||
"Value Regex": "Value 正規表現",
|
"Value Regex": "Value 正規表現",
|
||||||
"variable": "変数",
|
"variable": "変数",
|
||||||
|
|||||||
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.": "Проверьте строку {{line}} на пропущенную запятую.",
|
||||||
"Check out the Quick Start": "Ознакомьтесь с быстрым стартом",
|
"Check out the Quick Start": "Ознакомьтесь с быстрым стартом",
|
||||||
"Check resolved IPs against IP filters even when accessing by domain": "Проверять разрешенные IP-адреса по IP-фильтрам даже при доступе по домену",
|
"Check resolved IPs against IP filters even when accessing by domain": "Проверять разрешенные IP-адреса по IP-фильтрам даже при доступе по домену",
|
||||||
"Check-in failed": "Регистрация не удалась.",
|
"Check-in failed": "Регистрация не удалась.",
|
||||||
@@ -1528,6 +1529,7 @@
|
|||||||
"Expand": "Развернуть",
|
"Expand": "Развернуть",
|
||||||
"Expand All": "Развернуть все",
|
"Expand All": "Развернуть все",
|
||||||
"Expected a JSON array.": "Ожидается JSON-массив.",
|
"Expected a JSON array.": "Ожидается JSON-массив.",
|
||||||
|
"Expected a JSON array of group identifiers": "Ожидается JSON-массив идентификаторов групп",
|
||||||
"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",
|
"JSON Editor": "Редактирование JSON",
|
||||||
"JSON format error": "Ошибка формата JSON",
|
"JSON format error": "Ошибка формата JSON",
|
||||||
"JSON format supports service account JSON files": "Формат JSON поддерживает 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 → 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 group → ratio applied when the user selects the group explicitly.": "JSON-карта группы → соотношение, применяемое, когда пользователь явно выбирает группу.",
|
||||||
"JSON map of model → multiplier applied to quota billing.": "JSON-карта модели → множитель, применяемый к тарификации по квоте.",
|
"JSON map of model → multiplier applied to quota billing.": "JSON-карта модели → множитель, применяемый к тарификации по квоте.",
|
||||||
@@ -2103,6 +2108,7 @@
|
|||||||
"JSON Mode": "Режим JSON",
|
"JSON Mode": "Режим JSON",
|
||||||
"JSON must be an object": "JSON должен быть объектом",
|
"JSON must be an object": "JSON должен быть объектом",
|
||||||
"JSON object:": "Объект JSON:",
|
"JSON object:": "Объект JSON:",
|
||||||
|
"JSON structure is invalid": "Структура JSON недействительна",
|
||||||
"JSON Text": "JSON текст",
|
"JSON Text": "JSON текст",
|
||||||
"JSON-based access control rules. Leave empty to allow all users.": "Правила контроля доступа на основе JSON. Оставьте пустым, чтобы разрешить всем пользователям.",
|
"JSON-based access control rules. Leave empty to allow all users.": "Правила контроля доступа на основе JSON. Оставьте пустым, чтобы разрешить всем пользователям.",
|
||||||
"Just now": "Только что",
|
"Just now": "Только что",
|
||||||
@@ -4315,6 +4321,7 @@
|
|||||||
"Validity Period": "Срок действия",
|
"Validity Period": "Срок действия",
|
||||||
"Value": "Значение",
|
"Value": "Значение",
|
||||||
"Value (supports JSON or plain text)": "Значение (JSON или текст)",
|
"Value (supports JSON or plain text)": "Значение (JSON или текст)",
|
||||||
|
"Value is required": "Значение обязательно",
|
||||||
"Value must be at least 0": "Значение должно быть не менее 0",
|
"Value must be at least 0": "Значение должно быть не менее 0",
|
||||||
"Value Regex": "Регулярное выражение значения",
|
"Value Regex": "Регулярное выражение значения",
|
||||||
"variable": "переменная",
|
"variable": "переменная",
|
||||||
|
|||||||
Vendored
+7
@@ -681,6 +681,7 @@
|
|||||||
"Check for updates": "Kiểm tra cập nhật",
|
"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 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 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 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 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",
|
"Check-in failed": "Điểm danh thất bại",
|
||||||
@@ -1528,6 +1529,7 @@
|
|||||||
"Expand": "Mở rộng",
|
"Expand": "Mở rộng",
|
||||||
"Expand All": "Mở rộng tất cả",
|
"Expand All": "Mở rộng tất cả",
|
||||||
"Expected a JSON array.": "Cần là một mảng JSON.",
|
"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.",
|
"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",
|
"Expiration Time": "Thời gian hết hạn",
|
||||||
"expired": "Đã hết hạn",
|
"expired": "Đã hết hạn",
|
||||||
@@ -2095,6 +2097,9 @@
|
|||||||
"JSON Editor": "Trình chỉnh sửa JSON",
|
"JSON Editor": "Trình chỉnh sửa JSON",
|
||||||
"JSON format error": "Lỗi định dạng 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 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 → 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 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.",
|
"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 Mode": "Chế độ JSON",
|
||||||
"JSON must be an object": "JSON phải là object",
|
"JSON must be an object": "JSON phải là object",
|
||||||
"JSON object:": "Đối tượng JSON:",
|
"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 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.",
|
"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",
|
"Just now": "Vừa nãy",
|
||||||
@@ -4315,6 +4321,7 @@
|
|||||||
"Validity Period": "Thời hạn hiệu lực",
|
"Validity Period": "Thời hạn hiệu lực",
|
||||||
"Value": "Giá trị",
|
"Value": "Giá trị",
|
||||||
"Value (supports JSON or plain text)": "Giá trị (hỗ trợ JSON hoặc văn bản thuần)",
|
"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 must be at least 0": "Giá trị phải ít nhất là 0",
|
||||||
"Value Regex": "Regex giá trị",
|
"Value Regex": "Regex giá trị",
|
||||||
"variable": "biến",
|
"variable": "biến",
|
||||||
|
|||||||
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.": "请检查第 {{line}} 行是否缺少逗号。",
|
||||||
"Check out the Quick Start": "请查看 新手入门",
|
"Check out the Quick Start": "请查看 新手入门",
|
||||||
"Check resolved IPs against IP filters even when accessing by domain": "即使通过域名访问,也对照 IP 过滤器检查解析的 IP",
|
"Check resolved IPs against IP filters even when accessing by domain": "即使通过域名访问,也对照 IP 过滤器检查解析的 IP",
|
||||||
"Check-in failed": "签到失败",
|
"Check-in failed": "签到失败",
|
||||||
@@ -1528,6 +1529,7 @@
|
|||||||
"Expand": "展开",
|
"Expand": "展开",
|
||||||
"Expand All": "全部展开",
|
"Expand All": "全部展开",
|
||||||
"Expected a JSON array.": "应为 JSON 数组。",
|
"Expected a JSON array.": "应为 JSON 数组。",
|
||||||
|
"Expected a JSON array of group identifiers": "应为分组标识符的 JSON 数组",
|
||||||
"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 编辑",
|
"JSON Editor": "JSON 编辑",
|
||||||
"JSON format error": "JSON 格式错误",
|
"JSON format error": "JSON 格式错误",
|
||||||
"JSON format supports service account JSON files": "JSON 格式支持服务账户 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 → 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 group → ratio applied when the user selects the group explicitly.": "分组 → 比率的 JSON 映射,当用户明确选择该分组时应用此比率。",
|
||||||
"JSON map of model → multiplier applied to quota billing.": "模型 → 应用于配额计费的乘数的 JSON 映射。",
|
"JSON map of model → multiplier applied to quota billing.": "模型 → 应用于配额计费的乘数的 JSON 映射。",
|
||||||
@@ -2103,6 +2108,7 @@
|
|||||||
"JSON Mode": "JSON 模式",
|
"JSON Mode": "JSON 模式",
|
||||||
"JSON must be an object": "JSON 必须是对象",
|
"JSON must be an object": "JSON 必须是对象",
|
||||||
"JSON object:": "JSON 对象:",
|
"JSON object:": "JSON 对象:",
|
||||||
|
"JSON structure is invalid": "JSON 结构无效",
|
||||||
"JSON Text": "JSON 文本",
|
"JSON Text": "JSON 文本",
|
||||||
"JSON-based access control rules. Leave empty to allow all users.": "基于 JSON 的访问控制规则。留空以允许所有用户。",
|
"JSON-based access control rules. Leave empty to allow all users.": "基于 JSON 的访问控制规则。留空以允许所有用户。",
|
||||||
"Just now": "刚刚",
|
"Just now": "刚刚",
|
||||||
@@ -4315,6 +4321,7 @@
|
|||||||
"Validity Period": "有效期",
|
"Validity Period": "有效期",
|
||||||
"Value": "值",
|
"Value": "值",
|
||||||
"Value (supports JSON or plain text)": "值(支持 JSON 或普通文本)",
|
"Value (supports JSON or plain text)": "值(支持 JSON 或普通文本)",
|
||||||
|
"Value is required": "值为必填项",
|
||||||
"Value must be at least 0": "值必须至少为 0",
|
"Value must be at least 0": "值必须至少为 0",
|
||||||
"Value Regex": "Value 正则",
|
"Value Regex": "Value 正则",
|
||||||
"variable": "变量",
|
"variable": "变量",
|
||||||
|
|||||||
Vendored
+1
@@ -88,6 +88,7 @@ export const STATIC_I18N_KEYS = [
|
|||||||
'Failed to delete API key',
|
'Failed to delete API key',
|
||||||
'Failed to delete API keys',
|
'Failed to delete API keys',
|
||||||
'Failed to update API key status',
|
'Failed to update API key status',
|
||||||
|
'Expected a JSON array of group identifiers',
|
||||||
'Successfully created {{count}} API Key(s)',
|
'Successfully created {{count}} API Key(s)',
|
||||||
'Successfully deleted {{count}} API key(s)',
|
'Successfully deleted {{count}} API key(s)',
|
||||||
'Enter API key for this channel',
|
'Enter API key for this channel',
|
||||||
|
|||||||
Reference in New Issue
Block a user