feat: sync deployed v1.0.0-rc.38-custom.42

Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
This commit is contained in:
cneicy committed 2026-09-26 04:49:11 +08:00
1 parent aa6c3f50f4
commit ceddfd3d6b
828 files changed
+122253 -13702

No files matched your search

@@ -30,6 +30,7 @@ import userEvent from '@testing-library/user-event'
import { createRef } from 'react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { evaluateBillingExpression } from '@/features/pricing/lib/billing-expression/runtime'
import { tryParseTaskVisualConfig } from '@/features/pricing/lib/task-expr'
import { tryParseVisualConfig } from '@/features/pricing/lib/tier-expr'
import type { BillingUsageSchema } from '@/features/pricing/types'
@@ -45,6 +46,8 @@ import {
useSystemConfigStore,
} from '@/stores/system-config-store'
import { previewModelPricing } from '../api'
const clients: QueryClient[] = []
beforeEach(() => {
@@ -72,6 +75,24 @@ afterEach(() => {
localStorage.clear()
})
it('reads preview prices and billing metadata from the common data envelope', async () => {
const effective = { ModelRatio: 0, CompletionRatio: 2 }
const billingDetails = { audio_input_price: 1 }
vi.spyOn(api, 'post').mockResolvedValue({
data: {
success: true,
data: {
effective,
cache_write_mode: 'none',
billing_details: billingDetails,
},
},
})
await expect(
previewModelPricing({ model_name: 'gemini-2.5-flash', pricing: effective })
).resolves.toEqual({ effective, cacheWriteMode: 'none', billingDetails })
})
function renderEditor(
data: Partial<ModelRatioData> = {},
usageSchema?: BillingUsageSchema
@@ -124,6 +145,457 @@ async function commit(
return result as ModelRatioData | null
}
it('previews legacy conversion in the selected currency and applies only after confirmation', async () => {
const expression = 'tier("base", p * 2 + c * 4 + cr * 0)'
const effective = {
ModelRatio: 1,
CompletionRatio: 2,
CacheRatio: 0,
CreateCacheRatio: 1.25,
ImageRatio: 1,
}
const preview = vi.spyOn(api, 'post').mockImplementation(async (url) => ({
data: {
success: true,
data: url.endsWith('/convert')
? { expression, effective, cache_write_mode: 'none' }
: { effective, cache_write_mode: 'none' },
},
}))
const save = vi.spyOn(api, 'patch')
const editor = renderEditor({ cacheRatio: '0' })
await selectCurrency('Site currency (CNY)')
await userEvent
.setup()
.click(screen.getByRole('button', { name: 'Convert to expression' }))
let dialog = await screen.findByRole('alertdialog', {
name: 'Preview pricing conversion',
})
const before = within(dialog).getByRole('region', {
name: 'Before conversion',
})
const after = within(dialog).getByRole('region', {
name: 'After conversion',
})
expect(within(before).getAllByText(/¥14/).length).toBeGreaterThan(0)
expect(within(after).getAllByText(/¥14/).length).toBeGreaterThan(0)
expect(within(before).getByText(/^¥0 /)).toBeVisible()
expect(within(after).getByText(/^¥0 /)).toBeVisible()
expect(within(after).getByText(expression)).toBeVisible()
expect(
within(before).queryByText('Image input price')
).not.toBeInTheDocument()
expect(within(after).queryByText('Image input price')).not.toBeInTheDocument()
expect(within(dialog).queryByRole('combobox')).not.toBeInTheDocument()
expect(
within(before).queryByText('Cache write price')
).not.toBeInTheDocument()
expect(
within(after).queryByText('Cache create price')
).not.toBeInTheDocument()
expect(
within(dialog).queryByText('Cache create (1h) price')
).not.toBeInTheDocument()
const original = await commit(editor.ref)
expect(original).toMatchObject({
billingMode: 'per-token',
ratio: '1',
cacheRatio: '0',
})
expect(editor.dirty).toHaveBeenLastCalledWith(false)
await userEvent
.setup()
.click(within(dialog).getByRole('button', { name: 'Cancel' }))
expect(await commit(editor.ref)).toEqual(original)
expect(save).not.toHaveBeenCalled()
await userEvent
.setup()
.click(screen.getByRole('button', { name: 'Convert to expression' }))
dialog = await screen.findByRole('alertdialog', {
name: 'Preview pricing conversion',
})
await userEvent
.setup()
.click(within(dialog).getByRole('button', { name: 'Apply to draft' }))
await waitFor(() =>
expect(screen.getByRole('tab', { name: 'Expression' })).toHaveAttribute(
'aria-selected',
'true'
)
)
expect(preview).toHaveBeenCalledWith('/api/option/model_pricing/convert', {
model_name: 'currency-model',
pricing: {
'billing_setting.billing_mode': 'ratio',
ModelRatio: 1,
CompletionRatio: 2,
CacheRatio: 0,
},
})
expect(save).not.toHaveBeenCalled()
expect(await commit(editor.ref)).toMatchObject({
billingMode: 'tiered_expr',
billingExpr: expression,
ratio: '1',
cacheRatio: '0',
})
await userEvent
.setup()
.click(screen.getByRole('tab', { name: 'Per-token (deprecated)' }))
expect(
screen.getByRole('button', { name: 'Convert to expression' })
).toBeDisabled()
})
it.each([
{
name: 'custom-cache',
ratio: 0,
mode: 'standard',
expression: 'tier("base", p * 2 + c * 4 + cr * 1 + cc * 0)',
},
{
name: 'custom-claude',
ratio: 1.5,
mode: 'claude_ttl',
expression: 'tier("base", p * 2 + c * 4 + cr * 1 + cc * 3 + cc1h * 4.8)',
},
])(
'keeps configured cache prices visible on both sides for $name',
async (fixture) => {
const effective = {
ModelRatio: 1,
CompletionRatio: 2,
CacheRatio: 0.5,
CreateCacheRatio: fixture.ratio,
ImageRatio: 1,
}
vi.spyOn(api, 'post').mockImplementation(async (url) => ({
data: {
success: true,
data: url.endsWith('/convert')
? {
effective,
expression: fixture.expression,
cache_write_mode: fixture.mode,
}
: { effective, cache_write_mode: fixture.mode },
},
}))
const editor = renderEditor({
name: fixture.name,
createCacheRatio: String(fixture.ratio),
})
await userEvent
.setup()
.click(screen.getByRole('button', { name: 'Convert to expression' }))
const dialog = await screen.findByRole('alertdialog', {
name: 'Preview pricing conversion',
})
for (const side of ['Before conversion', 'After conversion']) {
const panel = within(dialog).getByRole('region', { name: side })
let label =
side === 'Before conversion'
? 'Cache write price'
: 'Cache create price'
if (fixture.mode === 'claude_ttl') label = 'Cache Creation (5m)'
expect(
within(panel).getByText(label).nextElementSibling
).toHaveTextContent(`$${fixture.ratio * 2} / 1M token`)
if (fixture.mode === 'claude_ttl') {
expect(
within(panel).getByText('Cache create (1h) price').nextElementSibling
).toHaveTextContent('$4.8 / 1M token')
} else {
expect(
within(panel).queryByText('Cache create (1h) price')
).not.toBeInTheDocument()
}
}
await userEvent
.setup()
.click(within(dialog).getByRole('button', { name: 'Apply to draft' }))
expect(await commit(editor.ref)).toMatchObject({
billingExpr: fixture.expression,
createCacheRatio: String(fixture.ratio),
})
}
)
it.each([
{
name: 'gpt-4-32k',
inputRatio: 30,
cacheRatio: 1,
expression: 'tier("base", p * 60 + c * 120)',
},
{
name: 'free-cache-model',
inputRatio: 0,
cacheRatio: 0,
expression: 'tier("base", p * 0 + c * 0 + cr * 0)',
},
{
name: 'overlapping-cache',
inputRatio: 30,
cacheRatio: 1,
createCacheRatio: 1.25,
expression: 'tier("base", p * 60 + c * 120 + cr * 60 + cc * 75)',
},
])(
'merges equal cache prices while preserving explicit free cache for $name',
async (fixture) => {
const effective = {
ModelRatio: fixture.inputRatio,
CompletionRatio: 2,
CacheRatio: fixture.cacheRatio,
CreateCacheRatio: 1.25,
ImageRatio: 1,
}
vi.spyOn(api, 'post').mockImplementation(async (url) => ({
data: {
success: true,
data: url.endsWith('/convert')
? {
effective,
expression: fixture.expression,
cache_write_mode:
fixture.createCacheRatio === undefined ? 'none' : 'standard',
}
: {
effective,
cache_write_mode:
fixture.createCacheRatio === undefined ? 'none' : 'standard',
},
},
}))
const editor = renderEditor({
name: fixture.name,
ratio: String(fixture.inputRatio),
cacheRatio: String(fixture.cacheRatio),
createCacheRatio: fixture.createCacheRatio?.toString(),
})
await userEvent
.setup()
.click(screen.getByRole('button', { name: 'Convert to expression' }))
const dialog = await screen.findByRole('alertdialog', {
name: 'Preview pricing conversion',
})
for (const side of ['Before conversion', 'After conversion']) {
const panel = within(dialog).getByRole('region', { name: side })
if (fixture.cacheRatio === 1) {
expect(
within(panel).queryByText('Cache read price')
).not.toBeInTheDocument()
} else {
expect(
within(panel).getByText('Cache read price').nextElementSibling
).toHaveTextContent('$0 / 1M token')
}
expect(
within(panel).getByText('Input price').nextElementSibling
).toHaveTextContent(`$${fixture.inputRatio * 2} / 1M token`)
}
await userEvent
.setup()
.click(within(dialog).getByRole('button', { name: 'Apply to draft' }))
expect(await commit(editor.ref)).toMatchObject({
billingExpr: fixture.expression,
cacheRatio: String(fixture.cacheRatio),
})
}
)
it('discards a conversion preview when the model being edited changes', async () => {
const effective = { ModelPrice: 0 }
vi.spyOn(api, 'post').mockResolvedValue({
data: {
success: true,
data: { expression: 'tier("request", fixed(0))', effective },
},
})
const editor = renderEditor({ billingMode: 'per-request', price: '0' })
await userEvent
.setup()
.click(screen.getByRole('button', { name: 'Convert to expression' }))
const dialog = await screen.findByRole('alertdialog', {
name: 'Preview pricing conversion',
})
expect(within(dialog).getAllByText(/^\$0 \/ request$/)).toHaveLength(2)
editor.reload({ name: 'another-model', ratio: '3' })
await waitFor(() =>
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
)
expect(await commit(editor.ref)).toMatchObject({
name: 'another-model',
billingMode: 'per-token',
ratio: '3',
})
expect((await commit(editor.ref))?.billingExpr).toBeUndefined()
})
it('previews and retains image quantity and nested quality rules when applying a conversion', async () => {
const condition =
'param("quality") == "hd" && (param("size") == "1024x1792" || param("size") == "1792x1024")'
const expression = `tier("image", fixed(0.04)) * image_count * (${condition} ? 3 : 1)`
vi.spyOn(api, 'post').mockResolvedValue({
data: {
success: true,
data: {
expression,
effective: { ModelPrice: 0.04 },
billing_details: {
image_count: true,
request_rules: [{ condition, multiplier: 3 }],
},
},
},
})
const editor = renderEditor({
name: 'dall-e-3',
billingMode: 'per-request',
price: '0.04',
})
await userEvent
.setup()
.click(screen.getByRole('button', { name: 'Convert to expression' }))
const dialog = await screen.findByRole('alertdialog', {
name: 'Preview pricing conversion',
})
for (const name of ['Before conversion', 'After conversion']) {
const panel = within(dialog).getByRole('region', { name })
expect(
within(panel).getByText('Price per image').nextElementSibling
).toHaveTextContent('$0.04 / image')
expect(
within(panel).getByText('Image count').nextElementSibling
).toHaveTextContent('Reserve requested images; settle returned images.')
expect(within(panel).getByText('× 3')).toBeInTheDocument()
}
expect((await commit(editor.ref))?.billingMode).toBe('per-request')
await userEvent
.setup()
.click(within(dialog).getByRole('button', { name: 'Apply to draft' }))
const committed = await commit(editor.ref)
expect(committed?.billingExpr).toContain('image_count')
expect(
evaluateBillingExpression(committed?.billingExpr || '', {
imageCount: 2,
request: { body: { quality: 'hd', size: '1024x1792' } },
})
).toMatchObject({ status: 'success', cost: 240000 })
})
it('shows Gemini audio pricing when the legacy text price is zero without creating a ratio override', async () => {
const effective = {
ModelRatio: 0,
CompletionRatio: 2,
CacheRatio: 1,
ImageRatio: 1,
}
const expression = 'tier("base", p * 0 + c * 0 + ai * 1)'
vi.spyOn(api, 'post').mockImplementation(async (url) => ({
data: {
success: true,
data: url.endsWith('/convert')
? { expression, effective, billing_details: { audio_input_price: 1 } }
: { effective, billing_details: { audio_input_price: 1 } },
},
}))
const editor = renderEditor({ name: 'gemini-2.5-flash', ratio: '0' })
await userEvent
.setup()
.click(screen.getByRole('button', { name: 'Convert to expression' }))
const dialog = await screen.findByRole('alertdialog', {
name: 'Preview pricing conversion',
})
for (const name of ['Before conversion', 'After conversion']) {
const panel = within(dialog).getByRole('region', { name })
expect(
within(panel).getByText('Audio input price').nextElementSibling
).toHaveTextContent('$1 / 1M token')
}
await userEvent
.setup()
.click(within(dialog).getByRole('button', { name: 'Apply to draft' }))
const committed = await commit(editor.ref)
expect(committed?.billingExpr).toBe(expression)
expect(committed?.audioRatio).toBe('')
})
it.each([0, 2])(
'keeps an independent image ratio of %s visible in both conversion panels',
async (imageRatio) => {
const effective = {
ModelRatio: 1,
CompletionRatio: 2,
ImageRatio: imageRatio,
}
vi.spyOn(api, 'post').mockResolvedValue({
data: {
success: true,
data: {
expression: `tier("base", p * 2 + c * 4 + img * ${imageRatio * 2})`,
effective,
},
},
})
renderEditor({ imageRatio: String(imageRatio) })
await userEvent
.setup()
.click(screen.getByRole('button', { name: 'Convert to expression' }))
const dialog = await screen.findByRole('alertdialog', {
name: 'Preview pricing conversion',
})
for (const title of ['Before conversion', 'After conversion']) {
const panel = within(dialog).getByRole('region', { name: title })
expect(within(panel).getByText('Image input price')).toBeVisible()
expect(
within(panel).getByText('Image input price').nextElementSibling
).toHaveTextContent(`$${imageRatio * 2} / 1M token`)
}
}
)
it('opens expression pricing by default when an existing model has no configured prices', () => {
const editor = renderEditor({
ratio: '',
completionRatio: '',
billingMode: 'per-token',
})
expect(screen.getByRole('tab', { name: 'Expression' })).toHaveAttribute(
'aria-selected',
'true'
)
expect(editor.dirty).toHaveBeenLastCalledWith(false)
})
it('preserves a legacy per-request draft when conversion is unsupported', async () => {
vi.spyOn(api, 'post').mockResolvedValue({
data: {
success: true,
data: {
unsupported_reason:
'Image prices with count, size or quality adjustments must be converted manually.',
},
},
})
const editor = renderEditor({
name: 'gpt-image-2',
billingMode: 'per-request',
price: '0',
})
await userEvent
.setup()
.click(screen.getByRole('button', { name: 'Convert to expression' }))
expect(await screen.findByRole('status')).toHaveTextContent(
'Image prices with count, size or quality adjustments must be converted manually.'
)
const draft = await commit(editor.ref)
expect(draft).toMatchObject({ billingMode: 'per-request', price: '0' })
expect(draft?.billingExpr).toBeUndefined()
})
it('defaults to USD, remembers a currency choice and restores it when reopened', async () => {
const editor = renderEditor()
expect(
@@ -336,7 +808,10 @@ it('converts tier price coefficients but leaves token thresholds and rule multip
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 })
const editor = renderEditor({
billingMode: 'tiered_expr',
billingExpr: expr,
})
await selectCurrency('Site currency (CNY)')
expect(await commit(editor.ref)).toMatchObject({ billingExpr: expr })
expect(
@@ -346,6 +821,28 @@ it('keeps custom raw expressions byte-for-byte intact on currency changes', asyn
).toBeVisible()
})
it('keeps time pricing and request rules unchanged when opening the simulator', async () => {
const user = userEvent.setup()
const expression =
'hour("Asia/Shanghai") < 12 ? tier("peak", p * 3 + c * 9) : tier("off_peak", p * 1.5 + c * 4.5)'
const rules = '(header("tier") == "fast" ? 2 : 1)'
const editor = renderEditor({
billingMode: 'tiered_expr',
billingExpr: expression,
requestRuleExpr: rules,
})
const before = await commit(editor.ref)
await user.click(screen.getByRole('button', { name: 'Request simulation' }))
fireEvent.input(
screen.getByRole('textbox', { name: 'Simulated request body' }),
{ target: { value: '{"unused":1}' } }
)
await selectCurrency('Site currency (CNY)')
expect(await commit(editor.ref)).toEqual(before)
expect(before?.billingExpr).toBe(expression)
expect(before?.requestRuleExpr).toBe(rules)
})
it('converts task base charges and second, token and credit prices, including whole-column fill', async () => {
const schema: BillingUsageSchema = {
seconds: { type: 'number', unit: 'second' },
@@ -482,7 +979,9 @@ it('does not block saving valid prices when a preview quantity has a fractional
},
schema
)
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '0.5' } })
fireEvent.change(screen.getByRole('spinbutton'), {
target: { value: '0.5' },
})
expect(await commit(editor.ref)).toMatchObject({
billingExpr: 'tier("base", u("seconds") * 1)',
})
@@ -505,3 +1004,34 @@ it('clears an invalid amount draft when pricing is reloaded with the same saved
expect(screen.getByRole('textbox', { name: 'Fixed price' })).toHaveValue('7')
expect(await commit(editor.ref)).toMatchObject({ price: '1' })
})
it('preserves time billing on direct save and blocks incomplete local conditions', async () => {
const expression =
'hour("Asia/Shanghai") >= 9 ? tier("peak", p * 3 + cr * 0 + c * 9) : tier("off", p * 1 + c * 2)'
const requestRuleExpr = '(header("x-plan") == "fast" ? 2.00 : 1)'
const editor = renderEditor({
billingMode: 'tiered_expr',
billingExpr: expression,
requestRuleExpr,
})
expect(await commit(editor.ref)).toMatchObject({
billingExpr: expression,
requestRuleExpr,
})
await selectCurrency('Site currency (CNY)')
expect(await commit(editor.ref)).toMatchObject({
billingExpr: expression,
requestRuleExpr,
})
fireEvent.change(screen.getByRole('textbox', { name: 'Condition value' }), {
target: { value: '' },
})
expect(await commit(editor.ref)).toBeNull()
fireEvent.change(screen.getByRole('textbox', { name: 'Condition value' }), {
target: { value: '10' },
})
expect(await commit(editor.ref)).toMatchObject({
billingExpr: expression.replace('>= 9', '>= 10'),
requestRuleExpr,
})
})
@@ -33,6 +33,11 @@ import { SettingsPageProvider } from '@/features/system-settings/components/sett
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'
import { useAuthStore } from '@/stores/auth-store'
import { usePricingPreferencesStore } from '@/stores/pricing-preferences-store'
import { ModelPricingPanel } from '../model-pricing-panel'
import type { PricingValues } from '../pricing'
const clients: QueryClient[] = []
const originalColumnVisibility = localStorage.getItem(
@@ -43,6 +48,7 @@ afterEach(() => {
cleanup()
for (const client of clients) client.clear()
clients.length = 0
useAuthStore.getState().auth.setUser(null)
if (originalColumnVisibility === null) {
localStorage.removeItem('model-ratio-column-visibility')
} else {
@@ -53,10 +59,129 @@ afterEach(() => {
}
})
it.each(['none', 'standard', 'claude_ttl'] as const)(
'uses the %s cache profile consistently in current billing and draft previews',
async (mode) => {
const cacheWriteMode = mode
const effective = {
ModelRatio: 2,
CompletionRatio: 4,
CacheRatio: 1,
CreateCacheRatio: 1.25,
ImageRatio: 1,
}
const configured = { ModelRatio: 2 }
useAuthStore
.getState()
.auth.setUser({ id: 1, username: 'administrator', role: 100 })
usePricingPreferencesStore.setState({ currency: 'USD' })
vi.spyOn(api, 'get').mockImplementation(async (url) => ({
data: {
success: true,
data:
url === '/api/option/model_pricing'
? {
entries: [
{
model_name: 'gpt-4o-custom',
version: 'v1',
configured,
effective,
cache_write_mode: cacheWriteMode,
},
],
}
: [],
vendors: [],
},
}))
vi.spyOn(api, 'post').mockResolvedValue({
data: {
success: true,
data: { effective, cache_write_mode: cacheWriteMode },
},
})
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
clients.push(client)
render(
<QueryClientProvider client={client}>
<ModelPricingPanel modelName='gpt-4o-custom' />
</QueryClientProvider>
)
const billing = await screen.findByRole('region', {
name: 'Current Billing',
})
expect(within(billing).getByText('16')).toBeVisible()
expect(within(billing).queryByText('Image input')).not.toBeInTheDocument()
const preview = screen.getByRole('complementary', { name: 'Preview' })
expect(await within(preview).findByText('$16')).toBeVisible()
expect(
within(preview).queryByText('Image input price')
).not.toBeInTheDocument()
expect(
within(preview).queryByText('Cache read price')
).not.toBeInTheDocument()
expect(
within(billing).queryByText('Cache read price')
).not.toBeInTheDocument()
for (const panel of [billing, preview]) {
if (mode === 'none') {
expect(
within(panel).queryByText('Cache write price')
).not.toBeInTheDocument()
expect(
within(panel).queryByText('Cache create (1h) price')
).not.toBeInTheDocument()
} else {
expect(
within(panel).getByText(
mode === 'claude_ttl' ? 'Cache Creation (5m)' : 'Cache write price'
).nextElementSibling
).toHaveTextContent('$5')
if (mode === 'claude_ttl') {
expect(
within(panel).getByText('Cache create (1h) price')
.nextElementSibling
).toHaveTextContent('$8')
} else {
expect(
within(panel).queryByText('Cache create (1h) price')
).not.toBeInTheDocument()
}
}
}
expect(
screen.getByRole('textbox', { name: 'Completion price' })
).toBeDisabled()
expect(
screen.getByRole('textbox', { name: 'Cache read price' })
).toBeDisabled()
}
)
function renderEditor(embedded = false) {
vi.spyOn(api, 'get').mockResolvedValue({
data: { success: true, data: [], vendors: [] },
})
vi.spyOn(api, 'post').mockImplementation(async (_url, request) => {
const pricing = (request as { pricing: PricingValues }).pricing
return {
data: {
success: true,
data: {
effective: {
...pricing,
CompletionRatio: pricing.CompletionRatio ?? 1,
CacheRatio: pricing.CacheRatio ?? 1,
CreateCacheRatio: pricing.CreateCacheRatio ?? 1.25,
ImageRatio: pricing.ImageRatio ?? 1,
},
},
},
}
})
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
@@ -124,10 +249,15 @@ it('updates the preview for explicit zero and disabled prices and explains depen
const cache = screen.getByRole('textbox', { name: 'Cache read price' })
await user.clear(cache)
await user.type(cache, '0')
expect(within(preview).getByText('$0')).toBeVisible()
expect(await within(preview).findByText('$0')).toBeVisible()
await user.click(screen.getByRole('switch', { name: 'Cache read price' }))
expect(cache).toBeDisabled()
expect(within(preview).queryByText('$0')).not.toBeInTheDocument()
await waitFor(() =>
expect(
within(preview).queryByText('Cache read price')
).not.toBeInTheDocument()
)
const audio = screen.getByRole('switch', { name: 'Audio output price' })
expect(audio).toHaveAttribute('aria-disabled', 'true')
expect(audio).toHaveAccessibleDescription(
@@ -156,6 +286,7 @@ function PricingFormFixture(props: {
AudioCompletionRatio: '{}',
BillingMode: '{}',
BillingExpr: '{}',
PluginBillingExpr: '{}',
ExposeRatioEnabled: false,
}
const [actionsContainer, setActionsContainer] =
@@ -245,7 +376,9 @@ it.each(['default', 'unset'] as const)(
).not.toBeInTheDocument()
}
await user.click(await screen.findByRole('button', { name: 'Edit' }))
await user.click(screen.getByRole('tab', { name: 'Per-request' }))
await user.click(
screen.getByRole('tab', { name: 'Per-request (deprecated)' })
)
const price = screen.getByRole('textbox', { name: 'Fixed price' })
await user.clear(price)
await user.type(price, '0.25')
@@ -143,3 +143,95 @@ describe('shared model pricing', () => {
}
})
})
it('tracks nested provider prices by model, preserves :: in model names, and ignores key order', () => {
const key = 'billing_setting.plugin_billing_expr'
const before = pricingOptions({
[key]: JSON.stringify({
'alpha::shared::model': 'u("seconds")',
'beta::shared::model': 'u("credits")',
'alpha::other': '1',
}),
})
const snapshot: ModelPricingConfig = {
options: before,
empty_version: 'empty',
entries: [
{
model_name: 'shared::model',
version: 'v1',
configured: { [key]: { alpha: 'u("seconds")', beta: 'u("credits")' } },
effective: {},
},
],
}
const reordered = {
...before,
[key]: JSON.stringify({
'alpha::other': '1',
'beta::shared::model': 'u("credits")',
'alpha::shared::model': 'u("seconds")',
}),
}
expect(buildPricingChanges(snapshot, before, reordered)).toEqual([])
const after = {
...before,
[key]: JSON.stringify({
'beta::shared::model': 'u("credits") * 2',
'alpha::other': '1',
}),
}
expect(buildPricingChanges(snapshot, before, after)).toEqual([
{
model_name: 'shared::model',
expected_version: 'v1',
pricing: { [key]: { beta: 'u("credits") * 2' } },
},
])
const draft = pricingRow('shared::model', snapshot.entries[0].configured)
expect(pricingFromDraft(draft)[key]).toEqual(
snapshot.entries[0].configured[key]
)
expect(
pricingRow('alpha::model', {
ModelPrice: 0.25,
'billing_setting.plugin_billing_expr': { beta: 'u("images")' },
})
).toMatchObject({
name: 'alpha::model',
price: '0.25',
billingMode: 'per-request',
})
})
it('retains provider overrides during model-only price synchronization', () => {
const key = 'billing_setting.plugin_billing_expr'
const options = pricingOptions({
[key]: JSON.stringify({ 'alpha::example': 'u("seconds") * 2' }),
})
const after = applyPriceSyncSelections(options, {
example: { billing_expr: 'tier("base", u("seconds"))' },
})
expect(after[key]).toEqual(options[key])
})
it('commits source provider edits while preserving target providers during batch copy', () => {
const key = 'billing_setting.plugin_billing_expr'
const options = pricingOptions({
[key]: JSON.stringify({ 'alpha::source': '1', 'beta::target': '2' }),
})
const after = applyPricingDraft(
options,
{
name: 'source',
billingMode: 'tiered_expr',
billingExpr: 'tier("base", u("seconds"))',
pluginBillingExpr: { alpha: '3' },
},
['source', 'target']
)
expect(JSON.parse(after[key])).toEqual({
'alpha::source': '3',
'beta::target': '2',
})
})
@@ -0,0 +1,151 @@
/*
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 { QueryClientProvider, type QueryClient } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AxiosError, type AxiosAdapter } from 'axios'
import { toast } from 'sonner'
import { afterEach, expect, it, vi } from 'vitest'
import { api } from '@/lib/http-client'
import { createAppQueryClient } from '@/lib/query-client'
import { useAuthStore } from '@/stores/auth-store'
import { usePricingPreferencesStore } from '@/stores/pricing-preferences-store'
import type { ModelPricingConfig } from '../api'
import { ModelPricingPanel } from '../model-pricing-panel'
import { pricingOptions } from '../pricing'
const originalAdapter = api.defaults.adapter
let client: QueryClient | undefined
afterEach(() => {
client?.clear()
api.defaults.adapter = originalAdapter
useAuthStore.getState().auth.setUser(null)
localStorage.clear()
vi.restoreAllMocks()
})
it.each([200, 400])(
'shows the backend pricing error once, preserves the draft, and reports a new attempt separately (HTTP %i)',
async (status) => {
useAuthStore
.getState()
.auth.setUser({ id: 1, username: 'administrator', role: 100 })
usePricingPreferencesStore.setState({ currency: 'USD' })
const values = {
'billing_setting.billing_mode': 'tiered_expr',
'billing_setting.billing_expr': 'tier("standard", p * 1 + c * 2)',
}
const snapshot: ModelPricingConfig = {
entries: [
{
model_name: 'example',
version: 'v1',
configured: values,
effective: values,
},
],
options: pricingOptions({}),
empty_version: 'empty',
}
const message = 'model_pricing: expression validation failed (1:18)'
const notify = vi.spyOn(toast, 'error').mockReturnValue('pricing-error')
const requests: unknown[] = []
const adapter: AxiosAdapter = async (config) => {
if (
config.method === 'get' &&
['/api/status', '/api/pricing'].includes(config.url ?? '')
) {
const data =
config.url === '/api/status'
? { success: true, data: {} }
: { success: true, data: [], vendors: [] }
return { data, status: 200, statusText: 'OK', headers: {}, config }
}
if (
config.method === 'get' &&
config.url === '/api/option/model_pricing'
) {
return {
data: { success: true, data: snapshot },
status: 200,
statusText: 'OK',
headers: {},
config,
}
}
if (
config.method === 'patch' &&
config.url === '/api/option/model_pricing'
) {
requests.push(config.data)
const response = {
data: { success: false, message },
status,
statusText: 'Error',
headers: {},
config,
}
if (status === 400) {
throw new AxiosError(
'Request failed with status code 400',
'ERR_BAD_REQUEST',
config,
undefined,
response
)
}
return response
}
throw new Error(`Unexpected request: ${config.method} ${config.url}`)
}
api.defaults.adapter = adapter
client = createAppQueryClient()
render(
<QueryClientProvider client={client}>
<ModelPricingPanel modelName='example' />
</QueryClientProvider>
)
const user = userEvent.setup()
const price = await screen.findByRole('textbox', {
name: 'Input price',
})
await user.clear(price)
await user.type(price, '3')
const save = screen.getByRole('button', { name: 'Save model prices' })
await user.click(save)
await waitFor(() =>
expect(notify.mock.calls.map(([text]) => text)).toEqual([message])
)
expect(await screen.findByRole('alert')).toHaveTextContent(message)
expect(price).toHaveValue('3')
await waitFor(() => expect(save).toBeEnabled())
await user.click(save)
await waitFor(() =>
expect(notify.mock.calls.map(([text]) => text)).toEqual([
message,
message,
])
)
expect(requests).toHaveLength(2)
expect(requests[0]).toContain('p * 3')
}
)
+78 -21
View File
@@ -22,12 +22,16 @@ import {
useQueryClient,
type QueryClient,
} from '@tanstack/react-query'
import { isAxiosError } from 'axios'
import { t } from 'i18next'
import type { BillingUsageSchema } from '@/features/pricing/types'
import { pluginExpressionsEqual } from '@/features/pricing/lib/plugin-pricing'
import type {
BillingUsageSchema,
BillingUsageExample,
} from '@/features/pricing/types'
import { api } from '@/lib/api'
import { ROLE } from '@/lib/roles'
import { createServerError } from '@/lib/server-error-message'
import { useAuthStore } from '@/stores/auth-store'
import {
@@ -35,13 +39,33 @@ import {
pricingValuesByModel,
type PricingOptions,
type PricingValues,
type CacheWriteMode,
type LegacyBillingDetails,
} from './pricing'
export type ModelPricingEntry = {
export type ModelPricingDescription = {
billing_details?: LegacyBillingDetails
effective: PricingValues
cache_write_mode?: CacheWriteMode
}
export type ModelPricingPluginVariant = {
plugin_key: string
plugin_name: string
icon?: string
usage_schema: BillingUsageSchema
usage_examples?: BillingUsageExample[]
configured: string
effective: string
compatible: boolean
stale?: boolean
}
export type ModelPricingEntry = ModelPricingDescription & {
plugin_variants?: ModelPricingPluginVariant[]
model_name: string
version: string
configured: PricingValues
effective: PricingValues
usage_schema?: BillingUsageSchema
}
@@ -57,6 +81,44 @@ export type ModelPricingChange = {
reset?: boolean
}
export type ModelPricingConversion = Partial<ModelPricingDescription> & {
expression?: string
unsupported_reason?: string
}
export async function previewModelPricingConversion(request: {
model_name: string
pricing: PricingValues
}): Promise<ModelPricingConversion> {
const response = await api.post('/api/option/model_pricing/convert', request)
if (!response.data.success) {
throw createServerError(
response.data,
t('Failed to prepare pricing conversion')
)
}
return response.data.data
}
export async function previewModelPricing(request: {
model_name: string
pricing: PricingValues
}): Promise<{
effective: PricingValues
cacheWriteMode?: CacheWriteMode
billingDetails?: LegacyBillingDetails
}> {
const response = await api.post('/api/option/model_pricing/preview', request)
if (!response.data.success) {
throw createServerError(response.data, t('Failed to load model pricing'))
}
return {
effective: response.data.data.effective,
cacheWriteMode: response.data.data.cache_write_mode,
billingDetails: response.data.data.billing_details,
}
}
export function useCanEditModelPricing() {
return useAuthStore((state) => state.auth.user?.role === ROLE.SUPER_ADMIN)
}
@@ -68,7 +130,7 @@ export async function getModelPricing(
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'))
throw createServerError(res.data, t('Failed to load model pricing'))
}
return res.data.data
}
@@ -86,6 +148,7 @@ export function useModelPricing(names: string[] = [], enabled = true) {
export async function invalidateModelPricing(client: QueryClient) {
await Promise.all([
client.invalidateQueries({ queryKey: ['model-pricing-config'] }),
client.invalidateQueries({ queryKey: ['model-pricing-preview'] }),
client.invalidateQueries({ queryKey: ['system-options'] }),
client.invalidateQueries({ queryKey: ['pricing'] }),
client.invalidateQueries({ queryKey: ['models'] }),
@@ -94,19 +157,9 @@ export async function invalidateModelPricing(client: QueryClient) {
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
const res = await api.patch('/api/option/model_pricing', { changes })
if (!res.data.success) {
throw createServerError(res.data, t('Failed to save model pricing'))
}
}
@@ -134,15 +187,19 @@ export function buildPricingChanges(
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]
const dirty = PRICING_KEYS.filter((key) =>
key === 'billing_setting.plugin_billing_expr'
? !pluginExpressionsEqual(oldValues[key], newValues[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[key] !== undefined) {
Object.assign(pricing, { [key]: newValues[key] })
}
}
if (newValues['billing_setting.billing_mode'] === 'tiered_expr') {
pricing['billing_setting.billing_mode'] = 'tiered_expr'
@@ -27,12 +27,16 @@ 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 {
buildPreviewRows,
createInitialLaneState,
} from '@/features/system-settings/models/model-pricing-core'
import {
ModelPricingEditorPanel,
type ModelPricingEditorPanelHandle,
} from '@/features/system-settings/models/model-pricing-sheet'
import { handleServerError } from '@/lib/handle-server-error'
import { usePricingPreferencesStore } from '@/stores/pricing-preferences-store'
import { useSystemConfigStore } from '@/stores/system-config-store'
import {
@@ -41,6 +45,11 @@ import {
useSaveModelPricing,
type ModelPricingEntry,
} from './api'
import {
getSitePricingCurrency,
isValidPricingCurrency,
USD_PRICING_CURRENCY,
} from './currency'
import { modelPricingDisplay, pricingFromDraft, pricingRow } from './pricing'
export function ModelPricingPanel(props: {
@@ -48,7 +57,10 @@ export function ModelPricingPanel(props: {
onDirtyChange?: (dirty: boolean) => void
}) {
const { t } = useTranslation()
useSystemConfigStore((state) => state.config.currency)
const currencyConfig = useSystemConfigStore((state) => state.config.currency)
const currencyPreference = usePricingPreferencesStore(
(state) => state.currency
)
const canEdit = useCanEditModelPricing()
const query = useModelPricing([props.modelName], Boolean(props.modelName))
const save = useSaveModelPricing()
@@ -118,125 +130,126 @@ export function ModelPricingPanel(props: {
}
if (!editData || !entry) return <LoadingState />
const effectivePricing = modelPricingDisplay(entry)
const siteCurrency = getSitePricingCurrency(currencyConfig)
const currency =
currencyPreference === 'site' && isValidPricingCurrency(siteCurrency)
? siteCurrency
: USD_PRICING_CURRENCY
const current = pricingRow(entry.model_name, entry.effective)
const currentLanes = createInitialLaneState(current)
const details = buildPreviewRows(
current,
current.billingMode ?? 'per-token',
'',
'',
currentLanes.promptPrice,
currentLanes.prices,
currentLanes.enabled,
t,
currency,
entry.cache_write_mode,
entry.billing_details
).filter(
(row) =>
row.key !== 'inputPrice' &&
row.key !== 'completion' &&
row.key !== 'price' &&
row.value !== t('Empty')
)
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}
pluginVariants={entry.plugin_variants}
onDirtyChange={props.onDirtyChange}
onSave={() => persist()}
isSaving={save.isPending}
className='rounded-none border-0'
scrollHeader={
<>
<div className='flex flex-wrap items-center justify-between gap-2'>
<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='space-y-3 border-b 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'>
{details.map((row) => (
<div key={row.key}>
<dt className='text-muted-foreground'>{row.label}</dt>
<dd className='mt-1 font-mono tabular-nums'>
{row.value}
{row.unit !== 'none' && ' / 1M'}
</dd>
</div>
))}
</dl>
)
)}
</section>
{save.isError && (
<div>
<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>
)}
</>
}
/>
<ConfirmDialog
open={resetOpen}
@@ -0,0 +1,204 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { CopyButton } from '@/components/copy-button'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
parseTiersFromExpr,
splitBillingExprAndRequestRules,
} from '@/features/pricing/lib/billing-expr'
import { compileBillingExpression } from '@/features/pricing/lib/billing-expression/parser'
import { getDynamicPriceEntries } from '@/features/pricing/lib/dynamic-price'
import {
buildPreviewRows,
createInitialLaneState,
pricingAdjustmentRows,
} from '@/features/system-settings/models/model-pricing-core'
import { formatPricingAmount, type PricingCurrency } from './currency'
import {
pricingRow,
type CacheWriteMode,
type PricingValues,
type LegacyBillingDetails,
} from './pricing'
export type PricingConversionPreview = {
modelName: string
effective: PricingValues
expression: string
draftFingerprint: string
cacheWriteMode?: CacheWriteMode
billingDetails?: LegacyBillingDetails
}
export function PricingConversionDialog(props: {
preview: PricingConversionPreview
currency: PricingCurrency
onCancel: () => void
onConfirm: () => void
}) {
const { t } = useTranslation()
const before = pricingRow(props.preview.modelName, props.preview.effective)
const lanes = createInitialLaneState(before)
const beforeRows = buildPreviewRows(
before,
before.billingMode ?? 'per-token',
'',
'',
lanes.promptPrice,
lanes.prices,
lanes.enabled,
t,
props.currency,
props.preview.cacheWriteMode,
props.preview.billingDetails
).filter((row) => row.value !== t('Empty'))
const tiers = parseTiersFromExpr(
splitBillingExprAndRequestRules(props.preview.expression).billingExpr
)
const afterRows = tiers.flatMap((tier, index) =>
getDynamicPriceEntries(tier, { tokenUnit: 'M' }).map((row) => ({
...row,
key: `${index}-${row.key}`,
tierLabel: tiers.length > 1 ? t(tier.conditionText || tier.label) : '',
imageCount: Boolean(tier.imageCount),
}))
)
const compiled = compileBillingExpression(props.preview.expression)
const afterAdjustments = pricingAdjustmentRows(
compiled.status === 'ready'
? {
image_count: compiled.variables.has('image_count'),
request_rules: compiled.requestRules.map((rule) => ({
condition: props.preview.expression.slice(
rule.condition.start,
rule.condition.end
),
multiplier: rule.multiplier,
})),
}
: undefined,
t
)
return (
<ConfirmDialog
open
onOpenChange={(open) => {
if (!open) props.onCancel()
}}
title={t('Preview pricing conversion')}
desc={t(
'Review the prices and billing expression. Confirming updates the draft; save model pricing to apply it.'
)}
confirmText={t('Apply to draft')}
handleConfirm={props.onConfirm}
className='max-h-[90vh] overflow-y-auto data-[size=default]:max-w-[calc(100%-2rem)] data-[size=default]:sm:max-w-5xl'
>
<p className='font-mono text-sm break-all'>{props.preview.modelName}</p>
<div className='grid min-w-0 gap-4 md:grid-cols-2'>
<section
aria-label={t('Before conversion')}
className='min-w-0 space-y-4 rounded-lg border p-4'
>
<h3 className='font-semibold'>{t('Before conversion')}</h3>
<dl className='space-y-3'>
{beforeRows.map((row) => {
let unitLabel =
before.billingMode === 'per-request'
? t('request')
: t('1M token')
if (row.unit === 'image') unitLabel = t('image')
return (
<div
key={row.key}
className='flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1'
>
<dt className='text-muted-foreground text-sm'>{row.label}</dt>
<dd className='font-mono text-sm tabular-nums'>
{row.value}
{row.unit !== 'none' && <> / {unitLabel}</>}
</dd>
</div>
)
})}
</dl>
</section>
<section
aria-label={t('After conversion')}
className='border-primary/30 bg-primary/5 min-w-0 space-y-4 rounded-lg border p-4'
>
<h3 className='font-semibold'>{t('After conversion')}</h3>
<dl className='space-y-3'>
{afterRows.map((row) => {
let unitLabel =
row.unit === 'request' ? t('request') : t('1M token')
if (row.imageCount) unitLabel = t('image')
return (
<div
key={row.key}
className='flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1'
>
<dt className='text-muted-foreground text-sm'>
{row.tierLabel && `${row.tierLabel} · `}
{t(row.label)}
</dt>
<dd className='font-mono text-sm tabular-nums'>
{formatPricingAmount(row.value, props.currency)} /{' '}
{unitLabel}
</dd>
</div>
)
})}
{afterAdjustments.map((row) => (
<div
key={row.key}
className='flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1'
>
<dt className='text-muted-foreground text-sm'>{row.label}</dt>
<dd className='font-mono text-sm tabular-nums'>{row.value}</dd>
</div>
))}
</dl>
<div className='space-y-2 border-t pt-3'>
<div className='flex items-center justify-between gap-2'>
<h4 className='text-sm font-medium'>
{t('Billing expression')} (USD)
</h4>
<CopyButton value={props.preview.expression} />
</div>
<pre className='bg-background rounded-md border p-3 text-xs break-words whitespace-pre-wrap'>
<code>{props.preview.expression}</code>
</pre>
</div>
</section>
</div>
<Alert>
<AlertDescription className='text-xs'>
{t(
'After conversion, expression reservation and rounding rules apply. Effective unit prices are preserved; individual rounded charges may differ.'
)}
</AlertDescription>
</Alert>
</ConfirmDialog>
)
}
+118 -15
View File
@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { t } from 'i18next'
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
import { splitPluginBillingExprKey } from '@/features/pricing/lib/plugin-pricing'
import type { PricingModel } from '@/features/pricing/types'
import type { ModelRatioData } from '@/features/system-settings/models/model-pricing-core'
import {
@@ -39,13 +40,32 @@ export const PRICING_KEYS = [
'AudioCompletionRatio',
'billing_setting.billing_mode',
'billing_setting.billing_expr',
'billing_setting.plugin_billing_expr',
] as const
export type PricingKey = (typeof PRICING_KEYS)[number]
export type PricingValues = Partial<Record<PricingKey, number | string>>
export type PricingValues = Partial<
Record<
Exclude<PricingKey, 'billing_setting.plugin_billing_expr'>,
number | string
>
> & {
'billing_setting.plugin_billing_expr'?: Record<string, string>
}
export type PricingOptions = Record<PricingKey, string>
export type CacheWriteMode = 'none' | 'standard' | 'claude_ttl'
export type LegacyBillingDetails = {
audio_input_price?: number
audio_output_price?: number
audio_text_branches?: boolean
image_count?: boolean
request_rules?: { condition: string; multiplier: number }[]
}
export function modelPricingDisplay(
entry: Pick<ModelPricingEntry, 'model_name' | 'effective' | 'usage_schema'>
entry: Pick<ModelPricingEntry, 'model_name' | 'effective' | 'usage_schema'> &
Partial<Pick<ModelPricingEntry, 'configured' | 'cache_write_mode'>>
): PricingModel {
const values = entry.effective
return {
@@ -62,13 +82,22 @@ export function modelPricingDisplay(
model_price:
values.ModelPrice === undefined ? undefined : Number(values.ModelPrice),
cache_ratio:
values.CacheRatio === undefined ? undefined : Number(values.CacheRatio),
values.CacheRatio === undefined || Number(values.CacheRatio) === 1
? undefined
: Number(values.CacheRatio),
create_cache_ratio:
values.CreateCacheRatio === undefined
values.CreateCacheRatio === undefined ||
!(
entry.cache_write_mode === 'standard' ||
entry.cache_write_mode === 'claude_ttl' ||
entry.configured?.CreateCacheRatio !== undefined
)
? undefined
: Number(values.CreateCacheRatio),
image_ratio:
values.ImageRatio === undefined ? undefined : Number(values.ImageRatio),
values.ImageRatio === undefined || Number(values.ImageRatio) === 1
? undefined
: Number(values.ImageRatio),
audio_ratio:
values.AudioRatio === undefined ? undefined : Number(values.AudioRatio),
audio_completion_ratio:
@@ -106,6 +135,9 @@ export function pricingOptions(
let value = values[key]
if (key === 'billing_setting.billing_mode') value ??= values.BillingMode
if (key === 'billing_setting.billing_expr') value ??= values.BillingExpr
if (key === 'billing_setting.plugin_billing_expr') {
value ??= values.PluginBillingExpr
}
return [key, typeof value === 'string' ? value : '{}']
})
) as PricingOptions
@@ -123,6 +155,7 @@ export function pricingRows(options: PricingOptions): ModelPricingSnapshot[] {
audioCompletionRatio: options.AudioCompletionRatio,
billingMode: options['billing_setting.billing_mode'],
billingExpr: options['billing_setting.billing_expr'],
pluginBillingExpr: options['billing_setting.plugin_billing_expr'],
})
}
@@ -131,20 +164,45 @@ export function pricingRow(
values: PricingValues
): ModelRatioData {
const options = Object.fromEntries(
PRICING_KEYS.map((key) => [
key,
JSON.stringify(values[key] === undefined ? {} : { [name]: values[key] }),
])
PRICING_KEYS.map((key) => {
if (key === 'billing_setting.plugin_billing_expr') {
return [
key,
JSON.stringify(
Object.fromEntries(
Object.entries(values[key] ?? {}).map(([plugin, expression]) => [
`${plugin}::${name}`,
expression,
])
)
),
]
}
return [
key,
JSON.stringify(
values[key] === undefined ? {} : { [name]: values[key] }
),
]
})
) as PricingOptions
const row = pricingRows(options)[0]
const row = pricingRows(options).find((entry) => entry.name === name)
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 }
return {
...row,
name,
billingMode,
pluginBillingExpr: values['billing_setting.plugin_billing_expr'],
}
}
export function pricingFromDraft(data: ModelRatioData): PricingValues {
const values: PricingValues = {
...(data.pluginBillingExpr === undefined
? {}
: { 'billing_setting.plugin_billing_expr': data.pluginBillingExpr }),
'billing_setting.billing_mode':
data.billingMode === 'tiered_expr' ? 'tiered_expr' : 'ratio',
}
@@ -178,7 +236,18 @@ export function applyPricingDraft(
data: ModelRatioData,
names: string[] = [data.name]
): PricingOptions {
return applyPricingValues(options, pricingFromDraft(data), names)
const values = pricingFromDraft(data)
if (names.length === 1 && names[0] === data.name) {
return applyPricingValues(options, values, names)
}
// Provider bindings belong to each model. Copy only the shared model price
// to other models, while still committing the source provider draft.
const copied = { ...values }
delete copied['billing_setting.plugin_billing_expr']
const next = applyPricingValues(options, copied, names)
return names.includes(data.name)
? applyPricingValues(next, values, [data.name])
: next
}
function applyPricingValues(
@@ -188,7 +257,25 @@ function applyPricingValues(
): PricingOptions {
return Object.fromEntries(
PRICING_KEYS.map((key) => {
const map = JSON.parse(options[key]) as Record<string, number | string>
const map = JSON.parse(options[key] ?? '{}') as Record<
string,
number | string
>
if (key === 'billing_setting.plugin_billing_expr') {
// Model-only imports and batch copies retain each target's provider prices.
if (values[key] === undefined) return [key, JSON.stringify(map)]
for (const name of names) {
for (const variant of Object.keys(map)) {
if (splitPluginBillingExprKey(variant)?.[1] === name) {
delete map[variant]
}
}
for (const [plugin, expression] of Object.entries(values[key])) {
map[`${plugin}::${name}`] = expression
}
}
return [key, JSON.stringify(map)]
}
for (const name of names) {
delete map[name]
if (values[key] !== undefined) {
@@ -210,7 +297,7 @@ export function pricingValuesByModel(
): Map<string, PricingValues> {
const models = new Map<string, PricingValues>()
for (const key of PRICING_KEYS) {
const map: unknown = JSON.parse(options[key])
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'))
}
@@ -218,6 +305,17 @@ export function pricingValuesByModel(
if (typeof value !== 'number' && typeof value !== 'string') {
throw new Error(t('Invalid pricing value'))
}
if (key === 'billing_setting.plugin_billing_expr') {
const parts = splitPluginBillingExprKey(name)
if (!parts || typeof value !== 'string') {
throw new Error(t('Invalid pricing value'))
}
const [plugin, modelName] = parts
const model = models.get(modelName) ?? {}
model[key] = { ...model[key], [plugin]: value }
models.set(modelName, model)
continue
}
const model = models.get(name) ?? {}
model[key] = value
models.set(name, model)
@@ -249,7 +347,12 @@ export function applyPriceSyncSelections(
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('') as PricingKey
if (PRICING_KEYS.includes(key)) next[key] = value
if (
PRICING_KEYS.includes(key) &&
key !== 'billing_setting.plugin_billing_expr'
) {
next[key] = value
}
}
}
const validated = pricingFromDraft(pricingRow(name, next))