Commit 75e53320 by CaIon

feat(pricing): support site currency in pricing editors

parent bee45b58
/*
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 { formatPricingNumber } from '@/features/system-settings/models/pricing-format'
import type { CurrencyConfig } from '@/stores/system-config-store'
export type PricingCurrency = {
label: string
symbol: string
exchangeRate: number
}
export const USD_PRICING_CURRENCY: PricingCurrency = {
label: 'USD',
symbol: '$',
exchangeRate: 1,
}
export function getSitePricingCurrency(
config: CurrencyConfig
): PricingCurrency | null {
if (config.quotaDisplayType === 'CNY') {
return { label: 'CNY', symbol: '¥', exchangeRate: config.usdExchangeRate }
}
if (config.quotaDisplayType === 'CUSTOM') {
const symbol = config.customCurrencySymbol?.trim() || '¤'
return {
label: symbol,
symbol,
exchangeRate: config.customCurrencyExchangeRate,
}
}
return null
}
export function isValidPricingCurrency(
currency: PricingCurrency | null
): currency is PricingCurrency {
return (
currency !== null &&
Number.isFinite(currency.exchangeRate) &&
currency.exchangeRate > 0
)
}
export function formatPricingAmount(
value: string | number,
currency = USD_PRICING_CURRENCY
): string {
if (value === '') return ''
const amount = Number(value) * currency.exchangeRate
if (!Number.isFinite(amount)) return '—'
return `${currency.symbol}${formatPricingNumber(amount)}`
}
/*
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 { useId, useState, type ComponentProps } from 'react'
import { useTranslation } from 'react-i18next'
import { Input } from '@/components/ui/input'
import { InputGroupInput } from '@/components/ui/input-group'
import { formatPricingNumber } from '@/features/system-settings/models/pricing-format'
import { USD_PRICING_CURRENCY, type PricingCurrency } from './currency'
type PricingAmountInputProps = Omit<
ComponentProps<'input'>,
'value' | 'onChange' | 'type'
> & {
value: string | number
onChange: (usd: string) => void
currency?: PricingCurrency
grouped?: boolean
}
/** The parent owns USD; only this input owns the uncommitted display string. */
export function PricingAmountInput({
value,
onChange,
currency = USD_PRICING_CURRENCY,
grouped,
...props
}: PricingAmountInputProps) {
const { t } = useTranslation()
const errorId = useId()
const source = String(value)
const [draft, setDraft] = useState<{
text: string
source: string
rate: number
} | null>(null)
const displayAmount = Number(value) * currency.exchangeRate
let displayed = ''
if (value !== '') {
displayed = String(displayAmount)
if (Number.isFinite(displayAmount)) {
displayed = Number(formatPricingNumber(displayAmount)).toLocaleString(
'en-US',
{
useGrouping: false,
maximumFractionDigits: 12,
}
)
}
}
const text =
draft?.source === source && draft.rate === currency.exchangeRate
? draft.text
: displayed
const amount = text === '' || text === '.' ? 0 : Number(text)
const usd = amount / currency.exchangeRate
const invalid =
!Number.isFinite(amount) ||
amount < 0 ||
!Number.isFinite(usd) ||
!Number.isFinite(displayAmount)
const error = invalid
? t('The converted price must be a finite, non-negative number.')
: ''
const Control = grouped ? InputGroupInput : Input
return (
<>
<Control
{...props}
ref={(element) => {
element?.setCustomValidity(error)
if (typeof props.ref === 'function') return props.ref(element)
if (props.ref) props.ref.current = element
}}
data-pricing-amount=''
type='text'
inputMode='decimal'
value={text}
aria-invalid={invalid || props['aria-invalid'] || undefined}
aria-describedby={
[props['aria-describedby'], invalid ? errorId : '']
.filter(Boolean)
.join(' ') || undefined
}
onChange={(event) => {
const next = event.target.value
if (!/^(\d+(\.\d*)?|\.\d*)?$/.test(next)) return
const nextAmount = next === '.' || next === '' ? 0 : Number(next)
const nextUSD = nextAmount / currency.exchangeRate
if (!Number.isFinite(nextUSD) || !Number.isFinite(nextAmount)) {
setDraft({ text: next, source, rate: currency.exchangeRate })
return
}
const canonical =
next === '' || next === '.' ? '' : formatPricingNumber(nextUSD)
const nextSource =
typeof value === 'number' ? String(Number(canonical)) : canonical
setDraft({
text: next,
source: nextSource,
rate: currency.exchangeRate,
})
if (nextSource !== source) onChange(canonical)
}}
onFocus={(event) => {
props.onFocus?.(event)
if (Number(event.currentTarget.value) === 0) {
event.currentTarget.select()
}
}}
/>
{invalid && (
<span
id={errorId}
role='alert'
data-pricing-error=''
className={
grouped
? 'text-destructive order-[10000] w-full px-2.5 pb-1 text-xs'
: 'text-destructive block text-xs'
}
>
{error}
</span>
)}
</>
)
}
/*
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 { HelpCircle } from 'lucide-react'
import { useId } from 'react'
import { useTranslation } from 'react-i18next'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Field, FieldLabel } from '@/components/ui/field'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { usePricingPreferencesStore } from '@/stores/pricing-preferences-store'
import { isValidPricingCurrency, type PricingCurrency } from './currency'
export function PricingCurrencySelector(props: {
siteCurrency: PricingCurrency | null
}) {
const { t } = useTranslation()
const id = useId()
const preference = usePricingPreferencesStore((state) => state.currency)
const setCurrency = usePricingPreferencesStore((state) => state.setCurrency)
const available = isValidPricingCurrency(props.siteCurrency)
const value = available && preference === 'site' ? 'site' : 'USD'
const items = [{ value: 'USD', label: t('US dollar (USD)') }]
if (props.siteCurrency) {
items.push({
value: 'site',
label: t('Site currency ({{currency}})', {
currency: props.siteCurrency.label,
}),
})
}
return (
<Field className='mb-4 gap-2'>
<div className='flex items-center gap-1'>
<FieldLabel htmlFor={id}>{t('Pricing currency')}</FieldLabel>
<Dialog
title={t('About pricing currency')}
contentClassName='sm:max-w-lg'
trigger={
<Button
type='button'
size='icon-sm'
variant='ghost'
aria-label={t('About pricing currency')}
>
<HelpCircle aria-hidden='true' />
</Button>
}
>
<p className='text-sm'>
{t(
'The system always bills in USD. Site currency makes entering and converting prices easier; amounts are converted to USD using the site exchange rate. Switching currencies does not change the actual price. Raw billing expressions always use USD.'
)}
</p>
{available && props.siteCurrency && (
<p className='mt-3 text-sm'>
{t('Current exchange rate: 1 USD = {{rate}} {{currency}}', {
rate: String(props.siteCurrency.exchangeRate),
currency: props.siteCurrency.label,
})}
</p>
)}
</Dialog>
</div>
<Select
items={items}
value={value}
onValueChange={(next) => {
if (next === 'USD' || (next === 'site' && available)) {
setCurrency(next)
}
}}
>
<SelectTrigger
id={id}
className='w-full sm:w-64'
aria-describedby={
props.siteCurrency && !available ? `${id}-error` : undefined
}
>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{items.map((item) => (
<SelectItem
key={item.value}
value={item.value}
disabled={item.value === 'site' && !available}
>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
{props.siteCurrency && !available && (
<p id={`${id}-error`} className='text-muted-foreground text-xs'>
{t('The site exchange rate is invalid. Prices are shown in USD.')}
</p>
)}
</Field>
)
}
......@@ -18,6 +18,11 @@ For commercial licensing, please contact support@quantumnous.com
*/
import * as z from 'zod'
import {
formatPricingAmount,
USD_PRICING_CURRENCY,
type PricingCurrency,
} from '@/features/model-pricing/currency'
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
import { formatPricingNumber } from './pricing-format'
......@@ -71,8 +76,6 @@ export type PreviewRow = {
multiline?: boolean
}
export const numericDraftRegex = /^(\d+(\.\d*)?|\.\d*)?$/
export const EMPTY_LANE_PRICES: Record<LaneKey, string> = {
completion: '',
cache: '',
......@@ -215,7 +218,8 @@ export function buildPreviewRows(
promptPrice: string,
lanePrices: Record<LaneKey, string>,
laneEnabled: Record<LaneKey, boolean>,
t: (key: string) => string
t: (key: string) => string,
currency: PricingCurrency = USD_PRICING_CURRENCY
): PreviewRow[] {
if (mode === 'tiered_expr') {
const effectiveExpr = combineBillingExpr(billingExpr, requestRuleExpr)
......@@ -223,7 +227,7 @@ export function buildPreviewRows(
{ key: 'mode', label: t('Pricing'), value: t('Expression') },
{
key: 'expr',
label: t('Expression'),
label: `${t('Expression')} (USD)`,
value: effectiveExpr || t('Empty'),
multiline: true,
},
......@@ -235,7 +239,9 @@ export function buildPreviewRows(
{
key: 'price',
label: t('Fixed price'),
value: values.price || t('Empty'),
value: values.price
? formatPricingAmount(values.price, currency)
: t('Empty'),
},
]
}
......@@ -244,14 +250,16 @@ export function buildPreviewRows(
{
key: 'inputPrice',
label: t('Input price'),
value: promptPrice ? `$${promptPrice}` : t('Empty'),
value: promptPrice
? formatPricingAmount(promptPrice, currency)
: t('Empty'),
},
{
key: 'completion',
label: t('Completion price'),
value:
laneEnabled.completion && lanePrices.completion
? `$${lanePrices.completion}`
? formatPricingAmount(lanePrices.completion, currency)
: t('Empty'),
},
{
......@@ -259,7 +267,7 @@ export function buildPreviewRows(
label: t('Cache read price'),
value:
laneEnabled.cache && lanePrices.cache
? `$${lanePrices.cache}`
? formatPricingAmount(lanePrices.cache, currency)
: t('Empty'),
},
{
......@@ -267,7 +275,7 @@ export function buildPreviewRows(
label: t('Cache write price'),
value:
laneEnabled.createCache && lanePrices.createCache
? `$${lanePrices.createCache}`
? formatPricingAmount(lanePrices.createCache, currency)
: t('Empty'),
},
{
......@@ -275,7 +283,7 @@ export function buildPreviewRows(
label: t('Image input price'),
value:
laneEnabled.image && lanePrices.image
? `$${lanePrices.image}`
? formatPricingAmount(lanePrices.image, currency)
: t('Empty'),
},
{
......@@ -283,7 +291,7 @@ export function buildPreviewRows(
label: t('Audio input price'),
value:
laneEnabled.audioInput && lanePrices.audioInput
? `$${lanePrices.audioInput}`
? formatPricingAmount(lanePrices.audioInput, currency)
: t('Empty'),
},
{
......@@ -291,7 +299,7 @@ export function buildPreviewRows(
label: t('Audio output price'),
value:
laneEnabled.audioOutput && lanePrices.audioOutput
? `$${lanePrices.audioOutput}`
? formatPricingAmount(lanePrices.audioOutput, currency)
: t('Empty'),
},
]
......
......@@ -18,11 +18,12 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { InputGroup, InputGroupAddon } from '@/components/ui/input-group'
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from '@/components/ui/input-group'
USD_PRICING_CURRENCY,
type PricingCurrency,
} from '@/features/model-pricing/currency'
import { PricingAmountInput } from '@/features/model-pricing/pricing-amount-input'
import { cn } from '@/lib/utils'
import {
......@@ -31,27 +32,41 @@ import {
} from '../components/settings-form-layout'
export function PriceInput(props: {
currency?: PricingCurrency
id?: string
'aria-label'?: string
'aria-describedby'?: string
value: string
placeholder?: string
disabled?: boolean
onChange: (value: string) => void
}) {
return (
<InputGroup>
<InputGroupAddon>$</InputGroupAddon>
<InputGroupInput
<InputGroup className='has-[[data-pricing-error]]:h-auto has-[[data-pricing-error]]:flex-wrap'>
<InputGroupAddon>
{(props.currency ?? USD_PRICING_CURRENCY).symbol}
</InputGroupAddon>
<PricingAmountInput
grouped
currency={props.currency}
id={props.id}
aria-label={props['aria-label']}
aria-describedby={props['aria-describedby']}
inputMode='decimal'
value={props.value}
placeholder={props.placeholder}
disabled={props.disabled}
onChange={(event) => props.onChange(event.target.value)}
onChange={props.onChange}
/>
<InputGroupAddon align='inline-end'>$/1M</InputGroupAddon>
<InputGroupAddon align='inline-end'>
{(props.currency ?? USD_PRICING_CURRENCY).symbol}/1M
</InputGroupAddon>
</InputGroup>
)
}
export function PriceLane(props: {
currency?: PricingCurrency
title: string
description: string
placeholder: string
......@@ -78,6 +93,8 @@ export function PriceLane(props: {
aria-label={props.title}
/>
<PriceInput
currency={props.currency}
aria-label={props.title}
value={props.value}
placeholder={props.placeholder}
disabled={effectiveDisabled}
......@@ -85,7 +102,9 @@ export function PriceLane(props: {
/>
<p className='text-muted-foreground text-xs'>
{props.enabled
? t('USD price per 1M tokens.')
? t('{{currency}} price per 1M tokens.', {
currency: (props.currency ?? USD_PRICING_CURRENCY).label,
})
: t('Disabled lanes are omitted on save.')}
</p>
</SettingsControlGroup>
......
......@@ -23,6 +23,7 @@ import {
useCallback,
useEffect,
useImperativeHandle,
useId,
useMemo,
useRef,
useState,
......@@ -49,11 +50,7 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from '@/components/ui/input-group'
import { InputGroup, InputGroupAddon } from '@/components/ui/input-group'
import {
Sheet,
SheetContent,
......@@ -62,6 +59,13 @@ import {
SheetTitle,
} from '@/components/ui/sheet'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
getSitePricingCurrency,
isValidPricingCurrency,
USD_PRICING_CURRENCY,
} from '@/features/model-pricing/currency'
import { PricingAmountInput } from '@/features/model-pricing/pricing-amount-input'
import { PricingCurrencySelector } from '@/features/model-pricing/pricing-currency-selector'
import { usePricingData } from '@/features/pricing/hooks/use-pricing-data'
import {
createDefaultTaskVisualConfig,
......@@ -69,6 +73,8 @@ import {
} from '@/features/pricing/lib/task-expr'
import type { BillingUsageSchema } from '@/features/pricing/types'
import { cn } from '@/lib/utils'
import { usePricingPreferencesStore } from '@/stores/pricing-preferences-store'
import { useSystemConfigStore } from '@/stores/system-config-store'
import {
EMPTY_LANE_ENABLED,
......@@ -78,7 +84,6 @@ import {
createModelPricingSchema,
hasValue,
laneConfigs,
numericDraftRegex,
ratioFieldByLane,
toNumberOrNull,
type LaneKey,
......@@ -167,6 +172,18 @@ export const ModelPricingEditorPanel = forwardRef<
ref
) {
const { t } = useTranslation()
const promptPriceId = useId()
const formElementRef = useRef<HTMLFormElement>(null)
const currencyConfig = useSystemConfigStore((state) => state.config.currency)
const preference = usePricingPreferencesStore((state) => state.currency)
const siteCurrency = useMemo(
() => getSitePricingCurrency(currencyConfig),
[currencyConfig]
)
const currency =
preference === 'site' && isValidPricingCurrency(siteCurrency)
? siteCurrency
: USD_PRICING_CURRENCY
const [pricingMode, setPricingMode] = useState<PricingMode>('per-token')
const [promptPrice, setPromptPrice] = useState('')
const [lanePrices, setLanePrices] = useState<Record<LaneKey, string>>({
......@@ -373,13 +390,11 @@ export const ModelPricingEditorPanel = forwardRef<
}
const handlePromptPriceChange = (value: string) => {
if (!numericDraftRegex.test(value)) return
setPromptPrice(value)
syncLaneRatios(value, lanePrices, laneEnabled)
}
const handleLanePriceChange = (lane: LaneKey, value: string) => {
if (!numericDraftRegex.test(value)) return
const nextLanePrices = { ...lanePrices, [lane]: value }
setLanePrices(nextLanePrices)
......@@ -446,7 +461,8 @@ export const ModelPricingEditorPanel = forwardRef<
promptPrice,
lanePrices,
laneEnabled,
t
t,
currency
),
[
resolvedBillingExpr,
......@@ -457,6 +473,7 @@ export const ModelPricingEditorPanel = forwardRef<
requestRuleExpr,
t,
watchedValues,
currency,
]
)
......@@ -580,6 +597,13 @@ export const ModelPricingEditorPanel = forwardRef<
ref,
() => ({
commitDraft: async () => {
const amounts =
formElementRef.current?.querySelectorAll<HTMLInputElement>(
'input[data-pricing-amount]'
)
if (amounts && [...amounts].some((input) => !input.reportValidity())) {
return null
}
const isValid = await form.trigger()
if (!isValid || !validatePricingValues()) return null
return buildSubmitData(form.getValues())
......@@ -609,6 +633,7 @@ export const ModelPricingEditorPanel = forwardRef<
<Form {...form}>
<form
ref={formElementRef}
onSubmit={(event) => event.preventDefault()}
className='flex min-h-0 flex-1 flex-col'
autoComplete='off'
......@@ -652,7 +677,10 @@ export const ModelPricingEditorPanel = forwardRef<
)}
/>
<PricingCurrencySelector siteCurrency={siteCurrency} />
<Tabs
key={editorReloadToken}
value={pricingMode}
onValueChange={handleModeChange}
className='gap-4'
......@@ -698,14 +726,21 @@ export const ModelPricingEditorPanel = forwardRef<
)}
<FieldGroup className='gap-5'>
<Field>
<FieldLabel>{t('Input price')}</FieldLabel>
<FieldLabel htmlFor={promptPriceId}>
{t('Input price')}
</FieldLabel>
<PriceInput
id={promptPriceId}
aria-describedby={`${promptPriceId}-description`}
currency={currency}
value={promptPrice}
placeholder='3'
onChange={handlePromptPriceChange}
/>
<FieldDescription>
{t('USD price per 1M input tokens.')}
<FieldDescription id={`${promptPriceId}-description`}>
{t('{{currency}} price per 1M input tokens.', {
currency: currency.label,
})}
</FieldDescription>
</Field>
......@@ -717,6 +752,7 @@ export const ModelPricingEditorPanel = forwardRef<
!hasValue(lanePrices.audioInput))
return (
<PriceLane
currency={currency}
key={lane.key}
title={t(lane.titleKey)}
description={t(lane.descriptionKey)}
......@@ -745,31 +781,31 @@ export const ModelPricingEditorPanel = forwardRef<
render={({ field }) => (
<FormItem className='contents'>
<Field>
<FieldLabel>{t('Fixed price')}</FieldLabel>
<FormLabel>{t('Fixed price')}</FormLabel>
<InputGroup className='has-[[data-pricing-error]]:h-auto has-[[data-pricing-error]]:flex-wrap'>
<InputGroupAddon>
{currency.symbol}
</InputGroupAddon>
<FormControl>
<InputGroup>
<InputGroupAddon>$</InputGroupAddon>
<InputGroupInput
inputMode='decimal'
placeholder='0.01'
<PricingAmountInput
{...field}
onChange={(event) => {
const value = event.target.value
if (numericDraftRegex.test(value)) {
field.onChange(value)
}
}}
value={field.value ?? ''}
currency={currency}
grouped
placeholder='0.01'
onChange={field.onChange}
/>
</FormControl>
<InputGroupAddon align='inline-end'>
{t('per request')}
</InputGroupAddon>
</InputGroup>
</FormControl>
<FieldDescription>
<FormDescription>
{t(
'Cost in USD per request, regardless of tokens used.'
'Cost in {{currency}} per request, regardless of tokens used.',
{ currency: currency.label }
)}
</FieldDescription>
</FormDescription>
<FormMessage />
</Field>
</FormItem>
......@@ -782,6 +818,7 @@ export const ModelPricingEditorPanel = forwardRef<
<FieldGroup className='gap-5'>
{taskUsageSchema ? (
<TaskUsagePricingEditor
currency={currency}
key={`${editorReloadToken}:${watchedValues.name}`}
billingExpr={resolvedBillingExpr}
requestRuleExpr={requestRuleExpr}
......@@ -792,6 +829,7 @@ export const ModelPricingEditorPanel = forwardRef<
/>
) : (
<TieredPricingEditor
currency={currency}
key={editorReloadToken}
modelName={watchedValues.name}
billingExpr={billingExpr}
......
......@@ -28,7 +28,6 @@ import {
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Field, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
Popover,
PopoverContent,
......@@ -50,6 +49,11 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
USD_PRICING_CURRENCY,
type PricingCurrency,
} from '@/features/model-pricing/currency'
import { PricingAmountInput } from '@/features/model-pricing/pricing-amount-input'
import { getTaskUsagePriceUnitLabelKey } from '@/features/pricing/lib/dynamic-price'
import {
getTaskEnumFields,
......@@ -66,6 +70,7 @@ import { cn } from '@/lib/utils'
const TASK_MATRIX_GROUP_THRESHOLD = 24
type TaskPricingMatrixProps = {
currency?: PricingCurrency
rows: TaskMatrixRow[]
usageSchema: BillingUsageSchema
matchedRowIndex: number | null
......@@ -79,6 +84,7 @@ type IndexedTaskMatrixRow = {
}
type FillColumnPopoverProps = {
currency?: PricingCurrency
priceKey: string
initialValue: number
onFillColumn: (priceKey: string, value: number) => void
......@@ -86,6 +92,7 @@ type FillColumnPopoverProps = {
function FillColumnPopover(props: FillColumnPopoverProps) {
const { t } = useTranslation()
const inputRef = useRef<HTMLInputElement>(null)
const [open, setOpen] = useState(false)
const [value, setValue] = useState(props.initialValue)
......@@ -95,6 +102,7 @@ function FillColumnPopover(props: FillColumnPopoverProps) {
}
const handleSubmit = () => {
if (inputRef.current && !inputRef.current.reportValidity()) return
const nextValue = Number(value)
props.onFillColumn(
props.priceKey,
......@@ -124,8 +132,9 @@ function FillColumnPopover(props: FillColumnPopoverProps) {
</PopoverHeader>
<Field className='gap-2'>
<FieldLabel className='sr-only'>{t('Fill entire column')}</FieldLabel>
<Input
type='number'
<PricingAmountInput
ref={inputRef}
currency={props.currency}
min={0}
step={0.000001}
value={value}
......@@ -135,7 +144,7 @@ function FillColumnPopover(props: FillColumnPopoverProps) {
event.currentTarget.select()
}
}}
onChange={(event) => setValue(Number(event.target.value))}
onChange={(usd) => setValue(Number(usd))}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
event.preventDefault()
......@@ -153,6 +162,7 @@ function FillColumnPopover(props: FillColumnPopoverProps) {
}
type TaskMatrixTableProps = {
currency?: PricingCurrency
entries: IndexedTaskMatrixRow[]
enumFields: [string, BillingUsageFieldSchema][]
numberFields: [string, BillingUsageFieldSchema][]
......@@ -190,10 +200,12 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
<div className='flex flex-col gap-0.5'>
<code>{field}</code>
<span className='text-muted-foreground text-[11px] font-normal'>
$/{t(getTaskUsagePriceUnitLabelKey(definition.unit))}
{(props.currency ?? USD_PRICING_CURRENCY).symbol}/
{t(getTaskUsagePriceUnitLabelKey(definition.unit))}
</span>
</div>
<FillColumnPopover
currency={props.currency}
priceKey={field}
initialValue={props.firstRow.unitPrices[field] ?? 0}
onFillColumn={props.onFillColumn}
......@@ -206,10 +218,12 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
<div className='flex flex-col gap-0.5'>
<span>{t('Base charge')}</span>
<span className='text-muted-foreground text-[11px] font-normal'>
$/{t('request')}
{(props.currency ?? USD_PRICING_CURRENCY).symbol}/
{t('request')}
</span>
</div>
<FillColumnPopover
currency={props.currency}
priceKey='constant'
initialValue={props.firstRow.constant}
onFillColumn={props.onFillColumn}
......@@ -244,8 +258,8 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
))}
{props.numberFields.map(([field]) => (
<TableCell key={field}>
<Input
type='number'
<PricingAmountInput
currency={props.currency}
min={0}
step={0.000001}
value={entry.row.unitPrices[field] ?? 0}
......@@ -257,8 +271,8 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
event.currentTarget.select()
}
}}
onChange={(event) => {
const value = Number(event.target.value)
onChange={(usd) => {
const value = Number(usd)
props.onRowChange(entry.index, {
...entry.row,
unitPrices: {
......@@ -276,8 +290,8 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
</TableCell>
))}
<TableCell>
<Input
type='number'
<PricingAmountInput
currency={props.currency}
min={0}
step={0.000001}
value={entry.row.constant}
......@@ -289,8 +303,8 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
event.currentTarget.select()
}
}}
onChange={(event) => {
const value = Number(event.target.value)
onChange={(usd) => {
const value = Number(usd)
props.onRowChange(entry.index, {
...entry.row,
constant:
......@@ -463,6 +477,7 @@ export function TaskPricingMatrix(props: TaskPricingMatrixProps) {
<div className='flex flex-col gap-2'>
{(firstEnumField[1].enum ?? []).map((groupValue) => (
<TaskMatrixGroup
currency={props.currency}
key={groupValue}
entries={entries.filter(
(entry) =>
......@@ -494,6 +509,7 @@ export function TaskPricingMatrix(props: TaskPricingMatrixProps) {
</div>
) : (
<TaskMatrixTable
currency={props.currency}
entries={entries}
enumFields={enumFields}
numberFields={numberFields}
......
......@@ -71,6 +71,8 @@
"{{count}} vendors": "{{count}} vendors",
"{{count}} weeks ago": "{{count}} weeks ago",
"{{created}} models created, {{updated}} models updated, {{vendors}} vendors created.": "{{created}} models created, {{updated}} models updated, {{vendors}} vendors created.",
"{{currency}} price per 1M input tokens.": "{{currency}} price per 1M input tokens.",
"{{currency}} price per 1M tokens.": "{{currency}} price per 1M tokens.",
"{{field}} updated to {{value}}": "{{field}} updated to {{value}}",
"{{field}} updated to {{value}} for tag: {{tag}}": "{{field}} updated to {{value}} for tag: {{tag}}",
"{{key}} · version {{version}} · from {{source}}": "{{key}} · version {{version}} · from {{source}}",
......@@ -144,6 +146,7 @@
"A vendor with this name already exists.": "A vendor with this name already exists.",
"About": "About",
"About {{days}} days left": "About {{days}} days left",
"About pricing currency": "About pricing currency",
"Accept Unpriced Models": "Accept Unpriced Models",
"Accepts a JSON array of model identifiers that support the Imagine API.": "Accepts a JSON array of model identifiers that support the Imagine API.",
"Accepts comma-separated status codes and inclusive ranges.": "Accepts comma-separated status codes and inclusive ranges.",
......@@ -1273,6 +1276,7 @@
"Cost = 10 × 0.8 = 8": "Cost = 10 × 0.8 = 8",
"Cost = 10 × 1.0 = 10": "Cost = 10 × 1.0 = 10",
"Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.",
"Cost in {{currency}} per request, regardless of tokens used.": "Cost in {{currency}} per request, regardless of tokens used.",
"Cost in USD per request, regardless of tokens used.": "Cost in USD per request, regardless of tokens used.",
"Cost Tracking": "Cost Tracking",
"Could not fetch the plugin source from this browser. The host may block cross-origin requests or be unreachable.": "Could not fetch the plugin source from this browser. The host may block cross-origin requests or be unreachable.",
......@@ -1356,6 +1360,7 @@
"Current domain": "Current domain",
"Current email verification code": "Current email verification code",
"Current email: {{email}}. Enter a new email to change.": "Current email: {{email}}. Enter a new email to change.",
"Current exchange rate: 1 USD = {{rate}} {{currency}}": "Current exchange rate: 1 USD = {{rate}} {{currency}}",
"Current key": "Current key",
"Current legacy JSON is invalid, cannot append": "Current legacy JSON is invalid, cannot append",
"Current Level Only": "Current Level Only",
......@@ -3981,6 +3986,7 @@
"Pricing & Display": "Pricing & Display",
"Pricing by Group": "Pricing by Group",
"Pricing Configuration": "Pricing Configuration",
"Pricing currency": "Pricing currency",
"Pricing group example": "Pricing group example",
"Pricing groups": "Pricing groups",
"Pricing mode": "Pricing mode",
......@@ -4114,6 +4120,7 @@
"Ratio: {{value}}": "Ratio: {{value}}",
"Ratios synced successfully": "Ratios synced successfully",
"Raw expression": "Raw expression",
"Raw expressions and presets use USD. Currency selection only converts visual price inputs and monetary previews.": "Raw expressions and presets use USD. Currency selection only converts visual price inputs and monetary previews.",
"Raw JSON": "Raw JSON",
"Raw Quota": "Raw Quota",
"Raw response": "Raw response",
......@@ -4797,6 +4804,7 @@
"Single .js file, up to 1 MiB. Its source is shown below before upload.": "Single .js file, up to 1 MiB. Its source is shown below before upload.",
"Single Key": "Single Key",
"Site & Branding": "Site & Branding",
"Site currency ({{currency}})": "Site currency ({{currency}})",
"Site Key": "Site Key",
"Site URL": "Site URL",
"Size:": "Size:",
......@@ -5113,6 +5121,7 @@
"The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.",
"The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.",
"The change succeeded, but the notification email could not be sent.": "The change succeeded, but the notification email could not be sent.",
"The converted price must be a finite, non-negative number.": "The converted price must be a finite, non-negative number.",
"The deployment node that handled the requests": "The deployment node that handled the requests",
"The downloaded source does not match the sha256 declared in the index. Do not install it.": "The downloaded source does not match the sha256 declared in the index. Do not install it.",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "The effective domain for Passkey registration. Must match the current domain or be its parent domain.",
......@@ -5134,9 +5143,11 @@
"The requested chat preset does not exist or has been removed.": "The requested chat preset does not exist or has been removed.",
"The reset request stays disabled until a credit is available.": "The reset request stays disabled until a credit is available.",
"The setup wizard will use this database during initialization.": "The setup wizard will use this database during initialization.",
"The site exchange rate is invalid. Prices are shown in USD.": "The site exchange rate is invalid. Prices are shown in USD.",
"The site is not available at the moment.": "The site is not available at the moment.",
"The slug is appended to the URL:": "The slug is appended to the URL:",
"The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.",
"The system always bills in USD. Site currency makes entering and converting prices easier; amounts are converted to USD using the site exchange rate. Switching currencies does not change the actual price. Raw billing expressions always use USD.": "The system always bills in USD. Site currency makes entering and converting prices easier; amounts are converted to USD using the site exchange rate. Switching currencies does not change the actual price. Raw billing expressions always use USD.",
"The Telegram authorization request is invalid or expired.": "The Telegram authorization request is invalid or expired.",
"The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.": "The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.",
"The token group that will have a custom ratio": "The token group that will have a custom ratio",
......@@ -5608,6 +5619,7 @@
"URL": "URL",
"URL is required": "URL is required",
"URL to your logo image (optional)": "URL to your logo image (optional)",
"US dollar (USD)": "US dollar (USD)",
"Usage": "Usage",
"Usage at a glance": "Usage at a glance",
"Usage guide": "Usage guide",
......@@ -5828,6 +5840,7 @@
"Visual indicator color for the API card": "Visual indicator color for the API card",
"Visual Mode": "Visual Mode",
"Visual Parameter Override": "Visual Parameter Override",
"Visual prices use {{currency}} per declared unit; token prices are per 1M tokens. Raw expressions always use USD, with token terms divided by 1000000.": "Visual prices use {{currency}} per declared unit; token prices are per 1M tokens. Raw expressions always use USD, with token terms divided by 1000000.",
"VolcEngine": "VolcEngine",
"vs. previous": "vs. previous",
"Waffo": "Waffo",
......
......@@ -71,6 +71,8 @@
"{{count}} vendors": "{{count}} fournisseurs",
"{{count}} weeks ago": "il y a {{count}} semaines",
"{{created}} models created, {{updated}} models updated, {{vendors}} vendors created.": "{{created}} modèles créés, {{updated}} modèles mis à jour, {{vendors}} fournisseurs créés.",
"{{currency}} price per 1M input tokens.": "Prix en {{currency}} par million de tokens en entrée.",
"{{currency}} price per 1M tokens.": "Prix en {{currency}} par million de tokens.",
"{{field}} updated to {{value}}": "{{field}} mis à jour en {{value}}",
"{{field}} updated to {{value}} for tag: {{tag}}": "{{field}} mis à jour en {{value}} pour le tag : {{tag}}",
"{{key}} · version {{version}} · from {{source}}": "{{key}} · version {{version}} · depuis {{source}}",
......@@ -144,6 +146,7 @@
"A vendor with this name already exists.": "Un fournisseur porte déjà ce nom.",
"About": "À propos",
"About {{days}} days left": "Environ {{days}} jours restants",
"About pricing currency": "À propos de la devise de tarification",
"Accept Unpriced Models": "Accepter les modèles non tarifés",
"Accepts a JSON array of model identifiers that support the Imagine API.": "Accepte un tableau JSON d'identifiants de modèles qui prennent en charge l'API Imagine.",
"Accepts comma-separated status codes and inclusive ranges.": "Accepte les codes de statut séparés par des virgules et les plages inclusives.",
......@@ -1273,6 +1276,7 @@
"Cost = 10 × 0.8 = 8": "Coût = 10 × 0,8 = 8",
"Cost = 10 × 1.0 = 10": "Coût = 10 × 1,0 = 10",
"Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "Coût = prix du modèle × ce seul taux. Rien d’autre dans les réglages de groupes n’entre dans la formule.",
"Cost in {{currency}} per request, regardless of tokens used.": "Coût en {{currency}} par requête, quel que soit le nombre de tokens utilisés.",
"Cost in USD per request, regardless of tokens used.": "Coût en USD par requête, quel que soit le nombre de jetons utilisés.",
"Cost Tracking": "Suivi des coûts",
"Could not fetch the plugin source from this browser. The host may block cross-origin requests or be unreachable.": "Impossible de récupérer le code du plugin depuis ce navigateur. L’hôte bloque peut-être les requêtes cross-origin ou est injoignable.",
......@@ -1356,6 +1360,7 @@
"Current domain": "Domaine actuel",
"Current email verification code": "Code de vérification de l’adresse actuelle",
"Current email: {{email}}. Enter a new email to change.": "E-mail actuel : {{email}}. Saisissez un nouvel e-mail pour le modifier.",
"Current exchange rate: 1 USD = {{rate}} {{currency}}": "Taux actuel : 1 USD = {{rate}} {{currency}}",
"Current key": "Clé actuelle",
"Current legacy JSON is invalid, cannot append": "Le JSON ancien format actuel n'est pas valide, impossible d'ajouter",
"Current Level Only": "Niveau actuel uniquement",
......@@ -3981,6 +3986,7 @@
"Pricing & Display": "Tarification et Affichage",
"Pricing by Group": "Tarification par groupe",
"Pricing Configuration": "Configuration de la tarification",
"Pricing currency": "Devise de tarification",
"Pricing group example": "Exemple de groupe tarifaire",
"Pricing groups": "Groupes tarifaires",
"Pricing mode": "Mode de tarification",
......@@ -4114,6 +4120,7 @@
"Ratio: {{value}}": "Ratio : {{value}}",
"Ratios synced successfully": "Ratios synchronisés avec succès",
"Raw expression": "Expression brute",
"Raw expressions and presets use USD. Currency selection only converts visual price inputs and monetary previews.": "Les expressions brutes et les préréglages utilisent les USD. Le choix de devise convertit uniquement les prix de l’éditeur visuel et les aperçus des montants.",
"Raw JSON": "JSON brut",
"Raw Quota": "Quota brut",
"Raw response": "Reponse brute",
......@@ -4797,6 +4804,7 @@
"Single .js file, up to 1 MiB. Its source is shown below before upload.": "Un seul fichier .js, jusqu’à 1 Mio. Sa source est affichée ci-dessous avant l’envoi.",
"Single Key": "Clé unique",
"Site & Branding": "Site et marque",
"Site currency ({{currency}})": "Devise du site ({{currency}})",
"Site Key": "Clé du site",
"Site URL": "URL du site",
"Size:": "Taille :",
......@@ -5113,6 +5121,7 @@
"The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "Le produit associé alimente les recharges de portefeuille : lorsqu’un utilisateur saisit un montant, new-api lance le paiement sur ce produit Pancake unique et remplace le prix pour la session, sans devoir précréer des SKU de 1 $, 5 $ ou 10 $.",
"The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "La boutique associée est le conteneur parent de tous les produits Pancake que new-api crée depuis cette administration, y compris le produit de recharge de portefeuille et les produits de forfaits d’abonnement. Une seule boutique suffit ; choisissez-en une autre uniquement si vous gérez réellement des catalogues Pancake séparés.",
"The change succeeded, but the notification email could not be sent.": "La modification a réussi, mais l’e-mail de notification n’a pas pu être envoyé.",
"The converted price must be a finite, non-negative number.": "Le prix converti doit être un nombre fini et positif ou nul.",
"The deployment node that handled the requests": "Le nœud de déploiement ayant traité les requêtes",
"The downloaded source does not match the sha256 declared in the index. Do not install it.": "Le code téléchargé ne correspond pas au sha256 déclaré dans l’index. Ne l’installez pas.",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "Le domaine effectif pour l'enregistrement de la clé d'accès. Doit correspondre au domaine actuel ou être son domaine parent.",
......@@ -5134,9 +5143,11 @@
"The requested chat preset does not exist or has been removed.": "Le préréglage de discussion demandé n'existe pas ou a été supprimé.",
"The reset request stays disabled until a credit is available.": "La demande de réinitialisation reste désactivée tant qu’aucun crédit n’est disponible.",
"The setup wizard will use this database during initialization.": "L'assistant de configuration utilisera cette base de données lors de l'initialisation.",
"The site exchange rate is invalid. Prices are shown in USD.": "Le taux de change du site est invalide. Les prix sont affichés en USD.",
"The site is not available at the moment.": "Le site n'est pas disponible pour le moment.",
"The slug is appended to the URL:": "Le slug est ajouté à l'URL :",
"The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "La synchronisation récupérera les modèles et fournisseurs manquants à partir de la source sélectionnée. Les enregistrements existants ne sont mis à jour que lorsque vous approuvez les conflits.",
"The system always bills in USD. Site currency makes entering and converting prices easier; amounts are converted to USD using the site exchange rate. Switching currencies does not change the actual price. Raw billing expressions always use USD.": "Le système facture toujours en USD. La devise du site facilite la saisie et la conversion des prix ; les montants sont convertis en USD au taux du site. Changer de devise ne modifie pas le prix réel. Les expressions de facturation brutes utilisent toujours les USD.",
"The Telegram authorization request is invalid or expired.": "La demande d’autorisation Telegram est invalide ou a expiré.",
"The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.": "Le nom de fournisseur OAuth telegram est réservé. Demandez à votre administrateur de renommer le fournisseur personnalisé en conflit.",
"The token group that will have a custom ratio": "Le groupe de jetons qui aura un ratio personnalisé",
......@@ -5608,6 +5619,7 @@
"URL": "URL",
"URL is required": "L'URL est requise",
"URL to your logo image (optional)": "URL de votre image de logo (facultatif)",
"US dollar (USD)": "Dollar américain (USD)",
"Usage": "Utilisation",
"Usage at a glance": "Vue d'ensemble de l'utilisation",
"Usage guide": "Guide d'utilisation",
......@@ -5828,6 +5840,7 @@
"Visual indicator color for the API card": "Couleur de l'indicateur visuel pour la carte API",
"Visual Mode": "Mode Visuel",
"Visual Parameter Override": "Remplacement visuel des paramètres",
"Visual prices use {{currency}} per declared unit; token prices are per 1M tokens. Raw expressions always use USD, with token terms divided by 1000000.": "Les prix visuels sont en {{currency}} par unité déclarée ; les prix des tokens sont par million. Les expressions brutes utilisent toujours les USD, avec les termes de tokens divisés par 1000000.",
"VolcEngine": "VolcEngine",
"vs. previous": "vs. précédent",
"Waffo": "Waffo",
......
......@@ -71,6 +71,8 @@
"{{count}} vendors": "{{count}} nhà cung cấp",
"{{count}} weeks ago": "{{count}} tuần trước",
"{{created}} models created, {{updated}} models updated, {{vendors}} vendors created.": "Đã tạo {{created}} mô hình, cập nhật {{updated}} mô hình và tạo {{vendors}} nhà cung cấp.",
"{{currency}} price per 1M input tokens.": "Giá bằng {{currency}} cho mỗi 1 triệu token đầu vào.",
"{{currency}} price per 1M tokens.": "Giá bằng {{currency}} cho mỗi 1 triệu token.",
"{{field}} updated to {{value}}": "{{field}} đã cập nhật thành {{value}}",
"{{field}} updated to {{value}} for tag: {{tag}}": "{{field}} đã cập nhật thành {{value}} cho nhãn: {{tag}}",
"{{key}} · version {{version}} · from {{source}}": "{{key}} · phiên bản {{version}} · từ {{source}}",
......@@ -144,6 +146,7 @@
"A vendor with this name already exists.": "Nhà cung cấp có tên này đã tồn tại.",
"About": "Giới thiệu",
"About {{days}} days left": "Còn khoảng {{days}} ngày",
"About pricing currency": "Về tiền tệ định giá",
"Accept Unpriced Models": "Chấp nhận các Mô hình chưa định giá",
"Accepts a JSON array of model identifiers that support the Imagine API.": "Chấp nhận một mảng JSON gồm các mã định danh mô hình hỗ trợ API Imagine.",
"Accepts comma-separated status codes and inclusive ranges.": "Chấp nhận mã trạng thái phân cách bằng dấu phẩy và phạm vi bao gồm.",
......@@ -1273,6 +1276,7 @@
"Cost = 10 × 0.8 = 8": "Chi phí = 10 × 0.8 = 8",
"Cost = 10 × 1.0 = 10": "Chi phí = 10 × 1.0 = 10",
"Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "Chi phí = giá mô hình × đúng một hệ số đó. Không có mục nào khác trong cài đặt nhóm tham gia công thức.",
"Cost in {{currency}} per request, regardless of tokens used.": "Chi phí mỗi yêu cầu bằng {{currency}}, không phụ thuộc số token sử dụng.",
"Cost in USD per request, regardless of tokens used.": "Chi phí bằng USD cho mỗi yêu cầu, bất kể số lượng token được sử dụng.",
"Cost Tracking": "Theo dõi chi phí",
"Could not fetch the plugin source from this browser. The host may block cross-origin requests or be unreachable.": "Không thể tải mã nguồn plugin từ trình duyệt này. Máy chủ có thể chặn yêu cầu cross-origin hoặc không truy cập được.",
......@@ -1356,6 +1360,7 @@
"Current domain": "Tên miền hiện tại",
"Current email verification code": "Mã xác minh email hiện tại",
"Current email: {{email}}. Enter a new email to change.": "Email hiện tại: {{email}}. Nhập email mới để thay đổi.",
"Current exchange rate: 1 USD = {{rate}} {{currency}}": "Tỷ giá hiện tại: 1 USD = {{rate}} {{currency}}",
"Current key": "Khóa hiện tại",
"Current legacy JSON is invalid, cannot append": "JSON định dạng cũ hiện tại không hợp lệ, không thể thêm",
"Current Level Only": "Chỉ cấp hiện tại",
......@@ -3981,6 +3986,7 @@
"Pricing & Display": "Giá cả & Hiển thị",
"Pricing by Group": "Giá theo Nhóm",
"Pricing Configuration": "Price configuration",
"Pricing currency": "Tiền tệ định giá",
"Pricing group example": "Ví dụ nhóm định giá",
"Pricing groups": "Nhóm định giá",
"Pricing mode": "Chế độ định giá",
......@@ -4114,6 +4120,7 @@
"Ratio: {{value}}": "Tỷ lệ: {{value}}",
"Ratios synced successfully": "Tỷ lệ đã đồng bộ thành công",
"Raw expression": "Biểu thức gốc",
"Raw expressions and presets use USD. Currency selection only converts visual price inputs and monetary previews.": "Biểu thức gốc và mẫu có sẵn dùng USD. Lựa chọn tiền tệ chỉ quy đổi giá nhập trong trình chỉnh sửa trực quan và số tiền xem trước.",
"Raw JSON": "JSON thô",
"Raw Quota": "Hạn mức gốc",
"Raw response": "Phản hồi thô",
......@@ -4797,6 +4804,7 @@
"Single .js file, up to 1 MiB. Its source is shown below before upload.": "Một tệp .js duy nhất, tối đa 1 MiB. Mã nguồn được hiển thị bên dưới trước khi tải lên.",
"Single Key": "Khóa đơn",
"Site & Branding": "Trang web & thương hiệu",
"Site currency ({{currency}})": "Tiền tệ trang web ({{currency}})",
"Site Key": "Khóa trang web",
"Site URL": "URL trang web",
"Size:": "Kích thước:",
......@@ -5113,6 +5121,7 @@
"The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "Sản phẩm đã liên kết dùng cho nạp ví: khi người dùng nhập bất kỳ số tiền nào, new-api chạy thanh toán trên một sản phẩm Pancake duy nhất này và ghi đè giá theo từng phiên — không cần tạo trước SKU $1 / $5 / $10.",
"The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "Cửa hàng đã liên kết là vùng chứa cha cho mọi sản phẩm Pancake mà new-api tạo từ trang quản trị này — bao gồm sản phẩm nạp ví và mọi sản phẩm gói đăng ký. Một cửa hàng là đủ; chỉ ghim cửa hàng khác nếu bạn thực sự vận hành các catalog Pancake riêng.",
"The change succeeded, but the notification email could not be sent.": "Thay đổi đã hoàn tất nhưng không thể gửi email thông báo.",
"The converted price must be a finite, non-negative number.": "Giá sau quy đổi phải là số hữu hạn không âm.",
"The deployment node that handled the requests": "Nút triển khai đã xử lý các yêu cầu",
"The downloaded source does not match the sha256 declared in the index. Do not install it.": "Mã nguồn đã tải không khớp với sha256 khai báo trong chỉ mục. Đừng cài đặt nó.",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "Mi",
......@@ -5134,9 +5143,11 @@
"The requested chat preset does not exist or has been removed.": "Cài đặt sẵn cuộc trò chuyện được yêu cầu không tồn tại hoặc đã bị xóa.",
"The reset request stays disabled until a credit is available.": "Yêu cầu đặt lại sẽ bị tắt cho đến khi có lượt khả dụng.",
"The setup wizard will use this database during initialization.": "Trình hướng dẫn thiết lập sẽ sử dụng cơ sở dữ liệu này trong quá trình khởi tạo.",
"The site exchange rate is invalid. Prices are shown in USD.": "Tỷ giá trang web không hợp lệ. Giá được hiển thị bằng USD.",
"The site is not available at the moment.": "Trang web hiện không khả dụng.",
"The slug is appended to the URL:": "Slug được gắn vào URL:",
"The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "Đồng bộ hóa sẽ tìm nạp các mẫu và nhà cung cấp còn thiếu từ nguồn đã chọn. Các bản ghi hiện có chỉ được cập nhật khi bạn chấp thuận các xung đột.",
"The system always bills in USD. Site currency makes entering and converting prices easier; amounts are converted to USD using the site exchange rate. Switching currencies does not change the actual price. Raw billing expressions always use USD.": "Hệ thống luôn tính phí bằng USD. Tiền tệ trang web giúp nhập và quy đổi giá thuận tiện hơn; số tiền được quy đổi sang USD theo tỷ giá của trang web. Đổi tiền tệ không làm thay đổi giá thực tế. Biểu thức tính phí gốc luôn dùng USD.",
"The Telegram authorization request is invalid or expired.": "Yêu cầu ủy quyền Telegram không hợp lệ hoặc đã hết hạn.",
"The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.": "Tên nhà cung cấp OAuth telegram đã được dành riêng. Hãy nhờ quản trị viên đổi tên nhà cung cấp tùy chỉnh bị trùng.",
"The token group that will have a custom ratio": "The token group will have a custom ratio.",
......@@ -5608,6 +5619,7 @@
"URL": "URL",
"URL is required": "URL là bắt buộc",
"URL to your logo image (optional)": "URL hình ảnh logo của bạn (tùy chọn)",
"US dollar (USD)": "Đô la Mỹ (USD)",
"Usage": "Sử dụng",
"Usage at a glance": "Tổng quan mức dùng",
"Usage guide": "Hướng dẫn sử dụng",
......@@ -5828,6 +5840,7 @@
"Visual indicator color for the API card": "Màu sắc chỉ báo trực quan cho thẻ API",
"Visual Mode": "Chế độ Trực quan",
"Visual Parameter Override": "Ghi đè tham số trực quan",
"Visual prices use {{currency}} per declared unit; token prices are per 1M tokens. Raw expressions always use USD, with token terms divided by 1000000.": "Giá trực quan dùng {{currency}} cho mỗi đơn vị đã khai báo; giá token tính theo 1 triệu token. Biểu thức gốc luôn dùng USD, với các hạng tử token chia cho 1000000.",
"VolcEngine": "VolcEngine",
"vs. previous": "so với kỳ trước",
"Waffo": "Waffo",
......
......@@ -71,6 +71,8 @@
"{{count}} vendors": "{{count}} 間供應商",
"{{count}} weeks ago": "{{count}} 週前",
"{{created}} models created, {{updated}} models updated, {{vendors}} vendors created.": "已建立 {{created}} 個模型、更新 {{updated}} 個模型,並新增 {{vendors}} 個供應商。",
"{{currency}} price per 1M input tokens.": "每百萬輸入 Token 的價格,單位為 {{currency}}。",
"{{currency}} price per 1M tokens.": "每百萬 Token 的價格,單位為 {{currency}}。",
"{{field}} updated to {{value}}": "{{field}} 已更新為 {{value}}",
"{{field}} updated to {{value}} for tag: {{tag}}": "標籤「{{tag}}」的 {{field}} 已更新為 {{value}}",
"{{key}} · version {{version}} · from {{source}}": "{{key}} · 版本 {{version}} · 來自 {{source}}",
......@@ -144,6 +146,7 @@
"A vendor with this name already exists.": "此供應商名稱已存在。",
"About": "關於",
"About {{days}} days left": "約剩 {{days}} 日",
"About pricing currency": "關於定價貨幣",
"Accept Unpriced Models": "接受未定價模型",
"Accepts a JSON array of model identifiers that support the Imagine API.": "接受支援 Imagine API 的模型標識符的 JSON 陣列。",
"Accepts comma-separated status codes and inclusive ranges.": "接受逗號分隔的狀態碼和包含性範圍。",
......@@ -1273,6 +1276,7 @@
"Cost = 10 × 0.8 = 8": "費用 = 10 × 0.8 = 8",
"Cost = 10 × 1.0 = 10": "費用 = 10 × 1.0 = 10",
"Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "費用 = 模型價格 × 這一個倍率。分組設定裡的其他項都不參與該公式。",
"Cost in {{currency}} per request, regardless of tokens used.": "每次請求的費用({{currency}}),與 Token 用量無關。",
"Cost in USD per request, regardless of tokens used.": "每請求的美元費用,不考慮使用的令牌數。",
"Cost Tracking": "成本追蹤",
"Could not fetch the plugin source from this browser. The host may block cross-origin requests or be unreachable.": "無法在瀏覽器中取得外掛原始碼。該主機可能禁止跨來源請求或無法連線。",
......@@ -1356,6 +1360,7 @@
"Current domain": "目前域名",
"Current email verification code": "目前信箱驗證碼",
"Current email: {{email}}. Enter a new email to change.": "目前電郵:{{email}}。輸入新電郵以更改。",
"Current exchange rate: 1 USD = {{rate}} {{currency}}": "目前匯率:1 USD = {{rate}} {{currency}}",
"Current key": "目前金鑰",
"Current legacy JSON is invalid, cannot append": "目前舊格式 JSON 不合法,無法追加模板",
"Current Level Only": "僅目前層",
......@@ -3981,6 +3986,7 @@
"Pricing & Display": "定價與顯示",
"Pricing by Group": "按分組定價",
"Pricing Configuration": "定價設定",
"Pricing currency": "定價貨幣",
"Pricing group example": "定價分組示例",
"Pricing groups": "定價分組",
"Pricing mode": "定價模式",
......@@ -4114,6 +4120,7 @@
"Ratio: {{value}}": "倍率:{{value}}",
"Ratios synced successfully": "比率同步成功",
"Raw expression": "原始表達式",
"Raw expressions and presets use USD. Currency selection only converts visual price inputs and monetary previews.": "原始表達式和預設使用美元。貨幣選擇僅換算視覺化價格輸入和金額預覽。",
"Raw JSON": "原始 JSON",
"Raw Quota": "原生額度",
"Raw response": "原始回覆",
......@@ -4797,6 +4804,7 @@
"Single .js file, up to 1 MiB. Its source is shown below before upload.": "單一 .js 檔案,最大 1 MiB。上傳前會在下方顯示其原始碼。",
"Single Key": "單金鑰",
"Site & Branding": "站點與品牌",
"Site currency ({{currency}})": "站點貨幣({{currency}})",
"Site Key": "站點金鑰",
"Site URL": "網站地址",
"Size:": "大小:",
......@@ -5113,6 +5121,7 @@
"The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "已連結產品用於錢包儲值:當用戶輸入任意金額時,new-api 會基於這個單一 Pancake 產品發起結帳,並按對話覆蓋價格,無需預先建立 $1 / $5 / $10 的 SKU。",
"The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "已連結店鋪是 new-api 從此管理端建立的所有 Pancake 產品的父容器,包括錢包儲值產品和訂閱套餐產品。一個店鋪通常足夠;只有在確實運營多個 Pancake 目錄時才需要連結不同店鋪。",
"The change succeeded, but the notification email could not be sent.": "變更已完成,但通知郵件傳送失敗。",
"The converted price must be a finite, non-negative number.": "換算後的價格必須是有限的非負數。",
"The deployment node that handled the requests": "處理請求的部署節點",
"The downloaded source does not match the sha256 declared in the index. Do not install it.": "下載到的原始碼與索引宣告的 sha256 不一致,請勿安裝。",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "用於 Passkey 註冊的有效域。必須與目前域匹配或為其父域。",
......@@ -5134,9 +5143,11 @@
"The requested chat preset does not exist or has been removed.": "請求的聊天預設不存在或已被刪除。",
"The reset request stays disabled until a credit is available.": "沒有可用次數時,重置請求會保持停用。",
"The setup wizard will use this database during initialization.": "設定精靈將在初始化過程中使用此資料庫。",
"The site exchange rate is invalid. Prices are shown in USD.": "站點匯率無效,價格以美元顯示。",
"The site is not available at the moment.": "該站點目前不可用。",
"The slug is appended to the URL:": "別名將附加到 URL:",
"The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "同步將從選定的源獲取缺失的模型和供應商。僅在您批准衝突時才會更新現有記錄。",
"The system always bills in USD. Site currency makes entering and converting prices easier; amounts are converted to USD using the site exchange rate. Switching currencies does not change the actual price. Raw billing expressions always use USD.": "系統底層統一使用美元計價。選擇站點貨幣是為了方便輸入和換算,金額會按站點匯率轉換為美元。切換貨幣不會改變實際價格;原始計費表達式一律使用美元。",
"The Telegram authorization request is invalid or expired.": "Telegram 授權要求無效或已過期。",
"The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.": "telegram 是保留的 OAuth 提供方名稱,請聯絡管理員重新命名衝突的自訂提供方。",
"The token group that will have a custom ratio": "將具有自訂比例的令牌分組",
......@@ -5608,6 +5619,7 @@
"URL": "URL",
"URL is required": "URL 為必填項",
"URL to your logo image (optional)": "您的徽標圖片 URL(可選)",
"US dollar (USD)": "美元(USD)",
"Usage": "用量",
"Usage at a glance": "用量概覽",
"Usage guide": "使用教學",
......@@ -5828,6 +5840,7 @@
"Visual indicator color for the API card": "API 卡的可視指示器顏色",
"Visual Mode": "可視模式",
"Visual Parameter Override": "可視化參數覆蓋",
"Visual prices use {{currency}} per declared unit; token prices are per 1M tokens. Raw expressions always use USD, with token terms divided by 1000000.": "視覺化價格以 {{currency}} 按宣告的單位計價;Token 價格按每百萬 Token 計價。原始表達式一律使用美元,Token 項除以 1000000。",
"VolcEngine": "火山方舟",
"vs. previous": "相較上期",
"Waffo": "Waffo",
......
......@@ -71,6 +71,8 @@
"{{count}} vendors": "{{count}} 家厂商",
"{{count}} weeks ago": "{{count}} 周前",
"{{created}} models created, {{updated}} models updated, {{vendors}} vendors created.": "已创建 {{created}} 个模型,更新 {{updated}} 个模型,新增 {{vendors}} 个供应商。",
"{{currency}} price per 1M input tokens.": "每百万输入 Token 的价格,单位为 {{currency}}。",
"{{currency}} price per 1M tokens.": "每百万 Token 的价格,单位为 {{currency}}。",
"{{field}} updated to {{value}}": "{{field}} 已更新为 {{value}}",
"{{field}} updated to {{value}} for tag: {{tag}}": "标签「{{tag}}」的 {{field}} 已更新为 {{value}}",
"{{key}} · version {{version}} · from {{source}}": "{{key}} · 版本 {{version}} · 来自 {{source}}",
......@@ -144,6 +146,7 @@
"A vendor with this name already exists.": "此供应商名称已存在。",
"About": "关于",
"About {{days}} days left": "约剩 {{days}} 天",
"About pricing currency": "关于定价货币",
"Accept Unpriced Models": "接受未定价模型",
"Accepts a JSON array of model identifiers that support the Imagine API.": "接受支持 Imagine API 的模型标识符的 JSON 数组。",
"Accepts comma-separated status codes and inclusive ranges.": "接受逗号分隔的状态码和包含性范围。",
......@@ -1273,6 +1276,7 @@
"Cost = 10 × 0.8 = 8": "费用 = 10 × 0.8 = 8",
"Cost = 10 × 1.0 = 10": "费用 = 10 × 1.0 = 10",
"Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "费用 = 模型价格 × 这一个倍率。分组设置里的其他项都不参与该公式。",
"Cost in {{currency}} per request, regardless of tokens used.": "每次请求的费用({{currency}}),与 Token 用量无关。",
"Cost in USD per request, regardless of tokens used.": "每请求的美元费用,不考虑使用的令牌数。",
"Cost Tracking": "成本跟踪",
"Could not fetch the plugin source from this browser. The host may block cross-origin requests or be unreachable.": "无法在浏览器中拉取插件源码。该主机可能禁止跨域请求或无法访问。",
......@@ -1356,6 +1360,7 @@
"Current domain": "当前域名",
"Current email verification code": "当前邮箱验证码",
"Current email: {{email}}. Enter a new email to change.": "当前邮箱:{{email}}。输入新邮箱以更改。",
"Current exchange rate: 1 USD = {{rate}} {{currency}}": "当前汇率:1 USD = {{rate}} {{currency}}",
"Current key": "当前密钥",
"Current legacy JSON is invalid, cannot append": "当前旧格式 JSON 不合法,无法追加模板",
"Current Level Only": "仅当前层",
......@@ -3981,6 +3986,7 @@
"Pricing & Display": "定价与显示",
"Pricing by Group": "按分组定价",
"Pricing Configuration": "定价配置",
"Pricing currency": "定价货币",
"Pricing group example": "定价分组示例",
"Pricing groups": "定价分组",
"Pricing mode": "定价模式",
......@@ -4114,6 +4120,7 @@
"Ratio: {{value}}": "倍率:{{value}}",
"Ratios synced successfully": "比率同步成功",
"Raw expression": "原始表达式",
"Raw expressions and presets use USD. Currency selection only converts visual price inputs and monetary previews.": "原始表达式和预设使用美元。货币选择仅换算可视化价格输入和金额预览。",
"Raw JSON": "原始 JSON",
"Raw Quota": "原生额度",
"Raw response": "原始回复",
......@@ -4797,6 +4804,7 @@
"Single .js file, up to 1 MiB. Its source is shown below before upload.": "单个 .js 文件,最大 1 MiB。上传前会在下方显示其源码。",
"Single Key": "单密钥",
"Site & Branding": "站点与品牌",
"Site currency ({{currency}})": "站点货币({{currency}})",
"Site Key": "站点密钥",
"Site URL": "网站地址",
"Size:": "大小:",
......@@ -5113,6 +5121,7 @@
"The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "已绑定产品用于钱包充值:当用户输入任意金额时,new-api 会基于这个单一 Pancake 产品发起结账,并按会话覆盖价格,无需预先创建 $1 / $5 / $10 的 SKU。",
"The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "已绑定店铺是 new-api 从此管理端创建的所有 Pancake 产品的父容器,包括钱包充值产品和订阅套餐产品。一个店铺通常足够;只有在确实运营多个 Pancake 目录时才需要绑定不同店铺。",
"The change succeeded, but the notification email could not be sent.": "变更已完成,但通知邮件发送失败。",
"The converted price must be a finite, non-negative number.": "换算后的价格必须是有限的非负数。",
"The deployment node that handled the requests": "处理请求的部署节点",
"The downloaded source does not match the sha256 declared in the index. Do not install it.": "下载到的源码与索引声明的 sha256 不一致,请勿安装。",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "用于 Passkey 注册的有效域。必须与当前域匹配或为其父域。",
......@@ -5134,9 +5143,11 @@
"The requested chat preset does not exist or has been removed.": "请求的聊天预设不存在或已被删除。",
"The reset request stays disabled until a credit is available.": "没有可用次数时,重置请求会保持禁用。",
"The setup wizard will use this database during initialization.": "设置向导将在初始化过程中使用此数据库。",
"The site exchange rate is invalid. Prices are shown in USD.": "站点汇率无效,价格以美元显示。",
"The site is not available at the moment.": "该站点目前不可用。",
"The slug is appended to the URL:": "别名将附加到 URL:",
"The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.": "同步将从选定的源获取缺失的模型和供应商。仅在您批准冲突时才会更新现有记录。",
"The system always bills in USD. Site currency makes entering and converting prices easier; amounts are converted to USD using the site exchange rate. Switching currencies does not change the actual price. Raw billing expressions always use USD.": "系统底层统一使用美元计价。选择站点货币是为了方便输入和换算,金额会按站点汇率转换为美元。切换货币不会改变实际价格;原始计费表达式始终使用美元。",
"The Telegram authorization request is invalid or expired.": "Telegram 授权请求无效或已过期。",
"The telegram OAuth provider name is reserved. Ask your administrator to rename the conflicting custom provider.": "telegram 是保留的 OAuth 提供方名称,请联系管理员重命名冲突的自定义提供方。",
"The token group that will have a custom ratio": "将具有自定义比例的令牌分组",
......@@ -5608,6 +5619,7 @@
"URL": "URL",
"URL is required": "URL 为必填项",
"URL to your logo image (optional)": "您的徽标图片 URL(可选)",
"US dollar (USD)": "美元(USD)",
"Usage": "用量",
"Usage at a glance": "用量概览",
"Usage guide": "使用教程",
......@@ -5828,6 +5840,7 @@
"Visual indicator color for the API card": "API 卡的可视指示器颜色",
"Visual Mode": "可视模式",
"Visual Parameter Override": "可视化参数覆盖",
"Visual prices use {{currency}} per declared unit; token prices are per 1M tokens. Raw expressions always use USD, with token terms divided by 1000000.": "可视化价格以 {{currency}} 按声明的单位计价;Token 价格按每百万 Token 计价。原始表达式始终使用美元,Token 项除以 1000000。",
"VolcEngine": "火山方舟",
"vs. previous": "相较上期",
"Waffo": "Waffo",
......
/*
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 { create } from 'zustand'
import { persist } from 'zustand/middleware'
export type PricingCurrencyPreference = 'USD' | 'site'
type PricingPreferences = {
currency: PricingCurrencyPreference
setCurrency: (currency: PricingCurrencyPreference) => void
}
export const usePricingPreferencesStore = create<PricingPreferences>()(
persist(
(set) => ({
currency: 'USD',
setCurrency: (currency) => set({ currency }),
}),
{
name: 'model-pricing-preferences',
partialize: (state) => ({ currency: state.currency }),
}
)
)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment