feat: publish v1.0.0-rc.36-custom.1

This commit is contained in:
Codex committed 2026-09-11 05:07:48 +08:00
commit 3a7e8f3b54
2381 files changed
+551494

No files matched your search

@@ -0,0 +1,507 @@
/*
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: 'Additional charge: mode: std' }),
{
target: { value: '7' },
}
)
fireEvent.change(
screen.getByRole('textbox', { name: 'Unit price: tokens: mode: std' }),
{
target: { value: '70' },
}
)
fireEvent.change(
screen.getByRole('textbox', { name: 'Unit price: credits: mode: 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: 'Unit price: seconds: mode: std' })
).toHaveValue('14')
expect(
screen.getByRole('textbox', { name: 'Unit price: seconds: mode: 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: 'Additional 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' })
})
@@ -0,0 +1,263 @@
/*
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 {
cleanup,
render,
screen,
waitFor,
within,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { afterEach, expect, it, vi } from 'vitest'
import { SettingsPageProvider } from '@/features/system-settings/components/settings-page-context'
import { ModelPricingEditorPanel } from '@/features/system-settings/models/model-pricing-sheet'
import { ModelRatioForm } from '@/features/system-settings/models/model-ratio-form'
import { api } from '@/lib/api'
const clients: QueryClient[] = []
const originalColumnVisibility = localStorage.getItem(
'model-ratio-column-visibility'
)
afterEach(() => {
cleanup()
for (const client of clients) client.clear()
clients.length = 0
if (originalColumnVisibility === null) {
localStorage.removeItem('model-ratio-column-visibility')
} else {
localStorage.setItem(
'model-ratio-column-visibility',
originalColumnVisibility
)
}
})
function renderEditor(embedded = false) {
vi.spyOn(api, 'get').mockResolvedValue({
data: { success: true, data: [], vendors: [] },
})
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
clients.push(client)
render(
<QueryClientProvider client={client}>
<ModelPricingEditorPanel
embedded={embedded}
editData={{
name: 'example-model',
billingMode: 'per-token',
ratio: '3.25',
completionRatio: '2',
cacheRatio: '0.2',
}}
onSave={() => {}}
/>
</QueryClientProvider>
)
}
it('keeps the preview expanded and the save action outside the scrolling embedded form', () => {
renderEditor(true)
const scrollRegion = screen.getByRole('region', {
name: 'Edit model pricing',
})
expect(scrollRegion).toHaveClass(
'overflow-y-auto',
'min-h-0',
'@container/pricing-editor'
)
expect(
within(scrollRegion).getByRole('complementary', { name: 'Preview' })
).toBeVisible()
expect(
within(scrollRegion).queryByRole('button', { name: 'Save model prices' })
).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Save model prices' })
).toBeVisible()
expect(
screen.queryByRole('heading', { name: 'Edit model pricing' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('textbox', { name: 'Model name' })
).not.toBeInTheDocument()
expect(screen.getAllByText(/USD price per 1M tokens\./)).toHaveLength(1)
})
it('retains the model identity and heading when the editor is used standalone', () => {
renderEditor()
expect(
screen.getByRole('heading', { name: 'Edit model pricing' })
).toBeVisible()
expect(screen.getByRole('textbox', { name: 'Model name' })).toHaveValue(
'example-model'
)
expect(screen.getByRole('textbox', { name: 'Model name' })).toBeDisabled()
})
it('updates the preview for explicit zero and disabled prices and explains dependent audio controls', async () => {
renderEditor(true)
const user = userEvent.setup()
const preview = screen.getByRole('complementary', { name: 'Preview' })
const cache = screen.getByRole('textbox', { name: 'Cache read price' })
await user.clear(cache)
await user.type(cache, '0')
expect(within(preview).getByText('$0')).toBeVisible()
await user.click(screen.getByRole('switch', { name: 'Cache read price' }))
expect(cache).toBeDisabled()
expect(within(preview).queryByText('$0')).not.toBeInTheDocument()
const audio = screen.getByRole('switch', { name: 'Audio output price' })
expect(audio).toHaveAttribute('aria-disabled', 'true')
expect(audio).toHaveAccessibleDescription(
'Audio output price requires an audio input price.'
)
await user.click(screen.getByRole('switch', { name: 'Audio input price' }))
await user.type(
screen.getByRole('textbox', { name: 'Audio input price' }),
'1'
)
expect(audio).not.toHaveAttribute('aria-disabled', 'true')
})
function PricingFormFixture(props: {
variant: 'default' | 'unset'
onSave: () => Promise<void>
}) {
const values = {
ModelPrice: props.variant === 'default' ? '{"example-model":0.1}' : '{}',
ModelRatio: '{}',
CacheRatio: '{}',
CreateCacheRatio: '{}',
CompletionRatio: '{}',
ImageRatio: '{}',
AudioRatio: '{}',
AudioCompletionRatio: '{}',
BillingMode: '{}',
BillingExpr: '{}',
ExposeRatioEnabled: false,
}
const [actionsContainer, setActionsContainer] =
useState<HTMLDivElement | null>(null)
const form = useForm({ defaultValues: values })
return (
<>
<header>
<div ref={setActionsContainer} />
</header>
<SettingsPageProvider actionsContainer={actionsContainer}>
<ModelRatioForm
form={form}
savedValues={values}
variant={props.variant}
onSave={props.onSave}
onReset={() => undefined}
isSaving={false}
isResetting={false}
/>
</SettingsPageProvider>
</>
)
}
it.each(['default', 'unset'] as const)(
'keeps the %s pricing workspace shrinkable and saves from its fixed action bar',
async (variant) => {
const user = userEvent.setup()
const save = vi.fn(async () => undefined)
vi.spyOn(api, 'get').mockImplementation(async (url) => ({
data: {
success: true,
data: url === '/api/channel/models_enabled' ? ['example-model'] : [],
vendors: [],
},
}))
localStorage.removeItem('model-ratio-column-visibility')
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
clients.push(client)
render(
<QueryClientProvider client={client}>
<PricingFormFixture variant={variant} onSave={save} />
</QueryClientProvider>
)
const workspace = screen.getByRole('region', { name: 'Model prices' })
expect(workspace).toHaveClass(
'flex-1',
'min-h-0',
'grid-rows-[minmax(0,1fr)]'
)
await waitFor(() => expect(client.isFetching()).toBe(0))
if (variant === 'default') {
const toggle = screen.getByRole('switch', { name: 'Expose ratio API' })
expect(screen.getByRole('banner')).toContainElement(toggle)
const help = within(screen.getByRole('banner')).getByRole('button', {
name: 'Learn more',
})
await user.click(help)
expect(screen.getByRole('dialog')).toHaveTextContent(
'Allow clients to query configured prices via `/api/ratio`.'
)
expect(toggle).not.toBeChecked()
await user.keyboard('{Escape}')
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
expect(help).toHaveFocus()
expect(toggle).toHaveAccessibleDescription(
'Allow clients to query configured prices via `/api/ratio`.'
)
await user.click(toggle)
expect(toggle).toBeChecked()
await user.click(screen.getByRole('button', { name: 'Switch to JSON' }))
expect(
screen.getAllByRole('switch', { name: 'Expose ratio API' })
).toHaveLength(1)
expect(
screen.getByRole('switch', { name: 'Expose ratio API' })
).toBeChecked()
await user.click(screen.getByRole('button', { name: 'Switch to Visual' }))
} else {
expect(
screen.queryByRole('switch', { name: 'Expose ratio API' })
).not.toBeInTheDocument()
}
await user.click(await screen.findByRole('button', { name: 'Edit' }))
await user.click(screen.getByRole('tab', { name: 'Per-request' }))
const price = screen.getByRole('textbox', { name: 'Fixed price' })
await user.clear(price)
await user.type(price, '0.25')
const region = screen.getByRole('region', { name: 'Edit model pricing' })
const button = screen.getByRole('button', { name: 'Save model prices' })
expect(region).not.toContainElement(button)
expect(button.parentElement?.parentElement).toHaveClass('shrink-0')
await user.click(button)
await waitFor(() => expect(save).toHaveBeenCalledOnce())
expect(save).toHaveBeenCalledWith(
expect.objectContaining({ ExposeRatioEnabled: variant === 'default' }),
undefined
)
}
)
@@ -0,0 +1,145 @@
/*
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 { describe, expect, it } from 'vitest'
import { buildPricingChanges, type ModelPricingConfig } from '../api'
import {
applyPriceSyncSelections,
applyPricingDraft,
pricingFromDraft,
pricingOptions,
pricingRow,
} from '../pricing'
describe('shared model pricing', () => {
it('preserves explicit zero prices and cache-write configuration', () => {
expect(
pricingFromDraft({
name: 'example',
billingMode: 'per-token',
ratio: '0',
completionRatio: '2',
cacheRatio: '0',
createCacheRatio: '1.25',
})
).toEqual({
'billing_setting.billing_mode': 'ratio',
ModelRatio: 0,
CompletionRatio: 2,
CacheRatio: 0,
CreateCacheRatio: 1.25,
})
expect(
pricingFromDraft({
name: 'example',
billingMode: 'per-request',
price: '',
})
).not.toHaveProperty('ModelPrice')
expect(
pricingFromDraft({
name: 'example',
billingMode: 'per-request',
price: '0',
})
).toHaveProperty('ModelPrice', 0)
})
it('keeps token and task expressions intact through both editing and sync', () => {
for (const expression of [
'len <= 200000 ? tier("short", p * 2 + cr * 0.2 + cc * 2.5) : tier("long", p * 4)',
'tier("base", u("seconds") * 0.4)',
]) {
const values = {
'billing_setting.billing_mode': 'tiered_expr',
'billing_setting.billing_expr': expression,
ModelRatio: 1,
}
expect(pricingFromDraft(pricingRow('example', values))).toEqual(values)
}
})
it('does not persist another model’s built-in display expression when editing one price', () => {
const options = pricingOptions({
ModelPrice: '{"edited":1}',
BillingMode: '{"builtin":"tiered_expr"}',
BillingExpr: '{"builtin":"tier(\\"base\\", p * 2)"}',
})
const snapshot: ModelPricingConfig = {
options,
empty_version: 'empty',
entries: [
{
model_name: 'edited',
version: 'v1',
configured: { ModelPrice: 1 },
effective: { ModelPrice: 1 },
},
{
model_name: 'builtin',
version: 'empty',
configured: {},
effective: {
'billing_setting.billing_mode': 'tiered_expr',
'billing_setting.billing_expr': 'tier("base", p * 2)',
},
},
],
}
const after = applyPricingDraft(options, {
name: 'edited',
billingMode: 'per-request',
price: '2',
})
expect(buildPricingChanges(snapshot, options, after)).toEqual([
{
model_name: 'edited',
expected_version: 'v1',
pricing: { ModelPrice: 2, 'billing_setting.billing_mode': 'ratio' },
},
])
})
it('clears conflicting expression settings when a fixed price is selected for sync', () => {
const options = pricingOptions({
ModelRatio: '{"example":1}',
CreateCacheRatio: '{"example":1.25}',
BillingMode: '{"example":"tiered_expr"}',
BillingExpr: '{"example":"tier(\\"base\\", p * 2)"}',
})
const after = applyPriceSyncSelections(options, {
example: { model_price: 0 },
})
expect(JSON.parse(after.ModelPrice)).toEqual({ example: 0 })
expect(JSON.parse(after.ModelRatio)).toEqual({})
expect(JSON.parse(after.CreateCacheRatio)).toEqual({})
expect(JSON.parse(after['billing_setting.billing_expr'])).toEqual({})
expect(JSON.parse(after['billing_setting.billing_mode'])).toEqual({
example: 'ratio',
})
})
it('rejects invalid prices instead of silently coercing them', () => {
for (const price of ['-1', 'NaN', 'Infinity', 'invalid']) {
expect(() =>
pricingFromDraft({ name: 'example', billingMode: 'per-request', price })
).toThrow()
}
})
})
+159
View File
@@ -0,0 +1,159 @@
/*
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 {
useMutation,
useQuery,
useQueryClient,
type QueryClient,
} from '@tanstack/react-query'
import { isAxiosError } from 'axios'
import { t } from 'i18next'
import type { BillingUsageSchema } from '@/features/pricing/types'
import { api } from '@/lib/api'
import { ROLE } from '@/lib/roles'
import { useAuthStore } from '@/stores/auth-store'
import {
PRICING_KEYS,
pricingValuesByModel,
type PricingOptions,
type PricingValues,
} from './pricing'
export type ModelPricingEntry = {
model_name: string
version: string
configured: PricingValues
effective: PricingValues
usage_schema?: BillingUsageSchema
}
export type ModelPricingConfig = {
entries: ModelPricingEntry[]
options: PricingOptions
empty_version: string
}
export type ModelPricingChange = {
model_name: string
expected_version: string
pricing: PricingValues
reset?: boolean
}
export function useCanEditModelPricing() {
return useAuthStore((state) => state.auth.user?.role === ROLE.SUPER_ADMIN)
}
export async function getModelPricing(
names: string[] = []
): Promise<ModelPricingConfig> {
const params = new URLSearchParams()
for (const name of names) params.append('model', name)
const res = await api.get('/api/option/model_pricing', { params })
if (!res.data.success) {
throw new Error(res.data.message || t('Failed to load model pricing'))
}
return res.data.data
}
export function useModelPricing(names: string[] = [], enabled = true) {
const canEdit = useCanEditModelPricing()
return useQuery({
queryKey: ['model-pricing-config', ...names],
queryFn: () => getModelPricing(names),
enabled: enabled && canEdit,
refetchOnWindowFocus: false,
})
}
export async function invalidateModelPricing(client: QueryClient) {
await Promise.all([
client.invalidateQueries({ queryKey: ['model-pricing-config'] }),
client.invalidateQueries({ queryKey: ['system-options'] }),
client.invalidateQueries({ queryKey: ['pricing'] }),
client.invalidateQueries({ queryKey: ['models'] }),
])
}
export async function saveModelPricing(changes: ModelPricingChange[]) {
if (!changes.length) return
try {
const res = await api.patch('/api/option/model_pricing', { changes })
if (!res.data.success) {
throw new Error(res.data.message || t('Failed to save model pricing'))
}
} catch (error) {
if (
isAxiosError<{ message?: string }>(error) &&
error.response?.data.message
) {
throw new Error(error.response.data.message, { cause: error })
}
throw error
}
}
export function useSaveModelPricing() {
const client = useQueryClient()
return useMutation({
mutationFn: saveModelPricing,
onSuccess: () => invalidateModelPricing(client),
})
}
// Only dirty model fields are applied to stored configuration. Display-only
// built-in expressions for other models never become administrator overrides.
export function buildPricingChanges(
snapshot: ModelPricingConfig,
before: PricingOptions,
after: PricingOptions
): ModelPricingChange[] {
const previous = pricingValuesByModel(before)
const next = pricingValuesByModel(after)
const entries = new Map(
snapshot.entries.map((entry) => [entry.model_name, entry])
)
const changes: ModelPricingChange[] = []
for (const name of new Set([...previous.keys(), ...next.keys()])) {
const oldValues = previous.get(name) ?? {}
const newValues = next.get(name) ?? {}
const dirty = PRICING_KEYS.filter(
(key) => oldValues[key] !== newValues[key]
)
if (!dirty.length) continue
const entry = entries.get(name)
const pricing = { ...entry?.configured }
for (const key of dirty) {
delete pricing[key]
if (newValues[key] !== undefined) pricing[key] = newValues[key]
}
if (newValues['billing_setting.billing_mode'] === 'tiered_expr') {
pricing['billing_setting.billing_mode'] = 'tiered_expr'
pricing['billing_setting.billing_expr'] =
newValues['billing_setting.billing_expr']
}
changes.push({
model_name: name,
expected_version: entry?.version ?? snapshot.empty_version,
pricing,
})
}
return changes
}
@@ -0,0 +1,69 @@
/*
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)}`
}
@@ -0,0 +1,254 @@
/*
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 { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { ErrorState } from '@/components/error-state'
import { LoadingState } from '@/components/loading-state'
import { Button } from '@/components/ui/button'
import { DynamicPricingBreakdown } from '@/features/pricing/components/dynamic-pricing-breakdown'
import { ModelPriceCell } from '@/features/pricing/components/model-price-cell'
import { isDynamicPricingModel } from '@/features/pricing/lib/dynamic-price'
import { formatPrice } from '@/features/pricing/lib/price'
import {
ModelPricingEditorPanel,
type ModelPricingEditorPanelHandle,
} from '@/features/system-settings/models/model-pricing-sheet'
import { handleServerError } from '@/lib/handle-server-error'
import { useSystemConfigStore } from '@/stores/system-config-store'
import {
useCanEditModelPricing,
useModelPricing,
useSaveModelPricing,
type ModelPricingEntry,
} from './api'
import { modelPricingDisplay, pricingFromDraft, pricingRow } from './pricing'
export function ModelPricingPanel(props: {
modelName: string
onDirtyChange?: (dirty: boolean) => void
}) {
const { t } = useTranslation()
useSystemConfigStore((state) => state.config.currency)
const canEdit = useCanEditModelPricing()
const query = useModelPricing([props.modelName], Boolean(props.modelName))
const save = useSaveModelPricing()
const [entry, setEntry] = useState<ModelPricingEntry | null>(null)
const [resetOpen, setResetOpen] = useState(false)
const editor = useRef<ModelPricingEditorPanelHandle>(null)
const editData = useMemo(() => {
if (!entry) return null
const values = { ...entry.configured }
if (entry.effective['billing_setting.billing_mode'] === 'tiered_expr') {
values['billing_setting.billing_mode'] = 'tiered_expr'
values['billing_setting.billing_expr'] =
entry.effective['billing_setting.billing_expr']
}
return pricingRow(entry.model_name, values)
}, [entry])
useEffect(() => {
const loaded = query.data?.entries.find(
(item) => item.model_name === props.modelName
)
if (loaded && (!entry || entry.model_name !== props.modelName)) {
setEntry(loaded)
}
}, [query.data, entry, props.modelName])
const persist = async (reset = false) => {
if (!entry) return
try {
const draft = reset ? null : await editor.current?.commitDraft()
if (!reset && !draft) return
await save.mutateAsync([
{
model_name: entry.model_name,
expected_version: entry.version,
pricing: draft ? pricingFromDraft(draft) : {},
reset,
},
])
const refreshed = await query.refetch()
setEntry(
refreshed.data?.entries.find(
(item) => item.model_name === props.modelName
) ?? null
)
setResetOpen(false)
toast.success(t('Model pricing saved'))
} catch (error) {
handleServerError(error)
}
}
if (!canEdit) {
return (
<div className='text-muted-foreground p-6 text-sm'>
{t('Model pricing is managed by a super administrator.')}
</div>
)
}
if (query.isError) {
return (
<ErrorState
description={query.error.message}
onRetry={() => void query.refetch()}
/>
)
}
if (!editData || !entry) return <LoadingState />
const effectivePricing = modelPricingDisplay(entry)
return (
<div className='flex min-h-0 min-w-0 flex-1 flex-col gap-3'>
<div className='flex flex-wrap items-center justify-between gap-2 px-4 pt-3'>
<div className='min-w-0 flex-1 break-words'>
<p className='text-muted-foreground text-xs'>
{Object.keys(entry.configured).length
? t('Stored configuration with effective defaults')
: t('Using built-in or default pricing')}
</p>
</div>
<Button
variant='outline'
size='sm'
onClick={() => setResetOpen(true)}
disabled={save.isPending}
>
{t('Restore default pricing')}
</Button>
</div>
<section
aria-label={t('Current Billing')}
className='max-h-[40vh] shrink-0 space-y-3 overflow-auto border-b px-4 pb-3'
>
<h3 className='text-muted-foreground text-xs'>
{t('Current Billing')}
</h3>
<div className='max-w-xs'>
<ModelPriceCell
model={effectivePricing}
options={{ tokenUnit: 'M' }}
showExpression={false}
/>
</div>
{isDynamicPricingModel(effectivePricing) ? (
<DynamicPricingBreakdown
compact
billingExpr={effectivePricing.billing_expr}
usageSchema={entry.usage_schema}
/>
) : (
effectivePricing.quota_type === 0 &&
Number.isFinite(effectivePricing.model_ratio) && (
<dl className='grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3'>
{(
[
{
field: 'cache_ratio',
type: 'cache',
label: t('Cache Read'),
},
{
field: 'create_cache_ratio',
type: 'create_cache',
label: t('Cache write'),
},
{
field: 'image_ratio',
type: 'image',
label: t('Image input'),
},
{
field: 'audio_ratio',
type: 'audio_input',
label: t('Audio input'),
},
{
field: 'audio_completion_ratio',
type: 'audio_output',
label: t('Audio output'),
},
] as const
).map((field) => {
if (effectivePricing[field.field] == null) return null
return (
<div key={field.field}>
<dt className='text-muted-foreground'>{field.label}</dt>
<dd className='mt-1 font-mono tabular-nums'>
{formatPrice(effectivePricing, field.type, 'M')} / 1M
</dd>
</div>
)
})}
</dl>
)
)}
</section>
{save.isError && (
<div className='px-4'>
<p role='alert' className='text-destructive mb-2 text-sm'>
{save.error?.message}
</p>
<Button
variant='outline'
size='sm'
onClick={async () => {
const refreshed = await query.refetch()
const loaded = refreshed.data?.entries.find(
(item) => item.model_name === props.modelName
)
if (loaded) {
setEntry(loaded)
save.reset()
}
}}
>
{t('Reload pricing')}
</Button>
</div>
)}
<ModelPricingEditorPanel
embedded
ref={editor}
editData={editData}
usageSchema={entry.usage_schema}
onDirtyChange={props.onDirtyChange}
onSave={() => persist()}
isSaving={save.isPending}
className='rounded-none border-0'
/>
<ConfirmDialog
open={resetOpen}
onOpenChange={setResetOpen}
title={t('Restore default pricing')}
desc={t(
'Remove this model’s custom pricing and use the built-in defaults. A model without a default may become unpriced.'
)}
confirmText={t('Restore defaults')}
isLoading={save.isPending}
handleConfirm={() => void persist(true)}
/>
</div>
)
}
@@ -0,0 +1,146 @@
/*
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>
)}
</>
)
}
@@ -0,0 +1,126 @@
/*
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>
)
}
+264
View File
@@ -0,0 +1,264 @@
/*
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 { t } from 'i18next'
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
import type { PricingModel } from '@/features/pricing/types'
import type { ModelRatioData } from '@/features/system-settings/models/model-pricing-core'
import {
buildModelSnapshots,
type ModelPricingSnapshot,
} from '@/features/system-settings/models/model-pricing-snapshots'
import type { ModelPricingEntry } from './api'
export const PRICING_KEYS = [
'ModelPrice',
'ModelRatio',
'CompletionRatio',
'CacheRatio',
'CreateCacheRatio',
'ImageRatio',
'AudioRatio',
'AudioCompletionRatio',
'billing_setting.billing_mode',
'billing_setting.billing_expr',
] as const
export type PricingKey = (typeof PRICING_KEYS)[number]
export type PricingValues = Partial<Record<PricingKey, number | string>>
export type PricingOptions = Record<PricingKey, string>
export function modelPricingDisplay(
entry: Pick<ModelPricingEntry, 'model_name' | 'effective' | 'usage_schema'>
): PricingModel {
const values = entry.effective
return {
id: 0,
model_name: entry.model_name,
enable_groups: [],
quota_type:
values.ModelPrice !== undefined &&
values['billing_setting.billing_mode'] !== 'tiered_expr'
? 1
: 0,
model_ratio: Number(values.ModelRatio ?? Number.NaN),
completion_ratio: Number(values.CompletionRatio ?? Number.NaN),
model_price:
values.ModelPrice === undefined ? undefined : Number(values.ModelPrice),
cache_ratio:
values.CacheRatio === undefined ? undefined : Number(values.CacheRatio),
create_cache_ratio:
values.CreateCacheRatio === undefined
? undefined
: Number(values.CreateCacheRatio),
image_ratio:
values.ImageRatio === undefined ? undefined : Number(values.ImageRatio),
audio_ratio:
values.AudioRatio === undefined ? undefined : Number(values.AudioRatio),
audio_completion_ratio:
values.AudioCompletionRatio === undefined
? undefined
: Number(values.AudioCompletionRatio),
billing_mode:
typeof values['billing_setting.billing_mode'] === 'string'
? values['billing_setting.billing_mode']
: undefined,
billing_expr:
typeof values['billing_setting.billing_expr'] === 'string'
? values['billing_setting.billing_expr']
: undefined,
billing_usage_schema: entry.usage_schema,
}
}
export const pricingFieldMap = {
price: 'ModelPrice',
ratio: 'ModelRatio',
completionRatio: 'CompletionRatio',
cacheRatio: 'CacheRatio',
createCacheRatio: 'CreateCacheRatio',
imageRatio: 'ImageRatio',
audioRatio: 'AudioRatio',
audioCompletionRatio: 'AudioCompletionRatio',
} as const
export function pricingOptions(
values: Record<string, string | boolean>
): PricingOptions {
return Object.fromEntries(
PRICING_KEYS.map((key) => {
let value = values[key]
if (key === 'billing_setting.billing_mode') value ??= values.BillingMode
if (key === 'billing_setting.billing_expr') value ??= values.BillingExpr
return [key, typeof value === 'string' ? value : '{}']
})
) as PricingOptions
}
export function pricingRows(options: PricingOptions): ModelPricingSnapshot[] {
return buildModelSnapshots({
modelPrice: options.ModelPrice,
modelRatio: options.ModelRatio,
completionRatio: options.CompletionRatio,
cacheRatio: options.CacheRatio,
createCacheRatio: options.CreateCacheRatio,
imageRatio: options.ImageRatio,
audioRatio: options.AudioRatio,
audioCompletionRatio: options.AudioCompletionRatio,
billingMode: options['billing_setting.billing_mode'],
billingExpr: options['billing_setting.billing_expr'],
})
}
export function pricingRow(
name: string,
values: PricingValues
): ModelRatioData {
const options = Object.fromEntries(
PRICING_KEYS.map((key) => [
key,
JSON.stringify(values[key] === undefined ? {} : { [name]: values[key] }),
])
) as PricingOptions
const row = pricingRows(options)[0]
let billingMode: ModelRatioData['billingMode'] = 'per-token'
if (row?.billingMode === 'tiered_expr') billingMode = 'tiered_expr'
else if (row?.price) billingMode = 'per-request'
return { ...row, name, billingMode }
}
export function pricingFromDraft(data: ModelRatioData): PricingValues {
const values: PricingValues = {
'billing_setting.billing_mode':
data.billingMode === 'tiered_expr' ? 'tiered_expr' : 'ratio',
}
for (const [field, key] of Object.entries(pricingFieldMap)) {
const value = data[field as keyof typeof pricingFieldMap]
if (value === undefined || value === '') continue
const number = Number(value)
if (!Number.isFinite(number) || number < 0) {
throw new Error(t('Enter a finite, non-negative price'))
}
if (
data.billingMode === 'tiered_expr' ||
(data.billingMode === 'per-request'
? key === 'ModelPrice'
: key !== 'ModelPrice')
) {
values[key] = number
}
}
if (data.billingMode === 'tiered_expr') {
values['billing_setting.billing_expr'] = combineBillingExpr(
data.billingExpr || '',
data.requestRuleExpr || ''
)
}
return values
}
export function applyPricingDraft(
options: PricingOptions,
data: ModelRatioData,
names: string[] = [data.name]
): PricingOptions {
return applyPricingValues(options, pricingFromDraft(data), names)
}
function applyPricingValues(
options: PricingOptions,
values: PricingValues,
names: string[]
): PricingOptions {
return Object.fromEntries(
PRICING_KEYS.map((key) => {
const map = JSON.parse(options[key]) as Record<string, number | string>
for (const name of names) {
delete map[name]
if (values[key] !== undefined) {
Object.defineProperty(map, name, {
value: values[key],
enumerable: true,
writable: true,
configurable: true,
})
}
}
return [key, JSON.stringify(map)]
})
) as PricingOptions
}
export function pricingValuesByModel(
options: PricingOptions
): Map<string, PricingValues> {
const models = new Map<string, PricingValues>()
for (const key of PRICING_KEYS) {
const map: unknown = JSON.parse(options[key])
if (map === null || typeof map !== 'object' || Array.isArray(map)) {
throw new Error(t('Pricing must be a JSON object'))
}
for (const [name, value] of Object.entries(map)) {
if (typeof value !== 'number' && typeof value !== 'string') {
throw new Error(t('Invalid pricing value'))
}
const model = models.get(name) ?? {}
model[key] = value
models.set(name, model)
}
}
return models
}
export function applyPriceSyncSelections(
options: PricingOptions,
selections: Record<string, Record<string, number | string>>
): PricingOptions {
let result = options
for (const [name, fields] of Object.entries(selections)) {
const next: PricingValues = {}
const expression =
typeof fields.billing_expr === 'string' &&
fields.billing_expr.trim() !== ''
if (expression) {
next['billing_setting.billing_mode'] = 'tiered_expr'
next['billing_setting.billing_expr'] = fields.billing_expr
} else {
next['billing_setting.billing_mode'] = 'ratio'
const fixed = fields.model_price !== undefined
for (const [field, value] of Object.entries(fields)) {
if (field === 'billing_mode' || field === 'billing_expr') continue
if (fixed && field !== 'model_price') continue
const key = field
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('') as PricingKey
if (PRICING_KEYS.includes(key)) next[key] = value
}
}
const validated = pricingFromDraft(pricingRow(name, next))
if (expression) {
// Sync is a literal import. The editor may normalize request-rule
// parentheses, which would otherwise produce another upstream diff.
validated['billing_setting.billing_expr'] = fields.billing_expr
}
result = applyPricingValues(result, validated, [name])
}
return result
}