Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4dd68bad52 | |||
| 0f043ae404 | |||
| 75c05bb4b8 | |||
| 81d3dc08e5 | |||
| 5681c92b3f | |||
| 6e5a359110 | |||
| 77d3157592 | |||
| 39e05118ff | |||
| 9e59ffc3d8 | |||
| abad0d3cc0 | |||
| 7aaa533265 | |||
| 7791b78429 | |||
| cb5c0453f5 | |||
| 4d20e053cb |
+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>
|
||||
)
|
||||
}
|
||||
+23
-13
@@ -24,7 +24,7 @@ import {
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { type SubmitErrorHandler, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
@@ -140,6 +140,7 @@ import {
|
||||
hasModelConfigChanged,
|
||||
findMissingModelsInMapping,
|
||||
validateModelMappingJson,
|
||||
hasAdvancedSettingsErrors,
|
||||
} from '../../lib'
|
||||
import {
|
||||
collectInvalidStatusCodeEntries,
|
||||
@@ -204,7 +205,6 @@ function readAdvancedSettingsPreference(): boolean {
|
||||
|
||||
function hasAdvancedSettingsValues(values: ChannelFormValues): boolean {
|
||||
return Boolean(
|
||||
values.model_mapping?.trim() ||
|
||||
values.param_override?.trim() ||
|
||||
values.header_override?.trim() ||
|
||||
values.status_code_mapping?.trim() ||
|
||||
@@ -1008,6 +1008,26 @@ export function ChannelMutateDrawer({
|
||||
]
|
||||
)
|
||||
|
||||
const handleAdvancedSettingsOpenChange = useCallback((nextOpen: boolean) => {
|
||||
setAdvancedSettingsOpen(nextOpen)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(
|
||||
ADVANCED_SETTINGS_EXPANDED_KEY,
|
||||
String(nextOpen)
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onInvalid: SubmitErrorHandler<ChannelFormValues> = useCallback(
|
||||
(errors) => {
|
||||
if (hasAdvancedSettingsErrors(errors)) {
|
||||
handleAdvancedSettingsOpenChange(true)
|
||||
}
|
||||
toast.error(t('Please fix the highlighted fields before saving'))
|
||||
},
|
||||
[handleAdvancedSettingsOpenChange, t]
|
||||
)
|
||||
|
||||
// Handle drawer close
|
||||
const handleOpenChange = useCallback(
|
||||
(v: boolean) => {
|
||||
@@ -1020,16 +1040,6 @@ export function ChannelMutateDrawer({
|
||||
[onOpenChange, form]
|
||||
)
|
||||
|
||||
const handleAdvancedSettingsOpenChange = useCallback((nextOpen: boolean) => {
|
||||
setAdvancedSettingsOpen(nextOpen)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(
|
||||
ADVANCED_SETTINGS_EXPANDED_KEY,
|
||||
String(nextOpen)
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={handleOpenChange}>
|
||||
@@ -1060,7 +1070,7 @@ export function ChannelMutateDrawer({
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='channel-form'
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
onSubmit={form.handleSubmit(onSubmit, onInvalid)}
|
||||
className={sideDrawerFormClassName('gap-5')}
|
||||
>
|
||||
{isChannelDetailLoading ? (
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
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 { FieldPath } from 'react-hook-form'
|
||||
import type { ChannelFormValues } from './channel-form'
|
||||
|
||||
type ChannelFormErrorMap = Partial<
|
||||
Record<FieldPath<ChannelFormValues>, unknown>
|
||||
>
|
||||
|
||||
const ADVANCED_SETTINGS_FIELDS = new Set<FieldPath<ChannelFormValues>>([
|
||||
'priority',
|
||||
'weight',
|
||||
'test_model',
|
||||
'auto_ban',
|
||||
'tag',
|
||||
'remark',
|
||||
'param_override',
|
||||
'header_override',
|
||||
'status_code_mapping',
|
||||
'force_format',
|
||||
'thinking_to_content',
|
||||
'pass_through_body_enabled',
|
||||
'proxy',
|
||||
'system_prompt',
|
||||
'system_prompt_override',
|
||||
'allow_service_tier',
|
||||
'disable_store',
|
||||
'allow_safety_identifier',
|
||||
'allow_include_obfuscation',
|
||||
'allow_inference_geo',
|
||||
'allow_speed',
|
||||
'claude_beta_query',
|
||||
'upstream_model_update_check_enabled',
|
||||
'upstream_model_update_auto_sync_enabled',
|
||||
'upstream_model_update_ignored_models',
|
||||
])
|
||||
|
||||
export function isAdvancedSettingsField(
|
||||
fieldName: string
|
||||
): fieldName is FieldPath<ChannelFormValues> {
|
||||
return ADVANCED_SETTINGS_FIELDS.has(fieldName as FieldPath<ChannelFormValues>)
|
||||
}
|
||||
|
||||
export function hasAdvancedSettingsErrors(
|
||||
errors: ChannelFormErrorMap
|
||||
): boolean {
|
||||
return Object.keys(errors).some((fieldName) =>
|
||||
isAdvancedSettingsField(fieldName)
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
// Re-export all library functions
|
||||
export * from './channel-actions'
|
||||
export * from './channel-form-errors'
|
||||
export * from './channel-form'
|
||||
export * from './channel-type-config'
|
||||
export * from './channel-utils'
|
||||
|
||||
@@ -70,7 +70,7 @@ function SettingsPageFrame(props: SettingsPageFrameProps) {
|
||||
<span className='truncate'>{props.title}</span>
|
||||
<span
|
||||
ref={setTitleStatusContainer}
|
||||
className='inline-flex shrink-0'
|
||||
className='inline-flex min-w-0 shrink-0 items-center'
|
||||
/>
|
||||
</span>
|
||||
</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
|
||||
*/
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { memo, useCallback, useRef, useState } from 'react'
|
||||
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 { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -31,14 +31,16 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import {
|
||||
SettingsForm,
|
||||
SettingsSwitchContent,
|
||||
SettingsSwitchItem,
|
||||
} from '../components/settings-form-layout'
|
||||
import { SettingsPageActionsPortal } from '../components/settings-page-context'
|
||||
import { ModelRatioVisualEditor } from './model-ratio-visual-editor'
|
||||
import {
|
||||
ModelRatioVisualEditor,
|
||||
type ModelRatioVisualEditorHandle,
|
||||
} from './model-ratio-visual-editor'
|
||||
|
||||
type ModelFormValues = {
|
||||
ModelPrice: string
|
||||
@@ -56,14 +58,106 @@ type ModelFormValues = {
|
||||
|
||||
type ModelRatioFormProps = {
|
||||
form: UseFormReturn<ModelFormValues>
|
||||
savedValues: ModelFormValues
|
||||
onSave: (values: ModelFormValues) => Promise<void>
|
||||
onReset: () => void
|
||||
isSaving: 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({
|
||||
form,
|
||||
savedValues,
|
||||
onSave,
|
||||
onReset,
|
||||
isSaving,
|
||||
@@ -71,6 +165,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
||||
}: ModelRatioFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
|
||||
const visualEditorRef = useRef<ModelRatioVisualEditorHandle>(null)
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
(field: keyof ModelFormValues, value: string) => {
|
||||
@@ -86,9 +181,39 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
||||
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 (
|
||||
<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}>
|
||||
{editMode === 'visual' ? (
|
||||
<>
|
||||
@@ -105,28 +230,20 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
||||
</div>
|
||||
|
||||
<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' ? (
|
||||
<div className='space-y-6'>
|
||||
<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')}
|
||||
modelRatio={form.watch('ModelRatio')}
|
||||
cacheRatio={form.watch('CacheRatio')}
|
||||
@@ -137,6 +254,8 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
||||
audioCompletionRatio={form.watch('AudioCompletionRatio')}
|
||||
billingMode={form.watch('BillingMode')}
|
||||
billingExpr={form.watch('BillingExpr')}
|
||||
onSave={handleSave}
|
||||
isSaving={isSaving}
|
||||
onChange={(field, value) => {
|
||||
const fieldMap: Record<string, keyof ModelFormValues> = {
|
||||
'billing_setting.billing_mode': 'BillingMode',
|
||||
@@ -173,155 +292,17 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
||||
</div>
|
||||
) : (
|
||||
<SettingsForm onSubmit={form.handleSubmit(onSave)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ModelPrice'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Model fixed pricing')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'JSON map of model → USD cost per request. Takes precedence over ratio based billing.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ModelRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Model ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'JSON map of model → multiplier applied to quota billing.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='CacheRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Prompt cache ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Optional ratio used when upstream cache hits occur.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='CreateCacheRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Create cache ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Ratio applied when creating cache entries for supported models.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='CompletionRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Completion ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Applies to custom completion endpoints. JSON map of model → ratio.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ImageRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Image ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={6} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Configure per-model ratio for image inputs or outputs.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='AudioRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Audio ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={6} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Ratio applied to audio inputs where supported by the upstream model.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='AudioCompletionRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Audio completion ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={6} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Ratio applied to audio completions for streaming models.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className='grid min-w-0 gap-x-5 gap-y-8 lg:grid-cols-2 2xl:grid-cols-3'>
|
||||
{modelJsonFields.map((config) => (
|
||||
<ModelJsonTextareaField
|
||||
key={config.name}
|
||||
form={form}
|
||||
name={config.name}
|
||||
label={t(config.labelKey)}
|
||||
description={t(config.descriptionKey)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
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
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
@@ -26,6 +26,7 @@ import { toast } from 'sonner'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { resetModelRatios } from '../api'
|
||||
import { SettingsPageTitleStatusPortal } from '../components/settings-page-context'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
import { GroupRatioForm } from './group-ratio-form'
|
||||
@@ -34,169 +35,99 @@ import { ToolPriceSettings } from './tool-price-settings'
|
||||
import { UpstreamRatioSync } from './upstream-ratio-sync'
|
||||
import {
|
||||
formatJsonForTextarea,
|
||||
type JsonValidationError,
|
||||
normalizeJsonString,
|
||||
validateJsonString,
|
||||
} from './utils'
|
||||
|
||||
const modelSchema = z.object({
|
||||
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',
|
||||
})
|
||||
}
|
||||
}),
|
||||
})
|
||||
type Translate = (key: string, options?: Record<string, unknown>) => string
|
||||
|
||||
const groupSchema = z.object({
|
||||
GroupRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
function formatJsonValidationError(
|
||||
t: Translate,
|
||||
error?: JsonValidationError,
|
||||
fallback = 'Invalid JSON'
|
||||
) {
|
||||
if (!error) return t(fallback)
|
||||
|
||||
if (error.type === 'required') return t('Value is required')
|
||||
if (error.type === 'structure') {
|
||||
return t(
|
||||
fallback === 'Invalid JSON' ? 'JSON structure is invalid' : fallback
|
||||
)
|
||||
}
|
||||
|
||||
const parts = [
|
||||
error.line && error.column
|
||||
? t('JSON is invalid at line {{line}}, column {{column}}.', {
|
||||
line: error.line,
|
||||
column: error.column,
|
||||
})
|
||||
: error.position !== undefined
|
||||
? t('JSON is invalid at position {{position}}.', {
|
||||
position: error.position,
|
||||
})
|
||||
: t('JSON is invalid. Please check the syntax.'),
|
||||
]
|
||||
|
||||
if (error.missingCommaLine) {
|
||||
parts.push(
|
||||
t('Check line {{line}} for a missing comma.', {
|
||||
line: error.missingCommaLine,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
function createJsonStringField(
|
||||
t: Translate,
|
||||
options?: Parameters<typeof validateJsonString>[1]
|
||||
) {
|
||||
return z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value, options)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
message: formatJsonValidationError(t, result.error, result.message),
|
||||
})
|
||||
}
|
||||
}),
|
||||
TopupGroupRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
UserUsableGroups: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
GroupGroupRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
AutoGroups: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value, {
|
||||
})
|
||||
}
|
||||
|
||||
const createModelSchema = (t: Translate) =>
|
||||
z.object({
|
||||
ModelPrice: createJsonStringField(t),
|
||||
ModelRatio: createJsonStringField(t),
|
||||
CacheRatio: createJsonStringField(t),
|
||||
CreateCacheRatio: createJsonStringField(t),
|
||||
CompletionRatio: createJsonStringField(t),
|
||||
ImageRatio: createJsonStringField(t),
|
||||
AudioRatio: createJsonStringField(t),
|
||||
AudioCompletionRatio: createJsonStringField(t),
|
||||
ExposeRatioEnabled: z.boolean(),
|
||||
BillingMode: createJsonStringField(t),
|
||||
BillingExpr: createJsonStringField(t),
|
||||
})
|
||||
|
||||
const createGroupSchema = (t: Translate) =>
|
||||
z.object({
|
||||
GroupRatio: createJsonStringField(t),
|
||||
TopupGroupRatio: createJsonStringField(t),
|
||||
UserUsableGroups: createJsonStringField(t),
|
||||
GroupGroupRatio: createJsonStringField(t),
|
||||
AutoGroups: createJsonStringField(t, {
|
||||
predicate: (parsed) =>
|
||||
Array.isArray(parsed) &&
|
||||
parsed.every((item) => typeof item === 'string'),
|
||||
predicateMessage: 'Expected a JSON array of group identifiers',
|
||||
})
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON array',
|
||||
})
|
||||
}
|
||||
}),
|
||||
DefaultUseAutoGroup: z.boolean(),
|
||||
GroupSpecialUsableGroup: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
})
|
||||
}),
|
||||
DefaultUseAutoGroup: z.boolean(),
|
||||
GroupSpecialUsableGroup: createJsonStringField(t),
|
||||
})
|
||||
|
||||
type ModelFormValues = z.infer<typeof modelSchema>
|
||||
type GroupFormValues = z.infer<typeof groupSchema>
|
||||
type ModelFormValues = z.infer<ReturnType<typeof createModelSchema>>
|
||||
type GroupFormValues = z.infer<ReturnType<typeof createGroupSchema>>
|
||||
type RatioTabId = 'models' | 'groups' | 'tool-prices' | 'upstream-sync'
|
||||
|
||||
type RatioSettingsCardProps = {
|
||||
@@ -250,6 +181,9 @@ export function RatioSettingsCard({
|
||||
BillingMode: normalizeJsonString(modelDefaults.BillingMode),
|
||||
BillingExpr: normalizeJsonString(modelDefaults.BillingExpr),
|
||||
})
|
||||
const [savedModelValues, setSavedModelValues] = useState(
|
||||
modelNormalizedDefaults.current
|
||||
)
|
||||
|
||||
const groupNormalizedDefaults = useRef({
|
||||
GroupRatio: normalizeJsonString(groupDefaults.GroupRatio),
|
||||
@@ -262,6 +196,8 @@ export function RatioSettingsCard({
|
||||
groupDefaults.GroupSpecialUsableGroup
|
||||
),
|
||||
})
|
||||
const modelSchema = useMemo(() => createModelSchema(t), [t])
|
||||
const groupSchema = useMemo(() => createGroupSchema(t), [t])
|
||||
|
||||
const modelForm = useForm<ModelFormValues>({
|
||||
resolver: zodResolver(modelSchema),
|
||||
@@ -315,6 +251,7 @@ export function RatioSettingsCard({
|
||||
BillingMode: normalizeJsonString(modelDefaults.BillingMode),
|
||||
BillingExpr: normalizeJsonString(modelDefaults.BillingExpr),
|
||||
}
|
||||
setSavedModelValues(modelNormalizedDefaults.current)
|
||||
|
||||
modelForm.reset({
|
||||
...modelDefaults,
|
||||
@@ -395,6 +332,9 @@ export function RatioSettingsCard({
|
||||
const apiKey = apiKeyMap[key as string] || (key as string)
|
||||
await updateOption.mutateAsync({ key: apiKey, value: normalized[key] })
|
||||
}
|
||||
|
||||
modelNormalizedDefaults.current = normalized
|
||||
setSavedModelValues(normalized)
|
||||
},
|
||||
[t, updateOption]
|
||||
)
|
||||
@@ -462,6 +402,7 @@ export function RatioSettingsCard({
|
||||
return (
|
||||
<ModelRatioForm
|
||||
form={modelForm}
|
||||
savedValues={savedModelValues}
|
||||
onSave={saveModelRatios}
|
||||
onReset={handleResetRatios}
|
||||
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 (
|
||||
<SettingsSection title={t(titleKey)}>
|
||||
<>
|
||||
{visibleTabs.length === 1 ? (
|
||||
renderTabContent(defaultTab)
|
||||
<SettingsSection title={t(titleKey)}>
|
||||
{renderTabContent(defaultTab)}
|
||||
</SettingsSection>
|
||||
) : (
|
||||
<Tabs defaultValue={defaultTab} className='space-y-6'>
|
||||
<TabsList className={`grid w-full ${tabsGridClass}`}>
|
||||
{visibleTabs.map((tab) => (
|
||||
<TabsTrigger key={tab} value={tab}>
|
||||
{t(tabLabels[tab])}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<SettingsPageTitleStatusPortal>
|
||||
{renderTabSwitcher()}
|
||||
</SettingsPageTitleStatusPortal>
|
||||
|
||||
{visibleTabs.map((tab) => (
|
||||
<TabsContent key={tab} value={tab}>
|
||||
{renderTabContent(tab)}
|
||||
</TabsContent>
|
||||
))}
|
||||
<SettingsSection title={t(titleKey)}>
|
||||
{visibleTabs.map((tab) => (
|
||||
<TabsContent key={tab} value={tab}>
|
||||
{renderTabContent(tab)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</SettingsSection>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
@@ -533,6 +484,6 @@ export function RatioSettingsCard({
|
||||
handleConfirm={handleConfirmReset}
|
||||
confirmText={t('Reset')}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+31
-30
@@ -40,6 +40,7 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
@@ -1309,9 +1310,7 @@ function PresetSection({ applyPreset }: PresetSectionProps) {
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Preset templates')}
|
||||
</span>
|
||||
<span className='text-sm font-medium'>{t('Preset templates')}</span>
|
||||
{hasMore && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
@@ -1770,35 +1769,37 @@ export const TieredPricingEditor = memo(function TieredPricingEditor({
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<Label className='text-xs'>{t('Editor mode')}</Label>
|
||||
<Select
|
||||
items={[
|
||||
{ value: 'visual', label: t('Visual editor') },
|
||||
{ value: 'raw', label: t('Expression editor') },
|
||||
]}
|
||||
value={editorMode}
|
||||
onValueChange={(value) => handleModeChange(value as EditorMode)}
|
||||
>
|
||||
<SelectTrigger className='w-44' size='sm'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
<SelectItem value='visual'>{t('Visual editor')}</SelectItem>
|
||||
<SelectItem value='raw'>{t('Expression editor')}</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className='space-y-5'>
|
||||
<div className='grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-end'>
|
||||
<Field className='gap-2'>
|
||||
<FieldLabel>{t('Editor mode')}</FieldLabel>
|
||||
<Select
|
||||
items={[
|
||||
{ value: 'visual', label: t('Visual editor') },
|
||||
{ value: 'raw', label: t('Expression editor') },
|
||||
]}
|
||||
value={editorMode}
|
||||
onValueChange={(value) => handleModeChange(value as EditorMode)}
|
||||
>
|
||||
<SelectTrigger className='w-full sm:w-56' size='sm'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
<SelectItem value='visual'>{t('Visual editor')}</SelectItem>
|
||||
<SelectItem value='raw'>{t('Expression editor')}</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{editorMode === 'raw' && (
|
||||
<div className='sm:pb-0.5'>
|
||||
<LlmPromptHelper modelName={modelName} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap items-start gap-x-4 gap-y-1'>
|
||||
<div className='flex-1'>
|
||||
<PresetSection applyPreset={applyPreset} />
|
||||
</div>
|
||||
{editorMode === 'raw' && <LlmPromptHelper modelName={modelName} />}
|
||||
</div>
|
||||
<PresetSection applyPreset={applyPreset} />
|
||||
|
||||
<div className='bg-muted/30 space-y-3 rounded-md border p-3'>
|
||||
{editorMode === 'visual' ? (
|
||||
|
||||
+47
-4
@@ -49,6 +49,14 @@ type JsonValidationOptions = {
|
||||
predicateMessage?: string
|
||||
}
|
||||
|
||||
export type JsonValidationError = {
|
||||
type: 'required' | 'structure' | 'syntax'
|
||||
line?: number
|
||||
column?: number
|
||||
position?: number
|
||||
missingCommaLine?: number
|
||||
}
|
||||
|
||||
function extractErrorPosition(
|
||||
error: unknown,
|
||||
jsonString: string
|
||||
@@ -81,8 +89,15 @@ function extractErrorPosition(
|
||||
return {}
|
||||
}
|
||||
|
||||
function formatErrorMessage(error: unknown, jsonString: string): string {
|
||||
if (!(error instanceof Error)) return 'Invalid JSON'
|
||||
function buildSyntaxError(
|
||||
error: unknown,
|
||||
jsonString: string
|
||||
): JsonValidationError {
|
||||
if (!(error instanceof Error)) {
|
||||
return {
|
||||
type: 'syntax',
|
||||
} satisfies JsonValidationError
|
||||
}
|
||||
|
||||
const position = extractErrorPosition(error, jsonString)
|
||||
const message = error.message
|
||||
@@ -93,10 +108,29 @@ function formatErrorMessage(error: unknown, jsonString: string): string {
|
||||
message.includes('Expected property name') ||
|
||||
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) {
|
||||
let hint = ''
|
||||
if (isMissingCommaError && position.line > 1) {
|
||||
hint = ` (check line ${position.line - 1} for missing comma)`
|
||||
if (syntaxError.missingCommaLine) {
|
||||
hint = ` (check line ${syntaxError.missingCommaLine} for missing comma)`
|
||||
}
|
||||
return `Error at line ${position.line}, column ${position.column}: ${message}${hint}`
|
||||
}
|
||||
@@ -119,6 +153,11 @@ export function validateJsonString(
|
||||
return {
|
||||
valid: allowEmpty,
|
||||
message: allowEmpty ? undefined : 'Value is required',
|
||||
error: allowEmpty
|
||||
? undefined
|
||||
: ({
|
||||
type: 'required',
|
||||
} satisfies JsonValidationError),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +167,9 @@ export function validateJsonString(
|
||||
return {
|
||||
valid: false,
|
||||
message: predicateMessage || 'JSON structure is invalid',
|
||||
error: {
|
||||
type: 'structure',
|
||||
} satisfies JsonValidationError,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +178,7 @@ export function validateJsonString(
|
||||
return {
|
||||
valid: false,
|
||||
message: formatErrorMessage(error, trimmed),
|
||||
error: buildSyntaxError(error, trimmed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,9 +183,6 @@ function CommonLogsCard<TData>({
|
||||
|
||||
const modelCell = cells.get('model_name')
|
||||
const quotaCell = cells.get('quota')
|
||||
const original = cells.get('created_at')?.row.original as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
|
||||
return (
|
||||
<div className='space-y-2.5'>
|
||||
@@ -203,8 +200,8 @@ function CommonLogsCard<TData>({
|
||||
{t('Time')}
|
||||
</div>
|
||||
<MobileLogTimeStatus
|
||||
createdAt={original?.created_at}
|
||||
type={original?.type}
|
||||
createdAt={cells.get('created_at')?.row.original?.created_at}
|
||||
type={cells.get('created_at')?.row.original?.type}
|
||||
/>
|
||||
</div>
|
||||
<SummaryField
|
||||
|
||||
Vendored
+7
@@ -679,6 +679,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.": "Check line {{line}} for a missing comma.",
|
||||
"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-in failed": "Check-in failed",
|
||||
@@ -1527,6 +1528,7 @@
|
||||
"Expand": "Expand",
|
||||
"Expand All": "Expand All",
|
||||
"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.",
|
||||
"Expiration Time": "Expiration Time",
|
||||
"expired": "expired",
|
||||
@@ -2093,6 +2095,9 @@
|
||||
"JSON Editor": "JSON Editor",
|
||||
"JSON format error": "JSON format error",
|
||||
"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 → 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.",
|
||||
@@ -2101,6 +2106,7 @@
|
||||
"JSON Mode": "JSON Mode",
|
||||
"JSON must be an object": "JSON must be an object",
|
||||
"JSON object:": "JSON object:",
|
||||
"JSON structure is invalid": "JSON structure is invalid",
|
||||
"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.",
|
||||
"Just now": "Just now",
|
||||
@@ -4313,6 +4319,7 @@
|
||||
"Validity Period": "Validity Period",
|
||||
"Value": "Value",
|
||||
"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 Regex": "Value Regex",
|
||||
"variable": "variable",
|
||||
|
||||
Vendored
+7
@@ -679,6 +679,7 @@
|
||||
"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 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 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",
|
||||
@@ -1527,6 +1528,7 @@
|
||||
"Expand": "Développer",
|
||||
"Expand All": "Tout développer",
|
||||
"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.",
|
||||
"Expiration Time": "Heure d'expiration",
|
||||
"expired": "expiré",
|
||||
@@ -2093,6 +2095,9 @@
|
||||
"JSON Editor": "Édition 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 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 → 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.",
|
||||
@@ -2101,6 +2106,7 @@
|
||||
"JSON Mode": "Mode JSON",
|
||||
"JSON must be an object": "Le JSON doit être un objet",
|
||||
"JSON object:": "Objet JSON :",
|
||||
"JSON structure is invalid": "La structure JSON est invalide",
|
||||
"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.",
|
||||
"Just now": "À l'instant",
|
||||
@@ -4313,6 +4319,7 @@
|
||||
"Validity Period": "Période de validité",
|
||||
"Value": "Valeur",
|
||||
"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 Regex": "Regex de valeur",
|
||||
"variable": "variable",
|
||||
|
||||
Vendored
+7
@@ -679,6 +679,7 @@
|
||||
"Check for updates": "更新を確認",
|
||||
"Check in daily to receive random quota rewards": "毎日チェックインして、ランダムなノルマ報酬を受け取りましょう",
|
||||
"Check in now": "今すぐチェックイン",
|
||||
"Check line {{line}} for a missing comma.": "{{line}} 行目にカンマの抜けがないか確認してください。",
|
||||
"Check out the Quick Start": "クイックスタートをご確認ください",
|
||||
"Check resolved IPs against IP filters even when accessing by domain": "ドメインによるアクセスであっても、解決されたIPをIPフィルターと照合してチェックします",
|
||||
"Check-in failed": "チェックインできませんでした",
|
||||
@@ -1527,6 +1528,7 @@
|
||||
"Expand": "展開",
|
||||
"Expand All": "すべて展開",
|
||||
"Expected a JSON array.": "JSON 配列が必要です。",
|
||||
"Expected a JSON array of group identifiers": "グループ識別子の JSON 配列が必要です",
|
||||
"Experiment with prompts and models in real time.": "プロンプトとモデルをリアルタイムで実験する。",
|
||||
"Expiration Time": "有効期限",
|
||||
"expired": "期限切れ",
|
||||
@@ -2093,6 +2095,9 @@
|
||||
"JSON Editor": "JSON編集",
|
||||
"JSON format error": "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 → ratio applied when the user selects the group explicitly.": "ユーザーがグループを明示的に選択したときに適用される、グループ → 比率のJSONマップ。",
|
||||
"JSON map of model → multiplier applied to quota billing.": "モデル → クォータ請求に適用される乗数のJSONマップ。",
|
||||
@@ -2101,6 +2106,7 @@
|
||||
"JSON Mode": "JSONモード",
|
||||
"JSON must be an object": "JSON はオブジェクトである必要があります",
|
||||
"JSON object:": "JSONオブジェクト:",
|
||||
"JSON structure is invalid": "JSON 構造が無効です",
|
||||
"JSON Text": "JSONテキスト",
|
||||
"JSON-based access control rules. Leave empty to allow all users.": "JSONベースのアクセス制御ルール。すべてのユーザーを許可する場合は空のままにしてください。",
|
||||
"Just now": "たった今",
|
||||
@@ -4313,6 +4319,7 @@
|
||||
"Validity Period": "有効期間",
|
||||
"Value": "値",
|
||||
"Value (supports JSON or plain text)": "値(JSONまたはプレーンテキスト対応)",
|
||||
"Value is required": "値は必須です",
|
||||
"Value must be at least 0": "値は 0 以上である必要があります",
|
||||
"Value Regex": "Value 正規表現",
|
||||
"variable": "変数",
|
||||
|
||||
Vendored
+7
@@ -679,6 +679,7 @@
|
||||
"Check for updates": "Проверить обновления",
|
||||
"Check in daily to receive random quota rewards": "Регистрируйтесь ежедневно, чтобы получать случайные вознаграждения по квоте",
|
||||
"Check in now": "Войдите сейчас",
|
||||
"Check line {{line}} for a missing comma.": "Проверьте строку {{line}} на пропущенную запятую.",
|
||||
"Check out the Quick Start": "Ознакомьтесь с быстрым стартом",
|
||||
"Check resolved IPs against IP filters even when accessing by domain": "Проверять разрешенные IP-адреса по IP-фильтрам даже при доступе по домену",
|
||||
"Check-in failed": "Регистрация не удалась.",
|
||||
@@ -1527,6 +1528,7 @@
|
||||
"Expand": "Развернуть",
|
||||
"Expand All": "Развернуть все",
|
||||
"Expected a JSON array.": "Ожидается JSON-массив.",
|
||||
"Expected a JSON array of group identifiers": "Ожидается JSON-массив идентификаторов групп",
|
||||
"Experiment with prompts and models in real time.": "Экспериментируйте с промптами и моделями в реальном времени.",
|
||||
"Expiration Time": "Время истечения срока действия",
|
||||
"expired": "истек",
|
||||
@@ -2093,6 +2095,9 @@
|
||||
"JSON Editor": "Редактирование JSON",
|
||||
"JSON format error": "Ошибка формата 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 → ratio applied when the user selects the group explicitly.": "JSON-карта группы → соотношение, применяемое, когда пользователь явно выбирает группу.",
|
||||
"JSON map of model → multiplier applied to quota billing.": "JSON-карта модели → множитель, применяемый к тарификации по квоте.",
|
||||
@@ -2101,6 +2106,7 @@
|
||||
"JSON Mode": "Режим JSON",
|
||||
"JSON must be an object": "JSON должен быть объектом",
|
||||
"JSON object:": "Объект JSON:",
|
||||
"JSON structure is invalid": "Структура JSON недействительна",
|
||||
"JSON Text": "JSON текст",
|
||||
"JSON-based access control rules. Leave empty to allow all users.": "Правила контроля доступа на основе JSON. Оставьте пустым, чтобы разрешить всем пользователям.",
|
||||
"Just now": "Только что",
|
||||
@@ -4313,6 +4319,7 @@
|
||||
"Validity Period": "Срок действия",
|
||||
"Value": "Значение",
|
||||
"Value (supports JSON or plain text)": "Значение (JSON или текст)",
|
||||
"Value is required": "Значение обязательно",
|
||||
"Value must be at least 0": "Значение должно быть не менее 0",
|
||||
"Value Regex": "Регулярное выражение значения",
|
||||
"variable": "переменная",
|
||||
|
||||
Vendored
+7
@@ -679,6 +679,7 @@
|
||||
"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 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 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",
|
||||
@@ -1527,6 +1528,7 @@
|
||||
"Expand": "Mở rộng",
|
||||
"Expand All": "Mở rộng tất cả",
|
||||
"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.",
|
||||
"Expiration Time": "Thời gian hết hạn",
|
||||
"expired": "Đã hết hạn",
|
||||
@@ -2093,6 +2095,9 @@
|
||||
"JSON Editor": "Trình chỉnh sửa 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 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 → 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.",
|
||||
@@ -2101,6 +2106,7 @@
|
||||
"JSON Mode": "Chế độ JSON",
|
||||
"JSON must be an object": "JSON phải là object",
|
||||
"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-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",
|
||||
@@ -4313,6 +4319,7 @@
|
||||
"Validity Period": "Thời hạn hiệu lực",
|
||||
"Value": "Giá trị",
|
||||
"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 Regex": "Regex giá trị",
|
||||
"variable": "biến",
|
||||
|
||||
Vendored
+7
@@ -679,6 +679,7 @@
|
||||
"Check for updates": "检查更新",
|
||||
"Check in daily to receive random quota rewards": "每日签到可获得随机额度奖励",
|
||||
"Check in now": "立即签到",
|
||||
"Check line {{line}} for a missing comma.": "请检查第 {{line}} 行是否缺少逗号。",
|
||||
"Check out the Quick Start": "请查看 新手入门",
|
||||
"Check resolved IPs against IP filters even when accessing by domain": "即使通过域名访问,也对照 IP 过滤器检查解析的 IP",
|
||||
"Check-in failed": "签到失败",
|
||||
@@ -1527,6 +1528,7 @@
|
||||
"Expand": "展开",
|
||||
"Expand All": "全部展开",
|
||||
"Expected a JSON array.": "应为 JSON 数组。",
|
||||
"Expected a JSON array of group identifiers": "应为分组标识符的 JSON 数组",
|
||||
"Experiment with prompts and models in real time.": "实时实验提示词和模型。",
|
||||
"Expiration Time": "过期时间",
|
||||
"expired": "已过期",
|
||||
@@ -2093,6 +2095,9 @@
|
||||
"JSON Editor": "JSON 编辑",
|
||||
"JSON format error": "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 → ratio applied when the user selects the group explicitly.": "分组 → 比率的 JSON 映射,当用户明确选择该分组时应用此比率。",
|
||||
"JSON map of model → multiplier applied to quota billing.": "模型 → 应用于配额计费的乘数的 JSON 映射。",
|
||||
@@ -2101,6 +2106,7 @@
|
||||
"JSON Mode": "JSON 模式",
|
||||
"JSON must be an object": "JSON 必须是对象",
|
||||
"JSON object:": "JSON 对象:",
|
||||
"JSON structure is invalid": "JSON 结构无效",
|
||||
"JSON Text": "JSON 文本",
|
||||
"JSON-based access control rules. Leave empty to allow all users.": "基于 JSON 的访问控制规则。留空以允许所有用户。",
|
||||
"Just now": "刚刚",
|
||||
@@ -4313,6 +4319,7 @@
|
||||
"Validity Period": "有效期",
|
||||
"Value": "值",
|
||||
"Value (supports JSON or plain text)": "值(支持 JSON 或普通文本)",
|
||||
"Value is required": "值为必填项",
|
||||
"Value must be at least 0": "值必须至少为 0",
|
||||
"Value Regex": "Value 正则",
|
||||
"variable": "变量",
|
||||
|
||||
Vendored
+1
@@ -88,6 +88,7 @@ export const STATIC_I18N_KEYS = [
|
||||
'Failed to delete API key',
|
||||
'Failed to delete API keys',
|
||||
'Failed to update API key status',
|
||||
'Expected a JSON array of group identifiers',
|
||||
'Successfully created {{count}} API Key(s)',
|
||||
'Successfully deleted {{count}} API key(s)',
|
||||
'Enter API key for this channel',
|
||||
|
||||
Reference in New Issue
Block a user