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>
)
}
File diff suppressed because it is too large Load Diff
@@ -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( </div>
'JSON map of model → USD cost per request. Takes precedence over ratio based billing.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='ModelRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Model ratio')}</FormLabel>
<FormControl>
<Textarea rows={8} {...field} />
</FormControl>
<FormDescription>
{t(
'JSON map of model → multiplier applied to quota billing.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='CacheRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Prompt cache ratio')}</FormLabel>
<FormControl>
<Textarea rows={8} {...field} />
</FormControl>
<FormDescription>
{t('Optional ratio used when upstream cache hits occur.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='CreateCacheRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Create cache ratio')}</FormLabel>
<FormControl>
<Textarea rows={8} {...field} />
</FormControl>
<FormDescription>
{t(
'Ratio applied when creating cache entries for supported models.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='CompletionRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Completion ratio')}</FormLabel>
<FormControl>
<Textarea rows={8} {...field} />
</FormControl>
<FormDescription>
{t(
'Applies to custom completion endpoints. JSON map of model → ratio.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='ImageRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Image ratio')}</FormLabel>
<FormControl>
<Textarea rows={6} {...field} />
</FormControl>
<FormDescription>
{t(
'Configure per-model ratio for image inputs or outputs.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='AudioRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Audio ratio')}</FormLabel>
<FormControl>
<Textarea rows={6} {...field} />
</FormControl>
<FormDescription>
{t(
'Ratio applied to audio inputs where supported by the upstream model.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='AudioCompletionRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Audio completion ratio')}</FormLabel>
<FormControl>
<Textarea rows={6} {...field} />
</FormControl>
<FormDescription>
{t(
'Ratio applied to audio completions for streaming models.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
@@ -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,
},
]
}
File diff suppressed because it is too large Load Diff
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import * as z from 'zod' import * as z from 'zod'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
@@ -26,6 +26,7 @@ import { toast } from 'sonner'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { resetModelRatios } from '../api' import { resetModelRatios } from '../api'
import { SettingsPageTitleStatusPortal } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section' import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option' import { useUpdateOption } from '../hooks/use-update-option'
import { GroupRatioForm } from './group-ratio-form' import { GroupRatioForm } from './group-ratio-form'
@@ -34,169 +35,99 @@ import { ToolPriceSettings } from './tool-price-settings'
import { UpstreamRatioSync } from './upstream-ratio-sync' import { UpstreamRatioSync } from './upstream-ratio-sync'
import { import {
formatJsonForTextarea, formatJsonForTextarea,
type JsonValidationError,
normalizeJsonString, normalizeJsonString,
validateJsonString, validateJsonString,
} from './utils' } from './utils'
const modelSchema = z.object({ type Translate = (key: string, options?: Record<string, unknown>) => string
ModelPrice: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
ModelRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
CacheRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
CreateCacheRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
CompletionRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
ImageRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
AudioRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
AudioCompletionRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
ExposeRatioEnabled: z.boolean(),
BillingMode: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
BillingExpr: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
})
const groupSchema = z.object({ function formatJsonValidationError(
GroupRatio: z.string().superRefine((value, ctx) => { t: Translate,
const result = validateJsonString(value) error?: JsonValidationError,
fallback = 'Invalid JSON'
) {
if (!error) return t(fallback)
if (error.type === 'required') return t('Value is required')
if (error.type === 'structure') {
return t(
fallback === 'Invalid JSON' ? 'JSON structure is invalid' : fallback
)
}
const parts = [
error.line && error.column
? t('JSON is invalid at line {{line}}, column {{column}}.', {
line: error.line,
column: error.column,
})
: error.position !== undefined
? t('JSON is invalid at position {{position}}.', {
position: error.position,
})
: t('JSON is invalid. Please check the syntax.'),
]
if (error.missingCommaLine) {
parts.push(
t('Check line {{line}} for a missing comma.', {
line: error.missingCommaLine,
})
)
}
return parts.join(' ')
}
function createJsonStringField(
t: Translate,
options?: Parameters<typeof validateJsonString>[1]
) {
return z.string().superRefine((value, ctx) => {
const result = validateJsonString(value, options)
if (!result.valid) { if (!result.valid) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON', message: formatJsonValidationError(t, result.error, result.message),
}) })
} }
}), })
TopupGroupRatio: z.string().superRefine((value, ctx) => { }
const result = validateJsonString(value)
if (!result.valid) { const createModelSchema = (t: Translate) =>
ctx.addIssue({ z.object({
code: z.ZodIssueCode.custom, ModelPrice: createJsonStringField(t),
message: result.message || 'Invalid JSON', ModelRatio: createJsonStringField(t),
}) CacheRatio: createJsonStringField(t),
} CreateCacheRatio: createJsonStringField(t),
}), CompletionRatio: createJsonStringField(t),
UserUsableGroups: z.string().superRefine((value, ctx) => { ImageRatio: createJsonStringField(t),
const result = validateJsonString(value) AudioRatio: createJsonStringField(t),
if (!result.valid) { AudioCompletionRatio: createJsonStringField(t),
ctx.addIssue({ ExposeRatioEnabled: z.boolean(),
code: z.ZodIssueCode.custom, BillingMode: createJsonStringField(t),
message: result.message || 'Invalid JSON', BillingExpr: createJsonStringField(t),
}) })
}
}), const createGroupSchema = (t: Translate) =>
GroupGroupRatio: z.string().superRefine((value, ctx) => { z.object({
const result = validateJsonString(value) GroupRatio: createJsonStringField(t),
if (!result.valid) { TopupGroupRatio: createJsonStringField(t),
ctx.addIssue({ UserUsableGroups: createJsonStringField(t),
code: z.ZodIssueCode.custom, GroupGroupRatio: createJsonStringField(t),
message: result.message || 'Invalid JSON', AutoGroups: createJsonStringField(t, {
})
}
}),
AutoGroups: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value, {
predicate: (parsed) => predicate: (parsed) =>
Array.isArray(parsed) && Array.isArray(parsed) &&
parsed.every((item) => typeof item === 'string'), parsed.every((item) => typeof item === 'string'),
predicateMessage: 'Expected a JSON array of group identifiers', predicateMessage: 'Expected a JSON array of group identifiers',
}) }),
if (!result.valid) { DefaultUseAutoGroup: z.boolean(),
ctx.addIssue({ GroupSpecialUsableGroup: createJsonStringField(t),
code: z.ZodIssueCode.custom, })
message: result.message || 'Invalid JSON array',
})
}
}),
DefaultUseAutoGroup: z.boolean(),
GroupSpecialUsableGroup: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
})
type ModelFormValues = z.infer<typeof modelSchema> type ModelFormValues = z.infer<ReturnType<typeof createModelSchema>>
type GroupFormValues = z.infer<typeof groupSchema> type GroupFormValues = z.infer<ReturnType<typeof createGroupSchema>>
type RatioTabId = 'models' | 'groups' | 'tool-prices' | 'upstream-sync' type RatioTabId = 'models' | 'groups' | 'tool-prices' | 'upstream-sync'
type RatioSettingsCardProps = { type RatioSettingsCardProps = {
@@ -250,6 +181,9 @@ export function RatioSettingsCard({
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({
) )
} }
const renderTabSwitcher = () => (
<TabsList className={`grid w-fit max-w-full ${tabsGridClass}`}>
{visibleTabs.map((tab) => (
<TabsTrigger key={tab} value={tab}>
{t(tabLabels[tab])}
</TabsTrigger>
))}
</TabsList>
)
return ( return (
<SettingsSection title={t(titleKey)}> <>
{visibleTabs.length === 1 ? ( {visibleTabs.length === 1 ? (
renderTabContent(defaultTab) <SettingsSection title={t(titleKey)}>
{renderTabContent(defaultTab)}
</SettingsSection>
) : ( ) : (
<Tabs defaultValue={defaultTab} className='space-y-6'> <Tabs defaultValue={defaultTab} className='space-y-6'>
<TabsList className={`grid w-full ${tabsGridClass}`}> <SettingsPageTitleStatusPortal>
{visibleTabs.map((tab) => ( {renderTabSwitcher()}
<TabsTrigger key={tab} value={tab}> </SettingsPageTitleStatusPortal>
{t(tabLabels[tab])}
</TabsTrigger>
))}
</TabsList>
{visibleTabs.map((tab) => ( <SettingsSection title={t(titleKey)}>
<TabsContent key={tab} value={tab}> {visibleTabs.map((tab) => (
{renderTabContent(tab)} <TabsContent key={tab} value={tab}>
</TabsContent> {renderTabContent(tab)}
))} </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,35 +1769,37 @@ export const TieredPricingEditor = memo(function TieredPricingEditor({
}, []) }, [])
return ( return (
<div className='space-y-4'> <div className='space-y-5'>
<div className='flex items-center justify-between gap-2'> <div className='grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-end'>
<Label className='text-xs'>{t('Editor mode')}</Label> <Field className='gap-2'>
<Select <FieldLabel>{t('Editor mode')}</FieldLabel>
items={[ <Select
{ value: 'visual', label: t('Visual editor') }, items={[
{ value: 'raw', label: t('Expression editor') }, { value: 'visual', label: t('Visual editor') },
]} { value: 'raw', label: t('Expression editor') },
value={editorMode} ]}
onValueChange={(value) => handleModeChange(value as EditorMode)} value={editorMode}
> onValueChange={(value) => handleModeChange(value as EditorMode)}
<SelectTrigger className='w-44' size='sm'> >
<SelectValue /> <SelectTrigger className='w-full sm:w-56' size='sm'>
</SelectTrigger> <SelectValue />
<SelectContent alignItemWithTrigger={false}> </SelectTrigger>
<SelectGroup> <SelectContent alignItemWithTrigger={false}>
<SelectItem value='visual'>{t('Visual editor')}</SelectItem> <SelectGroup>
<SelectItem value='raw'>{t('Expression editor')}</SelectItem> <SelectItem value='visual'>{t('Visual editor')}</SelectItem>
</SelectGroup> <SelectItem value='raw'>{t('Expression editor')}</SelectItem>
</SelectContent> </SelectGroup>
</Select> </SelectContent>
</Select>
</Field>
{editorMode === 'raw' && (
<div className='sm:pb-0.5'>
<LlmPromptHelper modelName={modelName} />
</div>
)}
</div> </div>
<div className='flex flex-wrap items-start gap-x-4 gap-y-1'> <PresetSection applyPreset={applyPreset} />
<div className='flex-1'>
<PresetSection applyPreset={applyPreset} />
</div>
{editorMode === 'raw' && <LlmPromptHelper modelName={modelName} />}
</div>
<div className='bg-muted/30 space-y-3 rounded-md border p-3'> <div className='bg-muted/30 space-y-3 rounded-md border p-3'>
{editorMode === 'visual' ? ( {editorMode === 'visual' ? (
+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',