feat: publish v1.0.0-rc.36-custom.1
This commit is contained in:
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user