Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
phsl
/
new-api
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Commit
0e0ba152
authored
Sep 08, 2026
by
CaIon
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(pricing): improve pricing editors and log display
parent
eb76b136
Hide whitespace changes
Inline
Side-by-side
Showing
29 changed files
with
1979 additions
and
380 deletions
+1979
-380
web/src/features/model-pricing/__tests__/editor-layout.test.tsx
+263
-0
web/src/features/model-pricing/model-pricing-panel.tsx
+3
-2
web/src/features/models/__tests__/metadata-editing.test.tsx
+165
-1
web/src/features/models/components/data-table-row-actions.tsx
+15
-0
web/src/features/models/components/drawers/model-mutate-drawer.tsx
+71
-40
web/src/features/models/components/models-columns.tsx
+1
-1
web/src/features/models/components/models-dialogs.tsx
+6
-1
web/src/features/models/components/models-provider.tsx
+1
-0
web/src/features/pricing/__tests__/dynamic-price.test.ts
+4
-1
web/src/features/pricing/__tests__/task-price-display.test.tsx
+270
-0
web/src/features/pricing/components/dynamic-pricing-breakdown.tsx
+102
-60
web/src/features/pricing/components/model-card.tsx
+10
-8
web/src/features/pricing/components/model-details.tsx
+87
-30
web/src/features/pricing/lib/billing-expr.ts
+19
-7
web/src/features/pricing/lib/dynamic-price.ts
+4
-4
web/src/features/pricing/lib/task-matrix-display.ts
+51
-1
web/src/features/pricing/lib/task-price-display.ts
+89
-0
web/src/features/pricing/types.ts
+1
-0
web/src/features/system-settings/components/settings-form-layout.tsx
+15
-2
web/src/features/system-settings/models/__tests__/task-pricing-copy.test.tsx
+120
-0
web/src/features/system-settings/models/model-pricing-inputs.tsx
+22
-9
web/src/features/system-settings/models/model-pricing-sheet.tsx
+123
-71
web/src/features/system-settings/models/model-ratio-form.tsx
+68
-85
web/src/features/system-settings/models/model-ratio-visual-editor.tsx
+6
-2
web/src/features/system-settings/models/ratio-settings-card.tsx
+17
-2
web/src/features/system-settings/models/task-pricing-matrix.tsx
+53
-12
web/src/features/system-settings/models/task-usage-pricing-editor.tsx
+52
-29
web/src/features/usage-logs/components/__tests__/detail-preview.test.tsx
+277
-0
web/src/features/usage-logs/components/columns/common-logs-columns.tsx
+64
-12
No files found.
web/src/features/model-pricing/__tests__/editor-layout.test.tsx
0 → 100644
View file @
0e0ba152
/*
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
)
}
)
web/src/features/model-pricing/model-pricing-panel.tsx
View file @
0e0ba152
...
...
@@ -121,9 +121,9 @@ export function ModelPricingPanel(props: {
}
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
>
<
div
className=
'min-w-0 flex-1 break-words'
>
<
p
className=
'text-muted-foreground text-xs'
>
{
Object
.
keys
(
entry
.
configured
).
length
?
t
(
'Stored configuration with effective defaults'
)
...
...
@@ -167,6 +167,7 @@ export function ModelPricingPanel(props: {
</
div
>
)
}
<
ModelPricingEditorPanel
embedded
ref=
{
editor
}
editData=
{
editData
}
usageSchema=
{
entry
.
usage_schema
}
...
...
web/src/features/models/__tests__/metadata-editing.test.tsx
View file @
0e0ba152
...
...
@@ -17,7 +17,8 @@ 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
{
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
{
AxiosError
}
from
'axios'
import
{
afterEach
,
describe
,
expect
,
it
,
vi
}
from
'vitest'
...
...
@@ -26,8 +27,11 @@ import { pricingOptions } from '@/features/model-pricing/pricing'
import
{
api
}
from
'@/lib/api'
import
{
useAuthStore
}
from
'@/stores/auth-store'
import
{
DataTableRowActions
}
from
'../components/data-table-row-actions'
import
{
ModelMutateDrawer
}
from
'../components/drawers/model-mutate-drawer'
import
{
ModelsDialogs
}
from
'../components/models-dialogs'
import
{
ModelsProvider
}
from
'../components/models-provider'
import
type
{
Model
}
from
'../types'
const
model
=
{
id
:
7
,
...
...
@@ -48,6 +52,166 @@ afterEach(() => {
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'
,
()
=>
{
it
.
each
([
{
...
...
web/src/features/models/components/data-table-row-actions.tsx
View file @
0e0ba152
...
...
@@ -28,6 +28,7 @@ import {
DropdownMenuItem
,
DropdownMenuShortcut
,
}
from
'@/components/ui/dropdown-menu'
import
{
useCanEditModelPricing
}
from
'@/features/model-pricing/api'
import
{
handleToggleModelStatus
,
isModelEnabled
}
from
'../lib'
import
type
{
Model
}
from
'../types'
...
...
@@ -40,6 +41,7 @@ interface DataTableRowActionsProps {
export
function
DataTableRowActions
({
row
}:
DataTableRowActionsProps
)
{
const
{
t
}
=
useTranslation
()
const
canPrice
=
useCanEditModelPricing
()
const
model
=
row
.
original
const
{
setOpen
,
setCurrentRow
}
=
useModels
()
const
queryClient
=
useQueryClient
()
...
...
@@ -66,6 +68,19 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
{
t
(
'Edit'
)
}
</
Button
>
{
canPrice
&&
(
<
Button
variant=
'ghost'
size=
'sm'
onClick=
{
()
=>
{
setCurrentRow
(
model
)
setOpen
(
'price-model'
)
}
}
>
{
t
(
'Pricing'
)
}
</
Button
>
)
}
<
DataTableRowActionMenu
ariaLabel=
{
t
(
'Open menu'
)
}
>
<
DropdownMenuItem
onClick=
{
handleToggleStatus
}
>
{
toggleLabel
}
...
...
web/src/features/models/components/drawers/model-mutate-drawer.tsx
View file @
0e0ba152
...
...
@@ -16,7 +16,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import
{
Combobox
}
from
'@/components/ui/combobox'
import
{
zodResolver
}
from
'@hookform/resolvers/zod'
import
{
useMutation
,
useQuery
,
useQueryClient
}
from
'@tanstack/react-query'
import
{
AxiosError
}
from
'axios'
...
...
@@ -41,6 +40,7 @@ import { LobeIconField } from '@/components/lobe-icon-field'
import
{
TagInput
}
from
'@/components/tag-input'
import
{
Alert
,
AlertDescription
}
from
'@/components/ui/alert'
import
{
Button
}
from
'@/components/ui/button'
import
{
Combobox
}
from
'@/components/ui/combobox'
import
{
Form
,
FormControl
,
...
...
@@ -53,7 +53,6 @@ import {
import
{
Input
}
from
'@/components/ui/input'
import
{
Label
}
from
'@/components/ui/label'
import
{
RadioGroup
,
RadioGroupItem
}
from
'@/components/ui/radio-group'
import
{
Sheet
,
SheetContent
,
...
...
@@ -83,14 +82,19 @@ export function ModelMutateDrawer(props: {
open
:
boolean
onOpenChange
:
(
open
:
boolean
)
=>
void
currentRow
?:
Model
|
null
initialSection
?:
'metadata'
|
'pricing'
})
{
const
{
t
}
=
useTranslation
()
const
queryClient
=
useQueryClient
()
const
currentRow
=
props
.
currentRow
const
isEditing
=
Boolean
(
currentRow
?.
id
)
const
[
section
,
setSection
]
=
useState
(
'metadata'
)
const
[
section
,
setSection
]
=
useState
<
string
>
(
props
.
initialSection
??
'metadata'
)
const
[
pricingName
,
setPricingName
]
=
useState
(
''
)
const
[
pricingVisited
,
setPricingVisited
]
=
useState
(
false
)
const
[
pricingVisited
,
setPricingVisited
]
=
useState
(
props
.
initialSection
===
'pricing'
)
const
[
pricingDirty
,
setPricingDirty
]
=
useState
(
false
)
const
[
pendingPricingName
,
setPendingPricingName
]
=
useState
<
string
|
null
>
(
null
...
...
@@ -130,6 +134,16 @@ export function ModelMutateDrawer(props: {
const
savedModel
=
modelQuery
.
data
??
currentRow
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
)
{
loadedKey
.
current
=
''
return
...
...
@@ -149,10 +163,6 @@ export function ModelMutateDrawer(props: {
)
)
loadedKey
.
current
=
key
setSection
(
'metadata'
)
setPricingName
(
''
)
setPricingVisited
(
false
)
setPricingDirty
(
false
)
},
[
props
.
open
,
currentRow
,
isEditing
,
modelQuery
.
data
,
form
])
const
save
=
useMutation
({
...
...
@@ -220,7 +230,9 @@ export function ModelMutateDrawer(props: {
return
(
<>
<
Sheet
open=
{
props
.
open
}
onOpenChange=
{
close
}
>
<
SheetContent
className=
{
sideDrawerContentClassName
(
'sm:max-w-3xl'
)
}
>
<
SheetContent
className=
{
sideDrawerContentClassName
(
'sm:max-w-[1280px]'
)
}
>
<
SheetHeader
className=
{
sideDrawerHeaderClassName
()
}
>
<
SheetTitle
className=
'pr-6 break-all'
>
{
isEditing
?
currentRow
?.
model_name
:
t
(
'Create Model'
)
}
...
...
@@ -239,12 +251,25 @@ export function ModelMutateDrawer(props: {
}
}
className=
'shrink-0 px-4'
>
<
TabsList
className=
'w-full'
>
<
TabsTrigger
value=
'metadata'
>
{
t
(
'Model metadata'
)
}
</
TabsTrigger
>
<
TabsTrigger
value=
'pricing'
disabled=
{
!
isEditing
}
>
<
TabsList
className=
'grid w-full grid-cols-3 group-data-horizontal/tabs:h-auto'
>
<
TabsTrigger
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'
)
}
</
TabsTrigger
>
<
TabsTrigger
value=
'connections'
disabled=
{
!
isEditing
}
>
<
TabsTrigger
value=
'connections'
disabled=
{
!
isEditing
}
className=
'h-auto min-w-0 whitespace-normal'
>
{
t
(
'Channels and groups'
)
}
</
TabsTrigger
>
</
TabsList
>
...
...
@@ -347,20 +372,22 @@ export function ModelMutateDrawer(props: {
render=
{
({
field
})
=>
(
<
FormItem
>
<
FormLabel
>
{
t
(
'Vendor'
)
}
</
FormLabel
>
<
FormControl
><
Combobox
options=
{
vendors
.
map
((
vendor
)
=>
({
value
:
String
(
vendor
.
id
),
label
:
vendor
.
name
,
}))
}
onValueChange=
{
(
value
)
=>
field
.
onChange
(
value
?
Number
.
parseInt
(
value
)
:
undefined
)
}
value=
{
field
.
value
?
String
(
field
.
value
)
:
null
}
className=
'w-full'
placeholder=
{
t
(
'Select vendor'
)
}
/></
FormControl
>
<
FormControl
>
<
Combobox
options=
{
vendors
.
map
((
vendor
)
=>
({
value
:
String
(
vendor
.
id
),
label
:
vendor
.
name
,
}))
}
onValueChange=
{
(
value
)
=>
field
.
onChange
(
value
?
Number
.
parseInt
(
value
)
:
undefined
)
}
value=
{
field
.
value
?
String
(
field
.
value
)
:
null
}
className=
'w-full'
placeholder=
{
t
(
'Select vendor'
)
}
/>
</
FormControl
>
<
FormMessage
/>
</
FormItem
>
)
}
...
...
@@ -445,12 +472,16 @@ placeholder={t('Select vendor')}
{
t
(
'Endpoints'
)
}
</
h3
>
<
Combobox
options=
{
Object
.
keys
(
ENDPOINT_TEMPLATES
).
map
((
key
)
=>
({
value
:
key
,
label
:
key
}))
}
onValueChange=
{
(
value
:
string
|
null
)
=>
{
if
(
value
)
handleFillEndpointTemplate
(
value
)
}
}
className=
'w-[200px]'
placeholder=
{
t
(
'Load template...'
)
}
aria
-
label=
{
t
(
'Load template...'
)
}
/>
options=
{
Object
.
keys
(
ENDPOINT_TEMPLATES
).
map
(
(
key
)
=>
({
value
:
key
,
label
:
key
})
)
}
onValueChange=
{
(
value
:
string
|
null
)
=>
{
if
(
value
)
handleFillEndpointTemplate
(
value
)
}
}
className=
'w-[200px]'
placeholder=
{
t
(
'Load template...'
)
}
aria
-
label=
{
t
(
'Load template...'
)
}
/>
</
div
>
<
FormField
...
...
@@ -591,8 +622,8 @@ placeholder={t('Select vendor')}
)
}
</
p
>
<
Combobox
value=
{
pricingName
}
onValueChange=
{
(
value
)
=>
{
value=
{
pricingName
}
onValueChange=
{
(
value
)
=>
{
if
(
pricingDirty
)
{
setPendingPricingName
(
value
??
''
)
setCloseConfirm
(
true
)
...
...
@@ -600,14 +631,14 @@ onValueChange={(value) => {
setPricingName
(
value
??
''
)
}
}
}
options=
{
(
savedModel
.
matched_models
??
[]).
map
((
name
)
=>
({
options=
{
(
savedModel
.
matched_models
??
[]).
map
((
name
)
=>
({
value
:
name
,
label
:
name
,
}))
}
aria
-
label=
{
t
(
'Select model'
)
}
className=
'w-full'
placeholder=
{
t
(
'Select model'
)
}
/>
aria
-
label=
{
t
(
'Select model'
)
}
className=
'w-full'
placeholder=
{
t
(
'Select model'
)
}
/>
</
div
>
)
}
{
(
savedModel
.
name_rule
===
0
||
pricingName
)
&&
(
...
...
web/src/features/models/components/models-columns.tsx
View file @
0e0ba152
...
...
@@ -265,7 +265,7 @@ export function useModelsColumns(
header
:
t
(
'Actions'
),
enableSorting
:
false
,
enableHiding
:
false
,
size
:
105
,
size
:
canPrice
?
170
:
105
,
cell
:
({
row
})
=>
<
DataTableRowActions
row=
{
row
}
/>,
},
{
...
...
web/src/features/models/components/models-dialogs.tsx
View file @
0e0ba152
...
...
@@ -43,7 +43,12 @@ export function ModelsDialogs() {
/>
{
/* Model Create/Update Drawer */
}
<
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
)
}
currentRow=
{
currentRow
}
/>
...
...
web/src/features/models/components/models-provider.tsx
View file @
0e0ba152
...
...
@@ -34,6 +34,7 @@ import type {
type
DialogType
=
|
'create-model'
|
'update-model'
|
'price-model'
|
'create-vendor'
|
'vendors'
|
'price-sync'
...
...
web/src/features/pricing/__tests__/dynamic-price.test.ts
View file @
0e0ba152
...
...
@@ -319,7 +319,10 @@ describe('task dynamic pricing', () => {
assert
.
ok
(
tokenSummary
)
assert
.
equal
(
tokenSummary
.
primaryEntries
[
0
]?.
shortLabel
,
'tokens'
)
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'
)
const
multiFieldModel
=
pricingModel
({
...
...
web/src/features/pricing/__tests__/task-price-display.test.tsx
0 → 100644
View file @
0e0ba152
/*
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
)
})
web/src/features/pricing/components/dynamic-pricing-breakdown.tsx
View file @
0e0ba152
...
...
@@ -34,7 +34,6 @@ import {
MATCH_LT
,
MATCH_RANGE
,
SOURCE_TIME
,
parseTaskTiersFromExpr
,
parseTiersFromExpr
,
requestRuleGroupsFromTrace
,
splitBillingExprAndRequestRules
,
...
...
@@ -47,8 +46,16 @@ import {
type
TierCondition
,
}
from
'../lib/billing-expr'
import
{
isBreakdownTierMatched
}
from
'../lib/breakdown-tier-match'
import
type
{
DynamicPriceLabelKind
}
from
'../lib/dynamic-price'
import
{
getTaskMatrixDisplayTiers
}
from
'../lib/task-matrix-display'
import
{
formatTaskUsageUnitPrice
,
type
DynamicPriceLabelKind
,
type
DynamicPriceOptions
,
}
from
'../lib/dynamic-price'
import
{
getTaskPricingDisplayTiers
}
from
'../lib/task-matrix-display'
import
{
taskPriceLabel
,
taskPricingConditions
,
}
from
'../lib/task-price-display'
import
type
{
BillingUsageSchema
,
BillingUsageUnit
}
from
'../types'
type
DynamicPricingBreakdownProps
=
{
...
...
@@ -74,6 +81,10 @@ type DynamicPricingBreakdownProps = {
*/
compact
?:
boolean
usageSchema
?:
BillingUsageSchema
taskPriceOptions
?:
Pick
<
DynamicPriceOptions
,
'showRechargePrice'
|
'priceRate'
|
'usdExchangeRate'
>
/**
* Settlement usage facts from the consume log. Used to highlight the
* expanded matrix display row when the engine label no longer matches
...
...
@@ -97,7 +108,7 @@ function breakdownPriceFieldLabel(
t
:
(
key
:
string
)
=>
string
):
ReactNode
{
if
(
field
.
labelKind
===
'schema'
)
{
return
<
code
className=
'font-mono'
>
{
field
.
label
}
</
code
>
return
<
span
className=
'break-words whitespace-normal'
>
{
field
.
label
}
</
span
>
}
return
t
(
field
.
label
)
}
...
...
@@ -153,14 +164,18 @@ function isTaskBreakdownTier(tier: BreakdownTier): tier is ParsedTaskTier {
function
formatBreakdownConditionSummary
(
tier
:
BreakdownTier
,
t
:
(
key
:
string
)
=>
string
t
:
(
key
:
string
)
=>
string
,
schema
:
BillingUsageSchema
|
undefined
,
language
:
string
,
tierCount
:
number
):
string
{
if
(
!
isTaskBreakdownTier
(
tier
))
{
return
formatConditionSummary
(
tier
.
conditions
,
t
)
}
return
tier
.
conditions
.
map
((
condition
)
=>
`
${
condition
.
field
}
=
${
condition
.
value
}
`
)
.
join
(
' && '
)
return
(
taskPricingConditions
(
tier
.
conditions
,
schema
,
language
,
t
)
||
t
(
tierCount
>
1
?
'Other cases'
:
'All requests'
)
)
}
function
formatBreakdownPrice
(
...
...
@@ -168,16 +183,17 @@ function formatBreakdownPrice(
field
:
BreakdownPriceField
,
symbol
:
string
,
rate
:
number
,
t
:
(
key
:
string
)
=>
string
t
:
(
key
:
string
)
=>
string
,
taskPriceOptions
:
DynamicPricingBreakdownProps
[
'taskPriceOptions'
]
):
string
{
const
amount
=
`
${
symbol
}${(
value
*
rate
).
toFixed
(
4
)}
`
const
amount
=
field
.
labelKind
===
'schema'
||
field
.
unit
===
'request'
?
formatTaskUsageUnitPrice
(
value
,
{
tokenUnit
:
'M'
,
...
taskPriceOptions
})
:
`
${
symbol
}${(
value
*
rate
).
toFixed
(
4
)}
`
if
(
field
.
unit
===
'second'
)
return
`
${
amount
}
/
${
t
(
's'
)}
`
if
(
field
.
unit
===
'count'
)
return
`
${
amount
}
/
${
t
(
'unit'
)}
`
if
(
field
.
unit
===
'credit'
)
return
`
${
amount
}
/
${
t
(
'credit'
)}
`
if
(
field
.
unit
===
'token'
&&
!
BILLING_PRICING_VARS
.
some
((
variable
)
=>
variable
.
field
===
field
.
id
)
)
{
if
(
field
.
unit
===
'token'
&&
field
.
labelKind
===
'schema'
)
{
return
`
${
amount
}
/
${
t
(
'1M token'
)}
`
}
if
(
field
.
unit
===
'request'
)
return
`
${
amount
}
/
${
t
(
'request'
)}
`
...
...
@@ -243,9 +259,10 @@ export function DynamicPricingBreakdown({
hideCacheColumns
=
false
,
compact
=
false
,
usageSchema
,
taskPriceOptions
,
usageFacts
,
}:
DynamicPricingBreakdownProps
)
{
const
{
t
}
=
useTranslation
()
const
{
t
,
i18n
}
=
useTranslation
()
const
expr
=
billingExpr
||
''
const
currency
=
useSystemConfigStore
((
s
)
=>
s
.
config
.
currency
)
...
...
@@ -264,18 +281,9 @@ export function DynamicPricingBreakdown({
const
{
tiers
,
ruleGroups
}
=
useMemo
(()
=>
{
const
split
=
splitBillingExprAndRequestRules
(
expr
)
const
matrixTiers
=
getTaskMatrixDisplayTiers
(
split
.
billingExpr
,
usageSchema
)
let
parsedTiers
if
(
matrixTiers
)
{
parsedTiers
=
matrixTiers
}
else
if
(
usageSchema
)
{
parsedTiers
=
parseTaskTiersFromExpr
(
split
.
billingExpr
,
usageSchema
)
}
else
{
parsedTiers
=
parseTiersFromExpr
(
split
.
billingExpr
)
}
const
parsedTiers
=
usageSchema
?
getTaskPricingDisplayTiers
(
split
.
billingExpr
,
usageSchema
)
:
parseTiersFromExpr
(
split
.
billingExpr
)
const
parsedRules
=
requestRules
!=
null
?
requestRuleGroupsFromTrace
(
requestRules
)
...
...
@@ -336,7 +344,7 @@ export function DynamicPricingBreakdown({
.
sort
(([
left
],
[
right
])
=>
left
.
localeCompare
(
right
))
.
map
(([
field
,
definition
])
=>
({
id
:
field
,
label
:
field
,
label
:
taskPriceLabel
(
definition
.
description
,
field
,
i18n
.
language
)
,
labelKind
:
'schema'
as
const
,
unit
:
definition
.
unit
as
BillingUsageUnit
,
value
:
(
tier
:
BreakdownTier
)
=>
...
...
@@ -347,7 +355,7 @@ export function DynamicPricingBreakdown({
)
{
fields
.
push
({
id
:
'constant'
,
label
:
'
Base
charge'
,
label
:
'
Additional
charge'
,
labelKind
:
'i18n'
,
unit
:
'request'
,
value
:
(
tier
:
BreakdownTier
)
=>
...
...
@@ -379,7 +387,7 @@ export function DynamicPricingBreakdown({
return
(
<
section
className=
{
cn
(
'min-w-0'
,
!
compact
&&
'py-3 sm:py-4'
)
}
>
{
!
compact
&&
(
{
!
compact
&&
!
usageSchema
&&
(
<
div
className=
'mb-3 flex items-start gap-2 sm:mb-4'
>
<
span
className=
'mt-0.5 inline-flex size-6 items-center justify-center rounded-lg bg-amber-100 text-amber-700 shadow-sm dark:bg-amber-500/20 dark:text-amber-300'
>
<
TagIcon
className=
'size-3.5'
/>
...
...
@@ -397,18 +405,26 @@ export function DynamicPricingBreakdown({
{
hasTiers
&&
(
<
div
className=
{
cn
(
compact
?
cn
(
hasRules
&&
'mb-2'
)
:
'mb-3 sm:mb-4'
)
}
>
<
div
className=
{
compact
?
'text-muted-foreground mb-1.5 text-xs font-medium'
:
'text-foreground mb-2 text-sm font-semibold'
}
>
{
t
(
'Tiered price table'
)
}
</
div
>
{
!
usageSchema
&&
(
<
div
className=
{
compact
?
'text-muted-foreground mb-1.5 text-xs font-medium'
:
'text-foreground mb-2 text-sm font-semibold'
}
>
{
t
(
'Tiered price table'
)
}
</
div
>
)
}
<
div
className=
'space-y-1.5 sm:hidden'
>
{
tiers
.
map
((
tier
)
=>
{
const
condSummary
=
formatBreakdownConditionSummary
(
tier
,
t
)
const
condSummary
=
formatBreakdownConditionSummary
(
tier
,
t
,
usageSchema
,
i18n
.
language
,
tiers
.
length
)
const
isMatched
=
isBreakdownTierMatched
(
tier
,
tiers
,
...
...
@@ -428,12 +444,14 @@ export function DynamicPricingBreakdown({
)
}
>
<
div
className=
'mb-1.5 flex flex-wrap items-center gap-1.5'
>
<
Badge
variant=
'secondary'
className=
'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
>
{
tier
.
label
||
t
(
'Default'
)
}
</
Badge
>
{
!
usageSchema
&&
(
<
Badge
variant=
'secondary'
className=
'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
>
{
tier
.
label
||
t
(
'Default'
)
}
</
Badge
>
)
}
{
isMatched
&&
(
<
Badge
variant=
'secondary'
...
...
@@ -448,17 +466,22 @@ export function DynamicPricingBreakdown({
{
condSummary
}
</
div
>
)
}
<
div
className=
'grid grid-cols-2 gap-x-3 gap-y-1.5'
>
<
div
className=
{
cn
(
'grid gap-x-3 gap-y-1.5'
,
visiblePriceFields
.
length
>
1
&&
'grid-cols-2'
)
}
>
{
visiblePriceFields
.
map
((
field
)
=>
{
const
value
=
field
.
value
(
tier
)
return
(
<
div
key=
{
field
.
id
}
className=
'min-w-0'
>
<
div
className=
'text-muted-foreground t
runcate text-[10px] font-medium tracking-wider uppercase
'
>
<
div
className=
'text-muted-foreground t
ext-xs font-medium break-words whitespace-normal
'
>
{
breakdownPriceFieldLabel
(
field
,
t
)
}
</
div
>
<
div
className=
{
cn
(
'
truncate
font-mono'
,
'
break-words
font-mono'
,
compact
?
'text-xs'
:
'text-sm font-semibold'
)
}
>
...
...
@@ -468,7 +491,8 @@ export function DynamicPricingBreakdown({
field
,
symbol
,
rate
,
t
t
,
taskPriceOptions
)
:
'-'
}
</
div
>
...
...
@@ -505,14 +529,23 @@ export function DynamicPricingBreakdown({
columns=
{
[
{
id
:
'tier'
,
header
:
t
(
'Tier'
),
header
:
usageSchema
?
t
(
'Applicable conditions'
)
:
t
(
'Tier'
),
className
:
cn
(
'text-muted-foreground py-2 font-medium'
,
compact
&&
'h-8'
),
cellClassName
:
cn
(
'align-top'
,
compact
?
'py-2'
:
'py-2.5'
),
cellClassName
:
cn
(
'align-top whitespace-normal break-words'
,
compact
?
'py-2'
:
'py-2.5'
),
cell
:
(
tier
)
=>
{
const
condSummary
=
formatBreakdownConditionSummary
(
tier
,
t
)
const
condSummary
=
formatBreakdownConditionSummary
(
tier
,
t
,
usageSchema
,
i18n
.
language
,
tiers
.
length
)
const
isMatched
=
isBreakdownTierMatched
(
tier
,
tiers
,
...
...
@@ -522,12 +555,14 @@ export function DynamicPricingBreakdown({
return
(
<>
<
div
className=
'flex flex-wrap items-center gap-1.5'
>
<
Badge
variant=
'secondary'
className=
'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
>
{
tier
.
label
||
t
(
'Default'
)
}
</
Badge
>
{
!
usageSchema
&&
(
<
Badge
variant=
'secondary'
className=
'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
>
{
tier
.
label
||
t
(
'Default'
)
}
</
Badge
>
)
}
{
isMatched
&&
(
<
Badge
variant=
'secondary'
...
...
@@ -561,7 +596,14 @@ export function DynamicPricingBreakdown({
const
value
=
field
.
value
(
tier
)
return
value
>
0
?
(
<
span
className=
{
cn
(
!
compact
&&
'font-semibold'
)
}
>
{
formatBreakdownPrice
(
value
,
field
,
symbol
,
rate
,
t
)
}
{
formatBreakdownPrice
(
value
,
field
,
symbol
,
rate
,
t
,
taskPriceOptions
)
}
</
span
>
)
:
(
'-'
...
...
web/src/features/pricing/components/model-card.tsx
View file @
0e0ba152
...
...
@@ -37,7 +37,7 @@ import {
import
{
parseTags
}
from
'../lib/filters'
import
{
isTokenBasedModel
}
from
'../lib/model-helpers'
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
{
ModelBillingModeBadge
}
from
'./model-billing-mode-badge'
import
{
ModelPerfBadge
,
type
ModelPerfBadgeData
}
from
'./model-perf-badge'
...
...
@@ -54,7 +54,7 @@ export interface ModelCardProps {
}
export
const
ModelCard
=
memo
(
function
ModelCard
(
props
:
ModelCardProps
)
{
const
{
t
}
=
useTranslation
()
const
{
t
,
i18n
}
=
useTranslation
()
const
tokenUnit
=
props
.
tokenUnit
??
DEFAULT_TOKEN_UNIT
const
priceRate
=
props
.
priceRate
??
1
const
usdExchangeRate
=
props
.
usdExchangeRate
??
1
...
...
@@ -85,8 +85,6 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
?
getDynamicPricingSummary
(
props
.
model
,
dynamicPriceOptions
)
:
null
const
cardExamplePrice
=
getCardExamplePrice
(
props
.
model
,
dynamicPriceOptions
)
const
showTaskFieldLabels
=
getTaskNumberFields
(
props
.
model
.
billing_usage_schema
).
length
>
1
let
priceSummary
:
ReactNode
if
(
dynamicSummary
)
{
if
(
dynamicSummary
.
isSpecialExpression
)
{
...
...
@@ -108,9 +106,11 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
let
label
:
ReactNode
=
null
if
(
entry
.
labelKind
!==
'schema'
)
{
label
=
t
(
entry
.
shortLabel
)
}
else
if
(
showTaskFieldLabels
)
{
label
=
(
<
code
className=
'font-mono break-all'
>
{
entry
.
shortLabel
}
</
code
>
}
else
{
label
=
taskPriceLabel
(
entry
.
description
,
entry
.
shortLabel
,
i18n
.
language
)
}
return
(
...
...
@@ -122,7 +122,9 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
)
}
>
{
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
>
{
entry
.
formattedRange
??
entry
.
formatted
}
</
span
>
...
...
web/src/features/pricing/components/model-details.tsx
View file @
0e0ba152
...
...
@@ -60,6 +60,7 @@ import { cn } from '@/lib/utils'
import
{
DEFAULT_TOKEN_UNIT
}
from
'../constants'
import
{
usePricingData
}
from
'../hooks/use-pricing-data'
import
type
{
ParsedTaskTier
}
from
'../lib/billing-expr'
import
{
formatTaskUsageUnitPrice
,
getDynamicPriceEntries
,
...
...
@@ -79,7 +80,12 @@ import {
getTaskEnumFields
,
getTaskNumberFields
,
}
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
{
ModelCapability
,
PriceType
,
...
...
@@ -104,9 +110,17 @@ function SectionTitle(props: { children: React.ReactNode }) {
}
function
DynamicPriceEntryLabel
(
props
:
{
entry
:
DynamicPriceEntry
})
{
const
{
t
}
=
useTranslation
()
const
{
t
,
i18n
}
=
useTranslation
()
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
)
}
...
...
@@ -718,7 +732,12 @@ function PriceSection(props: {
<
section
>
<
SectionTitle
>
{
t
(
'Base Price'
)
}
</
SectionTitle
>
{
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
)
=>
{
const
unitLabelKey
=
getDynamicPriceUnitLabelKey
(
entry
)
return
(
...
...
@@ -939,7 +958,7 @@ function GroupPricingSection(props: {
tokenUnit
:
TokenUnit
showRechargePrice
?:
boolean
})
{
const
{
t
}
=
useTranslation
()
const
{
t
,
i18n
}
=
useTranslation
()
const
showRechargePrice
=
props
.
showRechargePrice
??
false
const
availableGroups
=
useMemo
(
...
...
@@ -987,15 +1006,18 @@ function GroupPricingSection(props: {
)
}
const
thClass
=
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'
const
thClass
=
cn
(
'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
))
{
const
dynamicTiers
=
getTaskMatrixDisplayTiers
(
props
.
model
.
billing_expr
,
props
.
model
.
billing_usage_schema
)
??
getDynamicPricingTiers
(
props
.
model
)
const
dynamicTiers
=
props
.
model
.
billing_usage_schema
?
getTaskPricingDisplayTiers
(
props
.
model
.
billing_expr
,
props
.
model
.
billing_usage_schema
)
:
getDynamicPricingTiers
(
props
.
model
)
if
(
dynamicTiers
.
length
===
0
)
{
return
(
...
...
@@ -1082,21 +1104,39 @@ function GroupPricingSection(props: {
`${group}-${tier.label || tierIndex}`
}
columns=
{
[
{
id
:
'tier'
,
header
:
t
(
'Tier'
),
className
:
thClass
,
cellClassName
:
'text-muted-foreground py-2.5'
,
cell
:
(
tier
)
=>
tier
.
label
||
t
(
'Default'
),
},
...(
hasSimpleTaskPricing
(
props
.
model
)
?
[]
:
[
{
id
:
'tier'
,
header
:
props
.
model
.
billing_usage_schema
?
t
(
'Applicable conditions'
)
:
t
(
'Tier'
),
className
:
thClass
,
cellClassName
:
'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
)
=>
{
const
unitLabelKey
=
getDynamicPriceUnitLabelKey
(
fieldEntry
)
const
fieldLabel
=
fieldEntry
.
labelKind
===
'schema'
?
(
<
code
className=
'font-mono'
>
{
fieldEntry
.
shortLabel
}
</
code
>
<
DynamicPriceEntryLabel
entry=
{
fieldEntry
}
/>
)
:
(
t
(
fieldEntry
.
shortLabel
)
)
...
...
@@ -1307,6 +1347,16 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
props
.
model
.
billing_mode
===
'tiered_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
(
<
div
className=
'@container/details space-y-4'
>
<
ModelHeader
model=
{
props
.
model
}
/>
...
...
@@ -1333,17 +1383,24 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
<
section
className=
'bg-card/60 space-y-5 rounded-xl border p-4 shadow-sm'
>
<
SectionTitle
>
{
t
(
'Pricing'
)
}
</
SectionTitle
>
<
PriceSection
model=
{
props
.
model
}
priceRate=
{
props
.
priceRate
}
usdExchangeRate=
{
props
.
usdExchangeRate
}
tokenUnit=
{
props
.
tokenUnit
}
showRechargePrice=
{
showRechargePrice
}
/>
{
isDynamic
&&
(
{
showBasePrices
&&
(
<
PriceSection
model=
{
props
.
model
}
priceRate=
{
props
.
priceRate
}
usdExchangeRate=
{
props
.
usdExchangeRate
}
tokenUnit=
{
props
.
tokenUnit
}
showRechargePrice=
{
showRechargePrice
}
/>
)
}
{
isDynamic
&&
!
simpleTaskPricing
&&
(
<
DynamicPricingBreakdown
billingExpr=
{
props
.
model
.
billing_expr
}
usageSchema=
{
props
.
model
.
billing_usage_schema
}
taskPriceOptions=
{
{
showRechargePrice
,
priceRate
:
props
.
priceRate
,
usdExchangeRate
:
props
.
usdExchangeRate
,
}
}
/>
)
}
<
GroupPricingSection
...
...
web/src/features/pricing/lib/billing-expr.ts
View file @
0e0ba152
...
...
@@ -462,24 +462,34 @@ function splitTaskTopLevel(expression: string, operator: '&&' | '+'): string[] {
function
parseTaskConditions
(
expression
:
string
,
schema
:
BillingUsageSchema
schema
:
BillingUsageSchema
,
includeBooleanConditions
:
boolean
):
TaskTierCondition
[]
|
null
{
const
conditions
:
TaskTierCondition
[]
=
[]
for
(
const
part
of
splitTaskTopLevel
(
expression
,
'&&'
))
{
const
match
=
part
.
match
(
/^u
\(\s
*
(
"
(?:[^
"
\\]
|
\\
.
)
*"
)\s
*
\)\s
*==
\s
*
(
"
(?:[^
"
\\]
|
\\
.
)
*"
)
$/
/^u
\(\s
*
(
"
(?:[^
"
\\]
|
\\
.
)
*"
)\s
*
\)\s
*==
\s
*
(
"
(?:[^
"
\\]
|
\\
.
)
*"
|true|false
)
$/
)
if
(
!
match
)
return
null
let
field
:
string
let
value
:
string
try
{
field
=
JSON
.
parse
(
match
[
1
])
as
string
value
=
JSON
.
parse
(
match
[
2
])
as
string
value
=
String
(
JSON
.
parse
(
match
[
2
]))
}
catch
{
return
null
}
const
declaredValues
=
schema
[
field
]?.
enum
if
(
!
declaredValues
?.
includes
(
value
))
return
null
const
definition
=
schema
[
field
]
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
})
}
return
conditions
.
length
>
0
?
conditions
:
null
...
...
@@ -554,7 +564,8 @@ function parseTaskTierCall(
export
function
parseTaskTiersFromExpr
(
exprStr
:
string
,
schema
:
BillingUsageSchema
|
null
|
undefined
schema
:
BillingUsageSchema
|
null
|
undefined
,
includeBooleanConditions
=
false
):
ParsedTaskTier
[]
{
if
(
!
exprStr
||
!
schema
||
Object
.
keys
(
schema
).
length
===
0
)
return
[]
try
{
...
...
@@ -576,7 +587,8 @@ export function parseTaskTiersFromExpr(
if
(
colonIndex
<
0
)
return
[]
const
conditions
=
parseTaskConditions
(
remaining
.
slice
(
0
,
questionIndex
).
trim
(),
schema
schema
,
includeBooleanConditions
)
if
(
!
conditions
)
return
[]
const
tier
=
parseTaskTierCall
(
...
...
web/src/features/pricing/lib/dynamic-price.ts
View file @
0e0ba152
...
...
@@ -42,7 +42,7 @@ import {
tryParseTaskVisualConfig
,
}
from
'./task-expr'
type
DynamicPriceOptions
=
{
export
type
DynamicPriceOptions
=
{
tokenUnit
:
TokenUnit
showRechargePrice
?:
boolean
priceRate
?:
number
...
...
@@ -225,7 +225,7 @@ export function getDynamicPricingTiers(
model
.
billing_expr
||
''
)
if
(
isTaskUsagePricingModel
(
model
))
{
return
parseTaskTiersFromExpr
(
billingExpr
,
model
.
billing_usage_schema
)
return
parseTaskTiersFromExpr
(
billingExpr
,
model
.
billing_usage_schema
,
true
)
}
return
parseTiersFromExpr
(
billingExpr
)
}
...
...
@@ -268,8 +268,8 @@ export function getDynamicPriceEntries(
usageEntries
.
push
({
key
:
'constant'
,
field
:
'constant'
,
label
:
'
Base
charge'
,
shortLabel
:
'
Bas
e'
,
label
:
'
Additional
charge'
,
shortLabel
:
'
Additional charg
e'
,
labelKind
:
'i18n'
,
value
:
tier
.
constant
,
formatted
:
formatTaskUsageUnitPrice
(
tier
.
constant
,
options
),
...
...
web/src/features/pricing/lib/task-matrix-display.ts
View file @
0e0ba152
...
...
@@ -17,7 +17,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import
type
{
BillingUsageSchema
}
from
'../types'
import
type
{
ParsedTaskTier
}
from
'./billing-expr'
import
{
parseTaskTiersFromExpr
,
type
ParsedTaskTier
,
type
TaskTierCondition
,
}
from
'./billing-expr'
import
{
getTaskEnumFields
,
taskMatrixRowLabel
,
...
...
@@ -49,3 +53,49 @@ export function getTaskMatrixDisplayTiers(
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
]
}]
}
web/src/features/pricing/lib/task-price-display.ts
0 → 100644
View file @
0e0ba152
/*
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
}
web/src/features/pricing/types.ts
View file @
0e0ba152
...
...
@@ -33,6 +33,7 @@ export type BillingUsageFieldSchema = {
type
?:
'number'
|
'boolean'
unit
?:
BillingUsageUnit
enum
?:
string
[]
enumLabels
?:
Record
<
string
,
string
|
Record
<
string
,
string
>>
description
?:
string
|
Record
<
string
,
string
>
}
...
...
web/src/features/system-settings/components/settings-form-layout.tsx
View file @
0e0ba152
...
...
@@ -37,6 +37,7 @@ type SettingsSwitchRowProps = ComponentProps<'div'>
type
SettingsControlGroupProps
=
ComponentProps
<
'div'
>
type
SettingsControlChildrenProps
=
ComponentProps
<
'div'
>
type
SettingsSwitchFieldProps
=
SettingsSwitchRowProps
&
{
controlId
?:
string
checked
:
boolean
onCheckedChange
:
(
checked
:
boolean
)
=>
void
label
:
ReactNode
...
...
@@ -107,6 +108,7 @@ export function SettingsSwitchRow({
}
export
function
SettingsSwitchField
({
controlId
,
checked
,
onCheckedChange
,
label
,
...
...
@@ -118,12 +120,23 @@ export function SettingsSwitchField({
return
(
<
SettingsSwitchRow
className=
{
className
}
{
...
props
}
>
<
SettingsSwitchContent
>
<
Label
className=
'text-sm font-medium'
>
{
label
}
</
Label
>
<
Label
htmlFor=
{
controlId
}
className=
'text-sm font-medium'
>
{
label
}
</
Label
>
{
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
}
</
SettingsSwitchContent
>
<
Switch
id=
{
controlId
}
aria
-
describedby=
{
controlId
&&
description
?
`${controlId}-description`
:
undefined
}
checked=
{
checked
}
onCheckedChange=
{
onCheckedChange
}
disabled=
{
disabled
}
...
...
web/src/features/system-settings/models/__tests__/task-pricing-copy.test.tsx
0 → 100644
View file @
0e0ba152
/*
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'
)
})
web/src/features/system-settings/models/model-pricing-inputs.tsx
View file @
0e0ba152
...
...
@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import
{
useId
}
from
'react'
import
{
useTranslation
}
from
'react-i18next'
import
{
InputGroup
,
InputGroupAddon
}
from
'@/components/ui/input-group'
...
...
@@ -73,40 +74,52 @@ export function PriceLane(props: {
value
:
string
enabled
:
boolean
disabled
?:
boolean
compact
?:
boolean
disabledReason
?:
string
onEnabledChange
:
(
checked
:
boolean
)
=>
void
onChange
:
(
value
:
string
)
=>
void
})
{
const
{
t
}
=
useTranslation
()
const
controlId
=
useId
()
const
effectiveDisabled
=
props
.
disabled
||
!
props
.
enabled
return
(
<
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
}
>
<
SettingsSwitchField
controlId=
{
controlId
}
className=
{
props
.
compact
?
'py-0'
:
undefined
}
checked=
{
props
.
enabled
}
disabled=
{
props
.
disabled
}
onCheckedChange=
{
props
.
onEnabledChange
}
label=
{
props
.
title
}
description=
{
props
.
description
}
description=
{
props
.
d
isabledReason
||
props
.
d
escription
}
aria
-
label=
{
props
.
title
}
/>
<
PriceInput
currency=
{
props
.
currency
}
aria
-
label=
{
props
.
title
}
aria
-
describedby=
{
`${controlId}-description`
}
value=
{
props
.
value
}
placeholder=
{
props
.
placeholder
}
disabled=
{
effectiveDisabled
}
onChange=
{
props
.
onChange
}
/>
<
p
className=
'text-muted-foreground text-xs'
>
{
props
.
enabled
?
t
(
'
{{
currency
}}
price
per
1
M
tokens
.
'
,
{
currency
:
(
props
.
currency
??
USD_PRICING_CURRENCY
).
label
,
})
:
t
(
'Disabled lanes are omitted on save.'
)
}
</
p
>
{
!
props
.
compact
&&
(
<
p
className=
'text-muted-foreground text-xs'
>
{
props
.
enabled
?
t
(
'
{{
currency
}}
price
per
1
M
tokens
.
'
,
{
currency
:
(
props
.
currency
??
USD_PRICING_CURRENCY
).
label
,
})
:
t
(
'Disabled lanes are omitted on save.'
)
}
</
p
>
)
}
</
SettingsControlGroup
>
)
}
web/src/features/system-settings/models/model-pricing-sheet.tsx
View file @
0e0ba152
...
...
@@ -113,6 +113,7 @@ type ModelPricingEditorPanelProps = Omit<
'open'
|
'onOpenChange'
>
&
{
className
?:
string
embedded
?:
boolean
}
export
type
ModelPricingEditorPanelHandle
=
{
...
...
@@ -168,7 +169,15 @@ export const ModelPricingEditorPanel = forwardRef<
ModelPricingEditorPanelHandle
,
ModelPricingEditorPanelProps
>
(
function
ModelPricingEditorPanel
(
{
editData
,
className
,
onSave
,
isSaving
,
usageSchema
,
onDirtyChange
},
{
editData
,
className
,
onSave
,
isSaving
,
usageSchema
,
onDirtyChange
,
embedded
=
false
,
},
ref
)
{
const
{
t
}
=
useTranslation
()
...
...
@@ -617,19 +626,21 @@ export const ModelPricingEditorPanel = forwardRef<
return
(
<
div
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
)
}
>
<
div
className=
'border-b p-4'
>
<
div
className=
'flex flex-wrap items-start justify-between gap-3'
>
<
div
className=
'min-w-0'
>
<
h3
className=
'truncate text-base font-medium'
>
{
isEditMode
?
t
(
'Edit model pricing'
)
:
t
(
'Add model pricing'
)
}
</
h3
>
{
!
embedded
&&
(
<
div
className=
'border-b p-4'
>
<
div
className=
'flex flex-wrap items-start justify-between gap-3'
>
<
div
className=
'min-w-0'
>
<
h3
className=
'truncate text-base font-medium'
>
{
isEditMode
?
t
(
'Edit model pricing'
)
:
t
(
'Add model pricing'
)
}
</
h3
>
</
div
>
</
div
>
</
div
>
</
div
>
)
}
<
Form
{
...
form
}
>
<
form
...
...
@@ -638,9 +649,15 @@ export const ModelPricingEditorPanel = forwardRef<
className=
'flex min-h-0 flex-1 flex-col'
autoComplete=
'off'
>
<
div
className=
'min-h-0 flex-1 overflow-y-auto p-4 pb-6'
>
<
div
className=
'grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(220px,260px)]'
>
<
FieldGroup
>
<
div
role=
'region'
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
&&
(
<
Alert
variant=
'destructive'
>
<
AlertTriangle
data
-
icon=
'inline-start'
/>
...
...
@@ -654,28 +671,30 @@ export const ModelPricingEditorPanel = forwardRef<
</
Alert
>
)
}
<
FormField
control=
{
form
.
control
}
name=
'name'
render=
{
({
field
})
=>
(
<
FormItem
>
<
FormLabel
>
{
t
(
'Model name'
)
}
</
FormLabel
>
<
FormControl
>
<
Input
placeholder=
{
t
(
'gpt-4'
)
}
{
...
field
}
disabled=
{
isEditMode
}
/>
</
FormControl
>
<
FormDescription
>
{
t
(
'The exact model identifier as used in API requests.'
)
}
</
FormDescription
>
<
FormMessage
/>
</
FormItem
>
)
}
/>
{
!
embedded
&&
(
<
FormField
control=
{
form
.
control
}
name=
'name'
render=
{
({
field
})
=>
(
<
FormItem
>
<
FormLabel
>
{
t
(
'Model name'
)
}
</
FormLabel
>
<
FormControl
>
<
Input
placeholder=
{
t
(
'gpt-4'
)
}
{
...
field
}
disabled=
{
isEditMode
}
/>
</
FormControl
>
<
FormDescription
>
{
t
(
'The exact model identifier as used in API requests.'
)
}
</
FormDescription
>
<
FormMessage
/>
</
FormItem
>
)
}
/>
)
}
<
PricingCurrencySelector
siteCurrency=
{
siteCurrency
}
/>
...
...
@@ -697,7 +716,10 @@ export const ModelPricingEditorPanel = forwardRef<
</
TabsTrigger
>
</
TabsList
>
<
TabsContent
value=
'per-token'
className=
'pt-0'
>
<
TabsContent
value=
'per-token'
className=
'@container/pricing-fields min-w-0 pt-0'
>
{
taskUsageSchema
&&
Object
.
keys
(
taskUsageSchema
).
length
>
0
&&
(
<
Alert
className=
'mb-4'
>
...
...
@@ -724,53 +746,80 @@ export const ModelPricingEditorPanel = forwardRef<
</
AlertDescription
>
</
Alert
>
)
}
<
FieldGroup
className=
'gap-5'
>
<
Field
>
{
embedded
&&
(
<
p
className=
'text-muted-foreground mb-3 text-xs'
>
{
t
(
'
{{
currency
}}
price
per
1
M
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
}
>
{
t
(
'Input price'
)
}
</
FieldLabel
>
<
FieldDescription
id=
{
`${promptPriceId}-description`
}
className=
{
embedded
?
'text-xs'
:
undefined
}
>
{
t
(
'
{{
currency
}}
price
per
1
M
input
tokens
.
'
,
{
currency
:
currency
.
label
,
})
}
</
FieldDescription
>
<
PriceInput
currency=
{
currency
}
id=
{
promptPriceId
}
aria
-
describedby=
{
`${promptPriceId}-description`
}
currency=
{
currency
}
value=
{
promptPrice
}
placeholder=
'3'
onChange=
{
handlePromptPriceChange
}
/>
<
FieldDescription
id=
{
`${promptPriceId}-description`
}
>
{
t
(
'
{{
currency
}}
price
per
1
M
input
tokens
.
'
,
{
currency
:
currency
.
label
,
})
}
</
FieldDescription
>
</
Field
>
<
div
className=
'grid gap-3 sm:grid-cols-[repeat(auto-fit,minmax(400px,1fr))]'
>
{
laneConfigs
.
map
((
lane
)
=>
{
const
disabled
=
lane
.
key
===
'audioOutput'
&&
(
!
laneEnabled
.
audioInput
||
!
hasValue
(
lanePrices
.
audioInput
))
return
(
<
PriceLane
currency=
{
currency
}
key=
{
lane
.
key
}
title=
{
t
(
lane
.
titleKey
)
}
description=
{
t
(
lane
.
descriptionKey
)
}
placeholder=
{
lane
.
placeholder
}
value=
{
lanePrices
[
lane
.
key
]
}
enabled=
{
laneEnabled
[
lane
.
key
]
}
disabled=
{
disabled
}
onEnabledChange=
{
(
checked
)
=>
handleLaneToggle
(
lane
.
key
,
checked
)
}
onChange=
{
(
value
)
=>
handleLanePriceChange
(
lane
.
key
,
value
)
}
/>
)
})
}
</
div
>
</
FieldGroup
>
{
laneConfigs
.
map
((
lane
)
=>
{
const
disabled
=
lane
.
key
===
'audioOutput'
&&
(
!
laneEnabled
.
audioInput
||
!
hasValue
(
lanePrices
.
audioInput
))
return
(
<
PriceLane
currency=
{
currency
}
key=
{
lane
.
key
}
compact=
{
embedded
}
title=
{
t
(
lane
.
titleKey
)
}
description=
{
t
(
lane
.
descriptionKey
)
}
placeholder=
{
lane
.
placeholder
}
value=
{
lanePrices
[
lane
.
key
]
}
enabled=
{
laneEnabled
[
lane
.
key
]
}
disabled=
{
disabled
}
disabledReason=
{
disabled
?
t
(
'Audio output price requires an audio input price.'
)
:
undefined
}
onEnabledChange=
{
(
checked
)
=>
handleLaneToggle
(
lane
.
key
,
checked
)
}
onChange=
{
(
value
)
=>
handleLanePriceChange
(
lane
.
key
,
value
)
}
/>
)
})
}
</
div
>
</
TabsContent
>
<
TabsContent
value=
'per-request'
className=
'pt-0'
>
...
...
@@ -843,7 +892,10 @@ export const ModelPricingEditorPanel = forwardRef<
</
Tabs
>
</
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=
'text-sm font-medium'
>
{
t
(
'Preview'
)
}
</
div
>
</
div
>
...
...
web/src/features/system-settings/models/model-ratio-form.tsx
View file @
0e0ba152
...
...
@@ -24,6 +24,7 @@ import { useTranslation } from 'react-i18next'
import
{
toast
}
from
'sonner'
import
{
JsonCodeEditor
}
from
'@/components/json-code-editor'
import
{
LearnMore
}
from
'@/components/learn-more'
import
{
Button
}
from
'@/components/ui/button'
import
{
Form
,
...
...
@@ -42,6 +43,7 @@ import {
SettingsSwitchContent
,
SettingsSwitchItem
,
}
from
'../components/settings-form-layout'
import
{
SettingsPageActionsPortal
}
from
'../components/settings-page-context'
import
{
ModelRatioVisualEditor
,
type
ModelRatioVisualEditorHandle
,
...
...
@@ -220,49 +222,78 @@ export const ModelRatioForm = memo(function ModelRatioForm({
},
[
editMode
,
form
,
onSave
])
return
(
<
div
className=
'space-y-6'
>
{
!
isUnsetVariant
&&
(
<
div
className=
'flex flex-wrap justify-end gap-2'
>
<
Button
type=
'button'
variant=
'destructive'
size=
'sm'
onClick=
{
onReset
}
disabled=
{
isResetting
}
>
<
RotateCcw
data
-
icon=
'inline-start'
/>
{
t
(
'Reset prices'
)
}
</
Button
>
{
editMode
===
'json'
&&
(
<
Form
{
...
form
}
>
<
div
className=
'flex min-h-0 flex-1 flex-col gap-6'
>
{
!
isUnsetVariant
&&
(
<
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
type=
'button'
variant=
'destructive'
size=
'sm'
onClick=
{
handleSave
}
disabled=
{
is
Sav
ing
}
onClick=
{
onReset
}
disabled=
{
is
Resett
ing
}
>
<
Save
data
-
icon=
'inline-start'
/>
{
isSaving
?
t
(
'Saving...'
)
:
t
(
'Save model
prices'
)
}
<
RotateCcw
data
-
icon=
'inline-start'
/>
{
t
(
'Reset
prices'
)
}
</
Button
>
)
}
<
Button
variant=
'outline'
size=
'sm'
onClick=
{
toggleEditMode
}
>
{
editMode
===
'visual'
?
(
<>
<
Code2
className=
'mr-2 h-4 w-4'
/>
{
t
(
'Switch to JSON'
)
}
</>
)
:
(
<>
<
Eye
className=
'mr-2 h-4 w-4'
/>
{
t
(
'Switch to Visual'
)
}
</>
{
editMode
===
'json'
&&
(
<
Button
type=
'button'
size=
'sm'
onClick=
{
handleSave
}
disabled=
{
isSaving
}
>
<
Save
data
-
icon=
'inline-start'
/>
{
isSaving
?
t
(
'Saving...'
)
:
t
(
'Save model prices'
)
}
</
Button
>
)
}
</
Button
>
</
div
>
)
}
<
Button
variant=
'outline'
size=
'sm'
onClick=
{
toggleEditMode
}
>
{
editMode
===
'visual'
?
(
<>
<
Code2
className=
'mr-2 h-4 w-4'
/>
{
t
(
'Switch to JSON'
)
}
</>
)
:
(
<>
<
Eye
className=
'mr-2 h-4 w-4'
/>
{
t
(
'Switch to Visual'
)
}
</>
)
}
</
Button
>
</
div
>
)
}
<
Form
{
...
form
}
>
{
editMode
===
'visual'
?
(
<
div
className=
'
space-y
-6'
>
<
div
className=
'
flex min-h-0 flex-1 flex-col gap
-6'
>
<
ModelRatioVisualEditor
ref=
{
visualEditorRef
}
savedModelPrice=
{
savedValues
.
ModelPrice
}
...
...
@@ -304,31 +335,6 @@ export const ModelRatioForm = memo(function ModelRatioForm({
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
>
)
:
(
<
SettingsForm
onSubmit=
{
form
.
handleSubmit
(
onSave
)
}
>
...
...
@@ -343,32 +349,9 @@ export const ModelRatioForm = memo(function ModelRatioForm({
/>
))
}
</
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
>
)
}
</
Form
>
</
div
>
</
div
>
</
Form
>
)
})
web/src/features/system-settings/models/model-ratio-visual-editor.tsx
View file @
0e0ba152
...
...
@@ -606,8 +606,12 @@ const ModelRatioVisualEditorComponent = forwardRef<
}
return
(
<
div
className=
'flex 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
className=
'flex min-h-0 flex-1 flex-col gap-4'
>
<
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'
>
<
DataTableToolbar
table=
{
table
}
...
...
web/src/features/system-settings/models/ratio-settings-card.tsx
View file @
0e0ba152
...
...
@@ -528,7 +528,14 @@ export function RatioSettingsCard({
return
(
<>
{
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
)
}
</
SettingsSection
>
)
:
(
...
...
@@ -539,7 +546,15 @@ export function RatioSettingsCard({
<
SettingsSection
title=
{
t
(
titleKey
)
}
className=
'min-h-0 flex-1'
>
{
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
)
}
</
TabsContent
>
))
}
...
...
web/src/features/system-settings/models/task-pricing-matrix.tsx
View file @
0e0ba152
...
...
@@ -58,9 +58,13 @@ import { getTaskUsagePriceUnitLabelKey } from '@/features/pricing/lib/dynamic-pr
import
{
getTaskEnumFields
,
getTaskNumberFields
,
taskMatrixRowLabel
,
type
TaskMatrixRow
,
}
from
'@/features/pricing/lib/task-expr'
import
{
taskPriceLabel
,
taskEnumLabel
,
taskPricingConditions
,
}
from
'@/features/pricing/lib/task-price-display'
import
type
{
BillingUsageFieldSchema
,
BillingUsageSchema
,
...
...
@@ -180,7 +184,11 @@ type 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
(
([
field
])
=>
field
!==
props
.
hiddenEnumField
)
...
...
@@ -189,16 +197,24 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
<
Table
className=
'min-w-max'
>
<
TableHeader
>
<
TableRow
>
{
visibleEnumFields
.
map
(([
field
])
=>
(
{
visibleEnumFields
.
map
(([
field
,
definition
])
=>
(
<
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
>
))
}
{
props
.
numberFields
.
map
(([
field
,
definition
])
=>
(
<
TableHead
key=
{
field
}
scope=
'col'
className=
'min-w-40'
>
<
div
className=
'flex items-center justify-between gap-2'
>
<
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'
>
{
(
props
.
currency
??
USD_PRICING_CURRENCY
).
symbol
}
/
{
t
(
getTaskUsagePriceUnitLabelKey
(
definition
.
unit
))
}
...
...
@@ -216,7 +232,12 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
<
TableHead
scope=
'col'
className=
'min-w-40'
>
<
div
className=
'flex items-center justify-between gap-2'
>
<
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'
>
{
(
props
.
currency
??
USD_PRICING_CURRENCY
).
symbol
}
/
{
t
(
'request'
)
}
...
...
@@ -242,7 +263,15 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
props
.
numberFields
.
every
(
([
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
(
<
TableRow
key=
{
`${rowLabel}:${entry.index}`
}
...
...
@@ -253,7 +282,13 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
>
{
visibleEnumFields
.
map
(([
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
>
))
}
{
props
.
numberFields
.
map
(([
field
])
=>
(
...
...
@@ -265,7 +300,7 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
value=
{
entry
.
row
.
unitPrices
[
field
]
??
0
}
data
-
matrix
-
col=
{
field
}
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
)
=>
{
if
(
Number
(
event
.
currentTarget
.
value
)
===
0
)
{
event
.
currentTarget
.
select
()
...
...
@@ -297,7 +332,7 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
value=
{
entry
.
row
.
constant
}
data
-
matrix
-
col=
'constant'
data
-
matrix
-
row=
{
entry
.
index
}
aria
-
label=
{
`${t('
Base
charge')}: ${rowLabel}`
}
aria
-
label=
{
`${t('
Additional
charge')}: ${rowLabel}`
}
onFocus=
{
(
event
)
=>
{
if
(
Number
(
event
.
currentTarget
.
value
)
===
0
)
{
event
.
currentTarget
.
select
()
...
...
@@ -355,7 +390,7 @@ type TaskMatrixGroupProps = Omit<TaskMatrixTableProps, 'hiddenEnumField'> & {
}
function
TaskMatrixGroup
(
props
:
TaskMatrixGroupProps
)
{
const
{
t
}
=
useTranslation
()
const
{
t
,
i18n
}
=
useTranslation
()
const
freeCount
=
props
.
entries
.
filter
(
(
entry
)
=>
entry
.
row
.
constant
===
0
&&
...
...
@@ -377,7 +412,13 @@ function TaskMatrixGroup(props: TaskMatrixGroupProps) {
}
>
<
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'
>
{
t
(
'
{{
count
}}
combinations
'
,
{
count
:
props
.
entries
.
length
})
}
</
span
>
...
...
web/src/features/system-settings/models/task-usage-pricing-editor.tsx
View file @
0e0ba152
...
...
@@ -56,18 +56,21 @@ import {
getTaskEnumCombinations
,
getTaskEnumFields
,
getTaskNumberFields
,
taskMatrixRowLabel
,
taskMatrixToTiers
,
tryParseTaskMatrixConfig
,
tryParseTaskVisualConfig
,
type
TaskMatrixRow
,
type
TaskVisualConfig
,
}
from
'@/features/pricing/lib/task-expr'
import
{
taskPriceLabel
,
taskEnumLabel
,
taskPricingConditions
,
}
from
'@/features/pricing/lib/task-price-display'
import
type
{
BillingUsageExample
,
BillingUsageSchema
,
}
from
'@/features/pricing/types'
import
{
resolveLocalizedText
}
from
'@/lib/localized-text'
import
{
formatPricingNumber
}
from
'./pricing-format'
import
{
TaskPricingMatrix
}
from
'./task-pricing-matrix'
...
...
@@ -87,7 +90,6 @@ type EditorMode = 'visual' | 'raw'
type
TaskBillingPreviewProps
=
{
currency
?:
PricingCurrency
config
:
TaskVisualConfig
|
null
matchedRowLabel
:
string
|
null
requestRuleExpr
:
string
sample
:
Record
<
string
,
number
|
string
>
usageSchema
:
BillingUsageSchema
...
...
@@ -97,7 +99,7 @@ type TaskBillingPreviewProps = {
}
function
TaskBillingPreview
(
props
:
TaskBillingPreviewProps
)
{
const
{
t
}
=
useTranslation
()
const
{
t
,
i18n
}
=
useTranslation
()
const
enumFields
=
getTaskEnumFields
(
props
.
usageSchema
)
const
numberFields
=
getTaskNumberFields
(
props
.
usageSchema
)
const
result
=
props
.
config
...
...
@@ -116,7 +118,7 @@ function TaskBillingPreview(props: TaskBillingPreviewProps) {
const
formulaParts
=
result
.
parts
.
map
((
part
)
=>
{
if
(
part
.
kind
===
'constant'
)
{
return
formatPricingAmount
(
part
.
amount
,
props
.
currency
)
return
`
${
t
(
'Additional charge'
)}
:
${
formatPricingAmount
(
part
.
amount
,
props
.
currency
)}
`
}
const
definition
=
props
.
usageSchema
[
part
.
field
??
''
]
...
...
@@ -127,7 +129,7 @@ function TaskBillingPreview(props: TaskBillingPreviewProps) {
definition
?.
unit
===
'second'
?
`
${
formatPricingNumber
(
part
.
quantity
)}${
quantityUnitLabel
}
`
:
`
${
formatPricingNumber
(
part
.
quantity
)}
${
quantityUnitLabel
}
`
return
`
${
quantityLabel
}
×
${
formatPricingAmount
(
part
.
unitPrice
??
0
,
props
.
currency
)}
/${t
(
priceUnitKey
)
}
`
return
`
${
taskPriceLabel
(
definition
?.
description
,
part
.
field
??
''
,
i18n
.
language
)}:
$
{
quantityLabel
}
×
$
{
formatPricingAmount
(
part
.
unitPrice
??
0
,
props
.
currency
)}
/${t
(
priceUnitKey
)
}
`
})
const
formulaLeft
=
formulaParts
.
length
>
0
...
...
@@ -138,9 +140,11 @@ function TaskBillingPreview(props: TaskBillingPreviewProps) {
return
(
<
div
className=
'bg-muted/30 flex flex-col gap-3 rounded-md border p-3'
>
<
div
className=
'flex flex-col gap-1'
>
<
h4
className=
'text-sm font-medium'
>
{
t
(
'
Preview
'
)
}
</
h4
>
<
h4
className=
'text-sm font-medium'
>
{
t
(
'
Cost calculator
'
)
}
</
h4
>
<
p
className=
'text-muted-foreground text-xs'
>
{
t
(
'Preview excludes group ratios and request rule multipliers.'
)
}
{
t
(
'Enter sample usage to estimate the cost. Group and request multipliers are not included.'
)
}
{
props
.
requestRuleExpr
?
(
<>
{
t
(
'Request rules apply on top of this amount.'
)
}
</>
)
:
null
}
...
...
@@ -177,14 +181,19 @@ function TaskBillingPreview(props: TaskBillingPreviewProps) {
{
enumFields
.
map
(([
field
,
definition
])
=>
{
const
items
=
(
definition
.
enum
??
[]).
map
((
value
)
=>
({
value
,
label
:
value
,
label
:
taskEnumLabel
(
definition
,
value
,
i18n
.
language
)
,
}))
return
(
<
Field
key=
{
field
}
className=
'gap-1.5'
>
<
FieldLabel
>
<
code
>
{
field
}
</
code
>
{
taskPriceLabel
(
definition
.
description
,
field
,
i18n
.
language
)
}
</
FieldLabel
>
<
Combobox
aria
-
label=
{
taskPriceLabel
(
definition
.
description
,
field
,
i18n
.
language
)
}
options=
{
items
}
value=
{
String
(
props
.
sample
[
field
]
??
''
)
}
onValueChange=
{
(
value
)
=>
...
...
@@ -198,11 +207,24 @@ function TaskBillingPreview(props: TaskBillingPreviewProps) {
{
numberFields
.
map
(([
field
,
definition
])
=>
(
<
Field
key=
{
field
}
className=
'gap-1.5'
>
<
FieldLabel
>
<
code
>
{
field
}
</
code
>
{
t
(
'
Usage
·
{{
price
}}
'
,
{
price
:
taskPriceLabel
(
definition
.
description
,
field
,
i18n
.
language
),
})
}
</
FieldLabel
>
<
div
className=
'flex items-center gap-2'
>
<
Input
type=
'number'
aria
-
label=
{
t
(
'
Usage
·
{{
price
}}
'
,
{
price
:
taskPriceLabel
(
definition
.
description
,
field
,
i18n
.
language
),
})
}
min=
{
0
}
step=
{
1
}
value=
{
props
.
sample
[
field
]
??
0
}
...
...
@@ -225,7 +247,16 @@ function TaskBillingPreview(props: TaskBillingPreviewProps) {
)
:
null
}
<
div
className=
'border-primary/50 bg-primary/10 flex flex-col gap-2 rounded-md border p-3 text-sm'
>
<
Badge
variant=
'outline'
className=
'text-xs'
>
{
t
(
'Hit tier'
)
}
:
{
props
.
matchedRowLabel
??
result
.
tier
.
label
}
{
t
(
'Current pricing conditions'
)
}
:
{
' '
}
{
taskPricingConditions
(
enumFields
.
map
(([
field
])
=>
({
field
,
value
:
String
(
props
.
sample
[
field
]
??
''
),
})),
props
.
usageSchema
,
i18n
.
language
,
t
)
||
t
(
'All requests'
)
}
</
Badge
>
<
code
className=
'font-mono text-xs break-words'
>
{
formula
}
</
code
>
</
div
>
...
...
@@ -276,7 +307,6 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
let
previewConfig
:
TaskVisualConfig
|
null
=
null
let
previewRequestRuleExpr
=
props
.
requestRuleExpr
let
matchedRowIndex
:
number
|
null
=
null
let
matchedRowLabel
:
string
|
null
=
null
if
(
editorMode
===
'visual'
)
{
const
generatedExpression
=
generateTaskExprFromConfig
(
{
tiers
:
visualTiers
},
...
...
@@ -290,7 +320,6 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
)
if
(
nextMatchedRowIndex
>=
0
)
{
matchedRowIndex
=
nextMatchedRowIndex
matchedRowLabel
=
taskMatrixRowLabel
(
combinations
[
nextMatchedRowIndex
])
}
}
else
{
const
split
=
splitBillingExprAndRequestRules
(
rawExpr
)
...
...
@@ -413,7 +442,7 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
<
Alert
>
<
AlertDescription
className=
'text-xs'
>
{
t
(
'
Visual
prices
use
{{
currency
}}
per
declared
unit
;
token
prices
are
per
1
M
tokens
.
Raw
expressions
always
use
USD
,
with
token
terms
divided
by
1000000
.
'
,
'
Prices
are
in
{{
currency
}},
with
units
shown
below
.
Use
USD
when
editing
expressions
directly
.
'
,
{
currency
:
(
props
.
currency
??
USD_PRICING_CURRENCY
).
label
}
)
}
</
AlertDescription
>
...
...
@@ -425,9 +454,9 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
{
enumFields
.
length
>
0
?
(
<
div
className=
'flex flex-col gap-3'
>
<
p
className=
'text-muted-foreground text-xs'
>
{
t
(
'
Each
row
prices
one
combination
of
{{
fields
}}.
'
,
{
fields
:
enumFields
.
map
(([
field
])
=>
field
).
join
(
', '
),
}
)
}
{
t
(
'Set prices for each set of conditions below. Cost = usage × unit price + additional charge. Token prices are per million tokens.'
)
}
</
p
>
<
TaskPricingMatrix
currency=
{
props
.
currency
}
...
...
@@ -457,15 +486,14 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
</
Label
>
<
div
className=
'grid gap-3 sm:grid-cols-2'
>
{
numberFields
.
map
(([
field
,
definition
])
=>
{
const
description
=
resolveLocalizedText
(
const
description
=
taskPriceLabel
(
definition
.
description
,
field
,
i18n
.
language
)
return
(
<
Field
key=
{
field
}
className=
'gap-1.5'
>
<
FieldLabel
>
<
code
>
{
field
}
</
code
>
</
FieldLabel
>
<
FieldLabel
>
{
description
}
</
FieldLabel
>
<
div
className=
'flex items-center gap-2'
>
<
PricingAmountInput
currency=
{
props
.
currency
}
...
...
@@ -504,18 +532,15 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
)
}
</
span
>
</
div
>
{
description
?
(
<
FieldDescription
>
{
description
}
</
FieldDescription
>
)
:
null
}
</
Field
>
)
})
}
<
Field
className=
'gap-1.5'
>
<
FieldLabel
>
{
t
(
'
Base
charge'
)
}
</
FieldLabel
>
<
FieldLabel
>
{
t
(
'
Additional
charge'
)
}
</
FieldLabel
>
<
div
className=
'flex items-center gap-2'
>
<
PricingAmountInput
currency=
{
props
.
currency
}
aria
-
label=
{
t
(
'
Base
charge'
)
}
aria
-
label=
{
t
(
'
Additional
charge'
)
}
min=
{
0
}
step=
{
0.000001
}
value=
{
matrixRows
[
0
].
constant
}
...
...
@@ -551,7 +576,6 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
<
TaskBillingPreview
currency=
{
props
.
currency
}
config=
{
previewConfig
}
matchedRowLabel=
{
matchedRowLabel
}
requestRuleExpr=
{
previewRequestRuleExpr
}
sample=
{
previewSample
}
usageSchema=
{
props
.
usageSchema
}
...
...
@@ -614,7 +638,6 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
<
TaskBillingPreview
currency=
{
props
.
currency
}
config=
{
previewConfig
}
matchedRowLabel=
{
matchedRowLabel
}
requestRuleExpr=
{
previewRequestRuleExpr
}
sample=
{
previewSample
}
usageSchema=
{
props
.
usageSchema
}
...
...
web/src/features/usage-logs/components/__tests__/detail-preview.test.tsx
0 → 100644
View file @
0e0ba152
/*
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
:
3
e9
,
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'
)
}
)
web/src/features/usage-logs/components/columns/common-logs-columns.tsx
View file @
0e0ba152
...
...
@@ -35,6 +35,16 @@ import {
TooltipProvider
,
TooltipTrigger
,
}
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
{
formatBillingCurrencyFromUSD
}
from
'@/lib/currency'
import
{
formatLogQuota
,
formatTimestampToDate
}
from
'@/lib/format'
...
...
@@ -44,6 +54,7 @@ import { LOG_TYPE_ALL_VALUE } from '../../constants'
import
type
{
UsageLog
}
from
'../../data/schema'
import
{
formatModelName
,
decodeBillingExprB64
,
getTieredBillingSummary
,
hasAnyCacheTokens
,
parseLogOther
,
...
...
@@ -98,9 +109,10 @@ function buildDetailSegments(
log
:
UsageLog
,
other
:
LogOtherData
|
null
,
t
:
(
key
:
string
,
opts
?:
Record
<
string
,
unknown
>
)
=>
string
,
isAdmin
:
boolean
isAdmin
:
boolean
,
usageSchema
?:
BillingUsageSchema
):
DetailSegment
[]
{
const
segments
=
buildTypeDetailSegments
(
log
,
other
,
t
)
const
segments
=
buildTypeDetailSegments
(
log
,
other
,
t
,
usageSchema
)
const
adminSegments
:
DetailSegment
[]
=
[]
// 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
...
...
@@ -109,20 +121,14 @@ function buildDetailSegments(
if
(
isAdmin
&&
other
?.
admin_info
?.
quota_saturation
)
{
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
]
}
function
buildTypeDetailSegments
(
log
:
UsageLog
,
other
:
LogOtherData
|
null
,
t
:
(
key
:
string
,
opts
?:
Record
<
string
,
unknown
>
)
=>
string
t
:
(
key
:
string
,
opts
?:
Record
<
string
,
unknown
>
)
=>
string
,
usageSchema
?:
BillingUsageSchema
):
DetailSegment
[]
{
// Top-up, audit, and login logs can carry a localized operation descriptor.
if
(
log
.
type
===
1
||
log
.
type
===
3
||
log
.
type
===
7
)
{
...
...
@@ -168,7 +174,39 @@ function buildTypeDetailSegments(
}
const
isTieredExpr
=
other
.
billing_mode
===
'tiered_expr'
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
)
{
const
baseEntries
=
tieredSummary
.
priceEntries
.
filter
((
entry
)
=>
[
'inputPrice'
,
'outputPrice'
].
includes
(
entry
.
field
))
...
...
@@ -743,7 +781,21 @@ export function useCommonLogsColumns(
const
log
=
row
.
original
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
hasMore
=
segments
.
length
>
1
let
primaryTextClass
=
'text-foreground'
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment