perf(model-pricing): improve model pricing editor UX (#5275)
Merge pull request #5275 from QuantumNous/fix/model-pricing-draft-save
This commit is contained in:
+284
@@ -0,0 +1,284 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2023-2026 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ComponentProps,
|
||||||
|
type KeyboardEvent,
|
||||||
|
} from 'react'
|
||||||
|
import { AlertCircle, Braces, CheckCircle2, Code2 } from 'lucide-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
|
||||||
|
export type JsonCodeEditorProps = Omit<ComponentProps<'div'>, 'onChange'> & {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
disabled?: boolean
|
||||||
|
heightClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JsonCodeEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
heightClassName = 'h-56 min-h-56 max-h-56',
|
||||||
|
className,
|
||||||
|
id,
|
||||||
|
'aria-describedby': ariaDescribedBy,
|
||||||
|
'aria-invalid': ariaInvalid,
|
||||||
|
...rootProps
|
||||||
|
}: JsonCodeEditorProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const [scrollTop, setScrollTop] = useState(0)
|
||||||
|
const lineNumbers = useMemo(() => {
|
||||||
|
const count = Math.max(1, value.split('\n').length)
|
||||||
|
return Array.from({ length: count }, (_, index) => index + 1)
|
||||||
|
}, [value])
|
||||||
|
const jsonStatus = useMemo(() => {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return { valid: true, message: t('JSON') }
|
||||||
|
try {
|
||||||
|
JSON.parse(trimmed)
|
||||||
|
return { valid: true, message: t('JSON') }
|
||||||
|
} catch {
|
||||||
|
return { valid: false, message: t('Invalid JSON') }
|
||||||
|
}
|
||||||
|
}, [value, t])
|
||||||
|
|
||||||
|
const formatJson = () => {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
try {
|
||||||
|
onChange(JSON.stringify(JSON.parse(trimmed), null, 2))
|
||||||
|
} catch {
|
||||||
|
// Keep invalid drafts untouched; validation feedback remains visible.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateValueWithSelection = (
|
||||||
|
nextValue: string,
|
||||||
|
selectionStart: number,
|
||||||
|
selectionEnd = selectionStart
|
||||||
|
) => {
|
||||||
|
onChange(nextValue)
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
textareaRef.current?.setSelectionRange(selectionStart, selectionEnd)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLineIndent = (text: string, cursor: number) => {
|
||||||
|
const lineStart = text.lastIndexOf('\n', cursor - 1) + 1
|
||||||
|
return text.slice(lineStart, cursor).match(/^\s*/)?.[0] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditorKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
const target = event.currentTarget
|
||||||
|
const start = target.selectionStart
|
||||||
|
const end = target.selectionEnd
|
||||||
|
const selected = value.slice(start, end)
|
||||||
|
const before = value.slice(0, start)
|
||||||
|
const after = value.slice(end)
|
||||||
|
|
||||||
|
if (event.key === 'Tab') {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
if (start !== end && selected.includes('\n')) {
|
||||||
|
const selectionLineStart = value.lastIndexOf('\n', start - 1) + 1
|
||||||
|
const selectedBlock = value.slice(selectionLineStart, end)
|
||||||
|
const lines = selectedBlock.split('\n')
|
||||||
|
const nextBlock = event.shiftKey
|
||||||
|
? lines
|
||||||
|
.map((line) =>
|
||||||
|
line.startsWith(' ')
|
||||||
|
? line.slice(2)
|
||||||
|
: line.startsWith('\t')
|
||||||
|
? line.slice(1)
|
||||||
|
: line
|
||||||
|
)
|
||||||
|
.join('\n')
|
||||||
|
: lines.map((line) => ` ${line}`).join('\n')
|
||||||
|
const nextValue =
|
||||||
|
value.slice(0, selectionLineStart) + nextBlock + value.slice(end)
|
||||||
|
updateValueWithSelection(
|
||||||
|
nextValue,
|
||||||
|
selectionLineStart,
|
||||||
|
selectionLineStart + nextBlock.length
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.shiftKey) {
|
||||||
|
const lineStart = value.lastIndexOf('\n', start - 1) + 1
|
||||||
|
const removable = value.slice(lineStart, lineStart + 2)
|
||||||
|
if (removable === ' ') {
|
||||||
|
updateValueWithSelection(
|
||||||
|
value.slice(0, lineStart) + value.slice(lineStart + 2),
|
||||||
|
Math.max(lineStart, start - 2),
|
||||||
|
Math.max(lineStart, end - 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateValueWithSelection(`${before} ${after}`, start + 2)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
const indent = getLineIndent(value, start)
|
||||||
|
const previousChar = before.trimEnd().at(-1)
|
||||||
|
const nextChar = after.trimStart().at(0)
|
||||||
|
const shouldNest = previousChar === '{' || previousChar === '['
|
||||||
|
const shouldClose =
|
||||||
|
(previousChar === '{' && nextChar === '}') ||
|
||||||
|
(previousChar === '[' && nextChar === ']')
|
||||||
|
|
||||||
|
if (shouldNest && shouldClose) {
|
||||||
|
const innerIndent = `${indent} `
|
||||||
|
const insert = `\n${innerIndent}\n${indent}`
|
||||||
|
updateValueWithSelection(
|
||||||
|
`${before}${insert}${after}`,
|
||||||
|
start + 1 + innerIndent.length
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextIndent = shouldNest ? `${indent} ` : indent
|
||||||
|
const insert = `\n${nextIndent}`
|
||||||
|
updateValueWithSelection(
|
||||||
|
`${before}${insert}${after}`,
|
||||||
|
start + insert.length
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pairs: Record<string, string> = {
|
||||||
|
'"': '"',
|
||||||
|
'{': '}',
|
||||||
|
'[': ']',
|
||||||
|
}
|
||||||
|
const closingChars = new Set(Object.values(pairs))
|
||||||
|
|
||||||
|
if (closingChars.has(event.key) && value[start] === event.key) {
|
||||||
|
event.preventDefault()
|
||||||
|
textareaRef.current?.setSelectionRange(start + 1, start + 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pairs[event.key]) {
|
||||||
|
event.preventDefault()
|
||||||
|
const close = pairs[event.key]
|
||||||
|
const wrapped = `${event.key}${selected}${close}`
|
||||||
|
updateValueWithSelection(
|
||||||
|
`${before}${wrapped}${after}`,
|
||||||
|
start + 1,
|
||||||
|
start + 1 + selected.length
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Backspace' && start === end && start > 0) {
|
||||||
|
const previousChar = value[start - 1]
|
||||||
|
const nextChar = value[start]
|
||||||
|
if (pairs[previousChar] === nextChar) {
|
||||||
|
event.preventDefault()
|
||||||
|
updateValueWithSelection(
|
||||||
|
value.slice(0, start - 1) + value.slice(start + 1),
|
||||||
|
start - 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'border-input bg-background focus-within:border-ring focus-within:ring-ring/50 overflow-hidden rounded-lg border transition-colors focus-within:ring-3',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...rootProps}
|
||||||
|
>
|
||||||
|
<div className='bg-muted/30 flex h-8 items-center justify-between border-b px-2'>
|
||||||
|
<div className='text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs font-medium'>
|
||||||
|
<Braces className='h-3.5 w-3.5' />
|
||||||
|
<span>{t('JSON')}</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 text-xs',
|
||||||
|
jsonStatus.valid ? 'text-emerald-600' : 'text-destructive'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{jsonStatus.valid ? (
|
||||||
|
<CheckCircle2 className='h-3.5 w-3.5' />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className='h-3.5 w-3.5' />
|
||||||
|
)}
|
||||||
|
{jsonStatus.message}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
className='h-6 px-2 text-xs'
|
||||||
|
onClick={formatJson}
|
||||||
|
disabled={disabled || !jsonStatus.valid || !value.trim()}
|
||||||
|
>
|
||||||
|
<Code2 className='mr-1 h-3.5 w-3.5' />
|
||||||
|
{t('Format JSON')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={cn('relative flex overflow-hidden', heightClassName)}>
|
||||||
|
<div className='bg-muted/20 text-muted-foreground/70 relative w-10 shrink-0 overflow-hidden border-r font-mono text-xs leading-5 select-none'>
|
||||||
|
<div
|
||||||
|
className='px-2 py-2 text-right'
|
||||||
|
style={{ transform: `translateY(-${scrollTop}px)` }}
|
||||||
|
>
|
||||||
|
{lineNumbers.map((lineNumber) => (
|
||||||
|
<div key={lineNumber}>{lineNumber}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
id={id}
|
||||||
|
aria-describedby={ariaDescribedBy}
|
||||||
|
aria-invalid={ariaInvalid}
|
||||||
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
onKeyDown={handleEditorKeyDown}
|
||||||
|
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
|
||||||
|
className={cn(
|
||||||
|
'[field-sizing:fixed] resize-none overflow-auto rounded-none border-0 bg-transparent px-3 py-2 font-mono text-xs leading-5 shadow-none ring-0 outline-none focus-visible:ring-0',
|
||||||
|
heightClassName
|
||||||
|
)}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -70,7 +70,7 @@ function SettingsPageFrame(props: SettingsPageFrameProps) {
|
|||||||
<span className='truncate'>{props.title}</span>
|
<span className='truncate'>{props.title}</span>
|
||||||
<span
|
<span
|
||||||
ref={setTitleStatusContainer}
|
ref={setTitleStatusContainer}
|
||||||
className='inline-flex shrink-0'
|
className='inline-flex min-w-0 shrink-0 items-center'
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</SectionPageLayout.Title>
|
</SectionPageLayout.Title>
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2023-2026 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
import * as z from 'zod'
|
||||||
|
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
|
||||||
|
import { formatPricingNumber } from './pricing-format'
|
||||||
|
|
||||||
|
export const createModelPricingSchema = (t: (key: string) => string) =>
|
||||||
|
z.object({
|
||||||
|
name: z.string().min(1, t('Model name is required')),
|
||||||
|
price: z.string().optional(),
|
||||||
|
ratio: z.string().optional(),
|
||||||
|
cacheRatio: z.string().optional(),
|
||||||
|
createCacheRatio: z.string().optional(),
|
||||||
|
completionRatio: z.string().optional(),
|
||||||
|
imageRatio: z.string().optional(),
|
||||||
|
audioRatio: z.string().optional(),
|
||||||
|
audioCompletionRatio: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ModelPricingFormValues = z.infer<
|
||||||
|
ReturnType<typeof createModelPricingSchema>
|
||||||
|
>
|
||||||
|
|
||||||
|
export type PricingMode = 'per-token' | 'per-request' | 'tiered_expr'
|
||||||
|
|
||||||
|
export type LaneKey =
|
||||||
|
| 'completion'
|
||||||
|
| 'cache'
|
||||||
|
| 'createCache'
|
||||||
|
| 'image'
|
||||||
|
| 'audioInput'
|
||||||
|
| 'audioOutput'
|
||||||
|
|
||||||
|
export type ModelRatioData = {
|
||||||
|
name: string
|
||||||
|
price?: string
|
||||||
|
ratio?: string
|
||||||
|
cacheRatio?: string
|
||||||
|
createCacheRatio?: string
|
||||||
|
completionRatio?: string
|
||||||
|
imageRatio?: string
|
||||||
|
audioRatio?: string
|
||||||
|
audioCompletionRatio?: string
|
||||||
|
billingMode?: PricingMode
|
||||||
|
billingExpr?: string
|
||||||
|
requestRuleExpr?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PreviewRow = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
multiline?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const numericDraftRegex = /^(\d+(\.\d*)?|\.\d*)?$/
|
||||||
|
|
||||||
|
export const EMPTY_LANE_PRICES: Record<LaneKey, string> = {
|
||||||
|
completion: '',
|
||||||
|
cache: '',
|
||||||
|
createCache: '',
|
||||||
|
image: '',
|
||||||
|
audioInput: '',
|
||||||
|
audioOutput: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_LANE_ENABLED: Record<LaneKey, boolean> = {
|
||||||
|
completion: false,
|
||||||
|
cache: false,
|
||||||
|
createCache: false,
|
||||||
|
image: false,
|
||||||
|
audioInput: false,
|
||||||
|
audioOutput: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ratioFieldByLane: Record<LaneKey, keyof ModelPricingFormValues> = {
|
||||||
|
completion: 'completionRatio',
|
||||||
|
cache: 'cacheRatio',
|
||||||
|
createCache: 'createCacheRatio',
|
||||||
|
image: 'imageRatio',
|
||||||
|
audioInput: 'audioRatio',
|
||||||
|
audioOutput: 'audioCompletionRatio',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const laneConfigs: Array<{
|
||||||
|
key: LaneKey
|
||||||
|
titleKey: string
|
||||||
|
descriptionKey: string
|
||||||
|
placeholder: string
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
key: 'completion',
|
||||||
|
titleKey: 'Completion price',
|
||||||
|
descriptionKey: 'Output token price for generated tokens.',
|
||||||
|
placeholder: '15',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cache',
|
||||||
|
titleKey: 'Cache read price',
|
||||||
|
descriptionKey: 'Token price for cache reads.',
|
||||||
|
placeholder: '0.3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'createCache',
|
||||||
|
titleKey: 'Cache write price',
|
||||||
|
descriptionKey: 'Token price for creating cache entries.',
|
||||||
|
placeholder: '3.75',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'image',
|
||||||
|
titleKey: 'Image input price',
|
||||||
|
descriptionKey: 'Token price for image input.',
|
||||||
|
placeholder: '2.5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'audioInput',
|
||||||
|
titleKey: 'Audio input price',
|
||||||
|
descriptionKey: 'Token price for audio input.',
|
||||||
|
placeholder: '3.81',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'audioOutput',
|
||||||
|
titleKey: 'Audio output price',
|
||||||
|
descriptionKey: 'Token price for audio output.',
|
||||||
|
placeholder: '15.11',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function hasValue(value: unknown): boolean {
|
||||||
|
return (
|
||||||
|
value !== '' && value !== null && value !== undefined && value !== false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toNumberOrNull(value: unknown): number | null {
|
||||||
|
if (!hasValue(value) && value !== 0) return null
|
||||||
|
const num = Number(value)
|
||||||
|
return Number.isFinite(num) ? num : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioToBasePrice(ratio: unknown): string {
|
||||||
|
const num = toNumberOrNull(ratio)
|
||||||
|
if (num === null) return ''
|
||||||
|
return formatPricingNumber(num * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveLanePrice(
|
||||||
|
ratio: unknown,
|
||||||
|
denominator: unknown,
|
||||||
|
fallback = ''
|
||||||
|
): string {
|
||||||
|
const ratioNumber = toNumberOrNull(ratio)
|
||||||
|
const denominatorNumber = toNumberOrNull(denominator)
|
||||||
|
if (ratioNumber === null || denominatorNumber === null) return fallback
|
||||||
|
return formatPricingNumber(ratioNumber * denominatorNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitialLaneState(data?: ModelRatioData | null) {
|
||||||
|
if (!data) {
|
||||||
|
return {
|
||||||
|
promptPrice: '',
|
||||||
|
prices: { ...EMPTY_LANE_PRICES },
|
||||||
|
enabled: { ...EMPTY_LANE_ENABLED },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptPrice = ratioToBasePrice(data.ratio)
|
||||||
|
const audioInputPrice = deriveLanePrice(data.audioRatio, promptPrice)
|
||||||
|
const prices: Record<LaneKey, string> = {
|
||||||
|
completion: deriveLanePrice(data.completionRatio, promptPrice),
|
||||||
|
cache: deriveLanePrice(data.cacheRatio, promptPrice),
|
||||||
|
createCache: deriveLanePrice(data.createCacheRatio, promptPrice),
|
||||||
|
image: deriveLanePrice(data.imageRatio, promptPrice),
|
||||||
|
audioInput: audioInputPrice,
|
||||||
|
audioOutput: deriveLanePrice(data.audioCompletionRatio, audioInputPrice),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
promptPrice,
|
||||||
|
prices,
|
||||||
|
enabled: {
|
||||||
|
completion: hasValue(data.completionRatio),
|
||||||
|
cache: hasValue(data.cacheRatio),
|
||||||
|
createCache: hasValue(data.createCacheRatio),
|
||||||
|
image: hasValue(data.imageRatio),
|
||||||
|
audioInput: hasValue(data.audioRatio),
|
||||||
|
audioOutput: hasValue(data.audioCompletionRatio),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPreviewRows(
|
||||||
|
values: ModelPricingFormValues,
|
||||||
|
mode: PricingMode,
|
||||||
|
billingExpr: string,
|
||||||
|
requestRuleExpr: string,
|
||||||
|
promptPrice: string,
|
||||||
|
lanePrices: Record<LaneKey, string>,
|
||||||
|
laneEnabled: Record<LaneKey, boolean>,
|
||||||
|
t: (key: string) => string
|
||||||
|
): PreviewRow[] {
|
||||||
|
if (mode === 'tiered_expr') {
|
||||||
|
const effectiveExpr = combineBillingExpr(billingExpr, requestRuleExpr)
|
||||||
|
return [
|
||||||
|
{ key: 'mode', label: 'BillingMode', value: 'tiered_expr' },
|
||||||
|
{
|
||||||
|
key: 'expr',
|
||||||
|
label: t('Expression'),
|
||||||
|
value: effectiveExpr || t('Empty'),
|
||||||
|
multiline: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'per-request') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'price',
|
||||||
|
label: 'ModelPrice',
|
||||||
|
value: values.price || t('Empty'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'inputPrice',
|
||||||
|
label: t('Input price'),
|
||||||
|
value: promptPrice ? `$${promptPrice}` : t('Empty'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'completion',
|
||||||
|
label: t('Completion price'),
|
||||||
|
value:
|
||||||
|
laneEnabled.completion && lanePrices.completion
|
||||||
|
? `$${lanePrices.completion}`
|
||||||
|
: t('Empty'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cache',
|
||||||
|
label: t('Cache read price'),
|
||||||
|
value:
|
||||||
|
laneEnabled.cache && lanePrices.cache
|
||||||
|
? `$${lanePrices.cache}`
|
||||||
|
: t('Empty'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'createCache',
|
||||||
|
label: t('Cache write price'),
|
||||||
|
value:
|
||||||
|
laneEnabled.createCache && lanePrices.createCache
|
||||||
|
? `$${lanePrices.createCache}`
|
||||||
|
: t('Empty'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'image',
|
||||||
|
label: t('Image input price'),
|
||||||
|
value:
|
||||||
|
laneEnabled.image && lanePrices.image
|
||||||
|
? `$${lanePrices.image}`
|
||||||
|
: t('Empty'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'audio',
|
||||||
|
label: t('Audio input price'),
|
||||||
|
value:
|
||||||
|
laneEnabled.audioInput && lanePrices.audioInput
|
||||||
|
? `$${lanePrices.audioInput}`
|
||||||
|
: t('Empty'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'audioCompletion',
|
||||||
|
label: t('Audio output price'),
|
||||||
|
value:
|
||||||
|
laneEnabled.audioOutput && lanePrices.audioOutput
|
||||||
|
? `$${lanePrices.audioOutput}`
|
||||||
|
: t('Empty'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2023-2026 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
} from '@/components/ui/input-group'
|
||||||
|
import {
|
||||||
|
SettingsControlGroup,
|
||||||
|
SettingsSwitchField,
|
||||||
|
} from '../components/settings-form-layout'
|
||||||
|
|
||||||
|
export function PriceInput(props: {
|
||||||
|
value: string
|
||||||
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupAddon>$</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
inputMode='decimal'
|
||||||
|
value={props.value}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
disabled={props.disabled}
|
||||||
|
onChange={(event) => props.onChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon align='inline-end'>$/1M</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriceLane(props: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
placeholder: string
|
||||||
|
value: string
|
||||||
|
enabled: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
onEnabledChange: (checked: boolean) => void
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const effectiveDisabled = props.disabled || !props.enabled
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsControlGroup
|
||||||
|
className={cn('space-y-3', effectiveDisabled && 'opacity-75')}
|
||||||
|
data-disabled={effectiveDisabled || undefined}
|
||||||
|
>
|
||||||
|
<SettingsSwitchField
|
||||||
|
checked={props.enabled}
|
||||||
|
disabled={props.disabled}
|
||||||
|
onCheckedChange={props.onEnabledChange}
|
||||||
|
label={props.title}
|
||||||
|
description={props.description}
|
||||||
|
aria-label={props.title}
|
||||||
|
/>
|
||||||
|
<PriceInput
|
||||||
|
value={props.value}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
disabled={effectiveDisabled}
|
||||||
|
onChange={props.onChange}
|
||||||
|
/>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
{props.enabled
|
||||||
|
? t('USD price per 1M tokens.')
|
||||||
|
: t('Disabled lanes are omitted on save.')}
|
||||||
|
</p>
|
||||||
|
</SettingsControlGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
+284
-630
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 || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
+155
-174
@@ -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}
|
||||||
|
|||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2023-2026 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
import { type ColumnDef } from '@tanstack/react-table'
|
||||||
|
import { Pencil, Trash2 } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { DataTableColumnHeader } from '@/components/data-table'
|
||||||
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import {
|
||||||
|
getModeLabel,
|
||||||
|
getModeVariant,
|
||||||
|
getPriceDetail,
|
||||||
|
getPriceSummary,
|
||||||
|
type ModelRow,
|
||||||
|
} from './model-pricing-snapshots'
|
||||||
|
|
||||||
|
const filterBySelectedValues = (
|
||||||
|
rowValue: unknown,
|
||||||
|
filterValue: unknown
|
||||||
|
): boolean => {
|
||||||
|
if (!Array.isArray(filterValue) || filterValue.length === 0) return true
|
||||||
|
return filterValue.includes(String(rowValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
type BuildModelRatioColumnsOptions = {
|
||||||
|
onDelete: (name: string) => void
|
||||||
|
onEdit: (model: ModelRow) => void
|
||||||
|
t: (key: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildModelRatioColumns({
|
||||||
|
onDelete,
|
||||||
|
onEdit,
|
||||||
|
t,
|
||||||
|
}: BuildModelRatioColumnsOptions): ColumnDef<ModelRow>[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={table.getIsAllPageRowsSelected()}
|
||||||
|
indeterminate={table.getIsSomePageRowsSelected()}
|
||||||
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
|
aria-label={t('Select all')}
|
||||||
|
className='translate-y-[2px]'
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
aria-label={t('Select row')}
|
||||||
|
className='translate-y-[2px]'
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
meta: { label: t('Select') },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title={t('Model name')} />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className='flex items-center gap-2 font-medium'>
|
||||||
|
{row.getValue('name')}
|
||||||
|
{row.original.billingMode === 'tiered_expr' && (
|
||||||
|
<StatusBadge label={t('Tiered')} variant='info' copyable={false} />
|
||||||
|
)}
|
||||||
|
{row.original.hasConflict && (
|
||||||
|
<StatusBadge
|
||||||
|
label={t('Conflict')}
|
||||||
|
variant='danger'
|
||||||
|
copyable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'billingMode',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title={t('Mode')} />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<StatusBadge
|
||||||
|
label={t(getModeLabel(row.original.billingMode))}
|
||||||
|
variant={getModeVariant(row.original.billingMode)}
|
||||||
|
copyable={false}
|
||||||
|
showDot={false}
|
||||||
|
className='px-0'
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
filterFn: (row, id, value) =>
|
||||||
|
filterBySelectedValues(row.getValue(id), value),
|
||||||
|
meta: { label: t('Mode') },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'priceSummary',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title={t('Price summary')} />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className='flex min-w-[180px] flex-col gap-1'>
|
||||||
|
<span className='font-medium'>
|
||||||
|
{getPriceSummary(row.original, t)}
|
||||||
|
</span>
|
||||||
|
<span className='text-muted-foreground max-w-[320px] truncate text-xs'>
|
||||||
|
{getPriceDetail(row.original, t)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
sortingFn: (rowA, rowB) =>
|
||||||
|
getPriceSummary(rowA.original, t).localeCompare(
|
||||||
|
getPriceSummary(rowB.original, t)
|
||||||
|
),
|
||||||
|
meta: { label: t('Price summary') },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: () => <div className='text-right'>{t('Actions')}</div>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className='flex justify-end gap-2'>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => onEdit(row.original)}
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => onDelete(row.original.name)}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
+670
-899
File diff suppressed because it is too large
Load Diff
+115
-164
@@ -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>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-30
@@ -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
@@ -49,6 +49,14 @@ type JsonValidationOptions = {
|
|||||||
predicateMessage?: string
|
predicateMessage?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type JsonValidationError = {
|
||||||
|
type: 'required' | 'structure' | 'syntax'
|
||||||
|
line?: number
|
||||||
|
column?: number
|
||||||
|
position?: number
|
||||||
|
missingCommaLine?: number
|
||||||
|
}
|
||||||
|
|
||||||
function extractErrorPosition(
|
function extractErrorPosition(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
jsonString: string
|
jsonString: string
|
||||||
@@ -81,8 +89,15 @@ function extractErrorPosition(
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatErrorMessage(error: unknown, jsonString: string): string {
|
function buildSyntaxError(
|
||||||
if (!(error instanceof Error)) return 'Invalid JSON'
|
error: unknown,
|
||||||
|
jsonString: string
|
||||||
|
): JsonValidationError {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return {
|
||||||
|
type: 'syntax',
|
||||||
|
} satisfies JsonValidationError
|
||||||
|
}
|
||||||
|
|
||||||
const position = extractErrorPosition(error, jsonString)
|
const position = extractErrorPosition(error, jsonString)
|
||||||
const message = error.message
|
const message = error.message
|
||||||
@@ -93,10 +108,29 @@ function formatErrorMessage(error: unknown, jsonString: string): string {
|
|||||||
message.includes('Expected property name') ||
|
message.includes('Expected property name') ||
|
||||||
message.includes('Unexpected string')
|
message.includes('Unexpected string')
|
||||||
|
|
||||||
|
const missingCommaLine =
|
||||||
|
isMissingCommaError && position.line && position.line > 1
|
||||||
|
? position.line - 1
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'syntax',
|
||||||
|
...position,
|
||||||
|
missingCommaLine,
|
||||||
|
} satisfies JsonValidationError
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatErrorMessage(error: unknown, jsonString: string): string {
|
||||||
|
if (!(error instanceof Error)) return 'Invalid JSON'
|
||||||
|
|
||||||
|
const position = extractErrorPosition(error, jsonString)
|
||||||
|
const message = error.message
|
||||||
|
const syntaxError = buildSyntaxError(error, jsonString)
|
||||||
|
|
||||||
if (position.line && position.column) {
|
if (position.line && position.column) {
|
||||||
let hint = ''
|
let hint = ''
|
||||||
if (isMissingCommaError && position.line > 1) {
|
if (syntaxError.missingCommaLine) {
|
||||||
hint = ` (check line ${position.line - 1} for missing comma)`
|
hint = ` (check line ${syntaxError.missingCommaLine} for missing comma)`
|
||||||
}
|
}
|
||||||
return `Error at line ${position.line}, column ${position.column}: ${message}${hint}`
|
return `Error at line ${position.line}, column ${position.column}: ${message}${hint}`
|
||||||
}
|
}
|
||||||
@@ -119,6 +153,11 @@ export function validateJsonString(
|
|||||||
return {
|
return {
|
||||||
valid: allowEmpty,
|
valid: allowEmpty,
|
||||||
message: allowEmpty ? undefined : 'Value is required',
|
message: allowEmpty ? undefined : 'Value is required',
|
||||||
|
error: allowEmpty
|
||||||
|
? undefined
|
||||||
|
: ({
|
||||||
|
type: 'required',
|
||||||
|
} satisfies JsonValidationError),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +167,9 @@ export function validateJsonString(
|
|||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
message: predicateMessage || 'JSON structure is invalid',
|
message: predicateMessage || 'JSON structure is invalid',
|
||||||
|
error: {
|
||||||
|
type: 'structure',
|
||||||
|
} satisfies JsonValidationError,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +178,7 @@ export function validateJsonString(
|
|||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
message: formatErrorMessage(error, trimmed),
|
message: formatErrorMessage(error, trimmed),
|
||||||
|
error: buildSyntaxError(error, trimmed),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+7
@@ -681,6 +681,7 @@
|
|||||||
"Check for updates": "Check for updates",
|
"Check for updates": "Check for updates",
|
||||||
"Check in daily to receive random quota rewards": "Check in daily to receive random quota rewards",
|
"Check in daily to receive random quota rewards": "Check in daily to receive random quota rewards",
|
||||||
"Check in now": "Check in now",
|
"Check in now": "Check in now",
|
||||||
|
"Check line {{line}} for a missing comma.": "Check line {{line}} for a missing comma.",
|
||||||
"Check out the Quick Start": "Check out the Quick Start",
|
"Check out the Quick Start": "Check out the Quick Start",
|
||||||
"Check resolved IPs against IP filters even when accessing by domain": "Check resolved IPs against IP filters even when accessing by domain",
|
"Check resolved IPs against IP filters even when accessing by domain": "Check resolved IPs against IP filters even when accessing by domain",
|
||||||
"Check-in failed": "Check-in failed",
|
"Check-in failed": "Check-in failed",
|
||||||
@@ -1528,6 +1529,7 @@
|
|||||||
"Expand": "Expand",
|
"Expand": "Expand",
|
||||||
"Expand All": "Expand All",
|
"Expand All": "Expand All",
|
||||||
"Expected a JSON array.": "Expected a JSON array.",
|
"Expected a JSON array.": "Expected a JSON array.",
|
||||||
|
"Expected a JSON array of group identifiers": "Expected a JSON array of group identifiers",
|
||||||
"Experiment with prompts and models in real time.": "Experiment with prompts and models in real time.",
|
"Experiment with prompts and models in real time.": "Experiment with prompts and models in real time.",
|
||||||
"Expiration Time": "Expiration Time",
|
"Expiration Time": "Expiration Time",
|
||||||
"expired": "expired",
|
"expired": "expired",
|
||||||
@@ -2095,6 +2097,9 @@
|
|||||||
"JSON Editor": "JSON Editor",
|
"JSON Editor": "JSON Editor",
|
||||||
"JSON format error": "JSON format error",
|
"JSON format error": "JSON format error",
|
||||||
"JSON format supports service account JSON files": "JSON format supports service account JSON files",
|
"JSON format supports service account JSON files": "JSON format supports service account JSON files",
|
||||||
|
"JSON is invalid at line {{line}}, column {{column}}.": "JSON is invalid at line {{line}}, column {{column}}.",
|
||||||
|
"JSON is invalid at position {{position}}.": "JSON is invalid at position {{position}}.",
|
||||||
|
"JSON is invalid. Please check the syntax.": "JSON is invalid. Please check the syntax.",
|
||||||
"JSON map of group → description exposed when users create API keys.": "JSON map of group → description exposed when users create API keys.",
|
"JSON map of group → description exposed when users create API keys.": "JSON map of group → description exposed when users create API keys.",
|
||||||
"JSON map of group → ratio applied when the user selects the group explicitly.": "JSON map of group → ratio applied when the user selects the group explicitly.",
|
"JSON map of group → ratio applied when the user selects the group explicitly.": "JSON map of group → ratio applied when the user selects the group explicitly.",
|
||||||
"JSON map of model → multiplier applied to quota billing.": "JSON map of model → multiplier applied to quota billing.",
|
"JSON map of model → multiplier applied to quota billing.": "JSON map of model → multiplier applied to quota billing.",
|
||||||
@@ -2103,6 +2108,7 @@
|
|||||||
"JSON Mode": "JSON Mode",
|
"JSON Mode": "JSON Mode",
|
||||||
"JSON must be an object": "JSON must be an object",
|
"JSON must be an object": "JSON must be an object",
|
||||||
"JSON object:": "JSON object:",
|
"JSON object:": "JSON object:",
|
||||||
|
"JSON structure is invalid": "JSON structure is invalid",
|
||||||
"JSON Text": "JSON Text",
|
"JSON Text": "JSON Text",
|
||||||
"JSON-based access control rules. Leave empty to allow all users.": "JSON-based access control rules. Leave empty to allow all users.",
|
"JSON-based access control rules. Leave empty to allow all users.": "JSON-based access control rules. Leave empty to allow all users.",
|
||||||
"Just now": "Just now",
|
"Just now": "Just now",
|
||||||
@@ -4315,6 +4321,7 @@
|
|||||||
"Validity Period": "Validity Period",
|
"Validity Period": "Validity Period",
|
||||||
"Value": "Value",
|
"Value": "Value",
|
||||||
"Value (supports JSON or plain text)": "Value (supports JSON or plain text)",
|
"Value (supports JSON or plain text)": "Value (supports JSON or plain text)",
|
||||||
|
"Value is required": "Value is required",
|
||||||
"Value must be at least 0": "Value must be at least 0",
|
"Value must be at least 0": "Value must be at least 0",
|
||||||
"Value Regex": "Value Regex",
|
"Value Regex": "Value Regex",
|
||||||
"variable": "variable",
|
"variable": "variable",
|
||||||
|
|||||||
Vendored
+7
@@ -681,6 +681,7 @@
|
|||||||
"Check for updates": "Vérifier les mises à jour",
|
"Check for updates": "Vérifier les mises à jour",
|
||||||
"Check in daily to receive random quota rewards": "Connectez-vous quotidiennement pour recevoir des récompenses de quota aléatoires",
|
"Check in daily to receive random quota rewards": "Connectez-vous quotidiennement pour recevoir des récompenses de quota aléatoires",
|
||||||
"Check in now": "Se connecter maintenant",
|
"Check in now": "Se connecter maintenant",
|
||||||
|
"Check line {{line}} for a missing comma.": "Vérifiez la ligne {{line}} pour une virgule manquante.",
|
||||||
"Check out the Quick Start": "Consultez le démarrage rapide",
|
"Check out the Quick Start": "Consultez le démarrage rapide",
|
||||||
"Check resolved IPs against IP filters even when accessing by domain": "Vérifier les adresses IP résolues par rapport aux filtres IP même lors de l'accès par domaine",
|
"Check resolved IPs against IP filters even when accessing by domain": "Vérifier les adresses IP résolues par rapport aux filtres IP même lors de l'accès par domaine",
|
||||||
"Check-in failed": "Échec de la connexion",
|
"Check-in failed": "Échec de la connexion",
|
||||||
@@ -1528,6 +1529,7 @@
|
|||||||
"Expand": "Développer",
|
"Expand": "Développer",
|
||||||
"Expand All": "Tout développer",
|
"Expand All": "Tout développer",
|
||||||
"Expected a JSON array.": "Un tableau JSON est attendu.",
|
"Expected a JSON array.": "Un tableau JSON est attendu.",
|
||||||
|
"Expected a JSON array of group identifiers": "Un tableau JSON d'identifiants de groupe est attendu",
|
||||||
"Experiment with prompts and models in real time.": "Expérimentez avec des prompts et des modèles en temps réel.",
|
"Experiment with prompts and models in real time.": "Expérimentez avec des prompts et des modèles en temps réel.",
|
||||||
"Expiration Time": "Heure d'expiration",
|
"Expiration Time": "Heure d'expiration",
|
||||||
"expired": "expiré",
|
"expired": "expiré",
|
||||||
@@ -2095,6 +2097,9 @@
|
|||||||
"JSON Editor": "Édition JSON",
|
"JSON Editor": "Édition JSON",
|
||||||
"JSON format error": "Erreur de format JSON",
|
"JSON format error": "Erreur de format JSON",
|
||||||
"JSON format supports service account JSON files": "Le format JSON prend en charge les fichiers JSON de compte de service",
|
"JSON format supports service account JSON files": "Le format JSON prend en charge les fichiers JSON de compte de service",
|
||||||
|
"JSON is invalid at line {{line}}, column {{column}}.": "Le JSON est invalide à la ligne {{line}}, colonne {{column}}.",
|
||||||
|
"JSON is invalid at position {{position}}.": "Le JSON est invalide à la position {{position}}.",
|
||||||
|
"JSON is invalid. Please check the syntax.": "Le JSON est invalide. Veuillez vérifier la syntaxe.",
|
||||||
"JSON map of group → description exposed when users create API keys.": "Carte JSON de groupe → description exposée lorsque les utilisateurs créent des clés API.",
|
"JSON map of group → description exposed when users create API keys.": "Carte JSON de groupe → description exposée lorsque les utilisateurs créent des clés API.",
|
||||||
"JSON map of group → ratio applied when the user selects the group explicitly.": "Carte JSON de groupe → ratio appliqué lorsque l'utilisateur sélectionne explicitement le groupe.",
|
"JSON map of group → ratio applied when the user selects the group explicitly.": "Carte JSON de groupe → ratio appliqué lorsque l'utilisateur sélectionne explicitement le groupe.",
|
||||||
"JSON map of model → multiplier applied to quota billing.": "Carte JSON de modèle → multiplicateur appliqué à la facturation par quota.",
|
"JSON map of model → multiplier applied to quota billing.": "Carte JSON de modèle → multiplicateur appliqué à la facturation par quota.",
|
||||||
@@ -2103,6 +2108,7 @@
|
|||||||
"JSON Mode": "Mode JSON",
|
"JSON Mode": "Mode JSON",
|
||||||
"JSON must be an object": "Le JSON doit être un objet",
|
"JSON must be an object": "Le JSON doit être un objet",
|
||||||
"JSON object:": "Objet JSON :",
|
"JSON object:": "Objet JSON :",
|
||||||
|
"JSON structure is invalid": "La structure JSON est invalide",
|
||||||
"JSON Text": "Texte JSON",
|
"JSON Text": "Texte JSON",
|
||||||
"JSON-based access control rules. Leave empty to allow all users.": "Règles de contrôle d'accès basées sur JSON. Laisser vide pour autoriser tous les utilisateurs.",
|
"JSON-based access control rules. Leave empty to allow all users.": "Règles de contrôle d'accès basées sur JSON. Laisser vide pour autoriser tous les utilisateurs.",
|
||||||
"Just now": "À l'instant",
|
"Just now": "À l'instant",
|
||||||
@@ -4315,6 +4321,7 @@
|
|||||||
"Validity Period": "Période de validité",
|
"Validity Period": "Période de validité",
|
||||||
"Value": "Valeur",
|
"Value": "Valeur",
|
||||||
"Value (supports JSON or plain text)": "Valeur (JSON ou texte brut)",
|
"Value (supports JSON or plain text)": "Valeur (JSON ou texte brut)",
|
||||||
|
"Value is required": "La valeur est obligatoire",
|
||||||
"Value must be at least 0": "La valeur doit être au moins 0",
|
"Value must be at least 0": "La valeur doit être au moins 0",
|
||||||
"Value Regex": "Regex de valeur",
|
"Value Regex": "Regex de valeur",
|
||||||
"variable": "variable",
|
"variable": "variable",
|
||||||
|
|||||||
Vendored
+7
@@ -681,6 +681,7 @@
|
|||||||
"Check for updates": "更新を確認",
|
"Check for updates": "更新を確認",
|
||||||
"Check in daily to receive random quota rewards": "毎日チェックインして、ランダムなノルマ報酬を受け取りましょう",
|
"Check in daily to receive random quota rewards": "毎日チェックインして、ランダムなノルマ報酬を受け取りましょう",
|
||||||
"Check in now": "今すぐチェックイン",
|
"Check in now": "今すぐチェックイン",
|
||||||
|
"Check line {{line}} for a missing comma.": "{{line}} 行目にカンマの抜けがないか確認してください。",
|
||||||
"Check out the Quick Start": "クイックスタートをご確認ください",
|
"Check out the Quick Start": "クイックスタートをご確認ください",
|
||||||
"Check resolved IPs against IP filters even when accessing by domain": "ドメインによるアクセスであっても、解決されたIPをIPフィルターと照合してチェックします",
|
"Check resolved IPs against IP filters even when accessing by domain": "ドメインによるアクセスであっても、解決されたIPをIPフィルターと照合してチェックします",
|
||||||
"Check-in failed": "チェックインできませんでした",
|
"Check-in failed": "チェックインできませんでした",
|
||||||
@@ -1528,6 +1529,7 @@
|
|||||||
"Expand": "展開",
|
"Expand": "展開",
|
||||||
"Expand All": "すべて展開",
|
"Expand All": "すべて展開",
|
||||||
"Expected a JSON array.": "JSON 配列が必要です。",
|
"Expected a JSON array.": "JSON 配列が必要です。",
|
||||||
|
"Expected a JSON array of group identifiers": "グループ識別子の JSON 配列が必要です",
|
||||||
"Experiment with prompts and models in real time.": "プロンプトとモデルをリアルタイムで実験する。",
|
"Experiment with prompts and models in real time.": "プロンプトとモデルをリアルタイムで実験する。",
|
||||||
"Expiration Time": "有効期限",
|
"Expiration Time": "有効期限",
|
||||||
"expired": "期限切れ",
|
"expired": "期限切れ",
|
||||||
@@ -2095,6 +2097,9 @@
|
|||||||
"JSON Editor": "JSON編集",
|
"JSON Editor": "JSON編集",
|
||||||
"JSON format error": "JSONフォーマットエラー",
|
"JSON format error": "JSONフォーマットエラー",
|
||||||
"JSON format supports service account JSON files": "JSON形式はサービスアカウントJSONファイルをサポートします",
|
"JSON format supports service account JSON files": "JSON形式はサービスアカウントJSONファイルをサポートします",
|
||||||
|
"JSON is invalid at line {{line}}, column {{column}}.": "JSON は {{line}} 行目、{{column}} 列目で無効です。",
|
||||||
|
"JSON is invalid at position {{position}}.": "JSON は位置 {{position}} で無効です。",
|
||||||
|
"JSON is invalid. Please check the syntax.": "JSON が無効です。構文を確認してください。",
|
||||||
"JSON map of group → description exposed when users create API keys.": "ユーザーがAPIキーを作成する際に公開される、グループ → 説明のJSONマップ。",
|
"JSON map of group → description exposed when users create API keys.": "ユーザーがAPIキーを作成する際に公開される、グループ → 説明のJSONマップ。",
|
||||||
"JSON map of group → ratio applied when the user selects the group explicitly.": "ユーザーがグループを明示的に選択したときに適用される、グループ → 比率のJSONマップ。",
|
"JSON map of group → ratio applied when the user selects the group explicitly.": "ユーザーがグループを明示的に選択したときに適用される、グループ → 比率のJSONマップ。",
|
||||||
"JSON map of model → multiplier applied to quota billing.": "モデル → クォータ請求に適用される乗数のJSONマップ。",
|
"JSON map of model → multiplier applied to quota billing.": "モデル → クォータ請求に適用される乗数のJSONマップ。",
|
||||||
@@ -2103,6 +2108,7 @@
|
|||||||
"JSON Mode": "JSONモード",
|
"JSON Mode": "JSONモード",
|
||||||
"JSON must be an object": "JSON はオブジェクトである必要があります",
|
"JSON must be an object": "JSON はオブジェクトである必要があります",
|
||||||
"JSON object:": "JSONオブジェクト:",
|
"JSON object:": "JSONオブジェクト:",
|
||||||
|
"JSON structure is invalid": "JSON 構造が無効です",
|
||||||
"JSON Text": "JSONテキスト",
|
"JSON Text": "JSONテキスト",
|
||||||
"JSON-based access control rules. Leave empty to allow all users.": "JSONベースのアクセス制御ルール。すべてのユーザーを許可する場合は空のままにしてください。",
|
"JSON-based access control rules. Leave empty to allow all users.": "JSONベースのアクセス制御ルール。すべてのユーザーを許可する場合は空のままにしてください。",
|
||||||
"Just now": "たった今",
|
"Just now": "たった今",
|
||||||
@@ -4315,6 +4321,7 @@
|
|||||||
"Validity Period": "有効期間",
|
"Validity Period": "有効期間",
|
||||||
"Value": "値",
|
"Value": "値",
|
||||||
"Value (supports JSON or plain text)": "値(JSONまたはプレーンテキスト対応)",
|
"Value (supports JSON or plain text)": "値(JSONまたはプレーンテキスト対応)",
|
||||||
|
"Value is required": "値は必須です",
|
||||||
"Value must be at least 0": "値は 0 以上である必要があります",
|
"Value must be at least 0": "値は 0 以上である必要があります",
|
||||||
"Value Regex": "Value 正規表現",
|
"Value Regex": "Value 正規表現",
|
||||||
"variable": "変数",
|
"variable": "変数",
|
||||||
|
|||||||
Vendored
+7
@@ -681,6 +681,7 @@
|
|||||||
"Check for updates": "Проверить обновления",
|
"Check for updates": "Проверить обновления",
|
||||||
"Check in daily to receive random quota rewards": "Регистрируйтесь ежедневно, чтобы получать случайные вознаграждения по квоте",
|
"Check in daily to receive random quota rewards": "Регистрируйтесь ежедневно, чтобы получать случайные вознаграждения по квоте",
|
||||||
"Check in now": "Войдите сейчас",
|
"Check in now": "Войдите сейчас",
|
||||||
|
"Check line {{line}} for a missing comma.": "Проверьте строку {{line}} на пропущенную запятую.",
|
||||||
"Check out the Quick Start": "Ознакомьтесь с быстрым стартом",
|
"Check out the Quick Start": "Ознакомьтесь с быстрым стартом",
|
||||||
"Check resolved IPs against IP filters even when accessing by domain": "Проверять разрешенные IP-адреса по IP-фильтрам даже при доступе по домену",
|
"Check resolved IPs against IP filters even when accessing by domain": "Проверять разрешенные IP-адреса по IP-фильтрам даже при доступе по домену",
|
||||||
"Check-in failed": "Регистрация не удалась.",
|
"Check-in failed": "Регистрация не удалась.",
|
||||||
@@ -1528,6 +1529,7 @@
|
|||||||
"Expand": "Развернуть",
|
"Expand": "Развернуть",
|
||||||
"Expand All": "Развернуть все",
|
"Expand All": "Развернуть все",
|
||||||
"Expected a JSON array.": "Ожидается JSON-массив.",
|
"Expected a JSON array.": "Ожидается JSON-массив.",
|
||||||
|
"Expected a JSON array of group identifiers": "Ожидается JSON-массив идентификаторов групп",
|
||||||
"Experiment with prompts and models in real time.": "Экспериментируйте с промптами и моделями в реальном времени.",
|
"Experiment with prompts and models in real time.": "Экспериментируйте с промптами и моделями в реальном времени.",
|
||||||
"Expiration Time": "Время истечения срока действия",
|
"Expiration Time": "Время истечения срока действия",
|
||||||
"expired": "истек",
|
"expired": "истек",
|
||||||
@@ -2095,6 +2097,9 @@
|
|||||||
"JSON Editor": "Редактирование JSON",
|
"JSON Editor": "Редактирование JSON",
|
||||||
"JSON format error": "Ошибка формата JSON",
|
"JSON format error": "Ошибка формата JSON",
|
||||||
"JSON format supports service account JSON files": "Формат JSON поддерживает JSON-файлы сервисного аккаунта",
|
"JSON format supports service account JSON files": "Формат JSON поддерживает JSON-файлы сервисного аккаунта",
|
||||||
|
"JSON is invalid at line {{line}}, column {{column}}.": "JSON недействителен в строке {{line}}, столбце {{column}}.",
|
||||||
|
"JSON is invalid at position {{position}}.": "JSON недействителен в позиции {{position}}.",
|
||||||
|
"JSON is invalid. Please check the syntax.": "JSON недействителен. Проверьте синтаксис.",
|
||||||
"JSON map of group → description exposed when users create API keys.": "JSON-карта группы → описание, отображаемое при создании пользователями ключей API.",
|
"JSON map of group → description exposed when users create API keys.": "JSON-карта группы → описание, отображаемое при создании пользователями ключей API.",
|
||||||
"JSON map of group → ratio applied when the user selects the group explicitly.": "JSON-карта группы → соотношение, применяемое, когда пользователь явно выбирает группу.",
|
"JSON map of group → ratio applied when the user selects the group explicitly.": "JSON-карта группы → соотношение, применяемое, когда пользователь явно выбирает группу.",
|
||||||
"JSON map of model → multiplier applied to quota billing.": "JSON-карта модели → множитель, применяемый к тарификации по квоте.",
|
"JSON map of model → multiplier applied to quota billing.": "JSON-карта модели → множитель, применяемый к тарификации по квоте.",
|
||||||
@@ -2103,6 +2108,7 @@
|
|||||||
"JSON Mode": "Режим JSON",
|
"JSON Mode": "Режим JSON",
|
||||||
"JSON must be an object": "JSON должен быть объектом",
|
"JSON must be an object": "JSON должен быть объектом",
|
||||||
"JSON object:": "Объект JSON:",
|
"JSON object:": "Объект JSON:",
|
||||||
|
"JSON structure is invalid": "Структура JSON недействительна",
|
||||||
"JSON Text": "JSON текст",
|
"JSON Text": "JSON текст",
|
||||||
"JSON-based access control rules. Leave empty to allow all users.": "Правила контроля доступа на основе JSON. Оставьте пустым, чтобы разрешить всем пользователям.",
|
"JSON-based access control rules. Leave empty to allow all users.": "Правила контроля доступа на основе JSON. Оставьте пустым, чтобы разрешить всем пользователям.",
|
||||||
"Just now": "Только что",
|
"Just now": "Только что",
|
||||||
@@ -4315,6 +4321,7 @@
|
|||||||
"Validity Period": "Срок действия",
|
"Validity Period": "Срок действия",
|
||||||
"Value": "Значение",
|
"Value": "Значение",
|
||||||
"Value (supports JSON or plain text)": "Значение (JSON или текст)",
|
"Value (supports JSON or plain text)": "Значение (JSON или текст)",
|
||||||
|
"Value is required": "Значение обязательно",
|
||||||
"Value must be at least 0": "Значение должно быть не менее 0",
|
"Value must be at least 0": "Значение должно быть не менее 0",
|
||||||
"Value Regex": "Регулярное выражение значения",
|
"Value Regex": "Регулярное выражение значения",
|
||||||
"variable": "переменная",
|
"variable": "переменная",
|
||||||
|
|||||||
Vendored
+7
@@ -681,6 +681,7 @@
|
|||||||
"Check for updates": "Kiểm tra cập nhật",
|
"Check for updates": "Kiểm tra cập nhật",
|
||||||
"Check in daily to receive random quota rewards": "Nhận phòng hàng ngày để nhận phần thưởng theo hạn ngạch ngẫu nhiên",
|
"Check in daily to receive random quota rewards": "Nhận phòng hàng ngày để nhận phần thưởng theo hạn ngạch ngẫu nhiên",
|
||||||
"Check in now": "Điểm danh ngay",
|
"Check in now": "Điểm danh ngay",
|
||||||
|
"Check line {{line}} for a missing comma.": "Kiểm tra dòng {{line}} xem có thiếu dấu phẩy không.",
|
||||||
"Check out the Quick Start": "Xem hướng dẫn bắt đầu nhanh",
|
"Check out the Quick Start": "Xem hướng dẫn bắt đầu nhanh",
|
||||||
"Check resolved IPs against IP filters even when accessing by domain": "Kiểm tra các IP đã phân giải đối chiếu với các bộ lọc IP ngay cả khi truy cập bằng tên miền",
|
"Check resolved IPs against IP filters even when accessing by domain": "Kiểm tra các IP đã phân giải đối chiếu với các bộ lọc IP ngay cả khi truy cập bằng tên miền",
|
||||||
"Check-in failed": "Điểm danh thất bại",
|
"Check-in failed": "Điểm danh thất bại",
|
||||||
@@ -1528,6 +1529,7 @@
|
|||||||
"Expand": "Mở rộng",
|
"Expand": "Mở rộng",
|
||||||
"Expand All": "Mở rộng tất cả",
|
"Expand All": "Mở rộng tất cả",
|
||||||
"Expected a JSON array.": "Cần là một mảng JSON.",
|
"Expected a JSON array.": "Cần là một mảng JSON.",
|
||||||
|
"Expected a JSON array of group identifiers": "Cần là một mảng JSON gồm các định danh nhóm",
|
||||||
"Experiment with prompts and models in real time.": "Thử nghiệm với prompt và mô hình theo thời gian thực.",
|
"Experiment with prompts and models in real time.": "Thử nghiệm với prompt và mô hình theo thời gian thực.",
|
||||||
"Expiration Time": "Thời gian hết hạn",
|
"Expiration Time": "Thời gian hết hạn",
|
||||||
"expired": "Đã hết hạn",
|
"expired": "Đã hết hạn",
|
||||||
@@ -2095,6 +2097,9 @@
|
|||||||
"JSON Editor": "Trình chỉnh sửa JSON",
|
"JSON Editor": "Trình chỉnh sửa JSON",
|
||||||
"JSON format error": "Lỗi định dạng JSON",
|
"JSON format error": "Lỗi định dạng JSON",
|
||||||
"JSON format supports service account JSON files": "Định dạng JSON hỗ trợ các tệp JSON tài khoản dịch vụ",
|
"JSON format supports service account JSON files": "Định dạng JSON hỗ trợ các tệp JSON tài khoản dịch vụ",
|
||||||
|
"JSON is invalid at line {{line}}, column {{column}}.": "JSON không hợp lệ tại dòng {{line}}, cột {{column}}.",
|
||||||
|
"JSON is invalid at position {{position}}.": "JSON không hợp lệ tại vị trí {{position}}.",
|
||||||
|
"JSON is invalid. Please check the syntax.": "JSON không hợp lệ. Vui lòng kiểm tra cú pháp.",
|
||||||
"JSON map of group → description exposed when users create API keys.": "Ánh xạ JSON của nhóm → mô tả được hiển thị khi người dùng tạo khóa API.",
|
"JSON map of group → description exposed when users create API keys.": "Ánh xạ JSON của nhóm → mô tả được hiển thị khi người dùng tạo khóa API.",
|
||||||
"JSON map of group → ratio applied when the user selects the group explicitly.": "Bản đồ JSON của nhóm → tỷ lệ được áp dụng khi người dùng chọn nhóm đó một cách rõ ràng.",
|
"JSON map of group → ratio applied when the user selects the group explicitly.": "Bản đồ JSON của nhóm → tỷ lệ được áp dụng khi người dùng chọn nhóm đó một cách rõ ràng.",
|
||||||
"JSON map of model → multiplier applied to quota billing.": "Bản đồ JSON của mô hình → hệ số nhân áp dụng cho thanh toán hạn mức.",
|
"JSON map of model → multiplier applied to quota billing.": "Bản đồ JSON của mô hình → hệ số nhân áp dụng cho thanh toán hạn mức.",
|
||||||
@@ -2103,6 +2108,7 @@
|
|||||||
"JSON Mode": "Chế độ JSON",
|
"JSON Mode": "Chế độ JSON",
|
||||||
"JSON must be an object": "JSON phải là object",
|
"JSON must be an object": "JSON phải là object",
|
||||||
"JSON object:": "Đối tượng JSON:",
|
"JSON object:": "Đối tượng JSON:",
|
||||||
|
"JSON structure is invalid": "Cấu trúc JSON không hợp lệ",
|
||||||
"JSON Text": "Văn bản JSON",
|
"JSON Text": "Văn bản JSON",
|
||||||
"JSON-based access control rules. Leave empty to allow all users.": "Quy tắc kiểm soát truy cập dựa trên JSON. Để trống để cho phép tất cả người dùng.",
|
"JSON-based access control rules. Leave empty to allow all users.": "Quy tắc kiểm soát truy cập dựa trên JSON. Để trống để cho phép tất cả người dùng.",
|
||||||
"Just now": "Vừa nãy",
|
"Just now": "Vừa nãy",
|
||||||
@@ -4315,6 +4321,7 @@
|
|||||||
"Validity Period": "Thời hạn hiệu lực",
|
"Validity Period": "Thời hạn hiệu lực",
|
||||||
"Value": "Giá trị",
|
"Value": "Giá trị",
|
||||||
"Value (supports JSON or plain text)": "Giá trị (hỗ trợ JSON hoặc văn bản thuần)",
|
"Value (supports JSON or plain text)": "Giá trị (hỗ trợ JSON hoặc văn bản thuần)",
|
||||||
|
"Value is required": "Giá trị là bắt buộc",
|
||||||
"Value must be at least 0": "Giá trị phải ít nhất là 0",
|
"Value must be at least 0": "Giá trị phải ít nhất là 0",
|
||||||
"Value Regex": "Regex giá trị",
|
"Value Regex": "Regex giá trị",
|
||||||
"variable": "biến",
|
"variable": "biến",
|
||||||
|
|||||||
Vendored
+7
@@ -681,6 +681,7 @@
|
|||||||
"Check for updates": "检查更新",
|
"Check for updates": "检查更新",
|
||||||
"Check in daily to receive random quota rewards": "每日签到可获得随机额度奖励",
|
"Check in daily to receive random quota rewards": "每日签到可获得随机额度奖励",
|
||||||
"Check in now": "立即签到",
|
"Check in now": "立即签到",
|
||||||
|
"Check line {{line}} for a missing comma.": "请检查第 {{line}} 行是否缺少逗号。",
|
||||||
"Check out the Quick Start": "请查看 新手入门",
|
"Check out the Quick Start": "请查看 新手入门",
|
||||||
"Check resolved IPs against IP filters even when accessing by domain": "即使通过域名访问,也对照 IP 过滤器检查解析的 IP",
|
"Check resolved IPs against IP filters even when accessing by domain": "即使通过域名访问,也对照 IP 过滤器检查解析的 IP",
|
||||||
"Check-in failed": "签到失败",
|
"Check-in failed": "签到失败",
|
||||||
@@ -1528,6 +1529,7 @@
|
|||||||
"Expand": "展开",
|
"Expand": "展开",
|
||||||
"Expand All": "全部展开",
|
"Expand All": "全部展开",
|
||||||
"Expected a JSON array.": "应为 JSON 数组。",
|
"Expected a JSON array.": "应为 JSON 数组。",
|
||||||
|
"Expected a JSON array of group identifiers": "应为分组标识符的 JSON 数组",
|
||||||
"Experiment with prompts and models in real time.": "实时实验提示词和模型。",
|
"Experiment with prompts and models in real time.": "实时实验提示词和模型。",
|
||||||
"Expiration Time": "过期时间",
|
"Expiration Time": "过期时间",
|
||||||
"expired": "已过期",
|
"expired": "已过期",
|
||||||
@@ -2095,6 +2097,9 @@
|
|||||||
"JSON Editor": "JSON 编辑",
|
"JSON Editor": "JSON 编辑",
|
||||||
"JSON format error": "JSON 格式错误",
|
"JSON format error": "JSON 格式错误",
|
||||||
"JSON format supports service account JSON files": "JSON 格式支持服务账户 JSON 文件",
|
"JSON format supports service account JSON files": "JSON 格式支持服务账户 JSON 文件",
|
||||||
|
"JSON is invalid at line {{line}}, column {{column}}.": "JSON 在第 {{line}} 行、第 {{column}} 列无效。",
|
||||||
|
"JSON is invalid at position {{position}}.": "JSON 在位置 {{position}} 无效。",
|
||||||
|
"JSON is invalid. Please check the syntax.": "JSON 无效,请检查语法。",
|
||||||
"JSON map of group → description exposed when users create API keys.": "分组 → 描述的 JSON 映射,在用户创建 API 密钥时公开。",
|
"JSON map of group → description exposed when users create API keys.": "分组 → 描述的 JSON 映射,在用户创建 API 密钥时公开。",
|
||||||
"JSON map of group → ratio applied when the user selects the group explicitly.": "分组 → 比率的 JSON 映射,当用户明确选择该分组时应用此比率。",
|
"JSON map of group → ratio applied when the user selects the group explicitly.": "分组 → 比率的 JSON 映射,当用户明确选择该分组时应用此比率。",
|
||||||
"JSON map of model → multiplier applied to quota billing.": "模型 → 应用于配额计费的乘数的 JSON 映射。",
|
"JSON map of model → multiplier applied to quota billing.": "模型 → 应用于配额计费的乘数的 JSON 映射。",
|
||||||
@@ -2103,6 +2108,7 @@
|
|||||||
"JSON Mode": "JSON 模式",
|
"JSON Mode": "JSON 模式",
|
||||||
"JSON must be an object": "JSON 必须是对象",
|
"JSON must be an object": "JSON 必须是对象",
|
||||||
"JSON object:": "JSON 对象:",
|
"JSON object:": "JSON 对象:",
|
||||||
|
"JSON structure is invalid": "JSON 结构无效",
|
||||||
"JSON Text": "JSON 文本",
|
"JSON Text": "JSON 文本",
|
||||||
"JSON-based access control rules. Leave empty to allow all users.": "基于 JSON 的访问控制规则。留空以允许所有用户。",
|
"JSON-based access control rules. Leave empty to allow all users.": "基于 JSON 的访问控制规则。留空以允许所有用户。",
|
||||||
"Just now": "刚刚",
|
"Just now": "刚刚",
|
||||||
@@ -4315,6 +4321,7 @@
|
|||||||
"Validity Period": "有效期",
|
"Validity Period": "有效期",
|
||||||
"Value": "值",
|
"Value": "值",
|
||||||
"Value (supports JSON or plain text)": "值(支持 JSON 或普通文本)",
|
"Value (supports JSON or plain text)": "值(支持 JSON 或普通文本)",
|
||||||
|
"Value is required": "值为必填项",
|
||||||
"Value must be at least 0": "值必须至少为 0",
|
"Value must be at least 0": "值必须至少为 0",
|
||||||
"Value Regex": "Value 正则",
|
"Value Regex": "Value 正则",
|
||||||
"variable": "变量",
|
"variable": "变量",
|
||||||
|
|||||||
Vendored
+1
@@ -88,6 +88,7 @@ export const STATIC_I18N_KEYS = [
|
|||||||
'Failed to delete API key',
|
'Failed to delete API key',
|
||||||
'Failed to delete API keys',
|
'Failed to delete API keys',
|
||||||
'Failed to update API key status',
|
'Failed to update API key status',
|
||||||
|
'Expected a JSON array of group identifiers',
|
||||||
'Successfully created {{count}} API Key(s)',
|
'Successfully created {{count}} API Key(s)',
|
||||||
'Successfully deleted {{count}} API key(s)',
|
'Successfully deleted {{count}} API key(s)',
|
||||||
'Enter API key for this channel',
|
'Enter API key for this channel',
|
||||||
|
|||||||
Reference in New Issue
Block a user