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

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