refactor(model-pricing): split visual pricing editor modules
- extract pricing form primitives, snapshot helpers, and table column setup to keep the editor components smaller. - remove draft comparison UI now that switching models discards unsaved edits. - refine the model list with a fixed actions column and tighter mode and price summary display.
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
@@ -61,54 +60,27 @@ import {
|
||||
} from '@/components/ui/sheet'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { sideDrawerContentClassName } from '@/components/drawer-layout'
|
||||
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
|
||||
import {
|
||||
SettingsControlGroup,
|
||||
SettingsSwitchField,
|
||||
} from '../components/settings-form-layout'
|
||||
EMPTY_LANE_ENABLED,
|
||||
EMPTY_LANE_PRICES,
|
||||
buildPreviewRows,
|
||||
createInitialLaneState,
|
||||
createModelPricingSchema,
|
||||
hasValue,
|
||||
laneConfigs,
|
||||
numericDraftRegex,
|
||||
ratioFieldByLane,
|
||||
toNumberOrNull,
|
||||
type LaneKey,
|
||||
type ModelPricingFormValues,
|
||||
type ModelRatioData,
|
||||
type PricingMode,
|
||||
} from './model-pricing-core'
|
||||
import { PriceInput, PriceLane } from './model-pricing-inputs'
|
||||
import { formatPricingNumber } from './pricing-format'
|
||||
import { TieredPricingEditor } from './tiered-pricing-editor'
|
||||
|
||||
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(),
|
||||
})
|
||||
|
||||
type ModelPricingFormValues = z.infer<
|
||||
ReturnType<typeof createModelPricingSchema>
|
||||
>
|
||||
|
||||
type PricingMode = 'per-token' | 'per-request' | 'tiered_expr'
|
||||
type LaneKey =
|
||||
| 'completion'
|
||||
| 'cache'
|
||||
| 'createCache'
|
||||
| 'image'
|
||||
| 'audioInput'
|
||||
| 'audioOutput'
|
||||
|
||||
export type ModelRatioData = {
|
||||
name: string
|
||||
price?: string
|
||||
ratio?: string
|
||||
cacheRatio?: string
|
||||
createCacheRatio?: string
|
||||
completionRatio?: string
|
||||
imageRatio?: string
|
||||
audioRatio?: string
|
||||
audioCompletionRatio?: string
|
||||
billingMode?: PricingMode
|
||||
billingExpr?: string
|
||||
requestRuleExpr?: string
|
||||
}
|
||||
export type { ModelRatioData } from './model-pricing-core'
|
||||
|
||||
type ModelPricingSheetProps = {
|
||||
open: boolean
|
||||
@@ -127,239 +99,6 @@ export type ModelPricingEditorPanelHandle = {
|
||||
commitDraft: () => Promise<ModelRatioData | null>
|
||||
}
|
||||
|
||||
type PreviewRow = {
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
multiline?: boolean
|
||||
}
|
||||
|
||||
const numericDraftRegex = /^(\d+(\.\d*)?|\.\d*)?$/
|
||||
|
||||
const EMPTY_LANE_PRICES: Record<LaneKey, string> = {
|
||||
completion: '',
|
||||
cache: '',
|
||||
createCache: '',
|
||||
image: '',
|
||||
audioInput: '',
|
||||
audioOutput: '',
|
||||
}
|
||||
|
||||
const EMPTY_LANE_ENABLED: Record<LaneKey, boolean> = {
|
||||
completion: false,
|
||||
cache: false,
|
||||
createCache: false,
|
||||
image: false,
|
||||
audioInput: false,
|
||||
audioOutput: false,
|
||||
}
|
||||
|
||||
const ratioFieldByLane: Record<LaneKey, keyof ModelPricingFormValues> = {
|
||||
completion: 'completionRatio',
|
||||
cache: 'cacheRatio',
|
||||
createCache: 'createCacheRatio',
|
||||
image: 'imageRatio',
|
||||
audioInput: 'audioRatio',
|
||||
audioOutput: 'audioCompletionRatio',
|
||||
}
|
||||
|
||||
const laneConfigs: Array<{
|
||||
key: LaneKey
|
||||
titleKey: string
|
||||
descriptionKey: string
|
||||
placeholder: string
|
||||
}> = [
|
||||
{
|
||||
key: 'completion',
|
||||
titleKey: 'Completion price',
|
||||
descriptionKey: 'Output token price for generated tokens.',
|
||||
placeholder: '15',
|
||||
},
|
||||
{
|
||||
key: 'cache',
|
||||
titleKey: 'Cache read price',
|
||||
descriptionKey: 'Token price for cache reads.',
|
||||
placeholder: '0.3',
|
||||
},
|
||||
{
|
||||
key: 'createCache',
|
||||
titleKey: 'Cache write price',
|
||||
descriptionKey: 'Token price for creating cache entries.',
|
||||
placeholder: '3.75',
|
||||
},
|
||||
{
|
||||
key: 'image',
|
||||
titleKey: 'Image input price',
|
||||
descriptionKey: 'Token price for image input.',
|
||||
placeholder: '2.5',
|
||||
},
|
||||
{
|
||||
key: 'audioInput',
|
||||
titleKey: 'Audio input price',
|
||||
descriptionKey: 'Token price for audio input.',
|
||||
placeholder: '3.81',
|
||||
},
|
||||
{
|
||||
key: 'audioOutput',
|
||||
titleKey: 'Audio output price',
|
||||
descriptionKey: 'Token price for audio output.',
|
||||
placeholder: '15.11',
|
||||
},
|
||||
]
|
||||
|
||||
function hasValue(value: unknown): boolean {
|
||||
return (
|
||||
value !== '' && value !== null && value !== undefined && value !== false
|
||||
)
|
||||
}
|
||||
|
||||
function toNumberOrNull(value: unknown): number | null {
|
||||
if (!hasValue(value) && value !== 0) return null
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
|
||||
function ratioToBasePrice(ratio: unknown): string {
|
||||
const num = toNumberOrNull(ratio)
|
||||
if (num === null) return ''
|
||||
return formatPricingNumber(num * 2)
|
||||
}
|
||||
|
||||
function deriveLanePrice(
|
||||
ratio: unknown,
|
||||
denominator: unknown,
|
||||
fallback = ''
|
||||
): string {
|
||||
const ratioNumber = toNumberOrNull(ratio)
|
||||
const denominatorNumber = toNumberOrNull(denominator)
|
||||
if (ratioNumber === null || denominatorNumber === null) return fallback
|
||||
return formatPricingNumber(ratioNumber * denominatorNumber)
|
||||
}
|
||||
|
||||
function createInitialLaneState(data?: ModelRatioData | null) {
|
||||
if (!data) {
|
||||
return {
|
||||
promptPrice: '',
|
||||
prices: { ...EMPTY_LANE_PRICES },
|
||||
enabled: { ...EMPTY_LANE_ENABLED },
|
||||
}
|
||||
}
|
||||
|
||||
const promptPrice = ratioToBasePrice(data.ratio)
|
||||
const audioInputPrice = deriveLanePrice(data.audioRatio, promptPrice)
|
||||
const prices: Record<LaneKey, string> = {
|
||||
completion: deriveLanePrice(data.completionRatio, promptPrice),
|
||||
cache: deriveLanePrice(data.cacheRatio, promptPrice),
|
||||
createCache: deriveLanePrice(data.createCacheRatio, promptPrice),
|
||||
image: deriveLanePrice(data.imageRatio, promptPrice),
|
||||
audioInput: audioInputPrice,
|
||||
audioOutput: deriveLanePrice(data.audioCompletionRatio, audioInputPrice),
|
||||
}
|
||||
|
||||
return {
|
||||
promptPrice,
|
||||
prices,
|
||||
enabled: {
|
||||
completion: hasValue(data.completionRatio),
|
||||
cache: hasValue(data.cacheRatio),
|
||||
createCache: hasValue(data.createCacheRatio),
|
||||
image: hasValue(data.imageRatio),
|
||||
audioInput: hasValue(data.audioRatio),
|
||||
audioOutput: hasValue(data.audioCompletionRatio),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildPreviewRows(
|
||||
values: ModelPricingFormValues,
|
||||
mode: PricingMode,
|
||||
billingExpr: string,
|
||||
requestRuleExpr: string,
|
||||
promptPrice: string,
|
||||
lanePrices: Record<LaneKey, string>,
|
||||
laneEnabled: Record<LaneKey, boolean>,
|
||||
t: (key: string) => string
|
||||
): PreviewRow[] {
|
||||
if (mode === 'tiered_expr') {
|
||||
const effectiveExpr = combineBillingExpr(billingExpr, requestRuleExpr)
|
||||
return [
|
||||
{ key: 'mode', label: 'BillingMode', value: 'tiered_expr' },
|
||||
{
|
||||
key: 'expr',
|
||||
label: t('Expression'),
|
||||
value: effectiveExpr || t('Empty'),
|
||||
multiline: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (mode === 'per-request') {
|
||||
return [
|
||||
{
|
||||
key: 'price',
|
||||
label: 'ModelPrice',
|
||||
value: values.price || t('Empty'),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'inputPrice',
|
||||
label: t('Input price'),
|
||||
value: promptPrice ? `$${promptPrice}` : t('Empty'),
|
||||
},
|
||||
{
|
||||
key: 'completion',
|
||||
label: t('Completion price'),
|
||||
value:
|
||||
laneEnabled.completion && lanePrices.completion
|
||||
? `$${lanePrices.completion}`
|
||||
: t('Empty'),
|
||||
},
|
||||
{
|
||||
key: 'cache',
|
||||
label: t('Cache read price'),
|
||||
value:
|
||||
laneEnabled.cache && lanePrices.cache
|
||||
? `$${lanePrices.cache}`
|
||||
: t('Empty'),
|
||||
},
|
||||
{
|
||||
key: 'createCache',
|
||||
label: t('Cache write price'),
|
||||
value:
|
||||
laneEnabled.createCache && lanePrices.createCache
|
||||
? `$${lanePrices.createCache}`
|
||||
: t('Empty'),
|
||||
},
|
||||
{
|
||||
key: 'image',
|
||||
label: t('Image input price'),
|
||||
value:
|
||||
laneEnabled.image && lanePrices.image
|
||||
? `$${lanePrices.image}`
|
||||
: t('Empty'),
|
||||
},
|
||||
{
|
||||
key: 'audio',
|
||||
label: t('Audio input price'),
|
||||
value:
|
||||
laneEnabled.audioInput && lanePrices.audioInput
|
||||
? `$${lanePrices.audioInput}`
|
||||
: t('Empty'),
|
||||
},
|
||||
{
|
||||
key: 'audioCompletion',
|
||||
label: t('Audio output price'),
|
||||
value:
|
||||
laneEnabled.audioOutput && lanePrices.audioOutput
|
||||
? `$${lanePrices.audioOutput}`
|
||||
: t('Empty'),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export const ModelPricingSheet = forwardRef<
|
||||
ModelPricingEditorPanelHandle,
|
||||
ModelPricingSheetProps
|
||||
@@ -936,65 +675,3 @@ export const ModelPricingEditorPanel = forwardRef<
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function PriceInput(props: {
|
||||
value: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<InputGroup>
|
||||
<InputGroupAddon>$</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
inputMode='decimal'
|
||||
value={props.value}
|
||||
placeholder={props.placeholder}
|
||||
disabled={props.disabled}
|
||||
onChange={(event) => props.onChange(event.target.value)}
|
||||
/>
|
||||
<InputGroupAddon align='inline-end'>$/1M</InputGroupAddon>
|
||||
</InputGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function PriceLane(props: {
|
||||
title: string
|
||||
description: string
|
||||
placeholder: string
|
||||
value: string
|
||||
enabled: boolean
|
||||
disabled?: boolean
|
||||
onEnabledChange: (checked: boolean) => void
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const effectiveDisabled = props.disabled || !props.enabled
|
||||
|
||||
return (
|
||||
<SettingsControlGroup
|
||||
className={cn('space-y-3', effectiveDisabled && 'opacity-75')}
|
||||
data-disabled={effectiveDisabled || undefined}
|
||||
>
|
||||
<SettingsSwitchField
|
||||
checked={props.enabled}
|
||||
disabled={props.disabled}
|
||||
onCheckedChange={props.onEnabledChange}
|
||||
label={props.title}
|
||||
description={props.description}
|
||||
aria-label={props.title}
|
||||
/>
|
||||
<PriceInput
|
||||
value={props.value}
|
||||
placeholder={props.placeholder}
|
||||
disabled={effectiveDisabled}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{props.enabled
|
||||
? t('USD price per 1M tokens.')
|
||||
: t('Disabled lanes are omitted on save.')}
|
||||
</p>
|
||||
</SettingsControlGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { splitBillingExprAndRequestRules } from '@/features/pricing/lib/billing-expr'
|
||||
import { safeJsonParse } from '../utils/json-parser'
|
||||
import { formatPricingNumber } from './pricing-format'
|
||||
|
||||
export type ModelPricingSnapshotInput = {
|
||||
modelPrice: string
|
||||
modelRatio: string
|
||||
cacheRatio: string
|
||||
createCacheRatio: string
|
||||
completionRatio: string
|
||||
imageRatio: string
|
||||
audioRatio: string
|
||||
audioCompletionRatio: string
|
||||
billingMode: string
|
||||
billingExpr: string
|
||||
}
|
||||
|
||||
export type ModelPricingSnapshot = {
|
||||
name: string
|
||||
price?: string
|
||||
ratio?: string
|
||||
cacheRatio?: string
|
||||
createCacheRatio?: string
|
||||
completionRatio?: string
|
||||
imageRatio?: string
|
||||
audioRatio?: string
|
||||
audioCompletionRatio?: string
|
||||
billingMode?: string
|
||||
billingExpr?: string
|
||||
requestRuleExpr?: string
|
||||
hasConflict: boolean
|
||||
}
|
||||
|
||||
export type ModelRow = ModelPricingSnapshot & {
|
||||
saved?: ModelPricingSnapshot
|
||||
draft?: ModelPricingSnapshot
|
||||
isDraftChanged: boolean
|
||||
isDraftDeleted: boolean
|
||||
isDraftNew: boolean
|
||||
}
|
||||
|
||||
export const hasPricingValue = (value?: string) =>
|
||||
value !== undefined && value !== ''
|
||||
|
||||
const toNumberOrNull = (value?: string) => {
|
||||
if (!hasPricingValue(value)) return null
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
|
||||
const ratioToPrice = (ratio?: string, denominator?: string) => {
|
||||
const ratioNumber = toNumberOrNull(ratio)
|
||||
const denominatorNumber = denominator ? toNumberOrNull(denominator) : 2
|
||||
if (ratioNumber === null || denominatorNumber === null) return ''
|
||||
return formatPricingNumber(ratioNumber * denominatorNumber)
|
||||
}
|
||||
|
||||
export const getModeLabel = (mode?: string) => {
|
||||
if (mode === 'per-request') return 'Per-request'
|
||||
if (mode === 'tiered_expr') return 'Expression'
|
||||
return 'Per-token'
|
||||
}
|
||||
|
||||
export const getModeVariant = (
|
||||
mode?: string
|
||||
): 'warning' | 'info' | 'success' => {
|
||||
if (mode === 'per-request') return 'warning'
|
||||
if (mode === 'tiered_expr') return 'info'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
const getExpressionSummary = (
|
||||
row: ModelPricingSnapshot,
|
||||
t: (key: string) => string
|
||||
) => {
|
||||
const tierCount = (row.billingExpr?.match(/tier\(/g) || []).length
|
||||
if (tierCount > 0) {
|
||||
return `${t('Tiered pricing')} · ${tierCount} ${t('tiers')}`
|
||||
}
|
||||
return t('Expression pricing')
|
||||
}
|
||||
|
||||
export const getPriceSummary = (
|
||||
row: ModelPricingSnapshot,
|
||||
t: (key: string) => string
|
||||
) => {
|
||||
if (row.billingMode === 'tiered_expr') {
|
||||
return getExpressionSummary(row, t)
|
||||
}
|
||||
if (row.billingMode === 'per-request') {
|
||||
return row.price ? `$${row.price} / ${t('request')}` : t('Unset price')
|
||||
}
|
||||
|
||||
const inputPrice = ratioToPrice(row.ratio)
|
||||
if (!inputPrice) return t('Unset price')
|
||||
|
||||
const extraCount = [
|
||||
row.completionRatio,
|
||||
row.cacheRatio,
|
||||
row.createCacheRatio,
|
||||
row.imageRatio,
|
||||
row.audioRatio,
|
||||
row.audioCompletionRatio,
|
||||
].filter(hasPricingValue).length
|
||||
|
||||
return extraCount > 0
|
||||
? `${t('Input')} $${inputPrice} · ${extraCount} ${t('extras')}`
|
||||
: `${t('Input')} $${inputPrice}`
|
||||
}
|
||||
|
||||
export const getPriceDetail = (
|
||||
row: ModelPricingSnapshot,
|
||||
t: (key: string) => string
|
||||
) => {
|
||||
if (row.billingMode === 'tiered_expr') {
|
||||
return row.requestRuleExpr
|
||||
? t('Includes request rules')
|
||||
: t('Expression based')
|
||||
}
|
||||
if (row.billingMode === 'per-request') {
|
||||
return t('Fixed request price')
|
||||
}
|
||||
|
||||
const inputPrice = ratioToPrice(row.ratio)
|
||||
if (!inputPrice) return t('No base input price')
|
||||
|
||||
const details = [
|
||||
row.completionRatio &&
|
||||
`${t('Output')} $${ratioToPrice(row.completionRatio, inputPrice)}`,
|
||||
row.cacheRatio &&
|
||||
`${t('Cache')} $${ratioToPrice(row.cacheRatio, inputPrice)}`,
|
||||
row.createCacheRatio &&
|
||||
`${t('Cache write')} $${ratioToPrice(row.createCacheRatio, inputPrice)}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
|
||||
return details.length > 0 ? details.join(' · ') : t('Base input price only')
|
||||
}
|
||||
|
||||
export const buildModelSnapshots = ({
|
||||
modelPrice,
|
||||
modelRatio,
|
||||
cacheRatio,
|
||||
createCacheRatio,
|
||||
completionRatio,
|
||||
imageRatio,
|
||||
audioRatio,
|
||||
audioCompletionRatio,
|
||||
billingMode,
|
||||
billingExpr,
|
||||
}: ModelPricingSnapshotInput): ModelPricingSnapshot[] => {
|
||||
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
|
||||
fallback: {},
|
||||
context: 'model prices',
|
||||
})
|
||||
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
|
||||
fallback: {},
|
||||
context: 'model ratios',
|
||||
})
|
||||
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
|
||||
fallback: {},
|
||||
context: 'cache ratios',
|
||||
})
|
||||
const createCacheMap = safeJsonParse<Record<string, number>>(
|
||||
createCacheRatio,
|
||||
{ fallback: {}, context: 'create cache ratios' }
|
||||
)
|
||||
const completionMap = safeJsonParse<Record<string, number>>(completionRatio, {
|
||||
fallback: {},
|
||||
context: 'completion ratios',
|
||||
})
|
||||
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
|
||||
fallback: {},
|
||||
context: 'image ratios',
|
||||
})
|
||||
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
|
||||
fallback: {},
|
||||
context: 'audio ratios',
|
||||
})
|
||||
const audioCompletionMap = safeJsonParse<Record<string, number>>(
|
||||
audioCompletionRatio,
|
||||
{ fallback: {}, context: 'audio completion ratios' }
|
||||
)
|
||||
const billingModeMap = safeJsonParse<Record<string, string>>(billingMode, {
|
||||
fallback: {},
|
||||
context: 'billing mode',
|
||||
})
|
||||
const billingExprMap = safeJsonParse<Record<string, string>>(billingExpr, {
|
||||
fallback: {},
|
||||
context: 'billing expression',
|
||||
})
|
||||
|
||||
const modelNames = new Set([
|
||||
...Object.keys(priceMap),
|
||||
...Object.keys(ratioMap),
|
||||
...Object.keys(cacheMap),
|
||||
...Object.keys(createCacheMap),
|
||||
...Object.keys(completionMap),
|
||||
...Object.keys(imageMap),
|
||||
...Object.keys(audioMap),
|
||||
...Object.keys(audioCompletionMap),
|
||||
...Object.keys(billingModeMap),
|
||||
...Object.keys(billingExprMap),
|
||||
])
|
||||
|
||||
return Array.from(modelNames).map((name) => {
|
||||
const price = priceMap[name]?.toString() || ''
|
||||
const ratio = ratioMap[name]?.toString() || ''
|
||||
const cache = cacheMap[name]?.toString() || ''
|
||||
const createCache = createCacheMap[name]?.toString() || ''
|
||||
const completion = completionMap[name]?.toString() || ''
|
||||
const image = imageMap[name]?.toString() || ''
|
||||
const audio = audioMap[name]?.toString() || ''
|
||||
const audioCompletion = audioCompletionMap[name]?.toString() || ''
|
||||
|
||||
const modeForModel = billingModeMap[name]
|
||||
if (modeForModel === 'tiered_expr') {
|
||||
const fullExpr = billingExprMap[name] || ''
|
||||
const { billingExpr: pureExpr, requestRuleExpr } =
|
||||
splitBillingExprAndRequestRules(fullExpr)
|
||||
return {
|
||||
name,
|
||||
billingMode: 'tiered_expr',
|
||||
billingExpr: pureExpr,
|
||||
requestRuleExpr,
|
||||
price,
|
||||
ratio,
|
||||
cacheRatio: cache,
|
||||
createCacheRatio: createCache,
|
||||
completionRatio: completion,
|
||||
imageRatio: image,
|
||||
audioRatio: audio,
|
||||
audioCompletionRatio: audioCompletion,
|
||||
hasConflict: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
price,
|
||||
ratio,
|
||||
cacheRatio: cache,
|
||||
createCacheRatio: createCache,
|
||||
completionRatio: completion,
|
||||
imageRatio: image,
|
||||
audioRatio: audio,
|
||||
audioCompletionRatio: audioCompletion,
|
||||
billingMode: price !== '' ? 'per-request' : 'per-token',
|
||||
hasConflict:
|
||||
price !== '' &&
|
||||
(ratio !== '' ||
|
||||
completion !== '' ||
|
||||
cache !== '' ||
|
||||
createCache !== '' ||
|
||||
image !== '' ||
|
||||
audio !== '' ||
|
||||
audioCompletion !== ''),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const getSnapshotSignature = (snapshot?: ModelPricingSnapshot) => {
|
||||
if (!snapshot) return ''
|
||||
return JSON.stringify({
|
||||
price: snapshot.price || '',
|
||||
ratio: snapshot.ratio || '',
|
||||
cacheRatio: snapshot.cacheRatio || '',
|
||||
createCacheRatio: snapshot.createCacheRatio || '',
|
||||
completionRatio: snapshot.completionRatio || '',
|
||||
imageRatio: snapshot.imageRatio || '',
|
||||
audioRatio: snapshot.audioRatio || '',
|
||||
audioCompletionRatio: snapshot.audioCompletionRatio || '',
|
||||
billingMode: snapshot.billingMode || 'per-token',
|
||||
billingExpr: snapshot.billingExpr || '',
|
||||
requestRuleExpr: snapshot.requestRuleExpr || '',
|
||||
})
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
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',
|
||||
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,
|
||||
},
|
||||
]
|
||||
}
|
||||
+32
-444
@@ -27,7 +27,6 @@ import {
|
||||
useRef,
|
||||
} from 'react'
|
||||
import {
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type OnChangeFn,
|
||||
type PaginationState,
|
||||
@@ -44,22 +43,17 @@ import {
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { Copy, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { Copy, Plus } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
DataTableBulkActions,
|
||||
DataTableColumnHeader,
|
||||
DataTableToolbar,
|
||||
DataTablePagination,
|
||||
} from '@/components/data-table'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
combineBillingExpr,
|
||||
splitBillingExprAndRequestRules,
|
||||
} from '@/features/pricing/lib/billing-expr'
|
||||
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
|
||||
import { safeJsonParse } from '../utils/json-parser'
|
||||
import {
|
||||
ModelPricingEditorPanel,
|
||||
@@ -67,7 +61,12 @@ import {
|
||||
ModelPricingSheet,
|
||||
type ModelRatioData,
|
||||
} from './model-pricing-sheet'
|
||||
import { formatPricingNumber } from './pricing-format'
|
||||
import {
|
||||
buildModelSnapshots,
|
||||
getSnapshotSignature,
|
||||
type ModelRow,
|
||||
} from './model-pricing-snapshots'
|
||||
import { buildModelRatioColumns } from './model-ratio-table-columns'
|
||||
|
||||
type ModelRatioVisualEditorProps = {
|
||||
savedModelPrice: string
|
||||
@@ -93,289 +92,12 @@ type ModelRatioVisualEditorProps = {
|
||||
onChange: (field: string, value: string) => void
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type ModelRow = ModelPricingSnapshot & {
|
||||
saved?: ModelPricingSnapshot
|
||||
draft?: ModelPricingSnapshot
|
||||
isDraftChanged: boolean
|
||||
isDraftDeleted: boolean
|
||||
isDraftNew: boolean
|
||||
}
|
||||
|
||||
export type ModelRatioVisualEditorHandle = {
|
||||
commitOpenEditor: () => Promise<boolean>
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'model-ratio-column-visibility'
|
||||
|
||||
const hasValue = (value?: string) => value !== undefined && value !== ''
|
||||
|
||||
const toNumberOrNull = (value?: string) => {
|
||||
if (!hasValue(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)
|
||||
}
|
||||
|
||||
const filterBySelectedValues = (
|
||||
rowValue: unknown,
|
||||
filterValue: unknown
|
||||
): boolean => {
|
||||
if (!Array.isArray(filterValue) || filterValue.length === 0) return true
|
||||
return filterValue.includes(String(rowValue))
|
||||
}
|
||||
|
||||
const getModeLabel = (mode?: string) => {
|
||||
if (mode === 'per-request') return 'Per-request'
|
||||
if (mode === 'tiered_expr') return 'Expression'
|
||||
return 'Per-token'
|
||||
}
|
||||
|
||||
const getModeVariant = (mode?: string): 'warning' | 'info' | 'success' => {
|
||||
if (mode === 'per-request') return 'warning'
|
||||
if (mode === 'tiered_expr') return 'info'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
const getExpressionSummary = (
|
||||
row: 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')
|
||||
}
|
||||
|
||||
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(hasValue).length
|
||||
|
||||
return extraCount > 0
|
||||
? `${t('Input')} $${inputPrice} · ${extraCount} ${t('extras')}`
|
||||
: `${t('Input')} $${inputPrice}`
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
return details.length > 0 ? details.join(' · ') : t('Base input price only')
|
||||
}
|
||||
|
||||
const buildModelSnapshots = ({
|
||||
modelPrice,
|
||||
modelRatio,
|
||||
cacheRatio,
|
||||
createCacheRatio,
|
||||
completionRatio,
|
||||
imageRatio,
|
||||
audioRatio,
|
||||
audioCompletionRatio,
|
||||
billingMode,
|
||||
billingExpr,
|
||||
}: Pick<
|
||||
ModelRatioVisualEditorProps,
|
||||
| 'modelPrice'
|
||||
| 'modelRatio'
|
||||
| 'cacheRatio'
|
||||
| 'createCacheRatio'
|
||||
| 'completionRatio'
|
||||
| 'imageRatio'
|
||||
| 'audioRatio'
|
||||
| 'audioCompletionRatio'
|
||||
| 'billingMode'
|
||||
| 'billingExpr'
|
||||
>): 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 !== ''),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 || '',
|
||||
})
|
||||
}
|
||||
|
||||
const ModelRatioVisualEditorComponent = forwardRef<
|
||||
ModelRatioVisualEditorHandle,
|
||||
ModelRatioVisualEditorProps
|
||||
@@ -688,159 +410,15 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
||||
]
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<ModelRow>[]>(() => {
|
||||
return [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
indeterminate={table.getIsSomePageRowsSelected()}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
aria-label={t('Select all')}
|
||||
className='translate-y-[2px]'
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label={t('Select row')}
|
||||
className='translate-y-[2px]'
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
meta: { label: t('Select') },
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Model name')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex items-center gap-2 font-medium'>
|
||||
{row.getValue('name')}
|
||||
{row.original.isDraftChanged && (
|
||||
<StatusBadge
|
||||
label={t('Draft')}
|
||||
variant={row.original.isDraftDeleted ? 'danger' : 'warning'}
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
{row.original.billingMode === 'tiered_expr' && (
|
||||
<StatusBadge
|
||||
label={t('Tiered')}
|
||||
variant='info'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
{row.original.hasConflict && (
|
||||
<StatusBadge
|
||||
label={t('Conflict')}
|
||||
variant='danger'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'billingMode',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Mode')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge
|
||||
label={t(getModeLabel(row.original.billingMode))}
|
||||
variant={getModeVariant(row.original.billingMode)}
|
||||
copyable={false}
|
||||
/>
|
||||
),
|
||||
filterFn: (row, id, value) =>
|
||||
filterBySelectedValues(row.getValue(id), value),
|
||||
meta: { label: t('Mode') },
|
||||
},
|
||||
{
|
||||
id: 'priceSummary',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Price summary')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex min-w-[180px] flex-col gap-2'>
|
||||
<div className='flex 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>
|
||||
{row.original.isDraftChanged && (
|
||||
<div className='border-warning/45 bg-warning/10 text-foreground flex max-w-[360px] flex-col gap-1 rounded-md border px-2.5 py-2 shadow-sm'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<StatusBadge
|
||||
label={t('Draft')}
|
||||
variant={row.original.isDraftDeleted ? 'danger' : 'warning'}
|
||||
copyable={false}
|
||||
className='bg-background/70'
|
||||
/>
|
||||
{!row.original.isDraftDeleted && row.original.draft && (
|
||||
<StatusBadge
|
||||
label={t(getModeLabel(row.original.draft.billingMode))}
|
||||
variant={getModeVariant(row.original.draft.billingMode)}
|
||||
copyable={false}
|
||||
className='bg-background/70'
|
||||
/>
|
||||
)}
|
||||
<span className='truncate text-sm font-medium'>
|
||||
{row.original.isDraftDeleted
|
||||
? t('Will be removed')
|
||||
: getPriceSummary(row.original.draft ?? row.original, t)}
|
||||
</span>
|
||||
</div>
|
||||
{!row.original.isDraftDeleted && row.original.draft && (
|
||||
<span className='text-muted-foreground truncate text-xs'>
|
||||
{getPriceDetail(row.original.draft, t)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
sortingFn: (rowA, rowB) =>
|
||||
getPriceSummary(rowA.original, t).localeCompare(
|
||||
getPriceSummary(rowB.original, t)
|
||||
),
|
||||
meta: { label: t('Price summary') },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleEdit(row.original)}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleDelete(row.original.name)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
]
|
||||
}, [handleEdit, handleDelete, t])
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
buildModelRatioColumns({
|
||||
onDelete: handleDelete,
|
||||
onEdit: handleEdit,
|
||||
t,
|
||||
}),
|
||||
[handleEdit, handleDelete, t]
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: models,
|
||||
@@ -1101,7 +679,11 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
||||
<th
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
className='text-foreground h-10 px-2 text-left align-middle text-sm font-medium whitespace-nowrap'
|
||||
className={cn(
|
||||
'text-foreground h-10 px-2 text-left align-middle text-sm font-medium whitespace-nowrap',
|
||||
header.column.id === 'actions' &&
|
||||
'bg-background sticky right-0 z-20 w-24 min-w-24 shadow-[-10px_0_14px_-14px_hsl(var(--foreground))]'
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
@@ -1121,8 +703,8 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
||||
data-state={row.getIsSelected() ? 'selected' : undefined}
|
||||
className={
|
||||
editData?.name === row.original.name
|
||||
? 'bg-muted/45 hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors'
|
||||
: 'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors'
|
||||
? 'bg-muted/45 hover:bg-muted/50 data-[state=selected]:bg-muted group border-b transition-colors'
|
||||
: 'hover:bg-muted/50 data-[state=selected]:bg-muted group border-b transition-colors'
|
||||
}
|
||||
onClick={(event) => {
|
||||
const target = event.target as HTMLElement
|
||||
@@ -1133,7 +715,13 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td
|
||||
key={cell.id}
|
||||
className='p-2 align-middle text-sm whitespace-nowrap'
|
||||
className={cn(
|
||||
'p-2 align-middle text-sm whitespace-nowrap',
|
||||
cell.column.id === 'actions' &&
|
||||
(editData?.name === row.original.name
|
||||
? 'bg-muted/45 group-hover:bg-muted/50 group-data-[state=selected]:bg-muted sticky right-0 z-10 w-24 min-w-24 shadow-[-10px_0_14px_-14px_hsl(var(--foreground))]'
|
||||
: 'bg-background group-hover:bg-muted/50 group-data-[state=selected]:bg-muted sticky right-0 z-10 w-24 min-w-24 shadow-[-10px_0_14px_-14px_hsl(var(--foreground))]')
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
|
||||
Vendored
-2
@@ -1253,7 +1253,6 @@
|
||||
"DoubaoVideo": "DoubaoVideo",
|
||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "Double check the configuration below. Your system will be locked until initialization is complete.",
|
||||
"Download": "Download",
|
||||
"Draft": "Draft",
|
||||
"Draw": "Draw",
|
||||
"Drawing": "Drawing",
|
||||
"Drawing logs": "Drawing logs",
|
||||
@@ -4415,7 +4414,6 @@
|
||||
"We could not load the setup status.": "We could not load the setup status.",
|
||||
"We will prompt your device to confirm using biometrics or your hardware key.": "We will prompt your device to confirm using biometrics or your hardware key.",
|
||||
"We'll be back online shortly.": "We'll be back online shortly.",
|
||||
"Will be removed": "Will be removed",
|
||||
"Web search": "Web search",
|
||||
"Web Search": "Web Search",
|
||||
"Webhook Configuration:": "Webhook Configuration:",
|
||||
|
||||
Vendored
-2
@@ -1253,7 +1253,6 @@
|
||||
"DoubaoVideo": "DoubaoVideo",
|
||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "Vérifiez la configuration ci-dessous. Votre système sera verrouillé jusqu'à ce que l'initialisation soit terminée.",
|
||||
"Download": "Télécharger",
|
||||
"Draft": "Brouillon",
|
||||
"Draw": "Dessin",
|
||||
"Drawing": "Dessin",
|
||||
"Drawing logs": "Journaux de dessin",
|
||||
@@ -4415,7 +4414,6 @@
|
||||
"We could not load the setup status.": "Nous n'avons pas pu charger l'état de la configuration.",
|
||||
"We will prompt your device to confirm using biometrics or your hardware key.": "Nous allons demander à votre appareil de confirmer en utilisant la biométrie ou votre clé matérielle.",
|
||||
"We'll be back online shortly.": "Nous serons de retour en ligne sous peu.",
|
||||
"Will be removed": "Sera supprimé",
|
||||
"Web search": "Recherche web",
|
||||
"Web Search": "Recherche web",
|
||||
"Webhook Configuration:": "Configuration du Webhook :",
|
||||
|
||||
Vendored
-2
@@ -1253,7 +1253,6 @@
|
||||
"DoubaoVideo": "DoubaoVideo",
|
||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "下記の設定を再確認してください。初期化が完了するまでシステムはロックされます。",
|
||||
"Download": "ダウンロード",
|
||||
"Draft": "下書き",
|
||||
"Draw": "描画",
|
||||
"Drawing": "画像生成",
|
||||
"Drawing logs": "描画ログ",
|
||||
@@ -4415,7 +4414,6 @@
|
||||
"We could not load the setup status.": "セットアップステータスを読み込めませんでした。",
|
||||
"We will prompt your device to confirm using biometrics or your hardware key.": "生体認証またはハードウェアキーを使用して確認するよう、デバイスにプロンプトが表示されます。",
|
||||
"We'll be back online shortly.": "まもなくオンラインに戻ります。",
|
||||
"Will be removed": "削除予定",
|
||||
"Web search": "ウェブ検索",
|
||||
"Web Search": "Web 検索",
|
||||
"Webhook Configuration:": "Webhook設定:",
|
||||
|
||||
Vendored
-2
@@ -1253,7 +1253,6 @@
|
||||
"DoubaoVideo": "DoubaoVideo",
|
||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "Дважды проверьте конфигурацию ниже. Ваша система будет заблокирована до завершения инициализации.",
|
||||
"Download": "Скачать",
|
||||
"Draft": "Черновик",
|
||||
"Draw": "Рисование",
|
||||
"Drawing": "Рисование",
|
||||
"Drawing logs": "Журналы рисования",
|
||||
@@ -4415,7 +4414,6 @@
|
||||
"We could not load the setup status.": "Не удалось загрузить статус настройки.",
|
||||
"We will prompt your device to confirm using biometrics or your hardware key.": "Мы предложим вашему устройству подтвердить действие с помощью биометрии или аппаратного ключа.",
|
||||
"We'll be back online shortly.": "Мы скоро вернемся в сеть.",
|
||||
"Will be removed": "Будет удалено",
|
||||
"Web search": "Веб-поиск",
|
||||
"Web Search": "Веб-поиск",
|
||||
"Webhook Configuration:": "Конфигурация веб-хука:",
|
||||
|
||||
Vendored
-2
@@ -1253,7 +1253,6 @@
|
||||
"DoubaoVideo": "DoubaoVideo",
|
||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "Kiểm tra kỹ lại cấu hình bên dưới. Hệ thống của bạn sẽ bị khóa cho đến khi quá trình khởi tạo hoàn tất.",
|
||||
"Download": "Tải xuống",
|
||||
"Draft": "Bản nháp",
|
||||
"Draw": "Vẽ",
|
||||
"Drawing": "Vẽ",
|
||||
"Drawing logs": "Nhật ký vẽ",
|
||||
@@ -4415,7 +4414,6 @@
|
||||
"We could not load the setup status.": "Chúng tôi không thể tải trạng thái thiết lập.",
|
||||
"We will prompt your device to confirm using biometrics or your hardware key.": "Chúng tôi sẽ yêu cầu thiết bị của bạn xác nhận bằng cách sử dụng sinh trắc học hoặc khóa bảo mật phần cứng của bạn.",
|
||||
"We'll be back online shortly.": "Chúng tôi sẽ sớm trực tuyến trở lại.",
|
||||
"Will be removed": "Sẽ bị xóa",
|
||||
"Web search": "Tìm kiếm web",
|
||||
"Web Search": "Tìm kiếm web",
|
||||
"Webhook Configuration:": "Cấu hình Webhook:",
|
||||
|
||||
Vendored
-2
@@ -1253,7 +1253,6 @@
|
||||
"DoubaoVideo": "DoubaoVideo",
|
||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "仔细检查以下配置。您的系统将在初始化完成前保持锁定状态。",
|
||||
"Download": "下载",
|
||||
"Draft": "草稿",
|
||||
"Draw": "绘图",
|
||||
"Drawing": "绘图",
|
||||
"Drawing logs": "绘制日志",
|
||||
@@ -4415,7 +4414,6 @@
|
||||
"We could not load the setup status.": "我们无法加载设置状态。",
|
||||
"We will prompt your device to confirm using biometrics or your hardware key.": "我们将提示您的设备使用生物识别或硬件密钥进行确认。",
|
||||
"We'll be back online shortly.": "我们将很快恢复在线。",
|
||||
"Will be removed": "将被移除",
|
||||
"Web search": "网络搜索",
|
||||
"Web Search": "网页搜索",
|
||||
"Webhook Configuration:": "Webhook 配置:",
|
||||
|
||||
Reference in New Issue
Block a user