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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createRef } from 'react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { tryParseTaskVisualConfig } from '@/features/pricing/lib/task-expr'
import { tryParseVisualConfig } from '@/features/pricing/lib/tier-expr'
import type { BillingUsageSchema } from '@/features/pricing/types'
import {
ModelPricingEditorPanel,
type ModelPricingEditorPanelHandle,
type ModelRatioData,
} from '@/features/system-settings/models/model-pricing-sheet'
import { api } from '@/lib/api'
import { usePricingPreferencesStore } from '@/stores/pricing-preferences-store'
import {
DEFAULT_CURRENCY_CONFIG,
useSystemConfigStore,
} from '@/stores/system-config-store'
const clients: QueryClient[] = []
beforeEach(() => {
localStorage.clear()
usePricingPreferencesStore.setState({ currency: 'USD' })
useSystemConfigStore.getState().setConfig({
currency: {
...DEFAULT_CURRENCY_CONFIG,
quotaDisplayType: 'CNY',
usdExchangeRate: 7,
},
})
vi.spyOn(api, 'get').mockResolvedValue({
data: { success: true, data: [], vendors: [] },
})
})
afterEach(() => {
cleanup()
clients.splice(0).forEach((client) => client.clear())
usePricingPreferencesStore.setState({ currency: 'USD' })
useSystemConfigStore
.getState()
.setConfig({ currency: { ...DEFAULT_CURRENCY_CONFIG } })
localStorage.clear()
})
function renderEditor(
data: Partial<ModelRatioData> = {},
usageSchema?: BillingUsageSchema
) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
client.setQueryData(['status'], null)
clients.push(client)
const ref = createRef<ModelPricingEditorPanelHandle>()
const dirty = vi.fn()
const view = (entry: Partial<ModelRatioData>) => (
<QueryClientProvider client={client}>
<ModelPricingEditorPanel
ref={ref}
editData={{
name: 'currency-model',
billingMode: 'per-token',
ratio: '1',
completionRatio: '2',
...entry,
}}
usageSchema={usageSchema}
onDirtyChange={dirty}
/>
</QueryClientProvider>
)
const result = render(view(data))
return {
...result,
ref,
dirty,
reload: (entry: Partial<ModelRatioData>) => result.rerender(view(entry)),
}
}
async function selectCurrency(label: string) {
const user = userEvent.setup()
await user.click(screen.getByRole('combobox', { name: 'Pricing currency' }))
await user.click(await screen.findByRole('option', { name: label }))
}
async function commit(
ref: React.RefObject<ModelPricingEditorPanelHandle | null>
) {
let result: ModelRatioData | null = null
await act(async () => {
result = (await ref.current?.commitDraft()) ?? null
})
return result as ModelRatioData | null
}
it('defaults to USD, remembers a currency choice and restores it when reopened', async () => {
const editor = renderEditor()
expect(
screen.getByRole('combobox', { name: 'Pricing currency' })
).toHaveTextContent('US dollar (USD)')
expect(screen.getByRole('textbox', { name: 'Input price' })).toHaveValue('2')
await selectCurrency('Site currency (CNY)')
expect(screen.getByRole('textbox', { name: 'Input price' })).toHaveValue('14')
editor.unmount()
// Rehydrate from browser storage, rather than relying on the live store.
const stored = localStorage.getItem('model-pricing-preferences') ?? ''
expect(stored).not.toBe('')
usePricingPreferencesStore.setState({ currency: 'USD' })
localStorage.setItem('model-pricing-preferences', stored)
await usePricingPreferencesStore.persist.rehydrate()
renderEditor()
expect(
screen.getByRole('combobox', { name: 'Pricing currency' })
).toHaveTextContent('Site currency (CNY)')
})
it('opens currency help by keyboard and restores focus after Escape', async () => {
renderEditor()
const user = userEvent.setup()
const help = screen.getByRole('button', { name: 'About pricing currency' })
help.focus()
await user.keyboard('{Enter}')
const dialog = await screen.findByRole('dialog', {
name: 'About pricing currency',
})
expect(
within(dialog).getByText(/The system always bills in USD/)
).toBeVisible()
expect(
within(dialog).getByText('Current exchange rate: 1 USD = 7 CNY')
).toBeVisible()
await user.keyboard('{Escape}')
await waitFor(() => expect(help).toHaveFocus())
})
it('switches currencies and refreshes exchange rates without changing stored prices or dirty state', async () => {
const editor = renderEditor({ ratio: '0.123456789123', cacheRatio: '0' })
const before = await commit(editor.ref)
editor.dirty.mockClear()
await selectCurrency('Site currency (CNY)')
await selectCurrency('US dollar (USD)')
await selectCurrency('Site currency (CNY)')
act(() =>
useSystemConfigStore.getState().setConfig({
currency: {
...DEFAULT_CURRENCY_CONFIG,
quotaDisplayType: 'CNY',
usdExchangeRate: 3,
},
})
)
expect(await commit(editor.ref)).toEqual(before)
expect(editor.dirty).not.toHaveBeenCalledWith(true)
expect(screen.getByRole('textbox', { name: 'Cache read price' })).toHaveValue(
'0'
)
})
it('converts edited token prices to USD ratios while preserving unfinished decimals and disabled lanes', async () => {
const editor = renderEditor()
await selectCurrency('Site currency (CNY)')
const input = screen.getByRole('textbox', { name: 'Input price' })
fireEvent.change(input, { target: { value: '21.' } })
expect(input).toHaveValue('21.')
fireEvent.change(screen.getByRole('textbox', { name: 'Completion price' }), {
target: { value: '42' },
})
expect(await commit(editor.ref)).toMatchObject({
ratio: '1.5',
completionRatio: '2',
cacheRatio: '',
})
await selectCurrency('US dollar (USD)')
expect(input).toHaveValue('3')
expect(await commit(editor.ref)).toMatchObject({
ratio: '1.5',
completionRatio: '2',
})
})
it.each(['0', '0.0000007', '14'])(
'saves the CNY per-request price %s as USD without display rounding',
async (price) => {
const editor = renderEditor({ billingMode: 'per-request', price: '1' })
await selectCurrency('Site currency (CNY)')
const fixed = screen.getByRole('textbox', { name: 'Fixed price' })
fireEvent.change(fixed, { target: { value: price } })
const expected = { '0': '0', '0.0000007': '0.0000001', '14': '2' }[price]
expect(Number((await commit(editor.ref))?.price)).toBe(Number(expected))
await selectCurrency('US dollar (USD)')
expect(fixed).toHaveValue(expected)
}
)
it('uses the custom currency exchange rate instead of the CNY exchange rate', async () => {
useSystemConfigStore.getState().setConfig({
currency: {
...DEFAULT_CURRENCY_CONFIG,
quotaDisplayType: 'CUSTOM',
usdExchangeRate: 7,
customCurrencySymbol: '€',
customCurrencyExchangeRate: 0.5,
},
})
const editor = renderEditor({ price: '2', billingMode: 'per-request' })
await selectCurrency('Site currency (€)')
const fixed = screen.getByRole('textbox', { name: 'Fixed price' })
expect(fixed).toHaveValue('1')
fireEvent.change(fixed, { target: { value: '7' } })
expect(await commit(editor.ref)).toMatchObject({ price: '14' })
})
it.each([0, -1, Infinity, Number.NaN, undefined])(
'disables site currency and falls back to USD for invalid rate %s',
async (rate) => {
usePricingPreferencesStore.setState({ currency: 'site' })
useSystemConfigStore.getState().setConfig({
currency: {
...DEFAULT_CURRENCY_CONFIG,
quotaDisplayType: 'CNY',
usdExchangeRate: rate as number,
},
})
renderEditor()
expect(screen.getByRole('textbox', { name: 'Input price' })).toHaveValue(
'2'
)
expect(
screen.getByText(
'The site exchange rate is invalid. Prices are shown in USD.'
)
).toBeVisible()
await userEvent.click(
screen.getByRole('combobox', { name: 'Pricing currency' })
)
expect(
await screen.findByRole('option', { name: 'Site currency (CNY)' })
).toHaveAttribute('aria-disabled', 'true')
}
)
it.each(['USD', 'TOKENS'] as const)(
'offers only USD when the site uses %s',
async (type) => {
usePricingPreferencesStore.setState({ currency: 'site' })
useSystemConfigStore.getState().setConfig({
currency: { ...DEFAULT_CURRENCY_CONFIG, quotaDisplayType: type },
})
renderEditor()
await userEvent.click(
screen.getByRole('combobox', { name: 'Pricing currency' })
)
expect(screen.getAllByRole('option')).toHaveLength(1)
expect(
screen.getByRole('option', { name: 'US dollar (USD)' })
).toBeVisible()
}
)
it('blocks a non-finite conversion and allows saving after the amount is corrected', async () => {
useSystemConfigStore.getState().setConfig({
currency: {
...DEFAULT_CURRENCY_CONFIG,
quotaDisplayType: 'CNY',
usdExchangeRate: 1e-308,
},
})
const editor = renderEditor({ price: '1', billingMode: 'per-request' })
await selectCurrency('Site currency (CNY)')
const fixed = screen.getByRole('textbox', { name: 'Fixed price' })
fireEvent.change(fixed, { target: { value: '14' } })
expect(fixed).toHaveAttribute('aria-invalid', 'true')
expect(await commit(editor.ref)).toBeNull()
expect(
screen.getByText(
'The converted price must be a finite, non-negative number.'
)
).toBeVisible()
fireEvent.change(fixed, { target: { value: '0' } })
expect(await commit(editor.ref)).toMatchObject({ price: '0' })
})
it('converts tier price coefficients but leaves token thresholds and rule multipliers unchanged', async () => {
const expr =
'len <= 200000 ? tier("short", p * 2 + c * 4) : tier("long", p * 4 + c * 8)'
const editor = renderEditor({
billingMode: 'tiered_expr',
billingExpr: expr,
requestRuleExpr: '(header("x-priority") == "high" ? 2 : 1)',
})
const before = await commit(editor.ref)
await selectCurrency('Site currency (CNY)')
expect(await commit(editor.ref)).toEqual(before)
const inputs = screen.getAllByRole('textbox', { name: 'Input price' })
expect(inputs[0]).toHaveValue('14')
fireEvent.change(inputs[0], { target: { value: '21' } })
const saved = await commit(editor.ref)
const config = tryParseVisualConfig(saved?.billingExpr ?? '')
expect(config?.tiers[0].input_unit_cost).toBe(3)
expect(config?.tiers[0].conditions).toEqual([
{ var: 'len', op: '<=', value: 200000 },
])
expect(saved?.requestRuleExpr).toBe(before?.requestRuleExpr)
})
it('keeps custom raw expressions byte-for-byte intact on currency changes', async () => {
const expr = 'tier("custom", max(p * 2, 100))'
const editor = renderEditor({ billingMode: 'tiered_expr', billingExpr: expr })
await selectCurrency('Site currency (CNY)')
expect(await commit(editor.ref)).toMatchObject({ billingExpr: expr })
expect(
screen.getByText(
'Raw expressions and presets use USD. Currency selection only converts visual price inputs and monetary previews.'
)
).toBeVisible()
})
it('converts task base charges and second, token and credit prices, including whole-column fill', async () => {
const schema: BillingUsageSchema = {
seconds: { type: 'number', unit: 'second' },
tokens: { type: 'number', unit: 'token' },
credits: { type: 'number', unit: 'credit' },
mode: { enum: ['std', 'pro'] },
}
const editor = renderEditor(
{
billingMode: 'tiered_expr',
billingExpr: 'tier("base", u("seconds") * 1)',
},
schema
)
await selectCurrency('Site currency (CNY)')
fireEvent.change(screen.getByRole('textbox', { name: 'Base charge: std' }), {
target: { value: '7' },
})
fireEvent.change(screen.getByRole('textbox', { name: 'tokens: std' }), {
target: { value: '70' },
})
fireEvent.change(screen.getByRole('textbox', { name: 'credits: std' }), {
target: { value: '0.7' },
})
const secondsHeader = screen.getByRole('columnheader', { name: /seconds/ })
await userEvent.click(
within(secondsHeader).getByRole('button', { name: 'Fill entire column' })
)
fireEvent.change(
screen.getByRole('textbox', { name: 'Fill entire column' }),
{ target: { value: '14' } }
)
await userEvent.click(
screen.getByRole('button', { name: 'Apply to all rows' })
)
expect(screen.getByRole('textbox', { name: 'seconds: std' })).toHaveValue(
'14'
)
expect(screen.getByRole('textbox', { name: 'seconds: pro' })).toHaveValue(
'14'
)
const saved = await commit(editor.ref)
const config = tryParseTaskVisualConfig(saved?.billingExpr ?? '', schema)
expect(config?.tiers[0]).toMatchObject({
constant: 1,
unitPrices: { seconds: 2, tokens: 10, credits: 0.1 },
})
expect(config?.tiers[1].unitPrices.seconds).toBe(2)
expect(saved?.billingExpr).toContain('/ 1000000')
await selectCurrency('US dollar (USD)')
expect(await commit(editor.ref)).toEqual(saved)
})
it('converts task unit prices without enum tiers and updates the monetary preview', async () => {
const schema: BillingUsageSchema = {
seconds: { type: 'number', unit: 'second' },
}
const editor = renderEditor(
{
billingMode: 'tiered_expr',
billingExpr: 'tier("base", u("seconds") * 1)',
},
schema
)
await selectCurrency('Site currency (CNY)')
fireEvent.change(screen.getByRole('textbox', { name: 'seconds' }), {
target: { value: '14' },
})
fireEvent.change(screen.getByRole('textbox', { name: 'Base charge' }), {
target: { value: '7' },
})
expect(await commit(editor.ref)).toMatchObject({
billingExpr: 'tier("base", 1 + u("seconds") * 2)',
})
expect(screen.getByText(/= ¥77$/)).toBeVisible()
})
it('switches currency using the keyboard without changing the saved configuration', async () => {
const editor = renderEditor()
const original = await commit(editor.ref)
const user = userEvent.setup()
screen.getByRole('combobox', { name: 'Pricing currency' }).focus()
await user.keyboard('{ArrowDown}{End}{Enter}')
expect(
screen.getByRole('combobox', { name: 'Pricing currency' })
).toHaveTextContent('Site currency (CNY)')
expect(await commit(editor.ref)).toEqual(original)
})
it('shows the estimated token cost in the selected currency while token quantities stay unchanged', async () => {
renderEditor({
billingMode: 'tiered_expr',
billingExpr: 'tier("base", p * 2 + c * 4)',
})
const tokens = screen.getByRole('spinbutton', { name: 'Input tokens' })
fireEvent.change(tokens, { target: { value: '1000000' } })
expect(screen.getByText('Estimated cost: $2')).toBeVisible()
await selectCurrency('Site currency (CNY)')
expect(tokens).toHaveValue(1000000)
expect(screen.getByText('Estimated cost: ¥14')).toBeVisible()
})
it('keeps an empty per-request amount empty when currencies change', async () => {
const editor = renderEditor({
billingMode: 'per-request',
price: '1',
ratio: '',
completionRatio: '',
})
const fixed = screen.getByRole('textbox', { name: 'Fixed price' })
fireEvent.change(fixed, { target: { value: '' } })
await selectCurrency('Site currency (CNY)')
expect(fixed).toHaveValue('')
expect(await commit(editor.ref)).toMatchObject({ price: '' })
})
it('does not block saving valid prices when a preview quantity has a fractional step', async () => {
const schema: BillingUsageSchema = {
seconds: { type: 'number', unit: 'second' },
}
const editor = renderEditor(
{
billingMode: 'tiered_expr',
billingExpr: 'tier("base", u("seconds") * 1)',
},
schema
)
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '0.5' } })
expect(await commit(editor.ref)).toMatchObject({
billingExpr: 'tier("base", u("seconds") * 1)',
})
})
it('clears an invalid amount draft when pricing is reloaded with the same saved value', async () => {
const saved: Partial<ModelRatioData> = {
billingMode: 'per-request',
price: '1',
ratio: '',
completionRatio: '',
}
const editor = renderEditor(saved)
await selectCurrency('Site currency (CNY)')
fireEvent.change(screen.getByRole('textbox', { name: 'Fixed price' }), {
target: { value: '9'.repeat(309) },
})
expect(await commit(editor.ref)).toBeNull()
editor.reload({ ...saved })
expect(screen.getByRole('textbox', { name: 'Fixed price' })).toHaveValue('7')
expect(await commit(editor.ref)).toMatchObject({ price: '1' })
})
/*
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>
<FormControl>
<InputGroup>
<InputGroupAddon>$</InputGroupAddon>
<InputGroupInput
inputMode='decimal'
placeholder='0.01'
<FormLabel>{t('Fixed price')}</FormLabel>
<InputGroup className='has-[[data-pricing-error]]:h-auto has-[[data-pricing-error]]:flex-wrap'>
<InputGroupAddon>
{currency.symbol}
</InputGroupAddon>
<FormControl>
<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}
/>
<InputGroupAddon align='inline-end'>
{t('per request')}
</InputGroupAddon>
</InputGroup>
</FormControl>
<FieldDescription>
</FormControl>
<InputGroupAddon align='inline-end'>
{t('per request')}
</InputGroupAddon>
</InputGroup>
<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}
......
......@@ -16,19 +16,32 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Combobox } from '@/components/ui/combobox'
import { AlertTriangle } from 'lucide-react'
import { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Combobox } from '@/components/ui/combobox'
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
formatPricingAmount,
USD_PRICING_CURRENCY,
type PricingCurrency,
} from '@/features/model-pricing/currency'
import { PricingAmountInput } from '@/features/model-pricing/pricing-amount-input'
import {
combineBillingExpr,
splitBillingExprAndRequestRules,
} from '@/features/pricing/lib/billing-expr'
......@@ -60,6 +73,7 @@ import { formatPricingNumber } from './pricing-format'
import { TaskPricingMatrix } from './task-pricing-matrix'
type TaskUsagePricingEditorProps = {
currency?: PricingCurrency
billingExpr: string
requestRuleExpr: string
usageSchema: BillingUsageSchema
......@@ -71,6 +85,7 @@ type TaskUsagePricingEditorProps = {
type EditorMode = 'visual' | 'raw'
type TaskBillingPreviewProps = {
currency?: PricingCurrency
config: TaskVisualConfig | null
matchedRowLabel: string | null
requestRuleExpr: string
......@@ -85,13 +100,9 @@ function TaskBillingPreview(props: TaskBillingPreviewProps) {
const { t } = useTranslation()
const enumFields = getTaskEnumFields(props.usageSchema)
const numberFields = getTaskNumberFields(props.usageSchema)
const result = props.config
? evaluateTaskVisualConfig(
props.config,
props.sample,
props.usageSchema
)
: null
const result = props.config
? evaluateTaskVisualConfig(props.config, props.sample, props.usageSchema)
: null
if (!result) {
return (
......@@ -105,7 +116,7 @@ function TaskBillingPreview(props: TaskBillingPreviewProps) {
const formulaParts = result.parts.map((part) => {
if (part.kind === 'constant') {
return `$${formatPricingNumber(part.amount)}`
return formatPricingAmount(part.amount, props.currency)
}
const definition = props.usageSchema[part.field ?? '']
......@@ -116,10 +127,13 @@ function TaskBillingPreview(props: TaskBillingPreviewProps) {
definition?.unit === 'second'
? `${formatPricingNumber(part.quantity)}${quantityUnitLabel}`
: `${formatPricingNumber(part.quantity)} ${quantityUnitLabel}`
return `${quantityLabel} × $${formatPricingNumber(part.unitPrice)}/${t(priceUnitKey)}`
return `${quantityLabel} × ${formatPricingAmount(part.unitPrice ?? 0, props.currency)}/${t(priceUnitKey)}`
})
const formulaLeft = formulaParts.length > 0 ? formulaParts.join(' + ') : '$0'
const formula = `${formulaLeft} = $${formatPricingNumber(result.total)}`
const formulaLeft =
formulaParts.length > 0
? formulaParts.join(' + ')
: formatPricingAmount(0, props.currency)
const formula = `${formulaLeft} = ${formatPricingAmount(result.total, props.currency)}`
return (
<div className='bg-muted/30 flex flex-col gap-3 rounded-md border p-3'>
......@@ -136,26 +150,26 @@ function TaskBillingPreview(props: TaskBillingPreviewProps) {
<Field className='gap-1.5'>
<FieldLabel>{t('Example spec')}</FieldLabel>
<Combobox
options={props.usageExamples.map((example) => ({
options={props.usageExamples.map((example) => ({
value: example.label,
label: example.label,
}))}
value={
value={
props.usageExamples.find((example) =>
Object.entries(example.facts).every(
([field, value]) => props.sample[field] === value
)
)?.label ?? null
}
onValueChange={(label) => {
onValueChange={(label) => {
const example = props.usageExamples?.find(
(item) => item.label === label
)
if (example) props.onSampleReplace({ ...example.facts })
}}
className='w-full'
placeholder={t('Example spec')}
/>
className='w-full'
placeholder={t('Example spec')}
/>
</Field>
) : null}
{enumFields.length + numberFields.length > 0 ? (
......@@ -171,13 +185,13 @@ placeholder={t('Example spec')}
<code>{field}</code>
</FieldLabel>
<Combobox
options={items}
value={String(props.sample[field] ?? '')}
onValueChange={(value) =>
options={items}
value={String(props.sample[field] ?? '')}
onValueChange={(value) =>
value !== null && props.onSampleChange(field, value)
}
className='w-full'
/>
className='w-full'
/>
</Field>
)
})}
......@@ -399,7 +413,8 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
<Alert>
<AlertDescription className='text-xs'>
{t(
'Task usage prices are USD per declared unit. Token fields use dollars per 1M tokens; the editor writes / 1000000 into the expression. Other units are not divided by one million.'
'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: (props.currency ?? USD_PRICING_CURRENCY).label }
)}
</AlertDescription>
</Alert>
......@@ -415,6 +430,7 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
})}
</p>
<TaskPricingMatrix
currency={props.currency}
rows={matrixRows}
usageSchema={props.usageSchema}
matchedRowIndex={matchedRowIndex}
......@@ -451,8 +467,9 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
<code>{field}</code>
</FieldLabel>
<div className='flex items-center gap-2'>
<Input
type='number'
<PricingAmountInput
currency={props.currency}
aria-label={field}
min={0}
step={0.000001}
value={matrixRows[0].unitPrices[field] ?? 0}
......@@ -461,8 +478,8 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
event.currentTarget.select()
}
}}
onChange={(event) => {
const value = Number(event.target.value)
onChange={(usd) => {
const value = Number(usd)
handleRowChange(0, {
...matrixRows[0],
unitPrices: {
......@@ -477,7 +494,14 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
className='font-mono'
/>
<span className='text-muted-foreground shrink-0 text-xs'>
$/{t(getTaskUsagePriceUnitLabelKey(definition.unit))}
{
(props.currency ?? USD_PRICING_CURRENCY)
.symbol
}
/
{t(
getTaskUsagePriceUnitLabelKey(definition.unit)
)}
</span>
</div>
{description ? (
......@@ -489,8 +513,9 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
<Field className='gap-1.5'>
<FieldLabel>{t('Base charge')}</FieldLabel>
<div className='flex items-center gap-2'>
<Input
type='number'
<PricingAmountInput
currency={props.currency}
aria-label={t('Base charge')}
min={0}
step={0.000001}
value={matrixRows[0].constant}
......@@ -499,8 +524,8 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
event.currentTarget.select()
}
}}
onChange={(event) => {
const value = Number(event.target.value)
onChange={(usd) => {
const value = Number(usd)
handleRowChange(0, {
...matrixRows[0],
constant:
......@@ -512,7 +537,8 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
className='font-mono'
/>
<span className='text-muted-foreground shrink-0 text-xs'>
$/{t('request')}
{(props.currency ?? USD_PRICING_CURRENCY).symbol}/
{t('request')}
</span>
</div>
</Field>
......@@ -523,6 +549,7 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
)}
<TaskBillingPreview
currency={props.currency}
config={previewConfig}
matchedRowLabel={matchedRowLabel}
requestRuleExpr={previewRequestRuleExpr}
......@@ -585,6 +612,7 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
spellCheck={false}
/>
<TaskBillingPreview
currency={props.currency}
config={previewConfig}
matchedRowLabel={matchedRowLabel}
requestRuleExpr={previewRequestRuleExpr}
......
......@@ -16,12 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Combobox } from '@/components/ui/combobox'
import { ChevronDown, Copy, Plus, Trash2 } from 'lucide-react'
import {
memo,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
......@@ -41,13 +41,27 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Combobox } from '@/components/ui/combobox'
import { Field, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import {
formatPricingAmount,
USD_PRICING_CURRENCY,
type PricingCurrency,
} from '@/features/model-pricing/currency'
import { PricingAmountInput } from '@/features/model-pricing/pricing-amount-input'
import {
BILLING_EXTRA_VARS,
COMMON_TIMEZONES,
MATCH_CONTAINS,
......@@ -95,7 +109,6 @@ import {
} from '@/features/pricing/lib/tier-expr'
import { cn } from '@/lib/utils'
const PRICE_SUFFIX = '$/1M tokens'
const CACHE_PRICE_VARS = BILLING_EXTRA_VARS.filter(
(variable) => variable.group === 'cache'
)
......@@ -326,8 +339,9 @@ function formatTokenHint(n: number | string | null | undefined): string {
function formatNumberDraft(value: number | string): string {
if (value === '') return ''
if (typeof value === 'number')
if (typeof value === 'number') {
return Number.isFinite(value) ? String(value) : '0'
}
return value
}
......@@ -430,12 +444,10 @@ function ConditionRow({ condition, onChange, onRemove }: ConditionRowProps) {
return (
<div className='flex items-center gap-2'>
<Select
items={[
...CONDITION_INPUT_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
})),
]}
items={CONDITION_INPUT_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
}))}
value={condition.var}
onValueChange={(value) =>
onChange({ ...condition, var: value as TierConditionInput['var'] })
......@@ -506,21 +518,32 @@ function ConditionRow({ condition, onChange, onRemove }: ConditionRowProps) {
// ---------------------------------------------------------------------------
type PriceFieldProps = {
currency: PricingCurrency
label: string
hint?: string
value: number
onChange: (next: number) => void
}
function PriceField({ label, hint, value, onChange }: PriceFieldProps) {
function PriceField({
label,
hint,
value,
onChange,
currency,
}: PriceFieldProps) {
const id = useId()
return (
<div className='w-36 space-y-0.5'>
<Label className='text-muted-foreground text-xs'>{label}</Label>
<DraftNumberInput
min={0}
step={0.000001}
value={Number.isFinite(value) ? value : 0}
onValueChange={onChange}
<Label htmlFor={id} className='text-muted-foreground text-xs'>
{label}
</Label>
<PricingAmountInput
id={id}
currency={currency}
aria-label={label}
value={value}
onChange={(next) => onChange(Number(next))}
className='h-8 w-full'
/>
{hint && <p className='text-muted-foreground text-xs'>{hint}</p>}
......@@ -533,6 +556,7 @@ function PriceField({ label, hint, value, onChange }: PriceFieldProps) {
// ---------------------------------------------------------------------------
type VisualTierCardProps = {
currency: PricingCurrency
tier: VisualTier
index: number
total: number
......@@ -542,6 +566,7 @@ type VisualTierCardProps = {
}
function VisualTierCard({
currency,
tier,
index,
total,
......@@ -601,6 +626,7 @@ function VisualTierCard({
return (
<PriceField
currency={currency}
key={variable.key}
label={t(variable.label)}
value={value}
......@@ -661,6 +687,7 @@ function VisualTierCard({
) : (
tier.conditions.map((condition, conditionIndex) => (
<ConditionRow
// eslint-disable-next-line react/no-array-index-key -- Parsed editor rows have no IDs; preserve input identity while their editable labels and values change.
key={conditionIndex}
condition={condition}
onChange={(next) => handleConditionChange(conditionIndex, next)}
......@@ -674,13 +701,14 @@ function VisualTierCard({
<div className='flex items-center justify-between gap-3'>
<Label className='text-sm font-semibold'>{t('Token prices')}</Label>
<span className='bg-muted text-muted-foreground rounded-md px-2 py-1 text-xs'>
{PRICE_SUFFIX}
{currency.symbol}/{t('1M token')}
</span>
</div>
<div className='space-y-3'>
<div className='flex flex-wrap gap-x-4 gap-y-2'>
<PriceField
currency={currency}
label={t('Input price')}
value={inputUnitPrice}
onChange={(value) =>
......@@ -688,6 +716,7 @@ function VisualTierCard({
}
/>
<PriceField
currency={currency}
label={t('Output price')}
value={outputUnitPrice}
onChange={(value) =>
......@@ -764,11 +793,12 @@ function VisualTierCard({
// ---------------------------------------------------------------------------
type VisualEditorProps = {
currency: PricingCurrency
visualConfig: VisualConfig | null
onChange: (next: VisualConfig) => void
}
function VisualEditor({ visualConfig, onChange }: VisualEditorProps) {
function VisualEditor({ visualConfig, onChange, currency }: VisualEditorProps) {
const { t } = useTranslation()
const config = useMemo(
() => normalizeVisualConfig(visualConfig),
......@@ -843,6 +873,8 @@ function VisualEditor({ visualConfig, onChange }: VisualEditorProps) {
</p>
{config.tiers.map((tier, index) => (
<VisualTierCard
currency={currency}
// eslint-disable-next-line react/no-array-index-key -- Parsed editor rows have no IDs; preserve input identity while their editable labels and values change.
key={index}
tier={tier}
index={index}
......@@ -961,12 +993,9 @@ function RuleConditionRow({
return timeFunc
}
}
const sourceLabel =
condition.source === SOURCE_PARAM
? t('Body param')
: condition.source === SOURCE_HEADER
? t('Header')
: t('Time')
let sourceLabel = t('Time')
if (condition.source === SOURCE_PARAM) sourceLabel = t('Body param')
else if (condition.source === SOURCE_HEADER) sourceLabel = t('Header')
const handleSourceChange = (source: string) => {
if (source === SOURCE_TIME) {
......@@ -986,12 +1015,10 @@ function RuleConditionRow({
const renderTimeCondition = (timeCond: TimeCondition) => (
<>
<Select
items={[
...TIME_FUNCS.map((fn) => ({
value: fn,
label: getTimeFuncLabel(fn),
})),
]}
items={TIME_FUNCS.map((fn) => ({
value: fn,
label: getTimeFuncLabel(fn),
}))}
value={timeCond.timeFunc}
onValueChange={(value) =>
onChange({ ...timeCond, timeFunc: value as TimeFunc })
......@@ -1011,25 +1038,21 @@ function RuleConditionRow({
</SelectContent>
</Select>
<Combobox
options={[
...COMMON_TIMEZONES.map((tz) => ({
value: tz.value,
label: tz.label,
})),
]}
value={timeCond.timezone}
onValueChange={(value) =>
options={COMMON_TIMEZONES.map((tz) => ({
value: tz.value,
label: tz.label,
}))}
value={timeCond.timezone}
onValueChange={(value) =>
value !== null && onChange({ ...timeCond, timezone: value })
}
className='w-56'
/>
className='w-56'
/>
<Select
items={[
...matchOptions.map((option) => ({
value: option.value,
label: getMatchLabel(option.value),
})),
]}
items={matchOptions.map((option) => ({
value: option.value,
label: getMatchLabel(option.value),
}))}
value={timeCond.mode}
onValueChange={(v) => v !== null && handleModeChange(v)}
>
......@@ -1090,12 +1113,10 @@ className='w-56'
className='w-44'
/>
<Select
items={[
...matchOptions.map((option) => ({
value: option.value,
label: getMatchLabel(option.value),
})),
]}
items={matchOptions.map((option) => ({
value: option.value,
label: getMatchLabel(option.value),
}))}
value={phCond.mode}
onValueChange={(v) => v !== null && handleModeChange(v)}
>
......@@ -1225,6 +1246,7 @@ function RuleGroupCard({
<div className='space-y-2'>
{group.conditions.map((condition, conditionIndex) => (
<RuleConditionRow
// eslint-disable-next-line react/no-array-index-key -- Parsed editor rows have no IDs; preserve input identity while their editable labels and values change.
key={conditionIndex}
condition={condition}
onChange={(next) => handleConditionChange(conditionIndex, next)}
......@@ -1339,11 +1361,14 @@ function PresetSection({ applyPreset }: PresetSectionProps) {
// ---------------------------------------------------------------------------
type EstimatorProps = {
currency: PricingCurrency
effectiveExpr: string
}
function CostEstimator({ effectiveExpr }: EstimatorProps) {
function CostEstimator({ effectiveExpr, currency }: EstimatorProps) {
const { t } = useTranslation()
const inputId = useId()
const outputId = useId()
const [promptTokens, setPromptTokens] = useState(0)
const [completionTokens, setCompletionTokens] = useState(0)
const [extras, setExtras] = useState<ExtraTokenValues>({
......@@ -1379,16 +1404,22 @@ function CostEstimator({ effectiveExpr }: EstimatorProps) {
</div>
<div className='grid grid-cols-2 gap-3'>
<div className='space-y-1'>
<Label className='text-xs'>{t('Input tokens')}</Label>
<Label htmlFor={inputId} className='text-xs'>
{t('Input tokens')}
</Label>
<DraftNumberInput
id={inputId}
min={0}
value={promptTokens}
onValueChange={setPromptTokens}
/>
</div>
<div className='space-y-1'>
<Label className='text-xs'>{t('Output tokens')}</Label>
<Label htmlFor={outputId} className='text-xs'>
{t('Output tokens')}
</Label>
<DraftNumberInput
id={outputId}
min={0}
value={completionTokens}
onValueChange={setCompletionTokens}
......@@ -1439,7 +1470,8 @@ function CostEstimator({ effectiveExpr }: EstimatorProps) {
) : (
<div className='flex items-center gap-2'>
<span className='font-medium'>
{t('Estimated quota cost')}: {result.cost.toLocaleString()}
{t('Estimated cost')}:{' '}
{formatPricingAmount(result.cost / 1_000_000, currency)}
</span>
{result.matchedTier && (
<Badge variant='outline' className='text-xs'>
......@@ -1546,7 +1578,7 @@ function LlmPromptHelper({ modelName }: LlmPromptHelperProps) {
const prompt = useMemo(() => {
if (modelName) {
return LLM_PROMPT_TEMPLATE + `\n\nCurrent model: ${modelName}`
return `${LLM_PROMPT_TEMPLATE}\n\nCurrent model: ${modelName}`
}
return LLM_PROMPT_TEMPLATE
}, [modelName])
......@@ -1606,6 +1638,7 @@ function LlmPromptHelper({ modelName }: LlmPromptHelperProps) {
// ---------------------------------------------------------------------------
export type TieredPricingEditorProps = {
currency?: PricingCurrency
modelName?: string
billingExpr: string
requestRuleExpr: string
......@@ -1616,6 +1649,7 @@ export type TieredPricingEditorProps = {
type EditorMode = 'visual' | 'raw'
export const TieredPricingEditor = memo(function TieredPricingEditor({
currency = USD_PRICING_CURRENCY,
modelName,
billingExpr: currentExpr,
requestRuleExpr: currentRequestRuleExpr,
......@@ -1623,7 +1657,9 @@ export const TieredPricingEditor = memo(function TieredPricingEditor({
onRequestRuleExprChange,
}: TieredPricingEditorProps) {
const { t } = useTranslation()
const [editorMode, setEditorMode] = useState<EditorMode>('visual')
const [editorMode, setEditorMode] = useState<EditorMode>(() =>
currentExpr && !tryParseVisualConfig(currentExpr) ? 'raw' : 'visual'
)
const [visualConfig, setVisualConfig] = useState<VisualConfig | null>(() =>
tryParseVisualConfig(currentExpr)
)
......@@ -1784,11 +1820,17 @@ export const TieredPricingEditor = memo(function TieredPricingEditor({
)}
</div>
<p className='text-muted-foreground text-xs'>
{t(
'Raw expressions and presets use USD. Currency selection only converts visual price inputs and monetary previews.'
)}
</p>
<PresetSection applyPreset={applyPreset} />
<div className='bg-muted/30 space-y-3 rounded-md border p-3'>
{editorMode === 'visual' ? (
<VisualEditor
currency={currency}
visualConfig={visualConfig}
onChange={handleVisualChange}
/>
......@@ -1821,6 +1863,7 @@ export const TieredPricingEditor = memo(function TieredPricingEditor({
<>
{requestRuleGroups.map((group, groupIndex) => (
<RuleGroupCard
// eslint-disable-next-line react/no-array-index-key -- Parsed editor rows have no IDs; preserve input identity while their editable labels and values change.
key={groupIndex}
group={group}
index={groupIndex}
......@@ -1856,7 +1899,7 @@ export const TieredPricingEditor = memo(function TieredPricingEditor({
)}
</div>
<CostEstimator effectiveExpr={effectiveExpr} />
<CostEstimator effectiveExpr={effectiveExpr} currency={currency} />
</div>
)
})
......@@ -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}} ベンダー",
"{{count}} weeks ago": "{{count}} 週間前",
"{{created}} models created, {{updated}} models updated, {{vendors}} vendors created.": "モデル {{created}} 件を作成、{{updated}} 件を更新、ベンダー {{vendors}} 件を作成しました。",
"{{currency}} price per 1M input tokens.": "入力100万トークンあたりの価格({{currency}})。",
"{{currency}} price per 1M tokens.": "100万トークンあたりの価格({{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.": "費用 = モデル価格 × この1つの倍率。グループ設定の他の項目は計算式に入りません。",
"Cost in {{currency}} per request, regardless of tokens used.": "トークン使用量に関係なく、リクエストごとにかかる料金({{currency}})。",
"Cost in USD per request, regardless of tokens used.": "使用されたトークンに関係なく、リクエストあたりのUSDでのコスト。",
"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": "サイト 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 商品の親コンテナです。ウォレットチャージ商品とサブスクリプションプラン商品が含まれます。通常は 1 つのストアで十分です。別々の 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.": "OAuth プロバイダー名 telegram は予約されています。管理者に競合するカスタムプロバイダーの名前変更を依頼してください。",
"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}}で、トークン価格は100万トークンあたりです。式の直接編集では常に米ドルを使用し、トークン項は1000000で割ります。",
"VolcEngine": "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.": "Цена в {{currency}} за 1 млн входных токенов.",
"{{currency}} price per 1M tokens.": "Цена в {{currency}} за 1 млн токенов.",
"{{field}} updated to {{value}}": "{{field}} обновлено на {{value}}",
"{{field}} updated to {{value}} for tag: {{tag}}": "{{field}} обновлено на {{value}} для тега: {{tag}}",
"{{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.": "Принимает JSON-массив идентификаторов моделей, поддерживающих Imagine API.",
"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}}, независимо от числа токенов.",
"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: {{email}}. Введите новый 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.": "Исходные выражения и шаблоны используют USD. Выбор валюты влияет только на ввод цен в визуальном редакторе и предпросмотр сумм.",
"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 МиБ. Его исходный код показан ниже перед загрузкой.",
"Single Key": "Одиночный ключ",
"Site & Branding": "Сайт и брендинг",
"Site currency ({{currency}})": "Валюта сайта ({{currency}})",
"Site Key": "Ключ сайта",
"Site URL": "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 и переопределяет цену для каждой сессии — не нужно заранее создавать 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.": "Привязанный магазин является родительским контейнером для всех продуктов Pancake, которые new-api создает из этой админки: как продукта пополнения кошелька, так и продуктов планов подписки. Одного магазина достаточно; выбирайте другой только если действительно ведете отдельные каталоги 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.": "Курс сайта недействителен. Цены отображаются в 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.": "Система всегда рассчитывает стоимость в USD. Валюта сайта упрощает ввод и пересчёт цен; суммы переводятся в USD по курсу сайта. Переключение валюты не меняет фактическую цену. В исходных выражениях тарификации всегда используется 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.": "Имя OAuth-провайдера telegram зарезервировано. Попросите администратора переименовать конфликтующего пользовательского провайдера.",
"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}} за указанную единицу, а для токенов — за 1 млн. Исходные выражения всегда используют USD; слагаемые для токенов делятся на 1000000.",
"VolcEngine": "VolcEngine",
"vs. previous": "к предыдущему",
"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