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 (
// GetAllModelsMeta 获取模型列表(分页)
func GetAllModelsMeta(c *gin.Context) {
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,
})
listModelsMeta(c, "", "")
}
// SearchModelsMeta 搜索模型列表
func SearchModelsMeta(c *gin.Context) {
listModelsMeta(c, c.Query("keyword"), c.Query("vendor"))
}
keyword := c.Query("keyword")
vendor := c.Query("vendor")
status := c.Query("status")
syncOfficial := c.Query("sync_official")
pageInfo := common.GetPageQuery(c)
func listModelsMeta(c *gin.Context, keyword, vendor string) {
squareState := model.ModelSquareState(c.Query("square_state"))
switch squareState {
case "", model.ModelSquareVisible, model.ModelSquareUnavailable, model.ModelSquareHidden, model.ModelSquarePartial:
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 {
common.ApiError(c, err)
return
}
// 批量填充附加字段,提升列表接口性能
enrichModels(modelsMeta)
if err := enrichModels(modelsMeta); err != nil {
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()
pageInfo.SetTotal(int(total))
pageInfo.SetItems(modelsMeta)
......@@ -81,7 +96,10 @@ func GetModelMeta(c *gin.Context) {
common.ApiError(c, err)
return
}
enrichModels([]*model.Model{&m})
if err := enrichModels([]*model.Model{&m}); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &m)
}
......@@ -114,6 +132,7 @@ func CreateModelMeta(c *gin.Context) {
return
}
model.RefreshPricing()
m.HasMetadata = m.Id > 0
common.ApiSuccess(c, &m)
}
......@@ -165,6 +184,7 @@ func UpdateModelMeta(c *gin.Context) {
}
}
model.RefreshPricing()
m.HasMetadata = m.Id > 0
common.ApiSuccess(c, &m)
}
......@@ -224,14 +244,35 @@ func BatchDeleteModelMeta(c *gin.Context) {
// enrichModels keeps configured endpoints intact and derives connections from
// 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 {
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()
if err != nil {
common.SysError("load model connections: " + err.Error())
return
return err
}
if err := model.FillModelSquareStates(models, configured, connections); err != nil {
return err
}
for _, metadata := range models {
if metadata == nil {
......@@ -244,16 +285,7 @@ func enrichModels(models []*model.Model) {
quotas := make(map[int]bool)
for _, connection := range connections {
name := connection.Model
matched := name == metadata.ModelName
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 {
if !metadata.MatchesName(name) {
continue
}
names[name] = true
......@@ -301,4 +333,5 @@ func enrichModels(models []*model.Model) {
metadata.MatchedCount = len(names)
}
}
return nil
}
......@@ -18,6 +18,15 @@ const (
NameRuleSuffix
)
type ModelSquareState string
const (
ModelSquareVisible ModelSquareState = "visible"
ModelSquareUnavailable ModelSquareState = "unavailable"
ModelSquareHidden ModelSquareState = "hidden"
ModelSquarePartial ModelSquareState = "partial"
)
type BoundChannel struct {
Name string `json:"name"`
Type int `json:"type"`
......@@ -45,6 +54,183 @@ type Model struct {
MatchedModels []string `json:"matched_models,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 {
......
......@@ -191,54 +191,11 @@ func updatePricing() {
// 预加载模型元数据与供应商一次,避免循环查询
var allMeta []Model
_ = DB.Find(&allMeta).Error
metaMap := make(map[string]*Model)
prefixList := make([]*Model, 0)
suffixList := make([]*Model, 0)
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
}
}
}
names := make([]string, 0, len(enableAbilities))
for _, ability := range enableAbilities {
names = append(names, ability.Model)
}
metaMap := resolveModelMetadata(allMeta, names)
// 预加载供应商
var vendors []Vendor
......
......@@ -361,15 +361,24 @@ it('converts task base charges and second, token and credit prices, including wh
schema
)
await selectCurrency('Site currency (CNY)')
fireEvent.change(screen.getByRole('textbox', { name: 'Base charge: std' }), {
target: { value: '7' },
})
fireEvent.change(screen.getByRole('textbox', { name: 'tokens: std' }), {
target: { value: '70' },
})
fireEvent.change(screen.getByRole('textbox', { name: 'credits: std' }), {
target: { value: '0.7' },
})
fireEvent.change(
screen.getByRole('textbox', { name: 'Additional charge: mode: std' }),
{
target: { value: '7' },
}
)
fireEvent.change(
screen.getByRole('textbox', { name: 'Unit price: tokens: mode: std' }),
{
target: { value: '70' },
}
)
fireEvent.change(
screen.getByRole('textbox', { name: 'Unit price: credits: mode: std' }),
{
target: { value: '0.7' },
}
)
const secondsHeader = screen.getByRole('columnheader', { name: /seconds/ })
await userEvent.click(
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
await userEvent.click(
screen.getByRole('button', { name: 'Apply to all rows' })
)
expect(screen.getByRole('textbox', { name: 'seconds: std' })).toHaveValue(
'14'
)
expect(screen.getByRole('textbox', { name: 'seconds: pro' })).toHaveValue(
'14'
)
expect(
screen.getByRole('textbox', { name: 'Unit price: seconds: mode: std' })
).toHaveValue('14')
expect(
screen.getByRole('textbox', { name: 'Unit price: seconds: mode: pro' })
).toHaveValue('14')
const saved = await commit(editor.ref)
const config = tryParseTaskVisualConfig(saved?.billingExpr ?? '', schema)
expect(config?.tiers[0]).toMatchObject({
......@@ -414,7 +423,7 @@ it('converts task unit prices without enum tiers and updates the monetary previe
fireEvent.change(screen.getByRole('textbox', { name: 'seconds' }), {
target: { value: '14' },
})
fireEvent.change(screen.getByRole('textbox', { name: 'Base charge' }), {
fireEvent.change(screen.getByRole('textbox', { name: 'Additional charge' }), {
target: { value: '7' },
})
expect(await commit(editor.ref)).toMatchObject({
......
......@@ -24,15 +24,16 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
import { ErrorState } from '@/components/error-state'
import { LoadingState } from '@/components/loading-state'
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 {
ModelPricingEditorPanel,
type ModelPricingEditorPanelHandle,
} 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 { useSystemConfigStore } from '@/stores/system-config-store'
import {
useCanEditModelPricing,
......@@ -40,13 +41,14 @@ import {
useSaveModelPricing,
type ModelPricingEntry,
} from './api'
import { pricingFromDraft, pricingRow } from './pricing'
import { modelPricingDisplay, pricingFromDraft, pricingRow } from './pricing'
export function ModelPricingPanel(props: {
modelName: string
onDirtyChange?: (dirty: boolean) => void
}) {
const { t } = useTranslation()
useSystemConfigStore((state) => state.config.currency)
const canEdit = useCanEditModelPricing()
const query = useModelPricing([props.modelName], Boolean(props.modelName))
const save = useSaveModelPricing()
......@@ -115,10 +117,7 @@ export function ModelPricingPanel(props: {
)
}
if (!editData || !entry) return <LoadingState />
const effectivePricing = {
...pricingRow(entry.model_name, entry.effective),
hasConflict: false,
}
const effectivePricing = modelPricingDisplay(entry)
return (
<div className='flex min-h-0 min-w-0 flex-1 flex-col gap-3'>
......@@ -129,10 +128,6 @@ export function ModelPricingPanel(props: {
? t('Stored configuration with effective defaults')
: t('Using built-in or default pricing')}
</p>
<p className='mt-1 text-xs'>
{t('Current Billing')}: {getPriceSummary(effectivePricing, t)} ·{' '}
{getPriceDetail(effectivePricing, t)}
</p>
</div>
<Button
variant='outline'
......@@ -143,6 +138,73 @@ export function ModelPricingPanel(props: {
{t('Restore default pricing')}
</Button>
</div>
<section
aria-label={t('Current Billing')}
className='max-h-[40vh] shrink-0 space-y-3 overflow-auto border-b px-4 pb-3'
>
<h3 className='text-muted-foreground text-xs'>
{t('Current Billing')}
</h3>
<div className='max-w-xs'>
<ModelPriceCell
model={effectivePricing}
options={{ tokenUnit: 'M' }}
showExpression={false}
/>
</div>
{isDynamicPricingModel(effectivePricing) ? (
<DynamicPricingBreakdown
compact
billingExpr={effectivePricing.billing_expr}
usageSchema={entry.usage_schema}
/>
) : (
effectivePricing.quota_type === 0 &&
Number.isFinite(effectivePricing.model_ratio) && (
<dl className='grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3'>
{(
[
{
field: 'cache_ratio',
type: 'cache',
label: t('Cache Read'),
},
{
field: 'create_cache_ratio',
type: 'create_cache',
label: t('Cache write'),
},
{
field: 'image_ratio',
type: 'image',
label: t('Image input'),
},
{
field: 'audio_ratio',
type: 'audio_input',
label: t('Audio input'),
},
{
field: 'audio_completion_ratio',
type: 'audio_output',
label: t('Audio output'),
},
] as const
).map((field) => {
if (effectivePricing[field.field] == null) return null
return (
<div key={field.field}>
<dt className='text-muted-foreground'>{field.label}</dt>
<dd className='mt-1 font-mono tabular-nums'>
{formatPrice(effectivePricing, field.type, 'M')} / 1M
</dd>
</div>
)
})}
</dl>
)
)}
</section>
{save.isError && (
<div className='px-4'>
<p role='alert' className='text-destructive mb-2 text-sm'>
......
......@@ -19,12 +19,15 @@ For commercial licensing, please contact support@quantumnous.com
import { t } from 'i18next'
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 {
buildModelSnapshots,
type ModelPricingSnapshot,
} from '@/features/system-settings/models/model-pricing-snapshots'
import type { ModelPricingEntry } from './api'
export const PRICING_KEYS = [
'ModelPrice',
'ModelRatio',
......@@ -41,6 +44,49 @@ export type PricingKey = (typeof PRICING_KEYS)[number]
export type PricingValues = Partial<Record<PricingKey, number | 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 = {
price: 'ModelPrice',
ratio: 'ModelRatio',
......
......@@ -69,6 +69,79 @@ function renderModelActions(currentModel: Model = model, role = 100) {
}
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 () => {
let resolveDetail!: (value: Awaited<ReturnType<typeof api.get>>) => void
const detail = new Promise<Awaited<ReturnType<typeof api.get>>>(
......
......@@ -55,13 +55,17 @@ export function DataTableBulkActions<TData>({
const selectedIds = selectedRows.reduce<number[]>((ids, row) => {
const id = (row.original as Model).id
if (typeof id === 'number') {
if (typeof id === 'number' && id > 0) {
ids.push(id)
}
return ids
}, [])
const hasMissingMetadata = selectedRows.some(
(row) => !(row.original as Model).id
)
const selectedModels = selectedRows.map((row) => row.original as Model)
const handleClearSelection = () => {
......@@ -96,11 +100,36 @@ export function DataTableBulkActions<TData>({
/>
)}
<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
variant='outline'
size='icon'
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')}
onClick={() =>
setVendorOperation({ action: 'assign', model_ids: selectedIds })
......@@ -112,7 +141,12 @@ export function DataTableBulkActions<TData>({
variant='outline'
size='icon'
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')}
onClick={() =>
setVendorOperation({
......@@ -130,6 +164,7 @@ export function DataTableBulkActions<TData>({
<Button
variant='outline'
size='icon'
disabled={hasMissingMetadata}
onClick={handleEnableAll}
className='size-8'
aria-label={t('Show selected models in model square')}
......@@ -143,7 +178,13 @@ export function DataTableBulkActions<TData>({
</span>
</TooltipTrigger>
<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>
</Tooltip>
......@@ -153,6 +194,7 @@ export function DataTableBulkActions<TData>({
<Button
variant='outline'
size='icon'
disabled={hasMissingMetadata}
onClick={handleDisableAll}
className='size-8'
aria-label={t('Hide selected models from model square')}
......@@ -166,7 +208,13 @@ export function DataTableBulkActions<TData>({
</span>
</TooltipTrigger>
<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>
</Tooltip>
......@@ -197,6 +245,7 @@ export function DataTableBulkActions<TData>({
<Button
variant='destructive'
size='icon'
disabled={hasMissingMetadata}
onClick={() => setShowDeleteConfirm(true)}
className='size-8'
aria-label={t('Delete selected models')}
......@@ -208,7 +257,13 @@ export function DataTableBulkActions<TData>({
<span className='sr-only'>{t('Delete selected models')}</span>
</TooltipTrigger>
<TooltipContent>
<p>{t('Delete selected models')}</p>
<p>
{t(
hasMissingMetadata
? 'Add metadata to all selected models first.'
: 'Delete selected models'
)}
</p>
</TooltipContent>
</Tooltip>
</BulkActionsToolbar>
......
......@@ -63,9 +63,16 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
: t('Show in model square')
return (
<div className='-ml-1.5 flex items-center gap-1'>
<Button variant='ghost' size='sm' onClick={handleEdit}>
{t('Edit')}
<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}
title={model.id > 0 ? t('Edit') : t('Add metadata')}
>
<span className='truncate'>
{model.id > 0 ? t('Edit') : t('Add metadata')}
</span>
</Button>
{canPrice && (
......@@ -77,30 +84,32 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
setOpen('price-model')
}}
>
{t('Pricing')}
<span className='truncate'>{t('Pricing')}</span>
</Button>
)}
<DataTableRowActionMenu ariaLabel={t('Open menu')}>
<DropdownMenuItem onClick={handleToggleStatus}>
{toggleLabel}
<DropdownMenuShortcut>
{isEnabled ? <EyeOff size={16} /> : <Eye size={16} />}
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setDeleteConfirmOpen(true)
}}
className='text-destructive focus:text-destructive'
>
{t('Delete')}
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DataTableRowActionMenu>
{model.id > 0 && (
<DataTableRowActionMenu ariaLabel={t('Open menu')}>
<DropdownMenuItem onClick={handleToggleStatus}>
{toggleLabel}
<DropdownMenuShortcut>
{isEnabled ? <EyeOff size={16} /> : <Eye size={16} />}
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setDeleteConfirmOpen(true)
}}
className='text-destructive focus:text-destructive'
>
{t('Delete')}
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DataTableRowActionMenu>
)}
{deleteConfirmOpen && (
<ModelDeleteDialog
......
......@@ -86,8 +86,16 @@ export function ModelMutateDrawer(props: {
}) {
const { t } = useTranslation()
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 hasModelName = Boolean(currentRow?.model_name)
const [section, setSection] = useState<string>(
props.initialSection ?? 'metadata'
)
......@@ -141,14 +149,22 @@ export function ModelMutateDrawer(props: {
setPricingDirty(false)
setPendingPricingName(null)
setCloseConfirm(false)
}, [props.open, props.initialSection, currentRow?.id])
}, [
props.open,
props.initialSection,
props.currentRow?.id,
props.currentRow?.model_name,
])
useEffect(() => {
if (!props.open) {
setCreatedModel(null)
loadedKey.current = ''
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
form.reset(
transformModelToFormDefaults(
......@@ -185,6 +201,9 @@ export function ModelMutateDrawer(props: {
onSuccess: async (response) => {
form.reset(form.getValues())
if (response.data?.id) {
if (!currentRow?.id) {
setCreatedModel({ source: props.currentRow, model: response.data })
}
queryClient.setQueryData(
modelsQueryKeys.detail(response.data.id),
response.data
......@@ -235,7 +254,7 @@ export function ModelMutateDrawer(props: {
>
<SheetHeader className={sideDrawerHeaderClassName()}>
<SheetTitle className='pr-6 break-all'>
{isEditing ? currentRow?.model_name : t('Create Model')}
{hasModelName ? currentRow?.model_name : t('Create Model')}
</SheetTitle>
<SheetDescription>
{t(
......@@ -260,14 +279,14 @@ export function ModelMutateDrawer(props: {
</TabsTrigger>
<TabsTrigger
value='pricing'
disabled={!isEditing}
disabled={!hasModelName}
className='h-auto min-w-0 whitespace-normal'
>
{t('Pricing')}
</TabsTrigger>
<TabsTrigger
value='connections'
disabled={!isEditing}
disabled={!hasModelName}
className='h-auto min-w-0 whitespace-normal'
>
{t('Channels and groups')}
......@@ -532,7 +551,7 @@ export function ModelMutateDrawer(props: {
</FormLabel>
<FormDescription>
{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>
</div>
......
......@@ -24,6 +24,7 @@ import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge'
import type { Model } from '../types'
import { ModelSquareStatus } from './model-square-status'
export function ModelConnections(props: { model: Model }) {
const { t } = useTranslation()
......@@ -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.'
)}
</p>
<ModelSquareStatus model={props.model} detail />
<section className='space-y-3'>
<h3 className='font-medium'>{t('Bound Channels')}</h3>
{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'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from '@/components/ui/tooltip'
import {
useCanEditModelPricing,
type ModelPricingConfig,
} from '@/features/model-pricing/api'
import { pricingRow } from '@/features/model-pricing/pricing'
import {
getPriceDetail,
getPriceSummary,
isBasePricingUnset,
} from '@/features/system-settings/models/model-pricing-snapshots'
import { modelPricingDisplay } from '@/features/model-pricing/pricing'
import { ModelPriceCell } from '@/features/pricing/components/model-price-cell'
import { formatTimestampToDate } from '@/lib/format'
import { getLobeIcon } from '@/lib/lobe-icon'
import { getNameRuleConfig } from '../constants'
import { parseModelTags, formatEndpointsDisplay } from '../lib'
import { getModelChannelState } from '../lib/model-utils'
import type { Model, Vendor } from '../types'
import { DataTableRowActions } from './data-table-row-actions'
import { DescriptionCell } from './description-cell'
import { ModelSquareStatus } from './model-square-status'
import { useModels } from './models-provider'
export function useModelsColumns(
......@@ -99,7 +102,7 @@ export function useModelsColumns(
const vendor = vendorMap.get(model.vendor_id ?? 0)
const iconKey = model.icon || vendor?.icon || model.model_name[0]
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'>
{getLobeIcon(iconKey, 24)}
</span>
......@@ -123,7 +126,9 @@ export function useModelsColumns(
</div>
<div className='text-muted-foreground mt-1 flex min-w-0 items-center gap-2 text-xs'>
<span className='truncate' title={vendor?.name}>
{vendor?.name ?? t('No vendor')}
{model.id > 0
? (vendor?.name ?? t('No vendor'))
: t('Missing metadata')}
</span>
{model.name_rule !== 0 && (
<span className='shrink-0'>
......@@ -140,6 +145,7 @@ export function useModelsColumns(
{
id: 'pricing',
header: t('Pricing'),
meta: { label: t('Pricing') },
size: 225,
enableSorting: false,
cell: ({ row }) => {
......@@ -167,71 +173,69 @@ export function useModelsColumns(
)
}
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 (
<div className='space-y-1'>
<div className='text-sm tabular-nums'>
{getPriceSummary(price, t)}
</div>
{!isBasePricingUnset(price) && (
<div className='text-muted-foreground text-xs'>
{getPriceDetail(price, t)}
{price.billingMode === 'per-token' && ' · USD/1M'}
</div>
)}
</div>
<Button
variant='ghost'
className='h-auto w-full max-w-full min-w-0 justify-start px-0 py-1 text-left font-normal hover:bg-transparent'
aria-label={t('View pricing for {{model}}', {
model: row.original.model_name,
})}
onClick={() => {
setCurrentRow(row.original)
setOpen('price-model')
}}
>
<ModelPriceCell
model={modelPricingDisplay(
entry ?? { model_name: row.original.model_name, effective: {} }
)}
options={{ tokenUnit: 'M' }}
showExpression={false}
/>
</Button>
)
},
},
{
accessorKey: 'status',
accessorKey: 'square_state',
header: t('Model square visibility'),
size: 115,
enableSorting: false,
meta: { mobileBadge: true },
cell: ({ row }) => (
<StatusBadge
variant={row.original.status === 1 ? 'success' : 'neutral'}
label={row.original.status === 1 ? t('Shown') : t('Not shown')}
copyable={false}
/>
),
cell: ({ row }) => <ModelSquareStatus model={row.original} />,
},
{
id: 'connections',
header: t('Channels and groups'),
size: 180,
enableSorting: false,
cell: ({ row }) => (
<div className='space-y-1 text-sm'>
<span
className={
row.original.bound_channels?.length ? '' : 'text-muted-foreground'
}
>
{row.original.bound_channels?.length
? t('{{count}} available channels', {
count: row.original.bound_channels.length,
})
: t('No available channels')}
</span>
<div className='text-muted-foreground text-xs'>
{t('{{count}} enabled groups', {
count: row.original.enable_groups?.length ?? 0,
})}
cell: ({ row }) => {
const state = getModelChannelState(row.original)
return (
<div className='min-w-0 text-sm'>
<Tooltip>
<TooltipTrigger
render={
<span
tabIndex={0}
title={t(state.description)}
aria-description={t(state.description)}
className='block whitespace-normal sm:truncate'
/>
}
>
{t('Channels {{channels}} · Groups {{groups}}', {
channels: row.original.bound_channels?.length ?? 0,
groups: row.original.enable_groups?.length ?? 0,
})}
</TooltipTrigger>
<TooltipContent role='tooltip'>
{t(state.description)}
</TooltipContent>
</Tooltip>
</div>
</div>
),
)
},
},
{
accessorKey: 'tags',
......@@ -242,6 +246,7 @@ export function useModelsColumns(
cell: ({ row }) => (
<BadgeListCell
expandable
max={1}
items={parseModelTags(row.original.tags ?? '').map((tag) => (
<StatusBadge key={tag} label={tag} variant='neutral' size='sm' />
))}
......@@ -250,14 +255,20 @@ export function useModelsColumns(
},
{
accessorKey: 'sync_official',
header: t('Sync policy'),
header: () => (
<TruncatedCell className='max-w-[120px]'>
{t('Sync policy')}
</TruncatedCell>
),
size: 145,
enableSorting: false,
meta: { mobileHidden: true },
meta: { mobileHidden: true, label: t('Sync policy') },
cell: ({ row }) => (
<span className='text-muted-foreground text-sm'>
{row.original.sync_official ? t('Allow updates') : t('Keep local')}
</span>
<TruncatedCell className='text-muted-foreground max-w-[120px] text-sm'>
{row.original.id > 0 &&
(row.original.sync_official ? t('Allow updates') : t('Keep local'))}
{!row.original.id && '—'}
</TruncatedCell>
),
},
{
......@@ -269,8 +280,17 @@ export function useModelsColumns(
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',
header: t('ID'),
cell: ({ row }) => row.original.id || '—',
size: 65,
meta: { mobileHidden: true },
},
......@@ -329,14 +349,20 @@ export function useModelsColumns(
accessorKey: 'created_time',
header: t('Created'),
size: 160,
cell: ({ row }) => formatTimestampToDate(row.original.created_time),
cell: ({ row }) =>
row.original.id
? formatTimestampToDate(row.original.created_time)
: '—',
meta: { mobileHidden: true },
},
{
accessorKey: 'updated_time',
header: t('Updated'),
size: 160,
cell: ({ row }) => formatTimestampToDate(row.original.updated_time),
cell: ({ row }) =>
row.original.id
? formatTimestampToDate(row.original.updated_time)
: '—',
meta: { mobileHidden: true },
},
]
......
......@@ -30,6 +30,7 @@ import { useTableUrlState } from '@/hooks/use-table-url-state'
import { getModels, searchModels, getVendors } from '../api'
import { DEFAULT_PAGE_SIZE } from '../constants'
import { modelsQueryKeys, vendorsQueryKeys } from '../lib'
import type { ModelSquareState } from '../types'
import { DataTableBulkActions } from './data-table-bulk-actions'
import { useModelsColumns } from './models-columns'
import { useModels } from './models-provider'
......@@ -60,6 +61,7 @@ export function ModelsTable() {
globalFilter: { enabled: true, key: 'filter' },
columnFilters: [
{ columnId: 'status', searchKey: 'status', type: 'array' },
{ columnId: 'square_state', searchKey: 'square_state', type: 'array' },
{ columnId: 'vendor_id', searchKey: 'vendor', type: 'array' },
{ columnId: 'sync_official', searchKey: 'sync', type: 'array' },
],
......@@ -68,6 +70,11 @@ export function ModelsTable() {
// Extract filters from column filters
const statusFilter =
(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 =
(columnFilters.find((f) => f.id === 'vendor_id')?.value as string[]) || []
const syncFilter =
......@@ -113,6 +120,7 @@ export function ModelsTable() {
globalFilter?.trim() ||
activeVendorFilter ||
statusFilterValue ||
squareState ||
syncFilterValue
)
......@@ -120,9 +128,11 @@ export function ModelsTable() {
// eslint-disable-next-line @tanstack/query/exhaustive-deps
const { data, isLoading, isFetching, isError, error, refetch } = useQuery({
queryKey: modelsQueryKeys.list({
include_channel_models: true,
keyword: globalFilter,
vendor: activeVendorFilter,
status: statusFilterValue,
square_state: squareState,
sync_official: syncFilterValue,
p: pagination.pageIndex + 1,
page_size: pagination.pageSize,
......@@ -130,15 +140,18 @@ export function ModelsTable() {
queryFn: async () => {
if (shouldSearch) {
return searchModels({
include_channel_models: true,
keyword: globalFilter,
vendor: activeVendorFilter,
status: statusFilterValue,
square_state: squareState,
sync_official: syncFilterValue,
p: pagination.pageIndex + 1,
page_size: pagination.pageSize,
})
}
return getModels({
include_channel_models: true,
p: pagination.pageIndex + 1,
page_size: pagination.pageSize,
})
......@@ -164,7 +177,8 @@ export function ModelsTable() {
// React Table instance
const { table } = useDataTable({
data: models,
getRowId: (model) => String(model.id),
getRowId: (model) =>
model.id > 0 ? `metadata:${model.id}` : `channel:${model.model_name}`,
columns,
totalCount,
initialColumnVisibility: {
......@@ -175,6 +189,7 @@ export function ModelsTable() {
endpoints: false,
created_time: false,
updated_time: false,
status: false,
},
columnFilters,
pagination,
......@@ -235,10 +250,21 @@ export function ModelsTable() {
filters: [
{
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'),
options: [
{ label: t('Shown'), value: 'enabled' },
{ label: t('Not shown'), value: 'disabled' },
{ label: t('Displayed'), value: 'visible' },
{ label: t('Unavailable'), value: 'unavailable' },
{ label: t('Listing hidden'), value: 'hidden' },
{ label: t('Partly shown'), value: 'partial' },
],
singleSelect: true,
},
......
......@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type TFunction } from 'i18next'
import type { TFunction } from 'i18next'
import { formatTimestampToDate } from '@/lib/format'
......@@ -195,3 +195,34 @@ export function isModelEnabled(model: Model): boolean {
export function isModelSyncOfficial(model: Model): boolean {
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 {
/**
* Model entity from API
*/
export type ModelSquareState = 'visible' | 'unavailable' | 'hidden' | 'partial'
export interface Model {
square_state?: ModelSquareState
has_metadata?: boolean
configured_channel_count?: number
id: number
model_name: string
description?: string
......@@ -89,6 +94,8 @@ export interface PrefillGroup {
* Get models list parameters
*/
export interface GetModelsParams {
square_state?: ModelSquareState
include_channel_models?: boolean
p?: number
page_size?: number
vendor?: string // vendor ID to filter by
......@@ -100,6 +107,8 @@ export interface GetModelsParams {
* Search models parameters
*/
export interface SearchModelsParams {
square_state?: ModelSquareState
include_channel_models?: boolean
keyword?: string
vendor?: string // vendor ID to filter by
status?: string // filter by status
......
......@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { describe, expect, test } from 'vitest'
import { getBillingModeLabelKey } from '../lib/billing-mode'
import {
......@@ -52,6 +52,69 @@ const summaryOptions = {
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', () => {
test('treats task coefficients as dollars per unit without a token divisor', () => {
const model = pricingModel({
......
......@@ -116,7 +116,7 @@ function breakdownPriceFieldLabel(
const VAR_LABELS: Record<string, string> = {
p: 'Input',
c: 'Output',
len: 'Length',
len: 'Full input length',
}
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'
import { DEFAULT_TOKEN_UNIT } from '../constants'
import {
getDynamicDisplayGroupRatio,
getDynamicPriceUnitLabelKey,
getDynamicPricingSummary,
isUnconfiguredTaskUsageModel,
} from '../lib/dynamic-price'
import { parseTags } from '../lib/filters'
import { isTokenBasedModel } from '../lib/model-helpers'
import {
formatPrice,
formatRequestPrice,
stripTrailingZeros,
} from '../lib/price'
import type { PricingModel, TokenUnit } from '../types'
import { formatPrice, stripTrailingZeros } from '../lib/price'
import type { PricingModel } from '../types'
import { ModelBillingModeBadge } from './model-billing-mode-badge'
import { ModelPriceCell, type ModelPriceCellOptions } from './model-price-cell'
// ----------------------------------------------------------------------------
// Pricing Table Columns
// ----------------------------------------------------------------------------
export interface PricingColumnsOptions {
tokenUnit?: TokenUnit
priceRate?: number
usdExchangeRate?: number
showRechargePrice?: boolean
selectedGroup?: string
}
export type PricingColumnsOptions = ModelPriceCellOptions
export function usePricingColumns(
options: PricingColumnsOptions = {}
......@@ -114,145 +104,9 @@ export function usePricingColumns(
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Price')} />
),
cell: ({ row }) => {
const model = row.original
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>
)
},
cell: ({ row }) => (
<ModelPriceCell model={row.original} options={options} />
),
size: 180,
enableSorting: false,
},
......
......@@ -273,16 +273,30 @@ function stripExprVersion(exprStr: string): { version: number; body: string } {
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 re = new RegExp(BILLING_VAR_REGEX.source, 'g')
let end = 0
let m
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> = {}
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
}
......@@ -290,7 +304,8 @@ function parseTierBody(bodyStr: string): Record<string, number> {
export function parseTiersFromExpr(exprStr: string): ParsedTier[] {
if (!exprStr) return []
try {
const { body } = stripExprVersion(exprStr)
const versioned = stripExprVersion(exprStr.trim())
const body = unwrapOuterParens(versioned.body)
const condGroup =
`((?:(?: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[] {
'g'
)
const tiers: ParsedTier[] = []
let end = 0
let m
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 conditions: TierCondition[] = []
if (condStr) {
for (const cp of condStr.split(/\s*&&\s*/)) {
const cm = cp.trim().match(/^(p|c|len)\s*(<|<=|>|>=)\s*([\d.eE+]+)$/)
if (cm) {
if (!Number.isFinite(Number(cm[3]))) return []
conditions.push({
var: cm[1] as TierCondition['var'],
op: cm[2] as TierCondition['op'],
......@@ -315,11 +337,12 @@ export function parseTiersFromExpr(exprStr: string): ParsedTier[] {
}
}
}
const tier = parseTierBody(m[3]) as ParsedTier
tier.label = m[2]
tier.conditions = conditions
tiers.push(tier)
const prices = parseTierBody(m[3])
if (!prices) return []
tiers.push({ ...prices, label: m[2], conditions })
end = tierRe.lastIndex
}
if (body.slice(end).trim() || tiers.at(-1)?.conditions.length) return []
return tiers
} catch {
return []
......
......@@ -44,6 +44,7 @@ import {
export type DynamicPriceOptions = {
tokenUnit: TokenUnit
showCurrencySymbol?: boolean
showRechargePrice?: boolean
priceRate?: number
usdExchangeRate?: number
......@@ -189,6 +190,7 @@ export function formatDynamicUnitPrice(
)
return formatBillingCurrencyFromUSD(displayPrice, {
showSymbol: options.showCurrencySymbol ?? true,
digitsLarge: 4,
digitsSmall: 6,
abbreviate: false,
......@@ -211,6 +213,7 @@ export function formatTaskUsageUnitPrice(
)
return formatBillingCurrencyFromUSD(displayPrice, {
showSymbol: options.showCurrencySymbol ?? true,
digitsLarge: 4,
digitsSmall: 6,
abbreviate: false,
......@@ -249,7 +252,7 @@ export function getDynamicPriceEntries(
options.usageSchema
).flatMap(([field, definition]) => {
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 [
{
key: field,
......@@ -282,7 +285,7 @@ export function getDynamicPriceEntries(
return BILLING_PRICING_VARS.flatMap((variable) => {
if (!variable.field) return []
const value = Number((tier as ParsedTier)[variable.field])
if (!Number.isFinite(value) || value <= 0) return []
if (!Number.isFinite(value) || value < 0) return []
return [
{
......@@ -326,7 +329,7 @@ export function getDynamicPricingSummary(
for (const taskTier of tiers) {
if (!isTaskPricingTier(taskTier)) continue
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)
max = Math.max(max, value)
}
......
......@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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 type { PricingModel, TokenUnit, PriceType } from '../types'
......@@ -108,7 +108,7 @@ function hasRatio(value: number | null | undefined): boolean {
* priceRate represents how much users need to recharge (in the display currency)
* 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.
*
* Examples:
......@@ -118,14 +118,14 @@ function hasRatio(value: number | null | undefined): boolean {
* - priceRate = 0.5 (recharge $0.5 to get $1 credit)
* - usdExchangeRate = 1
* - Return: 1 × 0.5 / 1 = 0.5
* - formatCurrencyFromUSD(0.5) → $0.5 ✓
* - formatBillingCurrencyFromUSD(0.5) → $0.5 ✓
*
* 2. Display currency = CNY:
* - Model: 1 USD
* - priceRate = 4 (recharge ¥4 to get $1 credit)
* - usdExchangeRate = 7 (real rate: 1 USD = ¥7)
* - 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!)
*/
function applyRechargeRate(
......@@ -148,7 +148,8 @@ export function formatPrice(
showWithRecharge = false,
priceRate = 1,
usdExchangeRate = 1,
selectedGroup?: string
selectedGroup?: string,
showCurrencySymbol = true
): string {
if (model.quota_type === QUOTA_TYPE_VALUES.REQUEST) {
return '-'
......@@ -165,7 +166,8 @@ export function formatPrice(
)
const price = priceInUSD / TOKEN_UNIT_DIVISORS[tokenUnit]
return formatCurrencyFromUSD(price, {
return formatBillingCurrencyFromUSD(price, {
showSymbol: showCurrencySymbol,
digitsLarge: 4,
digitsSmall: 6,
abbreviate: false,
......@@ -200,7 +202,7 @@ export function formatGroupPrice(
)
const price = priceInUSD / TOKEN_UNIT_DIVISORS[tokenUnit]
return formatCurrencyFromUSD(price, {
return formatBillingCurrencyFromUSD(price, {
digitsLarge: 4,
digitsSmall: 6,
abbreviate: false,
......@@ -232,7 +234,7 @@ export function formatFixedPrice(
usdExchangeRate
)
return formatCurrencyFromUSD(priceInUSD, {
return formatBillingCurrencyFromUSD(priceInUSD, {
digitsLarge: 4,
digitsSmall: 4,
abbreviate: false,
......@@ -247,7 +249,8 @@ export function formatRequestPrice(
showWithRecharge = false,
priceRate = 1,
usdExchangeRate = 1,
selectedGroup?: string
selectedGroup?: string,
showCurrencySymbol = true
): string {
if (model.quota_type !== QUOTA_TYPE_VALUES.REQUEST) {
return '-'
......@@ -264,7 +267,8 @@ export function formatRequestPrice(
usdExchangeRate
)
return formatCurrencyFromUSD(priceInUSD, {
return formatBillingCurrencyFromUSD(priceInUSD, {
showSymbol: showCurrencySymbol,
digitsLarge: 4,
digitsSmall: 4,
abbreviate: false,
......
......@@ -196,7 +196,7 @@ function BillingBreakdown(props: {
} else {
rows.push({
label: t('Matched Tier'),
value: t('No matching results'),
value: other.matched_tier || t('No matching results'),
})
}
} else if (isPerCall) {
......
......@@ -21,6 +21,7 @@ import {
BILLING_PRICING_VARS,
normalizeTierLabel,
parseTiersFromExpr,
splitBillingExprAndRequestRules,
type ParsedTier,
} from '@/features/pricing/lib/billing-expr'
......@@ -337,7 +338,9 @@ export function getTieredBillingSummary(
if (!other || other.billing_mode !== 'tiered_expr') return null
const exprStr = decodeBillingExprB64(other.expr_b64)
if (!exprStr) return null
const tiers = parseTiersFromExpr(exprStr)
const tiers = parseTiersFromExpr(
splitBillingExprAndRequestRules(exprStr).billingExpr
)
const tier = resolveMatchedTier(tiers, other.matched_tier)
if (!tier) return null
......
......@@ -21,6 +21,15 @@ For commercial licensing, please contact support@quantumnous.com
export const STATIC_I18N_KEYS = [
'Account deletion',
// 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',
'Select models',
'Preview fields',
......
......@@ -37,6 +37,10 @@ const modelsSearchSchema = z.object({
filter: z.string().optional().catch(''),
vendor: 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([]),
dPage: z.number().optional().catch(1),
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