Commit 1e6f31b2 by 同語 Committed by GitHub

perf(model-pricing): improve model pricing editor UX (#5275)

Merge pull request #5275 from QuantumNous/fix/model-pricing-draft-save
parents adc390c5 4dd68bad
/*
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) { ...@@ -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>
......
/*
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'),
},
]
}
/*
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>
)
}
...@@ -16,22 +16,22 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,22 +16,22 @@ 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 { import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Field, Field,
FieldDescription, FieldDescription,
FieldGroup, FieldGroup,
...@@ -56,71 +56,39 @@ import { ...@@ -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< ...@@ -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*)?$/
const EMPTY_LANE_PRICES: Record<LaneKey, string> = {
completion: '',
cache: '',
createCache: '',
image: '',
audioInput: '',
audioOutput: '',
}
const EMPTY_LANE_ENABLED: Record<LaneKey, boolean> = {
completion: false,
cache: false,
createCache: false,
image: false,
audioInput: false,
audioOutput: false,
}
const ratioFieldByLane: Record<LaneKey, keyof ModelPricingFormValues> = {
completion: 'completionRatio',
cache: 'cacheRatio',
createCache: 'createCacheRatio',
image: 'imageRatio',
audioInput: 'audioRatio',
audioOutput: 'audioCompletionRatio',
}
const laneConfigs: Array<{
key: LaneKey
titleKey: string
descriptionKey: string
placeholder: string
}> = [
{
key: 'completion',
titleKey: 'Completion price',
descriptionKey: 'Output token price for generated tokens.',
placeholder: '15',
},
{
key: 'cache',
titleKey: 'Cache read price',
descriptionKey: 'Token price for cache reads.',
placeholder: '0.3',
},
{
key: 'createCache',
titleKey: 'Cache write price',
descriptionKey: 'Token price for creating cache entries.',
placeholder: '3.75',
},
{
key: 'image',
titleKey: 'Image input price',
descriptionKey: 'Token price for image input.',
placeholder: '2.5',
},
{
key: 'audioInput',
titleKey: 'Audio input price',
descriptionKey: 'Token price for audio input.',
placeholder: '3.81',
},
{
key: 'audioOutput',
titleKey: 'Audio output price',
descriptionKey: 'Token price for audio output.',
placeholder: '15.11',
},
]
function hasValue(value: unknown): boolean {
return (
value !== '' && value !== null && value !== undefined && value !== false
)
}
function toNumberOrNull(value: unknown): number | null {
if (!hasValue(value) && value !== 0) return null
const num = Number(value)
return Number.isFinite(num) ? num : null
}
function ratioToBasePrice(ratio: unknown): string {
const num = toNumberOrNull(ratio)
if (num === null) return ''
return formatPricingNumber(num * 2)
}
function deriveLanePrice(
ratio: unknown,
denominator: unknown,
fallback = ''
): string {
const ratioNumber = toNumberOrNull(ratio)
const denominatorNumber = toNumberOrNull(denominator)
if (ratioNumber === null || denominatorNumber === null) return fallback
return formatPricingNumber(ratioNumber * denominatorNumber)
}
function createInitialLaneState(data?: ModelRatioData | null) {
if (!data) {
return {
promptPrice: '',
prices: { ...EMPTY_LANE_PRICES },
enabled: { ...EMPTY_LANE_ENABLED },
}
}
const promptPrice = ratioToBasePrice(data.ratio)
const audioInputPrice = deriveLanePrice(data.audioRatio, promptPrice)
const prices: Record<LaneKey, string> = {
completion: deriveLanePrice(data.completionRatio, promptPrice),
cache: deriveLanePrice(data.cacheRatio, promptPrice),
createCache: deriveLanePrice(data.createCacheRatio, promptPrice),
image: deriveLanePrice(data.imageRatio, promptPrice),
audioInput: audioInputPrice,
audioOutput: deriveLanePrice(data.audioCompletionRatio, audioInputPrice),
}
return {
promptPrice,
prices,
enabled: {
completion: hasValue(data.completionRatio),
cache: hasValue(data.cacheRatio),
createCache: hasValue(data.createCacheRatio),
image: hasValue(data.imageRatio),
audioInput: hasValue(data.audioRatio),
audioOutput: hasValue(data.audioCompletionRatio),
},
}
}
function getModeLabel(mode: PricingMode) {
if (mode === 'per-request') return 'Per-request'
if (mode === 'tiered_expr') return 'Expression'
return 'Per-token'
}
function getModeBadgeVariant(
mode: PricingMode
): 'default' | 'secondary' | 'outline' {
if (mode === 'per-request') return 'secondary'
if (mode === 'tiered_expr') return 'default'
return 'outline'
}
function buildPreviewRows(
values: ModelPricingFormValues,
mode: PricingMode,
billingExpr: string,
requestRuleExpr: string,
promptPrice: string,
lanePrices: Record<LaneKey, string>,
laneEnabled: Record<LaneKey, boolean>,
t: (key: string) => string
): PreviewRow[] {
if (mode === 'tiered_expr') {
const effectiveExpr = combineBillingExpr(billingExpr, requestRuleExpr)
return [
{ key: 'mode', label: 'BillingMode', value: 'tiered_expr' },
{
key: 'expr',
label: t('Expression'),
value: effectiveExpr || t('Empty'),
multiline: true,
},
]
}
if (mode === 'per-request') {
return [
{
key: 'price',
label: 'ModelPrice',
value: values.price || t('Empty'),
},
]
}
return [
{
key: 'inputPrice',
label: t('Input price'),
value: promptPrice ? `$${promptPrice}` : t('Empty'),
},
{
key: 'completion',
label: t('Completion price'),
value:
laneEnabled.completion && lanePrices.completion
? `$${lanePrices.completion}`
: t('Empty'),
},
{
key: 'cache',
label: t('Cache read price'),
value:
laneEnabled.cache && lanePrices.cache
? `$${lanePrices.cache}`
: t('Empty'),
},
{
key: 'createCache',
label: t('Cache write price'),
value:
laneEnabled.createCache && lanePrices.createCache
? `$${lanePrices.createCache}`
: t('Empty'),
},
{
key: 'image',
label: t('Image input price'),
value:
laneEnabled.image && lanePrices.image
? `$${lanePrices.image}`
: t('Empty'),
},
{
key: 'audio',
label: t('Audio input price'),
value:
laneEnabled.audioInput && lanePrices.audioInput
? `$${lanePrices.audioInput}`
: t('Empty'),
},
{
key: 'audioCompletion',
label: t('Audio output price'),
value:
laneEnabled.audioOutput && lanePrices.audioOutput
? `$${lanePrices.audioOutput}`
: t('Empty'),
},
]
} }
export function ModelPricingSheet({ export const ModelPricingSheet = forwardRef<
open, ModelPricingEditorPanelHandle,
onOpenChange, ModelPricingSheetProps
onSave, >(function ModelPricingSheet(
onCancel, { open, onOpenChange, editData, onSave, isSaving },
editData, ref
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({ ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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,33 +428,50 @@ export function ModelPricingEditorPanel({ ...@@ -709,33 +428,50 @@ 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
} }
const data: ModelRatioData = { return true
name: values.name.trim(), }, [form, laneEnabled, lanePrices, pricingMode, promptPrice, t])
billingMode: pricingMode,
price: values.price || '', const buildSubmitData = useCallback(
ratio: values.ratio || '', (values: ModelPricingFormValues) => {
cacheRatio: values.cacheRatio || '', const data: ModelRatioData = {
createCacheRatio: values.createCacheRatio || '', name: values.name.trim(),
completionRatio: values.completionRatio || '', billingMode: pricingMode,
imageRatio: values.imageRatio || '', price: values.price || '',
audioRatio: values.audioRatio || '', ratio: values.ratio || '',
audioCompletionRatio: values.audioCompletionRatio || '', cacheRatio: values.cacheRatio || '',
} createCacheRatio: values.createCacheRatio || '',
completionRatio: values.completionRatio || '',
imageRatio: values.imageRatio || '',
audioRatio: values.audioRatio || '',
audioCompletionRatio: values.audioCompletionRatio || '',
}
if (pricingMode === 'tiered_expr') { if (pricingMode === 'tiered_expr') {
data.billingExpr = billingExpr data.billingExpr = billingExpr
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,296 +486,214 @@ export function ModelPricingEditorPanel({ ...@@ -750,296 +486,214 @@ 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'>
<FieldGroup> <div className='grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(220px,260px)]'>
{warnings.length > 0 && ( <FieldGroup>
<Alert variant='destructive'> {warnings.length > 0 && (
<AlertTriangle data-icon='inline-start' /> <Alert variant='destructive'>
<AlertDescription> <AlertTriangle data-icon='inline-start' />
<div className='flex flex-col gap-1'> <AlertDescription>
{warnings.map((warning) => ( <div className='flex flex-col gap-1'>
<span key={warning}>{warning}</span> {warnings.map((warning) => (
))} <span key={warning}>{warning}</span>
</div> ))}
</AlertDescription> </div>
</Alert> </AlertDescription>
)} </Alert>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Model name')}</FormLabel>
<FormControl>
<Input
placeholder={t('gpt-4')}
{...field}
disabled={isEditMode}
/>
</FormControl>
<FormDescription>
{t('The exact model identifier as used in API requests.')}
</FormDescription>
<FormMessage />
</FormItem>
)} )}
/>
<Tabs value={pricingMode} onValueChange={handleModeChange}>
<TabsList className='grid w-full grid-cols-3'>
<TabsTrigger value='per-token'>{t('Per-token')}</TabsTrigger>
<TabsTrigger value='per-request'>
{t('Per-request')}
</TabsTrigger>
<TabsTrigger value='tiered_expr'>
{t('Expression')}
</TabsTrigger>
</TabsList>
<TabsContent value='per-token' className='flex flex-col gap-5'>
<FieldGroup>
<Field>
<FieldLabel>{t('Input price')}</FieldLabel>
<PriceInput
value={promptPrice}
placeholder='3'
onChange={handlePromptPriceChange}
/>
<FieldDescription>
{t('USD price per 1M input tokens.')}
</FieldDescription>
</Field>
<div className='grid gap-3 sm:grid-cols-2'>
{laneConfigs.map((lane) => {
const disabled =
lane.key === 'audioOutput' &&
(!laneEnabled.audioInput ||
!hasValue(lanePrices.audioInput))
return (
<PriceLane
key={lane.key}
title={t(lane.titleKey)}
description={t(lane.descriptionKey)}
placeholder={lane.placeholder}
value={lanePrices[lane.key]}
enabled={laneEnabled[lane.key]}
disabled={disabled}
onEnabledChange={(checked) =>
handleLaneToggle(lane.key, checked)
}
onChange={(value) =>
handleLanePriceChange(lane.key, value)
}
/>
)
})}
</div>
</FieldGroup>
</TabsContent>
<TabsContent <FormField
value='per-request' control={form.control}
className='flex flex-col gap-5' name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Model name')}</FormLabel>
<FormControl>
<Input
placeholder={t('gpt-4')}
{...field}
disabled={isEditMode}
/>
</FormControl>
<FormDescription>
{t(
'The exact model identifier as used in API requests.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Tabs
value={pricingMode}
onValueChange={handleModeChange}
className='gap-4'
> >
<FormField <TabsList className='grid w-full grid-cols-3'>
control={form.control} <TabsTrigger value='per-token'>
name='price' {t('Per-token')}
render={({ field }) => ( </TabsTrigger>
<FormItem> <TabsTrigger value='per-request'>
<FormLabel>{t('Fixed price')}</FormLabel> {t('Per-request')}
<FormControl> </TabsTrigger>
<InputGroup> <TabsTrigger value='tiered_expr'>
<InputGroupAddon>$</InputGroupAddon> {t('Expression')}
<InputGroupInput </TabsTrigger>
inputMode='decimal' </TabsList>
placeholder='0.01'
{...field} <TabsContent value='per-token' className='pt-0'>
onChange={(event) => { <FieldGroup className='gap-5'>
const value = event.target.value <Field>
if (numericDraftRegex.test(value)) { <FieldLabel>{t('Input price')}</FieldLabel>
field.onChange(value) <PriceInput
} value={promptPrice}
}} placeholder='3'
onChange={handlePromptPriceChange}
/>
<FieldDescription>
{t('USD price per 1M input tokens.')}
</FieldDescription>
</Field>
<div className='grid gap-3 sm:grid-cols-[repeat(auto-fit,minmax(400px,1fr))]'>
{laneConfigs.map((lane) => {
const disabled =
lane.key === 'audioOutput' &&
(!laneEnabled.audioInput ||
!hasValue(lanePrices.audioInput))
return (
<PriceLane
key={lane.key}
title={t(lane.titleKey)}
description={t(lane.descriptionKey)}
placeholder={lane.placeholder}
value={lanePrices[lane.key]}
enabled={laneEnabled[lane.key]}
disabled={disabled}
onEnabledChange={(checked) =>
handleLaneToggle(lane.key, checked)
}
onChange={(value) =>
handleLanePriceChange(lane.key, value)
}
/> />
<InputGroupAddon align='inline-end'> )
{t('per request')} })}
</InputGroupAddon>
</InputGroup>
</FormControl>
<FormDescription>
{t(
'Cost in USD per request, regardless of tokens used.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent
value='tiered_expr'
className='flex flex-col gap-5'
>
<TieredPricingEditor
modelName={watchedValues.name}
billingExpr={billingExpr}
requestRuleExpr={requestRuleExpr}
onBillingExprChange={setBillingExpr}
onRequestRuleExprChange={setRequestRuleExpr}
/>
</TabsContent>
</Tabs>
<Collapsible open={previewOpen} onOpenChange={setPreviewOpen}>
<CollapsibleTrigger
render={
<Button
type='button'
variant='outline'
className='flex w-full justify-between'
/>
}
>
<span>{t('Save preview')}</span>
<ChevronDown
className={cn(
'transition-transform',
previewOpen && 'rotate-180'
)}
/>
</CollapsibleTrigger>
<CollapsibleContent className='pt-3'>
<div className='rounded-lg border'>
{previewRows.map((row) => (
<div
key={row.key}
className='grid grid-cols-[140px_1fr] gap-3 border-b px-3 py-2 text-sm last:border-b-0'
>
<span className='text-muted-foreground text-xs'>
{row.label}
</span>
<span
className={cn(
'min-w-0',
row.multiline
? 'font-mono text-xs leading-5 break-words whitespace-pre-wrap'
: 'truncate'
)}
>
{row.value}
</span>
</div> </div>
))} </FieldGroup>
</div> </TabsContent>
</CollapsibleContent>
</Collapsible> <TabsContent value='per-request' className='pt-0'>
</FieldGroup> <FieldGroup className='gap-5'>
</div> <FormField
control={form.control}
<SheetFooter name='price'
className={sideDrawerFooterClassName( render={({ field }) => (
'grid-cols-1 sm:items-center sm:justify-between' <FormItem className='contents'>
)} <Field>
> <FieldLabel>{t('Fixed price')}</FieldLabel>
<div className='text-muted-foreground text-xs'> <FormControl>
{selectedTargetCount > 0 <InputGroup>
? t('{{count}} selected targets available for bulk copy.', { <InputGroupAddon>$</InputGroupAddon>
count: selectedTargetCount, <InputGroupInput
}) inputMode='decimal'
: t('Changes are written to the settings draft on save.')} placeholder='0.01'
{...field}
onChange={(event) => {
const value = event.target.value
if (numericDraftRegex.test(value)) {
field.onChange(value)
}
}}
/>
<InputGroupAddon align='inline-end'>
{t('per request')}
</InputGroupAddon>
</InputGroup>
</FormControl>
<FieldDescription>
{t(
'Cost in USD per request, regardless of tokens used.'
)}
</FieldDescription>
<FormMessage />
</Field>
</FormItem>
)}
/>
</FieldGroup>
</TabsContent>
<TabsContent value='tiered_expr' className='pt-0'>
<FieldGroup className='gap-5'>
<TieredPricingEditor
modelName={watchedValues.name}
billingExpr={billingExpr}
requestRuleExpr={requestRuleExpr}
onBillingExprChange={setBillingExpr}
onRequestRuleExprChange={setRequestRuleExpr}
/>
</FieldGroup>
</TabsContent>
</Tabs>
</FieldGroup>
<aside className='bg-muted/20 sticky top-0 rounded-lg border'>
<div className='border-b px-3 py-2'>
<div className='text-sm font-medium'>{t('Preview')}</div>
</div>
<div className='divide-y'>
{previewRows.map((row) => (
<div key={row.key} className='grid gap-1 px-3 py-2.5'>
<span className='text-muted-foreground text-xs'>
{row.label}
</span>
<span
className={cn(
'min-w-0 text-sm',
row.multiline
? 'font-mono text-xs leading-5 break-words whitespace-pre-wrap'
: 'truncate'
)}
>
{row.value}
</span>
</div>
))}
</div>
</aside>
</div> </div>
<div className='flex justify-end gap-2'> </div>
<Button type='button' variant='outline' onClick={onCancel}> {showActions && (
{t('Cancel')} <div className='bg-background/95 supports-[backdrop-filter]:bg-background/80 shrink-0 border-t p-3 backdrop-blur'>
</Button> <div className='flex flex-col-reverse gap-2 sm:flex-row sm:justify-end'>
<Button type='submit'> {onSave && (
{isEditMode ? t('Update') : t('Add')} <Button
</Button> type='button'
onClick={onSave}
disabled={isSaving}
className='w-full sm:w-auto'
>
<Save data-icon='inline-start' />
{isSaving ? t('Saving...') : t('Save model prices')}
</Button>
)}
</div>
</div> </div>
</SheetFooter> )}
</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>
)
}
/*
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 || '',
})
}
...@@ -16,9 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -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 { ...@@ -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 = { ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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( </div>
'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>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
......
/*
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,
},
]
}
...@@ -16,9 +16,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -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 { ...@@ -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 = { ...@@ -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,180 +126,280 @@ export const ModelRatioVisualEditor = memo( ...@@ -205,180 +126,280 @@ export const ModelRatioVisualEditor = memo(
billingMode, billingMode,
billingExpr, billingExpr,
onChange, onChange,
}: ModelRatioVisualEditorProps) { onSave,
const { t } = useTranslation() isSaving,
const isMobile = useMediaQuery('(max-width: 767px)') },
const [sheetOpen, setSheetOpen] = useState(false) ref
const [editorOpen, setEditorOpen] = useState(false) ) {
const [editData, setEditData] = useState<ModelRatioData | null>(null) const { t } = useTranslation()
const [sorting, setSorting] = useState<SortingState>([]) const isMobile = useMediaQuery('(max-width: 767px)')
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]) const [sheetOpen, setSheetOpen] = useState(false)
const [globalFilter, setGlobalFilter] = useState('') const [editorOpen, setEditorOpen] = useState(false)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}) const [editData, setEditData] = useState<ModelRatioData | null>(null)
const [pagination, setPagination] = useState<PaginationState>({ const [sorting, setSorting] = useState<SortingState>([])
pageIndex: 0, const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
pageSize: 20, const [globalFilter, setGlobalFilter] = useState('')
}) const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>( const editorPanelRef = useRef<ModelPricingEditorPanelHandle>(null)
() => { const [pagination, setPagination] = useState<PaginationState>({
const saved = localStorage.getItem(STORAGE_KEY) pageIndex: 0,
if (saved) { pageSize: 20,
try { })
return safeJsonParse<VisibilityState>(saved, { const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
fallback: { () => {
cacheRatio: false, const saved = localStorage.getItem(STORAGE_KEY)
createCacheRatio: false, if (saved) {
imageRatio: false, try {
audioRatio: false, return safeJsonParse<VisibilityState>(saved, {
audioCompletionRatio: false, fallback: {
},
silent: true,
})
} catch {
return {
cacheRatio: false, cacheRatio: false,
createCacheRatio: false, createCacheRatio: false,
imageRatio: false, imageRatio: false,
audioRatio: false, audioRatio: false,
audioCompletionRatio: false, audioCompletionRatio: false,
} },
silent: true,
})
} catch {
return {
cacheRatio: false,
createCacheRatio: false,
imageRatio: false,
audioRatio: false,
audioCompletionRatio: false,
} }
} }
return {
cacheRatio: false,
createCacheRatio: false,
imageRatio: false,
audioRatio: false,
audioCompletionRatio: false,
}
} }
) return {
cacheRatio: false,
createCacheRatio: false,
imageRatio: false,
audioRatio: false,
audioCompletionRatio: false,
}
}
)
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(columnVisibility))
}, [columnVisibility])
const models = useMemo(() => {
const savedRows = buildModelSnapshots({
modelPrice: savedModelPrice,
modelRatio: savedModelRatio,
cacheRatio: savedCacheRatio,
createCacheRatio: savedCreateCacheRatio,
completionRatio: savedCompletionRatio,
imageRatio: savedImageRatio,
audioRatio: savedAudioRatio,
audioCompletionRatio: savedAudioCompletionRatio,
billingMode: savedBillingMode,
billingExpr: savedBillingExpr,
})
const draftRows = buildModelSnapshots({
modelPrice,
modelRatio,
cacheRatio,
createCacheRatio,
completionRatio,
imageRatio,
audioRatio,
audioCompletionRatio,
billingMode,
billingExpr,
})
useEffect(() => { const savedByName = new Map(savedRows.map((row) => [row.name, row]))
localStorage.setItem(STORAGE_KEY, JSON.stringify(columnVisibility)) const draftByName = new Map(draftRows.map((row) => [row.name, row]))
}, [columnVisibility]) const modelNames = new Set([...savedByName.keys(), ...draftByName.keys()])
return Array.from(modelNames)
.map((name) => {
const saved = savedByName.get(name)
const draft = draftByName.get(name)
const displayed = saved ?? draft
const savedSignature = getSnapshotSignature(saved)
const draftSignature = getSnapshotSignature(draft)
return {
...displayed!,
saved,
draft,
isDraftChanged: savedSignature !== draftSignature,
isDraftDeleted: Boolean(saved && !draft),
isDraftNew: Boolean(!saved && draft),
}
})
.sort((a, b) => a.name.localeCompare(b.name))
}, [
savedModelPrice,
savedModelRatio,
savedCacheRatio,
savedCreateCacheRatio,
savedCompletionRatio,
savedImageRatio,
savedAudioRatio,
savedAudioCompletionRatio,
savedBillingMode,
savedBillingExpr,
modelPrice,
modelRatio,
cacheRatio,
createCacheRatio,
completionRatio,
imageRatio,
audioRatio,
audioCompletionRatio,
billingMode,
billingExpr,
])
const modeCounts = useMemo(
() =>
models.reduce(
(acc, model) => {
const mode =
model.billingMode === 'per-request' ||
model.billingMode === 'tiered_expr'
? model.billingMode
: 'per-token'
acc[mode] += 1
return acc
},
{
'per-token': 0,
'per-request': 0,
tiered_expr: 0,
} as Record<'per-token' | 'per-request' | 'tiered_expr', number>
),
[models]
)
const handleEdit = useCallback(
(model: ModelRow) => {
const editableModel = model.draft ?? model.saved ?? model
setEditData({
name: editableModel.name,
price: editableModel.price,
ratio: editableModel.ratio,
cacheRatio: editableModel.cacheRatio,
createCacheRatio: editableModel.createCacheRatio,
completionRatio: editableModel.completionRatio,
imageRatio: editableModel.imageRatio,
audioRatio: editableModel.audioRatio,
audioCompletionRatio: editableModel.audioCompletionRatio,
billingMode:
editableModel.billingMode === 'tiered_expr'
? 'tiered_expr'
: editableModel.price && editableModel.price !== ''
? 'per-request'
: 'per-token',
billingExpr: editableModel.billingExpr,
requestRuleExpr: editableModel.requestRuleExpr,
})
setEditorOpen(true)
if (isMobile) setSheetOpen(true)
},
[isMobile]
)
const handleAdd = useCallback(() => {
setEditData(null)
setEditorOpen(true)
if (isMobile) setSheetOpen(true)
}, [isMobile])
const handleGlobalFilterChange = useCallback<OnChangeFn<string>>(
(updater) => {
setGlobalFilter((previous) => {
const next = typeof updater === 'function' ? updater(previous) : updater
if (next !== previous) {
setEditData(null)
setEditorOpen(false)
setSheetOpen(false)
}
return next
})
},
[]
)
const models = useMemo(() => { const handleDelete = useCallback(
(name: string) => {
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, { const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
fallback: {}, fallback: {},
context: 'model prices', silent: true,
}) })
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, { const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
fallback: {}, fallback: {},
context: 'model ratios', silent: true,
}) })
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, { const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
fallback: {}, fallback: {},
context: 'cache ratios', silent: true,
}) })
const createCacheMap = safeJsonParse<Record<string, number>>( const createCacheMap = safeJsonParse<Record<string, number>>(
createCacheRatio, createCacheRatio,
{ fallback: {}, context: 'create cache ratios' } { fallback: {}, silent: true }
) )
const completionMap = safeJsonParse<Record<string, number>>( const completionMap = safeJsonParse<Record<string, number>>(
completionRatio, completionRatio,
{ fallback: {}, context: 'completion ratios' } { fallback: {}, silent: true }
) )
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, { const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
fallback: {}, fallback: {},
context: 'image ratios', silent: true,
}) })
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, { const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
fallback: {}, fallback: {},
context: 'audio ratios', silent: true,
}) })
const audioCompletionMap = safeJsonParse<Record<string, number>>( const audioCompletionMap = safeJsonParse<Record<string, number>>(
audioCompletionRatio, audioCompletionRatio,
{ fallback: {}, context: 'audio completion ratios' } { fallback: {}, silent: true }
) )
const billingModeMap = safeJsonParse<Record<string, string>>( const billingModeMap = safeJsonParse<Record<string, string>>(
billingMode, billingMode,
{ { fallback: {}, silent: true }
fallback: {},
context: 'billing mode',
}
) )
const billingExprMap = safeJsonParse<Record<string, string>>( const billingExprMap = safeJsonParse<Record<string, string>>(
billingExpr, billingExpr,
{ { fallback: {}, silent: true }
fallback: {},
context: 'billing expression',
}
) )
const modelNames = new Set([ delete priceMap[name]
...Object.keys(priceMap), delete ratioMap[name]
...Object.keys(ratioMap), delete cacheMap[name]
...Object.keys(cacheMap), delete createCacheMap[name]
...Object.keys(createCacheMap), delete completionMap[name]
...Object.keys(completionMap), delete imageMap[name]
...Object.keys(imageMap), delete audioMap[name]
...Object.keys(audioMap), delete audioCompletionMap[name]
...Object.keys(audioCompletionMap), delete billingModeMap[name]
...Object.keys(billingModeMap), delete billingExprMap[name]
...Object.keys(billingExprMap),
]) onChange('ModelPrice', JSON.stringify(priceMap, null, 2))
onChange('ModelRatio', JSON.stringify(ratioMap, null, 2))
const modelData: ModelRow[] = Array.from(modelNames).map((name) => { onChange('CacheRatio', JSON.stringify(cacheMap, null, 2))
const price = priceMap[name]?.toString() || '' onChange('CreateCacheRatio', JSON.stringify(createCacheMap, null, 2))
const ratio = ratioMap[name]?.toString() || '' onChange('CompletionRatio', JSON.stringify(completionMap, null, 2))
const cache = cacheMap[name]?.toString() || '' onChange('ImageRatio', JSON.stringify(imageMap, null, 2))
const createCache = createCacheMap[name]?.toString() || '' onChange('AudioRatio', JSON.stringify(audioMap, null, 2))
const completion = completionMap[name]?.toString() || '' onChange(
const image = imageMap[name]?.toString() || '' 'AudioCompletionRatio',
const audio = audioMap[name]?.toString() || '' JSON.stringify(audioCompletionMap, null, 2)
const audioCompletion = audioCompletionMap[name]?.toString() || '' )
onChange(
const modeForModel = billingModeMap[name] 'billing_setting.billing_mode',
if (modeForModel === 'tiered_expr') { JSON.stringify(billingModeMap, null, 2)
// Tiered_expr models may also retain ratio/price values as fallback )
// during multi-instance sync delays. We preserve them in the row so onChange(
// the edit dialog round-trip and the next save don't drop them. 'billing_setting.billing_expr',
const fullExpr = billingExprMap[name] || '' JSON.stringify(billingExprMap, null, 2)
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))
}, [
modelPrice, modelPrice,
modelRatio, modelRatio,
cacheRatio, cacheRatio,
...@@ -389,127 +410,105 @@ export const ModelRatioVisualEditor = memo( ...@@ -389,127 +410,105 @@ export const ModelRatioVisualEditor = memo(
audioCompletionRatio, audioCompletionRatio,
billingMode, billingMode,
billingExpr, billingExpr,
]) onChange,
]
const modeCounts = useMemo( )
() =>
models.reduce( const columns = useMemo(
(acc, model) => { () =>
const mode = buildModelRatioColumns({
model.billingMode === 'per-request' || onDelete: handleDelete,
model.billingMode === 'tiered_expr' onEdit: handleEdit,
? model.billingMode t,
: 'per-token' }),
acc[mode] += 1 [handleEdit, handleDelete, t]
return acc )
},
{ const table = useReactTable({
'per-token': 0, data: models,
'per-request': 0, columns,
tiered_expr: 0, state: {
} as Record<'per-token' | 'per-request' | 'tiered_expr', number> sorting,
), columnFilters,
[models] globalFilter,
) columnVisibility,
pagination,
const handleEdit = useCallback( rowSelection,
(model: ModelRow) => { },
setEditData({ enableRowSelection: true,
name: model.name, onSortingChange: setSorting,
price: model.price, onColumnFiltersChange: setColumnFilters,
ratio: model.ratio, onGlobalFilterChange: handleGlobalFilterChange,
cacheRatio: model.cacheRatio, onColumnVisibilityChange: setColumnVisibility,
createCacheRatio: model.createCacheRatio, onPaginationChange: setPagination,
completionRatio: model.completionRatio, onRowSelectionChange: setRowSelection,
imageRatio: model.imageRatio, autoResetPageIndex: false,
audioRatio: model.audioRatio, getCoreRowModel: getCoreRowModel(),
audioCompletionRatio: model.audioCompletionRatio, getFilteredRowModel: getFilteredRowModel(),
billingMode: getSortedRowModel: getSortedRowModel(),
model.billingMode === 'tiered_expr' getPaginationRowModel: getPaginationRowModel(),
? 'tiered_expr' getFacetedRowModel: getFacetedRowModel(),
: model.price && model.price !== '' getFacetedUniqueValues: getFacetedUniqueValues(),
? 'per-request' globalFilterFn: (row, _columnId, filterValue) => {
: 'per-token', const searchValue = String(filterValue).toLowerCase()
billingExpr: model.billingExpr, return row.original.name.toLowerCase().includes(searchValue)
requestRuleExpr: model.requestRuleExpr, },
}) })
setEditorOpen(true)
if (isMobile) setSheetOpen(true) const persistPricingData = useCallback(
}, (data: ModelRatioData, targetNames: string[] = [data.name]) => {
[isMobile] const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
) fallback: {},
silent: true,
const handleAdd = useCallback(() => { })
setEditData(null) const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
setEditorOpen(true) fallback: {},
if (isMobile) setSheetOpen(true) silent: true,
}, [isMobile]) })
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
const handleCancel = useCallback(() => { fallback: {},
setEditData(null) silent: true,
setEditorOpen(false) })
setSheetOpen(false) const createCacheMap = safeJsonParse<Record<string, number>>(
}, []) createCacheRatio,
{ fallback: {}, silent: true }
const handleGlobalFilterChange = useCallback<OnChangeFn<string>>( )
(updater) => { const completionMap = safeJsonParse<Record<string, number>>(
setGlobalFilter((previous) => { completionRatio,
const next = { fallback: {}, silent: true }
typeof updater === 'function' ? updater(previous) : updater )
if (next !== previous) { const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
setEditData(null) fallback: {},
setEditorOpen(false) silent: true,
setSheetOpen(false) })
} const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
return next fallback: {},
}) silent: true,
}, })
[] const audioCompletionMap = safeJsonParse<Record<string, number>>(
) audioCompletionRatio,
{ fallback: {}, silent: true }
)
const billingModeMap = safeJsonParse<Record<string, string>>(
billingMode,
{ fallback: {}, silent: true }
)
const billingExprMap = safeJsonParse<Record<string, string>>(
billingExpr,
{ fallback: {}, silent: true }
)
const handleDelete = useCallback( const setIfPresent = (
(name: string) => { target: Record<string, number>,
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, { name: string,
fallback: {}, value: string | undefined
silent: true, ) => {
}) if (!value || value === '') return
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, { const parsed = parseFloat(value)
fallback: {}, if (Number.isFinite(parsed)) target[name] = parsed
silent: true, }
})
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
fallback: {},
silent: true,
})
const createCacheMap = safeJsonParse<Record<string, number>>(
createCacheRatio,
{ fallback: {}, silent: true }
)
const completionMap = safeJsonParse<Record<string, number>>(
completionRatio,
{ fallback: {}, silent: true }
)
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
fallback: {},
silent: true,
})
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
fallback: {},
silent: true,
})
const audioCompletionMap = safeJsonParse<Record<string, number>>(
audioCompletionRatio,
{ fallback: {}, silent: true }
)
const billingModeMap = safeJsonParse<Record<string, string>>(
billingMode,
{ fallback: {}, silent: true }
)
const billingExprMap = safeJsonParse<Record<string, string>>(
billingExpr,
{ fallback: {}, silent: true }
)
targetNames.forEach((name) => {
delete priceMap[name] delete priceMap[name]
delete ratioMap[name] delete ratioMap[name]
delete cacheMap[name] delete cacheMap[name]
...@@ -521,514 +520,284 @@ export const ModelRatioVisualEditor = memo( ...@@ -521,514 +520,284 @@ export const ModelRatioVisualEditor = memo(
delete billingModeMap[name] delete billingModeMap[name]
delete billingExprMap[name] delete billingExprMap[name]
onChange('ModelPrice', JSON.stringify(priceMap, null, 2)) if (data.billingMode === 'tiered_expr') {
onChange('ModelRatio', JSON.stringify(ratioMap, null, 2)) const combined = combineBillingExpr(
onChange('CacheRatio', JSON.stringify(cacheMap, null, 2)) data.billingExpr || '',
onChange('CreateCacheRatio', JSON.stringify(createCacheMap, null, 2)) data.requestRuleExpr || ''
onChange('CompletionRatio', JSON.stringify(completionMap, null, 2)) )
onChange('ImageRatio', JSON.stringify(imageMap, null, 2)) if (combined) {
onChange('AudioRatio', JSON.stringify(audioMap, null, 2)) billingModeMap[name] = 'tiered_expr'
onChange( billingExprMap[name] = combined
'AudioCompletionRatio',
JSON.stringify(audioCompletionMap, null, 2)
)
onChange(
'billing_setting.billing_mode',
JSON.stringify(billingModeMap, null, 2)
)
onChange(
'billing_setting.billing_expr',
JSON.stringify(billingExprMap, null, 2)
)
},
[
modelPrice,
modelRatio,
cacheRatio,
createCacheRatio,
completionRatio,
imageRatio,
audioRatio,
audioCompletionRatio,
billingMode,
billingExpr,
onChange,
]
)
const columns = useMemo<ColumnDef<ModelRow>[]>(() => {
return [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label={t('Select all')}
className='translate-y-[2px]'
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label={t('Select row')}
className='translate-y-[2px]'
/>
),
enableSorting: false,
enableHiding: false,
meta: { label: t('Select') },
},
{
accessorKey: 'name',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Model name')} />
),
cell: ({ row }) => (
<div className='flex items-center gap-2 font-medium'>
{row.getValue('name')}
{row.original.billingMode === 'tiered_expr' && (
<StatusBadge
label={t('Tiered')}
variant='info'
copyable={false}
/>
)}
{row.original.hasConflict && (
<StatusBadge
label={t('Conflict')}
variant='danger'
copyable={false}
/>
)}
</div>
),
enableHiding: false,
},
{
accessorKey: 'billingMode',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Mode')} />
),
cell: ({ row }) => (
<StatusBadge
label={t(getModeLabel(row.original.billingMode))}
variant={getModeVariant(row.original.billingMode)}
copyable={false}
/>
),
filterFn: (row, id, value) =>
filterBySelectedValues(row.getValue(id), value),
meta: { label: t('Mode') },
},
{
id: 'priceSummary',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Price summary')} />
),
cell: ({ row }) => (
<div className='flex min-w-[180px] flex-col gap-1'>
<span className='font-medium'>
{getPriceSummary(row.original, t)}
</span>
<span className='text-muted-foreground max-w-[320px] truncate text-xs'>
{getPriceDetail(row.original, t)}
</span>
</div>
),
sortingFn: (rowA, rowB) =>
getPriceSummary(rowA.original, t).localeCompare(
getPriceSummary(rowB.original, t)
),
meta: { label: t('Price summary') },
},
{
id: 'actions',
cell: ({ row }) => (
<div className='flex justify-end gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => handleEdit(row.original)}
>
<Pencil />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => handleDelete(row.original.name)}
>
<Trash2 />
</Button>
</div>
),
enableHiding: false,
},
]
}, [handleEdit, handleDelete, t])
const table = useReactTable({
data: models,
columns,
state: {
sorting,
columnFilters,
globalFilter,
columnVisibility,
pagination,
rowSelection,
},
enableRowSelection: true,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: handleGlobalFilterChange,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
autoResetPageIndex: false,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
globalFilterFn: (row, _columnId, filterValue) => {
const searchValue = String(filterValue).toLowerCase()
return row.original.name.toLowerCase().includes(searchValue)
},
})
const persistPricingData = useCallback(
(data: ModelRatioData, targetNames: string[] = [data.name]) => {
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
fallback: {},
silent: true,
})
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
fallback: {},
silent: true,
})
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
fallback: {},
silent: true,
})
const createCacheMap = safeJsonParse<Record<string, number>>(
createCacheRatio,
{ fallback: {}, silent: true }
)
const completionMap = safeJsonParse<Record<string, number>>(
completionRatio,
{ fallback: {}, silent: true }
)
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
fallback: {},
silent: true,
})
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
fallback: {},
silent: true,
})
const audioCompletionMap = safeJsonParse<Record<string, number>>(
audioCompletionRatio,
{ fallback: {}, silent: true }
)
const billingModeMap = safeJsonParse<Record<string, string>>(
billingMode,
{ fallback: {}, silent: true }
)
const billingExprMap = safeJsonParse<Record<string, string>>(
billingExpr,
{ fallback: {}, silent: true }
)
const setIfPresent = (
target: Record<string, number>,
name: string,
value: string | undefined
) => {
if (!value || value === '') return
const parsed = parseFloat(value)
if (Number.isFinite(parsed)) target[name] = parsed
}
targetNames.forEach((name) => {
delete priceMap[name]
delete ratioMap[name]
delete cacheMap[name]
delete createCacheMap[name]
delete completionMap[name]
delete imageMap[name]
delete audioMap[name]
delete audioCompletionMap[name]
delete billingModeMap[name]
delete billingExprMap[name]
if (data.billingMode === 'tiered_expr') {
const combined = combineBillingExpr(
data.billingExpr || '',
data.requestRuleExpr || ''
)
if (combined) {
billingModeMap[name] = 'tiered_expr'
billingExprMap[name] = combined
}
// Always serialize ratio/price values for tiered_expr models so they
// serve as fallback during multi-instance sync delays. The backend's
// ModelPriceHelper checks billing_mode first, so these values are
// only consulted when billing_setting hasn't propagated yet.
setIfPresent(priceMap, name, data.price)
setIfPresent(ratioMap, name, data.ratio)
setIfPresent(cacheMap, name, data.cacheRatio)
setIfPresent(createCacheMap, name, data.createCacheRatio)
setIfPresent(completionMap, name, data.completionRatio)
setIfPresent(imageMap, name, data.imageRatio)
setIfPresent(audioMap, name, data.audioRatio)
setIfPresent(audioCompletionMap, name, data.audioCompletionRatio)
} else if (data.price && data.price !== '') {
setIfPresent(priceMap, name, data.price)
} else {
setIfPresent(ratioMap, name, data.ratio)
setIfPresent(cacheMap, name, data.cacheRatio)
setIfPresent(createCacheMap, name, data.createCacheRatio)
setIfPresent(completionMap, name, data.completionRatio)
setIfPresent(imageMap, name, data.imageRatio)
setIfPresent(audioMap, name, data.audioRatio)
setIfPresent(audioCompletionMap, name, data.audioCompletionRatio)
} }
}) // Always serialize ratio/price values for tiered_expr models so they
// serve as fallback during multi-instance sync delays. The backend's
// ModelPriceHelper checks billing_mode first, so these values are
// only consulted when billing_setting hasn't propagated yet.
setIfPresent(priceMap, name, data.price)
setIfPresent(ratioMap, name, data.ratio)
setIfPresent(cacheMap, name, data.cacheRatio)
setIfPresent(createCacheMap, name, data.createCacheRatio)
setIfPresent(completionMap, name, data.completionRatio)
setIfPresent(imageMap, name, data.imageRatio)
setIfPresent(audioMap, name, data.audioRatio)
setIfPresent(audioCompletionMap, name, data.audioCompletionRatio)
} else if (data.price && data.price !== '') {
setIfPresent(priceMap, name, data.price)
} else {
setIfPresent(ratioMap, name, data.ratio)
setIfPresent(cacheMap, name, data.cacheRatio)
setIfPresent(createCacheMap, name, data.createCacheRatio)
setIfPresent(completionMap, name, data.completionRatio)
setIfPresent(imageMap, name, data.imageRatio)
setIfPresent(audioMap, name, data.audioRatio)
setIfPresent(audioCompletionMap, name, data.audioCompletionRatio)
}
})
onChange('ModelPrice', JSON.stringify(priceMap, null, 2)) onChange('ModelPrice', JSON.stringify(priceMap, null, 2))
onChange('ModelRatio', JSON.stringify(ratioMap, null, 2)) onChange('ModelRatio', JSON.stringify(ratioMap, null, 2))
onChange('CacheRatio', JSON.stringify(cacheMap, null, 2)) onChange('CacheRatio', JSON.stringify(cacheMap, null, 2))
onChange('CreateCacheRatio', JSON.stringify(createCacheMap, null, 2)) onChange('CreateCacheRatio', JSON.stringify(createCacheMap, null, 2))
onChange('CompletionRatio', JSON.stringify(completionMap, null, 2)) onChange('CompletionRatio', JSON.stringify(completionMap, null, 2))
onChange('ImageRatio', JSON.stringify(imageMap, null, 2)) onChange('ImageRatio', JSON.stringify(imageMap, null, 2))
onChange('AudioRatio', JSON.stringify(audioMap, null, 2)) onChange('AudioRatio', JSON.stringify(audioMap, null, 2))
onChange( onChange(
'AudioCompletionRatio', 'AudioCompletionRatio',
JSON.stringify(audioCompletionMap, null, 2) JSON.stringify(audioCompletionMap, null, 2)
) )
onChange( onChange(
'billing_setting.billing_mode', 'billing_setting.billing_mode',
JSON.stringify(billingModeMap, null, 2) JSON.stringify(billingModeMap, null, 2)
) )
onChange( onChange(
'billing_setting.billing_expr', 'billing_setting.billing_expr',
JSON.stringify(billingExprMap, null, 2) JSON.stringify(billingExprMap, null, 2)
) )
}, },
[ [
modelPrice, modelPrice,
modelRatio, modelRatio,
cacheRatio, cacheRatio,
createCacheRatio, createCacheRatio,
completionRatio, completionRatio,
imageRatio, imageRatio,
audioRatio, audioRatio,
audioCompletionRatio, audioCompletionRatio,
billingMode, billingMode,
billingExpr, billingExpr,
onChange, onChange,
] ]
)
const handleBatchCopy = useCallback(() => {
if (!editData) {
toast.error(t('Open a source model first'))
return
}
const targetNames = table
.getFilteredSelectedRowModel()
.rows.map((row) => row.original.name)
if (targetNames.length === 0) {
toast.error(t('Select at least one target model'))
return
}
persistPricingData(editData, targetNames)
table.resetRowSelection()
toast.success(
t('Applied {{name}} pricing to {{count}} models', {
name: editData.name,
count: targetNames.length,
})
) )
}, [editData, persistPricingData, t, table])
const handleSave = useCallback(
(data: ModelRatioData) => { useImperativeHandle(
ref,
() => ({
commitOpenEditor: async () => {
if (!editorOpen || !editorPanelRef.current) return true
const data = await editorPanelRef.current.commitDraft()
if (!data) return false
persistPricingData(data) persistPricingData(data)
setEditData(data) setEditData(data)
setEditorOpen(true) return true
toast.success(
t(
'Pricing changes saved to draft. Click "Save model prices" to apply.'
)
)
}, },
[persistPricingData, t] }),
) [editorOpen, persistPricingData]
)
const handleBatchCopy = useCallback(() => {
if (!editData) { return (
toast.error(t('Open a source model first')) <div className='flex flex-col gap-4'>
return <div className='grid h-[clamp(720px,calc(100vh-12rem),900px)] min-h-0 gap-4 md:grid-cols-[minmax(300px,0.72fr)_minmax(520px,1.28fr)] xl:grid-cols-[minmax(320px,0.68fr)_minmax(640px,1.32fr)]'>
} <div className='flex min-h-0 min-w-0 flex-col gap-3'>
<DataTableToolbar
const targetNames = table table={table}
.getFilteredSelectedRowModel() searchPlaceholder={t('Search models...')}
.rows.map((row) => row.original.name) filters={[
{
if (targetNames.length === 0) { columnId: 'billingMode',
toast.error(t('Select at least one target model')) title: t('Mode'),
return options: [
} {
label: 'Per-token',
value: 'per-token',
count: modeCounts['per-token'],
},
{
label: 'Per-request',
value: 'per-request',
count: modeCounts['per-request'],
},
{
label: 'Expression',
value: 'tiered_expr',
count: modeCounts.tiered_expr,
},
],
},
]}
preActions={
<Button onClick={handleAdd}>
<Plus data-icon='inline-start' />
{t('Add model')}
</Button>
}
/>
persistPricingData(editData, targetNames) {table.getRowModel().rows.length === 0 ? (
table.resetRowSelection() <div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
toast.success( {table.getState().globalFilter
t('Applied {{name}} pricing to {{count}} models', { ? t('No models match your search')
name: editData.name, : t('No models configured. Use Add model to get started.')}
count: targetNames.length, </div>
}) ) : (
) <div className='min-h-0 flex-1 overflow-auto rounded-md border'>
}, [editData, persistPricingData, t, table]) <table className='w-full caption-bottom text-sm tabular-nums'>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className='border-b'>
{headerGroup.headers.map((header) => (
<th
key={header.id}
colSpan={header.colSpan}
className={cn(
'bg-background text-foreground sticky top-0 z-10 h-10 px-2 text-left align-middle text-sm font-medium whitespace-nowrap',
header.column.id === 'actions' &&
'right-0 z-30 w-24 min-w-24 shadow-[-10px_0_14px_-14px_hsl(var(--foreground))]'
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr
key={row.id}
data-state={row.getIsSelected() ? 'selected' : undefined}
className={
editData?.name === row.original.name
? 'bg-muted/45 hover:bg-muted/50 data-[state=selected]:bg-muted group border-b transition-colors'
: 'hover:bg-muted/50 data-[state=selected]:bg-muted group border-b transition-colors'
}
onClick={(event) => {
const target = event.target as HTMLElement
if (target.closest('button, [role="checkbox"]')) return
handleEdit(row.original)
}}
>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className={cn(
'p-2 align-middle text-sm whitespace-nowrap',
cell.column.id === 'actions' &&
(editData?.name === row.original.name
? 'bg-muted/45 group-hover:bg-muted/50 group-data-[state=selected]:bg-muted sticky right-0 z-10 w-24 min-w-24 shadow-[-10px_0_14px_-14px_hsl(var(--foreground))]'
: 'bg-background group-hover:bg-muted/50 group-data-[state=selected]:bg-muted sticky right-0 z-10 w-24 min-w-24 shadow-[-10px_0_14px_-14px_hsl(var(--foreground))]')
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
const selectedTargetCount = table.getFilteredSelectedRowModel().rows.length {table.getRowModel().rows.length > 0 && (
<DataTablePagination table={table} />
)}
</div>
return ( <div className='hidden min-h-0 min-w-0 md:block'>
<div className='flex flex-col gap-4'> {editorOpen ? (
<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)]'> <ModelPricingEditorPanel
<div className='flex min-w-0 flex-col gap-4'> ref={editorPanelRef}
<DataTableToolbar editData={editData}
table={table} onSave={onSave}
searchPlaceholder={t('Search models...')} isSaving={isSaving}
filters={[ className='h-full min-h-0'
{
columnId: 'billingMode',
title: t('Mode'),
options: [
{
label: 'Per-token',
value: 'per-token',
count: modeCounts['per-token'],
},
{
label: 'Per-request',
value: 'per-request',
count: modeCounts['per-request'],
},
{
label: 'Expression',
value: 'tiered_expr',
count: modeCounts.tiered_expr,
},
],
},
]}
preActions={
<Button onClick={handleAdd}>
<Plus data-icon='inline-start' />
{t('Add model')}
</Button>
}
/> />
) : (
{table.getRowModel().rows.length === 0 ? ( <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-muted-foreground rounded-lg border border-dashed p-8 text-center'> <div className='text-foreground text-base font-medium'>
{table.getState().globalFilter {t('Select a model to edit pricing')}
? t('No models match your search')
: t('No models configured. Use Add model to get started.')}
</div> </div>
) : ( <p className='max-w-sm text-sm'>
<div className='overflow-hidden rounded-md border'> {t(
<Table> 'Use the full-width table to scan prices, then select a row to edit it here.'
<TableHeader> )}
{table.getHeaderGroups().map((headerGroup) => ( </p>
<TableRow key={headerGroup.id}> <Button variant='outline' onClick={handleAdd}>
{headerGroup.headers.map((header) => ( <Plus data-icon='inline-start' />
<TableHead key={header.id} colSpan={header.colSpan}> {t('Add model')}
{header.isPlaceholder </Button>
? null </div>
: flexRender( )}
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() ? 'selected' : undefined
}
className={
editData?.name === row.original.name
? 'bg-muted/45'
: undefined
}
onClick={(event) => {
const target = event.target as HTMLElement
if (target.closest('button, [role="checkbox"]'))
return
handleEdit(row.original)
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{table.getRowModel().rows.length > 0 && (
<DataTablePagination table={table} />
)}
</div>
<div className='hidden min-w-0 md:block'>
{editorOpen ? (
<ModelPricingEditorPanel
onSave={handleSave}
onCancel={handleCancel}
editData={editData}
selectedTargetCount={selectedTargetCount}
className='sticky top-4 h-[calc(100vh-8rem)] min-h-[620px]'
/>
) : (
<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='text-foreground text-base font-medium'>
{t('Select a model to edit pricing')}
</div>
<p className='max-w-sm text-sm'>
{t(
'Use the full-width table to scan prices, then select a row to edit it here.'
)}
</p>
<Button variant='outline' onClick={handleAdd}>
<Plus data-icon='inline-start' />
{t('Add model')}
</Button>
</div>
)}
</div>
</div> </div>
</div>
<DataTableBulkActions table={table} entityName={t('model')}> <DataTableBulkActions table={table} entityName={t('model')}>
<Button size='sm' disabled={!editData} onClick={handleBatchCopy}> <Button size='sm' disabled={!editData} onClick={handleBatchCopy}>
<Copy data-icon='inline-start' /> <Copy data-icon='inline-start' />
{editData {editData
? t('Copy {{name}} pricing', { name: editData.name }) ? t('Copy {{name}} pricing', { name: editData.name })
: t('Open a source model first')} : t('Open a source model first')}
</Button> </Button>
</DataTableBulkActions> </DataTableBulkActions>
{isMobile && (
<ModelPricingSheet
ref={editorPanelRef}
open={sheetOpen}
onOpenChange={setSheetOpen}
editData={editData}
onSave={onSave}
isSaving={isSaving}
/>
)}
</div>
)
})
{isMobile && ( export const ModelRatioVisualEditor = memo(
<ModelPricingSheet ModelRatioVisualEditorComponent,
open={sheetOpen}
onOpenChange={setSheetOpen}
onSave={handleSave}
onCancel={handleCancel}
editData={editData}
selectedTargetCount={selectedTargetCount}
/>
)}
</div>
)
},
// 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( ...@@ -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
) )
} }
) )
...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -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' ...@@ -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' ...@@ -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,
if (!result.valid) { fallback = 'Invalid JSON'
ctx.addIssue({ ) {
code: z.ZodIssueCode.custom, if (!error) return t(fallback)
message: result.message || 'Invalid JSON',
}) if (error.type === 'required') return t('Value is required')
} if (error.type === 'structure') {
}), return t(
TopupGroupRatio: z.string().superRefine((value, ctx) => { fallback === 'Invalid JSON' ? 'JSON structure is invalid' : fallback
const result = validateJsonString(value) )
if (!result.valid) { }
ctx.addIssue({
code: z.ZodIssueCode.custom, const parts = [
message: result.message || 'Invalid JSON', error.line && error.column
}) ? t('JSON is invalid at line {{line}}, column {{column}}.', {
} line: error.line,
}), column: error.column,
UserUsableGroups: z.string().superRefine((value, ctx) => { })
const result = validateJsonString(value) : error.position !== undefined
if (!result.valid) { ? t('JSON is invalid at position {{position}}.', {
ctx.addIssue({ position: error.position,
code: z.ZodIssueCode.custom, })
message: result.message || 'Invalid JSON', : t('JSON is invalid. Please check the syntax.'),
]
if (error.missingCommaLine) {
parts.push(
t('Check line {{line}} for a missing comma.', {
line: error.missingCommaLine,
}) })
} )
}), }
GroupGroupRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value) 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),
}) })
} }
}), })
AutoGroups: z.string().superRefine((value, ctx) => { }
const result = validateJsonString(value, {
const createModelSchema = (t: Translate) =>
z.object({
ModelPrice: createJsonStringField(t),
ModelRatio: createJsonStringField(t),
CacheRatio: createJsonStringField(t),
CreateCacheRatio: createJsonStringField(t),
CompletionRatio: createJsonStringField(t),
ImageRatio: createJsonStringField(t),
AudioRatio: createJsonStringField(t),
AudioCompletionRatio: createJsonStringField(t),
ExposeRatioEnabled: z.boolean(),
BillingMode: createJsonStringField(t),
BillingExpr: createJsonStringField(t),
})
const createGroupSchema = (t: Translate) =>
z.object({
GroupRatio: createJsonStringField(t),
TopupGroupRatio: createJsonStringField(t),
UserUsableGroups: createJsonStringField(t),
GroupGroupRatio: createJsonStringField(t),
AutoGroups: createJsonStringField(t, {
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) { DefaultUseAutoGroup: z.boolean(),
ctx.addIssue({ GroupSpecialUsableGroup: createJsonStringField(t),
code: z.ZodIssueCode.custom, })
message: result.message || 'Invalid JSON array',
})
}
}),
DefaultUseAutoGroup: z.boolean(),
GroupSpecialUsableGroup: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
})
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({ ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -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({ ...@@ -499,25 +440,35 @@ export function RatioSettingsCard({
) )
} }
const renderTabSwitcher = () => (
<TabsList className={`grid w-fit max-w-full ${tabsGridClass}`}>
{visibleTabs.map((tab) => (
<TabsTrigger key={tab} value={tab}>
{t(tabLabels[tab])}
</TabsTrigger>
))}
</TabsList>
)
return ( return (
<SettingsSection title={t(titleKey)}> <>
{visibleTabs.length === 1 ? ( {visibleTabs.length === 1 ? (
renderTabContent(defaultTab) <SettingsSection title={t(titleKey)}>
{renderTabContent(defaultTab)}
</SettingsSection>
) : ( ) : (
<Tabs defaultValue={defaultTab} className='space-y-6'> <Tabs defaultValue={defaultTab} className='space-y-6'>
<TabsList className={`grid w-full ${tabsGridClass}`}> <SettingsPageTitleStatusPortal>
{renderTabSwitcher()}
</SettingsPageTitleStatusPortal>
<SettingsSection title={t(titleKey)}>
{visibleTabs.map((tab) => ( {visibleTabs.map((tab) => (
<TabsTrigger key={tab} value={tab}> <TabsContent key={tab} value={tab}>
{t(tabLabels[tab])} {renderTabContent(tab)}
</TabsTrigger> </TabsContent>
))} ))}
</TabsList> </SettingsSection>
{visibleTabs.map((tab) => (
<TabsContent key={tab} value={tab}>
{renderTabContent(tab)}
</TabsContent>
))}
</Tabs> </Tabs>
)} )}
...@@ -533,6 +484,6 @@ export function RatioSettingsCard({ ...@@ -533,6 +484,6 @@ export function RatioSettingsCard({
handleConfirm={handleConfirmReset} handleConfirm={handleConfirmReset}
confirmText={t('Reset')} confirmText={t('Reset')}
/> />
</SettingsSection> </>
) )
} }
...@@ -40,6 +40,7 @@ import { ...@@ -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) { ...@@ -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,35 +1769,37 @@ export const TieredPricingEditor = memo(function TieredPricingEditor({ ...@@ -1770,35 +1769,37 @@ 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'>
<Select <FieldLabel>{t('Editor mode')}</FieldLabel>
items={[ <Select
{ value: 'visual', label: t('Visual editor') }, items={[
{ value: 'raw', label: t('Expression editor') }, { value: 'visual', label: t('Visual editor') },
]} { value: 'raw', label: t('Expression editor') },
value={editorMode} ]}
onValueChange={(value) => handleModeChange(value as EditorMode)} value={editorMode}
> onValueChange={(value) => handleModeChange(value as EditorMode)}
<SelectTrigger className='w-44' size='sm'> >
<SelectValue /> <SelectTrigger className='w-full sm:w-56' size='sm'>
</SelectTrigger> <SelectValue />
<SelectContent alignItemWithTrigger={false}> </SelectTrigger>
<SelectGroup> <SelectContent alignItemWithTrigger={false}>
<SelectItem value='visual'>{t('Visual editor')}</SelectItem> <SelectGroup>
<SelectItem value='raw'>{t('Expression editor')}</SelectItem> <SelectItem value='visual'>{t('Visual editor')}</SelectItem>
</SelectGroup> <SelectItem value='raw'>{t('Expression editor')}</SelectItem>
</SelectContent> </SelectGroup>
</Select> </SelectContent>
</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'> <PresetSection applyPreset={applyPreset} />
<div className='flex-1'>
<PresetSection applyPreset={applyPreset} />
</div>
{editorMode === 'raw' && <LlmPromptHelper modelName={modelName} />}
</div>
<div className='bg-muted/30 space-y-3 rounded-md border p-3'> <div className='bg-muted/30 space-y-3 rounded-md border p-3'>
{editorMode === 'visual' ? ( {editorMode === 'visual' ? (
......
...@@ -49,6 +49,14 @@ type JsonValidationOptions = { ...@@ -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( ...@@ -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 { ...@@ -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( ...@@ -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( ...@@ -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( ...@@ -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),
} }
} }
} }
...@@ -681,6 +681,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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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",
......
...@@ -681,6 +681,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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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",
......
...@@ -681,6 +681,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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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": "変数",
......
...@@ -681,6 +681,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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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": "переменная",
......
...@@ -681,6 +681,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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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",
......
...@@ -681,6 +681,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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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": "变量",
......
...@@ -88,6 +88,7 @@ export const STATIC_I18N_KEYS = [ ...@@ -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',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment