Commit 0e0ba152 by CaIon

feat(pricing): improve pricing editors and log display

parent eb76b136
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
cleanup,
render,
screen,
waitFor,
within,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { afterEach, expect, it, vi } from 'vitest'
import { SettingsPageProvider } from '@/features/system-settings/components/settings-page-context'
import { ModelPricingEditorPanel } from '@/features/system-settings/models/model-pricing-sheet'
import { ModelRatioForm } from '@/features/system-settings/models/model-ratio-form'
import { api } from '@/lib/api'
const clients: QueryClient[] = []
const originalColumnVisibility = localStorage.getItem(
'model-ratio-column-visibility'
)
afterEach(() => {
cleanup()
for (const client of clients) client.clear()
clients.length = 0
if (originalColumnVisibility === null) {
localStorage.removeItem('model-ratio-column-visibility')
} else {
localStorage.setItem(
'model-ratio-column-visibility',
originalColumnVisibility
)
}
})
function renderEditor(embedded = false) {
vi.spyOn(api, 'get').mockResolvedValue({
data: { success: true, data: [], vendors: [] },
})
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
clients.push(client)
render(
<QueryClientProvider client={client}>
<ModelPricingEditorPanel
embedded={embedded}
editData={{
name: 'example-model',
billingMode: 'per-token',
ratio: '3.25',
completionRatio: '2',
cacheRatio: '0.2',
}}
onSave={() => {}}
/>
</QueryClientProvider>
)
}
it('keeps the preview expanded and the save action outside the scrolling embedded form', () => {
renderEditor(true)
const scrollRegion = screen.getByRole('region', {
name: 'Edit model pricing',
})
expect(scrollRegion).toHaveClass(
'overflow-y-auto',
'min-h-0',
'@container/pricing-editor'
)
expect(
within(scrollRegion).getByRole('complementary', { name: 'Preview' })
).toBeVisible()
expect(
within(scrollRegion).queryByRole('button', { name: 'Save model prices' })
).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Save model prices' })
).toBeVisible()
expect(
screen.queryByRole('heading', { name: 'Edit model pricing' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('textbox', { name: 'Model name' })
).not.toBeInTheDocument()
expect(screen.getAllByText(/USD price per 1M tokens\./)).toHaveLength(1)
})
it('retains the model identity and heading when the editor is used standalone', () => {
renderEditor()
expect(
screen.getByRole('heading', { name: 'Edit model pricing' })
).toBeVisible()
expect(screen.getByRole('textbox', { name: 'Model name' })).toHaveValue(
'example-model'
)
expect(screen.getByRole('textbox', { name: 'Model name' })).toBeDisabled()
})
it('updates the preview for explicit zero and disabled prices and explains dependent audio controls', async () => {
renderEditor(true)
const user = userEvent.setup()
const preview = screen.getByRole('complementary', { name: 'Preview' })
const cache = screen.getByRole('textbox', { name: 'Cache read price' })
await user.clear(cache)
await user.type(cache, '0')
expect(within(preview).getByText('$0')).toBeVisible()
await user.click(screen.getByRole('switch', { name: 'Cache read price' }))
expect(cache).toBeDisabled()
expect(within(preview).queryByText('$0')).not.toBeInTheDocument()
const audio = screen.getByRole('switch', { name: 'Audio output price' })
expect(audio).toHaveAttribute('aria-disabled', 'true')
expect(audio).toHaveAccessibleDescription(
'Audio output price requires an audio input price.'
)
await user.click(screen.getByRole('switch', { name: 'Audio input price' }))
await user.type(
screen.getByRole('textbox', { name: 'Audio input price' }),
'1'
)
expect(audio).not.toHaveAttribute('aria-disabled', 'true')
})
function PricingFormFixture(props: {
variant: 'default' | 'unset'
onSave: () => Promise<void>
}) {
const values = {
ModelPrice: props.variant === 'default' ? '{"example-model":0.1}' : '{}',
ModelRatio: '{}',
CacheRatio: '{}',
CreateCacheRatio: '{}',
CompletionRatio: '{}',
ImageRatio: '{}',
AudioRatio: '{}',
AudioCompletionRatio: '{}',
BillingMode: '{}',
BillingExpr: '{}',
ExposeRatioEnabled: false,
}
const [actionsContainer, setActionsContainer] =
useState<HTMLDivElement | null>(null)
const form = useForm({ defaultValues: values })
return (
<>
<header>
<div ref={setActionsContainer} />
</header>
<SettingsPageProvider actionsContainer={actionsContainer}>
<ModelRatioForm
form={form}
savedValues={values}
variant={props.variant}
onSave={props.onSave}
onReset={() => undefined}
isSaving={false}
isResetting={false}
/>
</SettingsPageProvider>
</>
)
}
it.each(['default', 'unset'] as const)(
'keeps the %s pricing workspace shrinkable and saves from its fixed action bar',
async (variant) => {
const user = userEvent.setup()
const save = vi.fn(async () => undefined)
vi.spyOn(api, 'get').mockImplementation(async (url) => ({
data: {
success: true,
data: url === '/api/channel/models_enabled' ? ['example-model'] : [],
vendors: [],
},
}))
localStorage.removeItem('model-ratio-column-visibility')
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
clients.push(client)
render(
<QueryClientProvider client={client}>
<PricingFormFixture variant={variant} onSave={save} />
</QueryClientProvider>
)
const workspace = screen.getByRole('region', { name: 'Model prices' })
expect(workspace).toHaveClass(
'flex-1',
'min-h-0',
'grid-rows-[minmax(0,1fr)]'
)
await waitFor(() => expect(client.isFetching()).toBe(0))
if (variant === 'default') {
const toggle = screen.getByRole('switch', { name: 'Expose ratio API' })
expect(screen.getByRole('banner')).toContainElement(toggle)
const help = within(screen.getByRole('banner')).getByRole('button', {
name: 'Learn more',
})
await user.click(help)
expect(screen.getByRole('dialog')).toHaveTextContent(
'Allow clients to query configured prices via `/api/ratio`.'
)
expect(toggle).not.toBeChecked()
await user.keyboard('{Escape}')
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
expect(help).toHaveFocus()
expect(toggle).toHaveAccessibleDescription(
'Allow clients to query configured prices via `/api/ratio`.'
)
await user.click(toggle)
expect(toggle).toBeChecked()
await user.click(screen.getByRole('button', { name: 'Switch to JSON' }))
expect(
screen.getAllByRole('switch', { name: 'Expose ratio API' })
).toHaveLength(1)
expect(
screen.getByRole('switch', { name: 'Expose ratio API' })
).toBeChecked()
await user.click(screen.getByRole('button', { name: 'Switch to Visual' }))
} else {
expect(
screen.queryByRole('switch', { name: 'Expose ratio API' })
).not.toBeInTheDocument()
}
await user.click(await screen.findByRole('button', { name: 'Edit' }))
await user.click(screen.getByRole('tab', { name: 'Per-request' }))
const price = screen.getByRole('textbox', { name: 'Fixed price' })
await user.clear(price)
await user.type(price, '0.25')
const region = screen.getByRole('region', { name: 'Edit model pricing' })
const button = screen.getByRole('button', { name: 'Save model prices' })
expect(region).not.toContainElement(button)
expect(button.parentElement?.parentElement).toHaveClass('shrink-0')
await user.click(button)
await waitFor(() => expect(save).toHaveBeenCalledOnce())
expect(save).toHaveBeenCalledWith(
expect.objectContaining({ ExposeRatioEnabled: variant === 'default' }),
undefined
)
}
)
...@@ -121,9 +121,9 @@ export function ModelPricingPanel(props: { ...@@ -121,9 +121,9 @@ export function ModelPricingPanel(props: {
} }
return ( return (
<div className='flex min-h-0 flex-1 flex-col gap-3'> <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='flex flex-wrap items-center justify-between gap-2 px-4 pt-3'>
<div> <div className='min-w-0 flex-1 break-words'>
<p className='text-muted-foreground text-xs'> <p className='text-muted-foreground text-xs'>
{Object.keys(entry.configured).length {Object.keys(entry.configured).length
? t('Stored configuration with effective defaults') ? t('Stored configuration with effective defaults')
...@@ -167,6 +167,7 @@ export function ModelPricingPanel(props: { ...@@ -167,6 +167,7 @@ export function ModelPricingPanel(props: {
</div> </div>
)} )}
<ModelPricingEditorPanel <ModelPricingEditorPanel
embedded
ref={editor} ref={editor}
editData={editData} editData={editData}
usageSchema={entry.usage_schema} usageSchema={entry.usage_schema}
......
...@@ -17,7 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,7 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor, cleanup } from '@testing-library/react' import type { Row } from '@tanstack/react-table'
import { render, screen, waitFor, cleanup, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { AxiosError } from 'axios' import { AxiosError } from 'axios'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
...@@ -26,8 +27,11 @@ import { pricingOptions } from '@/features/model-pricing/pricing' ...@@ -26,8 +27,11 @@ import { pricingOptions } from '@/features/model-pricing/pricing'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { useAuthStore } from '@/stores/auth-store' import { useAuthStore } from '@/stores/auth-store'
import { DataTableRowActions } from '../components/data-table-row-actions'
import { ModelMutateDrawer } from '../components/drawers/model-mutate-drawer' import { ModelMutateDrawer } from '../components/drawers/model-mutate-drawer'
import { ModelsDialogs } from '../components/models-dialogs'
import { ModelsProvider } from '../components/models-provider' import { ModelsProvider } from '../components/models-provider'
import type { Model } from '../types'
const model = { const model = {
id: 7, id: 7,
...@@ -48,6 +52,166 @@ afterEach(() => { ...@@ -48,6 +52,166 @@ afterEach(() => {
useAuthStore.getState().auth.reset() useAuthStore.getState().auth.reset()
}) })
function renderModelActions(currentModel: Model = model, role = 100) {
useAuthStore.getState().auth.setUser({ id: 1, username: 'admin', role })
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
render(
<QueryClientProvider client={client}>
<ModelsProvider>
<DataTableRowActions row={{ original: currentModel } as Row<Model>} />
<ModelsDialogs />
</ModelsProvider>
</QueryClientProvider>
)
return client
}
describe('model pricing entry', () => {
it('opens pricing directly, keeps it selected after metadata loads, and reopens Edit on metadata', async () => {
let resolveDetail!: (value: Awaited<ReturnType<typeof api.get>>) => void
const detail = new Promise<Awaited<ReturnType<typeof api.get>>>(
(resolve) => {
resolveDetail = resolve
}
)
vi.spyOn(api, 'get').mockImplementation(async (url) => {
if (url === '/api/models/7') return detail
if (url === '/api/option/model_pricing') {
return {
data: {
success: true,
data: {
entries: [
{
model_name: model.model_name,
version: 'v1',
configured: { ModelRatio: 3.25, CompletionRatio: 27 / 6.5 },
effective: { ModelRatio: 3.25, CompletionRatio: 27 / 6.5 },
},
],
options: pricingOptions({}),
empty_version: 'empty',
},
},
}
}
return { data: { success: true, data: { items: [] } } }
})
const client = renderModelActions()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Pricing' }))
expect(screen.getByRole('tab', { name: 'Pricing' })).toHaveAttribute(
'aria-selected',
'true'
)
await act(async () =>
resolveDetail({ data: { success: true, data: model } })
)
expect(screen.getByRole('tab', { name: 'Pricing' })).toHaveAttribute(
'aria-selected',
'true'
)
expect(
await screen.findByRole('textbox', { name: 'Input price' })
).toHaveValue('6.5')
expect(
screen.queryByRole('heading', { name: 'Edit model pricing' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('textbox', { name: 'Model name' })
).not.toBeInTheDocument()
expect(screen.getByRole('complementary', { name: 'Preview' })).toBeVisible()
for (const tab of ['Model metadata', 'Channels and groups', 'Pricing']) {
expect(screen.getByRole('tab', { name: tab })).toHaveClass(
'min-w-0',
'whitespace-normal'
)
await user.click(screen.getByRole('tab', { name: tab }))
expect(
screen.getByRole('dialog', { name: model.model_name })
).toHaveClass('sm:max-w-[1280px]')
}
await user.click(screen.getByRole('button', { name: 'Close' }))
await user.click(screen.getByRole('button', { name: 'Edit' }))
expect(await screen.findByLabelText('Description')).toHaveValue('Original')
expect(screen.getByRole('tab', { name: 'Model metadata' })).toHaveAttribute(
'aria-selected',
'true'
)
client.clear()
})
it('does not expose a pricing shortcut to an ordinary administrator', () => {
vi.spyOn(api, 'get').mockResolvedValue({
data: { success: true, data: { items: [] } },
})
const client = renderModelActions(model, 10)
expect(screen.getByRole('button', { name: 'Edit' })).toBeVisible()
expect(
screen.queryByRole('button', { name: 'Pricing' })
).not.toBeInTheDocument()
client.clear()
})
it('requires a concrete model for matching rules and protects an unsaved price on close', async () => {
const matchedModel = {
...model,
name_rule: 1,
matched_models: ['example-concrete'],
}
const get = vi.spyOn(api, 'get').mockImplementation(async (url) => {
if (url === '/api/models/7') {
return { data: { success: true, data: matchedModel } }
}
if (url === '/api/option/model_pricing') {
return {
data: {
success: true,
data: {
entries: [
{
model_name: 'example-concrete',
version: 'v1',
configured: { ModelPrice: 1.5 },
effective: { ModelPrice: 1.5 },
},
],
options: pricingOptions({}),
empty_version: 'empty',
},
},
}
}
return { data: { success: true, data: { items: [] } } }
})
const client = renderModelActions(matchedModel)
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Pricing' }))
await user.click(
await screen.findByRole('combobox', { name: 'Select model' })
)
expect(
get.mock.calls.some(([url]) => url === '/api/option/model_pricing')
).toBe(false)
await user.click(
await screen.findByRole('option', { name: 'example-concrete' })
)
const price = await screen.findByPlaceholderText('0.01')
await waitFor(() => expect(price).toHaveValue('1.5'))
await user.clear(price)
await user.type(price, '2')
await user.click(screen.getByRole('button', { name: 'Close' }))
expect(await screen.findByRole('alertdialog')).toHaveTextContent(
'Discard unsaved changes?'
)
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(price).toHaveValue('2')
client.clear()
})
})
describe('metadata editing', () => { describe('metadata editing', () => {
it.each([ it.each([
{ {
......
...@@ -28,6 +28,7 @@ import { ...@@ -28,6 +28,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuShortcut, DropdownMenuShortcut,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { useCanEditModelPricing } from '@/features/model-pricing/api'
import { handleToggleModelStatus, isModelEnabled } from '../lib' import { handleToggleModelStatus, isModelEnabled } from '../lib'
import type { Model } from '../types' import type { Model } from '../types'
...@@ -40,6 +41,7 @@ interface DataTableRowActionsProps { ...@@ -40,6 +41,7 @@ interface DataTableRowActionsProps {
export function DataTableRowActions({ row }: DataTableRowActionsProps) { export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { t } = useTranslation() const { t } = useTranslation()
const canPrice = useCanEditModelPricing()
const model = row.original const model = row.original
const { setOpen, setCurrentRow } = useModels() const { setOpen, setCurrentRow } = useModels()
const queryClient = useQueryClient() const queryClient = useQueryClient()
...@@ -66,6 +68,19 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { ...@@ -66,6 +68,19 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
{t('Edit')} {t('Edit')}
</Button> </Button>
{canPrice && (
<Button
variant='ghost'
size='sm'
onClick={() => {
setCurrentRow(model)
setOpen('price-model')
}}
>
{t('Pricing')}
</Button>
)}
<DataTableRowActionMenu ariaLabel={t('Open menu')}> <DataTableRowActionMenu ariaLabel={t('Open menu')}>
<DropdownMenuItem onClick={handleToggleStatus}> <DropdownMenuItem onClick={handleToggleStatus}>
{toggleLabel} {toggleLabel}
......
...@@ -16,7 +16,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,7 +16,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { Combobox } from '@/components/ui/combobox'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AxiosError } from 'axios' import { AxiosError } from 'axios'
...@@ -41,6 +40,7 @@ import { LobeIconField } from '@/components/lobe-icon-field' ...@@ -41,6 +40,7 @@ import { LobeIconField } from '@/components/lobe-icon-field'
import { TagInput } from '@/components/tag-input' import { TagInput } from '@/components/tag-input'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Combobox } from '@/components/ui/combobox'
import { import {
Form, Form,
FormControl, FormControl,
...@@ -53,7 +53,6 @@ import { ...@@ -53,7 +53,6 @@ import {
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
...@@ -83,14 +82,19 @@ export function ModelMutateDrawer(props: { ...@@ -83,14 +82,19 @@ export function ModelMutateDrawer(props: {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
currentRow?: Model | null currentRow?: Model | null
initialSection?: 'metadata' | 'pricing'
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const currentRow = props.currentRow const currentRow = props.currentRow
const isEditing = Boolean(currentRow?.id) const isEditing = Boolean(currentRow?.id)
const [section, setSection] = useState('metadata') const [section, setSection] = useState<string>(
props.initialSection ?? 'metadata'
)
const [pricingName, setPricingName] = useState('') const [pricingName, setPricingName] = useState('')
const [pricingVisited, setPricingVisited] = useState(false) const [pricingVisited, setPricingVisited] = useState(
props.initialSection === 'pricing'
)
const [pricingDirty, setPricingDirty] = useState(false) const [pricingDirty, setPricingDirty] = useState(false)
const [pendingPricingName, setPendingPricingName] = useState<string | null>( const [pendingPricingName, setPendingPricingName] = useState<string | null>(
null null
...@@ -130,6 +134,16 @@ export function ModelMutateDrawer(props: { ...@@ -130,6 +134,16 @@ export function ModelMutateDrawer(props: {
const savedModel = modelQuery.data ?? currentRow const savedModel = modelQuery.data ?? currentRow
useEffect(() => { useEffect(() => {
if (!props.open) return
setSection(props.initialSection ?? 'metadata')
setPricingVisited(props.initialSection === 'pricing')
setPricingName('')
setPricingDirty(false)
setPendingPricingName(null)
setCloseConfirm(false)
}, [props.open, props.initialSection, currentRow?.id])
useEffect(() => {
if (!props.open) { if (!props.open) {
loadedKey.current = '' loadedKey.current = ''
return return
...@@ -149,10 +163,6 @@ export function ModelMutateDrawer(props: { ...@@ -149,10 +163,6 @@ export function ModelMutateDrawer(props: {
) )
) )
loadedKey.current = key loadedKey.current = key
setSection('metadata')
setPricingName('')
setPricingVisited(false)
setPricingDirty(false)
}, [props.open, currentRow, isEditing, modelQuery.data, form]) }, [props.open, currentRow, isEditing, modelQuery.data, form])
const save = useMutation({ const save = useMutation({
...@@ -220,7 +230,9 @@ export function ModelMutateDrawer(props: { ...@@ -220,7 +230,9 @@ export function ModelMutateDrawer(props: {
return ( return (
<> <>
<Sheet open={props.open} onOpenChange={close}> <Sheet open={props.open} onOpenChange={close}>
<SheetContent className={sideDrawerContentClassName('sm:max-w-3xl')}> <SheetContent
className={sideDrawerContentClassName('sm:max-w-[1280px]')}
>
<SheetHeader className={sideDrawerHeaderClassName()}> <SheetHeader className={sideDrawerHeaderClassName()}>
<SheetTitle className='pr-6 break-all'> <SheetTitle className='pr-6 break-all'>
{isEditing ? currentRow?.model_name : t('Create Model')} {isEditing ? currentRow?.model_name : t('Create Model')}
...@@ -239,12 +251,25 @@ export function ModelMutateDrawer(props: { ...@@ -239,12 +251,25 @@ export function ModelMutateDrawer(props: {
}} }}
className='shrink-0 px-4' className='shrink-0 px-4'
> >
<TabsList className='w-full'> <TabsList className='grid w-full grid-cols-3 group-data-horizontal/tabs:h-auto'>
<TabsTrigger value='metadata'>{t('Model metadata')}</TabsTrigger> <TabsTrigger
<TabsTrigger value='pricing' disabled={!isEditing}> value='metadata'
className='h-auto min-w-0 whitespace-normal'
>
{t('Model metadata')}
</TabsTrigger>
<TabsTrigger
value='pricing'
disabled={!isEditing}
className='h-auto min-w-0 whitespace-normal'
>
{t('Pricing')} {t('Pricing')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value='connections' disabled={!isEditing}> <TabsTrigger
value='connections'
disabled={!isEditing}
className='h-auto min-w-0 whitespace-normal'
>
{t('Channels and groups')} {t('Channels and groups')}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
...@@ -347,20 +372,22 @@ export function ModelMutateDrawer(props: { ...@@ -347,20 +372,22 @@ export function ModelMutateDrawer(props: {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('Vendor')}</FormLabel> <FormLabel>{t('Vendor')}</FormLabel>
<FormControl><Combobox <FormControl>
options={vendors.map((vendor) => ({ <Combobox
options={vendors.map((vendor) => ({
value: String(vendor.id), value: String(vendor.id),
label: vendor.name, label: vendor.name,
}))} }))}
onValueChange={(value) => onValueChange={(value) =>
field.onChange( field.onChange(
value ? Number.parseInt(value) : undefined value ? Number.parseInt(value) : undefined
) )
} }
value={field.value ? String(field.value) : null} value={field.value ? String(field.value) : null}
className='w-full' className='w-full'
placeholder={t('Select vendor')} placeholder={t('Select vendor')}
/></FormControl> />
</FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
...@@ -445,12 +472,16 @@ placeholder={t('Select vendor')} ...@@ -445,12 +472,16 @@ placeholder={t('Select vendor')}
{t('Endpoints')} {t('Endpoints')}
</h3> </h3>
<Combobox <Combobox
options={Object.keys(ENDPOINT_TEMPLATES).map((key) => ({ value: key, label: key }))} options={Object.keys(ENDPOINT_TEMPLATES).map(
onValueChange={(value: string | null) => { if (value) handleFillEndpointTemplate(value) }} (key) => ({ value: key, label: key })
)}
onValueChange={(value: string | null) => {
if (value) handleFillEndpointTemplate(value)
}}
className='w-[200px]' className='w-[200px]'
placeholder={t('Load template...')} placeholder={t('Load template...')}
aria-label={t('Load template...')} aria-label={t('Load template...')}
/> />
</div> </div>
<FormField <FormField
...@@ -591,8 +622,8 @@ placeholder={t('Select vendor')} ...@@ -591,8 +622,8 @@ placeholder={t('Select vendor')}
)} )}
</p> </p>
<Combobox <Combobox
value={pricingName} value={pricingName}
onValueChange={(value) => { onValueChange={(value) => {
if (pricingDirty) { if (pricingDirty) {
setPendingPricingName(value ?? '') setPendingPricingName(value ?? '')
setCloseConfirm(true) setCloseConfirm(true)
...@@ -600,14 +631,14 @@ onValueChange={(value) => { ...@@ -600,14 +631,14 @@ onValueChange={(value) => {
setPricingName(value ?? '') setPricingName(value ?? '')
} }
}} }}
options={(savedModel.matched_models ?? []).map((name) => ({ options={(savedModel.matched_models ?? []).map((name) => ({
value: name, value: name,
label: name, label: name,
}))} }))}
aria-label={t('Select model')} aria-label={t('Select model')}
className='w-full' className='w-full'
placeholder={t('Select model')} placeholder={t('Select model')}
/> />
</div> </div>
)} )}
{(savedModel.name_rule === 0 || pricingName) && ( {(savedModel.name_rule === 0 || pricingName) && (
......
...@@ -265,7 +265,7 @@ export function useModelsColumns( ...@@ -265,7 +265,7 @@ export function useModelsColumns(
header: t('Actions'), header: t('Actions'),
enableSorting: false, enableSorting: false,
enableHiding: false, enableHiding: false,
size: 105, size: canPrice ? 170 : 105,
cell: ({ row }) => <DataTableRowActions row={row} />, cell: ({ row }) => <DataTableRowActions row={row} />,
}, },
{ {
......
...@@ -43,7 +43,12 @@ export function ModelsDialogs() { ...@@ -43,7 +43,12 @@ export function ModelsDialogs() {
/> />
{/* Model Create/Update Drawer */} {/* Model Create/Update Drawer */}
<ModelMutateDrawer <ModelMutateDrawer
open={open === 'create-model' || open === 'update-model'} open={
open === 'create-model' ||
open === 'update-model' ||
open === 'price-model'
}
initialSection={open === 'price-model' ? 'pricing' : 'metadata'}
onOpenChange={(v) => !v && setOpen(null)} onOpenChange={(v) => !v && setOpen(null)}
currentRow={currentRow} currentRow={currentRow}
/> />
......
...@@ -34,6 +34,7 @@ import type { ...@@ -34,6 +34,7 @@ import type {
type DialogType = type DialogType =
| 'create-model' | 'create-model'
| 'update-model' | 'update-model'
| 'price-model'
| 'create-vendor' | 'create-vendor'
| 'vendors' | 'vendors'
| 'price-sync' | 'price-sync'
......
...@@ -319,7 +319,10 @@ describe('task dynamic pricing', () => { ...@@ -319,7 +319,10 @@ describe('task dynamic pricing', () => {
assert.ok(tokenSummary) assert.ok(tokenSummary)
assert.equal(tokenSummary.primaryEntries[0]?.shortLabel, 'tokens') assert.equal(tokenSummary.primaryEntries[0]?.shortLabel, 'tokens')
assert.equal(tokenSummary.primaryEntries[0]?.labelKind, 'schema') assert.equal(tokenSummary.primaryEntries[0]?.labelKind, 'schema')
assert.equal(tokenSummary.secondaryEntries[0]?.shortLabel, 'Base') assert.equal(
tokenSummary.secondaryEntries[0]?.shortLabel,
'Additional charge'
)
assert.equal(tokenSummary.secondaryEntries[0]?.labelKind, 'i18n') assert.equal(tokenSummary.secondaryEntries[0]?.labelKind, 'i18n')
const multiFieldModel = pricingModel({ const multiFieldModel = pricingModel({
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, render, screen, cleanup } from '@testing-library/react'
import i18next from 'i18next'
import { afterEach, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
import { DynamicPricingBreakdown } from '../components/dynamic-pricing-breakdown'
import { ModelCard } from '../components/model-card'
import { ModelDetailsContent } from '../components/model-details'
import { getTaskPricingDisplayTiers } from '../lib/task-matrix-display'
import {
hasSimpleTaskPricing,
taskPriceLabel,
taskEnumLabel,
taskPricingConditions,
} from '../lib/task-price-display'
import type { PricingModel, BillingUsageSchema } from '../types'
vi.mock('@visactor/react-vchart', () => ({ VChart: () => null }))
const model: PricingModel = {
id: 1,
model_name: 'incho_music',
quota_type: 0,
model_ratio: 1,
completion_ratio: 1,
enable_groups: ['default'],
billing_mode: 'tiered_expr',
billing_expr: 'tier("music", u("clips") * 0.22)',
billing_usage_schema: {
clips: {
type: 'number',
unit: 'count',
description: { en: 'Song generation unit price', zh: '生成歌曲单价' },
},
action: {
enum: ['music'],
enumLabels: { music: { en: 'Generate songs', zh: '生成歌曲' } },
description: { en: 'Generate songs', zh: '生成歌曲' },
},
},
}
const clients: QueryClient[] = []
afterEach(async () => {
cleanup()
clients.forEach((client) => client.clear())
clients.length = 0
vi.restoreAllMocks()
await i18next.changeLanguage('en')
})
it('shows one standard task price and a localized group price without duplicate tiers', async () => {
vi.spyOn(api, 'get').mockResolvedValue({ data: { data: { groups: [] } } })
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
clients.push(client)
render(
<QueryClientProvider client={client}>
<ModelDetailsContent
model={model}
groupRatio={{ default: 2 }}
usableGroup={{ default: { desc: '', ratio: 2 } }}
endpointMap={{}}
autoGroups={[]}
priceRate={1}
usdExchangeRate={7}
tokenUnit='M'
/>
</QueryClientProvider>
)
expect(
screen.getAllByText('Song generation unit price', { exact: false })
).toHaveLength(2)
expect(screen.queryByText('Tiered price table')).not.toBeInTheDocument()
expect(screen.queryByText('Dynamic Pricing')).not.toBeInTheDocument()
expect(screen.queryByText('music')).not.toBeInTheDocument()
expect(screen.getByText('$0.22')).toBeVisible()
expect(screen.getByText('$0.44')).toBeVisible()
await act(() => i18next.changeLanguage('zhCN'))
expect(screen.getAllByText('生成歌曲单价', { exact: false })).toHaveLength(2)
await act(() => i18next.changeLanguage('fr'))
expect(
screen.getAllByText('Song generation unit price', { exact: false })
).toHaveLength(2)
})
it('labels even a single task price on model cards', async () => {
render(<ModelCard model={model} onClick={() => {}} />)
expect(screen.getByText('Song generation unit price')).toBeVisible()
await act(() => i18next.changeLanguage('zhCN'))
expect(screen.getByText('生成歌曲单价')).toBeVisible()
})
it('preserves condition tables, boolean states and additional charges', () => {
render(
<DynamicPricingBreakdown
billingExpr='u("audio") == true ? tier("audio", 1 + u("seconds") * 0.8) : tier("silent", u("seconds") * 0.4)'
usageSchema={{
seconds: {
type: 'number',
unit: 'second',
description: { en: 'Video generation unit price' },
},
audio: {
type: 'boolean',
description: { en: 'Whether audio is generated' },
},
}}
/>
)
expect(
screen.getByRole('columnheader', { name: 'Applicable conditions' })
).toBeVisible()
expect(screen.queryByText('Pricing conditions')).not.toBeInTheDocument()
expect(
screen.getAllByText('Whether audio is generated: Yes').length
).toBeGreaterThan(0)
expect(
screen.getAllByText('Whether audio is generated: No').length
).toBeGreaterThan(0)
expect(screen.getAllByText('Additional charge').length).toBeGreaterThan(0)
expect(screen.getAllByText('Video generation unit price')[0]).toHaveClass(
'whitespace-normal',
'break-words'
)
})
it('keeps rules and custom expressions out of the simple price layout', () => {
expect(hasSimpleTaskPricing(model)).toBe(true)
expect(
hasSimpleTaskPricing({
...model,
billing_expr: `${model.billing_expr}|||when(header("x-fast") == "true") * 2`,
})
).toBe(false)
expect(
hasSimpleTaskPricing({
...model,
billing_expr: 'max(u("clips"), 2) * 0.22',
})
).toBe(false)
expect(taskPriceLabel(undefined, 'clips', 'fr')).toBe('clips')
expect(
taskPriceLabel({ en: 'Song price', zh: '歌曲单价' }, 'clips', 'zh-TW')
).toBe('歌曲单价')
expect(
taskPriceLabel({ en: 'Song price', zh: '歌曲单价' }, 'clips', 'ja')
).toBe('Song price')
})
const videoSchema: BillingUsageSchema = {
tokens: {
type: 'number',
unit: 'token',
description: { en: 'Billing token unit price', zh: '计费 Token 单价' },
},
resolution: {
enum: ['480p', '720p', '1080p'],
description: { en: 'Output resolution', zh: '输出分辨率' },
},
video_input: {
enum: ['none', 'video'],
description: { en: 'Reference video input', zh: '参考视频输入' },
enumLabels: {
none: { en: 'No reference video', zh: '无参考视频' },
video: { en: 'With reference video', zh: '有参考视频' },
},
},
}
const videoExpression =
'u("video_input") == "none" ? tier("none", u("tokens") * 10 / 1000000) : tier("video", u("tokens") * 6 / 1000000)'
it('uses plugin option labels and infers a unique fallback without expanding unrelated fields', async () => {
render(
<DynamicPricingBreakdown
billingExpr={videoExpression}
usageSchema={videoSchema}
/>
)
expect(screen.getAllByText('No reference video')).toHaveLength(2)
expect(screen.getAllByText('With reference video')).toHaveLength(2)
expect(screen.queryByText('Other cases')).not.toBeInTheDocument()
expect(screen.queryByText('Pricing conditions')).not.toBeInTheDocument()
expect(screen.queryByText('480p')).not.toBeInTheDocument()
await act(() => i18next.changeLanguage('zhCN'))
expect(screen.getAllByText('无参考视频')).toHaveLength(2)
expect(screen.getAllByText('有参考视频')).toHaveLength(2)
await act(() => i18next.changeLanguage('ja'))
expect(screen.getAllByText('With reference video')).toHaveLength(2)
})
it('names ambiguous fallback rows other cases and keeps unmatched enum values readable', () => {
const schema = {
...videoSchema,
video_input: {
...videoSchema.video_input,
enum: ['none', 'video', 'mixed'],
},
}
render(
<DynamicPricingBreakdown
billingExpr={videoExpression}
usageSchema={schema}
/>
)
expect(screen.getAllByText('Other cases')).toHaveLength(2)
expect(taskEnumLabel(schema.video_input, 'mixed', 'zhCN')).toBe('mixed')
expect(
taskPricingConditions(
[{ field: 'video_input', value: 'mixed' }],
schema,
'en',
(key) => key
)
).toBe('Reference video input: mixed')
expect(
taskEnumLabel(
{ enum: ['x'], enumLabels: { x: { en: 'English', zh: '中文' } } },
'x',
'fr'
)
).toBe('English')
})
it('preserves all conditions and leaves multiple possible fallback combinations unspecified', () => {
const tiers = getTaskPricingDisplayTiers(
'u("resolution") == "720p" && u("video_input") == "none" ? tier("one", u("tokens") * 10 / 1000000) : tier("rest", u("tokens") * 6 / 1000000)',
videoSchema
)
expect(
taskPricingConditions(tiers[0].conditions, videoSchema, 'en', (key) => key)
).toBe('Output resolution: 720p · No reference video')
expect(tiers[1].conditions).toEqual([])
})
it('uses the same recharge conversion and token unit in task condition prices', () => {
render(
<DynamicPricingBreakdown
billingExpr={videoExpression}
usageSchema={videoSchema}
taskPriceOptions={{
showRechargePrice: true,
priceRate: 1,
usdExchangeRate: 2,
}}
/>
)
expect(screen.getAllByText('$5/1M token')).toHaveLength(2)
expect(screen.getAllByText('$3/1M token')).toHaveLength(2)
})
...@@ -37,7 +37,7 @@ import { ...@@ -37,7 +37,7 @@ import {
import { parseTags } from '../lib/filters' import { parseTags } from '../lib/filters'
import { isTokenBasedModel } from '../lib/model-helpers' import { isTokenBasedModel } from '../lib/model-helpers'
import { formatPrice, formatRequestPrice } from '../lib/price' import { formatPrice, formatRequestPrice } from '../lib/price'
import { getTaskNumberFields } from '../lib/task-expr' import { taskPriceLabel } from '../lib/task-price-display'
import type { PricingModel, PriceType, TokenUnit } from '../types' import type { PricingModel, PriceType, TokenUnit } from '../types'
import { ModelBillingModeBadge } from './model-billing-mode-badge' import { ModelBillingModeBadge } from './model-billing-mode-badge'
import { ModelPerfBadge, type ModelPerfBadgeData } from './model-perf-badge' import { ModelPerfBadge, type ModelPerfBadgeData } from './model-perf-badge'
...@@ -54,7 +54,7 @@ export interface ModelCardProps { ...@@ -54,7 +54,7 @@ export interface ModelCardProps {
} }
export const ModelCard = memo(function ModelCard(props: ModelCardProps) { export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
const { t } = useTranslation() const { t, i18n } = useTranslation()
const tokenUnit = props.tokenUnit ?? DEFAULT_TOKEN_UNIT const tokenUnit = props.tokenUnit ?? DEFAULT_TOKEN_UNIT
const priceRate = props.priceRate ?? 1 const priceRate = props.priceRate ?? 1
const usdExchangeRate = props.usdExchangeRate ?? 1 const usdExchangeRate = props.usdExchangeRate ?? 1
...@@ -85,8 +85,6 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) { ...@@ -85,8 +85,6 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
? getDynamicPricingSummary(props.model, dynamicPriceOptions) ? getDynamicPricingSummary(props.model, dynamicPriceOptions)
: null : null
const cardExamplePrice = getCardExamplePrice(props.model, dynamicPriceOptions) const cardExamplePrice = getCardExamplePrice(props.model, dynamicPriceOptions)
const showTaskFieldLabels =
getTaskNumberFields(props.model.billing_usage_schema).length > 1
let priceSummary: ReactNode let priceSummary: ReactNode
if (dynamicSummary) { if (dynamicSummary) {
if (dynamicSummary.isSpecialExpression) { if (dynamicSummary.isSpecialExpression) {
...@@ -108,9 +106,11 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) { ...@@ -108,9 +106,11 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
let label: ReactNode = null let label: ReactNode = null
if (entry.labelKind !== 'schema') { if (entry.labelKind !== 'schema') {
label = t(entry.shortLabel) label = t(entry.shortLabel)
} else if (showTaskFieldLabels) { } else {
label = ( label = taskPriceLabel(
<code className='font-mono break-all'>{entry.shortLabel}</code> entry.description,
entry.shortLabel,
i18n.language
) )
} }
return ( return (
...@@ -122,7 +122,9 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) { ...@@ -122,7 +122,9 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
)} )}
> >
{label && ( {label && (
<span className='text-muted-foreground text-xs'>{label}</span> <span className='text-muted-foreground text-xs break-words whitespace-normal'>
{label}
</span>
)} )}
<span className='flex flex-wrap items-baseline gap-x-1 font-mono text-sm font-semibold tabular-nums'> <span className='flex flex-wrap items-baseline gap-x-1 font-mono text-sm font-semibold tabular-nums'>
<span>{entry.formattedRange ?? entry.formatted}</span> <span>{entry.formattedRange ?? entry.formatted}</span>
......
...@@ -60,6 +60,7 @@ import { cn } from '@/lib/utils' ...@@ -60,6 +60,7 @@ import { cn } from '@/lib/utils'
import { DEFAULT_TOKEN_UNIT } from '../constants' import { DEFAULT_TOKEN_UNIT } from '../constants'
import { usePricingData } from '../hooks/use-pricing-data' import { usePricingData } from '../hooks/use-pricing-data'
import type { ParsedTaskTier } from '../lib/billing-expr'
import { import {
formatTaskUsageUnitPrice, formatTaskUsageUnitPrice,
getDynamicPriceEntries, getDynamicPriceEntries,
...@@ -79,7 +80,12 @@ import { ...@@ -79,7 +80,12 @@ import {
getTaskEnumFields, getTaskEnumFields,
getTaskNumberFields, getTaskNumberFields,
} from '../lib/task-expr' } from '../lib/task-expr'
import { getTaskMatrixDisplayTiers } from '../lib/task-matrix-display' import { getTaskPricingDisplayTiers } from '../lib/task-matrix-display'
import {
hasSimpleTaskPricing,
taskPriceLabel,
taskPricingConditions,
} from '../lib/task-price-display'
import type { import type {
ModelCapability, ModelCapability,
PriceType, PriceType,
...@@ -104,9 +110,17 @@ function SectionTitle(props: { children: React.ReactNode }) { ...@@ -104,9 +110,17 @@ function SectionTitle(props: { children: React.ReactNode }) {
} }
function DynamicPriceEntryLabel(props: { entry: DynamicPriceEntry }) { function DynamicPriceEntryLabel(props: { entry: DynamicPriceEntry }) {
const { t } = useTranslation() const { t, i18n } = useTranslation()
if (props.entry.labelKind === 'schema') { if (props.entry.labelKind === 'schema') {
return <code className='font-mono'>{props.entry.shortLabel}</code> return (
<span className='break-words whitespace-normal'>
{taskPriceLabel(
props.entry.description,
props.entry.shortLabel,
i18n.language
)}
</span>
)
} }
return t(props.entry.shortLabel) return t(props.entry.shortLabel)
} }
...@@ -718,7 +732,12 @@ function PriceSection(props: { ...@@ -718,7 +732,12 @@ function PriceSection(props: {
<section> <section>
<SectionTitle>{t('Base Price')}</SectionTitle> <SectionTitle>{t('Base Price')}</SectionTitle>
{dynamicSummary.primaryEntries.length > 0 ? ( {dynamicSummary.primaryEntries.length > 0 ? (
<div className='grid grid-cols-2 gap-2'> <div
className={cn(
'grid gap-2',
dynamicSummary.primaryEntries.length > 1 && 'grid-cols-2'
)}
>
{dynamicSummary.primaryEntries.map((entry) => { {dynamicSummary.primaryEntries.map((entry) => {
const unitLabelKey = getDynamicPriceUnitLabelKey(entry) const unitLabelKey = getDynamicPriceUnitLabelKey(entry)
return ( return (
...@@ -939,7 +958,7 @@ function GroupPricingSection(props: { ...@@ -939,7 +958,7 @@ function GroupPricingSection(props: {
tokenUnit: TokenUnit tokenUnit: TokenUnit
showRechargePrice?: boolean showRechargePrice?: boolean
}) { }) {
const { t } = useTranslation() const { t, i18n } = useTranslation()
const showRechargePrice = props.showRechargePrice ?? false const showRechargePrice = props.showRechargePrice ?? false
const availableGroups = useMemo( const availableGroups = useMemo(
...@@ -987,15 +1006,18 @@ function GroupPricingSection(props: { ...@@ -987,15 +1006,18 @@ function GroupPricingSection(props: {
) )
} }
const thClass = const thClass = cn(
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase' 'text-muted-foreground py-2 text-xs font-medium whitespace-normal break-words',
!props.model.billing_usage_schema && 'tracking-wider uppercase'
)
if (isDynamicPricingModel(props.model)) { if (isDynamicPricingModel(props.model)) {
const dynamicTiers = const dynamicTiers = props.model.billing_usage_schema
getTaskMatrixDisplayTiers( ? getTaskPricingDisplayTiers(
props.model.billing_expr, props.model.billing_expr,
props.model.billing_usage_schema props.model.billing_usage_schema
) ?? getDynamicPricingTiers(props.model) )
: getDynamicPricingTiers(props.model)
if (dynamicTiers.length === 0) { if (dynamicTiers.length === 0) {
return ( return (
...@@ -1082,21 +1104,39 @@ function GroupPricingSection(props: { ...@@ -1082,21 +1104,39 @@ function GroupPricingSection(props: {
`${group}-${tier.label || tierIndex}` `${group}-${tier.label || tierIndex}`
} }
columns={[ columns={[
...(hasSimpleTaskPricing(props.model)
? []
: [
{ {
id: 'tier', id: 'tier',
header: t('Tier'), header: props.model.billing_usage_schema
? t('Applicable conditions')
: t('Tier'),
className: thClass, className: thClass,
cellClassName: 'text-muted-foreground py-2.5', cellClassName:
cell: (tier) => tier.label || t('Default'), 'text-muted-foreground py-2.5 whitespace-normal break-words',
cell: (tier: DynamicPricingTier) =>
'unitPrices' in tier
? taskPricingConditions(
(tier as ParsedTaskTier).conditions,
props.model.billing_usage_schema,
i18n.language,
t
) ||
t(
dynamicTiers.length > 1
? 'Other cases'
: 'All requests'
)
: tier.label || t('Default'),
}, },
]),
...priceFields.map((fieldEntry) => { ...priceFields.map((fieldEntry) => {
const unitLabelKey = const unitLabelKey =
getDynamicPriceUnitLabelKey(fieldEntry) getDynamicPriceUnitLabelKey(fieldEntry)
const fieldLabel = const fieldLabel =
fieldEntry.labelKind === 'schema' ? ( fieldEntry.labelKind === 'schema' ? (
<code className='font-mono'> <DynamicPriceEntryLabel entry={fieldEntry} />
{fieldEntry.shortLabel}
</code>
) : ( ) : (
t(fieldEntry.shortLabel) t(fieldEntry.shortLabel)
) )
...@@ -1307,6 +1347,16 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) { ...@@ -1307,6 +1347,16 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
props.model.billing_mode === 'tiered_expr' && props.model.billing_mode === 'tiered_expr' &&
Boolean(props.model.billing_expr) Boolean(props.model.billing_expr)
const simpleTaskPricing = hasSimpleTaskPricing(props.model)
const taskTiers = getTaskPricingDisplayTiers(
props.model.billing_expr,
props.model.billing_usage_schema
)
const showBasePrices =
!props.model.billing_usage_schema ||
simpleTaskPricing ||
taskTiers.length === 0
return ( return (
<div className='@container/details space-y-4'> <div className='@container/details space-y-4'>
<ModelHeader model={props.model} /> <ModelHeader model={props.model} />
...@@ -1333,6 +1383,7 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) { ...@@ -1333,6 +1383,7 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
<section className='bg-card/60 space-y-5 rounded-xl border p-4 shadow-sm'> <section className='bg-card/60 space-y-5 rounded-xl border p-4 shadow-sm'>
<SectionTitle>{t('Pricing')}</SectionTitle> <SectionTitle>{t('Pricing')}</SectionTitle>
{showBasePrices && (
<PriceSection <PriceSection
model={props.model} model={props.model}
priceRate={props.priceRate} priceRate={props.priceRate}
...@@ -1340,10 +1391,16 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) { ...@@ -1340,10 +1391,16 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
tokenUnit={props.tokenUnit} tokenUnit={props.tokenUnit}
showRechargePrice={showRechargePrice} showRechargePrice={showRechargePrice}
/> />
{isDynamic && ( )}
{isDynamic && !simpleTaskPricing && (
<DynamicPricingBreakdown <DynamicPricingBreakdown
billingExpr={props.model.billing_expr} billingExpr={props.model.billing_expr}
usageSchema={props.model.billing_usage_schema} usageSchema={props.model.billing_usage_schema}
taskPriceOptions={{
showRechargePrice,
priceRate: props.priceRate,
usdExchangeRate: props.usdExchangeRate,
}}
/> />
)} )}
<GroupPricingSection <GroupPricingSection
......
...@@ -462,24 +462,34 @@ function splitTaskTopLevel(expression: string, operator: '&&' | '+'): string[] { ...@@ -462,24 +462,34 @@ function splitTaskTopLevel(expression: string, operator: '&&' | '+'): string[] {
function parseTaskConditions( function parseTaskConditions(
expression: string, expression: string,
schema: BillingUsageSchema schema: BillingUsageSchema,
includeBooleanConditions: boolean
): TaskTierCondition[] | null { ): TaskTierCondition[] | null {
const conditions: TaskTierCondition[] = [] const conditions: TaskTierCondition[] = []
for (const part of splitTaskTopLevel(expression, '&&')) { for (const part of splitTaskTopLevel(expression, '&&')) {
const match = part.match( const match = part.match(
/^u\(\s*("(?:[^"\\]|\\.)*")\s*\)\s*==\s*("(?:[^"\\]|\\.)*")$/ /^u\(\s*("(?:[^"\\]|\\.)*")\s*\)\s*==\s*("(?:[^"\\]|\\.)*"|true|false)$/
) )
if (!match) return null if (!match) return null
let field: string let field: string
let value: string let value: string
try { try {
field = JSON.parse(match[1]) as string field = JSON.parse(match[1]) as string
value = JSON.parse(match[2]) as string value = String(JSON.parse(match[2]))
} catch { } catch {
return null return null
} }
const declaredValues = schema[field]?.enum const definition = schema[field]
if (!declaredValues?.includes(value)) return null if (definition?.type === 'boolean') {
if (!includeBooleanConditions || !['true', 'false'].includes(match[2])) {
return null
}
} else if (
!definition?.enum?.includes(value) ||
!match[2].startsWith('"')
) {
return null
}
conditions.push({ field, value }) conditions.push({ field, value })
} }
return conditions.length > 0 ? conditions : null return conditions.length > 0 ? conditions : null
...@@ -554,7 +564,8 @@ function parseTaskTierCall( ...@@ -554,7 +564,8 @@ function parseTaskTierCall(
export function parseTaskTiersFromExpr( export function parseTaskTiersFromExpr(
exprStr: string, exprStr: string,
schema: BillingUsageSchema | null | undefined schema: BillingUsageSchema | null | undefined,
includeBooleanConditions = false
): ParsedTaskTier[] { ): ParsedTaskTier[] {
if (!exprStr || !schema || Object.keys(schema).length === 0) return [] if (!exprStr || !schema || Object.keys(schema).length === 0) return []
try { try {
...@@ -576,7 +587,8 @@ export function parseTaskTiersFromExpr( ...@@ -576,7 +587,8 @@ export function parseTaskTiersFromExpr(
if (colonIndex < 0) return [] if (colonIndex < 0) return []
const conditions = parseTaskConditions( const conditions = parseTaskConditions(
remaining.slice(0, questionIndex).trim(), remaining.slice(0, questionIndex).trim(),
schema schema,
includeBooleanConditions
) )
if (!conditions) return [] if (!conditions) return []
const tier = parseTaskTierCall( const tier = parseTaskTierCall(
......
...@@ -42,7 +42,7 @@ import { ...@@ -42,7 +42,7 @@ import {
tryParseTaskVisualConfig, tryParseTaskVisualConfig,
} from './task-expr' } from './task-expr'
type DynamicPriceOptions = { export type DynamicPriceOptions = {
tokenUnit: TokenUnit tokenUnit: TokenUnit
showRechargePrice?: boolean showRechargePrice?: boolean
priceRate?: number priceRate?: number
...@@ -225,7 +225,7 @@ export function getDynamicPricingTiers( ...@@ -225,7 +225,7 @@ export function getDynamicPricingTiers(
model.billing_expr || '' model.billing_expr || ''
) )
if (isTaskUsagePricingModel(model)) { if (isTaskUsagePricingModel(model)) {
return parseTaskTiersFromExpr(billingExpr, model.billing_usage_schema) return parseTaskTiersFromExpr(billingExpr, model.billing_usage_schema, true)
} }
return parseTiersFromExpr(billingExpr) return parseTiersFromExpr(billingExpr)
} }
...@@ -268,8 +268,8 @@ export function getDynamicPriceEntries( ...@@ -268,8 +268,8 @@ export function getDynamicPriceEntries(
usageEntries.push({ usageEntries.push({
key: 'constant', key: 'constant',
field: 'constant', field: 'constant',
label: 'Base charge', label: 'Additional charge',
shortLabel: 'Base', shortLabel: 'Additional charge',
labelKind: 'i18n', labelKind: 'i18n',
value: tier.constant, value: tier.constant,
formatted: formatTaskUsageUnitPrice(tier.constant, options), formatted: formatTaskUsageUnitPrice(tier.constant, options),
......
...@@ -17,7 +17,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,7 +17,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import type { BillingUsageSchema } from '../types' import type { BillingUsageSchema } from '../types'
import type { ParsedTaskTier } from './billing-expr' import {
parseTaskTiersFromExpr,
type ParsedTaskTier,
type TaskTierCondition,
} from './billing-expr'
import { import {
getTaskEnumFields, getTaskEnumFields,
taskMatrixRowLabel, taskMatrixRowLabel,
...@@ -49,3 +53,49 @@ export function getTaskMatrixDisplayTiers( ...@@ -49,3 +53,49 @@ export function getTaskMatrixDisplayTiers(
unitPrices: { ...row.unitPrices }, unitPrices: { ...row.unitPrices },
})) }))
} }
/** Display explicit conditions for a fallback only when its complement is unique.
* Unlike the editor matrix, unrelated schema fields do not expand the price table.
*/
export function getTaskPricingDisplayTiers(
expression: string | null | undefined,
schema: BillingUsageSchema | null | undefined
): ParsedTaskTier[] {
const tiers = parseTaskTiersFromExpr(expression || '', schema, true)
const fallback = tiers.at(-1)
if (!schema || tiers.length < 2 || !fallback) return tiers
const previous = tiers.slice(0, -1)
const fields = [
...new Set(
previous.flatMap((tier) =>
tier.conditions.map((condition) => condition.field)
)
),
].sort()
let combinations: TaskTierCondition[][] = [[]]
for (const field of fields) {
const definition = schema[field]
const values =
definition?.type === 'boolean' ? ['false', 'true'] : definition?.enum
// Avoid expanding large plugin schemas merely to name a fallback row.
if (!values?.length || combinations.length * values.length > 256) {
return tiers
}
combinations = combinations.flatMap((combination) =>
values.map((value) => [...combination, { field, value }])
)
}
const remaining = combinations.filter(
(combination) =>
!previous.some((tier) =>
tier.conditions.every((condition) =>
combination.some(
(value) =>
value.field === condition.field && value.value === condition.value
)
)
)
)
if (remaining.length !== 1) return tiers
return [...previous, { ...fallback, conditions: remaining[0] }]
}
/*
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 {
resolveLocalizedText,
type LocalizedTextValue,
} from '@/lib/localized-text'
import type {
BillingUsageFieldSchema,
BillingUsageSchema,
PricingModel,
} from '../types'
import {
splitBillingExprAndRequestRules,
type TaskTierCondition,
} from './billing-expr'
import { getTaskPricingDisplayTiers } from './task-matrix-display'
export function taskPriceLabel(
description: LocalizedTextValue | undefined,
field: string,
language: string
): string {
const localized =
typeof description === 'object' && description
? { ...description, en: description.en?.trim() || field }
: description
return resolveLocalizedText(localized, language) || field
}
export function taskEnumLabel(
definition: BillingUsageFieldSchema | undefined,
value: string,
language: string
): string {
return taskPriceLabel(definition?.enumLabels?.[value], value, language)
}
export function taskPricingConditions(
conditions: TaskTierCondition[],
schema: BillingUsageSchema | undefined,
language: string,
t: (key: string) => string
): string {
return conditions
.map(({ field, value }) => {
const definition = schema?.[field]
const label = taskPriceLabel(definition?.description, field, language)
if (definition?.type === 'boolean') {
return `${label}: ${value === 'true' ? t('Yes') : t('No')}`
}
const optionLabel = taskEnumLabel(definition, value, language)
return optionLabel !== value ? optionLabel : `${label}: ${optionLabel}`
})
.join(' · ')
}
export function hasSimpleTaskPricing(model: PricingModel): boolean {
if (
!model.billing_usage_schema ||
model.billing_mode !== 'tiered_expr' ||
!model.billing_expr
) {
return false
}
const split = splitBillingExprAndRequestRules(model.billing_expr)
if (split.requestRuleExpr?.trim()) return false
const tiers = getTaskPricingDisplayTiers(
split.billingExpr,
model.billing_usage_schema
)
return tiers.length === 1
}
...@@ -33,6 +33,7 @@ export type BillingUsageFieldSchema = { ...@@ -33,6 +33,7 @@ export type BillingUsageFieldSchema = {
type?: 'number' | 'boolean' type?: 'number' | 'boolean'
unit?: BillingUsageUnit unit?: BillingUsageUnit
enum?: string[] enum?: string[]
enumLabels?: Record<string, string | Record<string, string>>
description?: string | Record<string, string> description?: string | Record<string, string>
} }
......
...@@ -37,6 +37,7 @@ type SettingsSwitchRowProps = ComponentProps<'div'> ...@@ -37,6 +37,7 @@ type SettingsSwitchRowProps = ComponentProps<'div'>
type SettingsControlGroupProps = ComponentProps<'div'> type SettingsControlGroupProps = ComponentProps<'div'>
type SettingsControlChildrenProps = ComponentProps<'div'> type SettingsControlChildrenProps = ComponentProps<'div'>
type SettingsSwitchFieldProps = SettingsSwitchRowProps & { type SettingsSwitchFieldProps = SettingsSwitchRowProps & {
controlId?: string
checked: boolean checked: boolean
onCheckedChange: (checked: boolean) => void onCheckedChange: (checked: boolean) => void
label: ReactNode label: ReactNode
...@@ -107,6 +108,7 @@ export function SettingsSwitchRow({ ...@@ -107,6 +108,7 @@ export function SettingsSwitchRow({
} }
export function SettingsSwitchField({ export function SettingsSwitchField({
controlId,
checked, checked,
onCheckedChange, onCheckedChange,
label, label,
...@@ -118,12 +120,23 @@ export function SettingsSwitchField({ ...@@ -118,12 +120,23 @@ export function SettingsSwitchField({
return ( return (
<SettingsSwitchRow className={className} {...props}> <SettingsSwitchRow className={className} {...props}>
<SettingsSwitchContent> <SettingsSwitchContent>
<Label className='text-sm font-medium'>{label}</Label> <Label htmlFor={controlId} className='text-sm font-medium'>
{label}
</Label>
{description ? ( {description ? (
<p className='text-muted-foreground text-xs'>{description}</p> <p
id={controlId ? `${controlId}-description` : undefined}
className='text-muted-foreground text-xs'
>
{description}
</p>
) : null} ) : null}
</SettingsSwitchContent> </SettingsSwitchContent>
<Switch <Switch
id={controlId}
aria-describedby={
controlId && description ? `${controlId}-description` : undefined
}
checked={checked} checked={checked}
onCheckedChange={onCheckedChange} onCheckedChange={onCheckedChange}
disabled={disabled} disabled={disabled}
......
/*
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 { act, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import i18next from 'i18next'
import { afterEach, expect, it, vi } from 'vitest'
import { TaskUsagePricingEditor } from '../task-usage-pricing-editor'
function renderPricing() {
const onBillingExprChange = vi.fn()
render(
<TaskUsagePricingEditor
billingExpr='tier("music", 1 + u("clips") * 11)'
requestRuleExpr=''
usageSchema={{
action: {
enum: ['music', 'lyrics'],
enumLabels: {
music: { en: 'Generate songs', zh: '生成歌曲' },
lyrics: { en: 'Generate lyrics', zh: '生成歌词' },
},
description: { en: 'Generate songs or lyrics', zh: '生成歌曲或歌词' },
},
clips: {
type: 'number',
unit: 'count',
description: { en: 'Song generation unit price', zh: '生成歌曲单价' },
},
}}
onBillingExprChange={onBillingExprChange}
onRequestRuleExprChange={vi.fn()}
/>
)
return onBillingExprChange
}
afterEach(async () => {
await act(() => i18next.changeLanguage('en'))
})
it('shows localized schema explanations in the price table and calculator', async () => {
renderPricing()
const table = screen.getByRole('table')
expect(within(table).getByText('Song generation unit price')).toBeVisible()
expect(within(table).getByText('Generate songs or lyrics')).toBeVisible()
expect(
screen.getByRole('spinbutton', {
name: 'Usage · Song generation unit price',
})
).toBeVisible()
expect(
within(table).getByText(
'Added to the usage cost. Set to 0 for no additional charge.'
)
).toBeVisible()
await act(() => i18next.changeLanguage('zhCN'))
expect(within(table).getByText('生成歌曲单价')).toBeVisible()
expect(screen.getByRole('combobox', { name: '生成歌曲或歌词' })).toBeVisible()
})
it('identifies pricing conditions and keeps the additional charge unchanged when sample usage changes', async () => {
renderPricing()
expect(
screen.getByText('Current pricing conditions: Generate songs')
).toBeVisible()
expect(
screen.getByText(
'Additional charge: $1 + Song generation unit price: 1 unit × $11/unit = $12'
)
).toBeVisible()
const user = userEvent.setup()
const quantity = screen.getByRole('spinbutton', {
name: 'Usage · Song generation unit price',
})
await user.clear(quantity)
await user.type(quantity, '2')
expect(
screen.getByText(
'Additional charge: $1 + Song generation unit price: 2 unit × $11/unit = $23'
)
).toBeVisible()
})
it('shows localized enum choices while preserving raw values in generated billing expressions', async () => {
const onChange = renderPricing()
const user = userEvent.setup()
await user.click(
screen.getByRole('combobox', { name: 'Generate songs or lyrics' })
)
await user.click(screen.getByRole('option', { name: 'Generate lyrics' }))
expect(
screen.getByText('Current pricing conditions: Generate lyrics')
).toBeVisible()
const price = screen.getByRole('textbox', {
name: 'Song generation unit price: Generate lyrics',
})
await user.clear(price)
await user.type(price, '3')
const expression = onChange.mock.lastCall?.[0]
expect(expression).toContain('u("action") == "music"')
expect(expression).toContain('tier("lyrics"')
expect(expression).not.toContain('Generate lyrics')
})
...@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useId } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { InputGroup, InputGroupAddon } from '@/components/ui/input-group' import { InputGroup, InputGroupAddon } from '@/components/ui/input-group'
...@@ -73,33 +74,44 @@ export function PriceLane(props: { ...@@ -73,33 +74,44 @@ export function PriceLane(props: {
value: string value: string
enabled: boolean enabled: boolean
disabled?: boolean disabled?: boolean
compact?: boolean
disabledReason?: string
onEnabledChange: (checked: boolean) => void onEnabledChange: (checked: boolean) => void
onChange: (value: string) => void onChange: (value: string) => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const controlId = useId()
const effectiveDisabled = props.disabled || !props.enabled const effectiveDisabled = props.disabled || !props.enabled
return ( return (
<SettingsControlGroup <SettingsControlGroup
className={cn('space-y-3', effectiveDisabled && 'opacity-75')} className={cn(
'space-y-3',
props.compact && 'space-y-2 rounded-lg bg-transparent p-3',
effectiveDisabled && 'opacity-75'
)}
data-disabled={effectiveDisabled || undefined} data-disabled={effectiveDisabled || undefined}
> >
<SettingsSwitchField <SettingsSwitchField
controlId={controlId}
className={props.compact ? 'py-0' : undefined}
checked={props.enabled} checked={props.enabled}
disabled={props.disabled} disabled={props.disabled}
onCheckedChange={props.onEnabledChange} onCheckedChange={props.onEnabledChange}
label={props.title} label={props.title}
description={props.description} description={props.disabledReason || props.description}
aria-label={props.title} aria-label={props.title}
/> />
<PriceInput <PriceInput
currency={props.currency} currency={props.currency}
aria-label={props.title} aria-label={props.title}
aria-describedby={`${controlId}-description`}
value={props.value} value={props.value}
placeholder={props.placeholder} placeholder={props.placeholder}
disabled={effectiveDisabled} disabled={effectiveDisabled}
onChange={props.onChange} onChange={props.onChange}
/> />
{!props.compact && (
<p className='text-muted-foreground text-xs'> <p className='text-muted-foreground text-xs'>
{props.enabled {props.enabled
? t('{{currency}} price per 1M tokens.', { ? t('{{currency}} price per 1M tokens.', {
...@@ -107,6 +119,7 @@ export function PriceLane(props: { ...@@ -107,6 +119,7 @@ export function PriceLane(props: {
}) })
: t('Disabled lanes are omitted on save.')} : t('Disabled lanes are omitted on save.')}
</p> </p>
)}
</SettingsControlGroup> </SettingsControlGroup>
) )
} }
...@@ -113,6 +113,7 @@ type ModelPricingEditorPanelProps = Omit< ...@@ -113,6 +113,7 @@ type ModelPricingEditorPanelProps = Omit<
'open' | 'onOpenChange' 'open' | 'onOpenChange'
> & { > & {
className?: string className?: string
embedded?: boolean
} }
export type ModelPricingEditorPanelHandle = { export type ModelPricingEditorPanelHandle = {
...@@ -168,7 +169,15 @@ export const ModelPricingEditorPanel = forwardRef< ...@@ -168,7 +169,15 @@ export const ModelPricingEditorPanel = forwardRef<
ModelPricingEditorPanelHandle, ModelPricingEditorPanelHandle,
ModelPricingEditorPanelProps ModelPricingEditorPanelProps
>(function ModelPricingEditorPanel( >(function ModelPricingEditorPanel(
{ editData, className, onSave, isSaving, usageSchema, onDirtyChange }, {
editData,
className,
onSave,
isSaving,
usageSchema,
onDirtyChange,
embedded = false,
},
ref ref
) { ) {
const { t } = useTranslation() const { t } = useTranslation()
...@@ -617,10 +626,11 @@ export const ModelPricingEditorPanel = forwardRef< ...@@ -617,10 +626,11 @@ export const ModelPricingEditorPanel = forwardRef<
return ( return (
<div <div
className={cn( className={cn(
'bg-background flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border', 'bg-background flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-xl border',
className className
)} )}
> >
{!embedded && (
<div className='border-b p-4'> <div className='border-b p-4'>
<div className='flex flex-wrap items-start justify-between gap-3'> <div className='flex flex-wrap items-start justify-between gap-3'>
<div className='min-w-0'> <div className='min-w-0'>
...@@ -630,6 +640,7 @@ export const ModelPricingEditorPanel = forwardRef< ...@@ -630,6 +640,7 @@ export const ModelPricingEditorPanel = forwardRef<
</div> </div>
</div> </div>
</div> </div>
)}
<Form {...form}> <Form {...form}>
<form <form
...@@ -638,9 +649,15 @@ export const ModelPricingEditorPanel = forwardRef< ...@@ -638,9 +649,15 @@ export const ModelPricingEditorPanel = forwardRef<
className='flex min-h-0 flex-1 flex-col' className='flex min-h-0 flex-1 flex-col'
autoComplete='off' autoComplete='off'
> >
<div className='min-h-0 flex-1 overflow-y-auto p-4 pb-6'> <div
<div className='grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(220px,260px)]'> role='region'
<FieldGroup> aria-label={
isEditMode ? t('Edit model pricing') : t('Add model pricing')
}
className='@container/pricing-editor min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 pb-6'
>
<div className='grid min-w-0 items-start gap-4 @min-[960px]/pricing-editor:grid-cols-[minmax(0,1fr)_260px]'>
<FieldGroup className='min-w-0'>
{warnings.length > 0 && ( {warnings.length > 0 && (
<Alert variant='destructive'> <Alert variant='destructive'>
<AlertTriangle data-icon='inline-start' /> <AlertTriangle data-icon='inline-start' />
...@@ -654,6 +671,7 @@ export const ModelPricingEditorPanel = forwardRef< ...@@ -654,6 +671,7 @@ export const ModelPricingEditorPanel = forwardRef<
</Alert> </Alert>
)} )}
{!embedded && (
<FormField <FormField
control={form.control} control={form.control}
name='name' name='name'
...@@ -676,6 +694,7 @@ export const ModelPricingEditorPanel = forwardRef< ...@@ -676,6 +694,7 @@ export const ModelPricingEditorPanel = forwardRef<
</FormItem> </FormItem>
)} )}
/> />
)}
<PricingCurrencySelector siteCurrency={siteCurrency} /> <PricingCurrencySelector siteCurrency={siteCurrency} />
...@@ -697,7 +716,10 @@ export const ModelPricingEditorPanel = forwardRef< ...@@ -697,7 +716,10 @@ export const ModelPricingEditorPanel = forwardRef<
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value='per-token' className='pt-0'> <TabsContent
value='per-token'
className='@container/pricing-fields min-w-0 pt-0'
>
{taskUsageSchema && {taskUsageSchema &&
Object.keys(taskUsageSchema).length > 0 && ( Object.keys(taskUsageSchema).length > 0 && (
<Alert className='mb-4'> <Alert className='mb-4'>
...@@ -724,27 +746,47 @@ export const ModelPricingEditorPanel = forwardRef< ...@@ -724,27 +746,47 @@ export const ModelPricingEditorPanel = forwardRef<
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
<FieldGroup className='gap-5'> {embedded && (
<Field> <p className='text-muted-foreground mb-3 text-xs'>
{t('{{currency}} price per 1M tokens.', {
currency: currency.label,
})}{' '}
{t('Disabled lanes are omitted on save.')}
</p>
)}
<div
className={cn(
'grid min-w-0 gap-3',
embedded && '@min-[560px]/pricing-fields:grid-cols-2'
)}
>
<Field
className={cn(
'min-w-0',
embedded && 'rounded-lg border p-3'
)}
>
<FieldLabel htmlFor={promptPriceId}> <FieldLabel htmlFor={promptPriceId}>
{t('Input price')} {t('Input price')}
</FieldLabel> </FieldLabel>
<FieldDescription
id={`${promptPriceId}-description`}
className={embedded ? 'text-xs' : undefined}
>
{t('{{currency}} price per 1M input tokens.', {
currency: currency.label,
})}
</FieldDescription>
<PriceInput <PriceInput
currency={currency}
id={promptPriceId} id={promptPriceId}
aria-describedby={`${promptPriceId}-description`} aria-describedby={`${promptPriceId}-description`}
currency={currency}
value={promptPrice} value={promptPrice}
placeholder='3' placeholder='3'
onChange={handlePromptPriceChange} onChange={handlePromptPriceChange}
/> />
<FieldDescription id={`${promptPriceId}-description`}>
{t('{{currency}} price per 1M input tokens.', {
currency: currency.label,
})}
</FieldDescription>
</Field> </Field>
<div className='grid gap-3 sm:grid-cols-[repeat(auto-fit,minmax(400px,1fr))]'>
{laneConfigs.map((lane) => { {laneConfigs.map((lane) => {
const disabled = const disabled =
lane.key === 'audioOutput' && lane.key === 'audioOutput' &&
...@@ -754,12 +796,20 @@ export const ModelPricingEditorPanel = forwardRef< ...@@ -754,12 +796,20 @@ export const ModelPricingEditorPanel = forwardRef<
<PriceLane <PriceLane
currency={currency} currency={currency}
key={lane.key} key={lane.key}
compact={embedded}
title={t(lane.titleKey)} title={t(lane.titleKey)}
description={t(lane.descriptionKey)} description={t(lane.descriptionKey)}
placeholder={lane.placeholder} placeholder={lane.placeholder}
value={lanePrices[lane.key]} value={lanePrices[lane.key]}
enabled={laneEnabled[lane.key]} enabled={laneEnabled[lane.key]}
disabled={disabled} disabled={disabled}
disabledReason={
disabled
? t(
'Audio output price requires an audio input price.'
)
: undefined
}
onEnabledChange={(checked) => onEnabledChange={(checked) =>
handleLaneToggle(lane.key, checked) handleLaneToggle(lane.key, checked)
} }
...@@ -770,7 +820,6 @@ export const ModelPricingEditorPanel = forwardRef< ...@@ -770,7 +820,6 @@ export const ModelPricingEditorPanel = forwardRef<
) )
})} })}
</div> </div>
</FieldGroup>
</TabsContent> </TabsContent>
<TabsContent value='per-request' className='pt-0'> <TabsContent value='per-request' className='pt-0'>
...@@ -843,7 +892,10 @@ export const ModelPricingEditorPanel = forwardRef< ...@@ -843,7 +892,10 @@ export const ModelPricingEditorPanel = forwardRef<
</Tabs> </Tabs>
</FieldGroup> </FieldGroup>
<aside className='bg-muted/20 sticky top-0 rounded-lg border'> <aside
aria-label={t('Preview')}
className='bg-muted/20 min-w-0 rounded-lg border @min-[960px]/pricing-editor:sticky @min-[960px]/pricing-editor:top-0'
>
<div className='border-b px-3 py-2'> <div className='border-b px-3 py-2'>
<div className='text-sm font-medium'>{t('Preview')}</div> <div className='text-sm font-medium'>{t('Preview')}</div>
</div> </div>
......
...@@ -24,6 +24,7 @@ import { useTranslation } from 'react-i18next' ...@@ -24,6 +24,7 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { JsonCodeEditor } from '@/components/json-code-editor' import { JsonCodeEditor } from '@/components/json-code-editor'
import { LearnMore } from '@/components/learn-more'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
Form, Form,
...@@ -42,6 +43,7 @@ import { ...@@ -42,6 +43,7 @@ import {
SettingsSwitchContent, SettingsSwitchContent,
SettingsSwitchItem, SettingsSwitchItem,
} from '../components/settings-form-layout' } from '../components/settings-form-layout'
import { SettingsPageActionsPortal } from '../components/settings-page-context'
import { import {
ModelRatioVisualEditor, ModelRatioVisualEditor,
type ModelRatioVisualEditorHandle, type ModelRatioVisualEditorHandle,
...@@ -220,9 +222,39 @@ export const ModelRatioForm = memo(function ModelRatioForm({ ...@@ -220,9 +222,39 @@ export const ModelRatioForm = memo(function ModelRatioForm({
}, [editMode, form, onSave]) }, [editMode, form, onSave])
return ( return (
<div className='space-y-6'> <Form {...form}>
<div className='flex min-h-0 flex-1 flex-col gap-6'>
{!isUnsetVariant && ( {!isUnsetVariant && (
<div className='flex flex-wrap justify-end gap-2'> <div className='flex shrink-0 flex-wrap items-center justify-end gap-2'>
<SettingsPageActionsPortal>
<FormField
control={form.control}
name='ExposeRatioEnabled'
render={({ field }) => (
<SettingsSwitchItem className='gap-2 py-0'>
<SettingsSwitchContent>
<FormLabel>{t('Expose ratio API')}</FormLabel>
<FormDescription className='sr-only'>
{t(
'Allow clients to query configured prices via `/api/ratio`.'
)}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<LearnMore contentProps={{ side: 'bottom', align: 'end' }}>
{t(
'Allow clients to query configured prices via `/api/ratio`.'
)}
</LearnMore>
</SettingsSwitchItem>
)}
/>
</SettingsPageActionsPortal>
<Button <Button
type='button' type='button'
variant='destructive' variant='destructive'
...@@ -260,9 +292,8 @@ export const ModelRatioForm = memo(function ModelRatioForm({ ...@@ -260,9 +292,8 @@ export const ModelRatioForm = memo(function ModelRatioForm({
</div> </div>
)} )}
<Form {...form}>
{editMode === 'visual' ? ( {editMode === 'visual' ? (
<div className='space-y-6'> <div className='flex min-h-0 flex-1 flex-col gap-6'>
<ModelRatioVisualEditor <ModelRatioVisualEditor
ref={visualEditorRef} ref={visualEditorRef}
savedModelPrice={savedValues.ModelPrice} savedModelPrice={savedValues.ModelPrice}
...@@ -304,31 +335,6 @@ export const ModelRatioForm = memo(function ModelRatioForm({ ...@@ -304,31 +335,6 @@ export const ModelRatioForm = memo(function ModelRatioForm({
handleFieldChange(formField, value) handleFieldChange(formField, value)
}} }}
/> />
{!isUnsetVariant && (
<FormField
control={form.control}
name='ExposeRatioEnabled'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Expose ratio API')}</FormLabel>
<FormDescription>
{t(
'Allow clients to query configured ratios via `/api/ratio`.'
)}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
)}
</div> </div>
) : ( ) : (
<SettingsForm onSubmit={form.handleSubmit(onSave)}> <SettingsForm onSubmit={form.handleSubmit(onSave)}>
...@@ -343,32 +349,9 @@ export const ModelRatioForm = memo(function ModelRatioForm({ ...@@ -343,32 +349,9 @@ export const ModelRatioForm = memo(function ModelRatioForm({
/> />
))} ))}
</div> </div>
<FormField
control={form.control}
name='ExposeRatioEnabled'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Expose ratio API')}</FormLabel>
<FormDescription>
{t(
'Allow clients to query configured ratios via `/api/ratio`.'
)}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
</SettingsForm> </SettingsForm>
)} )}
</Form>
</div> </div>
</Form>
) )
}) })
...@@ -606,8 +606,12 @@ const ModelRatioVisualEditorComponent = forwardRef< ...@@ -606,8 +606,12 @@ const ModelRatioVisualEditorComponent = forwardRef<
} }
return ( return (
<div className='flex flex-col gap-4'> <div className='flex min-h-0 flex-1 flex-col gap-4'>
<div className='grid h-[clamp(720px,calc(100vh-12rem),900px)] min-h-0 gap-4 md:grid-cols-[minmax(300px,0.72fr)_minmax(520px,1.28fr)] xl:grid-cols-[minmax(320px,0.68fr)_minmax(640px,1.32fr)]'> <div
role='region'
aria-label={t('Model prices')}
className='grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)] gap-4 md:grid-cols-[minmax(300px,0.72fr)_minmax(520px,1.28fr)] xl:grid-cols-[minmax(320px,0.68fr)_minmax(640px,1.32fr)]'
>
<div className='flex min-h-0 min-w-0 flex-col gap-3'> <div className='flex min-h-0 min-w-0 flex-col gap-3'>
<DataTableToolbar <DataTableToolbar
table={table} table={table}
......
...@@ -528,7 +528,14 @@ export function RatioSettingsCard({ ...@@ -528,7 +528,14 @@ export function RatioSettingsCard({
return ( return (
<> <>
{visibleTabs.length === 1 ? ( {visibleTabs.length === 1 ? (
<SettingsSection title={t(titleKey)}> <SettingsSection
title={t(titleKey)}
className={
defaultTab === 'models' || defaultTab === 'unset-models'
? 'min-h-0 flex-1'
: undefined
}
>
{renderTabContent(defaultTab)} {renderTabContent(defaultTab)}
</SettingsSection> </SettingsSection>
) : ( ) : (
...@@ -539,7 +546,15 @@ export function RatioSettingsCard({ ...@@ -539,7 +546,15 @@ export function RatioSettingsCard({
<SettingsSection title={t(titleKey)} className='min-h-0 flex-1'> <SettingsSection title={t(titleKey)} className='min-h-0 flex-1'>
{visibleTabs.map((tab) => ( {visibleTabs.map((tab) => (
<TabsContent key={tab} value={tab} className='min-h-0'> <TabsContent
key={tab}
value={tab}
className={
tab === 'models' || tab === 'unset-models'
? 'flex min-h-0 flex-col data-hidden:hidden'
: 'min-h-0'
}
>
{renderTabContent(tab)} {renderTabContent(tab)}
</TabsContent> </TabsContent>
))} ))}
......
...@@ -58,9 +58,13 @@ import { getTaskUsagePriceUnitLabelKey } from '@/features/pricing/lib/dynamic-pr ...@@ -58,9 +58,13 @@ import { getTaskUsagePriceUnitLabelKey } from '@/features/pricing/lib/dynamic-pr
import { import {
getTaskEnumFields, getTaskEnumFields,
getTaskNumberFields, getTaskNumberFields,
taskMatrixRowLabel,
type TaskMatrixRow, type TaskMatrixRow,
} from '@/features/pricing/lib/task-expr' } from '@/features/pricing/lib/task-expr'
import {
taskPriceLabel,
taskEnumLabel,
taskPricingConditions,
} from '@/features/pricing/lib/task-price-display'
import type { import type {
BillingUsageFieldSchema, BillingUsageFieldSchema,
BillingUsageSchema, BillingUsageSchema,
...@@ -180,7 +184,11 @@ type TaskMatrixTableProps = { ...@@ -180,7 +184,11 @@ type TaskMatrixTableProps = {
} }
function TaskMatrixTable(props: TaskMatrixTableProps) { function TaskMatrixTable(props: TaskMatrixTableProps) {
const { t } = useTranslation() const { t, i18n } = useTranslation()
const usageSchema = Object.fromEntries([
...props.enumFields,
...props.numberFields,
])
const visibleEnumFields = props.enumFields.filter( const visibleEnumFields = props.enumFields.filter(
([field]) => field !== props.hiddenEnumField ([field]) => field !== props.hiddenEnumField
) )
...@@ -189,16 +197,24 @@ function TaskMatrixTable(props: TaskMatrixTableProps) { ...@@ -189,16 +197,24 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
<Table className='min-w-max'> <Table className='min-w-max'>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
{visibleEnumFields.map(([field]) => ( {visibleEnumFields.map(([field, definition]) => (
<TableHead key={field} scope='col'> <TableHead key={field} scope='col'>
<code>{field}</code> <span className='block max-w-64 break-words whitespace-normal'>
{taskPriceLabel(definition.description, field, i18n.language)}
</span>
</TableHead> </TableHead>
))} ))}
{props.numberFields.map(([field, definition]) => ( {props.numberFields.map(([field, definition]) => (
<TableHead key={field} scope='col' className='min-w-40'> <TableHead key={field} scope='col' className='min-w-40'>
<div className='flex items-center justify-between gap-2'> <div className='flex items-center justify-between gap-2'>
<div className='flex flex-col gap-0.5'> <div className='flex flex-col gap-0.5'>
<code>{field}</code> <span className='max-w-64 break-words whitespace-normal'>
{taskPriceLabel(
definition.description,
t('Unit price: {{field}}', { field }),
i18n.language
)}
</span>
<span className='text-muted-foreground text-[11px] font-normal'> <span className='text-muted-foreground text-[11px] font-normal'>
{(props.currency ?? USD_PRICING_CURRENCY).symbol}/ {(props.currency ?? USD_PRICING_CURRENCY).symbol}/
{t(getTaskUsagePriceUnitLabelKey(definition.unit))} {t(getTaskUsagePriceUnitLabelKey(definition.unit))}
...@@ -216,7 +232,12 @@ function TaskMatrixTable(props: TaskMatrixTableProps) { ...@@ -216,7 +232,12 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
<TableHead scope='col' className='min-w-40'> <TableHead scope='col' className='min-w-40'>
<div className='flex items-center justify-between gap-2'> <div className='flex items-center justify-between gap-2'>
<div className='flex flex-col gap-0.5'> <div className='flex flex-col gap-0.5'>
<span>{t('Base charge')}</span> <span>{t('Additional charge')}</span>
<span className='text-muted-foreground max-w-48 text-xs font-normal whitespace-normal'>
{t(
'Added to the usage cost. Set to 0 for no additional charge.'
)}
</span>
<span className='text-muted-foreground text-[11px] font-normal'> <span className='text-muted-foreground text-[11px] font-normal'>
{(props.currency ?? USD_PRICING_CURRENCY).symbol}/ {(props.currency ?? USD_PRICING_CURRENCY).symbol}/
{t('request')} {t('request')}
...@@ -242,7 +263,15 @@ function TaskMatrixTable(props: TaskMatrixTableProps) { ...@@ -242,7 +263,15 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
props.numberFields.every( props.numberFields.every(
([field]) => !(entry.row.unitPrices[field] > 0) ([field]) => !(entry.row.unitPrices[field] > 0)
) )
const rowLabel = taskMatrixRowLabel(entry.row.combination) const rowLabel = taskPricingConditions(
Object.entries(entry.row.combination).map(([field, value]) => ({
field,
value,
})),
usageSchema,
i18n.language,
t
)
return ( return (
<TableRow <TableRow
key={`${rowLabel}:${entry.index}`} key={`${rowLabel}:${entry.index}`}
...@@ -253,7 +282,13 @@ function TaskMatrixTable(props: TaskMatrixTableProps) { ...@@ -253,7 +282,13 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
> >
{visibleEnumFields.map(([field]) => ( {visibleEnumFields.map(([field]) => (
<TableCell key={field}> <TableCell key={field}>
<code>{entry.row.combination[field]}</code> <span className='break-words whitespace-normal'>
{taskEnumLabel(
usageSchema[field],
entry.row.combination[field],
i18n.language
)}
</span>
</TableCell> </TableCell>
))} ))}
{props.numberFields.map(([field]) => ( {props.numberFields.map(([field]) => (
...@@ -265,7 +300,7 @@ function TaskMatrixTable(props: TaskMatrixTableProps) { ...@@ -265,7 +300,7 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
value={entry.row.unitPrices[field] ?? 0} value={entry.row.unitPrices[field] ?? 0}
data-matrix-col={field} data-matrix-col={field}
data-matrix-row={entry.index} data-matrix-row={entry.index}
aria-label={`${field}: ${rowLabel}`} aria-label={`${taskPriceLabel(usageSchema[field]?.description, t('Unit price: {{field}}', { field }), i18n.language)}: ${rowLabel}`}
onFocus={(event) => { onFocus={(event) => {
if (Number(event.currentTarget.value) === 0) { if (Number(event.currentTarget.value) === 0) {
event.currentTarget.select() event.currentTarget.select()
...@@ -297,7 +332,7 @@ function TaskMatrixTable(props: TaskMatrixTableProps) { ...@@ -297,7 +332,7 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
value={entry.row.constant} value={entry.row.constant}
data-matrix-col='constant' data-matrix-col='constant'
data-matrix-row={entry.index} data-matrix-row={entry.index}
aria-label={`${t('Base charge')}: ${rowLabel}`} aria-label={`${t('Additional charge')}: ${rowLabel}`}
onFocus={(event) => { onFocus={(event) => {
if (Number(event.currentTarget.value) === 0) { if (Number(event.currentTarget.value) === 0) {
event.currentTarget.select() event.currentTarget.select()
...@@ -355,7 +390,7 @@ type TaskMatrixGroupProps = Omit<TaskMatrixTableProps, 'hiddenEnumField'> & { ...@@ -355,7 +390,7 @@ type TaskMatrixGroupProps = Omit<TaskMatrixTableProps, 'hiddenEnumField'> & {
} }
function TaskMatrixGroup(props: TaskMatrixGroupProps) { function TaskMatrixGroup(props: TaskMatrixGroupProps) {
const { t } = useTranslation() const { t, i18n } = useTranslation()
const freeCount = props.entries.filter( const freeCount = props.entries.filter(
(entry) => (entry) =>
entry.row.constant === 0 && entry.row.constant === 0 &&
...@@ -377,7 +412,13 @@ function TaskMatrixGroup(props: TaskMatrixGroupProps) { ...@@ -377,7 +412,13 @@ function TaskMatrixGroup(props: TaskMatrixGroupProps) {
} }
> >
<span className='flex min-w-0 items-center gap-2'> <span className='flex min-w-0 items-center gap-2'>
<code>{props.groupValue}</code> <span className='break-words whitespace-normal'>
{taskEnumLabel(
Object.fromEntries(props.enumFields)[props.groupField],
props.groupValue,
i18n.language
)}
</span>
<span className='text-muted-foreground text-xs'> <span className='text-muted-foreground text-xs'>
{t('{{count}} combinations', { count: props.entries.length })} {t('{{count}} combinations', { count: props.entries.length })}
</span> </span>
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { createInstance } from 'i18next'
import { I18nextProvider } from 'react-i18next'
import { afterAll, afterEach, beforeEach, expect, test, vi } from 'vitest'
import en from '@/i18n/locales/en.json'
import {
DEFAULT_CURRENCY_CONFIG,
useSystemConfigStore,
} from '@/stores/system-config-store'
import type { UsageLog } from '../../data/schema'
import type { LogOtherData } from '../../types'
import { useCommonLogsColumns } from '../columns/common-logs-columns'
vi.mock('@lobehub/icons', () => ({}))
vi.hoisted(() => {
vi.stubGlobal('localStorage', {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
})
})
afterAll(() => vi.unstubAllGlobals())
function makeLog(other: LogOtherData): UsageLog {
return {
id: 1,
user_id: 1,
created_at: 1,
type: 2,
content: '',
username: 'user',
token_name: 'token',
model_name: 'wan2.5-i2v-preview',
quota: 5000,
prompt_tokens: 0,
completion_tokens: 0,
use_time: 0,
is_stream: false,
channel: 1,
channel_name: '',
token_id: 1,
group: 'default',
ip: '',
other: JSON.stringify(other),
request_id: 'req-1',
upstream_request_id: '',
}
}
function DetailPreview(props: { other: LogOtherData; isAdmin: boolean }) {
const table = useReactTable({
data: [makeLog(props.other)],
columns: useCommonLogsColumns(props.isAdmin, false),
getCoreRowModel: getCoreRowModel(),
})
const cell = table
.getRowModel()
.rows[0].getAllCells()
.find((item) => item.column.id === 'content')
if (!cell) throw new Error('The log must have a content column')
return flexRender(cell.column.columnDef.cell, cell.getContext())
}
const plugin = {
key: 'incho',
name: 'Incho',
version: '1.0.1',
author: { name: 'Plugin maintainer' },
}
const previousConfig = useSystemConfigStore.getState().config
let client: QueryClient
const i18n = createInstance()
beforeEach(async () => {
await i18n.init({
lng: 'en',
resources: { en },
interpolation: { escapeValue: false },
})
useSystemConfigStore
.getState()
.setConfig({ currency: { ...DEFAULT_CURRENCY_CONFIG } })
client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
client.setQueryData(['status'], {}, { updatedAt: Date.now() + 60_000 })
client.setQueryData(
['pricing'],
{ data: [], vendors: [] },
{ updatedAt: Date.now() + 60_000 }
)
})
afterEach(() => {
client.clear()
useSystemConfigStore.getState().setConfig(previousConfig)
})
function renderPreview(other: LogOtherData, isAdmin = true) {
render(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={client}>
<DetailPreview other={other} isAdmin={isAdmin} />
</QueryClientProvider>
</I18nextProvider>
)
return screen.getByRole('button', { name: /./ })
}
test.each([
{
name: 'per-call',
other: { model_price: 0.25 },
expected: 'Per-call · $0.25',
},
{
name: 'standard',
other: { model_ratio: 1, completion_ratio: 2 },
expected: 'Standard · $2 / $4/M',
},
{
name: 'zero price fallback',
other: { model_price: 0, group_ratio: 1 },
expected: 'Group Ratio 1x',
},
{ name: 'missing price fallback', other: {}, expected: '—' },
])('$name stays visible without a plugin counter', ({ other, expected }) => {
const preview = renderPreview({
...other,
admin_info: { task_plugin: plugin },
})
expect(preview.textContent).toBe(expected)
})
test('quota saturation remains first and only billing adds to the counter', () => {
const preview = renderPreview({
model_price: 0.25,
admin_info: {
task_plugin: plugin,
quota_saturation: {
op: 'round',
kind: 'overflow',
original: 3e9,
clamped: 2147483647,
},
},
})
expect(preview.textContent).toBe('Quota clamped+1')
})
test.each([true, false])(
'plugin information in the opened dialog respects admin=%s',
async (isAdmin) => {
const preview = renderPreview(
{ model_price: 0.25, admin_info: { task_plugin: plugin } },
isAdmin
)
expect(preview.textContent).toBe('Per-call · $0.25')
fireEvent.click(preview)
const dialog = within(await screen.findByRole('dialog'))
if (isAdmin) {
expect(dialog.getByText('Incho')).toBeVisible()
expect(dialog.getByText('1.0.1')).toBeVisible()
expect(dialog.getByText('Plugin maintainer')).toBeVisible()
} else {
expect(dialog.queryByText('Incho')).not.toBeInTheDocument()
expect(dialog.queryByText('Plugin maintainer')).not.toBeInTheDocument()
}
}
)
test.each([
{
expression: 'tier("music", u("clips") * 0.25)',
tier: 'music',
expected: 'music · clips $0.25/unit',
},
{
expression:
'u("mode") == "pro" ? tier("pro", u("seconds") * 0.8) : tier("std", u("seconds") * 0.4)',
tier: 'pro',
expected: 'pro · seconds $0.8/second',
},
{
expression: 'tier("tokens", u("tokens") * 9.8 / 1000000)',
tier: 'tokens',
expected: 'tokens · tokens $9.8/1M token',
},
{
expression: 'tier("free", u("clips") * 0)',
tier: 'free',
expected: 'free · clips $0/unit',
},
{
expression: 'tier("mixed", 0.1 + u("clips") * 0.25 + u("units") * 0.14)',
tier: 'mixed',
expected:
'mixed · clips $0.25/unit · units $0.14/credit · Additional charge $0.1/request',
},
])(
'task expression $tier shows its recorded unit price',
({ expression, tier, expected }) => {
client.setQueryData(['pricing'], {
data: [
{
model_name: 'wan2.5-i2v-preview',
billing_expr: 'tier("current", u("clips") * 99)',
billing_usage_schema: {
clips: { type: 'number', unit: 'count' },
seconds: { type: 'number', unit: 'second' },
tokens: { type: 'number', unit: 'token' },
units: { type: 'number', unit: 'credit' },
mode: { enum: ['pro', 'std'] },
},
},
],
vendors: [],
})
const preview = renderPreview({
is_task: true,
billing_mode: 'tiered_expr',
expr_b64: Buffer.from(expression).toString('base64'),
matched_tier: tier,
model_price: 0,
admin_info: { task_plugin: plugin },
})
expect(preview.textContent).toBe(expected)
}
)
test.each(['missing schema', 'unsupported expression', 'unknown tier'])(
'task pricing with %s shows an explicit unavailable summary',
(scenario) => {
if (scenario !== 'missing schema') {
client.setQueryData(['pricing'], {
data: [
{
model_name: 'wan2.5-i2v-preview',
billing_usage_schema: { clips: { type: 'number', unit: 'count' } },
},
],
vendors: [],
})
}
const expression =
scenario === 'unsupported expression'
? 'tier("music", max(u("clips"), 1) * 0.25)'
: 'tier("music", u("clips") * 0.25)'
const preview = renderPreview({
is_task: true,
billing_mode: 'tiered_expr',
expr_b64: Buffer.from(expression).toString('base64'),
matched_tier: scenario === 'unknown tier' ? 'old' : 'music',
})
expect(preview.textContent).toBe('Dynamic Pricing · No matching results')
}
)
...@@ -35,6 +35,16 @@ import { ...@@ -35,6 +35,16 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { usePricingData } from '@/features/pricing/hooks/use-pricing-data'
import {
normalizeTierLabel,
parseTaskTiersFromExpr,
} from '@/features/pricing/lib/billing-expr'
import {
formatTaskUsageUnitPrice,
getTaskUsagePriceUnitLabelKey,
} from '@/features/pricing/lib/dynamic-price'
import type { BillingUsageSchema } from '@/features/pricing/types'
import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar' import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
import { formatBillingCurrencyFromUSD } from '@/lib/currency' import { formatBillingCurrencyFromUSD } from '@/lib/currency'
import { formatLogQuota, formatTimestampToDate } from '@/lib/format' import { formatLogQuota, formatTimestampToDate } from '@/lib/format'
...@@ -44,6 +54,7 @@ import { LOG_TYPE_ALL_VALUE } from '../../constants' ...@@ -44,6 +54,7 @@ import { LOG_TYPE_ALL_VALUE } from '../../constants'
import type { UsageLog } from '../../data/schema' import type { UsageLog } from '../../data/schema'
import { import {
formatModelName, formatModelName,
decodeBillingExprB64,
getTieredBillingSummary, getTieredBillingSummary,
hasAnyCacheTokens, hasAnyCacheTokens,
parseLogOther, parseLogOther,
...@@ -98,9 +109,10 @@ function buildDetailSegments( ...@@ -98,9 +109,10 @@ function buildDetailSegments(
log: UsageLog, log: UsageLog,
other: LogOtherData | null, other: LogOtherData | null,
t: (key: string, opts?: Record<string, unknown>) => string, t: (key: string, opts?: Record<string, unknown>) => string,
isAdmin: boolean isAdmin: boolean,
usageSchema?: BillingUsageSchema
): DetailSegment[] { ): DetailSegment[] {
const segments = buildTypeDetailSegments(log, other, t) const segments = buildTypeDetailSegments(log, other, t, usageSchema)
const adminSegments: DetailSegment[] = [] const adminSegments: DetailSegment[] = []
// Quota saturation is a rare, admin-only anomaly marker; surface it first // Quota saturation is a rare, admin-only anomaly marker; surface it first
// and in danger styling so it stands out on the related billing log. The // and in danger styling so it stands out on the related billing log. The
...@@ -109,20 +121,14 @@ function buildDetailSegments( ...@@ -109,20 +121,14 @@ function buildDetailSegments(
if (isAdmin && other?.admin_info?.quota_saturation) { if (isAdmin && other?.admin_info?.quota_saturation) {
adminSegments.push({ text: t('Quota clamped'), danger: true }) adminSegments.push({ text: t('Quota clamped'), danger: true })
} }
const plugin = isAdmin ? other?.admin_info?.task_plugin : undefined
if (plugin) {
const version = plugin.version ? ` @ ${plugin.version}` : ''
adminSegments.push({
text: `${t('Plugin')}: ${plugin.name || plugin.key}${version}`,
})
}
return [...adminSegments, ...segments] return [...adminSegments, ...segments]
} }
function buildTypeDetailSegments( function buildTypeDetailSegments(
log: UsageLog, log: UsageLog,
other: LogOtherData | null, other: LogOtherData | null,
t: (key: string, opts?: Record<string, unknown>) => string t: (key: string, opts?: Record<string, unknown>) => string,
usageSchema?: BillingUsageSchema
): DetailSegment[] { ): DetailSegment[] {
// Top-up, audit, and login logs can carry a localized operation descriptor. // Top-up, audit, and login logs can carry a localized operation descriptor.
if (log.type === 1 || log.type === 3 || log.type === 7) { if (log.type === 1 || log.type === 3 || log.type === 7) {
...@@ -168,7 +174,39 @@ function buildTypeDetailSegments( ...@@ -168,7 +174,39 @@ function buildTypeDetailSegments(
} }
const isTieredExpr = other.billing_mode === 'tiered_expr' const isTieredExpr = other.billing_mode === 'tiered_expr'
const tieredSummary = getTieredBillingSummary(other) const tieredSummary = getTieredBillingSummary(other)
if (isTieredExpr) { if (isTieredExpr && other.is_task) {
const tiers = parseTaskTiersFromExpr(
decodeBillingExprB64(other.expr_b64),
usageSchema,
true
)
const tier = tiers.find(
(entry) =>
Boolean(other.matched_tier) &&
normalizeTierLabel(entry.label) ===
normalizeTierLabel(other.matched_tier)
)
if (tier) {
const prices = Object.entries(tier.unitPrices).map(([field, price]) => {
const unit = usageSchema?.[field]?.unit
const unitKey = getTaskUsagePriceUnitLabelKey(unit)
return `${field} ${formatTaskUsageUnitPrice(price, { tokenUnit: 'M' })}/${t(unitKey)}`
})
if (tier.constant > 0) {
prices.push(
`${t('Additional charge')} ${formatTaskUsageUnitPrice(tier.constant, { tokenUnit: 'M' })}/${t('request')}`
)
}
segments.push({
text: `${tier.label || t('Default')} · ${prices.join(' · ')}`,
})
} else {
segments.push({
text: `${t('Dynamic Pricing')} · ${t('No matching results')}`,
muted: true,
})
}
} else if (isTieredExpr) {
if (tieredSummary) { if (tieredSummary) {
const baseEntries = tieredSummary.priceEntries const baseEntries = tieredSummary.priceEntries
.filter((entry) => ['inputPrice', 'outputPrice'].includes(entry.field)) .filter((entry) => ['inputPrice', 'outputPrice'].includes(entry.field))
...@@ -743,7 +781,21 @@ export function useCommonLogsColumns( ...@@ -743,7 +781,21 @@ export function useCommonLogsColumns(
const log = row.original const log = row.original
const other = parseLogOther(log.other) const other = parseLogOther(log.other)
const segments = buildDetailSegments(log, other, t, isAdmin) const pricingData = usePricingData(
log.type === 2 &&
other?.is_task === true &&
other.billing_mode === 'tiered_expr'
)
const usageSchema = pricingData.models.find(
(model) => model.model_name === log.model_name
)?.billing_usage_schema
const segments = buildDetailSegments(
log,
other,
t,
isAdmin,
usageSchema
)
const primary = segments[0] const primary = segments[0]
const hasMore = segments.length > 1 const hasMore = segments.length > 1
let primaryTextClass = 'text-foreground' let primaryTextClass = 'text-foreground'
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment