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: {
...
@@ -121,9 +121,9 @@ export function ModelPricingPanel(props: {
}
}
return
(
return
(
<
div
className=
'flex min-h-0 flex-1 flex-col gap-3'
>
<
div
className=
'flex min-h-0
min-w-0
flex-1 flex-col gap-3'
>
<
div
className=
'flex flex-wrap items-center justify-between gap-2 px-4 pt-3'
>
<
div
className=
'flex flex-wrap items-center justify-between gap-2 px-4 pt-3'
>
<
div
>
<
div
className=
'min-w-0 flex-1 break-words'
>
<
p
className=
'text-muted-foreground text-xs'
>
<
p
className=
'text-muted-foreground text-xs'
>
{
Object
.
keys
(
entry
.
configured
).
length
{
Object
.
keys
(
entry
.
configured
).
length
?
t
(
'Stored configuration with effective defaults'
)
?
t
(
'Stored configuration with effective defaults'
)
...
@@ -167,6 +167,7 @@ export function ModelPricingPanel(props: {
...
@@ -167,6 +167,7 @@ export function ModelPricingPanel(props: {
</
div
>
</
div
>
)
}
)
}
<
ModelPricingEditorPanel
<
ModelPricingEditorPanel
embedded
ref=
{
editor
}
ref=
{
editor
}
editData=
{
editData
}
editData=
{
editData
}
usageSchema=
{
entry
.
usage_schema
}
usageSchema=
{
entry
.
usage_schema
}
...
...
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/>.
...
@@ -17,7 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
For commercial licensing, please contact support@quantumnous.com
*/
*/
import
{
QueryClient
,
QueryClientProvider
}
from
'@tanstack/react-query'
import
{
QueryClient
,
QueryClientProvider
}
from
'@tanstack/react-query'
import
{
render
,
screen
,
waitFor
,
cleanup
}
from
'@testing-library/react'
import
type
{
Row
}
from
'@tanstack/react-table'
import
{
render
,
screen
,
waitFor
,
cleanup
,
act
}
from
'@testing-library/react'
import
userEvent
from
'@testing-library/user-event'
import
userEvent
from
'@testing-library/user-event'
import
{
AxiosError
}
from
'axios'
import
{
AxiosError
}
from
'axios'
import
{
afterEach
,
describe
,
expect
,
it
,
vi
}
from
'vitest'
import
{
afterEach
,
describe
,
expect
,
it
,
vi
}
from
'vitest'
...
@@ -26,8 +27,11 @@ import { pricingOptions } from '@/features/model-pricing/pricing'
...
@@ -26,8 +27,11 @@ import { pricingOptions } from '@/features/model-pricing/pricing'
import
{
api
}
from
'@/lib/api'
import
{
api
}
from
'@/lib/api'
import
{
useAuthStore
}
from
'@/stores/auth-store'
import
{
useAuthStore
}
from
'@/stores/auth-store'
import
{
DataTableRowActions
}
from
'../components/data-table-row-actions'
import
{
ModelMutateDrawer
}
from
'../components/drawers/model-mutate-drawer'
import
{
ModelMutateDrawer
}
from
'../components/drawers/model-mutate-drawer'
import
{
ModelsDialogs
}
from
'../components/models-dialogs'
import
{
ModelsProvider
}
from
'../components/models-provider'
import
{
ModelsProvider
}
from
'../components/models-provider'
import
type
{
Model
}
from
'../types'
const
model
=
{
const
model
=
{
id
:
7
,
id
:
7
,
...
@@ -48,6 +52,166 @@ afterEach(() => {
...
@@ -48,6 +52,166 @@ afterEach(() => {
useAuthStore
.
getState
().
auth
.
reset
()
useAuthStore
.
getState
().
auth
.
reset
()
})
})
function
renderModelActions
(
currentModel
:
Model
=
model
,
role
=
100
)
{
useAuthStore
.
getState
().
auth
.
setUser
({
id
:
1
,
username
:
'admin'
,
role
})
const
client
=
new
QueryClient
({
defaultOptions
:
{
queries
:
{
retry
:
false
},
mutations
:
{
retry
:
false
}
},
})
render
(
<
QueryClientProvider
client=
{
client
}
>
<
ModelsProvider
>
<
DataTableRowActions
row=
{
{
original
:
currentModel
}
as
Row
<
Model
>
}
/>
<
ModelsDialogs
/>
</
ModelsProvider
>
</
QueryClientProvider
>
)
return
client
}
describe
(
'model pricing entry'
,
()
=>
{
it
(
'opens pricing directly, keeps it selected after metadata loads, and reopens Edit on metadata'
,
async
()
=>
{
let
resolveDetail
!
:
(
value
:
Awaited
<
ReturnType
<
typeof
api
.
get
>>
)
=>
void
const
detail
=
new
Promise
<
Awaited
<
ReturnType
<
typeof
api
.
get
>>>
(
(
resolve
)
=>
{
resolveDetail
=
resolve
}
)
vi
.
spyOn
(
api
,
'get'
).
mockImplementation
(
async
(
url
)
=>
{
if
(
url
===
'/api/models/7'
)
return
detail
if
(
url
===
'/api/option/model_pricing'
)
{
return
{
data
:
{
success
:
true
,
data
:
{
entries
:
[
{
model_name
:
model
.
model_name
,
version
:
'v1'
,
configured
:
{
ModelRatio
:
3.25
,
CompletionRatio
:
27
/
6.5
},
effective
:
{
ModelRatio
:
3.25
,
CompletionRatio
:
27
/
6.5
},
},
],
options
:
pricingOptions
({}),
empty_version
:
'empty'
,
},
},
}
}
return
{
data
:
{
success
:
true
,
data
:
{
items
:
[]
}
}
}
})
const
client
=
renderModelActions
()
const
user
=
userEvent
.
setup
()
await
user
.
click
(
screen
.
getByRole
(
'button'
,
{
name
:
'Pricing'
}))
expect
(
screen
.
getByRole
(
'tab'
,
{
name
:
'Pricing'
})).
toHaveAttribute
(
'aria-selected'
,
'true'
)
await
act
(
async
()
=>
resolveDetail
({
data
:
{
success
:
true
,
data
:
model
}
})
)
expect
(
screen
.
getByRole
(
'tab'
,
{
name
:
'Pricing'
})).
toHaveAttribute
(
'aria-selected'
,
'true'
)
expect
(
await
screen
.
findByRole
(
'textbox'
,
{
name
:
'Input price'
})
).
toHaveValue
(
'6.5'
)
expect
(
screen
.
queryByRole
(
'heading'
,
{
name
:
'Edit model pricing'
})
).
not
.
toBeInTheDocument
()
expect
(
screen
.
queryByRole
(
'textbox'
,
{
name
:
'Model name'
})
).
not
.
toBeInTheDocument
()
expect
(
screen
.
getByRole
(
'complementary'
,
{
name
:
'Preview'
})).
toBeVisible
()
for
(
const
tab
of
[
'Model metadata'
,
'Channels and groups'
,
'Pricing'
])
{
expect
(
screen
.
getByRole
(
'tab'
,
{
name
:
tab
})).
toHaveClass
(
'min-w-0'
,
'whitespace-normal'
)
await
user
.
click
(
screen
.
getByRole
(
'tab'
,
{
name
:
tab
}))
expect
(
screen
.
getByRole
(
'dialog'
,
{
name
:
model
.
model_name
})
).
toHaveClass
(
'sm:max-w-[1280px]'
)
}
await
user
.
click
(
screen
.
getByRole
(
'button'
,
{
name
:
'Close'
}))
await
user
.
click
(
screen
.
getByRole
(
'button'
,
{
name
:
'Edit'
}))
expect
(
await
screen
.
findByLabelText
(
'Description'
)).
toHaveValue
(
'Original'
)
expect
(
screen
.
getByRole
(
'tab'
,
{
name
:
'Model metadata'
})).
toHaveAttribute
(
'aria-selected'
,
'true'
)
client
.
clear
()
})
it
(
'does not expose a pricing shortcut to an ordinary administrator'
,
()
=>
{
vi
.
spyOn
(
api
,
'get'
).
mockResolvedValue
({
data
:
{
success
:
true
,
data
:
{
items
:
[]
}
},
})
const
client
=
renderModelActions
(
model
,
10
)
expect
(
screen
.
getByRole
(
'button'
,
{
name
:
'Edit'
})).
toBeVisible
()
expect
(
screen
.
queryByRole
(
'button'
,
{
name
:
'Pricing'
})
).
not
.
toBeInTheDocument
()
client
.
clear
()
})
it
(
'requires a concrete model for matching rules and protects an unsaved price on close'
,
async
()
=>
{
const
matchedModel
=
{
...
model
,
name_rule
:
1
,
matched_models
:
[
'example-concrete'
],
}
const
get
=
vi
.
spyOn
(
api
,
'get'
).
mockImplementation
(
async
(
url
)
=>
{
if
(
url
===
'/api/models/7'
)
{
return
{
data
:
{
success
:
true
,
data
:
matchedModel
}
}
}
if
(
url
===
'/api/option/model_pricing'
)
{
return
{
data
:
{
success
:
true
,
data
:
{
entries
:
[
{
model_name
:
'example-concrete'
,
version
:
'v1'
,
configured
:
{
ModelPrice
:
1.5
},
effective
:
{
ModelPrice
:
1.5
},
},
],
options
:
pricingOptions
({}),
empty_version
:
'empty'
,
},
},
}
}
return
{
data
:
{
success
:
true
,
data
:
{
items
:
[]
}
}
}
})
const
client
=
renderModelActions
(
matchedModel
)
const
user
=
userEvent
.
setup
()
await
user
.
click
(
screen
.
getByRole
(
'button'
,
{
name
:
'Pricing'
}))
await
user
.
click
(
await
screen
.
findByRole
(
'combobox'
,
{
name
:
'Select model'
})
)
expect
(
get
.
mock
.
calls
.
some
(([
url
])
=>
url
===
'/api/option/model_pricing'
)
).
toBe
(
false
)
await
user
.
click
(
await
screen
.
findByRole
(
'option'
,
{
name
:
'example-concrete'
})
)
const
price
=
await
screen
.
findByPlaceholderText
(
'0.01'
)
await
waitFor
(()
=>
expect
(
price
).
toHaveValue
(
'1.5'
))
await
user
.
clear
(
price
)
await
user
.
type
(
price
,
'2'
)
await
user
.
click
(
screen
.
getByRole
(
'button'
,
{
name
:
'Close'
}))
expect
(
await
screen
.
findByRole
(
'alertdialog'
)).
toHaveTextContent
(
'Discard unsaved changes?'
)
await
user
.
click
(
screen
.
getByRole
(
'button'
,
{
name
:
'Cancel'
}))
expect
(
price
).
toHaveValue
(
'2'
)
client
.
clear
()
})
})
describe
(
'metadata editing'
,
()
=>
{
describe
(
'metadata editing'
,
()
=>
{
it
.
each
([
it
.
each
([
{
{
...
...
web/src/features/models/components/data-table-row-actions.tsx
View file @
0e0ba152
...
@@ -28,6 +28,7 @@ import {
...
@@ -28,6 +28,7 @@ import {
DropdownMenuItem
,
DropdownMenuItem
,
DropdownMenuShortcut
,
DropdownMenuShortcut
,
}
from
'@/components/ui/dropdown-menu'
}
from
'@/components/ui/dropdown-menu'
import
{
useCanEditModelPricing
}
from
'@/features/model-pricing/api'
import
{
handleToggleModelStatus
,
isModelEnabled
}
from
'../lib'
import
{
handleToggleModelStatus
,
isModelEnabled
}
from
'../lib'
import
type
{
Model
}
from
'../types'
import
type
{
Model
}
from
'../types'
...
@@ -40,6 +41,7 @@ interface DataTableRowActionsProps {
...
@@ -40,6 +41,7 @@ interface DataTableRowActionsProps {
export
function
DataTableRowActions
({
row
}:
DataTableRowActionsProps
)
{
export
function
DataTableRowActions
({
row
}:
DataTableRowActionsProps
)
{
const
{
t
}
=
useTranslation
()
const
{
t
}
=
useTranslation
()
const
canPrice
=
useCanEditModelPricing
()
const
model
=
row
.
original
const
model
=
row
.
original
const
{
setOpen
,
setCurrentRow
}
=
useModels
()
const
{
setOpen
,
setCurrentRow
}
=
useModels
()
const
queryClient
=
useQueryClient
()
const
queryClient
=
useQueryClient
()
...
@@ -66,6 +68,19 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
...
@@ -66,6 +68,19 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
{
t
(
'Edit'
)
}
{
t
(
'Edit'
)
}
</
Button
>
</
Button
>
{
canPrice
&&
(
<
Button
variant=
'ghost'
size=
'sm'
onClick=
{
()
=>
{
setCurrentRow
(
model
)
setOpen
(
'price-model'
)
}
}
>
{
t
(
'Pricing'
)
}
</
Button
>
)
}
<
DataTableRowActionMenu
ariaLabel=
{
t
(
'Open menu'
)
}
>
<
DataTableRowActionMenu
ariaLabel=
{
t
(
'Open menu'
)
}
>
<
DropdownMenuItem
onClick=
{
handleToggleStatus
}
>
<
DropdownMenuItem
onClick=
{
handleToggleStatus
}
>
{
toggleLabel
}
{
toggleLabel
}
...
...
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/>.
...
@@ -16,7 +16,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
For commercial licensing, please contact support@quantumnous.com
*/
*/
import
{
Combobox
}
from
'@/components/ui/combobox'
import
{
zodResolver
}
from
'@hookform/resolvers/zod'
import
{
zodResolver
}
from
'@hookform/resolvers/zod'
import
{
useMutation
,
useQuery
,
useQueryClient
}
from
'@tanstack/react-query'
import
{
useMutation
,
useQuery
,
useQueryClient
}
from
'@tanstack/react-query'
import
{
AxiosError
}
from
'axios'
import
{
AxiosError
}
from
'axios'
...
@@ -41,6 +40,7 @@ import { LobeIconField } from '@/components/lobe-icon-field'
...
@@ -41,6 +40,7 @@ import { LobeIconField } from '@/components/lobe-icon-field'
import
{
TagInput
}
from
'@/components/tag-input'
import
{
TagInput
}
from
'@/components/tag-input'
import
{
Alert
,
AlertDescription
}
from
'@/components/ui/alert'
import
{
Alert
,
AlertDescription
}
from
'@/components/ui/alert'
import
{
Button
}
from
'@/components/ui/button'
import
{
Button
}
from
'@/components/ui/button'
import
{
Combobox
}
from
'@/components/ui/combobox'
import
{
import
{
Form
,
Form
,
FormControl
,
FormControl
,
...
@@ -53,7 +53,6 @@ import {
...
@@ -53,7 +53,6 @@ import {
import
{
Input
}
from
'@/components/ui/input'
import
{
Input
}
from
'@/components/ui/input'
import
{
Label
}
from
'@/components/ui/label'
import
{
Label
}
from
'@/components/ui/label'
import
{
RadioGroup
,
RadioGroupItem
}
from
'@/components/ui/radio-group'
import
{
RadioGroup
,
RadioGroupItem
}
from
'@/components/ui/radio-group'
import
{
import
{
Sheet
,
Sheet
,
SheetContent
,
SheetContent
,
...
@@ -83,14 +82,19 @@ export function ModelMutateDrawer(props: {
...
@@ -83,14 +82,19 @@ export function ModelMutateDrawer(props: {
open
:
boolean
open
:
boolean
onOpenChange
:
(
open
:
boolean
)
=>
void
onOpenChange
:
(
open
:
boolean
)
=>
void
currentRow
?:
Model
|
null
currentRow
?:
Model
|
null
initialSection
?:
'metadata'
|
'pricing'
})
{
})
{
const
{
t
}
=
useTranslation
()
const
{
t
}
=
useTranslation
()
const
queryClient
=
useQueryClient
()
const
queryClient
=
useQueryClient
()
const
currentRow
=
props
.
currentRow
const
currentRow
=
props
.
currentRow
const
isEditing
=
Boolean
(
currentRow
?.
id
)
const
isEditing
=
Boolean
(
currentRow
?.
id
)
const
[
section
,
setSection
]
=
useState
(
'metadata'
)
const
[
section
,
setSection
]
=
useState
<
string
>
(
props
.
initialSection
??
'metadata'
)
const
[
pricingName
,
setPricingName
]
=
useState
(
''
)
const
[
pricingName
,
setPricingName
]
=
useState
(
''
)
const
[
pricingVisited
,
setPricingVisited
]
=
useState
(
false
)
const
[
pricingVisited
,
setPricingVisited
]
=
useState
(
props
.
initialSection
===
'pricing'
)
const
[
pricingDirty
,
setPricingDirty
]
=
useState
(
false
)
const
[
pricingDirty
,
setPricingDirty
]
=
useState
(
false
)
const
[
pendingPricingName
,
setPendingPricingName
]
=
useState
<
string
|
null
>
(
const
[
pendingPricingName
,
setPendingPricingName
]
=
useState
<
string
|
null
>
(
null
null
...
@@ -130,6 +134,16 @@ export function ModelMutateDrawer(props: {
...
@@ -130,6 +134,16 @@ export function ModelMutateDrawer(props: {
const
savedModel
=
modelQuery
.
data
??
currentRow
const
savedModel
=
modelQuery
.
data
??
currentRow
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
!
props
.
open
)
return
setSection
(
props
.
initialSection
??
'metadata'
)
setPricingVisited
(
props
.
initialSection
===
'pricing'
)
setPricingName
(
''
)
setPricingDirty
(
false
)
setPendingPricingName
(
null
)
setCloseConfirm
(
false
)
},
[
props
.
open
,
props
.
initialSection
,
currentRow
?.
id
])
useEffect
(()
=>
{
if
(
!
props
.
open
)
{
if
(
!
props
.
open
)
{
loadedKey
.
current
=
''
loadedKey
.
current
=
''
return
return
...
@@ -149,10 +163,6 @@ export function ModelMutateDrawer(props: {
...
@@ -149,10 +163,6 @@ export function ModelMutateDrawer(props: {
)
)
)
)
loadedKey
.
current
=
key
loadedKey
.
current
=
key
setSection
(
'metadata'
)
setPricingName
(
''
)
setPricingVisited
(
false
)
setPricingDirty
(
false
)
},
[
props
.
open
,
currentRow
,
isEditing
,
modelQuery
.
data
,
form
])
},
[
props
.
open
,
currentRow
,
isEditing
,
modelQuery
.
data
,
form
])
const
save
=
useMutation
({
const
save
=
useMutation
({
...
@@ -220,7 +230,9 @@ export function ModelMutateDrawer(props: {
...
@@ -220,7 +230,9 @@ export function ModelMutateDrawer(props: {
return
(
return
(
<>
<>
<
Sheet
open=
{
props
.
open
}
onOpenChange=
{
close
}
>
<
Sheet
open=
{
props
.
open
}
onOpenChange=
{
close
}
>
<
SheetContent
className=
{
sideDrawerContentClassName
(
'sm:max-w-3xl'
)
}
>
<
SheetContent
className=
{
sideDrawerContentClassName
(
'sm:max-w-[1280px]'
)
}
>
<
SheetHeader
className=
{
sideDrawerHeaderClassName
()
}
>
<
SheetHeader
className=
{
sideDrawerHeaderClassName
()
}
>
<
SheetTitle
className=
'pr-6 break-all'
>
<
SheetTitle
className=
'pr-6 break-all'
>
{
isEditing
?
currentRow
?.
model_name
:
t
(
'Create Model'
)
}
{
isEditing
?
currentRow
?.
model_name
:
t
(
'Create Model'
)
}
...
@@ -239,12 +251,25 @@ export function ModelMutateDrawer(props: {
...
@@ -239,12 +251,25 @@ export function ModelMutateDrawer(props: {
}
}
}
}
className=
'shrink-0 px-4'
className=
'shrink-0 px-4'
>
>
<
TabsList
className=
'w-full'
>
<
TabsList
className=
'grid w-full grid-cols-3 group-data-horizontal/tabs:h-auto'
>
<
TabsTrigger
value=
'metadata'
>
{
t
(
'Model metadata'
)
}
</
TabsTrigger
>
<
TabsTrigger
<
TabsTrigger
value=
'pricing'
disabled=
{
!
isEditing
}
>
value=
'metadata'
className=
'h-auto min-w-0 whitespace-normal'
>
{
t
(
'Model metadata'
)
}
</
TabsTrigger
>
<
TabsTrigger
value=
'pricing'
disabled=
{
!
isEditing
}
className=
'h-auto min-w-0 whitespace-normal'
>
{
t
(
'Pricing'
)
}
{
t
(
'Pricing'
)
}
</
TabsTrigger
>
</
TabsTrigger
>
<
TabsTrigger
value=
'connections'
disabled=
{
!
isEditing
}
>
<
TabsTrigger
value=
'connections'
disabled=
{
!
isEditing
}
className=
'h-auto min-w-0 whitespace-normal'
>
{
t
(
'Channels and groups'
)
}
{
t
(
'Channels and groups'
)
}
</
TabsTrigger
>
</
TabsTrigger
>
</
TabsList
>
</
TabsList
>
...
@@ -347,20 +372,22 @@ export function ModelMutateDrawer(props: {
...
@@ -347,20 +372,22 @@ export function ModelMutateDrawer(props: {
render=
{
({
field
})
=>
(
render=
{
({
field
})
=>
(
<
FormItem
>
<
FormItem
>
<
FormLabel
>
{
t
(
'Vendor'
)
}
</
FormLabel
>
<
FormLabel
>
{
t
(
'Vendor'
)
}
</
FormLabel
>
<
FormControl
><
Combobox
<
FormControl
>
options=
{
vendors
.
map
((
vendor
)
=>
({
<
Combobox
value
:
String
(
vendor
.
id
),
options=
{
vendors
.
map
((
vendor
)
=>
({
label
:
vendor
.
name
,
value
:
String
(
vendor
.
id
),
}))
}
label
:
vendor
.
name
,
onValueChange=
{
(
value
)
=>
}))
}
field
.
onChange
(
onValueChange=
{
(
value
)
=>
value
?
Number
.
parseInt
(
value
)
:
undefined
field
.
onChange
(
)
value
?
Number
.
parseInt
(
value
)
:
undefined
}
)
value=
{
field
.
value
?
String
(
field
.
value
)
:
null
}
}
className=
'w-full'
value=
{
field
.
value
?
String
(
field
.
value
)
:
null
}
placeholder=
{
t
(
'Select vendor'
)
}
className=
'w-full'
/></
FormControl
>
placeholder=
{
t
(
'Select vendor'
)
}
/>
</
FormControl
>
<
FormMessage
/>
<
FormMessage
/>
</
FormItem
>
</
FormItem
>
)
}
)
}
...
@@ -445,12 +472,16 @@ placeholder={t('Select vendor')}
...
@@ -445,12 +472,16 @@ placeholder={t('Select vendor')}
{
t
(
'Endpoints'
)
}
{
t
(
'Endpoints'
)
}
</
h3
>
</
h3
>
<
Combobox
<
Combobox
options=
{
Object
.
keys
(
ENDPOINT_TEMPLATES
).
map
((
key
)
=>
({
value
:
key
,
label
:
key
}))
}
options=
{
Object
.
keys
(
ENDPOINT_TEMPLATES
).
map
(
onValueChange=
{
(
value
:
string
|
null
)
=>
{
if
(
value
)
handleFillEndpointTemplate
(
value
)
}
}
(
key
)
=>
({
value
:
key
,
label
:
key
})
className=
'w-[200px]'
)
}
placeholder=
{
t
(
'Load template...'
)
}
onValueChange=
{
(
value
:
string
|
null
)
=>
{
aria
-
label=
{
t
(
'Load template...'
)
}
if
(
value
)
handleFillEndpointTemplate
(
value
)
/>
}
}
className=
'w-[200px]'
placeholder=
{
t
(
'Load template...'
)
}
aria
-
label=
{
t
(
'Load template...'
)
}
/>
</
div
>
</
div
>
<
FormField
<
FormField
...
@@ -591,8 +622,8 @@ placeholder={t('Select vendor')}
...
@@ -591,8 +622,8 @@ placeholder={t('Select vendor')}
)
}
)
}
</
p
>
</
p
>
<
Combobox
<
Combobox
value=
{
pricingName
}
value=
{
pricingName
}
onValueChange=
{
(
value
)
=>
{
onValueChange=
{
(
value
)
=>
{
if
(
pricingDirty
)
{
if
(
pricingDirty
)
{
setPendingPricingName
(
value
??
''
)
setPendingPricingName
(
value
??
''
)
setCloseConfirm
(
true
)
setCloseConfirm
(
true
)
...
@@ -600,14 +631,14 @@ onValueChange={(value) => {
...
@@ -600,14 +631,14 @@ onValueChange={(value) => {
setPricingName
(
value
??
''
)
setPricingName
(
value
??
''
)
}
}
}
}
}
}
options=
{
(
savedModel
.
matched_models
??
[]).
map
((
name
)
=>
({
options=
{
(
savedModel
.
matched_models
??
[]).
map
((
name
)
=>
({
value
:
name
,
value
:
name
,
label
:
name
,
label
:
name
,
}))
}
}))
}
aria
-
label=
{
t
(
'Select model'
)
}
aria
-
label=
{
t
(
'Select model'
)
}
className=
'w-full'
className=
'w-full'
placeholder=
{
t
(
'Select model'
)
}
placeholder=
{
t
(
'Select model'
)
}
/>
/>
</
div
>
</
div
>
)
}
)
}
{
(
savedModel
.
name_rule
===
0
||
pricingName
)
&&
(
{
(
savedModel
.
name_rule
===
0
||
pricingName
)
&&
(
...
...
web/src/features/models/components/models-columns.tsx
View file @
0e0ba152
...
@@ -265,7 +265,7 @@ export function useModelsColumns(
...
@@ -265,7 +265,7 @@ export function useModelsColumns(
header
:
t
(
'Actions'
),
header
:
t
(
'Actions'
),
enableSorting
:
false
,
enableSorting
:
false
,
enableHiding
:
false
,
enableHiding
:
false
,
size
:
105
,
size
:
canPrice
?
170
:
105
,
cell
:
({
row
})
=>
<
DataTableRowActions
row=
{
row
}
/>,
cell
:
({
row
})
=>
<
DataTableRowActions
row=
{
row
}
/>,
},
},
{
{
...
...
web/src/features/models/components/models-dialogs.tsx
View file @
0e0ba152
...
@@ -43,7 +43,12 @@ export function ModelsDialogs() {
...
@@ -43,7 +43,12 @@ export function ModelsDialogs() {
/>
/>
{
/* Model Create/Update Drawer */
}
{
/* Model Create/Update Drawer */
}
<
ModelMutateDrawer
<
ModelMutateDrawer
open=
{
open
===
'create-model'
||
open
===
'update-model'
}
open=
{
open
===
'create-model'
||
open
===
'update-model'
||
open
===
'price-model'
}
initialSection=
{
open
===
'price-model'
?
'pricing'
:
'metadata'
}
onOpenChange=
{
(
v
)
=>
!
v
&&
setOpen
(
null
)
}
onOpenChange=
{
(
v
)
=>
!
v
&&
setOpen
(
null
)
}
currentRow=
{
currentRow
}
currentRow=
{
currentRow
}
/>
/>
...
...
web/src/features/models/components/models-provider.tsx
View file @
0e0ba152
...
@@ -34,6 +34,7 @@ import type {
...
@@ -34,6 +34,7 @@ import type {
type
DialogType
=
type
DialogType
=
|
'create-model'
|
'create-model'
|
'update-model'
|
'update-model'
|
'price-model'
|
'create-vendor'
|
'create-vendor'
|
'vendors'
|
'vendors'
|
'price-sync'
|
'price-sync'
...
...
web/src/features/pricing/__tests__/dynamic-price.test.ts
View file @
0e0ba152
...
@@ -319,7 +319,10 @@ describe('task dynamic pricing', () => {
...
@@ -319,7 +319,10 @@ describe('task dynamic pricing', () => {
assert
.
ok
(
tokenSummary
)
assert
.
ok
(
tokenSummary
)
assert
.
equal
(
tokenSummary
.
primaryEntries
[
0
]?.
shortLabel
,
'tokens'
)
assert
.
equal
(
tokenSummary
.
primaryEntries
[
0
]?.
shortLabel
,
'tokens'
)
assert
.
equal
(
tokenSummary
.
primaryEntries
[
0
]?.
labelKind
,
'schema'
)
assert
.
equal
(
tokenSummary
.
primaryEntries
[
0
]?.
labelKind
,
'schema'
)
assert
.
equal
(
tokenSummary
.
secondaryEntries
[
0
]?.
shortLabel
,
'Base'
)
assert
.
equal
(
tokenSummary
.
secondaryEntries
[
0
]?.
shortLabel
,
'Additional charge'
)
assert
.
equal
(
tokenSummary
.
secondaryEntries
[
0
]?.
labelKind
,
'i18n'
)
assert
.
equal
(
tokenSummary
.
secondaryEntries
[
0
]?.
labelKind
,
'i18n'
)
const
multiFieldModel
=
pricingModel
({
const
multiFieldModel
=
pricingModel
({
...
...
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 {
...
@@ -34,7 +34,6 @@ import {
MATCH_LT
,
MATCH_LT
,
MATCH_RANGE
,
MATCH_RANGE
,
SOURCE_TIME
,
SOURCE_TIME
,
parseTaskTiersFromExpr
,
parseTiersFromExpr
,
parseTiersFromExpr
,
requestRuleGroupsFromTrace
,
requestRuleGroupsFromTrace
,
splitBillingExprAndRequestRules
,
splitBillingExprAndRequestRules
,
...
@@ -47,8 +46,16 @@ import {
...
@@ -47,8 +46,16 @@ import {
type
TierCondition
,
type
TierCondition
,
}
from
'../lib/billing-expr'
}
from
'../lib/billing-expr'
import
{
isBreakdownTierMatched
}
from
'../lib/breakdown-tier-match'
import
{
isBreakdownTierMatched
}
from
'../lib/breakdown-tier-match'
import
type
{
DynamicPriceLabelKind
}
from
'../lib/dynamic-price'
import
{
import
{
getTaskMatrixDisplayTiers
}
from
'../lib/task-matrix-display'
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'
import
type
{
BillingUsageSchema
,
BillingUsageUnit
}
from
'../types'
type
DynamicPricingBreakdownProps
=
{
type
DynamicPricingBreakdownProps
=
{
...
@@ -74,6 +81,10 @@ type DynamicPricingBreakdownProps = {
...
@@ -74,6 +81,10 @@ type DynamicPricingBreakdownProps = {
*/
*/
compact
?:
boolean
compact
?:
boolean
usageSchema
?:
BillingUsageSchema
usageSchema
?:
BillingUsageSchema
taskPriceOptions
?:
Pick
<
DynamicPriceOptions
,
'showRechargePrice'
|
'priceRate'
|
'usdExchangeRate'
>
/**
/**
* Settlement usage facts from the consume log. Used to highlight the
* Settlement usage facts from the consume log. Used to highlight the
* expanded matrix display row when the engine label no longer matches
* expanded matrix display row when the engine label no longer matches
...
@@ -97,7 +108,7 @@ function breakdownPriceFieldLabel(
...
@@ -97,7 +108,7 @@ function breakdownPriceFieldLabel(
t
:
(
key
:
string
)
=>
string
t
:
(
key
:
string
)
=>
string
):
ReactNode
{
):
ReactNode
{
if
(
field
.
labelKind
===
'schema'
)
{
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
)
return
t
(
field
.
label
)
}
}
...
@@ -153,14 +164,18 @@ function isTaskBreakdownTier(tier: BreakdownTier): tier is ParsedTaskTier {
...
@@ -153,14 +164,18 @@ function isTaskBreakdownTier(tier: BreakdownTier): tier is ParsedTaskTier {
function
formatBreakdownConditionSummary
(
function
formatBreakdownConditionSummary
(
tier
:
BreakdownTier
,
tier
:
BreakdownTier
,
t
:
(
key
:
string
)
=>
string
t
:
(
key
:
string
)
=>
string
,
schema
:
BillingUsageSchema
|
undefined
,
language
:
string
,
tierCount
:
number
):
string
{
):
string
{
if
(
!
isTaskBreakdownTier
(
tier
))
{
if
(
!
isTaskBreakdownTier
(
tier
))
{
return
formatConditionSummary
(
tier
.
conditions
,
t
)
return
formatConditionSummary
(
tier
.
conditions
,
t
)
}
}
return
tier
.
conditions
return
(
.
map
((
condition
)
=>
`
${
condition
.
field
}
=
${
condition
.
value
}
`
)
taskPricingConditions
(
tier
.
conditions
,
schema
,
language
,
t
)
||
.
join
(
' && '
)
t
(
tierCount
>
1
?
'Other cases'
:
'All requests'
)
)
}
}
function
formatBreakdownPrice
(
function
formatBreakdownPrice
(
...
@@ -168,16 +183,17 @@ function formatBreakdownPrice(
...
@@ -168,16 +183,17 @@ function formatBreakdownPrice(
field
:
BreakdownPriceField
,
field
:
BreakdownPriceField
,
symbol
:
string
,
symbol
:
string
,
rate
:
number
,
rate
:
number
,
t
:
(
key
:
string
)
=>
string
t
:
(
key
:
string
)
=>
string
,
taskPriceOptions
:
DynamicPricingBreakdownProps
[
'taskPriceOptions'
]
):
string
{
):
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
===
'second'
)
return
`
${
amount
}
/
${
t
(
's'
)}
`
if
(
field
.
unit
===
'count'
)
return
`
${
amount
}
/
${
t
(
'unit'
)}
`
if
(
field
.
unit
===
'count'
)
return
`
${
amount
}
/
${
t
(
'unit'
)}
`
if
(
field
.
unit
===
'credit'
)
return
`
${
amount
}
/
${
t
(
'credit'
)}
`
if
(
field
.
unit
===
'credit'
)
return
`
${
amount
}
/
${
t
(
'credit'
)}
`
if
(
if
(
field
.
unit
===
'token'
&&
field
.
labelKind
===
'schema'
)
{
field
.
unit
===
'token'
&&
!
BILLING_PRICING_VARS
.
some
((
variable
)
=>
variable
.
field
===
field
.
id
)
)
{
return
`
${
amount
}
/
${
t
(
'1M token'
)}
`
return
`
${
amount
}
/
${
t
(
'1M token'
)}
`
}
}
if
(
field
.
unit
===
'request'
)
return
`
${
amount
}
/
${
t
(
'request'
)}
`
if
(
field
.
unit
===
'request'
)
return
`
${
amount
}
/
${
t
(
'request'
)}
`
...
@@ -243,9 +259,10 @@ export function DynamicPricingBreakdown({
...
@@ -243,9 +259,10 @@ export function DynamicPricingBreakdown({
hideCacheColumns
=
false
,
hideCacheColumns
=
false
,
compact
=
false
,
compact
=
false
,
usageSchema
,
usageSchema
,
taskPriceOptions
,
usageFacts
,
usageFacts
,
}:
DynamicPricingBreakdownProps
)
{
}:
DynamicPricingBreakdownProps
)
{
const
{
t
}
=
useTranslation
()
const
{
t
,
i18n
}
=
useTranslation
()
const
expr
=
billingExpr
||
''
const
expr
=
billingExpr
||
''
const
currency
=
useSystemConfigStore
((
s
)
=>
s
.
config
.
currency
)
const
currency
=
useSystemConfigStore
((
s
)
=>
s
.
config
.
currency
)
...
@@ -264,18 +281,9 @@ export function DynamicPricingBreakdown({
...
@@ -264,18 +281,9 @@ export function DynamicPricingBreakdown({
const
{
tiers
,
ruleGroups
}
=
useMemo
(()
=>
{
const
{
tiers
,
ruleGroups
}
=
useMemo
(()
=>
{
const
split
=
splitBillingExprAndRequestRules
(
expr
)
const
split
=
splitBillingExprAndRequestRules
(
expr
)
const
matrixTiers
=
getTaskMatrixDisplayTiers
(
const
parsedTiers
=
usageSchema
split
.
billingExpr
,
?
getTaskPricingDisplayTiers
(
split
.
billingExpr
,
usageSchema
)
usageSchema
:
parseTiersFromExpr
(
split
.
billingExpr
)
)
let
parsedTiers
if
(
matrixTiers
)
{
parsedTiers
=
matrixTiers
}
else
if
(
usageSchema
)
{
parsedTiers
=
parseTaskTiersFromExpr
(
split
.
billingExpr
,
usageSchema
)
}
else
{
parsedTiers
=
parseTiersFromExpr
(
split
.
billingExpr
)
}
const
parsedRules
=
const
parsedRules
=
requestRules
!=
null
requestRules
!=
null
?
requestRuleGroupsFromTrace
(
requestRules
)
?
requestRuleGroupsFromTrace
(
requestRules
)
...
@@ -336,7 +344,7 @@ export function DynamicPricingBreakdown({
...
@@ -336,7 +344,7 @@ export function DynamicPricingBreakdown({
.
sort
(([
left
],
[
right
])
=>
left
.
localeCompare
(
right
))
.
sort
(([
left
],
[
right
])
=>
left
.
localeCompare
(
right
))
.
map
(([
field
,
definition
])
=>
({
.
map
(([
field
,
definition
])
=>
({
id
:
field
,
id
:
field
,
label
:
field
,
label
:
taskPriceLabel
(
definition
.
description
,
field
,
i18n
.
language
)
,
labelKind
:
'schema'
as
const
,
labelKind
:
'schema'
as
const
,
unit
:
definition
.
unit
as
BillingUsageUnit
,
unit
:
definition
.
unit
as
BillingUsageUnit
,
value
:
(
tier
:
BreakdownTier
)
=>
value
:
(
tier
:
BreakdownTier
)
=>
...
@@ -347,7 +355,7 @@ export function DynamicPricingBreakdown({
...
@@ -347,7 +355,7 @@ export function DynamicPricingBreakdown({
)
{
)
{
fields
.
push
({
fields
.
push
({
id
:
'constant'
,
id
:
'constant'
,
label
:
'
Base
charge'
,
label
:
'
Additional
charge'
,
labelKind
:
'i18n'
,
labelKind
:
'i18n'
,
unit
:
'request'
,
unit
:
'request'
,
value
:
(
tier
:
BreakdownTier
)
=>
value
:
(
tier
:
BreakdownTier
)
=>
...
@@ -379,7 +387,7 @@ export function DynamicPricingBreakdown({
...
@@ -379,7 +387,7 @@ export function DynamicPricingBreakdown({
return
(
return
(
<
section
className=
{
cn
(
'min-w-0'
,
!
compact
&&
'py-3 sm:py-4'
)
}
>
<
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'
>
<
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'
>
<
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'
/>
<
TagIcon
className=
'size-3.5'
/>
...
@@ -397,18 +405,26 @@ export function DynamicPricingBreakdown({
...
@@ -397,18 +405,26 @@ export function DynamicPricingBreakdown({
{
hasTiers
&&
(
{
hasTiers
&&
(
<
div
className=
{
cn
(
compact
?
cn
(
hasRules
&&
'mb-2'
)
:
'mb-3 sm:mb-4'
)
}
>
<
div
className=
{
cn
(
compact
?
cn
(
hasRules
&&
'mb-2'
)
:
'mb-3 sm:mb-4'
)
}
>
<
div
{
!
usageSchema
&&
(
className=
{
<
div
compact
className=
{
?
'text-muted-foreground mb-1.5 text-xs font-medium'
compact
:
'text-foreground mb-2 text-sm font-semibold'
?
'text-muted-foreground mb-1.5 text-xs font-medium'
}
:
'text-foreground mb-2 text-sm font-semibold'
>
}
{
t
(
'Tiered price table'
)
}
>
</
div
>
{
t
(
'Tiered price table'
)
}
</
div
>
)
}
<
div
className=
'space-y-1.5 sm:hidden'
>
<
div
className=
'space-y-1.5 sm:hidden'
>
{
tiers
.
map
((
tier
)
=>
{
{
tiers
.
map
((
tier
)
=>
{
const
condSummary
=
formatBreakdownConditionSummary
(
tier
,
t
)
const
condSummary
=
formatBreakdownConditionSummary
(
tier
,
t
,
usageSchema
,
i18n
.
language
,
tiers
.
length
)
const
isMatched
=
isBreakdownTierMatched
(
const
isMatched
=
isBreakdownTierMatched
(
tier
,
tier
,
tiers
,
tiers
,
...
@@ -428,12 +444,14 @@ export function DynamicPricingBreakdown({
...
@@ -428,12 +444,14 @@ export function DynamicPricingBreakdown({
)
}
)
}
>
>
<
div
className=
'mb-1.5 flex flex-wrap items-center gap-1.5'
>
<
div
className=
'mb-1.5 flex flex-wrap items-center gap-1.5'
>
<
Badge
{
!
usageSchema
&&
(
variant=
'secondary'
<
Badge
className=
'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
variant=
'secondary'
>
className=
'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
{
tier
.
label
||
t
(
'Default'
)
}
>
</
Badge
>
{
tier
.
label
||
t
(
'Default'
)
}
</
Badge
>
)
}
{
isMatched
&&
(
{
isMatched
&&
(
<
Badge
<
Badge
variant=
'secondary'
variant=
'secondary'
...
@@ -448,17 +466,22 @@ export function DynamicPricingBreakdown({
...
@@ -448,17 +466,22 @@ export function DynamicPricingBreakdown({
{
condSummary
}
{
condSummary
}
</
div
>
</
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
)
=>
{
{
visiblePriceFields
.
map
((
field
)
=>
{
const
value
=
field
.
value
(
tier
)
const
value
=
field
.
value
(
tier
)
return
(
return
(
<
div
key=
{
field
.
id
}
className=
'min-w-0'
>
<
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
)
}
{
breakdownPriceFieldLabel
(
field
,
t
)
}
</
div
>
</
div
>
<
div
<
div
className=
{
cn
(
className=
{
cn
(
'
truncate
font-mono'
,
'
break-words
font-mono'
,
compact
?
'text-xs'
:
'text-sm font-semibold'
compact
?
'text-xs'
:
'text-sm font-semibold'
)
}
)
}
>
>
...
@@ -468,7 +491,8 @@ export function DynamicPricingBreakdown({
...
@@ -468,7 +491,8 @@ export function DynamicPricingBreakdown({
field
,
field
,
symbol
,
symbol
,
rate
,
rate
,
t
t
,
taskPriceOptions
)
)
:
'-'
}
:
'-'
}
</
div
>
</
div
>
...
@@ -505,14 +529,23 @@ export function DynamicPricingBreakdown({
...
@@ -505,14 +529,23 @@ export function DynamicPricingBreakdown({
columns=
{
[
columns=
{
[
{
{
id
:
'tier'
,
id
:
'tier'
,
header
:
t
(
'Tier'
),
header
:
usageSchema
?
t
(
'Applicable conditions'
)
:
t
(
'Tier'
),
className
:
cn
(
className
:
cn
(
'text-muted-foreground py-2 font-medium'
,
'text-muted-foreground py-2 font-medium'
,
compact
&&
'h-8'
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
)
=>
{
cell
:
(
tier
)
=>
{
const
condSummary
=
formatBreakdownConditionSummary
(
tier
,
t
)
const
condSummary
=
formatBreakdownConditionSummary
(
tier
,
t
,
usageSchema
,
i18n
.
language
,
tiers
.
length
)
const
isMatched
=
isBreakdownTierMatched
(
const
isMatched
=
isBreakdownTierMatched
(
tier
,
tier
,
tiers
,
tiers
,
...
@@ -522,12 +555,14 @@ export function DynamicPricingBreakdown({
...
@@ -522,12 +555,14 @@ export function DynamicPricingBreakdown({
return
(
return
(
<>
<>
<
div
className=
'flex flex-wrap items-center gap-1.5'
>
<
div
className=
'flex flex-wrap items-center gap-1.5'
>
<
Badge
{
!
usageSchema
&&
(
variant=
'secondary'
<
Badge
className=
'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
variant=
'secondary'
>
className=
'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
{
tier
.
label
||
t
(
'Default'
)
}
>
</
Badge
>
{
tier
.
label
||
t
(
'Default'
)
}
</
Badge
>
)
}
{
isMatched
&&
(
{
isMatched
&&
(
<
Badge
<
Badge
variant=
'secondary'
variant=
'secondary'
...
@@ -561,7 +596,14 @@ export function DynamicPricingBreakdown({
...
@@ -561,7 +596,14 @@ export function DynamicPricingBreakdown({
const
value
=
field
.
value
(
tier
)
const
value
=
field
.
value
(
tier
)
return
value
>
0
?
(
return
value
>
0
?
(
<
span
className=
{
cn
(
!
compact
&&
'font-semibold'
)
}
>
<
span
className=
{
cn
(
!
compact
&&
'font-semibold'
)
}
>
{
formatBreakdownPrice
(
value
,
field
,
symbol
,
rate
,
t
)
}
{
formatBreakdownPrice
(
value
,
field
,
symbol
,
rate
,
t
,
taskPriceOptions
)
}
</
span
>
</
span
>
)
:
(
)
:
(
'-'
'-'
...
...
web/src/features/pricing/components/model-card.tsx
View file @
0e0ba152
...
@@ -37,7 +37,7 @@ import {
...
@@ -37,7 +37,7 @@ import {
import
{
parseTags
}
from
'../lib/filters'
import
{
parseTags
}
from
'../lib/filters'
import
{
isTokenBasedModel
}
from
'../lib/model-helpers'
import
{
isTokenBasedModel
}
from
'../lib/model-helpers'
import
{
formatPrice
,
formatRequestPrice
}
from
'../lib/price'
import
{
formatPrice
,
formatRequestPrice
}
from
'../lib/price'
import
{
getTaskNumberFields
}
from
'../lib/task-expr
'
import
{
taskPriceLabel
}
from
'../lib/task-price-display
'
import
type
{
PricingModel
,
PriceType
,
TokenUnit
}
from
'../types'
import
type
{
PricingModel
,
PriceType
,
TokenUnit
}
from
'../types'
import
{
ModelBillingModeBadge
}
from
'./model-billing-mode-badge'
import
{
ModelBillingModeBadge
}
from
'./model-billing-mode-badge'
import
{
ModelPerfBadge
,
type
ModelPerfBadgeData
}
from
'./model-perf-badge'
import
{
ModelPerfBadge
,
type
ModelPerfBadgeData
}
from
'./model-perf-badge'
...
@@ -54,7 +54,7 @@ export interface ModelCardProps {
...
@@ -54,7 +54,7 @@ export interface ModelCardProps {
}
}
export
const
ModelCard
=
memo
(
function
ModelCard
(
props
:
ModelCardProps
)
{
export
const
ModelCard
=
memo
(
function
ModelCard
(
props
:
ModelCardProps
)
{
const
{
t
}
=
useTranslation
()
const
{
t
,
i18n
}
=
useTranslation
()
const
tokenUnit
=
props
.
tokenUnit
??
DEFAULT_TOKEN_UNIT
const
tokenUnit
=
props
.
tokenUnit
??
DEFAULT_TOKEN_UNIT
const
priceRate
=
props
.
priceRate
??
1
const
priceRate
=
props
.
priceRate
??
1
const
usdExchangeRate
=
props
.
usdExchangeRate
??
1
const
usdExchangeRate
=
props
.
usdExchangeRate
??
1
...
@@ -85,8 +85,6 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
...
@@ -85,8 +85,6 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
?
getDynamicPricingSummary
(
props
.
model
,
dynamicPriceOptions
)
?
getDynamicPricingSummary
(
props
.
model
,
dynamicPriceOptions
)
:
null
:
null
const
cardExamplePrice
=
getCardExamplePrice
(
props
.
model
,
dynamicPriceOptions
)
const
cardExamplePrice
=
getCardExamplePrice
(
props
.
model
,
dynamicPriceOptions
)
const
showTaskFieldLabels
=
getTaskNumberFields
(
props
.
model
.
billing_usage_schema
).
length
>
1
let
priceSummary
:
ReactNode
let
priceSummary
:
ReactNode
if
(
dynamicSummary
)
{
if
(
dynamicSummary
)
{
if
(
dynamicSummary
.
isSpecialExpression
)
{
if
(
dynamicSummary
.
isSpecialExpression
)
{
...
@@ -108,9 +106,11 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
...
@@ -108,9 +106,11 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
let
label
:
ReactNode
=
null
let
label
:
ReactNode
=
null
if
(
entry
.
labelKind
!==
'schema'
)
{
if
(
entry
.
labelKind
!==
'schema'
)
{
label
=
t
(
entry
.
shortLabel
)
label
=
t
(
entry
.
shortLabel
)
}
else
if
(
showTaskFieldLabels
)
{
}
else
{
label
=
(
label
=
taskPriceLabel
(
<
code
className=
'font-mono break-all'
>
{
entry
.
shortLabel
}
</
code
>
entry
.
description
,
entry
.
shortLabel
,
i18n
.
language
)
)
}
}
return
(
return
(
...
@@ -122,7 +122,9 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
...
@@ -122,7 +122,9 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
)
}
)
}
>
>
{
label
&&
(
{
label
&&
(
<
span
className=
'text-muted-foreground text-xs'
>
{
label
}
</
span
>
<
span
className=
'text-muted-foreground text-xs break-words whitespace-normal'
>
{
label
}
</
span
>
)
}
)
}
<
span
className=
'flex flex-wrap items-baseline gap-x-1 font-mono text-sm font-semibold tabular-nums'
>
<
span
className=
'flex flex-wrap items-baseline gap-x-1 font-mono text-sm font-semibold tabular-nums'
>
<
span
>
{
entry
.
formattedRange
??
entry
.
formatted
}
</
span
>
<
span
>
{
entry
.
formattedRange
??
entry
.
formatted
}
</
span
>
...
...
web/src/features/pricing/components/model-details.tsx
View file @
0e0ba152
...
@@ -60,6 +60,7 @@ import { cn } from '@/lib/utils'
...
@@ -60,6 +60,7 @@ import { cn } from '@/lib/utils'
import
{
DEFAULT_TOKEN_UNIT
}
from
'../constants'
import
{
DEFAULT_TOKEN_UNIT
}
from
'../constants'
import
{
usePricingData
}
from
'../hooks/use-pricing-data'
import
{
usePricingData
}
from
'../hooks/use-pricing-data'
import
type
{
ParsedTaskTier
}
from
'../lib/billing-expr'
import
{
import
{
formatTaskUsageUnitPrice
,
formatTaskUsageUnitPrice
,
getDynamicPriceEntries
,
getDynamicPriceEntries
,
...
@@ -79,7 +80,12 @@ import {
...
@@ -79,7 +80,12 @@ import {
getTaskEnumFields
,
getTaskEnumFields
,
getTaskNumberFields
,
getTaskNumberFields
,
}
from
'../lib/task-expr'
}
from
'../lib/task-expr'
import
{
getTaskMatrixDisplayTiers
}
from
'../lib/task-matrix-display'
import
{
getTaskPricingDisplayTiers
}
from
'../lib/task-matrix-display'
import
{
hasSimpleTaskPricing
,
taskPriceLabel
,
taskPricingConditions
,
}
from
'../lib/task-price-display'
import
type
{
import
type
{
ModelCapability
,
ModelCapability
,
PriceType
,
PriceType
,
...
@@ -104,9 +110,17 @@ function SectionTitle(props: { children: React.ReactNode }) {
...
@@ -104,9 +110,17 @@ function SectionTitle(props: { children: React.ReactNode }) {
}
}
function
DynamicPriceEntryLabel
(
props
:
{
entry
:
DynamicPriceEntry
})
{
function
DynamicPriceEntryLabel
(
props
:
{
entry
:
DynamicPriceEntry
})
{
const
{
t
}
=
useTranslation
()
const
{
t
,
i18n
}
=
useTranslation
()
if
(
props
.
entry
.
labelKind
===
'schema'
)
{
if
(
props
.
entry
.
labelKind
===
'schema'
)
{
return
<
code
className=
'font-mono'
>
{
props
.
entry
.
shortLabel
}
</
code
>
return
(
<
span
className=
'break-words whitespace-normal'
>
{
taskPriceLabel
(
props
.
entry
.
description
,
props
.
entry
.
shortLabel
,
i18n
.
language
)
}
</
span
>
)
}
}
return
t
(
props
.
entry
.
shortLabel
)
return
t
(
props
.
entry
.
shortLabel
)
}
}
...
@@ -718,7 +732,12 @@ function PriceSection(props: {
...
@@ -718,7 +732,12 @@ function PriceSection(props: {
<
section
>
<
section
>
<
SectionTitle
>
{
t
(
'Base Price'
)
}
</
SectionTitle
>
<
SectionTitle
>
{
t
(
'Base Price'
)
}
</
SectionTitle
>
{
dynamicSummary
.
primaryEntries
.
length
>
0
?
(
{
dynamicSummary
.
primaryEntries
.
length
>
0
?
(
<
div
className=
'grid grid-cols-2 gap-2'
>
<
div
className=
{
cn
(
'grid gap-2'
,
dynamicSummary
.
primaryEntries
.
length
>
1
&&
'grid-cols-2'
)
}
>
{
dynamicSummary
.
primaryEntries
.
map
((
entry
)
=>
{
{
dynamicSummary
.
primaryEntries
.
map
((
entry
)
=>
{
const
unitLabelKey
=
getDynamicPriceUnitLabelKey
(
entry
)
const
unitLabelKey
=
getDynamicPriceUnitLabelKey
(
entry
)
return
(
return
(
...
@@ -939,7 +958,7 @@ function GroupPricingSection(props: {
...
@@ -939,7 +958,7 @@ function GroupPricingSection(props: {
tokenUnit
:
TokenUnit
tokenUnit
:
TokenUnit
showRechargePrice
?:
boolean
showRechargePrice
?:
boolean
})
{
})
{
const
{
t
}
=
useTranslation
()
const
{
t
,
i18n
}
=
useTranslation
()
const
showRechargePrice
=
props
.
showRechargePrice
??
false
const
showRechargePrice
=
props
.
showRechargePrice
??
false
const
availableGroups
=
useMemo
(
const
availableGroups
=
useMemo
(
...
@@ -987,15 +1006,18 @@ function GroupPricingSection(props: {
...
@@ -987,15 +1006,18 @@ function GroupPricingSection(props: {
)
)
}
}
const
thClass
=
const
thClass
=
cn
(
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'
'text-muted-foreground py-2 text-xs font-medium whitespace-normal break-words'
,
!
props
.
model
.
billing_usage_schema
&&
'tracking-wider uppercase'
)
if
(
isDynamicPricingModel
(
props
.
model
))
{
if
(
isDynamicPricingModel
(
props
.
model
))
{
const
dynamicTiers
=
const
dynamicTiers
=
props
.
model
.
billing_usage_schema
getTaskMatrixDisplayTiers
(
?
getTaskPricingDisplayTiers
(
props
.
model
.
billing_expr
,
props
.
model
.
billing_expr
,
props
.
model
.
billing_usage_schema
props
.
model
.
billing_usage_schema
)
??
getDynamicPricingTiers
(
props
.
model
)
)
:
getDynamicPricingTiers
(
props
.
model
)
if
(
dynamicTiers
.
length
===
0
)
{
if
(
dynamicTiers
.
length
===
0
)
{
return
(
return
(
...
@@ -1082,21 +1104,39 @@ function GroupPricingSection(props: {
...
@@ -1082,21 +1104,39 @@ function GroupPricingSection(props: {
`${group}-${tier.label || tierIndex}`
`${group}-${tier.label || tierIndex}`
}
}
columns=
{
[
columns=
{
[
{
...(
hasSimpleTaskPricing
(
props
.
model
)
id
:
'tier'
,
?
[]
header
:
t
(
'Tier'
),
:
[
className
:
thClass
,
{
cellClassName
:
'text-muted-foreground py-2.5'
,
id
:
'tier'
,
cell
:
(
tier
)
=>
tier
.
label
||
t
(
'Default'
),
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
)
=>
{
...
priceFields
.
map
((
fieldEntry
)
=>
{
const
unitLabelKey
=
const
unitLabelKey
=
getDynamicPriceUnitLabelKey
(
fieldEntry
)
getDynamicPriceUnitLabelKey
(
fieldEntry
)
const
fieldLabel
=
const
fieldLabel
=
fieldEntry
.
labelKind
===
'schema'
?
(
fieldEntry
.
labelKind
===
'schema'
?
(
<
code
className=
'font-mono'
>
<
DynamicPriceEntryLabel
entry=
{
fieldEntry
}
/>
{
fieldEntry
.
shortLabel
}
</
code
>
)
:
(
)
:
(
t
(
fieldEntry
.
shortLabel
)
t
(
fieldEntry
.
shortLabel
)
)
)
...
@@ -1307,6 +1347,16 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
...
@@ -1307,6 +1347,16 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
props
.
model
.
billing_mode
===
'tiered_expr'
&&
props
.
model
.
billing_mode
===
'tiered_expr'
&&
Boolean
(
props
.
model
.
billing_expr
)
Boolean
(
props
.
model
.
billing_expr
)
const
simpleTaskPricing
=
hasSimpleTaskPricing
(
props
.
model
)
const
taskTiers
=
getTaskPricingDisplayTiers
(
props
.
model
.
billing_expr
,
props
.
model
.
billing_usage_schema
)
const
showBasePrices
=
!
props
.
model
.
billing_usage_schema
||
simpleTaskPricing
||
taskTiers
.
length
===
0
return
(
return
(
<
div
className=
'@container/details space-y-4'
>
<
div
className=
'@container/details space-y-4'
>
<
ModelHeader
model=
{
props
.
model
}
/>
<
ModelHeader
model=
{
props
.
model
}
/>
...
@@ -1333,17 +1383,24 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
...
@@ -1333,17 +1383,24 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
<
section
className=
'bg-card/60 space-y-5 rounded-xl border p-4 shadow-sm'
>
<
section
className=
'bg-card/60 space-y-5 rounded-xl border p-4 shadow-sm'
>
<
SectionTitle
>
{
t
(
'Pricing'
)
}
</
SectionTitle
>
<
SectionTitle
>
{
t
(
'Pricing'
)
}
</
SectionTitle
>
<
PriceSection
{
showBasePrices
&&
(
model=
{
props
.
model
}
<
PriceSection
priceRate=
{
props
.
priceRate
}
model=
{
props
.
model
}
usdExchangeRate=
{
props
.
usdExchangeRate
}
priceRate=
{
props
.
priceRate
}
tokenUnit=
{
props
.
tokenUnit
}
usdExchangeRate=
{
props
.
usdExchangeRate
}
showRechargePrice=
{
showRechargePrice
}
tokenUnit=
{
props
.
tokenUnit
}
/>
showRechargePrice=
{
showRechargePrice
}
{
isDynamic
&&
(
/>
)
}
{
isDynamic
&&
!
simpleTaskPricing
&&
(
<
DynamicPricingBreakdown
<
DynamicPricingBreakdown
billingExpr=
{
props
.
model
.
billing_expr
}
billingExpr=
{
props
.
model
.
billing_expr
}
usageSchema=
{
props
.
model
.
billing_usage_schema
}
usageSchema=
{
props
.
model
.
billing_usage_schema
}
taskPriceOptions=
{
{
showRechargePrice
,
priceRate
:
props
.
priceRate
,
usdExchangeRate
:
props
.
usdExchangeRate
,
}
}
/>
/>
)
}
)
}
<
GroupPricingSection
<
GroupPricingSection
...
...
web/src/features/pricing/lib/billing-expr.ts
View file @
0e0ba152
...
@@ -462,24 +462,34 @@ function splitTaskTopLevel(expression: string, operator: '&&' | '+'): string[] {
...
@@ -462,24 +462,34 @@ function splitTaskTopLevel(expression: string, operator: '&&' | '+'): string[] {
function
parseTaskConditions
(
function
parseTaskConditions
(
expression
:
string
,
expression
:
string
,
schema
:
BillingUsageSchema
schema
:
BillingUsageSchema
,
includeBooleanConditions
:
boolean
):
TaskTierCondition
[]
|
null
{
):
TaskTierCondition
[]
|
null
{
const
conditions
:
TaskTierCondition
[]
=
[]
const
conditions
:
TaskTierCondition
[]
=
[]
for
(
const
part
of
splitTaskTopLevel
(
expression
,
'&&'
))
{
for
(
const
part
of
splitTaskTopLevel
(
expression
,
'&&'
))
{
const
match
=
part
.
match
(
const
match
=
part
.
match
(
/^u
\(\s
*
(
"
(?:[^
"
\\]
|
\\
.
)
*"
)\s
*
\)\s
*==
\s
*
(
"
(?:[^
"
\\]
|
\\
.
)
*"
)
$/
/^u
\(\s
*
(
"
(?:[^
"
\\]
|
\\
.
)
*"
)\s
*
\)\s
*==
\s
*
(
"
(?:[^
"
\\]
|
\\
.
)
*"
|true|false
)
$/
)
)
if
(
!
match
)
return
null
if
(
!
match
)
return
null
let
field
:
string
let
field
:
string
let
value
:
string
let
value
:
string
try
{
try
{
field
=
JSON
.
parse
(
match
[
1
])
as
string
field
=
JSON
.
parse
(
match
[
1
])
as
string
value
=
JSON
.
parse
(
match
[
2
])
as
string
value
=
String
(
JSON
.
parse
(
match
[
2
]))
}
catch
{
}
catch
{
return
null
return
null
}
}
const
declaredValues
=
schema
[
field
]?.
enum
const
definition
=
schema
[
field
]
if
(
!
declaredValues
?.
includes
(
value
))
return
null
if
(
definition
?.
type
===
'boolean'
)
{
if
(
!
includeBooleanConditions
||
!
[
'true'
,
'false'
].
includes
(
match
[
2
]))
{
return
null
}
}
else
if
(
!
definition
?.
enum
?.
includes
(
value
)
||
!
match
[
2
].
startsWith
(
'"'
)
)
{
return
null
}
conditions
.
push
({
field
,
value
})
conditions
.
push
({
field
,
value
})
}
}
return
conditions
.
length
>
0
?
conditions
:
null
return
conditions
.
length
>
0
?
conditions
:
null
...
@@ -554,7 +564,8 @@ function parseTaskTierCall(
...
@@ -554,7 +564,8 @@ function parseTaskTierCall(
export
function
parseTaskTiersFromExpr
(
export
function
parseTaskTiersFromExpr
(
exprStr
:
string
,
exprStr
:
string
,
schema
:
BillingUsageSchema
|
null
|
undefined
schema
:
BillingUsageSchema
|
null
|
undefined
,
includeBooleanConditions
=
false
):
ParsedTaskTier
[]
{
):
ParsedTaskTier
[]
{
if
(
!
exprStr
||
!
schema
||
Object
.
keys
(
schema
).
length
===
0
)
return
[]
if
(
!
exprStr
||
!
schema
||
Object
.
keys
(
schema
).
length
===
0
)
return
[]
try
{
try
{
...
@@ -576,7 +587,8 @@ export function parseTaskTiersFromExpr(
...
@@ -576,7 +587,8 @@ export function parseTaskTiersFromExpr(
if
(
colonIndex
<
0
)
return
[]
if
(
colonIndex
<
0
)
return
[]
const
conditions
=
parseTaskConditions
(
const
conditions
=
parseTaskConditions
(
remaining
.
slice
(
0
,
questionIndex
).
trim
(),
remaining
.
slice
(
0
,
questionIndex
).
trim
(),
schema
schema
,
includeBooleanConditions
)
)
if
(
!
conditions
)
return
[]
if
(
!
conditions
)
return
[]
const
tier
=
parseTaskTierCall
(
const
tier
=
parseTaskTierCall
(
...
...
web/src/features/pricing/lib/dynamic-price.ts
View file @
0e0ba152
...
@@ -42,7 +42,7 @@ import {
...
@@ -42,7 +42,7 @@ import {
tryParseTaskVisualConfig
,
tryParseTaskVisualConfig
,
}
from
'./task-expr'
}
from
'./task-expr'
type
DynamicPriceOptions
=
{
export
type
DynamicPriceOptions
=
{
tokenUnit
:
TokenUnit
tokenUnit
:
TokenUnit
showRechargePrice
?:
boolean
showRechargePrice
?:
boolean
priceRate
?:
number
priceRate
?:
number
...
@@ -225,7 +225,7 @@ export function getDynamicPricingTiers(
...
@@ -225,7 +225,7 @@ export function getDynamicPricingTiers(
model
.
billing_expr
||
''
model
.
billing_expr
||
''
)
)
if
(
isTaskUsagePricingModel
(
model
))
{
if
(
isTaskUsagePricingModel
(
model
))
{
return
parseTaskTiersFromExpr
(
billingExpr
,
model
.
billing_usage_schema
)
return
parseTaskTiersFromExpr
(
billingExpr
,
model
.
billing_usage_schema
,
true
)
}
}
return
parseTiersFromExpr
(
billingExpr
)
return
parseTiersFromExpr
(
billingExpr
)
}
}
...
@@ -268,8 +268,8 @@ export function getDynamicPriceEntries(
...
@@ -268,8 +268,8 @@ export function getDynamicPriceEntries(
usageEntries
.
push
({
usageEntries
.
push
({
key
:
'constant'
,
key
:
'constant'
,
field
:
'constant'
,
field
:
'constant'
,
label
:
'
Base
charge'
,
label
:
'
Additional
charge'
,
shortLabel
:
'
Bas
e'
,
shortLabel
:
'
Additional charg
e'
,
labelKind
:
'i18n'
,
labelKind
:
'i18n'
,
value
:
tier
.
constant
,
value
:
tier
.
constant
,
formatted
:
formatTaskUsageUnitPrice
(
tier
.
constant
,
options
),
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/>.
...
@@ -17,7 +17,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
For commercial licensing, please contact support@quantumnous.com
*/
*/
import
type
{
BillingUsageSchema
}
from
'../types'
import
type
{
BillingUsageSchema
}
from
'../types'
import
type
{
ParsedTaskTier
}
from
'./billing-expr'
import
{
parseTaskTiersFromExpr
,
type
ParsedTaskTier
,
type
TaskTierCondition
,
}
from
'./billing-expr'
import
{
import
{
getTaskEnumFields
,
getTaskEnumFields
,
taskMatrixRowLabel
,
taskMatrixRowLabel
,
...
@@ -49,3 +53,49 @@ export function getTaskMatrixDisplayTiers(
...
@@ -49,3 +53,49 @@ export function getTaskMatrixDisplayTiers(
unitPrices
:
{
...
row
.
unitPrices
},
unitPrices
:
{
...
row
.
unitPrices
},
}))
}))
}
}
/** Display explicit conditions for a fallback only when its complement is unique.
* Unlike the editor matrix, unrelated schema fields do not expand the price table.
*/
export
function
getTaskPricingDisplayTiers
(
expression
:
string
|
null
|
undefined
,
schema
:
BillingUsageSchema
|
null
|
undefined
):
ParsedTaskTier
[]
{
const
tiers
=
parseTaskTiersFromExpr
(
expression
||
''
,
schema
,
true
)
const
fallback
=
tiers
.
at
(
-
1
)
if
(
!
schema
||
tiers
.
length
<
2
||
!
fallback
)
return
tiers
const
previous
=
tiers
.
slice
(
0
,
-
1
)
const
fields
=
[
...
new
Set
(
previous
.
flatMap
((
tier
)
=>
tier
.
conditions
.
map
((
condition
)
=>
condition
.
field
)
)
),
].
sort
()
let
combinations
:
TaskTierCondition
[][]
=
[[]]
for
(
const
field
of
fields
)
{
const
definition
=
schema
[
field
]
const
values
=
definition
?.
type
===
'boolean'
?
[
'false'
,
'true'
]
:
definition
?.
enum
// Avoid expanding large plugin schemas merely to name a fallback row.
if
(
!
values
?.
length
||
combinations
.
length
*
values
.
length
>
256
)
{
return
tiers
}
combinations
=
combinations
.
flatMap
((
combination
)
=>
values
.
map
((
value
)
=>
[...
combination
,
{
field
,
value
}])
)
}
const
remaining
=
combinations
.
filter
(
(
combination
)
=>
!
previous
.
some
((
tier
)
=>
tier
.
conditions
.
every
((
condition
)
=>
combination
.
some
(
(
value
)
=>
value
.
field
===
condition
.
field
&&
value
.
value
===
condition
.
value
)
)
)
)
if
(
remaining
.
length
!==
1
)
return
tiers
return
[...
previous
,
{
...
fallback
,
conditions
:
remaining
[
0
]
}]
}
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 = {
...
@@ -33,6 +33,7 @@ export type BillingUsageFieldSchema = {
type
?:
'number'
|
'boolean'
type
?:
'number'
|
'boolean'
unit
?:
BillingUsageUnit
unit
?:
BillingUsageUnit
enum
?:
string
[]
enum
?:
string
[]
enumLabels
?:
Record
<
string
,
string
|
Record
<
string
,
string
>>
description
?:
string
|
Record
<
string
,
string
>
description
?:
string
|
Record
<
string
,
string
>
}
}
...
...
web/src/features/system-settings/components/settings-form-layout.tsx
View file @
0e0ba152
...
@@ -37,6 +37,7 @@ type SettingsSwitchRowProps = ComponentProps<'div'>
...
@@ -37,6 +37,7 @@ type SettingsSwitchRowProps = ComponentProps<'div'>
type
SettingsControlGroupProps
=
ComponentProps
<
'div'
>
type
SettingsControlGroupProps
=
ComponentProps
<
'div'
>
type
SettingsControlChildrenProps
=
ComponentProps
<
'div'
>
type
SettingsControlChildrenProps
=
ComponentProps
<
'div'
>
type
SettingsSwitchFieldProps
=
SettingsSwitchRowProps
&
{
type
SettingsSwitchFieldProps
=
SettingsSwitchRowProps
&
{
controlId
?:
string
checked
:
boolean
checked
:
boolean
onCheckedChange
:
(
checked
:
boolean
)
=>
void
onCheckedChange
:
(
checked
:
boolean
)
=>
void
label
:
ReactNode
label
:
ReactNode
...
@@ -107,6 +108,7 @@ export function SettingsSwitchRow({
...
@@ -107,6 +108,7 @@ export function SettingsSwitchRow({
}
}
export
function
SettingsSwitchField
({
export
function
SettingsSwitchField
({
controlId
,
checked
,
checked
,
onCheckedChange
,
onCheckedChange
,
label
,
label
,
...
@@ -118,12 +120,23 @@ export function SettingsSwitchField({
...
@@ -118,12 +120,23 @@ export function SettingsSwitchField({
return
(
return
(
<
SettingsSwitchRow
className=
{
className
}
{
...
props
}
>
<
SettingsSwitchRow
className=
{
className
}
{
...
props
}
>
<
SettingsSwitchContent
>
<
SettingsSwitchContent
>
<
Label
className=
'text-sm font-medium'
>
{
label
}
</
Label
>
<
Label
htmlFor=
{
controlId
}
className=
'text-sm font-medium'
>
{
label
}
</
Label
>
{
description
?
(
{
description
?
(
<
p
className=
'text-muted-foreground text-xs'
>
{
description
}
</
p
>
<
p
id=
{
controlId
?
`${controlId}-description`
:
undefined
}
className=
'text-muted-foreground text-xs'
>
{
description
}
</
p
>
)
:
null
}
)
:
null
}
</
SettingsSwitchContent
>
</
SettingsSwitchContent
>
<
Switch
<
Switch
id=
{
controlId
}
aria
-
describedby=
{
controlId
&&
description
?
`${controlId}-description`
:
undefined
}
checked=
{
checked
}
checked=
{
checked
}
onCheckedChange=
{
onCheckedChange
}
onCheckedChange=
{
onCheckedChange
}
disabled=
{
disabled
}
disabled=
{
disabled
}
...
...
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/>.
...
@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
For commercial licensing, please contact support@quantumnous.com
*/
*/
import
{
useId
}
from
'react'
import
{
useTranslation
}
from
'react-i18next'
import
{
useTranslation
}
from
'react-i18next'
import
{
InputGroup
,
InputGroupAddon
}
from
'@/components/ui/input-group'
import
{
InputGroup
,
InputGroupAddon
}
from
'@/components/ui/input-group'
...
@@ -73,40 +74,52 @@ export function PriceLane(props: {
...
@@ -73,40 +74,52 @@ export function PriceLane(props: {
value
:
string
value
:
string
enabled
:
boolean
enabled
:
boolean
disabled
?:
boolean
disabled
?:
boolean
compact
?:
boolean
disabledReason
?:
string
onEnabledChange
:
(
checked
:
boolean
)
=>
void
onEnabledChange
:
(
checked
:
boolean
)
=>
void
onChange
:
(
value
:
string
)
=>
void
onChange
:
(
value
:
string
)
=>
void
})
{
})
{
const
{
t
}
=
useTranslation
()
const
{
t
}
=
useTranslation
()
const
controlId
=
useId
()
const
effectiveDisabled
=
props
.
disabled
||
!
props
.
enabled
const
effectiveDisabled
=
props
.
disabled
||
!
props
.
enabled
return
(
return
(
<
SettingsControlGroup
<
SettingsControlGroup
className=
{
cn
(
'space-y-3'
,
effectiveDisabled
&&
'opacity-75'
)
}
className=
{
cn
(
'space-y-3'
,
props
.
compact
&&
'space-y-2 rounded-lg bg-transparent p-3'
,
effectiveDisabled
&&
'opacity-75'
)
}
data
-
disabled=
{
effectiveDisabled
||
undefined
}
data
-
disabled=
{
effectiveDisabled
||
undefined
}
>
>
<
SettingsSwitchField
<
SettingsSwitchField
controlId=
{
controlId
}
className=
{
props
.
compact
?
'py-0'
:
undefined
}
checked=
{
props
.
enabled
}
checked=
{
props
.
enabled
}
disabled=
{
props
.
disabled
}
disabled=
{
props
.
disabled
}
onCheckedChange=
{
props
.
onEnabledChange
}
onCheckedChange=
{
props
.
onEnabledChange
}
label=
{
props
.
title
}
label=
{
props
.
title
}
description=
{
props
.
description
}
description=
{
props
.
d
isabledReason
||
props
.
d
escription
}
aria
-
label=
{
props
.
title
}
aria
-
label=
{
props
.
title
}
/>
/>
<
PriceInput
<
PriceInput
currency=
{
props
.
currency
}
currency=
{
props
.
currency
}
aria
-
label=
{
props
.
title
}
aria
-
label=
{
props
.
title
}
aria
-
describedby=
{
`${controlId}-description`
}
value=
{
props
.
value
}
value=
{
props
.
value
}
placeholder=
{
props
.
placeholder
}
placeholder=
{
props
.
placeholder
}
disabled=
{
effectiveDisabled
}
disabled=
{
effectiveDisabled
}
onChange=
{
props
.
onChange
}
onChange=
{
props
.
onChange
}
/>
/>
<
p
className=
'text-muted-foreground text-xs'
>
{
!
props
.
compact
&&
(
{
props
.
enabled
<
p
className=
'text-muted-foreground text-xs'
>
?
t
(
'
{{
currency
}}
price
per
1
M
tokens
.
'
,
{
{
props
.
enabled
currency
:
(
props
.
currency
??
USD_PRICING_CURRENCY
).
label
,
?
t
(
'
{{
currency
}}
price
per
1
M
tokens
.
'
,
{
})
currency
:
(
props
.
currency
??
USD_PRICING_CURRENCY
).
label
,
:
t
(
'Disabled lanes are omitted on save.'
)
}
})
</
p
>
:
t
(
'Disabled lanes are omitted on save.'
)
}
</
p
>
)
}
</
SettingsControlGroup
>
</
SettingsControlGroup
>
)
)
}
}
web/src/features/system-settings/models/model-pricing-sheet.tsx
View file @
0e0ba152
...
@@ -113,6 +113,7 @@ type ModelPricingEditorPanelProps = Omit<
...
@@ -113,6 +113,7 @@ type ModelPricingEditorPanelProps = Omit<
'open'
|
'onOpenChange'
'open'
|
'onOpenChange'
>
&
{
>
&
{
className
?:
string
className
?:
string
embedded
?:
boolean
}
}
export
type
ModelPricingEditorPanelHandle
=
{
export
type
ModelPricingEditorPanelHandle
=
{
...
@@ -168,7 +169,15 @@ export const ModelPricingEditorPanel = forwardRef<
...
@@ -168,7 +169,15 @@ export const ModelPricingEditorPanel = forwardRef<
ModelPricingEditorPanelHandle
,
ModelPricingEditorPanelHandle
,
ModelPricingEditorPanelProps
ModelPricingEditorPanelProps
>
(
function
ModelPricingEditorPanel
(
>
(
function
ModelPricingEditorPanel
(
{
editData
,
className
,
onSave
,
isSaving
,
usageSchema
,
onDirtyChange
},
{
editData
,
className
,
onSave
,
isSaving
,
usageSchema
,
onDirtyChange
,
embedded
=
false
,
},
ref
ref
)
{
)
{
const
{
t
}
=
useTranslation
()
const
{
t
}
=
useTranslation
()
...
@@ -617,19 +626,21 @@ export const ModelPricingEditorPanel = forwardRef<
...
@@ -617,19 +626,21 @@ export const ModelPricingEditorPanel = forwardRef<
return
(
return
(
<
div
<
div
className=
{
cn
(
className=
{
cn
(
'bg-background flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border'
,
'bg-background flex min-h-0
min-w-0
flex-1 flex-col overflow-hidden rounded-xl border'
,
className
className
)
}
)
}
>
>
<
div
className=
'border-b p-4'
>
{
!
embedded
&&
(
<
div
className=
'flex flex-wrap items-start justify-between gap-3'
>
<
div
className=
'border-b p-4'
>
<
div
className=
'min-w-0'
>
<
div
className=
'flex flex-wrap items-start justify-between gap-3'
>
<
h3
className=
'truncate text-base font-medium'
>
<
div
className=
'min-w-0'
>
{
isEditMode
?
t
(
'Edit model pricing'
)
:
t
(
'Add model pricing'
)
}
<
h3
className=
'truncate text-base font-medium'
>
</
h3
>
{
isEditMode
?
t
(
'Edit model pricing'
)
:
t
(
'Add model pricing'
)
}
</
h3
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
)
}
<
Form
{
...
form
}
>
<
Form
{
...
form
}
>
<
form
<
form
...
@@ -638,9 +649,15 @@ export const ModelPricingEditorPanel = forwardRef<
...
@@ -638,9 +649,15 @@ export const ModelPricingEditorPanel = forwardRef<
className=
'flex min-h-0 flex-1 flex-col'
className=
'flex min-h-0 flex-1 flex-col'
autoComplete=
'off'
autoComplete=
'off'
>
>
<
div
className=
'min-h-0 flex-1 overflow-y-auto p-4 pb-6'
>
<
div
<
div
className=
'grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(220px,260px)]'
>
role=
'region'
<
FieldGroup
>
aria
-
label=
{
isEditMode
?
t
(
'Edit model pricing'
)
:
t
(
'Add model pricing'
)
}
className=
'@container/pricing-editor min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 pb-6'
>
<
div
className=
'grid min-w-0 items-start gap-4 @min-[960px]/pricing-editor:grid-cols-[minmax(0,1fr)_260px]'
>
<
FieldGroup
className=
'min-w-0'
>
{
warnings
.
length
>
0
&&
(
{
warnings
.
length
>
0
&&
(
<
Alert
variant=
'destructive'
>
<
Alert
variant=
'destructive'
>
<
AlertTriangle
data
-
icon=
'inline-start'
/>
<
AlertTriangle
data
-
icon=
'inline-start'
/>
...
@@ -654,28 +671,30 @@ export const ModelPricingEditorPanel = forwardRef<
...
@@ -654,28 +671,30 @@ export const ModelPricingEditorPanel = forwardRef<
</
Alert
>
</
Alert
>
)
}
)
}
<
FormField
{
!
embedded
&&
(
control=
{
form
.
control
}
<
FormField
name=
'name'
control=
{
form
.
control
}
render=
{
({
field
})
=>
(
name=
'name'
<
FormItem
>
render=
{
({
field
})
=>
(
<
FormLabel
>
{
t
(
'Model name'
)
}
</
FormLabel
>
<
FormItem
>
<
FormControl
>
<
FormLabel
>
{
t
(
'Model name'
)
}
</
FormLabel
>
<
Input
<
FormControl
>
placeholder=
{
t
(
'gpt-4'
)
}
<
Input
{
...
field
}
placeholder=
{
t
(
'gpt-4'
)
}
disabled=
{
isEditMode
}
{
...
field
}
/>
disabled=
{
isEditMode
}
</
FormControl
>
/>
<
FormDescription
>
</
FormControl
>
{
t
(
<
FormDescription
>
'The exact model identifier as used in API requests.'
{
t
(
)
}
'The exact model identifier as used in API requests.'
</
FormDescription
>
)
}
<
FormMessage
/>
</
FormDescription
>
</
FormItem
>
<
FormMessage
/>
)
}
</
FormItem
>
/>
)
}
/>
)
}
<
PricingCurrencySelector
siteCurrency=
{
siteCurrency
}
/>
<
PricingCurrencySelector
siteCurrency=
{
siteCurrency
}
/>
...
@@ -697,7 +716,10 @@ export const ModelPricingEditorPanel = forwardRef<
...
@@ -697,7 +716,10 @@ export const ModelPricingEditorPanel = forwardRef<
</
TabsTrigger
>
</
TabsTrigger
>
</
TabsList
>
</
TabsList
>
<
TabsContent
value=
'per-token'
className=
'pt-0'
>
<
TabsContent
value=
'per-token'
className=
'@container/pricing-fields min-w-0 pt-0'
>
{
taskUsageSchema
&&
{
taskUsageSchema
&&
Object
.
keys
(
taskUsageSchema
).
length
>
0
&&
(
Object
.
keys
(
taskUsageSchema
).
length
>
0
&&
(
<
Alert
className=
'mb-4'
>
<
Alert
className=
'mb-4'
>
...
@@ -724,53 +746,80 @@ export const ModelPricingEditorPanel = forwardRef<
...
@@ -724,53 +746,80 @@ export const ModelPricingEditorPanel = forwardRef<
</
AlertDescription
>
</
AlertDescription
>
</
Alert
>
</
Alert
>
)
}
)
}
<
FieldGroup
className=
'gap-5'
>
{
embedded
&&
(
<
Field
>
<
p
className=
'text-muted-foreground mb-3 text-xs'
>
{
t
(
'
{{
currency
}}
price
per
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
}
>
<
FieldLabel
htmlFor=
{
promptPriceId
}
>
{
t
(
'Input price'
)
}
{
t
(
'Input price'
)
}
</
FieldLabel
>
</
FieldLabel
>
<
FieldDescription
id=
{
`${promptPriceId}-description`
}
className=
{
embedded
?
'text-xs'
:
undefined
}
>
{
t
(
'
{{
currency
}}
price
per
1
M
input
tokens
.
'
,
{
currency
:
currency
.
label
,
})
}
</
FieldDescription
>
<
PriceInput
<
PriceInput
currency=
{
currency
}
id=
{
promptPriceId
}
id=
{
promptPriceId
}
aria
-
describedby=
{
`${promptPriceId}-description`
}
aria
-
describedby=
{
`${promptPriceId}-description`
}
currency=
{
currency
}
value=
{
promptPrice
}
value=
{
promptPrice
}
placeholder=
'3'
placeholder=
'3'
onChange=
{
handlePromptPriceChange
}
onChange=
{
handlePromptPriceChange
}
/>
/>
<
FieldDescription
id=
{
`${promptPriceId}-description`
}
>
{
t
(
'
{{
currency
}}
price
per
1
M
input
tokens
.
'
,
{
currency
:
currency
.
label
,
})
}
</
FieldDescription
>
</
Field
>
</
Field
>
<
div
className=
'grid gap-3 sm:grid-cols-[repeat(auto-fit,minmax(400px,1fr))]'
>
{
laneConfigs
.
map
((
lane
)
=>
{
{
laneConfigs
.
map
((
lane
)
=>
{
const
disabled
=
const
disabled
=
lane
.
key
===
'audioOutput'
&&
lane
.
key
===
'audioOutput'
&&
(
!
laneEnabled
.
audioInput
||
(
!
laneEnabled
.
audioInput
||
!
hasValue
(
lanePrices
.
audioInput
))
!
hasValue
(
lanePrices
.
audioInput
))
return
(
return
(
<
PriceLane
<
PriceLane
currency=
{
currency
}
currency=
{
currency
}
key=
{
lane
.
key
}
key=
{
lane
.
key
}
compact=
{
embedded
}
title=
{
t
(
lane
.
titleKey
)
}
title=
{
t
(
lane
.
titleKey
)
}
description=
{
t
(
lane
.
descriptionKey
)
}
description=
{
t
(
lane
.
descriptionKey
)
}
placeholder=
{
lane
.
placeholder
}
placeholder=
{
lane
.
placeholder
}
value=
{
lanePrices
[
lane
.
key
]
}
value=
{
lanePrices
[
lane
.
key
]
}
enabled=
{
laneEnabled
[
lane
.
key
]
}
enabled=
{
laneEnabled
[
lane
.
key
]
}
disabled=
{
disabled
}
disabled=
{
disabled
}
onEnabledChange=
{
(
checked
)
=>
disabledReason=
{
handleLaneToggle
(
lane
.
key
,
checked
)
disabled
}
?
t
(
onChange=
{
(
value
)
=>
'Audio output price requires an audio input price.'
handleLanePriceChange
(
lane
.
key
,
value
)
)
}
:
undefined
/>
}
)
onEnabledChange=
{
(
checked
)
=>
})
}
handleLaneToggle
(
lane
.
key
,
checked
)
</
div
>
}
</
FieldGroup
>
onChange=
{
(
value
)
=>
handleLanePriceChange
(
lane
.
key
,
value
)
}
/>
)
})
}
</
div
>
</
TabsContent
>
</
TabsContent
>
<
TabsContent
value=
'per-request'
className=
'pt-0'
>
<
TabsContent
value=
'per-request'
className=
'pt-0'
>
...
@@ -843,7 +892,10 @@ export const ModelPricingEditorPanel = forwardRef<
...
@@ -843,7 +892,10 @@ export const ModelPricingEditorPanel = forwardRef<
</
Tabs
>
</
Tabs
>
</
FieldGroup
>
</
FieldGroup
>
<
aside
className=
'bg-muted/20 sticky top-0 rounded-lg border'
>
<
aside
aria
-
label=
{
t
(
'Preview'
)
}
className=
'bg-muted/20 min-w-0 rounded-lg border @min-[960px]/pricing-editor:sticky @min-[960px]/pricing-editor:top-0'
>
<
div
className=
'border-b px-3 py-2'
>
<
div
className=
'border-b px-3 py-2'
>
<
div
className=
'text-sm font-medium'
>
{
t
(
'Preview'
)
}
</
div
>
<
div
className=
'text-sm font-medium'
>
{
t
(
'Preview'
)
}
</
div
>
</
div
>
</
div
>
...
...
web/src/features/system-settings/models/model-ratio-form.tsx
View file @
0e0ba152
...
@@ -24,6 +24,7 @@ import { useTranslation } from 'react-i18next'
...
@@ -24,6 +24,7 @@ import { useTranslation } from 'react-i18next'
import
{
toast
}
from
'sonner'
import
{
toast
}
from
'sonner'
import
{
JsonCodeEditor
}
from
'@/components/json-code-editor'
import
{
JsonCodeEditor
}
from
'@/components/json-code-editor'
import
{
LearnMore
}
from
'@/components/learn-more'
import
{
Button
}
from
'@/components/ui/button'
import
{
Button
}
from
'@/components/ui/button'
import
{
import
{
Form
,
Form
,
...
@@ -42,6 +43,7 @@ import {
...
@@ -42,6 +43,7 @@ import {
SettingsSwitchContent
,
SettingsSwitchContent
,
SettingsSwitchItem
,
SettingsSwitchItem
,
}
from
'../components/settings-form-layout'
}
from
'../components/settings-form-layout'
import
{
SettingsPageActionsPortal
}
from
'../components/settings-page-context'
import
{
import
{
ModelRatioVisualEditor
,
ModelRatioVisualEditor
,
type
ModelRatioVisualEditorHandle
,
type
ModelRatioVisualEditorHandle
,
...
@@ -220,49 +222,78 @@ export const ModelRatioForm = memo(function ModelRatioForm({
...
@@ -220,49 +222,78 @@ export const ModelRatioForm = memo(function ModelRatioForm({
},
[
editMode
,
form
,
onSave
])
},
[
editMode
,
form
,
onSave
])
return
(
return
(
<
div
className=
'space-y-6'
>
<
Form
{
...
form
}
>
{
!
isUnsetVariant
&&
(
<
div
className=
'flex min-h-0 flex-1 flex-col gap-6'
>
<
div
className=
'flex flex-wrap justify-end gap-2'
>
{
!
isUnsetVariant
&&
(
<
Button
<
div
className=
'flex shrink-0 flex-wrap items-center justify-end gap-2'
>
type=
'button'
<
SettingsPageActionsPortal
>
variant=
'destructive'
<
FormField
size=
'sm'
control=
{
form
.
control
}
onClick=
{
onReset
}
name=
'ExposeRatioEnabled'
disabled=
{
isResetting
}
render=
{
({
field
})
=>
(
>
<
SettingsSwitchItem
className=
'gap-2 py-0'
>
<
RotateCcw
data
-
icon=
'inline-start'
/>
<
SettingsSwitchContent
>
{
t
(
'Reset prices'
)
}
<
FormLabel
>
{
t
(
'Expose ratio API'
)
}
</
FormLabel
>
</
Button
>
<
FormDescription
className=
'sr-only'
>
{
editMode
===
'json'
&&
(
{
t
(
'Allow clients to query configured prices via `/api/ratio`.'
)
}
</
FormDescription
>
</
SettingsSwitchContent
>
<
FormControl
>
<
Switch
checked=
{
field
.
value
}
onCheckedChange=
{
field
.
onChange
}
/>
</
FormControl
>
<
LearnMore
contentProps=
{
{
side
:
'bottom'
,
align
:
'end'
}
}
>
{
t
(
'Allow clients to query configured prices via `/api/ratio`.'
)
}
</
LearnMore
>
</
SettingsSwitchItem
>
)
}
/>
</
SettingsPageActionsPortal
>
<
Button
<
Button
type=
'button'
type=
'button'
variant=
'destructive'
size=
'sm'
size=
'sm'
onClick=
{
handleSave
}
onClick=
{
onReset
}
disabled=
{
is
Sav
ing
}
disabled=
{
is
Resett
ing
}
>
>
<
Save
data
-
icon=
'inline-start'
/>
<
RotateCcw
data
-
icon=
'inline-start'
/>
{
isSaving
?
t
(
'Saving...'
)
:
t
(
'Save model
prices'
)
}
{
t
(
'Reset
prices'
)
}
</
Button
>
</
Button
>
)
}
{
editMode
===
'json'
&&
(
<
Button
variant=
'outline'
size=
'sm'
onClick=
{
toggleEditMode
}
>
<
Button
{
editMode
===
'visual'
?
(
type=
'button'
<>
size=
'sm'
<
Code2
className=
'mr-2 h-4 w-4'
/>
onClick=
{
handleSave
}
{
t
(
'Switch to JSON'
)
}
disabled=
{
isSaving
}
</>
>
)
:
(
<
Save
data
-
icon=
'inline-start'
/>
<>
{
isSaving
?
t
(
'Saving...'
)
:
t
(
'Save model prices'
)
}
<
Eye
className=
'mr-2 h-4 w-4'
/>
</
Button
>
{
t
(
'Switch to Visual'
)
}
</>
)
}
)
}
</
Button
>
<
Button
variant=
'outline'
size=
'sm'
onClick=
{
toggleEditMode
}
>
</
div
>
{
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'
?
(
{
editMode
===
'visual'
?
(
<
div
className=
'
space-y
-6'
>
<
div
className=
'
flex min-h-0 flex-1 flex-col gap
-6'
>
<
ModelRatioVisualEditor
<
ModelRatioVisualEditor
ref=
{
visualEditorRef
}
ref=
{
visualEditorRef
}
savedModelPrice=
{
savedValues
.
ModelPrice
}
savedModelPrice=
{
savedValues
.
ModelPrice
}
...
@@ -304,31 +335,6 @@ export const ModelRatioForm = memo(function ModelRatioForm({
...
@@ -304,31 +335,6 @@ export const ModelRatioForm = memo(function ModelRatioForm({
handleFieldChange
(
formField
,
value
)
handleFieldChange
(
formField
,
value
)
}
}
}
}
/>
/>
{
!
isUnsetVariant
&&
(
<
FormField
control=
{
form
.
control
}
name=
'ExposeRatioEnabled'
render=
{
({
field
})
=>
(
<
SettingsSwitchItem
>
<
SettingsSwitchContent
>
<
FormLabel
>
{
t
(
'Expose ratio API'
)
}
</
FormLabel
>
<
FormDescription
>
{
t
(
'Allow clients to query configured ratios via `/api/ratio`.'
)
}
</
FormDescription
>
</
SettingsSwitchContent
>
<
FormControl
>
<
Switch
checked=
{
field
.
value
}
onCheckedChange=
{
field
.
onChange
}
/>
</
FormControl
>
</
SettingsSwitchItem
>
)
}
/>
)
}
</
div
>
</
div
>
)
:
(
)
:
(
<
SettingsForm
onSubmit=
{
form
.
handleSubmit
(
onSave
)
}
>
<
SettingsForm
onSubmit=
{
form
.
handleSubmit
(
onSave
)
}
>
...
@@ -343,32 +349,9 @@ export const ModelRatioForm = memo(function ModelRatioForm({
...
@@ -343,32 +349,9 @@ export const ModelRatioForm = memo(function ModelRatioForm({
/>
/>
))
}
))
}
</
div
>
</
div
>
<
FormField
control=
{
form
.
control
}
name=
'ExposeRatioEnabled'
render=
{
({
field
})
=>
(
<
SettingsSwitchItem
>
<
SettingsSwitchContent
>
<
FormLabel
>
{
t
(
'Expose ratio API'
)
}
</
FormLabel
>
<
FormDescription
>
{
t
(
'Allow clients to query configured ratios via `/api/ratio`.'
)
}
</
FormDescription
>
</
SettingsSwitchContent
>
<
FormControl
>
<
Switch
checked=
{
field
.
value
}
onCheckedChange=
{
field
.
onChange
}
/>
</
FormControl
>
</
SettingsSwitchItem
>
)
}
/>
</
SettingsForm
>
</
SettingsForm
>
)
}
)
}
</
Form
>
</
div
>
</
div
>
</
Form
>
)
)
})
})
web/src/features/system-settings/models/model-ratio-visual-editor.tsx
View file @
0e0ba152
...
@@ -606,8 +606,12 @@ const ModelRatioVisualEditorComponent = forwardRef<
...
@@ -606,8 +606,12 @@ const ModelRatioVisualEditorComponent = forwardRef<
}
}
return
(
return
(
<
div
className=
'flex flex-col gap-4'
>
<
div
className=
'flex min-h-0 flex-1 flex-col gap-4'
>
<
div
className=
'grid h-[clamp(720px,calc(100vh-12rem),900px)] min-h-0 gap-4 md:grid-cols-[minmax(300px,0.72fr)_minmax(520px,1.28fr)] xl:grid-cols-[minmax(320px,0.68fr)_minmax(640px,1.32fr)]'
>
<
div
role=
'region'
aria
-
label=
{
t
(
'Model prices'
)
}
className=
'grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)] gap-4 md:grid-cols-[minmax(300px,0.72fr)_minmax(520px,1.28fr)] xl:grid-cols-[minmax(320px,0.68fr)_minmax(640px,1.32fr)]'
>
<
div
className=
'flex min-h-0 min-w-0 flex-col gap-3'
>
<
div
className=
'flex min-h-0 min-w-0 flex-col gap-3'
>
<
DataTableToolbar
<
DataTableToolbar
table=
{
table
}
table=
{
table
}
...
...
web/src/features/system-settings/models/ratio-settings-card.tsx
View file @
0e0ba152
...
@@ -528,7 +528,14 @@ export function RatioSettingsCard({
...
@@ -528,7 +528,14 @@ export function RatioSettingsCard({
return
(
return
(
<>
<>
{
visibleTabs
.
length
===
1
?
(
{
visibleTabs
.
length
===
1
?
(
<
SettingsSection
title=
{
t
(
titleKey
)
}
>
<
SettingsSection
title=
{
t
(
titleKey
)
}
className=
{
defaultTab
===
'models'
||
defaultTab
===
'unset-models'
?
'min-h-0 flex-1'
:
undefined
}
>
{
renderTabContent
(
defaultTab
)
}
{
renderTabContent
(
defaultTab
)
}
</
SettingsSection
>
</
SettingsSection
>
)
:
(
)
:
(
...
@@ -539,7 +546,15 @@ export function RatioSettingsCard({
...
@@ -539,7 +546,15 @@ export function RatioSettingsCard({
<
SettingsSection
title=
{
t
(
titleKey
)
}
className=
'min-h-0 flex-1'
>
<
SettingsSection
title=
{
t
(
titleKey
)
}
className=
'min-h-0 flex-1'
>
{
visibleTabs
.
map
((
tab
)
=>
(
{
visibleTabs
.
map
((
tab
)
=>
(
<
TabsContent
key=
{
tab
}
value=
{
tab
}
className=
'min-h-0'
>
<
TabsContent
key=
{
tab
}
value=
{
tab
}
className=
{
tab
===
'models'
||
tab
===
'unset-models'
?
'flex min-h-0 flex-col data-hidden:hidden'
:
'min-h-0'
}
>
{
renderTabContent
(
tab
)
}
{
renderTabContent
(
tab
)
}
</
TabsContent
>
</
TabsContent
>
))
}
))
}
...
...
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
...
@@ -58,9 +58,13 @@ import { getTaskUsagePriceUnitLabelKey } from '@/features/pricing/lib/dynamic-pr
import
{
import
{
getTaskEnumFields
,
getTaskEnumFields
,
getTaskNumberFields
,
getTaskNumberFields
,
taskMatrixRowLabel
,
type
TaskMatrixRow
,
type
TaskMatrixRow
,
}
from
'@/features/pricing/lib/task-expr'
}
from
'@/features/pricing/lib/task-expr'
import
{
taskPriceLabel
,
taskEnumLabel
,
taskPricingConditions
,
}
from
'@/features/pricing/lib/task-price-display'
import
type
{
import
type
{
BillingUsageFieldSchema
,
BillingUsageFieldSchema
,
BillingUsageSchema
,
BillingUsageSchema
,
...
@@ -180,7 +184,11 @@ type TaskMatrixTableProps = {
...
@@ -180,7 +184,11 @@ type TaskMatrixTableProps = {
}
}
function
TaskMatrixTable
(
props
:
TaskMatrixTableProps
)
{
function
TaskMatrixTable
(
props
:
TaskMatrixTableProps
)
{
const
{
t
}
=
useTranslation
()
const
{
t
,
i18n
}
=
useTranslation
()
const
usageSchema
=
Object
.
fromEntries
([
...
props
.
enumFields
,
...
props
.
numberFields
,
])
const
visibleEnumFields
=
props
.
enumFields
.
filter
(
const
visibleEnumFields
=
props
.
enumFields
.
filter
(
([
field
])
=>
field
!==
props
.
hiddenEnumField
([
field
])
=>
field
!==
props
.
hiddenEnumField
)
)
...
@@ -189,16 +197,24 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
...
@@ -189,16 +197,24 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
<
Table
className=
'min-w-max'
>
<
Table
className=
'min-w-max'
>
<
TableHeader
>
<
TableHeader
>
<
TableRow
>
<
TableRow
>
{
visibleEnumFields
.
map
(([
field
])
=>
(
{
visibleEnumFields
.
map
(([
field
,
definition
])
=>
(
<
TableHead
key=
{
field
}
scope=
'col'
>
<
TableHead
key=
{
field
}
scope=
'col'
>
<
code
>
{
field
}
</
code
>
<
span
className=
'block max-w-64 break-words whitespace-normal'
>
{
taskPriceLabel
(
definition
.
description
,
field
,
i18n
.
language
)
}
</
span
>
</
TableHead
>
</
TableHead
>
))
}
))
}
{
props
.
numberFields
.
map
(([
field
,
definition
])
=>
(
{
props
.
numberFields
.
map
(([
field
,
definition
])
=>
(
<
TableHead
key=
{
field
}
scope=
'col'
className=
'min-w-40'
>
<
TableHead
key=
{
field
}
scope=
'col'
className=
'min-w-40'
>
<
div
className=
'flex items-center justify-between gap-2'
>
<
div
className=
'flex items-center justify-between gap-2'
>
<
div
className=
'flex flex-col gap-0.5'
>
<
div
className=
'flex flex-col gap-0.5'
>
<
code
>
{
field
}
</
code
>
<
span
className=
'max-w-64 break-words whitespace-normal'
>
{
taskPriceLabel
(
definition
.
description
,
t
(
'
Unit
price
:
{{
field
}}
'
,
{
field
}),
i18n
.
language
)
}
</
span
>
<
span
className=
'text-muted-foreground text-[11px] font-normal'
>
<
span
className=
'text-muted-foreground text-[11px] font-normal'
>
{
(
props
.
currency
??
USD_PRICING_CURRENCY
).
symbol
}
/
{
(
props
.
currency
??
USD_PRICING_CURRENCY
).
symbol
}
/
{
t
(
getTaskUsagePriceUnitLabelKey
(
definition
.
unit
))
}
{
t
(
getTaskUsagePriceUnitLabelKey
(
definition
.
unit
))
}
...
@@ -216,7 +232,12 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
...
@@ -216,7 +232,12 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
<
TableHead
scope=
'col'
className=
'min-w-40'
>
<
TableHead
scope=
'col'
className=
'min-w-40'
>
<
div
className=
'flex items-center justify-between gap-2'
>
<
div
className=
'flex items-center justify-between gap-2'
>
<
div
className=
'flex flex-col gap-0.5'
>
<
div
className=
'flex flex-col gap-0.5'
>
<
span
>
{
t
(
'Base charge'
)
}
</
span
>
<
span
>
{
t
(
'Additional charge'
)
}
</
span
>
<
span
className=
'text-muted-foreground max-w-48 text-xs font-normal whitespace-normal'
>
{
t
(
'Added to the usage cost. Set to 0 for no additional charge.'
)
}
</
span
>
<
span
className=
'text-muted-foreground text-[11px] font-normal'
>
<
span
className=
'text-muted-foreground text-[11px] font-normal'
>
{
(
props
.
currency
??
USD_PRICING_CURRENCY
).
symbol
}
/
{
(
props
.
currency
??
USD_PRICING_CURRENCY
).
symbol
}
/
{
t
(
'request'
)
}
{
t
(
'request'
)
}
...
@@ -242,7 +263,15 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
...
@@ -242,7 +263,15 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
props
.
numberFields
.
every
(
props
.
numberFields
.
every
(
([
field
])
=>
!
(
entry
.
row
.
unitPrices
[
field
]
>
0
)
([
field
])
=>
!
(
entry
.
row
.
unitPrices
[
field
]
>
0
)
)
)
const
rowLabel
=
taskMatrixRowLabel
(
entry
.
row
.
combination
)
const
rowLabel
=
taskPricingConditions
(
Object
.
entries
(
entry
.
row
.
combination
).
map
(([
field
,
value
])
=>
({
field
,
value
,
})),
usageSchema
,
i18n
.
language
,
t
)
return
(
return
(
<
TableRow
<
TableRow
key=
{
`${rowLabel}:${entry.index}`
}
key=
{
`${rowLabel}:${entry.index}`
}
...
@@ -253,7 +282,13 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
...
@@ -253,7 +282,13 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
>
>
{
visibleEnumFields
.
map
(([
field
])
=>
(
{
visibleEnumFields
.
map
(([
field
])
=>
(
<
TableCell
key=
{
field
}
>
<
TableCell
key=
{
field
}
>
<
code
>
{
entry
.
row
.
combination
[
field
]
}
</
code
>
<
span
className=
'break-words whitespace-normal'
>
{
taskEnumLabel
(
usageSchema
[
field
],
entry
.
row
.
combination
[
field
],
i18n
.
language
)
}
</
span
>
</
TableCell
>
</
TableCell
>
))
}
))
}
{
props
.
numberFields
.
map
(([
field
])
=>
(
{
props
.
numberFields
.
map
(([
field
])
=>
(
...
@@ -265,7 +300,7 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
...
@@ -265,7 +300,7 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
value=
{
entry
.
row
.
unitPrices
[
field
]
??
0
}
value=
{
entry
.
row
.
unitPrices
[
field
]
??
0
}
data
-
matrix
-
col=
{
field
}
data
-
matrix
-
col=
{
field
}
data
-
matrix
-
row=
{
entry
.
index
}
data
-
matrix
-
row=
{
entry
.
index
}
aria
-
label=
{
`${
field
}: ${rowLabel}`
}
aria
-
label=
{
`${
taskPriceLabel(usageSchema[field]?.description, t('Unit price: {{field}}', { field }), i18n.language)
}: ${rowLabel}`
}
onFocus=
{
(
event
)
=>
{
onFocus=
{
(
event
)
=>
{
if
(
Number
(
event
.
currentTarget
.
value
)
===
0
)
{
if
(
Number
(
event
.
currentTarget
.
value
)
===
0
)
{
event
.
currentTarget
.
select
()
event
.
currentTarget
.
select
()
...
@@ -297,7 +332,7 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
...
@@ -297,7 +332,7 @@ function TaskMatrixTable(props: TaskMatrixTableProps) {
value=
{
entry
.
row
.
constant
}
value=
{
entry
.
row
.
constant
}
data
-
matrix
-
col=
'constant'
data
-
matrix
-
col=
'constant'
data
-
matrix
-
row=
{
entry
.
index
}
data
-
matrix
-
row=
{
entry
.
index
}
aria
-
label=
{
`${t('
Base
charge')}: ${rowLabel}`
}
aria
-
label=
{
`${t('
Additional
charge')}: ${rowLabel}`
}
onFocus=
{
(
event
)
=>
{
onFocus=
{
(
event
)
=>
{
if
(
Number
(
event
.
currentTarget
.
value
)
===
0
)
{
if
(
Number
(
event
.
currentTarget
.
value
)
===
0
)
{
event
.
currentTarget
.
select
()
event
.
currentTarget
.
select
()
...
@@ -355,7 +390,7 @@ type TaskMatrixGroupProps = Omit<TaskMatrixTableProps, 'hiddenEnumField'> & {
...
@@ -355,7 +390,7 @@ type TaskMatrixGroupProps = Omit<TaskMatrixTableProps, 'hiddenEnumField'> & {
}
}
function
TaskMatrixGroup
(
props
:
TaskMatrixGroupProps
)
{
function
TaskMatrixGroup
(
props
:
TaskMatrixGroupProps
)
{
const
{
t
}
=
useTranslation
()
const
{
t
,
i18n
}
=
useTranslation
()
const
freeCount
=
props
.
entries
.
filter
(
const
freeCount
=
props
.
entries
.
filter
(
(
entry
)
=>
(
entry
)
=>
entry
.
row
.
constant
===
0
&&
entry
.
row
.
constant
===
0
&&
...
@@ -377,7 +412,13 @@ function TaskMatrixGroup(props: TaskMatrixGroupProps) {
...
@@ -377,7 +412,13 @@ function TaskMatrixGroup(props: TaskMatrixGroupProps) {
}
}
>
>
<
span
className=
'flex min-w-0 items-center gap-2'
>
<
span
className=
'flex min-w-0 items-center gap-2'
>
<
code
>
{
props
.
groupValue
}
</
code
>
<
span
className=
'break-words whitespace-normal'
>
{
taskEnumLabel
(
Object
.
fromEntries
(
props
.
enumFields
)[
props
.
groupField
],
props
.
groupValue
,
i18n
.
language
)
}
</
span
>
<
span
className=
'text-muted-foreground text-xs'
>
<
span
className=
'text-muted-foreground text-xs'
>
{
t
(
'
{{
count
}}
combinations
'
,
{
count
:
props
.
entries
.
length
})
}
{
t
(
'
{{
count
}}
combinations
'
,
{
count
:
props
.
entries
.
length
})
}
</
span
>
</
span
>
...
...
web/src/features/system-settings/models/task-usage-pricing-editor.tsx
View file @
0e0ba152
...
@@ -56,18 +56,21 @@ import {
...
@@ -56,18 +56,21 @@ import {
getTaskEnumCombinations
,
getTaskEnumCombinations
,
getTaskEnumFields
,
getTaskEnumFields
,
getTaskNumberFields
,
getTaskNumberFields
,
taskMatrixRowLabel
,
taskMatrixToTiers
,
taskMatrixToTiers
,
tryParseTaskMatrixConfig
,
tryParseTaskMatrixConfig
,
tryParseTaskVisualConfig
,
tryParseTaskVisualConfig
,
type
TaskMatrixRow
,
type
TaskMatrixRow
,
type
TaskVisualConfig
,
type
TaskVisualConfig
,
}
from
'@/features/pricing/lib/task-expr'
}
from
'@/features/pricing/lib/task-expr'
import
{
taskPriceLabel
,
taskEnumLabel
,
taskPricingConditions
,
}
from
'@/features/pricing/lib/task-price-display'
import
type
{
import
type
{
BillingUsageExample
,
BillingUsageExample
,
BillingUsageSchema
,
BillingUsageSchema
,
}
from
'@/features/pricing/types'
}
from
'@/features/pricing/types'
import
{
resolveLocalizedText
}
from
'@/lib/localized-text'
import
{
formatPricingNumber
}
from
'./pricing-format'
import
{
formatPricingNumber
}
from
'./pricing-format'
import
{
TaskPricingMatrix
}
from
'./task-pricing-matrix'
import
{
TaskPricingMatrix
}
from
'./task-pricing-matrix'
...
@@ -87,7 +90,6 @@ type EditorMode = 'visual' | 'raw'
...
@@ -87,7 +90,6 @@ type EditorMode = 'visual' | 'raw'
type
TaskBillingPreviewProps
=
{
type
TaskBillingPreviewProps
=
{
currency
?:
PricingCurrency
currency
?:
PricingCurrency
config
:
TaskVisualConfig
|
null
config
:
TaskVisualConfig
|
null
matchedRowLabel
:
string
|
null
requestRuleExpr
:
string
requestRuleExpr
:
string
sample
:
Record
<
string
,
number
|
string
>
sample
:
Record
<
string
,
number
|
string
>
usageSchema
:
BillingUsageSchema
usageSchema
:
BillingUsageSchema
...
@@ -97,7 +99,7 @@ type TaskBillingPreviewProps = {
...
@@ -97,7 +99,7 @@ type TaskBillingPreviewProps = {
}
}
function
TaskBillingPreview
(
props
:
TaskBillingPreviewProps
)
{
function
TaskBillingPreview
(
props
:
TaskBillingPreviewProps
)
{
const
{
t
}
=
useTranslation
()
const
{
t
,
i18n
}
=
useTranslation
()
const
enumFields
=
getTaskEnumFields
(
props
.
usageSchema
)
const
enumFields
=
getTaskEnumFields
(
props
.
usageSchema
)
const
numberFields
=
getTaskNumberFields
(
props
.
usageSchema
)
const
numberFields
=
getTaskNumberFields
(
props
.
usageSchema
)
const
result
=
props
.
config
const
result
=
props
.
config
...
@@ -116,7 +118,7 @@ function TaskBillingPreview(props: TaskBillingPreviewProps) {
...
@@ -116,7 +118,7 @@ function TaskBillingPreview(props: TaskBillingPreviewProps) {
const
formulaParts
=
result
.
parts
.
map
((
part
)
=>
{
const
formulaParts
=
result
.
parts
.
map
((
part
)
=>
{
if
(
part
.
kind
===
'constant'
)
{
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
??
''
]
const
definition
=
props
.
usageSchema
[
part
.
field
??
''
]
...
@@ -127,7 +129,7 @@ function TaskBillingPreview(props: TaskBillingPreviewProps) {
...
@@ -127,7 +129,7 @@ function TaskBillingPreview(props: TaskBillingPreviewProps) {
definition
?.
unit
===
'second'
definition
?.
unit
===
'second'
?
`
${
formatPricingNumber
(
part
.
quantity
)}${
quantityUnitLabel
}
`
?
`
${
formatPricingNumber
(
part
.
quantity
)}${
quantityUnitLabel
}
`
:
`
${
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
=
const
formulaLeft
=
formulaParts
.
length
>
0
formulaParts
.
length
>
0
...
@@ -138,9 +140,11 @@ function TaskBillingPreview(props: TaskBillingPreviewProps) {
...
@@ -138,9 +140,11 @@ function TaskBillingPreview(props: TaskBillingPreviewProps) {
return
(
return
(
<
div
className=
'bg-muted/30 flex flex-col gap-3 rounded-md border p-3'
>
<
div
className=
'bg-muted/30 flex flex-col gap-3 rounded-md border p-3'
>
<
div
className=
'flex flex-col gap-1'
>
<
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'
>
<
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
?
(
{
props
.
requestRuleExpr
?
(
<>
{
t
(
'Request rules apply on top of this amount.'
)
}
</>
<>
{
t
(
'Request rules apply on top of this amount.'
)
}
</>
)
:
null
}
)
:
null
}
...
@@ -177,14 +181,19 @@ function TaskBillingPreview(props: TaskBillingPreviewProps) {
...
@@ -177,14 +181,19 @@ function TaskBillingPreview(props: TaskBillingPreviewProps) {
{
enumFields
.
map
(([
field
,
definition
])
=>
{
{
enumFields
.
map
(([
field
,
definition
])
=>
{
const
items
=
(
definition
.
enum
??
[]).
map
((
value
)
=>
({
const
items
=
(
definition
.
enum
??
[]).
map
((
value
)
=>
({
value
,
value
,
label
:
value
,
label
:
taskEnumLabel
(
definition
,
value
,
i18n
.
language
)
,
}))
}))
return
(
return
(
<
Field
key=
{
field
}
className=
'gap-1.5'
>
<
Field
key=
{
field
}
className=
'gap-1.5'
>
<
FieldLabel
>
<
FieldLabel
>
<
code
>
{
field
}
</
code
>
{
taskPriceLabel
(
definition
.
description
,
field
,
i18n
.
language
)
}
</
FieldLabel
>
</
FieldLabel
>
<
Combobox
<
Combobox
aria
-
label=
{
taskPriceLabel
(
definition
.
description
,
field
,
i18n
.
language
)
}
options=
{
items
}
options=
{
items
}
value=
{
String
(
props
.
sample
[
field
]
??
''
)
}
value=
{
String
(
props
.
sample
[
field
]
??
''
)
}
onValueChange=
{
(
value
)
=>
onValueChange=
{
(
value
)
=>
...
@@ -198,11 +207,24 @@ function TaskBillingPreview(props: TaskBillingPreviewProps) {
...
@@ -198,11 +207,24 @@ function TaskBillingPreview(props: TaskBillingPreviewProps) {
{
numberFields
.
map
(([
field
,
definition
])
=>
(
{
numberFields
.
map
(([
field
,
definition
])
=>
(
<
Field
key=
{
field
}
className=
'gap-1.5'
>
<
Field
key=
{
field
}
className=
'gap-1.5'
>
<
FieldLabel
>
<
FieldLabel
>
<
code
>
{
field
}
</
code
>
{
t
(
'
Usage
·
{{
price
}}
'
,
{
price
:
taskPriceLabel
(
definition
.
description
,
field
,
i18n
.
language
),
})
}
</
FieldLabel
>
</
FieldLabel
>
<
div
className=
'flex items-center gap-2'
>
<
div
className=
'flex items-center gap-2'
>
<
Input
<
Input
type=
'number'
type=
'number'
aria
-
label=
{
t
(
'
Usage
·
{{
price
}}
'
,
{
price
:
taskPriceLabel
(
definition
.
description
,
field
,
i18n
.
language
),
})
}
min=
{
0
}
min=
{
0
}
step=
{
1
}
step=
{
1
}
value=
{
props
.
sample
[
field
]
??
0
}
value=
{
props
.
sample
[
field
]
??
0
}
...
@@ -225,7 +247,16 @@ function TaskBillingPreview(props: TaskBillingPreviewProps) {
...
@@ -225,7 +247,16 @@ function TaskBillingPreview(props: TaskBillingPreviewProps) {
)
:
null
}
)
:
null
}
<
div
className=
'border-primary/50 bg-primary/10 flex flex-col gap-2 rounded-md border p-3 text-sm'
>
<
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'
>
<
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
>
</
Badge
>
<
code
className=
'font-mono text-xs break-words'
>
{
formula
}
</
code
>
<
code
className=
'font-mono text-xs break-words'
>
{
formula
}
</
code
>
</
div
>
</
div
>
...
@@ -276,7 +307,6 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
...
@@ -276,7 +307,6 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
let
previewConfig
:
TaskVisualConfig
|
null
=
null
let
previewConfig
:
TaskVisualConfig
|
null
=
null
let
previewRequestRuleExpr
=
props
.
requestRuleExpr
let
previewRequestRuleExpr
=
props
.
requestRuleExpr
let
matchedRowIndex
:
number
|
null
=
null
let
matchedRowIndex
:
number
|
null
=
null
let
matchedRowLabel
:
string
|
null
=
null
if
(
editorMode
===
'visual'
)
{
if
(
editorMode
===
'visual'
)
{
const
generatedExpression
=
generateTaskExprFromConfig
(
const
generatedExpression
=
generateTaskExprFromConfig
(
{
tiers
:
visualTiers
},
{
tiers
:
visualTiers
},
...
@@ -290,7 +320,6 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
...
@@ -290,7 +320,6 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
)
)
if
(
nextMatchedRowIndex
>=
0
)
{
if
(
nextMatchedRowIndex
>=
0
)
{
matchedRowIndex
=
nextMatchedRowIndex
matchedRowIndex
=
nextMatchedRowIndex
matchedRowLabel
=
taskMatrixRowLabel
(
combinations
[
nextMatchedRowIndex
])
}
}
}
else
{
}
else
{
const
split
=
splitBillingExprAndRequestRules
(
rawExpr
)
const
split
=
splitBillingExprAndRequestRules
(
rawExpr
)
...
@@ -413,7 +442,7 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
...
@@ -413,7 +442,7 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
<
Alert
>
<
Alert
>
<
AlertDescription
className=
'text-xs'
>
<
AlertDescription
className=
'text-xs'
>
{
t
(
{
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
}
{
currency
:
(
props
.
currency
??
USD_PRICING_CURRENCY
).
label
}
)
}
)
}
</
AlertDescription
>
</
AlertDescription
>
...
@@ -425,9 +454,9 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
...
@@ -425,9 +454,9 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
{
enumFields
.
length
>
0
?
(
{
enumFields
.
length
>
0
?
(
<
div
className=
'flex flex-col gap-3'
>
<
div
className=
'flex flex-col gap-3'
>
<
p
className=
'text-muted-foreground text-xs'
>
<
p
className=
'text-muted-foreground text-xs'
>
{
t
(
'
Each
row
prices
one
combination
of
{{
fields
}}.
'
,
{
{
t
(
fields
:
enumFields
.
map
(([
field
])
=>
field
).
join
(
', '
),
'Set prices for each set of conditions below. Cost = usage × unit price + additional charge. Token prices are per million tokens.'
}
)
}
)
}
</
p
>
</
p
>
<
TaskPricingMatrix
<
TaskPricingMatrix
currency=
{
props
.
currency
}
currency=
{
props
.
currency
}
...
@@ -457,15 +486,14 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
...
@@ -457,15 +486,14 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
</
Label
>
</
Label
>
<
div
className=
'grid gap-3 sm:grid-cols-2'
>
<
div
className=
'grid gap-3 sm:grid-cols-2'
>
{
numberFields
.
map
(([
field
,
definition
])
=>
{
{
numberFields
.
map
(([
field
,
definition
])
=>
{
const
description
=
resolveLocalizedText
(
const
description
=
taskPriceLabel
(
definition
.
description
,
definition
.
description
,
field
,
i18n
.
language
i18n
.
language
)
)
return
(
return
(
<
Field
key=
{
field
}
className=
'gap-1.5'
>
<
Field
key=
{
field
}
className=
'gap-1.5'
>
<
FieldLabel
>
<
FieldLabel
>
{
description
}
</
FieldLabel
>
<
code
>
{
field
}
</
code
>
</
FieldLabel
>
<
div
className=
'flex items-center gap-2'
>
<
div
className=
'flex items-center gap-2'
>
<
PricingAmountInput
<
PricingAmountInput
currency=
{
props
.
currency
}
currency=
{
props
.
currency
}
...
@@ -504,18 +532,15 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
...
@@ -504,18 +532,15 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
)
}
)
}
</
span
>
</
span
>
</
div
>
</
div
>
{
description
?
(
<
FieldDescription
>
{
description
}
</
FieldDescription
>
)
:
null
}
</
Field
>
</
Field
>
)
)
})
}
})
}
<
Field
className=
'gap-1.5'
>
<
Field
className=
'gap-1.5'
>
<
FieldLabel
>
{
t
(
'
Base
charge'
)
}
</
FieldLabel
>
<
FieldLabel
>
{
t
(
'
Additional
charge'
)
}
</
FieldLabel
>
<
div
className=
'flex items-center gap-2'
>
<
div
className=
'flex items-center gap-2'
>
<
PricingAmountInput
<
PricingAmountInput
currency=
{
props
.
currency
}
currency=
{
props
.
currency
}
aria
-
label=
{
t
(
'
Base
charge'
)
}
aria
-
label=
{
t
(
'
Additional
charge'
)
}
min=
{
0
}
min=
{
0
}
step=
{
0.000001
}
step=
{
0.000001
}
value=
{
matrixRows
[
0
].
constant
}
value=
{
matrixRows
[
0
].
constant
}
...
@@ -551,7 +576,6 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
...
@@ -551,7 +576,6 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
<
TaskBillingPreview
<
TaskBillingPreview
currency=
{
props
.
currency
}
currency=
{
props
.
currency
}
config=
{
previewConfig
}
config=
{
previewConfig
}
matchedRowLabel=
{
matchedRowLabel
}
requestRuleExpr=
{
previewRequestRuleExpr
}
requestRuleExpr=
{
previewRequestRuleExpr
}
sample=
{
previewSample
}
sample=
{
previewSample
}
usageSchema=
{
props
.
usageSchema
}
usageSchema=
{
props
.
usageSchema
}
...
@@ -614,7 +638,6 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
...
@@ -614,7 +638,6 @@ export const TaskUsagePricingEditor = memo(function TaskUsagePricingEditor(
<
TaskBillingPreview
<
TaskBillingPreview
currency=
{
props
.
currency
}
currency=
{
props
.
currency
}
config=
{
previewConfig
}
config=
{
previewConfig
}
matchedRowLabel=
{
matchedRowLabel
}
requestRuleExpr=
{
previewRequestRuleExpr
}
requestRuleExpr=
{
previewRequestRuleExpr
}
sample=
{
previewSample
}
sample=
{
previewSample
}
usageSchema=
{
props
.
usageSchema
}
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 {
...
@@ -35,6 +35,16 @@ import {
TooltipProvider
,
TooltipProvider
,
TooltipTrigger
,
TooltipTrigger
,
}
from
'@/components/ui/tooltip'
}
from
'@/components/ui/tooltip'
import
{
usePricingData
}
from
'@/features/pricing/hooks/use-pricing-data'
import
{
normalizeTierLabel
,
parseTaskTiersFromExpr
,
}
from
'@/features/pricing/lib/billing-expr'
import
{
formatTaskUsageUnitPrice
,
getTaskUsagePriceUnitLabelKey
,
}
from
'@/features/pricing/lib/dynamic-price'
import
type
{
BillingUsageSchema
}
from
'@/features/pricing/types'
import
{
getUserAvatarFallback
,
getUserAvatarStyle
}
from
'@/lib/avatar'
import
{
getUserAvatarFallback
,
getUserAvatarStyle
}
from
'@/lib/avatar'
import
{
formatBillingCurrencyFromUSD
}
from
'@/lib/currency'
import
{
formatBillingCurrencyFromUSD
}
from
'@/lib/currency'
import
{
formatLogQuota
,
formatTimestampToDate
}
from
'@/lib/format'
import
{
formatLogQuota
,
formatTimestampToDate
}
from
'@/lib/format'
...
@@ -44,6 +54,7 @@ import { LOG_TYPE_ALL_VALUE } from '../../constants'
...
@@ -44,6 +54,7 @@ import { LOG_TYPE_ALL_VALUE } from '../../constants'
import
type
{
UsageLog
}
from
'../../data/schema'
import
type
{
UsageLog
}
from
'../../data/schema'
import
{
import
{
formatModelName
,
formatModelName
,
decodeBillingExprB64
,
getTieredBillingSummary
,
getTieredBillingSummary
,
hasAnyCacheTokens
,
hasAnyCacheTokens
,
parseLogOther
,
parseLogOther
,
...
@@ -98,9 +109,10 @@ function buildDetailSegments(
...
@@ -98,9 +109,10 @@ function buildDetailSegments(
log
:
UsageLog
,
log
:
UsageLog
,
other
:
LogOtherData
|
null
,
other
:
LogOtherData
|
null
,
t
:
(
key
:
string
,
opts
?:
Record
<
string
,
unknown
>
)
=>
string
,
t
:
(
key
:
string
,
opts
?:
Record
<
string
,
unknown
>
)
=>
string
,
isAdmin
:
boolean
isAdmin
:
boolean
,
usageSchema
?:
BillingUsageSchema
):
DetailSegment
[]
{
):
DetailSegment
[]
{
const
segments
=
buildTypeDetailSegments
(
log
,
other
,
t
)
const
segments
=
buildTypeDetailSegments
(
log
,
other
,
t
,
usageSchema
)
const
adminSegments
:
DetailSegment
[]
=
[]
const
adminSegments
:
DetailSegment
[]
=
[]
// Quota saturation is a rare, admin-only anomaly marker; surface it first
// Quota saturation is a rare, admin-only anomaly marker; surface it first
// and in danger styling so it stands out on the related billing log. The
// and in danger styling so it stands out on the related billing log. The
...
@@ -109,20 +121,14 @@ function buildDetailSegments(
...
@@ -109,20 +121,14 @@ function buildDetailSegments(
if
(
isAdmin
&&
other
?.
admin_info
?.
quota_saturation
)
{
if
(
isAdmin
&&
other
?.
admin_info
?.
quota_saturation
)
{
adminSegments
.
push
({
text
:
t
(
'Quota clamped'
),
danger
:
true
})
adminSegments
.
push
({
text
:
t
(
'Quota clamped'
),
danger
:
true
})
}
}
const
plugin
=
isAdmin
?
other
?.
admin_info
?.
task_plugin
:
undefined
if
(
plugin
)
{
const
version
=
plugin
.
version
?
` @
${
plugin
.
version
}
`
:
''
adminSegments
.
push
({
text
:
`
${
t
(
'Plugin'
)}
:
${
plugin
.
name
||
plugin
.
key
}${
version
}
`
,
})
}
return
[...
adminSegments
,
...
segments
]
return
[...
adminSegments
,
...
segments
]
}
}
function
buildTypeDetailSegments
(
function
buildTypeDetailSegments
(
log
:
UsageLog
,
log
:
UsageLog
,
other
:
LogOtherData
|
null
,
other
:
LogOtherData
|
null
,
t
:
(
key
:
string
,
opts
?:
Record
<
string
,
unknown
>
)
=>
string
t
:
(
key
:
string
,
opts
?:
Record
<
string
,
unknown
>
)
=>
string
,
usageSchema
?:
BillingUsageSchema
):
DetailSegment
[]
{
):
DetailSegment
[]
{
// Top-up, audit, and login logs can carry a localized operation descriptor.
// Top-up, audit, and login logs can carry a localized operation descriptor.
if
(
log
.
type
===
1
||
log
.
type
===
3
||
log
.
type
===
7
)
{
if
(
log
.
type
===
1
||
log
.
type
===
3
||
log
.
type
===
7
)
{
...
@@ -168,7 +174,39 @@ function buildTypeDetailSegments(
...
@@ -168,7 +174,39 @@ function buildTypeDetailSegments(
}
}
const
isTieredExpr
=
other
.
billing_mode
===
'tiered_expr'
const
isTieredExpr
=
other
.
billing_mode
===
'tiered_expr'
const
tieredSummary
=
getTieredBillingSummary
(
other
)
const
tieredSummary
=
getTieredBillingSummary
(
other
)
if
(
isTieredExpr
)
{
if
(
isTieredExpr
&&
other
.
is_task
)
{
const
tiers
=
parseTaskTiersFromExpr
(
decodeBillingExprB64
(
other
.
expr_b64
),
usageSchema
,
true
)
const
tier
=
tiers
.
find
(
(
entry
)
=>
Boolean
(
other
.
matched_tier
)
&&
normalizeTierLabel
(
entry
.
label
)
===
normalizeTierLabel
(
other
.
matched_tier
)
)
if
(
tier
)
{
const
prices
=
Object
.
entries
(
tier
.
unitPrices
).
map
(([
field
,
price
])
=>
{
const
unit
=
usageSchema
?.[
field
]?.
unit
const
unitKey
=
getTaskUsagePriceUnitLabelKey
(
unit
)
return
`
${
field
}
${
formatTaskUsageUnitPrice
(
price
,
{
tokenUnit
:
'M'
})}
/
${
t
(
unitKey
)}
`
})
if
(
tier
.
constant
>
0
)
{
prices
.
push
(
`
${
t
(
'Additional charge'
)}
${
formatTaskUsageUnitPrice
(
tier
.
constant
,
{
tokenUnit
:
'M'
})}
/
${
t
(
'request'
)}
`
)
}
segments
.
push
({
text
:
`
${
tier
.
label
||
t
(
'Default'
)}
·
${
prices
.
join
(
' · '
)}
`
,
})
}
else
{
segments
.
push
({
text
:
`
${
t
(
'Dynamic Pricing'
)}
·
${
t
(
'No matching results'
)}
`
,
muted
:
true
,
})
}
}
else
if
(
isTieredExpr
)
{
if
(
tieredSummary
)
{
if
(
tieredSummary
)
{
const
baseEntries
=
tieredSummary
.
priceEntries
const
baseEntries
=
tieredSummary
.
priceEntries
.
filter
((
entry
)
=>
[
'inputPrice'
,
'outputPrice'
].
includes
(
entry
.
field
))
.
filter
((
entry
)
=>
[
'inputPrice'
,
'outputPrice'
].
includes
(
entry
.
field
))
...
@@ -743,7 +781,21 @@ export function useCommonLogsColumns(
...
@@ -743,7 +781,21 @@ export function useCommonLogsColumns(
const
log
=
row
.
original
const
log
=
row
.
original
const
other
=
parseLogOther
(
log
.
other
)
const
other
=
parseLogOther
(
log
.
other
)
const segments = buildDetailSegments(log, other, t, isAdmin)
const
pricingData
=
usePricingData
(
log
.
type
===
2
&&
other
?.
is_task
===
true
&&
other
.
billing_mode
===
'tiered_expr'
)
const
usageSchema
=
pricingData
.
models
.
find
(
(
model
)
=>
model
.
model_name
===
log
.
model_name
)?.
billing_usage_schema
const
segments
=
buildDetailSegments
(
log
,
other
,
t
,
isAdmin
,
usageSchema
)
const
primary
=
segments
[
0
]
const
primary
=
segments
[
0
]
const
hasMore
=
segments
.
length
>
1
const
hasMore
=
segments
.
length
>
1
let
primaryTextClass
=
'text-foreground'
let
primaryTextClass
=
'text-foreground'
...
...
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