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:
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')
|
||||
}
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user