Commit 71c1fd7c by CaIon

feat(models): improve model listing, pricing and visibility filters

Include configured channel models without creating metadata, derive square
visibility from live routes and metadata policy, and filter before pagination.

Share pricing display with the model square, show expression tiers and task
unit prices, preserve zero rates, and expose full pricing and visibility
reasons from compact responsive rows. Complete all seven locale translations.

Validated frontend tests, typecheck, lint and production build, plus the
model database matrix on SQLite 3.50.4, MySQL 5.7.44 and PostgreSQL 9.6.24.
parent 0e0ba152
...@@ -14,48 +14,63 @@ import ( ...@@ -14,48 +14,63 @@ import (
// GetAllModelsMeta 获取模型列表(分页) // GetAllModelsMeta 获取模型列表(分页)
func GetAllModelsMeta(c *gin.Context) { func GetAllModelsMeta(c *gin.Context) {
listModelsMeta(c, "", "")
pageInfo := common.GetPageQuery(c)
status := c.Query("status")
syncOfficial := c.Query("sync_official")
modelsMeta, total, err := model.SearchModels("", "", status, syncOfficial, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
// 批量填充附加字段,提升列表接口性能
enrichModels(modelsMeta)
// 统计供应商计数(全部数据,不受分页影响)
vendorCounts, _ := model.GetVendorModelCounts()
pageInfo.SetTotal(int(total))
pageInfo.SetItems(modelsMeta)
common.ApiSuccess(c, gin.H{
"items": modelsMeta,
"total": total,
"page": pageInfo.GetPage(),
"page_size": pageInfo.GetPageSize(),
"vendor_counts": vendorCounts,
})
} }
// SearchModelsMeta 搜索模型列表 // SearchModelsMeta 搜索模型列表
func SearchModelsMeta(c *gin.Context) { func SearchModelsMeta(c *gin.Context) {
listModelsMeta(c, c.Query("keyword"), c.Query("vendor"))
}
keyword := c.Query("keyword") func listModelsMeta(c *gin.Context, keyword, vendor string) {
vendor := c.Query("vendor") squareState := model.ModelSquareState(c.Query("square_state"))
status := c.Query("status") switch squareState {
syncOfficial := c.Query("sync_official") case "", model.ModelSquareVisible, model.ModelSquareUnavailable, model.ModelSquareHidden, model.ModelSquarePartial:
pageInfo := common.GetPageQuery(c) default:
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "Invalid model square state"})
return
}
modelsMeta, total, err := model.SearchModels(keyword, vendor, status, syncOfficial, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) pageInfo := common.GetPageQuery(c)
if squareState != "" && (pageInfo.GetPage() < 1 || pageInfo.GetPageSize() < 1) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "Invalid pagination"})
return
}
offset, limit := pageInfo.GetStartIdx(), pageInfo.GetPageSize()
if squareState != "" {
// Visibility depends on live channels and metadata rules. Filter the
// enriched candidate set before counting and paginating the results.
offset, limit = 0, -1
}
search := model.SearchModels
if c.Query("include_channel_models") == "true" {
search = model.SearchModelsWithChannels
}
modelsMeta, total, err := search(keyword, vendor, c.Query("status"), c.Query("sync_official"), offset, limit)
if err != nil { if err != nil {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
// 批量填充附加字段,提升列表接口性能 if err := enrichModels(modelsMeta); err != nil {
enrichModels(modelsMeta) common.ApiError(c, err)
return
}
if squareState != "" {
filtered := make([]*model.Model, 0, len(modelsMeta))
for _, metadata := range modelsMeta {
if metadata.SquareState == squareState {
filtered = append(filtered, metadata)
}
}
total = int64(len(filtered))
start := len(filtered)
if pageInfo.GetPage()-1 <= len(filtered)/pageInfo.GetPageSize() {
start = (pageInfo.GetPage() - 1) * pageInfo.GetPageSize()
}
end := min(start+pageInfo.GetPageSize(), len(filtered))
modelsMeta = filtered[start:end]
}
vendorCounts, _ := model.GetVendorModelCounts() vendorCounts, _ := model.GetVendorModelCounts()
pageInfo.SetTotal(int(total)) pageInfo.SetTotal(int(total))
pageInfo.SetItems(modelsMeta) pageInfo.SetItems(modelsMeta)
...@@ -81,7 +96,10 @@ func GetModelMeta(c *gin.Context) { ...@@ -81,7 +96,10 @@ func GetModelMeta(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
enrichModels([]*model.Model{&m}) if err := enrichModels([]*model.Model{&m}); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &m) common.ApiSuccess(c, &m)
} }
...@@ -114,6 +132,7 @@ func CreateModelMeta(c *gin.Context) { ...@@ -114,6 +132,7 @@ func CreateModelMeta(c *gin.Context) {
return return
} }
model.RefreshPricing() model.RefreshPricing()
m.HasMetadata = m.Id > 0
common.ApiSuccess(c, &m) common.ApiSuccess(c, &m)
} }
...@@ -165,6 +184,7 @@ func UpdateModelMeta(c *gin.Context) { ...@@ -165,6 +184,7 @@ func UpdateModelMeta(c *gin.Context) {
} }
} }
model.RefreshPricing() model.RefreshPricing()
m.HasMetadata = m.Id > 0
common.ApiSuccess(c, &m) common.ApiSuccess(c, &m)
} }
...@@ -224,14 +244,35 @@ func BatchDeleteModelMeta(c *gin.Context) { ...@@ -224,14 +244,35 @@ func BatchDeleteModelMeta(c *gin.Context) {
// enrichModels keeps configured endpoints intact and derives connections from // enrichModels keeps configured endpoints intact and derives connections from
// enabled routes, including hidden or unpriced models absent from the catalog. // enabled routes, including hidden or unpriced models absent from the catalog.
func enrichModels(models []*model.Model) { func enrichModels(models []*model.Model) error {
if len(models) == 0 { if len(models) == 0 {
return return nil
}
configured, err := model.GetConfiguredModelChannels()
if err != nil {
return err
}
for _, metadata := range models {
if metadata == nil {
continue
}
metadata.HasMetadata = metadata.Id > 0
channelIDs := make(map[int]struct{})
for name, ids := range configured {
if metadata.MatchesName(name) {
for _, id := range ids {
channelIDs[id] = struct{}{}
}
}
}
metadata.ConfiguredChannelCount = len(channelIDs)
} }
connections, err := model.GetModelConnections() connections, err := model.GetModelConnections()
if err != nil { if err != nil {
common.SysError("load model connections: " + err.Error()) return err
return }
if err := model.FillModelSquareStates(models, configured, connections); err != nil {
return err
} }
for _, metadata := range models { for _, metadata := range models {
if metadata == nil { if metadata == nil {
...@@ -244,16 +285,7 @@ func enrichModels(models []*model.Model) { ...@@ -244,16 +285,7 @@ func enrichModels(models []*model.Model) {
quotas := make(map[int]bool) quotas := make(map[int]bool)
for _, connection := range connections { for _, connection := range connections {
name := connection.Model name := connection.Model
matched := name == metadata.ModelName if !metadata.MatchesName(name) {
switch metadata.NameRule {
case model.NameRulePrefix:
matched = strings.HasPrefix(name, metadata.ModelName)
case model.NameRuleSuffix:
matched = strings.HasSuffix(name, metadata.ModelName)
case model.NameRuleContains:
matched = strings.Contains(name, metadata.ModelName)
}
if !matched {
continue continue
} }
names[name] = true names[name] = true
...@@ -301,4 +333,5 @@ func enrichModels(models []*model.Model) { ...@@ -301,4 +333,5 @@ func enrichModels(models []*model.Model) {
metadata.MatchedCount = len(names) metadata.MatchedCount = len(names)
} }
} }
return nil
} }
...@@ -18,6 +18,15 @@ const ( ...@@ -18,6 +18,15 @@ const (
NameRuleSuffix NameRuleSuffix
) )
type ModelSquareState string
const (
ModelSquareVisible ModelSquareState = "visible"
ModelSquareUnavailable ModelSquareState = "unavailable"
ModelSquareHidden ModelSquareState = "hidden"
ModelSquarePartial ModelSquareState = "partial"
)
type BoundChannel struct { type BoundChannel struct {
Name string `json:"name"` Name string `json:"name"`
Type int `json:"type"` Type int `json:"type"`
...@@ -45,6 +54,183 @@ type Model struct { ...@@ -45,6 +54,183 @@ type Model struct {
MatchedModels []string `json:"matched_models,omitempty" gorm:"-"` MatchedModels []string `json:"matched_models,omitempty" gorm:"-"`
MatchedCount int `json:"matched_count,omitempty" gorm:"-"` MatchedCount int `json:"matched_count,omitempty" gorm:"-"`
HasMetadata bool `json:"has_metadata" gorm:"-"`
ConfiguredChannelCount int `json:"configured_channel_count" gorm:"-"`
SquareState ModelSquareState `json:"square_state" gorm:"-"`
}
// MatchesName applies a metadata rule to a concrete channel model name.
func (mi *Model) MatchesName(name string) bool {
switch mi.NameRule {
case NameRulePrefix:
return strings.HasPrefix(name, mi.ModelName)
case NameRuleSuffix:
return strings.HasSuffix(name, mi.ModelName)
case NameRuleContains:
return strings.Contains(name, mi.ModelName)
default:
return name == mi.ModelName
}
}
// resolveModelMetadata preserves catalog precedence: exact, prefix, suffix,
// then contains. The first matching record within a rule type wins.
func resolveModelMetadata(records []Model, names []string) map[string]*Model {
resolved := make(map[string]*Model)
for i := range records {
if records[i].NameRule == NameRuleExact {
resolved[records[i].ModelName] = &records[i]
}
}
for _, rule := range []int{NameRulePrefix, NameRuleSuffix, NameRuleContains} {
for i := range records {
metadata := &records[i]
if metadata.NameRule != rule {
continue
}
for _, name := range names {
if _, exists := resolved[name]; !exists && metadata.MatchesName(name) {
resolved[name] = metadata
}
}
}
}
return resolved
}
// FillModelSquareStates applies the same metadata policy as the public catalog
// to live routes, then aggregates concrete models for metadata rule rows.
func FillModelSquareStates(rows []*Model, configured map[string][]int, connections []ModelConnection) error {
var metadata []Model
if err := DB.Find(&metadata).Error; err != nil {
return err
}
nameSet := make(map[string]struct{}, len(configured))
for name := range configured {
nameSet[name] = struct{}{}
}
for _, row := range rows {
if row != nil && row.NameRule == NameRuleExact {
nameSet[row.ModelName] = struct{}{}
}
}
names := make([]string, 0, len(nameSet))
for name := range nameSet {
names = append(names, name)
}
resolved := resolveModelMetadata(metadata, names)
available := make(map[string]bool)
for _, connection := range connections {
available[connection.Model] = true
}
states := make(map[string]ModelSquareState, len(names))
for _, name := range names {
state := ModelSquareUnavailable
if policy := resolved[name]; policy != nil && policy.Status != 1 {
state = ModelSquareHidden
} else if available[name] {
state = ModelSquareVisible
}
states[name] = state
}
for _, row := range rows {
if row == nil {
continue
}
if row.NameRule == NameRuleExact {
row.SquareState = states[row.ModelName]
continue
}
total, visible, hidden := 0, 0, 0
for name := range configured {
if !row.MatchesName(name) {
continue
}
total++
switch states[name] {
case ModelSquareVisible:
visible++
case ModelSquareHidden:
hidden++
}
}
row.SquareState = ModelSquareUnavailable
switch {
case total == 0:
if row.Status != 1 {
row.SquareState = ModelSquareHidden
}
case hidden == total:
row.SquareState = ModelSquareHidden
case visible == total:
row.SquareState = ModelSquareVisible
case visible > 0:
row.SquareState = ModelSquarePartial
}
}
return nil
}
// GetConfiguredModelChannels includes disabled channels and reads no credentials.
func GetConfiguredModelChannels() (map[string][]int, error) {
var channels []Channel
if err := DB.Select("id", "models").Find(&channels).Error; err != nil {
return nil, err
}
configured := make(map[string][]int)
for _, channel := range channels {
for _, name := range normalizeLookupValues(channel.GetModels()) {
configured[name] = append(configured[name], channel.Id)
}
}
return configured, nil
}
// SearchModelsWithChannels augments metadata with concrete configured names.
// Synthetic rows never persist and never affect the public pricing catalog.
func SearchModelsWithChannels(keyword, vendor, status, syncOfficial string, offset, limit int) ([]*Model, int64, error) {
records, _, err := SearchModels(keyword, vendor, status, syncOfficial, 0, -1)
if err != nil {
return nil, 0, err
}
_, filterStatus := parseModelStatusFilter(status)
_, filterSync := parseModelSyncFilter(syncOfficial)
if !filterStatus && !filterSync && (vendor == "" || vendor == "0") {
configured, err := GetConfiguredModelChannels()
if err != nil {
return nil, 0, err
}
var exactNames []string
if err := DB.Model(&Model{}).Where("name_rule = ?", NameRuleExact).Pluck("model_name", &exactNames).Error; err != nil {
return nil, 0, err
}
for _, name := range exactNames {
delete(configured, name)
}
names := make([]string, 0, len(configured))
for name := range configured {
if keyword == "" || strings.Contains(strings.ToLower(name), strings.ToLower(keyword)) {
names = append(names, name)
}
}
sort.Strings(names)
for _, name := range names {
records = append(records, &Model{ModelName: name, NameRule: NameRuleExact})
}
}
total := len(records)
if offset < 0 {
offset = 0
}
if offset >= total {
return []*Model{}, int64(total), nil
}
end := total
if limit >= 0 && limit < total-offset {
end = offset + limit
}
return records[offset:end], int64(total), nil
} }
func (mi *Model) Insert() error { func (mi *Model) Insert() error {
......
...@@ -191,54 +191,11 @@ func updatePricing() { ...@@ -191,54 +191,11 @@ func updatePricing() {
// 预加载模型元数据与供应商一次,避免循环查询 // 预加载模型元数据与供应商一次,避免循环查询
var allMeta []Model var allMeta []Model
_ = DB.Find(&allMeta).Error _ = DB.Find(&allMeta).Error
metaMap := make(map[string]*Model) names := make([]string, 0, len(enableAbilities))
prefixList := make([]*Model, 0) for _, ability := range enableAbilities {
suffixList := make([]*Model, 0) names = append(names, ability.Model)
containsList := make([]*Model, 0)
for i := range allMeta {
m := &allMeta[i]
if m.NameRule == NameRuleExact {
metaMap[m.ModelName] = m
} else {
switch m.NameRule {
case NameRulePrefix:
prefixList = append(prefixList, m)
case NameRuleSuffix:
suffixList = append(suffixList, m)
case NameRuleContains:
containsList = append(containsList, m)
}
}
}
// 将非精确规则模型匹配到 metaMap
for _, m := range prefixList {
for _, pricingModel := range enableAbilities {
if strings.HasPrefix(pricingModel.Model, m.ModelName) {
if _, exists := metaMap[pricingModel.Model]; !exists {
metaMap[pricingModel.Model] = m
}
}
}
}
for _, m := range suffixList {
for _, pricingModel := range enableAbilities {
if strings.HasSuffix(pricingModel.Model, m.ModelName) {
if _, exists := metaMap[pricingModel.Model]; !exists {
metaMap[pricingModel.Model] = m
}
}
}
}
for _, m := range containsList {
for _, pricingModel := range enableAbilities {
if strings.Contains(pricingModel.Model, m.ModelName) {
if _, exists := metaMap[pricingModel.Model]; !exists {
metaMap[pricingModel.Model] = m
}
}
}
} }
metaMap := resolveModelMetadata(allMeta, names)
// 预加载供应商 // 预加载供应商
var vendors []Vendor var vendors []Vendor
......
...@@ -361,15 +361,24 @@ it('converts task base charges and second, token and credit prices, including wh ...@@ -361,15 +361,24 @@ it('converts task base charges and second, token and credit prices, including wh
schema schema
) )
await selectCurrency('Site currency (CNY)') await selectCurrency('Site currency (CNY)')
fireEvent.change(screen.getByRole('textbox', { name: 'Base charge: std' }), { fireEvent.change(
screen.getByRole('textbox', { name: 'Additional charge: mode: std' }),
{
target: { value: '7' }, target: { value: '7' },
}) }
fireEvent.change(screen.getByRole('textbox', { name: 'tokens: std' }), { )
fireEvent.change(
screen.getByRole('textbox', { name: 'Unit price: tokens: mode: std' }),
{
target: { value: '70' }, target: { value: '70' },
}) }
fireEvent.change(screen.getByRole('textbox', { name: 'credits: std' }), { )
fireEvent.change(
screen.getByRole('textbox', { name: 'Unit price: credits: mode: std' }),
{
target: { value: '0.7' }, target: { value: '0.7' },
}) }
)
const secondsHeader = screen.getByRole('columnheader', { name: /seconds/ }) const secondsHeader = screen.getByRole('columnheader', { name: /seconds/ })
await userEvent.click( await userEvent.click(
within(secondsHeader).getByRole('button', { name: 'Fill entire column' }) within(secondsHeader).getByRole('button', { name: 'Fill entire column' })
...@@ -381,12 +390,12 @@ it('converts task base charges and second, token and credit prices, including wh ...@@ -381,12 +390,12 @@ it('converts task base charges and second, token and credit prices, including wh
await userEvent.click( await userEvent.click(
screen.getByRole('button', { name: 'Apply to all rows' }) screen.getByRole('button', { name: 'Apply to all rows' })
) )
expect(screen.getByRole('textbox', { name: 'seconds: std' })).toHaveValue( expect(
'14' screen.getByRole('textbox', { name: 'Unit price: seconds: mode: std' })
) ).toHaveValue('14')
expect(screen.getByRole('textbox', { name: 'seconds: pro' })).toHaveValue( expect(
'14' screen.getByRole('textbox', { name: 'Unit price: seconds: mode: pro' })
) ).toHaveValue('14')
const saved = await commit(editor.ref) const saved = await commit(editor.ref)
const config = tryParseTaskVisualConfig(saved?.billingExpr ?? '', schema) const config = tryParseTaskVisualConfig(saved?.billingExpr ?? '', schema)
expect(config?.tiers[0]).toMatchObject({ expect(config?.tiers[0]).toMatchObject({
...@@ -414,7 +423,7 @@ it('converts task unit prices without enum tiers and updates the monetary previe ...@@ -414,7 +423,7 @@ it('converts task unit prices without enum tiers and updates the monetary previe
fireEvent.change(screen.getByRole('textbox', { name: 'seconds' }), { fireEvent.change(screen.getByRole('textbox', { name: 'seconds' }), {
target: { value: '14' }, target: { value: '14' },
}) })
fireEvent.change(screen.getByRole('textbox', { name: 'Base charge' }), { fireEvent.change(screen.getByRole('textbox', { name: 'Additional charge' }), {
target: { value: '7' }, target: { value: '7' },
}) })
expect(await commit(editor.ref)).toMatchObject({ expect(await commit(editor.ref)).toMatchObject({
......
...@@ -24,15 +24,16 @@ import { ConfirmDialog } from '@/components/confirm-dialog' ...@@ -24,15 +24,16 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
import { ErrorState } from '@/components/error-state' import { ErrorState } from '@/components/error-state'
import { LoadingState } from '@/components/loading-state' import { LoadingState } from '@/components/loading-state'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { DynamicPricingBreakdown } from '@/features/pricing/components/dynamic-pricing-breakdown'
import { ModelPriceCell } from '@/features/pricing/components/model-price-cell'
import { isDynamicPricingModel } from '@/features/pricing/lib/dynamic-price'
import { formatPrice } from '@/features/pricing/lib/price'
import { import {
ModelPricingEditorPanel, ModelPricingEditorPanel,
type ModelPricingEditorPanelHandle, type ModelPricingEditorPanelHandle,
} from '@/features/system-settings/models/model-pricing-sheet' } from '@/features/system-settings/models/model-pricing-sheet'
import {
getPriceSummary,
getPriceDetail,
} from '@/features/system-settings/models/model-pricing-snapshots'
import { handleServerError } from '@/lib/handle-server-error' import { handleServerError } from '@/lib/handle-server-error'
import { useSystemConfigStore } from '@/stores/system-config-store'
import { import {
useCanEditModelPricing, useCanEditModelPricing,
...@@ -40,13 +41,14 @@ import { ...@@ -40,13 +41,14 @@ import {
useSaveModelPricing, useSaveModelPricing,
type ModelPricingEntry, type ModelPricingEntry,
} from './api' } from './api'
import { pricingFromDraft, pricingRow } from './pricing' import { modelPricingDisplay, pricingFromDraft, pricingRow } from './pricing'
export function ModelPricingPanel(props: { export function ModelPricingPanel(props: {
modelName: string modelName: string
onDirtyChange?: (dirty: boolean) => void onDirtyChange?: (dirty: boolean) => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
useSystemConfigStore((state) => state.config.currency)
const canEdit = useCanEditModelPricing() const canEdit = useCanEditModelPricing()
const query = useModelPricing([props.modelName], Boolean(props.modelName)) const query = useModelPricing([props.modelName], Boolean(props.modelName))
const save = useSaveModelPricing() const save = useSaveModelPricing()
...@@ -115,10 +117,7 @@ export function ModelPricingPanel(props: { ...@@ -115,10 +117,7 @@ export function ModelPricingPanel(props: {
) )
} }
if (!editData || !entry) return <LoadingState /> if (!editData || !entry) return <LoadingState />
const effectivePricing = { const effectivePricing = modelPricingDisplay(entry)
...pricingRow(entry.model_name, entry.effective),
hasConflict: false,
}
return ( return (
<div className='flex min-h-0 min-w-0 flex-1 flex-col gap-3'> <div className='flex min-h-0 min-w-0 flex-1 flex-col gap-3'>
...@@ -129,10 +128,6 @@ export function ModelPricingPanel(props: { ...@@ -129,10 +128,6 @@ export function ModelPricingPanel(props: {
? t('Stored configuration with effective defaults') ? t('Stored configuration with effective defaults')
: t('Using built-in or default pricing')} : t('Using built-in or default pricing')}
</p> </p>
<p className='mt-1 text-xs'>
{t('Current Billing')}: {getPriceSummary(effectivePricing, t)} ·{' '}
{getPriceDetail(effectivePricing, t)}
</p>
</div> </div>
<Button <Button
variant='outline' variant='outline'
...@@ -143,6 +138,73 @@ export function ModelPricingPanel(props: { ...@@ -143,6 +138,73 @@ export function ModelPricingPanel(props: {
{t('Restore default pricing')} {t('Restore default pricing')}
</Button> </Button>
</div> </div>
<section
aria-label={t('Current Billing')}
className='max-h-[40vh] shrink-0 space-y-3 overflow-auto border-b px-4 pb-3'
>
<h3 className='text-muted-foreground text-xs'>
{t('Current Billing')}
</h3>
<div className='max-w-xs'>
<ModelPriceCell
model={effectivePricing}
options={{ tokenUnit: 'M' }}
showExpression={false}
/>
</div>
{isDynamicPricingModel(effectivePricing) ? (
<DynamicPricingBreakdown
compact
billingExpr={effectivePricing.billing_expr}
usageSchema={entry.usage_schema}
/>
) : (
effectivePricing.quota_type === 0 &&
Number.isFinite(effectivePricing.model_ratio) && (
<dl className='grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3'>
{(
[
{
field: 'cache_ratio',
type: 'cache',
label: t('Cache Read'),
},
{
field: 'create_cache_ratio',
type: 'create_cache',
label: t('Cache write'),
},
{
field: 'image_ratio',
type: 'image',
label: t('Image input'),
},
{
field: 'audio_ratio',
type: 'audio_input',
label: t('Audio input'),
},
{
field: 'audio_completion_ratio',
type: 'audio_output',
label: t('Audio output'),
},
] as const
).map((field) => {
if (effectivePricing[field.field] == null) return null
return (
<div key={field.field}>
<dt className='text-muted-foreground'>{field.label}</dt>
<dd className='mt-1 font-mono tabular-nums'>
{formatPrice(effectivePricing, field.type, 'M')} / 1M
</dd>
</div>
)
})}
</dl>
)
)}
</section>
{save.isError && ( {save.isError && (
<div className='px-4'> <div className='px-4'>
<p role='alert' className='text-destructive mb-2 text-sm'> <p role='alert' className='text-destructive mb-2 text-sm'>
......
...@@ -19,12 +19,15 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -19,12 +19,15 @@ For commercial licensing, please contact support@quantumnous.com
import { t } from 'i18next' import { t } from 'i18next'
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr' import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
import type { PricingModel } from '@/features/pricing/types'
import type { ModelRatioData } from '@/features/system-settings/models/model-pricing-core' import type { ModelRatioData } from '@/features/system-settings/models/model-pricing-core'
import { import {
buildModelSnapshots, buildModelSnapshots,
type ModelPricingSnapshot, type ModelPricingSnapshot,
} from '@/features/system-settings/models/model-pricing-snapshots' } from '@/features/system-settings/models/model-pricing-snapshots'
import type { ModelPricingEntry } from './api'
export const PRICING_KEYS = [ export const PRICING_KEYS = [
'ModelPrice', 'ModelPrice',
'ModelRatio', 'ModelRatio',
...@@ -41,6 +44,49 @@ export type PricingKey = (typeof PRICING_KEYS)[number] ...@@ -41,6 +44,49 @@ export type PricingKey = (typeof PRICING_KEYS)[number]
export type PricingValues = Partial<Record<PricingKey, number | string>> export type PricingValues = Partial<Record<PricingKey, number | string>>
export type PricingOptions = Record<PricingKey, string> export type PricingOptions = Record<PricingKey, string>
export function modelPricingDisplay(
entry: Pick<ModelPricingEntry, 'model_name' | 'effective' | 'usage_schema'>
): PricingModel {
const values = entry.effective
return {
id: 0,
model_name: entry.model_name,
enable_groups: [],
quota_type:
values.ModelPrice !== undefined &&
values['billing_setting.billing_mode'] !== 'tiered_expr'
? 1
: 0,
model_ratio: Number(values.ModelRatio ?? Number.NaN),
completion_ratio: Number(values.CompletionRatio ?? Number.NaN),
model_price:
values.ModelPrice === undefined ? undefined : Number(values.ModelPrice),
cache_ratio:
values.CacheRatio === undefined ? undefined : Number(values.CacheRatio),
create_cache_ratio:
values.CreateCacheRatio === undefined
? undefined
: Number(values.CreateCacheRatio),
image_ratio:
values.ImageRatio === undefined ? undefined : Number(values.ImageRatio),
audio_ratio:
values.AudioRatio === undefined ? undefined : Number(values.AudioRatio),
audio_completion_ratio:
values.AudioCompletionRatio === undefined
? undefined
: Number(values.AudioCompletionRatio),
billing_mode:
typeof values['billing_setting.billing_mode'] === 'string'
? values['billing_setting.billing_mode']
: undefined,
billing_expr:
typeof values['billing_setting.billing_expr'] === 'string'
? values['billing_setting.billing_expr']
: undefined,
billing_usage_schema: entry.usage_schema,
}
}
export const pricingFieldMap = { export const pricingFieldMap = {
price: 'ModelPrice', price: 'ModelPrice',
ratio: 'ModelRatio', ratio: 'ModelRatio',
......
...@@ -69,6 +69,79 @@ function renderModelActions(currentModel: Model = model, role = 100) { ...@@ -69,6 +69,79 @@ function renderModelActions(currentModel: Model = model, role = 100) {
} }
describe('model pricing entry', () => { describe('model pricing entry', () => {
it('saves pricing and opens connections for a channel model without creating metadata', async () => {
const channelModel = { ...model, id: 0, model_name: 'channel-only' }
let storedPrice = 1.5
const get = vi.spyOn(api, 'get').mockImplementation(async (url) => {
if (url === '/api/option/model_pricing') {
return {
data: {
success: true,
data: {
entries: [
{
model_name: 'channel-only',
version: 'v1',
configured: { ModelPrice: storedPrice },
effective: { ModelPrice: storedPrice },
},
],
options: pricingOptions({}),
empty_version: 'empty',
},
},
}
}
return { data: { success: true, data: { items: [] } } }
})
const post = vi.spyOn(api, 'post')
const put = vi.spyOn(api, 'put')
const patch = vi.spyOn(api, 'patch').mockImplementation(async () => {
storedPrice = 0
return { data: { success: true } }
})
const client = renderModelActions(channelModel)
const user = userEvent.setup()
expect(screen.getByRole('button', { name: 'Add metadata' })).toBeVisible()
expect(
screen.queryByRole('button', { name: 'Open menu' })
).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Pricing' }))
expect(screen.getByRole('tab', { name: 'Pricing' })).not.toHaveAttribute(
'aria-disabled',
'true'
)
expect(
await screen.findByRole('button', { name: 'Save model prices' })
).toBeVisible()
const price = await screen.findByPlaceholderText('0.01')
await user.clear(price)
await user.type(price, '0')
await user.click(screen.getByRole('button', { name: 'Save model prices' }))
await waitFor(() =>
expect(patch).toHaveBeenCalledWith('/api/option/model_pricing', {
changes: [
{
model_name: 'channel-only',
expected_version: 'v1',
pricing: { ModelPrice: 0, 'billing_setting.billing_mode': 'ratio' },
reset: false,
},
],
})
)
expect(put).not.toHaveBeenCalled()
await user.click(screen.getByRole('tab', { name: 'Channels and groups' }))
expect(
screen.getByText(
'Channel availability and group access are derived from enabled channels. Importing metadata does not create a callable channel.'
)
).toBeVisible()
expect(get).not.toHaveBeenCalledWith('/api/models/0')
expect(post).not.toHaveBeenCalled()
client.clear()
})
it('opens pricing directly, keeps it selected after metadata loads, and reopens Edit on metadata', async () => { 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 let resolveDetail!: (value: Awaited<ReturnType<typeof api.get>>) => void
const detail = new Promise<Awaited<ReturnType<typeof api.get>>>( const detail = new Promise<Awaited<ReturnType<typeof api.get>>>(
......
...@@ -55,13 +55,17 @@ export function DataTableBulkActions<TData>({ ...@@ -55,13 +55,17 @@ export function DataTableBulkActions<TData>({
const selectedIds = selectedRows.reduce<number[]>((ids, row) => { const selectedIds = selectedRows.reduce<number[]>((ids, row) => {
const id = (row.original as Model).id const id = (row.original as Model).id
if (typeof id === 'number') { if (typeof id === 'number' && id > 0) {
ids.push(id) ids.push(id)
} }
return ids return ids
}, []) }, [])
const hasMissingMetadata = selectedRows.some(
(row) => !(row.original as Model).id
)
const selectedModels = selectedRows.map((row) => row.original as Model) const selectedModels = selectedRows.map((row) => row.original as Model)
const handleClearSelection = () => { const handleClearSelection = () => {
...@@ -96,11 +100,36 @@ export function DataTableBulkActions<TData>({ ...@@ -96,11 +100,36 @@ export function DataTableBulkActions<TData>({
/> />
)} )}
<BulkActionsToolbar table={table} entityName='model'> <BulkActionsToolbar table={table} entityName='model'>
{hasMissingMetadata && (
<Tooltip>
<TooltipTrigger
render={
<span
tabIndex={0}
aria-description={t(
'Add metadata to all selected models first.'
)}
className='text-muted-foreground max-w-28 truncate text-xs'
/>
}
>
{t('Missing metadata')}
</TooltipTrigger>
<TooltipContent>
{t('Add metadata to all selected models first.')}
</TooltipContent>
</Tooltip>
)}
<Button <Button
variant='outline' variant='outline'
size='icon' size='icon'
className='size-8' className='size-8'
title={t('Change vendor')} disabled={hasMissingMetadata}
title={t(
hasMissingMetadata
? 'Add metadata to all selected models first.'
: 'Change vendor'
)}
aria-label={t('Change vendor')} aria-label={t('Change vendor')}
onClick={() => onClick={() =>
setVendorOperation({ action: 'assign', model_ids: selectedIds }) setVendorOperation({ action: 'assign', model_ids: selectedIds })
...@@ -112,7 +141,12 @@ export function DataTableBulkActions<TData>({ ...@@ -112,7 +141,12 @@ export function DataTableBulkActions<TData>({
variant='outline' variant='outline'
size='icon' size='icon'
className='size-8' className='size-8'
title={t('Clear vendor')} disabled={hasMissingMetadata}
title={t(
hasMissingMetadata
? 'Add metadata to all selected models first.'
: 'Clear vendor'
)}
aria-label={t('Clear vendor')} aria-label={t('Clear vendor')}
onClick={() => onClick={() =>
setVendorOperation({ setVendorOperation({
...@@ -130,6 +164,7 @@ export function DataTableBulkActions<TData>({ ...@@ -130,6 +164,7 @@ export function DataTableBulkActions<TData>({
<Button <Button
variant='outline' variant='outline'
size='icon' size='icon'
disabled={hasMissingMetadata}
onClick={handleEnableAll} onClick={handleEnableAll}
className='size-8' className='size-8'
aria-label={t('Show selected models in model square')} aria-label={t('Show selected models in model square')}
...@@ -143,7 +178,13 @@ export function DataTableBulkActions<TData>({ ...@@ -143,7 +178,13 @@ export function DataTableBulkActions<TData>({
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{t('Show selected models in model square')}</p> <p>
{t(
hasMissingMetadata
? 'Add metadata to all selected models first.'
: 'Show selected models in model square'
)}
</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
...@@ -153,6 +194,7 @@ export function DataTableBulkActions<TData>({ ...@@ -153,6 +194,7 @@ export function DataTableBulkActions<TData>({
<Button <Button
variant='outline' variant='outline'
size='icon' size='icon'
disabled={hasMissingMetadata}
onClick={handleDisableAll} onClick={handleDisableAll}
className='size-8' className='size-8'
aria-label={t('Hide selected models from model square')} aria-label={t('Hide selected models from model square')}
...@@ -166,7 +208,13 @@ export function DataTableBulkActions<TData>({ ...@@ -166,7 +208,13 @@ export function DataTableBulkActions<TData>({
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{t('Hide selected models from model square')}</p> <p>
{t(
hasMissingMetadata
? 'Add metadata to all selected models first.'
: 'Hide selected models from model square'
)}
</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
...@@ -197,6 +245,7 @@ export function DataTableBulkActions<TData>({ ...@@ -197,6 +245,7 @@ export function DataTableBulkActions<TData>({
<Button <Button
variant='destructive' variant='destructive'
size='icon' size='icon'
disabled={hasMissingMetadata}
onClick={() => setShowDeleteConfirm(true)} onClick={() => setShowDeleteConfirm(true)}
className='size-8' className='size-8'
aria-label={t('Delete selected models')} aria-label={t('Delete selected models')}
...@@ -208,7 +257,13 @@ export function DataTableBulkActions<TData>({ ...@@ -208,7 +257,13 @@ export function DataTableBulkActions<TData>({
<span className='sr-only'>{t('Delete selected models')}</span> <span className='sr-only'>{t('Delete selected models')}</span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{t('Delete selected models')}</p> <p>
{t(
hasMissingMetadata
? 'Add metadata to all selected models first.'
: 'Delete selected models'
)}
</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</BulkActionsToolbar> </BulkActionsToolbar>
......
...@@ -63,9 +63,16 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { ...@@ -63,9 +63,16 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
: t('Show in model square') : t('Show in model square')
return ( return (
<div className='-ml-1.5 flex items-center gap-1'> <div className='-ml-1.5 flex min-w-0 items-center gap-1 [&>button]:min-w-0 [&>button]:shrink'>
<Button variant='ghost' size='sm' onClick={handleEdit}> <Button
{t('Edit')} variant='ghost'
size='sm'
onClick={handleEdit}
title={model.id > 0 ? t('Edit') : t('Add metadata')}
>
<span className='truncate'>
{model.id > 0 ? t('Edit') : t('Add metadata')}
</span>
</Button> </Button>
{canPrice && ( {canPrice && (
...@@ -77,10 +84,11 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { ...@@ -77,10 +84,11 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
setOpen('price-model') setOpen('price-model')
}} }}
> >
{t('Pricing')} <span className='truncate'>{t('Pricing')}</span>
</Button> </Button>
)} )}
{model.id > 0 && (
<DataTableRowActionMenu ariaLabel={t('Open menu')}> <DataTableRowActionMenu ariaLabel={t('Open menu')}>
<DropdownMenuItem onClick={handleToggleStatus}> <DropdownMenuItem onClick={handleToggleStatus}>
{toggleLabel} {toggleLabel}
...@@ -101,6 +109,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { ...@@ -101,6 +109,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
</DropdownMenuShortcut> </DropdownMenuShortcut>
</DropdownMenuItem> </DropdownMenuItem>
</DataTableRowActionMenu> </DataTableRowActionMenu>
)}
{deleteConfirmOpen && ( {deleteConfirmOpen && (
<ModelDeleteDialog <ModelDeleteDialog
......
...@@ -86,8 +86,16 @@ export function ModelMutateDrawer(props: { ...@@ -86,8 +86,16 @@ export function ModelMutateDrawer(props: {
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const currentRow = props.currentRow const [createdModel, setCreatedModel] = useState<{
source: Model | null | undefined
model: Model
} | null>(null)
const currentRow =
createdModel && createdModel.source === props.currentRow
? createdModel.model
: props.currentRow
const isEditing = Boolean(currentRow?.id) const isEditing = Boolean(currentRow?.id)
const hasModelName = Boolean(currentRow?.model_name)
const [section, setSection] = useState<string>( const [section, setSection] = useState<string>(
props.initialSection ?? 'metadata' props.initialSection ?? 'metadata'
) )
...@@ -141,14 +149,22 @@ export function ModelMutateDrawer(props: { ...@@ -141,14 +149,22 @@ export function ModelMutateDrawer(props: {
setPricingDirty(false) setPricingDirty(false)
setPendingPricingName(null) setPendingPricingName(null)
setCloseConfirm(false) setCloseConfirm(false)
}, [props.open, props.initialSection, currentRow?.id]) }, [
props.open,
props.initialSection,
props.currentRow?.id,
props.currentRow?.model_name,
])
useEffect(() => { useEffect(() => {
if (!props.open) { if (!props.open) {
setCreatedModel(null)
loadedKey.current = '' loadedKey.current = ''
return return
} }
const key = String(currentRow?.id ?? currentRow?.model_name ?? 'new') const key = currentRow?.id
? `metadata:${currentRow.id}`
: `channel:${currentRow?.model_name ?? ''}`
if (loadedKey.current === key || (isEditing && !modelQuery.data)) return if (loadedKey.current === key || (isEditing && !modelQuery.data)) return
form.reset( form.reset(
transformModelToFormDefaults( transformModelToFormDefaults(
...@@ -185,6 +201,9 @@ export function ModelMutateDrawer(props: { ...@@ -185,6 +201,9 @@ export function ModelMutateDrawer(props: {
onSuccess: async (response) => { onSuccess: async (response) => {
form.reset(form.getValues()) form.reset(form.getValues())
if (response.data?.id) { if (response.data?.id) {
if (!currentRow?.id) {
setCreatedModel({ source: props.currentRow, model: response.data })
}
queryClient.setQueryData( queryClient.setQueryData(
modelsQueryKeys.detail(response.data.id), modelsQueryKeys.detail(response.data.id),
response.data response.data
...@@ -235,7 +254,7 @@ export function ModelMutateDrawer(props: { ...@@ -235,7 +254,7 @@ export function ModelMutateDrawer(props: {
> >
<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')} {hasModelName ? currentRow?.model_name : t('Create Model')}
</SheetTitle> </SheetTitle>
<SheetDescription> <SheetDescription>
{t( {t(
...@@ -260,14 +279,14 @@ export function ModelMutateDrawer(props: { ...@@ -260,14 +279,14 @@ export function ModelMutateDrawer(props: {
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value='pricing' value='pricing'
disabled={!isEditing} disabled={!hasModelName}
className='h-auto min-w-0 whitespace-normal' className='h-auto min-w-0 whitespace-normal'
> >
{t('Pricing')} {t('Pricing')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value='connections' value='connections'
disabled={!isEditing} disabled={!hasModelName}
className='h-auto min-w-0 whitespace-normal' className='h-auto min-w-0 whitespace-normal'
> >
{t('Channels and groups')} {t('Channels and groups')}
...@@ -532,7 +551,7 @@ export function ModelMutateDrawer(props: { ...@@ -532,7 +551,7 @@ export function ModelMutateDrawer(props: {
</FormLabel> </FormLabel>
<FormDescription> <FormDescription>
{t( {t(
'Controls visibility in the model square. Channel status and existing API access are unchanged.' 'Allow listing when a channel is available and the user has group access. This does not change API access.'
)} )}
</FormDescription> </FormDescription>
</div> </div>
......
...@@ -24,6 +24,7 @@ import { GroupBadge } from '@/components/group-badge' ...@@ -24,6 +24,7 @@ import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import type { Model } from '../types' import type { Model } from '../types'
import { ModelSquareStatus } from './model-square-status'
export function ModelConnections(props: { model: Model }) { export function ModelConnections(props: { model: Model }) {
const { t } = useTranslation() const { t } = useTranslation()
...@@ -34,6 +35,7 @@ export function ModelConnections(props: { model: Model }) { ...@@ -34,6 +35,7 @@ export function ModelConnections(props: { model: Model }) {
'Channel availability and group access are derived from enabled channels. Importing metadata does not create a callable channel.' 'Channel availability and group access are derived from enabled channels. Importing metadata does not create a callable channel.'
)} )}
</p> </p>
<ModelSquareStatus model={props.model} detail />
<section className='space-y-3'> <section className='space-y-3'>
<h3 className='font-medium'>{t('Bound Channels')}</h3> <h3 className='font-medium'>{t('Bound Channels')}</h3>
{props.model.bound_channels?.length ? ( {props.model.bound_channels?.length ? (
......
/*
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 { TriangleAlert } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { StatusBadge, type StatusVariant } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverTrigger,
PopoverContent,
PopoverTitle,
PopoverDescription,
} from '@/components/ui/popover'
import type { Model } from '../types'
export function ModelSquareStatus(props: { model: Model; detail?: boolean }) {
const { t } = useTranslation()
let label: string
let description: string
let variant: StatusVariant = 'neutral'
switch (props.model.square_state) {
case 'visible':
label = t('Displayed')
description = t(
'Visible only to users with access to the model’s groups.'
)
variant = 'success'
break
case 'hidden':
label = t('Listing hidden')
description = t('Hidden from the model square by metadata policy.')
break
case 'partial':
label = t('Partly shown')
description = t(
'Some matching models are hidden or have no available channel.'
)
variant = 'warning'
break
case 'unavailable':
label = t('Unavailable')
variant = 'warning'
description = t(
'No channel is currently available. This model will not appear in the model square.'
)
if (props.model.configured_channel_count === 0) {
description = t(
'No channel is configured. This model will not appear in the model square.'
)
if (props.model.name_rule !== 0) {
description = t(
'No configured channel models match this metadata rule.'
)
}
}
break
default:
return <span className='text-muted-foreground'></span>
}
const badge = (
<StatusBadge
variant={variant}
label={label}
icon={variant === 'warning' ? TriangleAlert : undefined}
copyable={false}
title={undefined}
/>
)
if (props.detail) {
return (
<div className='space-y-2'>
{badge}
<p className='text-muted-foreground text-sm'>{description}</p>
</div>
)
}
return (
<Popover>
<PopoverTrigger
render={
<Button
variant='ghost'
size='sm'
title={description}
aria-description={description}
className='h-auto max-w-full min-w-0 cursor-pointer border-0 p-0'
/>
}
>
{badge}
</PopoverTrigger>
<PopoverContent
role='dialog'
className='max-w-[calc(100vw-2rem)] break-words whitespace-normal'
collisionPadding={16}
>
<PopoverTitle>{label}</PopoverTitle>
<PopoverDescription>{description}</PopoverDescription>
</PopoverContent>
</Popover>
)
}
...@@ -26,23 +26,26 @@ import { StatusBadge } from '@/components/status-badge' ...@@ -26,23 +26,26 @@ import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from '@/components/ui/tooltip'
import {
useCanEditModelPricing, useCanEditModelPricing,
type ModelPricingConfig, type ModelPricingConfig,
} from '@/features/model-pricing/api' } from '@/features/model-pricing/api'
import { pricingRow } from '@/features/model-pricing/pricing' import { modelPricingDisplay } from '@/features/model-pricing/pricing'
import { import { ModelPriceCell } from '@/features/pricing/components/model-price-cell'
getPriceDetail,
getPriceSummary,
isBasePricingUnset,
} from '@/features/system-settings/models/model-pricing-snapshots'
import { formatTimestampToDate } from '@/lib/format' import { formatTimestampToDate } from '@/lib/format'
import { getLobeIcon } from '@/lib/lobe-icon' import { getLobeIcon } from '@/lib/lobe-icon'
import { getNameRuleConfig } from '../constants' import { getNameRuleConfig } from '../constants'
import { parseModelTags, formatEndpointsDisplay } from '../lib' import { parseModelTags, formatEndpointsDisplay } from '../lib'
import { getModelChannelState } from '../lib/model-utils'
import type { Model, Vendor } from '../types' import type { Model, Vendor } from '../types'
import { DataTableRowActions } from './data-table-row-actions' import { DataTableRowActions } from './data-table-row-actions'
import { DescriptionCell } from './description-cell' import { DescriptionCell } from './description-cell'
import { ModelSquareStatus } from './model-square-status'
import { useModels } from './models-provider' import { useModels } from './models-provider'
export function useModelsColumns( export function useModelsColumns(
...@@ -99,7 +102,7 @@ export function useModelsColumns( ...@@ -99,7 +102,7 @@ export function useModelsColumns(
const vendor = vendorMap.get(model.vendor_id ?? 0) const vendor = vendorMap.get(model.vendor_id ?? 0)
const iconKey = model.icon || vendor?.icon || model.model_name[0] const iconKey = model.icon || vendor?.icon || model.model_name[0]
return ( return (
<div className='flex max-w-[420px] min-w-0 items-start gap-2.5 py-1'> <div className='flex max-w-[320px] min-w-0 items-start gap-2.5 py-1'>
<span className='mt-1 flex size-6 shrink-0 items-center justify-center'> <span className='mt-1 flex size-6 shrink-0 items-center justify-center'>
{getLobeIcon(iconKey, 24)} {getLobeIcon(iconKey, 24)}
</span> </span>
...@@ -123,7 +126,9 @@ export function useModelsColumns( ...@@ -123,7 +126,9 @@ export function useModelsColumns(
</div> </div>
<div className='text-muted-foreground mt-1 flex min-w-0 items-center gap-2 text-xs'> <div className='text-muted-foreground mt-1 flex min-w-0 items-center gap-2 text-xs'>
<span className='truncate' title={vendor?.name}> <span className='truncate' title={vendor?.name}>
{vendor?.name ?? t('No vendor')} {model.id > 0
? (vendor?.name ?? t('No vendor'))
: t('Missing metadata')}
</span> </span>
{model.name_rule !== 0 && ( {model.name_rule !== 0 && (
<span className='shrink-0'> <span className='shrink-0'>
...@@ -140,6 +145,7 @@ export function useModelsColumns( ...@@ -140,6 +145,7 @@ export function useModelsColumns(
{ {
id: 'pricing', id: 'pricing',
header: t('Pricing'), header: t('Pricing'),
meta: { label: t('Pricing') },
size: 225, size: 225,
enableSorting: false, enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
...@@ -167,71 +173,69 @@ export function useModelsColumns( ...@@ -167,71 +173,69 @@ export function useModelsColumns(
) )
} }
const entry = priceMap.get(row.original.model_name) const entry = priceMap.get(row.original.model_name)
if (!entry) {
return (
<span className='text-muted-foreground text-sm'>
{t('Unset price')}
</span>
)
}
const price = {
...pricingRow(row.original.model_name, entry.effective),
hasConflict: false,
}
return ( return (
<div className='space-y-1'> <Button
<div className='text-sm tabular-nums'> variant='ghost'
{getPriceSummary(price, t)} className='h-auto w-full max-w-full min-w-0 justify-start px-0 py-1 text-left font-normal hover:bg-transparent'
</div> aria-label={t('View pricing for {{model}}', {
{!isBasePricingUnset(price) && ( model: row.original.model_name,
<div className='text-muted-foreground text-xs'> })}
{getPriceDetail(price, t)} onClick={() => {
{price.billingMode === 'per-token' && ' · USD/1M'} setCurrentRow(row.original)
</div> setOpen('price-model')
}}
>
<ModelPriceCell
model={modelPricingDisplay(
entry ?? { model_name: row.original.model_name, effective: {} }
)} )}
</div> options={{ tokenUnit: 'M' }}
showExpression={false}
/>
</Button>
) )
}, },
}, },
{ {
accessorKey: 'status', accessorKey: 'square_state',
header: t('Model square visibility'), header: t('Model square visibility'),
size: 115, size: 115,
enableSorting: false, enableSorting: false,
meta: { mobileBadge: true }, meta: { mobileBadge: true },
cell: ({ row }) => ( cell: ({ row }) => <ModelSquareStatus model={row.original} />,
<StatusBadge
variant={row.original.status === 1 ? 'success' : 'neutral'}
label={row.original.status === 1 ? t('Shown') : t('Not shown')}
copyable={false}
/>
),
}, },
{ {
id: 'connections', id: 'connections',
header: t('Channels and groups'), header: t('Channels and groups'),
size: 180, size: 180,
enableSorting: false, enableSorting: false,
cell: ({ row }) => ( cell: ({ row }) => {
<div className='space-y-1 text-sm'> const state = getModelChannelState(row.original)
return (
<div className='min-w-0 text-sm'>
<Tooltip>
<TooltipTrigger
render={
<span <span
className={ tabIndex={0}
row.original.bound_channels?.length ? '' : 'text-muted-foreground' title={t(state.description)}
aria-description={t(state.description)}
className='block whitespace-normal sm:truncate'
/>
} }
> >
{row.original.bound_channels?.length {t('Channels {{channels}} · Groups {{groups}}', {
? t('{{count}} available channels', { channels: row.original.bound_channels?.length ?? 0,
count: row.original.bound_channels.length, groups: row.original.enable_groups?.length ?? 0,
})
: t('No available channels')}
</span>
<div className='text-muted-foreground text-xs'>
{t('{{count}} enabled groups', {
count: row.original.enable_groups?.length ?? 0,
})} })}
</TooltipTrigger>
<TooltipContent role='tooltip'>
{t(state.description)}
</TooltipContent>
</Tooltip>
</div> </div>
</div> )
), },
}, },
{ {
accessorKey: 'tags', accessorKey: 'tags',
...@@ -242,6 +246,7 @@ export function useModelsColumns( ...@@ -242,6 +246,7 @@ export function useModelsColumns(
cell: ({ row }) => ( cell: ({ row }) => (
<BadgeListCell <BadgeListCell
expandable expandable
max={1}
items={parseModelTags(row.original.tags ?? '').map((tag) => ( items={parseModelTags(row.original.tags ?? '').map((tag) => (
<StatusBadge key={tag} label={tag} variant='neutral' size='sm' /> <StatusBadge key={tag} label={tag} variant='neutral' size='sm' />
))} ))}
...@@ -250,14 +255,20 @@ export function useModelsColumns( ...@@ -250,14 +255,20 @@ export function useModelsColumns(
}, },
{ {
accessorKey: 'sync_official', accessorKey: 'sync_official',
header: t('Sync policy'), header: () => (
<TruncatedCell className='max-w-[120px]'>
{t('Sync policy')}
</TruncatedCell>
),
size: 145, size: 145,
enableSorting: false, enableSorting: false,
meta: { mobileHidden: true }, meta: { mobileHidden: true, label: t('Sync policy') },
cell: ({ row }) => ( cell: ({ row }) => (
<span className='text-muted-foreground text-sm'> <TruncatedCell className='text-muted-foreground max-w-[120px] text-sm'>
{row.original.sync_official ? t('Allow updates') : t('Keep local')} {row.original.id > 0 &&
</span> (row.original.sync_official ? t('Allow updates') : t('Keep local'))}
{!row.original.id && '—'}
</TruncatedCell>
), ),
}, },
{ {
...@@ -269,8 +280,17 @@ export function useModelsColumns( ...@@ -269,8 +280,17 @@ export function useModelsColumns(
cell: ({ row }) => <DataTableRowActions row={row} />, cell: ({ row }) => <DataTableRowActions row={row} />,
}, },
{ {
accessorKey: 'status',
header: t('Display policy'),
enableHiding: false,
enableSorting: false,
cell: ({ row }) =>
row.original.status === 1 ? t('Allowed') : t('Not listed'),
},
{
accessorKey: 'id', accessorKey: 'id',
header: t('ID'), header: t('ID'),
cell: ({ row }) => row.original.id || '—',
size: 65, size: 65,
meta: { mobileHidden: true }, meta: { mobileHidden: true },
}, },
...@@ -329,14 +349,20 @@ export function useModelsColumns( ...@@ -329,14 +349,20 @@ export function useModelsColumns(
accessorKey: 'created_time', accessorKey: 'created_time',
header: t('Created'), header: t('Created'),
size: 160, size: 160,
cell: ({ row }) => formatTimestampToDate(row.original.created_time), cell: ({ row }) =>
row.original.id
? formatTimestampToDate(row.original.created_time)
: '—',
meta: { mobileHidden: true }, meta: { mobileHidden: true },
}, },
{ {
accessorKey: 'updated_time', accessorKey: 'updated_time',
header: t('Updated'), header: t('Updated'),
size: 160, size: 160,
cell: ({ row }) => formatTimestampToDate(row.original.updated_time), cell: ({ row }) =>
row.original.id
? formatTimestampToDate(row.original.updated_time)
: '—',
meta: { mobileHidden: true }, meta: { mobileHidden: true },
}, },
] ]
......
...@@ -30,6 +30,7 @@ import { useTableUrlState } from '@/hooks/use-table-url-state' ...@@ -30,6 +30,7 @@ import { useTableUrlState } from '@/hooks/use-table-url-state'
import { getModels, searchModels, getVendors } from '../api' import { getModels, searchModels, getVendors } from '../api'
import { DEFAULT_PAGE_SIZE } from '../constants' import { DEFAULT_PAGE_SIZE } from '../constants'
import { modelsQueryKeys, vendorsQueryKeys } from '../lib' import { modelsQueryKeys, vendorsQueryKeys } from '../lib'
import type { ModelSquareState } from '../types'
import { DataTableBulkActions } from './data-table-bulk-actions' import { DataTableBulkActions } from './data-table-bulk-actions'
import { useModelsColumns } from './models-columns' import { useModelsColumns } from './models-columns'
import { useModels } from './models-provider' import { useModels } from './models-provider'
...@@ -60,6 +61,7 @@ export function ModelsTable() { ...@@ -60,6 +61,7 @@ export function ModelsTable() {
globalFilter: { enabled: true, key: 'filter' }, globalFilter: { enabled: true, key: 'filter' },
columnFilters: [ columnFilters: [
{ columnId: 'status', searchKey: 'status', type: 'array' }, { columnId: 'status', searchKey: 'status', type: 'array' },
{ columnId: 'square_state', searchKey: 'square_state', type: 'array' },
{ columnId: 'vendor_id', searchKey: 'vendor', type: 'array' }, { columnId: 'vendor_id', searchKey: 'vendor', type: 'array' },
{ columnId: 'sync_official', searchKey: 'sync', type: 'array' }, { columnId: 'sync_official', searchKey: 'sync', type: 'array' },
], ],
...@@ -68,6 +70,11 @@ export function ModelsTable() { ...@@ -68,6 +70,11 @@ export function ModelsTable() {
// Extract filters from column filters // Extract filters from column filters
const statusFilter = const statusFilter =
(columnFilters.find((f) => f.id === 'status')?.value as string[]) || [] (columnFilters.find((f) => f.id === 'status')?.value as string[]) || []
const squareState = (
columnFilters.find((f) => f.id === 'square_state')?.value as
| ModelSquareState[]
| undefined
)?.[0]
const vendorFilter = const vendorFilter =
(columnFilters.find((f) => f.id === 'vendor_id')?.value as string[]) || [] (columnFilters.find((f) => f.id === 'vendor_id')?.value as string[]) || []
const syncFilter = const syncFilter =
...@@ -113,6 +120,7 @@ export function ModelsTable() { ...@@ -113,6 +120,7 @@ export function ModelsTable() {
globalFilter?.trim() || globalFilter?.trim() ||
activeVendorFilter || activeVendorFilter ||
statusFilterValue || statusFilterValue ||
squareState ||
syncFilterValue syncFilterValue
) )
...@@ -120,9 +128,11 @@ export function ModelsTable() { ...@@ -120,9 +128,11 @@ export function ModelsTable() {
// eslint-disable-next-line @tanstack/query/exhaustive-deps // eslint-disable-next-line @tanstack/query/exhaustive-deps
const { data, isLoading, isFetching, isError, error, refetch } = useQuery({ const { data, isLoading, isFetching, isError, error, refetch } = useQuery({
queryKey: modelsQueryKeys.list({ queryKey: modelsQueryKeys.list({
include_channel_models: true,
keyword: globalFilter, keyword: globalFilter,
vendor: activeVendorFilter, vendor: activeVendorFilter,
status: statusFilterValue, status: statusFilterValue,
square_state: squareState,
sync_official: syncFilterValue, sync_official: syncFilterValue,
p: pagination.pageIndex + 1, p: pagination.pageIndex + 1,
page_size: pagination.pageSize, page_size: pagination.pageSize,
...@@ -130,15 +140,18 @@ export function ModelsTable() { ...@@ -130,15 +140,18 @@ export function ModelsTable() {
queryFn: async () => { queryFn: async () => {
if (shouldSearch) { if (shouldSearch) {
return searchModels({ return searchModels({
include_channel_models: true,
keyword: globalFilter, keyword: globalFilter,
vendor: activeVendorFilter, vendor: activeVendorFilter,
status: statusFilterValue, status: statusFilterValue,
square_state: squareState,
sync_official: syncFilterValue, sync_official: syncFilterValue,
p: pagination.pageIndex + 1, p: pagination.pageIndex + 1,
page_size: pagination.pageSize, page_size: pagination.pageSize,
}) })
} }
return getModels({ return getModels({
include_channel_models: true,
p: pagination.pageIndex + 1, p: pagination.pageIndex + 1,
page_size: pagination.pageSize, page_size: pagination.pageSize,
}) })
...@@ -164,7 +177,8 @@ export function ModelsTable() { ...@@ -164,7 +177,8 @@ export function ModelsTable() {
// React Table instance // React Table instance
const { table } = useDataTable({ const { table } = useDataTable({
data: models, data: models,
getRowId: (model) => String(model.id), getRowId: (model) =>
model.id > 0 ? `metadata:${model.id}` : `channel:${model.model_name}`,
columns, columns,
totalCount, totalCount,
initialColumnVisibility: { initialColumnVisibility: {
...@@ -175,6 +189,7 @@ export function ModelsTable() { ...@@ -175,6 +189,7 @@ export function ModelsTable() {
endpoints: false, endpoints: false,
created_time: false, created_time: false,
updated_time: false, updated_time: false,
status: false,
}, },
columnFilters, columnFilters,
pagination, pagination,
...@@ -235,10 +250,21 @@ export function ModelsTable() { ...@@ -235,10 +250,21 @@ export function ModelsTable() {
filters: [ filters: [
{ {
columnId: 'status', columnId: 'status',
title: t('Display policy'),
options: [
{ label: t('Allowed'), value: 'enabled' },
{ label: t('Not listed'), value: 'disabled' },
],
singleSelect: true,
},
{
columnId: 'square_state',
title: t('Model square visibility'), title: t('Model square visibility'),
options: [ options: [
{ label: t('Shown'), value: 'enabled' }, { label: t('Displayed'), value: 'visible' },
{ label: t('Not shown'), value: 'disabled' }, { label: t('Unavailable'), value: 'unavailable' },
{ label: t('Listing hidden'), value: 'hidden' },
{ label: t('Partly shown'), value: 'partial' },
], ],
singleSelect: true, singleSelect: true,
}, },
......
...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,7 +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 { type TFunction } from 'i18next' import type { TFunction } from 'i18next'
import { formatTimestampToDate } from '@/lib/format' import { formatTimestampToDate } from '@/lib/format'
...@@ -195,3 +195,34 @@ export function isModelEnabled(model: Model): boolean { ...@@ -195,3 +195,34 @@ export function isModelEnabled(model: Model): boolean {
export function isModelSyncOfficial(model: Model): boolean { export function isModelSyncOfficial(model: Model): boolean {
return model.sync_official === 1 return model.sync_official === 1
} }
// Keep table labels compact; the drawer and tooltip share the full explanation.
export function getModelChannelState(model: Model) {
const available = model.bound_channels?.length ?? 0
const configured = model.configured_channel_count ?? available
if (configured === 0) {
if (model.name_rule !== 0) {
return {
label: 'No matching channels',
description: 'No configured channel models match this metadata rule.',
}
}
return {
label: 'Metadata only',
description:
'No channel is configured. This model will not appear in the model square.',
}
}
if (available === 0) {
return {
label: 'No available channels',
description:
'No channel is currently available. This model will not appear in the model square.',
}
}
return {
label: 'Available channels: {{count}}',
description:
'Listing also depends on metadata visibility and the user’s group access.',
}
}
...@@ -33,7 +33,12 @@ export interface BoundChannel { ...@@ -33,7 +33,12 @@ export interface BoundChannel {
/** /**
* Model entity from API * Model entity from API
*/ */
export type ModelSquareState = 'visible' | 'unavailable' | 'hidden' | 'partial'
export interface Model { export interface Model {
square_state?: ModelSquareState
has_metadata?: boolean
configured_channel_count?: number
id: number id: number
model_name: string model_name: string
description?: string description?: string
...@@ -89,6 +94,8 @@ export interface PrefillGroup { ...@@ -89,6 +94,8 @@ export interface PrefillGroup {
* Get models list parameters * Get models list parameters
*/ */
export interface GetModelsParams { export interface GetModelsParams {
square_state?: ModelSquareState
include_channel_models?: boolean
p?: number p?: number
page_size?: number page_size?: number
vendor?: string // vendor ID to filter by vendor?: string // vendor ID to filter by
...@@ -100,6 +107,8 @@ export interface GetModelsParams { ...@@ -100,6 +107,8 @@ export interface GetModelsParams {
* Search models parameters * Search models parameters
*/ */
export interface SearchModelsParams { export interface SearchModelsParams {
square_state?: ModelSquareState
include_channel_models?: boolean
keyword?: string keyword?: string
vendor?: string // vendor ID to filter by vendor?: string // vendor ID to filter by
status?: string // filter by status status?: string // filter by status
......
...@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { describe, test } from 'vitest' import { describe, expect, test } from 'vitest'
import { getBillingModeLabelKey } from '../lib/billing-mode' import { getBillingModeLabelKey } from '../lib/billing-mode'
import { import {
...@@ -52,6 +52,69 @@ const summaryOptions = { ...@@ -52,6 +52,69 @@ const summaryOptions = {
groupRatioMultiplier: 2, groupRatioMultiplier: 2,
} }
describe('expression price summaries', () => {
test('preserves a versioned parenthesized base price and its request rule', () => {
const summary = getDynamicPricingSummary(
pricingModel({
billing_mode: 'tiered_expr',
billing_expr:
'v1:(tier("base", p * 2 + c * 8)) * (header("x-priority") == "high" ? 2 : 1)',
}),
{ tokenUnit: 'M' }
)
expect(summary?.isSpecialExpression).toBe(false)
expect(summary?.primaryEntries.map((entry) => entry.value)).toEqual([2, 8])
expect(summary?.hasRequestRules).toBe(true)
})
test.each([
'tier("custom", max(p * 2 + c * 8, 100))',
'tier("base", p * 2 + c * 8) * 3',
'param("premium") ? tier("pro", p * 4 + c * 16) : tier("base", p * 2 + c * 8)',
'tier("overflow", p * 1e999 + c * 8)',
])('does not invent structured prices from %s', (expression) => {
const summary = getDynamicPricingSummary(
pricingModel({ billing_mode: 'tiered_expr', billing_expr: expression }),
{ tokenUnit: 'M' }
)
expect(summary?.isSpecialExpression).toBe(true)
expect(summary?.entries).toEqual([])
})
test('retains explicit zero token rates while omitting absent categories', () => {
const summary = getDynamicPricingSummary(
pricingModel({
billing_mode: 'tiered_expr',
billing_expr: 'tier("free", p * 0 + c * 0)',
}),
{ tokenUnit: 'M' }
)
expect(
summary?.primaryEntries.map((entry) => [entry.field, entry.value])
).toEqual([
['inputPrice', 0],
['outputPrice', 0],
])
expect(summary?.secondaryEntries).toEqual([])
})
test('includes a free task tier in its range', () => {
const summary = getDynamicPricingSummary(
pricingModel({
billing_mode: 'tiered_expr',
billing_expr:
'u("mode") == "pro" ? tier("pro", u("seconds") * 0.8) : tier("free", u("seconds") * 0)',
billing_usage_schema: {
seconds: { type: 'number', unit: 'second' },
mode: { enum: ['free', 'pro'] },
},
}),
{ tokenUnit: 'M' }
)
expect(summary?.primaryEntries[0]?.value).toBe(0)
expect(summary?.primaryEntries[0]?.formattedRange).toBe('$0 – $0.8')
})
})
describe('task dynamic pricing', () => { describe('task dynamic pricing', () => {
test('treats task coefficients as dollars per unit without a token divisor', () => { test('treats task coefficients as dollars per unit without a token divisor', () => {
const model = pricingModel({ const model = pricingModel({
......
...@@ -116,7 +116,7 @@ function breakdownPriceFieldLabel( ...@@ -116,7 +116,7 @@ function breakdownPriceFieldLabel(
const VAR_LABELS: Record<string, string> = { const VAR_LABELS: Record<string, string> = {
p: 'Input', p: 'Input',
c: 'Output', c: 'Output',
len: 'Length', len: 'Full input length',
} }
const OP_LABELS: Record<string, string> = { const OP_LABELS: Record<string, string> = {
'<': '<', '<': '<',
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { getCurrencyLabel } from '@/lib/currency'
import { useSystemConfigStore } from '@/stores/system-config-store'
import { DEFAULT_TOKEN_UNIT } from '../constants'
import {
getDynamicDisplayGroupRatio,
getDynamicPriceUnitLabelKey,
getDynamicPricingSummary,
isUnconfiguredTaskUsageModel,
} from '../lib/dynamic-price'
import { isTokenBasedModel } from '../lib/model-helpers'
import { formatPrice, formatRequestPrice } from '../lib/price'
import type { PricingModel, TokenUnit } from '../types'
export type ModelPriceCellOptions = {
tokenUnit?: TokenUnit
priceRate?: number
usdExchangeRate?: number
showRechargePrice?: boolean
selectedGroup?: string
}
export function ModelPriceCell(props: {
model: PricingModel
options?: ModelPriceCellOptions
showExpression?: boolean
}) {
const { t } = useTranslation()
const currency = useSystemConfigStore((state) => state.config.currency)
const currencyLabel =
currency.quotaDisplayType === 'TOKENS' ? 'USD' : getCurrencyLabel()
const options = props.options ?? {}
const tokenUnit = options.tokenUnit ?? DEFAULT_TOKEN_UNIT
const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M'
const dynamic = getDynamicPricingSummary(props.model, {
...options,
tokenUnit,
showCurrencySymbol: false,
groupRatioMultiplier: getDynamicDisplayGroupRatio(
props.model,
options.selectedGroup
),
})
let metrics: Array<{ label: string; value: string }>
let caption = t('{{currency}} / {{unit}} tokens', {
currency: currencyLabel,
unit: tokenUnitLabel,
})
if (dynamic) {
if (dynamic.isSpecialExpression) {
return (
<span className='block max-w-full min-w-0'>
<span className='text-muted-foreground block truncate text-sm'>
{t('Special billing expression')}
</span>
{props.showExpression !== false && (
<code className='text-muted-foreground mt-1 line-clamp-2 block text-xs break-all whitespace-normal'>
{dynamic.rawExpression}
</code>
)}
</span>
)
}
metrics = dynamic.primaryEntries.slice(0, 2).map((entry) => {
const unit = getDynamicPriceUnitLabelKey(entry)
return {
label:
entry.labelKind === 'schema' ? entry.shortLabel : t(entry.shortLabel),
value: `${entry.formattedRange ?? entry.formatted}${unit ? `/${t(unit)}` : ''}`,
}
})
if (metrics.length === 0) {
return (
<span className='text-muted-foreground text-sm'>
{t('Dynamic Pricing')}
</span>
)
}
if (dynamic.isTaskUsage) caption = currencyLabel
if (dynamic.tierCount > 1) {
caption += ` · ${t('{{count}} tiers', { count: dynamic.tierCount })}`
}
} else {
if (isUnconfiguredTaskUsageModel(props.model)) {
return (
<span className='text-muted-foreground text-sm'>
{t('Not configured')}
</span>
)
}
const tokenBased = isTokenBasedModel(props.model)
if (
!Number.isFinite(
tokenBased ? props.model.model_ratio : props.model.model_price
)
) {
return (
<span className='text-muted-foreground text-sm'>
{t('Unset price')}
</span>
)
}
if (tokenBased) {
metrics = [
{
label: t('Input'),
value: formatPrice(
props.model,
'input',
tokenUnit,
options.showRechargePrice,
options.priceRate,
options.usdExchangeRate,
options.selectedGroup,
false
),
},
{
label: t('Output'),
value: formatPrice(
props.model,
'output',
tokenUnit,
options.showRechargePrice,
options.priceRate,
options.usdExchangeRate,
options.selectedGroup,
false
),
},
]
} else {
metrics = [
{
label: t('Per-request'),
value: formatRequestPrice(
props.model,
options.showRechargePrice,
options.priceRate,
options.usdExchangeRate,
options.selectedGroup,
false
),
},
]
caption = `${currencyLabel} / ${t('request')}`
}
}
return (
<span className='block w-full max-w-full min-w-0 space-y-1.5'>
<span
className={metrics.length > 1 ? 'grid grid-cols-2 gap-x-4' : 'grid'}
>
{metrics.map((metric) => (
<span
key={metric.label}
className='flex min-w-0 flex-col items-start gap-y-0.5 sm:flex-row sm:flex-wrap sm:items-baseline sm:gap-x-1.5'
>
<span
className='text-muted-foreground min-w-0 truncate text-xs font-normal'
title={metric.label}
>
{metric.label}
</span>
<span
className='min-w-0 font-mono text-sm break-words whitespace-normal tabular-nums'
title={metric.value}
>
{metric.value}
</span>
</span>
))}
</span>
<span
className='text-muted-foreground block text-xs font-normal break-words whitespace-normal sm:truncate'
title={caption}
>
{caption}
</span>
</span>
)
}
...@@ -31,31 +31,21 @@ import { getLobeIcon } from '@/lib/lobe-icon' ...@@ -31,31 +31,21 @@ import { getLobeIcon } from '@/lib/lobe-icon'
import { DEFAULT_TOKEN_UNIT } from '../constants' import { DEFAULT_TOKEN_UNIT } from '../constants'
import { import {
getDynamicDisplayGroupRatio, getDynamicDisplayGroupRatio,
getDynamicPriceUnitLabelKey,
getDynamicPricingSummary, getDynamicPricingSummary,
isUnconfiguredTaskUsageModel, isUnconfiguredTaskUsageModel,
} from '../lib/dynamic-price' } from '../lib/dynamic-price'
import { parseTags } from '../lib/filters' import { parseTags } from '../lib/filters'
import { isTokenBasedModel } from '../lib/model-helpers' import { isTokenBasedModel } from '../lib/model-helpers'
import { import { formatPrice, stripTrailingZeros } from '../lib/price'
formatPrice, import type { PricingModel } from '../types'
formatRequestPrice,
stripTrailingZeros,
} from '../lib/price'
import type { PricingModel, TokenUnit } from '../types'
import { ModelBillingModeBadge } from './model-billing-mode-badge' import { ModelBillingModeBadge } from './model-billing-mode-badge'
import { ModelPriceCell, type ModelPriceCellOptions } from './model-price-cell'
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Pricing Table Columns // Pricing Table Columns
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
export interface PricingColumnsOptions { export type PricingColumnsOptions = ModelPriceCellOptions
tokenUnit?: TokenUnit
priceRate?: number
usdExchangeRate?: number
showRechargePrice?: boolean
selectedGroup?: string
}
export function usePricingColumns( export function usePricingColumns(
options: PricingColumnsOptions = {} options: PricingColumnsOptions = {}
...@@ -114,145 +104,9 @@ export function usePricingColumns( ...@@ -114,145 +104,9 @@ export function usePricingColumns(
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Price')} /> <DataTableColumnHeader column={column} title={t('Price')} />
), ),
cell: ({ row }) => { cell: ({ row }) => (
const model = row.original <ModelPriceCell model={row.original} options={options} />
const dynamicSummary = getDynamicPricingSummary(model, {
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
groupRatioMultiplier: getDynamicDisplayGroupRatio(
model,
selectedGroup
), ),
})
if (dynamicSummary) {
if (dynamicSummary.isSpecialExpression) {
return (
<div className='max-w-full min-w-0'>
<div className='text-xs font-medium text-amber-700 dark:text-amber-300'>
{t('Special billing expression')}
</div>
<div className='text-muted-foreground text-[11px]'>
{t('Unable to parse structured pricing')}
</div>
<code className='text-muted-foreground/70 mt-1 line-clamp-2 block font-mono text-[10px] leading-relaxed break-all'>
{dynamicSummary.rawExpression}
</code>
</div>
)
}
const primaryEntries = dynamicSummary.primaryEntries.slice(0, 2)
if (primaryEntries.length === 0) {
return (
<span className='text-muted-foreground text-xs'>
{t('Dynamic Pricing')}
</span>
)
}
return (
<div className='max-w-full min-w-0'>
<span className='font-mono text-sm tabular-nums'>
{primaryEntries.map((entry, index) => {
const unitLabelKey = getDynamicPriceUnitLabelKey(entry)
return (
<span key={entry.key}>
{index > 0 && (
<span className='text-muted-foreground/40 mx-1'>/</span>
)}
{stripTrailingZeros(
entry.formattedRange ?? entry.formatted
)}
{unitLabelKey && <>/{t(unitLabelKey)}</>}
</span>
)
})}
</span>
<div className='text-muted-foreground/50 text-[10px]'>
{!dynamicSummary.isTaskUsage && `/ ${tokenUnitLabel} tokens`}
{dynamicSummary.isTaskUsage && dynamicSummary.tier?.label}
{dynamicSummary.tierCount > 1 &&
` · ${t('{{count}} tiers', {
count: dynamicSummary.tierCount,
})}`}
</div>
</div>
)
}
if (isUnconfiguredTaskUsageModel(model)) {
return (
<div className='max-w-full min-w-0'>
<div className='text-sm font-medium'>{t('Not configured')}</div>
<div className='text-muted-foreground/50 text-[10px]'>
{t('Usage-based billing')}
</div>
</div>
)
}
const isTokenBased = isTokenBasedModel(model)
if (isTokenBased) {
const inputPrice = stripTrailingZeros(
formatPrice(
model,
'input',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
selectedGroup
)
)
const outputPrice = stripTrailingZeros(
formatPrice(
model,
'output',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
selectedGroup
)
)
return (
<div className='max-w-full min-w-0'>
<span className='font-mono text-sm tabular-nums'>
{inputPrice}
<span className='text-muted-foreground/40 mx-1'>/</span>
{outputPrice}
</span>
<div className='text-muted-foreground/50 text-[10px]'>
/ {tokenUnitLabel} tokens
</div>
</div>
)
}
const price = stripTrailingZeros(
formatRequestPrice(
model,
showRechargePrice,
priceRate,
usdExchangeRate,
selectedGroup
)
)
return (
<div className='max-w-full min-w-0'>
<span className='font-mono text-sm tabular-nums'>{price}</span>
<div className='text-muted-foreground/50 text-[10px]'>
/ {t('request')}
</div>
</div>
)
},
size: 180, size: 180,
enableSorting: false, enableSorting: false,
}, },
......
...@@ -273,16 +273,30 @@ function stripExprVersion(exprStr: string): { version: number; body: string } { ...@@ -273,16 +273,30 @@ function stripExprVersion(exprStr: string): { version: number; body: string } {
return { version: 1, body: exprStr } return { version: 1, body: exprStr }
} }
function parseTierBody(bodyStr: string): Record<string, number> { function parseTierBody(bodyStr: string): Record<string, number> | null {
const coeffs: Record<string, number> = {} const coeffs: Record<string, number> = {}
const re = new RegExp(BILLING_VAR_REGEX.source, 'g') const re = new RegExp(BILLING_VAR_REGEX.source, 'g')
let end = 0
let m let m
while ((m = re.exec(bodyStr)) !== null) { while ((m = re.exec(bodyStr)) !== null) {
if (!(m[1] in coeffs)) coeffs[m[1]] = Number(m[2]) const separator = bodyStr.slice(end, m.index).trim()
const value = Number(m[2])
if (
separator !== (end === 0 ? '' : '+') ||
!NUMERIC_LITERAL_REGEX.test(m[2]) ||
!Number.isFinite(value) ||
value < 0 ||
Object.hasOwn(coeffs, m[1])
) {
return null
}
coeffs[m[1]] = value
end = re.lastIndex
} }
if (end === 0 || bodyStr.slice(end).trim()) return null
const tier: Record<string, number> = {} const tier: Record<string, number> = {}
for (const [varName, field] of Object.entries(BILLING_VAR_KEY_TO_FIELD)) { for (const [varName, field] of Object.entries(BILLING_VAR_KEY_TO_FIELD)) {
tier[field] = coeffs[varName] || 0 if (Object.hasOwn(coeffs, varName)) tier[field] = coeffs[varName]
} }
return tier return tier
} }
...@@ -290,7 +304,8 @@ function parseTierBody(bodyStr: string): Record<string, number> { ...@@ -290,7 +304,8 @@ function parseTierBody(bodyStr: string): Record<string, number> {
export function parseTiersFromExpr(exprStr: string): ParsedTier[] { export function parseTiersFromExpr(exprStr: string): ParsedTier[] {
if (!exprStr) return [] if (!exprStr) return []
try { try {
const { body } = stripExprVersion(exprStr) const versioned = stripExprVersion(exprStr.trim())
const body = unwrapOuterParens(versioned.body)
const condGroup = const condGroup =
`((?:(?:p|c|len)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)` + `((?:(?:p|c|len)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)` +
`(?:\\s*&&\\s*(?:p|c|len)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)*)` `(?:\\s*&&\\s*(?:p|c|len)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)*)`
...@@ -299,14 +314,21 @@ export function parseTiersFromExpr(exprStr: string): ParsedTier[] { ...@@ -299,14 +314,21 @@ export function parseTiersFromExpr(exprStr: string): ParsedTier[] {
'g' 'g'
) )
const tiers: ParsedTier[] = [] const tiers: ParsedTier[] = []
let end = 0
let m let m
while ((m = tierRe.exec(body)) !== null) { while ((m = tierRe.exec(body)) !== null) {
// Only summarize an entire linear tier chain. Extracting a price from
// inside max(), an unknown condition, or a trailing multiplier lies
// about what the expression actually charges.
if (body.slice(end, m.index).trim() !== (end === 0 ? '' : ':')) return []
if (tiers.length > 0 && tiers.at(-1)?.conditions.length === 0) return []
const condStr = m[1] || '' const condStr = m[1] || ''
const conditions: TierCondition[] = [] const conditions: TierCondition[] = []
if (condStr) { if (condStr) {
for (const cp of condStr.split(/\s*&&\s*/)) { for (const cp of condStr.split(/\s*&&\s*/)) {
const cm = cp.trim().match(/^(p|c|len)\s*(<|<=|>|>=)\s*([\d.eE+]+)$/) const cm = cp.trim().match(/^(p|c|len)\s*(<|<=|>|>=)\s*([\d.eE+]+)$/)
if (cm) { if (cm) {
if (!Number.isFinite(Number(cm[3]))) return []
conditions.push({ conditions.push({
var: cm[1] as TierCondition['var'], var: cm[1] as TierCondition['var'],
op: cm[2] as TierCondition['op'], op: cm[2] as TierCondition['op'],
...@@ -315,11 +337,12 @@ export function parseTiersFromExpr(exprStr: string): ParsedTier[] { ...@@ -315,11 +337,12 @@ export function parseTiersFromExpr(exprStr: string): ParsedTier[] {
} }
} }
} }
const tier = parseTierBody(m[3]) as ParsedTier const prices = parseTierBody(m[3])
tier.label = m[2] if (!prices) return []
tier.conditions = conditions tiers.push({ ...prices, label: m[2], conditions })
tiers.push(tier) end = tierRe.lastIndex
} }
if (body.slice(end).trim() || tiers.at(-1)?.conditions.length) return []
return tiers return tiers
} catch { } catch {
return [] return []
......
...@@ -44,6 +44,7 @@ import { ...@@ -44,6 +44,7 @@ import {
export type DynamicPriceOptions = { export type DynamicPriceOptions = {
tokenUnit: TokenUnit tokenUnit: TokenUnit
showCurrencySymbol?: boolean
showRechargePrice?: boolean showRechargePrice?: boolean
priceRate?: number priceRate?: number
usdExchangeRate?: number usdExchangeRate?: number
...@@ -189,6 +190,7 @@ export function formatDynamicUnitPrice( ...@@ -189,6 +190,7 @@ export function formatDynamicUnitPrice(
) )
return formatBillingCurrencyFromUSD(displayPrice, { return formatBillingCurrencyFromUSD(displayPrice, {
showSymbol: options.showCurrencySymbol ?? true,
digitsLarge: 4, digitsLarge: 4,
digitsSmall: 6, digitsSmall: 6,
abbreviate: false, abbreviate: false,
...@@ -211,6 +213,7 @@ export function formatTaskUsageUnitPrice( ...@@ -211,6 +213,7 @@ export function formatTaskUsageUnitPrice(
) )
return formatBillingCurrencyFromUSD(displayPrice, { return formatBillingCurrencyFromUSD(displayPrice, {
showSymbol: options.showCurrencySymbol ?? true,
digitsLarge: 4, digitsLarge: 4,
digitsSmall: 6, digitsSmall: 6,
abbreviate: false, abbreviate: false,
...@@ -249,7 +252,7 @@ export function getDynamicPriceEntries( ...@@ -249,7 +252,7 @@ export function getDynamicPriceEntries(
options.usageSchema options.usageSchema
).flatMap(([field, definition]) => { ).flatMap(([field, definition]) => {
const value = Number(tier.unitPrices[field]) const value = Number(tier.unitPrices[field])
if (!Number.isFinite(value) || value <= 0 || !definition.unit) return [] if (!Number.isFinite(value) || value < 0 || !definition.unit) return []
return [ return [
{ {
key: field, key: field,
...@@ -282,7 +285,7 @@ export function getDynamicPriceEntries( ...@@ -282,7 +285,7 @@ export function getDynamicPriceEntries(
return BILLING_PRICING_VARS.flatMap((variable) => { return BILLING_PRICING_VARS.flatMap((variable) => {
if (!variable.field) return [] if (!variable.field) return []
const value = Number((tier as ParsedTier)[variable.field]) const value = Number((tier as ParsedTier)[variable.field])
if (!Number.isFinite(value) || value <= 0) return [] if (!Number.isFinite(value) || value < 0) return []
return [ return [
{ {
...@@ -326,7 +329,7 @@ export function getDynamicPricingSummary( ...@@ -326,7 +329,7 @@ export function getDynamicPricingSummary(
for (const taskTier of tiers) { for (const taskTier of tiers) {
if (!isTaskPricingTier(taskTier)) continue if (!isTaskPricingTier(taskTier)) continue
const value = Number(taskTier.unitPrices[field]) const value = Number(taskTier.unitPrices[field])
if (!Number.isFinite(value) || value <= 0) continue if (!Number.isFinite(value) || value < 0) continue
min = Math.min(min, value) min = Math.min(min, value)
max = Math.max(max, value) max = Math.max(max, value)
} }
......
...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,7 +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 { formatCurrencyFromUSD } from '@/lib/currency' import { formatBillingCurrencyFromUSD } from '@/lib/currency'
import { QUOTA_TYPE_VALUES, TOKEN_UNIT_DIVISORS } from '../constants' import { QUOTA_TYPE_VALUES, TOKEN_UNIT_DIVISORS } from '../constants'
import type { PricingModel, TokenUnit, PriceType } from '../types' import type { PricingModel, TokenUnit, PriceType } from '../types'
...@@ -108,7 +108,7 @@ function hasRatio(value: number | null | undefined): boolean { ...@@ -108,7 +108,7 @@ function hasRatio(value: number | null | undefined): boolean {
* priceRate represents how much users need to recharge (in the display currency) * priceRate represents how much users need to recharge (in the display currency)
* to get 1 USD credit. usdExchangeRate is the real exchange rate. * to get 1 USD credit. usdExchangeRate is the real exchange rate.
* *
* The returned value will be formatted by formatCurrencyFromUSD, which will * The returned value will be formatted by formatBillingCurrencyFromUSD, which will
* multiply by the display currency's exchange rate. * multiply by the display currency's exchange rate.
* *
* Examples: * Examples:
...@@ -118,14 +118,14 @@ function hasRatio(value: number | null | undefined): boolean { ...@@ -118,14 +118,14 @@ function hasRatio(value: number | null | undefined): boolean {
* - priceRate = 0.5 (recharge $0.5 to get $1 credit) * - priceRate = 0.5 (recharge $0.5 to get $1 credit)
* - usdExchangeRate = 1 * - usdExchangeRate = 1
* - Return: 1 × 0.5 / 1 = 0.5 * - Return: 1 × 0.5 / 1 = 0.5
* - formatCurrencyFromUSD(0.5) → $0.5 ✓ * - formatBillingCurrencyFromUSD(0.5) → $0.5 ✓
* *
* 2. Display currency = CNY: * 2. Display currency = CNY:
* - Model: 1 USD * - Model: 1 USD
* - priceRate = 4 (recharge ¥4 to get $1 credit) * - priceRate = 4 (recharge ¥4 to get $1 credit)
* - usdExchangeRate = 7 (real rate: 1 USD = ¥7) * - usdExchangeRate = 7 (real rate: 1 USD = ¥7)
* - Return: 1 × 4 / 7 = 0.571 * - Return: 1 × 4 / 7 = 0.571
* - formatCurrencyFromUSD(0.571) → 0.571 × 7 = ¥4 ✓ * - formatBillingCurrencyFromUSD(0.571) → 0.571 × 7 = ¥4 ✓
* - Normal price: ¥7, Recharge price: ¥4 (cheaper!) * - Normal price: ¥7, Recharge price: ¥4 (cheaper!)
*/ */
function applyRechargeRate( function applyRechargeRate(
...@@ -148,7 +148,8 @@ export function formatPrice( ...@@ -148,7 +148,8 @@ export function formatPrice(
showWithRecharge = false, showWithRecharge = false,
priceRate = 1, priceRate = 1,
usdExchangeRate = 1, usdExchangeRate = 1,
selectedGroup?: string selectedGroup?: string,
showCurrencySymbol = true
): string { ): string {
if (model.quota_type === QUOTA_TYPE_VALUES.REQUEST) { if (model.quota_type === QUOTA_TYPE_VALUES.REQUEST) {
return '-' return '-'
...@@ -165,7 +166,8 @@ export function formatPrice( ...@@ -165,7 +166,8 @@ export function formatPrice(
) )
const price = priceInUSD / TOKEN_UNIT_DIVISORS[tokenUnit] const price = priceInUSD / TOKEN_UNIT_DIVISORS[tokenUnit]
return formatCurrencyFromUSD(price, { return formatBillingCurrencyFromUSD(price, {
showSymbol: showCurrencySymbol,
digitsLarge: 4, digitsLarge: 4,
digitsSmall: 6, digitsSmall: 6,
abbreviate: false, abbreviate: false,
...@@ -200,7 +202,7 @@ export function formatGroupPrice( ...@@ -200,7 +202,7 @@ export function formatGroupPrice(
) )
const price = priceInUSD / TOKEN_UNIT_DIVISORS[tokenUnit] const price = priceInUSD / TOKEN_UNIT_DIVISORS[tokenUnit]
return formatCurrencyFromUSD(price, { return formatBillingCurrencyFromUSD(price, {
digitsLarge: 4, digitsLarge: 4,
digitsSmall: 6, digitsSmall: 6,
abbreviate: false, abbreviate: false,
...@@ -232,7 +234,7 @@ export function formatFixedPrice( ...@@ -232,7 +234,7 @@ export function formatFixedPrice(
usdExchangeRate usdExchangeRate
) )
return formatCurrencyFromUSD(priceInUSD, { return formatBillingCurrencyFromUSD(priceInUSD, {
digitsLarge: 4, digitsLarge: 4,
digitsSmall: 4, digitsSmall: 4,
abbreviate: false, abbreviate: false,
...@@ -247,7 +249,8 @@ export function formatRequestPrice( ...@@ -247,7 +249,8 @@ export function formatRequestPrice(
showWithRecharge = false, showWithRecharge = false,
priceRate = 1, priceRate = 1,
usdExchangeRate = 1, usdExchangeRate = 1,
selectedGroup?: string selectedGroup?: string,
showCurrencySymbol = true
): string { ): string {
if (model.quota_type !== QUOTA_TYPE_VALUES.REQUEST) { if (model.quota_type !== QUOTA_TYPE_VALUES.REQUEST) {
return '-' return '-'
...@@ -264,7 +267,8 @@ export function formatRequestPrice( ...@@ -264,7 +267,8 @@ export function formatRequestPrice(
usdExchangeRate usdExchangeRate
) )
return formatCurrencyFromUSD(priceInUSD, { return formatBillingCurrencyFromUSD(priceInUSD, {
showSymbol: showCurrencySymbol,
digitsLarge: 4, digitsLarge: 4,
digitsSmall: 4, digitsSmall: 4,
abbreviate: false, abbreviate: false,
......
...@@ -196,7 +196,7 @@ function BillingBreakdown(props: { ...@@ -196,7 +196,7 @@ function BillingBreakdown(props: {
} else { } else {
rows.push({ rows.push({
label: t('Matched Tier'), label: t('Matched Tier'),
value: t('No matching results'), value: other.matched_tier || t('No matching results'),
}) })
} }
} else if (isPerCall) { } else if (isPerCall) {
......
...@@ -21,6 +21,7 @@ import { ...@@ -21,6 +21,7 @@ import {
BILLING_PRICING_VARS, BILLING_PRICING_VARS,
normalizeTierLabel, normalizeTierLabel,
parseTiersFromExpr, parseTiersFromExpr,
splitBillingExprAndRequestRules,
type ParsedTier, type ParsedTier,
} from '@/features/pricing/lib/billing-expr' } from '@/features/pricing/lib/billing-expr'
...@@ -337,7 +338,9 @@ export function getTieredBillingSummary( ...@@ -337,7 +338,9 @@ export function getTieredBillingSummary(
if (!other || other.billing_mode !== 'tiered_expr') return null if (!other || other.billing_mode !== 'tiered_expr') return null
const exprStr = decodeBillingExprB64(other.expr_b64) const exprStr = decodeBillingExprB64(other.expr_b64)
if (!exprStr) return null if (!exprStr) return null
const tiers = parseTiersFromExpr(exprStr) const tiers = parseTiersFromExpr(
splitBillingExprAndRequestRules(exprStr).billingExpr
)
const tier = resolveMatchedTier(tiers, other.matched_tier) const tier = resolveMatchedTier(tiers, other.matched_tier)
if (!tier) return null if (!tier) return null
......
...@@ -21,6 +21,15 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -21,6 +21,15 @@ For commercial licensing, please contact support@quantumnous.com
export const STATIC_I18N_KEYS = [ export const STATIC_I18N_KEYS = [
'Account deletion', 'Account deletion',
// Model management and metadata synchronization // Model management and metadata synchronization
'No matching channels',
'Metadata only',
'Available channels: {{count}}',
'No configured channel models match this metadata rule.',
'No channel is configured. This model will not appear in the model square.',
'No channel is currently available. This model will not appear in the model square.',
'Listing also depends on metadata visibility and the user’s group access.',
'Add metadata to all selected models first.',
'Model management', 'Model management',
'Select models', 'Select models',
'Preview fields', 'Preview fields',
......
...@@ -37,6 +37,10 @@ const modelsSearchSchema = z.object({ ...@@ -37,6 +37,10 @@ const modelsSearchSchema = z.object({
filter: z.string().optional().catch(''), filter: z.string().optional().catch(''),
vendor: z.array(z.string()).optional().catch([]), vendor: z.array(z.string()).optional().catch([]),
status: z.array(z.string()).optional().catch([]), status: z.array(z.string()).optional().catch([]),
square_state: z
.array(z.enum(['visible', 'unavailable', 'hidden', 'partial']))
.optional()
.catch([]),
sync: z.array(z.string()).optional().catch([]), sync: z.array(z.string()).optional().catch([]),
dPage: z.number().optional().catch(1), dPage: z.number().optional().catch(1),
dPageSize: z.number().optional().catch(10), dPageSize: z.number().optional().catch(10),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment