mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 16:46:57 +08:00
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.
This commit is contained in:
36 files changed
+2547
-412
No files matched your search
@@ -120,6 +120,285 @@ export function parseTaskResult() { return {}; }
|
||||
t.Skip("set " + dialect.env + " to run this database")
|
||||
}
|
||||
db := modelManagementDB(t, dialect.kind, os.Getenv(dialect.env))
|
||||
|
||||
t.Run("square_states_follow_catalog_policy", func(t *testing.T) {
|
||||
records := []model.Model{
|
||||
{ModelName: "square-visible", Status: 1},
|
||||
{ModelName: "square-hidden", Status: 0},
|
||||
{ModelName: "square-catalog", Status: 1},
|
||||
{ModelName: "square-hidden-catalog", Status: 0},
|
||||
{ModelName: "square-partial-", NameRule: model.NameRulePrefix, Status: 1},
|
||||
{ModelName: "square-partial-hidden", Status: 0},
|
||||
{ModelName: "square-hidden-", NameRule: model.NameRulePrefix, Status: 0},
|
||||
{ModelName: "square-hidden-override", Status: 1},
|
||||
{ModelName: "square-off-", NameRule: model.NameRulePrefix, Status: 0},
|
||||
{ModelName: "square-empty", NameRule: model.NameRulePrefix, Status: 1},
|
||||
{ModelName: "square-empty-hidden", NameRule: model.NameRulePrefix, Status: 0},
|
||||
{ModelName: "-ending", NameRule: model.NameRuleSuffix, Status: 1},
|
||||
{ModelName: "square-prefix-", NameRule: model.NameRulePrefix, Status: 0},
|
||||
{ModelName: "square-contains", NameRule: model.NameRuleContains, Status: 0},
|
||||
}
|
||||
ids := make([]int, 0, len(records))
|
||||
for i := range records {
|
||||
require.NoError(t, records[i].Insert())
|
||||
ids = append(ids, records[i].Id)
|
||||
}
|
||||
active := model.Channel{Name: "Square active", Type: 1, Key: "fixture", Group: "default", Status: common.ChannelStatusEnabled,
|
||||
Models: "square-visible,square-hidden,square-bare,square-partial-on,square-partial-hidden,square-hidden-child,square-hidden-override,square-prefix-ending,square-contains-ending"}
|
||||
inactive := model.Channel{Name: "Square inactive", Type: 1, Key: "fixture", Group: "default", Status: common.ChannelStatusManuallyDisabled,
|
||||
Models: "square-disabled,square-partial-off,square-off-child"}
|
||||
for _, channel := range []*model.Channel{&active, &inactive} {
|
||||
require.NoError(t, channel.Insert())
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, db.Where("channel_id IN ?", []int{active.Id, inactive.Id}).Delete(&model.Ability{}).Error)
|
||||
require.NoError(t, db.Where("id IN ?", []int{active.Id, inactive.Id}).Delete(&model.Channel{}).Error)
|
||||
require.NoError(t, db.Unscoped().Where("id IN ?", ids).Delete(&model.Model{}).Error)
|
||||
model.RefreshPricing()
|
||||
})
|
||||
var response struct {
|
||||
Success bool
|
||||
Data struct{ Items []model.Model }
|
||||
}
|
||||
modelManagementRequest(t, SearchModelsMeta, "GET", "/api/models/search?include_channel_models=true&keyword=square-&page_size=100", nil, &response)
|
||||
require.True(t, response.Success)
|
||||
byName := make(map[string]model.Model)
|
||||
for _, row := range response.Data.Items {
|
||||
byName[row.ModelName] = row
|
||||
}
|
||||
catalog := make(map[string]bool)
|
||||
model.RefreshPricing()
|
||||
for _, row := range model.GetPricing() {
|
||||
catalog[row.ModelName] = true
|
||||
}
|
||||
expected := map[string]model.ModelSquareState{
|
||||
"square-visible": model.ModelSquareVisible,
|
||||
"square-hidden": model.ModelSquareHidden,
|
||||
"square-catalog": model.ModelSquareUnavailable,
|
||||
"square-hidden-catalog": model.ModelSquareHidden,
|
||||
"square-bare": model.ModelSquareVisible,
|
||||
"square-disabled": model.ModelSquareUnavailable,
|
||||
"square-partial-": model.ModelSquarePartial,
|
||||
"square-partial-hidden": model.ModelSquareHidden,
|
||||
"square-partial-on": model.ModelSquareVisible,
|
||||
"square-partial-off": model.ModelSquareUnavailable,
|
||||
"square-hidden-": model.ModelSquarePartial,
|
||||
"square-hidden-child": model.ModelSquareHidden,
|
||||
"square-hidden-override": model.ModelSquareVisible,
|
||||
"square-off-": model.ModelSquareHidden,
|
||||
"square-off-child": model.ModelSquareHidden,
|
||||
"square-empty": model.ModelSquareUnavailable,
|
||||
"square-empty-hidden": model.ModelSquareHidden,
|
||||
"square-prefix-ending": model.ModelSquareHidden,
|
||||
"square-contains-ending": model.ModelSquareVisible,
|
||||
"square-contains": model.ModelSquareVisible,
|
||||
"square-prefix-": model.ModelSquareHidden,
|
||||
}
|
||||
for name, state := range expected {
|
||||
row, exists := byName[name]
|
||||
require.True(t, exists, name)
|
||||
assert.Equal(t, state, row.SquareState, name)
|
||||
if row.NameRule == model.NameRuleExact {
|
||||
assert.Equal(t, state == model.ModelSquareVisible, catalog[name], name)
|
||||
}
|
||||
}
|
||||
assert.Zero(t, byName["square-bare"].Id)
|
||||
assert.Zero(t, byName["square-hidden-child"].Id)
|
||||
t.Run("filter_square_state_before_pagination", func(t *testing.T) {
|
||||
type filteredResponse struct {
|
||||
Success bool
|
||||
Data struct {
|
||||
Items []model.Model
|
||||
Total int
|
||||
}
|
||||
}
|
||||
for _, state := range []model.ModelSquareState{model.ModelSquareVisible, model.ModelSquareUnavailable, model.ModelSquareHidden, model.ModelSquarePartial} {
|
||||
t.Run(string(state), func(t *testing.T) {
|
||||
expectedNames := []string{}
|
||||
for name, expectedState := range expected {
|
||||
if expectedState == state {
|
||||
expectedNames = append(expectedNames, name)
|
||||
}
|
||||
}
|
||||
actualNames := []string{}
|
||||
for page := 1; page <= (len(expectedNames)+1)/2; page++ {
|
||||
var result filteredResponse
|
||||
path := fmt.Sprintf("/api/models/search?include_channel_models=true&keyword=square-&square_state=%s&page_size=2&p=%d", state, page)
|
||||
modelManagementRequest(t, SearchModelsMeta, "GET", path, nil, &result)
|
||||
require.True(t, result.Success)
|
||||
assert.Equal(t, len(expectedNames), result.Data.Total)
|
||||
for _, row := range result.Data.Items {
|
||||
assert.Equal(t, state, row.SquareState)
|
||||
actualNames = append(actualNames, row.ModelName)
|
||||
}
|
||||
}
|
||||
assert.ElementsMatch(t, expectedNames, actualNames)
|
||||
var beyondLastPage filteredResponse
|
||||
modelManagementRequest(t, SearchModelsMeta, "GET", "/api/models/search?include_channel_models=true&keyword=square-&square_state="+string(state)+"&page_size=2&p=99", nil, &beyondLastPage)
|
||||
require.True(t, beyondLastPage.Success)
|
||||
assert.Equal(t, len(expectedNames), beyondLastPage.Data.Total)
|
||||
assert.Empty(t, beyondLastPage.Data.Items)
|
||||
})
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
handler gin.HandlerFunc
|
||||
query string
|
||||
names []string
|
||||
}{
|
||||
{"list", GetAllModelsMeta, "?include_channel_models=true&square_state=visible", []string{"square-visible", "square-bare", "square-partial-on", "square-hidden-override", "square-contains-ending", "square-contains"}},
|
||||
{"metadata_only", SearchModelsMeta, "?keyword=square-&square_state=visible", []string{"square-visible", "square-hidden-override", "square-contains"}},
|
||||
{"keyword", SearchModelsMeta, "?include_channel_models=true&keyword=square-partial&square_state=hidden", []string{"square-partial-hidden"}},
|
||||
{"policy", SearchModelsMeta, "?include_channel_models=true&keyword=square-&square_state=partial&status=disabled", []string{"square-hidden-"}},
|
||||
{"vendor_and_sync", SearchModelsMeta, "?include_channel_models=true&keyword=square-&square_state=partial&vendor=0&sync_official=no", []string{"square-hidden-", "square-partial-"}},
|
||||
{"no_matches", SearchModelsMeta, "?include_channel_models=true&keyword=square-bare&square_state=hidden", []string{}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var result filteredResponse
|
||||
modelManagementRequest(t, test.handler, "GET", "/api/models/"+test.query+"&page_size=100", nil, &result)
|
||||
require.True(t, result.Success)
|
||||
names := []string{}
|
||||
for _, row := range result.Data.Items {
|
||||
names = append(names, row.ModelName)
|
||||
}
|
||||
assert.Equal(t, len(test.names), result.Data.Total)
|
||||
assert.ElementsMatch(t, test.names, names)
|
||||
})
|
||||
}
|
||||
for _, handler := range []gin.HandlerFunc{GetAllModelsMeta, SearchModelsMeta} {
|
||||
var result filteredResponse
|
||||
recorder := modelManagementRequest(t, handler, "GET", "/api/models/?square_state=unknown", nil, &result)
|
||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
assert.False(t, result.Success)
|
||||
}
|
||||
for _, query := range []string{"p=-1", "page_size=-1"} {
|
||||
recorder := modelManagementRequest(t, SearchModelsMeta, "GET", "/api/models/search?square_state=visible&"+query, nil, nil)
|
||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
}
|
||||
var oversizedPage filteredResponse
|
||||
modelManagementRequest(t, SearchModelsMeta, "GET", "/api/models/search?square_state=visible&keyword=square-&p="+strconv.Itoa(int(^uint(0)>>1)), nil, &oversizedPage)
|
||||
require.True(t, oversizedPage.Success)
|
||||
assert.Equal(t, 3, oversizedPage.Data.Total)
|
||||
assert.Empty(t, oversizedPage.Data.Items)
|
||||
})
|
||||
var detail struct {
|
||||
Success bool
|
||||
Data model.Model
|
||||
}
|
||||
modelManagementRequest(t, func(c *gin.Context) {
|
||||
c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(records[4].Id)}}
|
||||
GetModelMeta(c)
|
||||
}, "GET", "/api/models/"+strconv.Itoa(records[4].Id), nil, &detail)
|
||||
require.True(t, detail.Success)
|
||||
assert.Equal(t, model.ModelSquarePartial, detail.Data.SquareState)
|
||||
})
|
||||
t.Run("channel_model_listing", func(t *testing.T) {
|
||||
exact := model.Model{ModelName: "listing-exact", Status: 1, SyncOfficial: 1}
|
||||
catalog := model.Model{ModelName: "listing-catalog", Status: 1}
|
||||
rule := model.Model{ModelName: "listing-rule-", NameRule: model.NameRulePrefix, Status: 1}
|
||||
for _, item := range []*model.Model{&exact, &catalog, &rule} {
|
||||
require.NoError(t, item.Insert())
|
||||
}
|
||||
active := model.Channel{Name: "Listing active", Type: 1, Key: "fixture", Models: "listing-exact, listing-new,listing-rule-child,listing-new, ,", Group: "default", Status: common.ChannelStatusEnabled}
|
||||
inactive := model.Channel{Name: "Listing inactive", Type: 1, Key: "fixture", Models: "listing-new,listing-disabled", Group: "default", Status: common.ChannelStatusManuallyDisabled}
|
||||
for _, channel := range []*model.Channel{&active, &inactive} {
|
||||
require.NoError(t, channel.Insert())
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, db.Where("channel_id IN ?", []int{active.Id, inactive.Id}).Delete(&model.Ability{}).Error)
|
||||
require.NoError(t, db.Where("id IN ?", []int{active.Id, inactive.Id}).Delete(&model.Channel{}).Error)
|
||||
require.NoError(t, db.Unscoped().Where("model_name LIKE ?", "listing-%").Delete(&model.Model{}).Error)
|
||||
model.RefreshPricing()
|
||||
})
|
||||
type listingResponse struct {
|
||||
Success bool
|
||||
Data struct {
|
||||
Items []model.Model
|
||||
Total int
|
||||
}
|
||||
}
|
||||
var response listingResponse
|
||||
modelManagementRequest(t, SearchModelsMeta, "GET", "/api/models/search?include_channel_models=true&keyword=listing-", nil, &response)
|
||||
require.True(t, response.Success)
|
||||
require.Equal(t, 6, response.Data.Total)
|
||||
names := make([]string, 0)
|
||||
byName := make(map[string]model.Model)
|
||||
for _, item := range response.Data.Items {
|
||||
names = append(names, item.ModelName)
|
||||
byName[item.ModelName] = item
|
||||
}
|
||||
assert.Equal(t, []string{"listing-rule-", "listing-catalog", "listing-exact", "listing-disabled", "listing-new", "listing-rule-child"}, names)
|
||||
assert.True(t, byName[exact.ModelName].HasMetadata)
|
||||
assert.False(t, byName["listing-new"].HasMetadata)
|
||||
assert.Zero(t, byName["listing-new"].Id)
|
||||
assert.Equal(t, 2, byName["listing-new"].ConfiguredChannelCount)
|
||||
assert.Equal(t, 1, byName["listing-disabled"].ConfiguredChannelCount)
|
||||
assert.Empty(t, byName["listing-disabled"].BoundChannels)
|
||||
assert.Zero(t, byName[catalog.ModelName].ConfiguredChannelCount)
|
||||
for _, tc := range []struct {
|
||||
query string
|
||||
total int
|
||||
names []string
|
||||
}{
|
||||
{"&p=2&page_size=2", 6, []string{"listing-exact", "listing-disabled"}},
|
||||
{"&p=9&page_size=2", 6, []string{}},
|
||||
{"&status=enabled", 3, []string{"listing-rule-", "listing-catalog", "listing-exact"}},
|
||||
{"&sync_official=no", 2, []string{"listing-rule-", "listing-catalog"}},
|
||||
{"&vendor=0", 6, []string{"listing-rule-", "listing-catalog", "listing-exact", "listing-disabled", "listing-new", "listing-rule-child"}},
|
||||
{"&vendor=999", 0, []string{}},
|
||||
} {
|
||||
var page listingResponse
|
||||
modelManagementRequest(t, SearchModelsMeta, "GET", "/api/models/search?include_channel_models=true&keyword=listing-"+tc.query, nil, &page)
|
||||
require.True(t, page.Success)
|
||||
assert.Equal(t, tc.total, page.Data.Total)
|
||||
actual := make([]string, 0)
|
||||
for _, item := range page.Data.Items {
|
||||
actual = append(actual, item.ModelName)
|
||||
}
|
||||
assert.Equal(t, tc.names, actual)
|
||||
}
|
||||
var legacy listingResponse
|
||||
modelManagementRequest(t, SearchModelsMeta, "GET", "/api/models/search?keyword=listing-", nil, &legacy)
|
||||
assert.Equal(t, 3, legacy.Data.Total)
|
||||
var count int64
|
||||
require.NoError(t, db.Model(&model.Model{}).Where("model_name LIKE ?", "listing-%").Count(&count).Error)
|
||||
assert.EqualValues(t, 3, count)
|
||||
prices, err := model.GetModelPricingSnapshot([]string{"listing-new"})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, model.UpdateModelPricing([]model.ModelPricingChange{{ModelName: "listing-new", ExpectedVersion: prices.Entries[0].Version, Pricing: model.PricingValues{"ModelPrice": float64(0)}}}))
|
||||
t.Cleanup(func() {
|
||||
snapshot, err := model.GetModelPricingSnapshot([]string{"listing-new"})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, model.UpdateModelPricing([]model.ModelPricingChange{{ModelName: "listing-new", ExpectedVersion: snapshot.Entries[0].Version, Reset: true}}))
|
||||
})
|
||||
require.NoError(t, db.Model(&model.Model{}).Where("model_name = ?", "listing-new").Count(&count).Error)
|
||||
assert.Zero(t, count)
|
||||
for _, price := range model.GetPricing() {
|
||||
assert.NotEqual(t, catalog.ModelName, price.ModelName)
|
||||
}
|
||||
created := model.Model{ModelName: "listing-new", Status: 1}
|
||||
require.NoError(t, created.Insert())
|
||||
var after listingResponse
|
||||
modelManagementRequest(t, SearchModelsMeta, "GET", "/api/models/search?include_channel_models=true&keyword=listing-new", nil, &after)
|
||||
require.Len(t, after.Data.Items, 1)
|
||||
assert.Equal(t, created.Id, after.Data.Items[0].Id)
|
||||
require.NoError(t, created.Delete())
|
||||
after = listingResponse{}
|
||||
modelManagementRequest(t, SearchModelsMeta, "GET", "/api/models/search?include_channel_models=true&keyword=listing-new", nil, &after)
|
||||
require.Len(t, after.Data.Items, 1)
|
||||
assert.False(t, after.Data.Items[0].HasMetadata)
|
||||
require.NoError(t, db.Callback().Query().Before("gorm:query").Register("fail_listing_channels", func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "channels" {
|
||||
tx.AddError(errors.New("channel lookup failed"))
|
||||
}
|
||||
}))
|
||||
var failed listingResponse
|
||||
modelManagementRequest(t, GetAllModelsMeta, "GET", "/api/models/?include_channel_models=true", nil, &failed)
|
||||
require.NoError(t, db.Callback().Query().Remove("fail_listing_channels"))
|
||||
assert.False(t, failed.Success, "a channel lookup failure must not look like an empty configuration")
|
||||
|
||||
})
|
||||
|
||||
t.Run("pricing_saves_zero_switches_modes_and_rejects_stale_batches", func(t *testing.T) {
|
||||
before, err := model.GetModelPricingSnapshot([]string{"matrix-priced", "matrix-other"})
|
||||
require.NoError(t, err)
|
||||
@@ -436,6 +715,7 @@ func TestVendorManagementDatabaseMatrix(t *testing.T) {
|
||||
t.Skip("set " + dialect.env)
|
||||
}
|
||||
db := modelManagementDB(t, dialect.kind, os.Getenv(dialect.env))
|
||||
|
||||
t.Run("pricing_reads_keep_default_brands_without_writing_vendors", func(t *testing.T) {
|
||||
channel := model.Channel{Name: "Vendor fixture", Type: 1, Status: common.ChannelStatusEnabled}
|
||||
require.NoError(t, db.Create(&channel).Error)
|
||||
@@ -647,6 +927,7 @@ func TestModelDeletionDatabaseMatrix(t *testing.T) {
|
||||
t.Skip("set " + dialect.env + " to run this database")
|
||||
}
|
||||
db := modelManagementDB(t, dialect.kind, os.Getenv(dialect.env))
|
||||
|
||||
var response struct {
|
||||
Success bool
|
||||
Data model.ModelDeleteResult
|
||||
|
||||
+80
-47
@@ -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"))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
keyword := c.Query("keyword")
|
||||
vendor := c.Query("vendor")
|
||||
status := c.Query("status")
|
||||
syncOfficial := c.Query("sync_official")
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
|
||||
modelsMeta, total, err := model.SearchModels(keyword, vendor, status, syncOfficial, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
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 {
|
||||
|
||||
+4
-47
@@ -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>>>(
|
||||
|
||||
@@ -0,0 +1,857 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import {
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
createMemoryHistory,
|
||||
RouterProvider,
|
||||
} from '@tanstack/react-router'
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
within,
|
||||
cleanup,
|
||||
waitFor,
|
||||
act,
|
||||
} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import i18n from 'i18next'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
ModelPricingConfig,
|
||||
ModelPricingEntry,
|
||||
} from '@/features/model-pricing/api'
|
||||
import { pricingOptions } from '@/features/model-pricing/pricing'
|
||||
import { usePricingColumns } from '@/features/pricing/components/pricing-columns'
|
||||
import type { PricingModel } from '@/features/pricing/types'
|
||||
import fr from '@/i18n/locales/fr.json'
|
||||
import zhCN from '@/i18n/locales/zh.json'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import {
|
||||
DEFAULT_CURRENCY_CONFIG,
|
||||
useSystemConfigStore,
|
||||
} from '@/stores/system-config-store'
|
||||
|
||||
import { ModelsDialogs } from '../components/models-dialogs'
|
||||
import { ModelsProvider } from '../components/models-provider'
|
||||
import { ModelsTable } from '../components/models-table'
|
||||
import type { Model } from '../types'
|
||||
|
||||
const metadata: Model = {
|
||||
id: 7,
|
||||
model_name: 'catalog-only',
|
||||
square_state: 'unavailable',
|
||||
has_metadata: true,
|
||||
configured_channel_count: 0,
|
||||
name_rule: 0,
|
||||
status: 1,
|
||||
sync_official: 1,
|
||||
created_time: 1,
|
||||
updated_time: 1,
|
||||
}
|
||||
const channel: Model = {
|
||||
...metadata,
|
||||
id: 0,
|
||||
model_name: 'channel-only',
|
||||
has_metadata: false,
|
||||
configured_channel_count: 1,
|
||||
status: 0,
|
||||
sync_official: 0,
|
||||
}
|
||||
const clients: QueryClient[] = []
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<ModelsProvider>
|
||||
<ModelsTable />
|
||||
<ModelsDialogs />
|
||||
</ModelsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function CatalogPrice(props: { model: PricingModel }) {
|
||||
const columns = usePricingColumns({ tokenUnit: 'M' })
|
||||
const table = useReactTable({
|
||||
data: [props.model],
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
const cell = table
|
||||
.getRowModel()
|
||||
.rows[0].getAllCells()
|
||||
.find((item) => item.column.id === 'price')
|
||||
return (
|
||||
<div role='group' aria-label='Catalog price'>
|
||||
{cell && flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function renderList(
|
||||
items: Model[] = [
|
||||
metadata,
|
||||
channel,
|
||||
{ ...channel, model_name: 'other-channel' },
|
||||
],
|
||||
options: {
|
||||
pricing?: ModelPricingEntry[] | Promise<ModelPricingConfig>
|
||||
waitForPricing?: boolean
|
||||
initialUrl?: string
|
||||
total?: number
|
||||
} = {}
|
||||
) {
|
||||
useAuthStore.getState().auth.setUser({ id: 1, username: 'admin', role: 100 })
|
||||
const get = vi.spyOn(api, 'get').mockImplementation(async (url) => {
|
||||
if (url === '/api/models/' || url === '/api/models/search') {
|
||||
return {
|
||||
data: {
|
||||
success: true,
|
||||
data: { items, total: options.total ?? items.length },
|
||||
},
|
||||
}
|
||||
}
|
||||
if (url === '/api/models/7') {
|
||||
return { data: { success: true, data: metadata } }
|
||||
}
|
||||
if (url === '/api/option/model_pricing') {
|
||||
return {
|
||||
data: {
|
||||
success: true,
|
||||
data:
|
||||
options.pricing && !Array.isArray(options.pricing)
|
||||
? await options.pricing
|
||||
: {
|
||||
entries: options.pricing ?? [],
|
||||
options: pricingOptions({}),
|
||||
empty_version: 'empty',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return { data: { success: true, data: { items: [] } } }
|
||||
})
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
clients.push(client)
|
||||
const root = createRootRoute()
|
||||
const authenticated = createRoute({
|
||||
getParentRoute: () => root,
|
||||
id: '_authenticated',
|
||||
})
|
||||
const models = createRoute({
|
||||
getParentRoute: () => authenticated,
|
||||
path: 'models/$section',
|
||||
component: Page,
|
||||
})
|
||||
const router = createRouter({
|
||||
routeTree: root.addChildren([authenticated.addChildren([models])]),
|
||||
history: createMemoryHistory({
|
||||
initialEntries: [options.initialUrl ?? '/models/metadata'],
|
||||
}),
|
||||
})
|
||||
await router.load()
|
||||
const result = render(
|
||||
<QueryClientProvider client={client}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
if (items.length) {
|
||||
await screen.findByRole('button', { name: items[0].model_name })
|
||||
} else await screen.findByText('No Models Found')
|
||||
if (options.waitForPricing !== false) {
|
||||
await waitFor(() => expect(client.isFetching()).toBe(0))
|
||||
}
|
||||
return { ...result, get, router }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useSystemConfigStore.getState().setConfig({
|
||||
currency: { ...DEFAULT_CURRENCY_CONFIG, quotaDisplayType: 'USD' },
|
||||
})
|
||||
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
|
||||
i18n.addResourceBundle('fr', 'translation', fr.translation, true, true)
|
||||
i18n.addResourceBundle('zhCN', 'translation', zhCN.translation, true, true)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
cleanup()
|
||||
clients.splice(0).forEach((client) => client.clear())
|
||||
useAuthStore.getState().auth.reset()
|
||||
useSystemConfigStore
|
||||
.getState()
|
||||
.setConfig({ currency: { ...DEFAULT_CURRENCY_CONFIG } })
|
||||
await i18n.changeLanguage('en')
|
||||
})
|
||||
|
||||
it('requests channel models and distinguishes catalog policy from availability using compact labels', async () => {
|
||||
const { get } = await renderList()
|
||||
expect(get).toHaveBeenCalledWith('/api/models/', {
|
||||
params: expect.objectContaining({ include_channel_models: true }),
|
||||
})
|
||||
expect(screen.getAllByText('Unavailable')).toHaveLength(3)
|
||||
const warning = screen
|
||||
.getAllByText('Unavailable')[0]
|
||||
.closest('[data-slot="status-badge"]')
|
||||
expect(warning).toHaveClass('text-warning')
|
||||
expect(warning?.querySelector('svg')).toBeInTheDocument()
|
||||
expect(warning).not.toHaveClass('text-success')
|
||||
expect(screen.getAllByText('Missing metadata')).toHaveLength(2)
|
||||
expect(screen.getAllByText('Channels 0 · Groups 0')).toHaveLength(3)
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'No channel is configured. This model will not appear in the model square.'
|
||||
)
|
||||
).not.toBeInTheDocument()
|
||||
const user = userEvent.setup()
|
||||
const trigger = screen.getAllByRole('button', {
|
||||
name: 'Unavailable',
|
||||
})[0]
|
||||
expect(trigger).toHaveAttribute(
|
||||
'title',
|
||||
'No channel is configured. This model will not appear in the model square.'
|
||||
)
|
||||
await user.click(trigger)
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: 'Unavailable' })
|
||||
).toHaveTextContent(
|
||||
'No channel is configured. This model will not appear in the model square.'
|
||||
)
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: 'Unavailable' })
|
||||
).not.toBeInTheDocument()
|
||||
)
|
||||
expect(trigger).toHaveFocus()
|
||||
await user.click(screen.getByRole('button', { name: 'catalog-only' }))
|
||||
await user.click(screen.getByRole('tab', { name: 'Channels and groups' }))
|
||||
expect(
|
||||
screen.getByText(
|
||||
'No channel is configured. This model will not appear in the model square.'
|
||||
)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps channel rows individually selectable and disables metadata mutations for mixed selection', async () => {
|
||||
await renderList()
|
||||
const user = userEvent.setup()
|
||||
await user.click(
|
||||
screen.getByRole('checkbox', { name: 'Select channel-only' })
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: 'Select channel-only' })
|
||||
).toBeChecked()
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: 'Select other-channel' })
|
||||
).not.toBeChecked()
|
||||
await user.click(
|
||||
screen.getByRole('checkbox', { name: 'Select catalog-only' })
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: 'Select channel-only' })
|
||||
).toBeChecked()
|
||||
const toolbar = screen.getByRole('toolbar', { name: /Bulk actions/ })
|
||||
for (const name of [
|
||||
'Change vendor',
|
||||
'Clear vendor',
|
||||
'Show selected models in model square',
|
||||
'Hide selected models from model square',
|
||||
'Delete selected models',
|
||||
]) {
|
||||
expect(within(toolbar).getByRole('button', { name })).toBeDisabled()
|
||||
}
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: 'Copy model names' })
|
||||
).toBeEnabled()
|
||||
await user.click(
|
||||
screen.getByRole('checkbox', { name: 'Select channel-only' })
|
||||
)
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: 'Delete selected models' })
|
||||
).toBeEnabled()
|
||||
})
|
||||
|
||||
it('keeps long model names and translated channel labels truncated inside their existing cells', async () => {
|
||||
const longName =
|
||||
'provider/very-long-channel-model-with-detailed-version-and-context-window'
|
||||
await renderList([
|
||||
{ ...channel, model_name: longName },
|
||||
{ ...metadata, status: 0, square_state: 'hidden' },
|
||||
])
|
||||
expect(screen.getByText(longName)).toHaveClass('truncate')
|
||||
expect(screen.getByText('Add metadata')).toHaveClass('truncate')
|
||||
await act(async () => {
|
||||
await i18n.changeLanguage('fr')
|
||||
})
|
||||
const label = screen.getAllByText(
|
||||
i18n.t('Channels {{channels}} · Groups {{groups}}', {
|
||||
channels: 0,
|
||||
groups: 0,
|
||||
})
|
||||
)[0]
|
||||
expect(label).toHaveClass('whitespace-normal', 'sm:truncate')
|
||||
expect(label).toHaveAttribute('tabindex', '0')
|
||||
await act(async () => {
|
||||
await i18n.changeLanguage('zhCN')
|
||||
})
|
||||
expect(screen.getByText('缺元数据')).toBeVisible()
|
||||
expect(screen.getByText('已隐藏')).toBeVisible()
|
||||
expect(screen.getAllByText('渠道 0 · 分组 0')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('prefills and creates metadata only when the user explicitly saves it', async () => {
|
||||
await renderList([channel])
|
||||
const post = vi.spyOn(api, 'post').mockResolvedValue({
|
||||
data: { success: true, data: { ...channel, id: 9, has_metadata: true } },
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: 'Add metadata' }))
|
||||
expect(screen.getByRole('textbox', { name: 'Model Name *' })).toHaveValue(
|
||||
'channel-only'
|
||||
)
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: 'Save metadata' }))
|
||||
await waitFor(() =>
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/api/models/',
|
||||
expect.objectContaining({ model_name: 'channel-only' }),
|
||||
expect.anything()
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps prices and channel explanations readable in the mobile card and detail drawer', async () => {
|
||||
const originalMatchMedia = window.matchMedia
|
||||
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
|
||||
...originalMatchMedia(query),
|
||||
matches: query === '(max-width: 640px)',
|
||||
}))
|
||||
await renderList([metadata], {
|
||||
pricing: [
|
||||
{
|
||||
model_name: metadata.model_name,
|
||||
version: 'v1',
|
||||
configured: {},
|
||||
effective: { ModelRatio: 0.625, CompletionRatio: 8 },
|
||||
},
|
||||
],
|
||||
})
|
||||
const price = screen.getByRole('button', {
|
||||
name: 'View pricing for catalog-only',
|
||||
})
|
||||
expect(within(price).getByText('1.25')).toHaveClass('whitespace-normal')
|
||||
expect(within(price).getByText('USD / 1M tokens')).toHaveClass(
|
||||
'whitespace-normal'
|
||||
)
|
||||
expect(screen.getByText('Channels 0 · Groups 0')).toHaveClass(
|
||||
'whitespace-normal'
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: 'catalog-only' }))
|
||||
await user.click(screen.getByRole('tab', { name: 'Channels and groups' }))
|
||||
expect(
|
||||
screen.getByText(
|
||||
'No channel is configured. This model will not appear in the model square.'
|
||||
)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
it('uses backend square states for success, warning, hidden, and partial rows including models without metadata', async () => {
|
||||
const activeChannels = [{ name: 'Active', type: 1 }]
|
||||
await renderList([
|
||||
{ ...metadata, model_name: 'catalog-only' },
|
||||
{
|
||||
...metadata,
|
||||
id: 8,
|
||||
model_name: 'enabled-model',
|
||||
square_state: 'visible',
|
||||
configured_channel_count: 1,
|
||||
bound_channels: activeChannels,
|
||||
},
|
||||
{
|
||||
...metadata,
|
||||
id: 9,
|
||||
model_name: 'hidden-model',
|
||||
status: 0,
|
||||
square_state: 'hidden',
|
||||
configured_channel_count: 1,
|
||||
bound_channels: activeChannels,
|
||||
},
|
||||
{
|
||||
...metadata,
|
||||
id: 10,
|
||||
model_name: 'partial-rule-',
|
||||
name_rule: 1,
|
||||
square_state: 'partial',
|
||||
configured_channel_count: 2,
|
||||
bound_channels: activeChannels,
|
||||
},
|
||||
{
|
||||
...channel,
|
||||
model_name: 'bare-visible',
|
||||
square_state: 'visible',
|
||||
bound_channels: activeChannels,
|
||||
},
|
||||
{
|
||||
...channel,
|
||||
model_name: 'bare-hidden-by-rule',
|
||||
square_state: 'hidden',
|
||||
bound_channels: activeChannels,
|
||||
},
|
||||
])
|
||||
expect(screen.getByRole('button', { name: 'Display policy' })).toBeVisible()
|
||||
for (const label of ['Unavailable', 'Partly shown']) {
|
||||
const badge = screen.getByText(label).closest('[data-slot="status-badge"]')
|
||||
expect(badge).toHaveClass('text-warning')
|
||||
expect(badge?.querySelector('svg')).toBeInTheDocument()
|
||||
expect(badge).not.toHaveClass('text-success')
|
||||
}
|
||||
const displayed = screen.getAllByText('Displayed')
|
||||
expect(displayed).toHaveLength(2)
|
||||
for (const label of displayed) {
|
||||
expect(label.closest('[data-slot="status-badge"]')).toHaveClass(
|
||||
'text-success'
|
||||
)
|
||||
}
|
||||
const hidden = screen.getAllByText('Listing hidden')
|
||||
expect(hidden).toHaveLength(2)
|
||||
for (const label of hidden) {
|
||||
expect(label.closest('[data-slot="status-badge"]')).toHaveClass(
|
||||
'text-muted-foreground'
|
||||
)
|
||||
}
|
||||
await act(async () => {
|
||||
await i18n.changeLanguage('zhCN')
|
||||
})
|
||||
expect(screen.getByText('无法展示')).toBeVisible()
|
||||
expect(screen.getByText('部分展示')).toBeVisible()
|
||||
expect(screen.getAllByText('正常展示')).toHaveLength(2)
|
||||
expect(screen.getAllByText('已隐藏')).toHaveLength(2)
|
||||
for (const label of ['无法展示', '部分展示']) {
|
||||
expect(screen.getByText(label)).toHaveClass('truncate')
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['{Enter}', ' '])(
|
||||
'opens the reason with %s and returns focus to the status after Escape',
|
||||
async (key) => {
|
||||
await renderList([metadata])
|
||||
const user = userEvent.setup()
|
||||
const trigger = screen.getByRole('button', {
|
||||
name: 'Unavailable',
|
||||
})
|
||||
act(() => trigger.focus())
|
||||
await user.keyboard(key)
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: 'Unavailable' })
|
||||
).toHaveTextContent('No channel is configured.')
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() =>
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
)
|
||||
expect(trigger).toHaveFocus()
|
||||
}
|
||||
)
|
||||
|
||||
it('opens the warning icon’s own model reason and closes it on an outside click without selecting the row', async () => {
|
||||
await renderList([metadata, channel])
|
||||
const user = userEvent.setup()
|
||||
const triggers = screen.getAllByRole('button', {
|
||||
name: 'Unavailable',
|
||||
})
|
||||
const icon = triggers[1].querySelector('svg')
|
||||
expect(icon).not.toBeNull()
|
||||
await user.click(icon as SVGElement)
|
||||
const popup = await screen.findByRole('dialog', { name: 'Unavailable' })
|
||||
expect(popup).toHaveTextContent('No channel is currently available.')
|
||||
expect(popup).not.toHaveTextContent('No channel is configured.')
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: 'Select channel-only' })
|
||||
).not.toBeChecked()
|
||||
await user.click(screen.getByPlaceholderText('Filter by model name...'))
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: 'Unavailable' })
|
||||
).not.toBeInTheDocument()
|
||||
)
|
||||
expect(triggers[1]).toHaveAttribute('aria-expanded', 'false')
|
||||
await user.click(triggers[0])
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: 'Unavailable' })
|
||||
).toHaveTextContent('No channel is configured.')
|
||||
})
|
||||
|
||||
it('opens a long translated reason by touch in the mobile card without requiring the detail drawer', async () => {
|
||||
const originalMatchMedia = window.matchMedia
|
||||
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
|
||||
...originalMatchMedia(query),
|
||||
matches: query === '(max-width: 640px)',
|
||||
}))
|
||||
await renderList([metadata])
|
||||
await act(async () => {
|
||||
await i18n.changeLanguage('fr')
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
const trigger = screen.getByRole('button', {
|
||||
name: i18n.t('Unavailable'),
|
||||
})
|
||||
await user.pointer([
|
||||
{ keys: '[TouchA>]', target: trigger },
|
||||
{ keys: '[/TouchA]' },
|
||||
])
|
||||
const popup = await screen.findByRole('dialog', {
|
||||
name: i18n.t('Unavailable'),
|
||||
})
|
||||
expect(popup).toHaveTextContent(
|
||||
i18n.t(
|
||||
'No channel is configured. This model will not appear in the model square.'
|
||||
)
|
||||
)
|
||||
expect(popup).toHaveClass('whitespace-normal', 'break-words')
|
||||
expect(popup).toHaveClass('max-w-[calc(100vw-2rem)]')
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: metadata.model_name })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'legacy',
|
||||
effective: { ModelRatio: 1.5, CompletionRatio: 5, CacheRatio: 0.1 },
|
||||
catalog: { model_ratio: 1.5, completion_ratio: 5 },
|
||||
text: 'Input3Output15',
|
||||
},
|
||||
{
|
||||
name: 'single-expression',
|
||||
effective: {
|
||||
'billing_setting.billing_mode': 'tiered_expr',
|
||||
'billing_setting.billing_expr': 'tier("base", p * 3 + c * 15 + cr * 0.3)',
|
||||
},
|
||||
catalog: {
|
||||
billing_mode: 'tiered_expr',
|
||||
billing_expr: 'tier("base", p * 3 + c * 15 + cr * 0.3)',
|
||||
},
|
||||
text: 'Input3Output15',
|
||||
},
|
||||
{
|
||||
name: 'tier-expression',
|
||||
effective: {
|
||||
'billing_setting.billing_mode': 'tiered_expr',
|
||||
'billing_setting.billing_expr':
|
||||
'len <= 200000 ? tier("standard", p * 3 + c * 15) : tier("long", p * 6 + c * 22.5)',
|
||||
},
|
||||
catalog: {
|
||||
billing_mode: 'tiered_expr',
|
||||
billing_expr:
|
||||
'len <= 200000 ? tier("standard", p * 3 + c * 15) : tier("long", p * 6 + c * 22.5)',
|
||||
},
|
||||
text: 'Input3Output15',
|
||||
},
|
||||
{
|
||||
name: 'free-request',
|
||||
effective: { ModelPrice: 0 },
|
||||
catalog: { quota_type: 1, model_price: 0 },
|
||||
text: 'Per-request0USD/request',
|
||||
},
|
||||
{
|
||||
name: 'free-tokens',
|
||||
effective: { ModelRatio: 0, CompletionRatio: 2 },
|
||||
catalog: { model_ratio: 0, completion_ratio: 2 },
|
||||
text: 'Input0Output0',
|
||||
},
|
||||
{
|
||||
name: 'free-expression',
|
||||
effective: {
|
||||
'billing_setting.billing_mode': 'tiered_expr',
|
||||
'billing_setting.billing_expr': 'tier("free", p * 0 + c * 0)',
|
||||
},
|
||||
catalog: {
|
||||
billing_mode: 'tiered_expr',
|
||||
billing_expr: 'tier("free", p * 0 + c * 0)',
|
||||
},
|
||||
text: 'Input0Output0',
|
||||
},
|
||||
])(
|
||||
'shows $name effective pricing like the catalog without requiring a listed model',
|
||||
async ({ name, effective, catalog, text }) => {
|
||||
const model = { ...channel, model_name: name }
|
||||
await renderList([model], {
|
||||
pricing: [{ model_name: name, version: 'v1', configured: {}, effective }],
|
||||
})
|
||||
const button = screen.getByRole('button', {
|
||||
name: `View pricing for ${name}`,
|
||||
})
|
||||
expect(button.textContent?.replaceAll(/\s/g, '')).toContain(text)
|
||||
expect(button).not.toHaveTextContent('Unset price')
|
||||
expect(button).not.toHaveTextContent('Cache')
|
||||
render(
|
||||
<CatalogPrice
|
||||
model={{
|
||||
id: 1,
|
||||
model_name: name,
|
||||
quota_type: 0,
|
||||
model_ratio: 0,
|
||||
completion_ratio: 0,
|
||||
enable_groups: [],
|
||||
...catalog,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
expect(button.textContent).toBe(
|
||||
screen.getByRole('group', { name: 'Catalog price' }).textContent
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('shows task tier ranges in the schema unit and converts site currency only once', async () => {
|
||||
useSystemConfigStore.getState().setConfig({
|
||||
currency: {
|
||||
...DEFAULT_CURRENCY_CONFIG,
|
||||
quotaDisplayType: 'CNY',
|
||||
usdExchangeRate: 7,
|
||||
},
|
||||
})
|
||||
const expression =
|
||||
'u("mode") == "pro" ? tier("pro", u("seconds") * 0.8) : tier("std", u("seconds") * 0.4)'
|
||||
const schema = {
|
||||
seconds: { type: 'number' as const, unit: 'second' as const },
|
||||
mode: { enum: ['std', 'pro'] },
|
||||
}
|
||||
await renderList([channel], {
|
||||
pricing: [
|
||||
{
|
||||
model_name: channel.model_name,
|
||||
version: 'v1',
|
||||
configured: {},
|
||||
effective: {
|
||||
'billing_setting.billing_mode': 'tiered_expr',
|
||||
'billing_setting.billing_expr': expression,
|
||||
},
|
||||
usage_schema: schema,
|
||||
},
|
||||
],
|
||||
})
|
||||
const button = screen.getByRole('button', {
|
||||
name: 'View pricing for channel-only',
|
||||
})
|
||||
expect(button).toHaveTextContent(/2.8.*5.6/)
|
||||
expect(button).toHaveTextContent('/s')
|
||||
expect(button).toHaveTextContent('CNY')
|
||||
expect(button).not.toHaveTextContent('¥')
|
||||
expect(button).not.toHaveTextContent('1M tokens')
|
||||
})
|
||||
|
||||
it('opens the effective expression breakdown from the price without creating metadata', async () => {
|
||||
const expression =
|
||||
'(len <= 200000 ? tier("standard", p * 3 + c * 15 + cr * 0.3) : tier("long", p * 6 + c * 22.5 + cr * 0.6)) * (header("x-priority") == "high" ? 2 : 1)'
|
||||
await renderList([channel], {
|
||||
pricing: [
|
||||
{
|
||||
model_name: channel.model_name,
|
||||
version: 'v1',
|
||||
configured: {},
|
||||
effective: {
|
||||
'billing_setting.billing_mode': 'tiered_expr',
|
||||
'billing_setting.billing_expr': expression,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const write = vi.spyOn(api, 'post')
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'View pricing for channel-only' })
|
||||
)
|
||||
const preview = await screen.findByRole('region', { name: 'Current Billing' })
|
||||
expect(preview).toHaveTextContent('standard')
|
||||
expect(preview).toHaveTextContent('long')
|
||||
expect(preview).toHaveTextContent('0.3')
|
||||
expect(preview).toHaveTextContent('0.6')
|
||||
expect(preview).toHaveTextContent('x-priority')
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows an unrecognized expression as special and retains its full source in pricing details', async () => {
|
||||
const expression = 'tier("custom", max(p * 2 + c * 8, 100))'
|
||||
await renderList([channel], {
|
||||
pricing: [
|
||||
{
|
||||
model_name: channel.model_name,
|
||||
version: 'v1',
|
||||
configured: {},
|
||||
effective: {
|
||||
'billing_setting.billing_mode': 'tiered_expr',
|
||||
'billing_setting.billing_expr': expression,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const button = screen.getByRole('button', {
|
||||
name: 'View pricing for channel-only',
|
||||
})
|
||||
expect(button).toHaveTextContent('Special billing expression')
|
||||
expect(button).not.toHaveTextContent('$2')
|
||||
expect(button).not.toHaveTextContent(expression)
|
||||
await userEvent.click(button)
|
||||
const preview = await screen.findByRole('region', { name: 'Current Billing' })
|
||||
expect(preview).toHaveTextContent(expression)
|
||||
})
|
||||
|
||||
it('distinguishes pending and failed pricing requests from an unset price', async () => {
|
||||
let rejectPricing: (reason: Error) => void = () => {}
|
||||
const pricing = new Promise<ModelPricingConfig>((_resolve, reject) => {
|
||||
rejectPricing = reject
|
||||
})
|
||||
await renderList([channel], { pricing, waitForPricing: false })
|
||||
expect(screen.getByText('Loading...')).toBeVisible()
|
||||
expect(screen.queryByText('Unset price')).not.toBeInTheDocument()
|
||||
await act(async () => rejectPricing(new Error('pricing unavailable')))
|
||||
expect(await screen.findByText('Failed to load model pricing')).toBeVisible()
|
||||
expect(screen.queryByText('Unset price')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters actual visibility independently of policy and restores filters through browser history', async () => {
|
||||
const { get, router } = await renderList([channel], {
|
||||
initialUrl: '/models/metadata?page=3&status=%5B%22enabled%22%5D',
|
||||
total: 100,
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Model square visibility' })
|
||||
)
|
||||
await user.click(screen.getByRole('option', { name: 'Partly shown' }))
|
||||
await waitFor(() =>
|
||||
expect(get).toHaveBeenCalledWith('/api/models/search', {
|
||||
params: expect.objectContaining({
|
||||
status: 'enabled',
|
||||
square_state: 'partial',
|
||||
p: 1,
|
||||
}),
|
||||
})
|
||||
)
|
||||
expect(router.state.location.search).toMatchObject({
|
||||
status: ['enabled'],
|
||||
square_state: ['partial'],
|
||||
})
|
||||
await user.keyboard('{Escape}')
|
||||
await act(async () => {
|
||||
router.history.back()
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(router.state.location.search).not.toHaveProperty('square_state')
|
||||
)
|
||||
await act(async () => {
|
||||
router.history.forward()
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(router.state.location.search).toMatchObject({
|
||||
square_state: ['partial'],
|
||||
})
|
||||
)
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /Model square visibility/ })
|
||||
)
|
||||
await user.click(screen.getByRole('option', { name: 'Clear filters' }))
|
||||
await waitFor(() =>
|
||||
expect(router.state.location.search).not.toHaveProperty('square_state')
|
||||
)
|
||||
expect(router.state.location.search).toMatchObject({ status: ['enabled'] })
|
||||
})
|
||||
|
||||
it('keeps all columns while collapsing tags and connection counts', async () => {
|
||||
await renderList([
|
||||
{
|
||||
...channel,
|
||||
tags: 'Tools,Files,Vision',
|
||||
bound_channels: [{ name: 'Main', type: 1 }],
|
||||
enable_groups: ['default', 'premium'],
|
||||
},
|
||||
])
|
||||
expect(screen.getByText('Channels 1 · Groups 2')).toBeVisible()
|
||||
expect(
|
||||
screen.getByRole('columnheader', { name: 'Sync policy' })
|
||||
).toBeVisible()
|
||||
expect(screen.getByRole('columnheader', { name: 'Tags' })).toBeVisible()
|
||||
expect(screen.getByText('Tools')).toBeVisible()
|
||||
expect(screen.queryByText('Files')).not.toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Show all tags' }))
|
||||
expect(await screen.findByText('Files')).toBeVisible()
|
||||
expect(screen.getByText('Vision')).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps an active visibility filter when its server result is empty', async () => {
|
||||
const { get } = await renderList([], {
|
||||
initialUrl: '/models/metadata?square_state=%5B%22unavailable%22%5D',
|
||||
})
|
||||
expect(screen.getByText('No Models Found')).toBeVisible()
|
||||
expect(screen.getByText('Try adjusting your search')).toBeVisible()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Model square visibility.*Unavailable/ })
|
||||
).toBeVisible()
|
||||
expect(get).toHaveBeenCalledWith('/api/models/search', {
|
||||
params: expect.objectContaining({ square_state: 'unavailable', p: 1 }),
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ type: 'CUSTOM' as const, caption: '🐱 / 1M tokens' },
|
||||
{ type: 'TOKENS' as const, caption: 'USD / 1M tokens' },
|
||||
])(
|
||||
'uses one currency caption in $type mode without replacing prices with quota counts',
|
||||
async ({ type, caption }) => {
|
||||
useSystemConfigStore.getState().setConfig({
|
||||
currency: {
|
||||
...DEFAULT_CURRENCY_CONFIG,
|
||||
quotaDisplayType: type,
|
||||
customCurrencySymbol: '🐱',
|
||||
customCurrencyExchangeRate: 1,
|
||||
},
|
||||
})
|
||||
await renderList([channel], {
|
||||
pricing: [
|
||||
{
|
||||
model_name: channel.model_name,
|
||||
version: 'v1',
|
||||
configured: {},
|
||||
effective: { ModelRatio: 0.25, CompletionRatio: 2 },
|
||||
},
|
||||
],
|
||||
})
|
||||
const button = screen.getByRole('button', {
|
||||
name: 'View pricing for channel-only',
|
||||
})
|
||||
expect(button.textContent?.replaceAll(/\s/g, '')).toContain(
|
||||
'Input0.5Output1'
|
||||
)
|
||||
expect(within(button).getByText(caption)).toBeVisible()
|
||||
if (type === 'CUSTOM') {
|
||||
expect(button.textContent?.match(/🐱/g)).toHaveLength(1)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -25,24 +25,27 @@ import { BadgeListCell, TruncatedCell } from '@/components/data-table'
|
||||
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>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -268,9 +279,18 @@ export function useModelsColumns(
|
||||
size: canPrice ? 170 : 105,
|
||||
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> = {
|
||||
'<': '<',
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
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
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"{{count}} vendors": "{{count}} vendors",
|
||||
"{{count}} weeks ago": "{{count}} weeks ago",
|
||||
"{{created}} models created, {{updated}} models updated, {{vendors}} vendors created.": "{{created}} models created, {{updated}} models updated, {{vendors}} vendors created.",
|
||||
"{{currency}} / {{unit}} tokens": "{{currency}} / {{unit}} tokens",
|
||||
"{{currency}} price per 1M input tokens.": "{{currency}} price per 1M input tokens.",
|
||||
"{{currency}} price per 1M tokens.": "{{currency}} price per 1M tokens.",
|
||||
"{{field}} updated to {{value}}": "{{field}} updated to {{value}}",
|
||||
@@ -233,6 +234,8 @@
|
||||
"Add group rules": "Add group rules",
|
||||
"Add management route": "Add management route",
|
||||
"Add Mapping": "Add Mapping",
|
||||
"Add metadata": "Add metadata",
|
||||
"Add metadata to all selected models first.": "Add metadata to all selected models first.",
|
||||
"Add method": "Add method",
|
||||
"Add missing models": "Add missing models",
|
||||
"Add Mode": "Add Mode",
|
||||
@@ -380,6 +383,7 @@
|
||||
"Allow inference geography passthrough": "Allow inference geography passthrough",
|
||||
"Allow inference_geo passthrough": "Allow inference_geo passthrough",
|
||||
"Allow Insecure Origins": "Allow Insecure Origins",
|
||||
"Allow listing when a channel is available and the user has group access. This does not change API access.": "Allow listing when a channel is available and the user has group access. This does not change API access.",
|
||||
"Allow metadata sync": "Allow metadata sync",
|
||||
"Allow new users to register": "Allow new users to register",
|
||||
"Allow Private IPs": "Allow Private IPs",
|
||||
@@ -634,6 +638,7 @@
|
||||
"Automatically sync model list when upstream changes are detected": "Automatically sync model list when upstream changes are detected",
|
||||
"Availability (last 24h)": "Availability (last 24h)",
|
||||
"Available": "Available",
|
||||
"Available channels: {{count}}": "Available channels: {{count}}",
|
||||
"Available credits are ordered by soonest expiration.": "Available credits are ordered by soonest expiration.",
|
||||
"Available disk space": "Available disk space",
|
||||
"Available Models": "Available Models",
|
||||
@@ -910,6 +915,7 @@
|
||||
"Channel:": "Channel:",
|
||||
"channel(s)? This action cannot be undone.": "channel(s)? This action cannot be undone.",
|
||||
"Channels": "Channels",
|
||||
"Channels {{channels}} · Groups {{groups}}": "Channels {{channels}} · Groups {{groups}}",
|
||||
"Channels and groups": "Channels and groups",
|
||||
"Channels deleted successfully": "Channels deleted successfully",
|
||||
"Character chat, storytelling, persona": "Character chat, storytelling, persona",
|
||||
@@ -1658,7 +1664,9 @@
|
||||
"Display name for this payment method.": "Display name for this payment method.",
|
||||
"Display name shown to users.": "Display name shown to users.",
|
||||
"Display Options": "Display Options",
|
||||
"Display policy": "Display policy",
|
||||
"Display Token Statistics": "Display Token Statistics",
|
||||
"Displayed": "Displayed",
|
||||
"Displayed in": "Displayed in",
|
||||
"Displays the mobile sidebar.": "Displays the mobile sidebar.",
|
||||
"Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.": "Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.",
|
||||
@@ -2513,6 +2521,7 @@
|
||||
"Hidden {{count}} models from model square": "Hidden {{count}} models from model square",
|
||||
"Hidden from {{group}}": "Hidden from {{group}}",
|
||||
"Hidden from catalog": "Hidden from catalog",
|
||||
"Hidden from the model square by metadata policy.": "Hidden from the model square by metadata policy.",
|
||||
"Hide": "Hide",
|
||||
"Hide API key": "Hide API key",
|
||||
"Hide from catalog": "Hide from catalog",
|
||||
@@ -2869,6 +2878,8 @@
|
||||
"List of origins (one per line) allowed for Passkey registration and authentication.": "List of origins (one per line) allowed for Passkey registration and authentication.",
|
||||
"List registered task plugins and bind them when creating or editing task plugin channels.": "List registered task plugins and bind them when creating or editing task plugin channels.",
|
||||
"List view": "List view",
|
||||
"Listing also depends on metadata visibility and the user’s group access.": "Listing also depends on metadata visibility and the user’s group access.",
|
||||
"Listing hidden": "Listing hidden",
|
||||
"Live refresh pauses when no task is running": "Live refresh pauses when no task is running",
|
||||
"LLM Leaderboard": "LLM Leaderboard",
|
||||
"LLM prompt helper": "LLM prompt helper",
|
||||
@@ -3021,6 +3032,7 @@
|
||||
"Metadata": "Metadata",
|
||||
"Metadata changes": "Metadata changes",
|
||||
"Metadata language": "Metadata language",
|
||||
"Metadata only": "Metadata only",
|
||||
"Metadata sync completed": "Metadata sync completed",
|
||||
"Metadata sync disabled": "Metadata sync disabled",
|
||||
"Metadata sync failed": "Metadata sync failed",
|
||||
@@ -3046,6 +3058,7 @@
|
||||
"Minute": "Minute",
|
||||
"minutes": "minutes",
|
||||
"Missing code": "Missing code",
|
||||
"Missing metadata": "Missing metadata",
|
||||
"Missing Models": "Missing Models",
|
||||
"Missing user data from Passkey login response": "Missing user data from Passkey login response",
|
||||
"Mistral": "Mistral",
|
||||
@@ -3298,6 +3311,8 @@
|
||||
"No changes": "No changes",
|
||||
"No changes made": "No changes made",
|
||||
"No changes to save": "No changes to save",
|
||||
"No channel is configured. This model will not appear in the model square.": "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.": "No channel is currently available. This model will not appear in the model square.",
|
||||
"No channel selected": "No channel selected",
|
||||
"No channel type found.": "No channel type found.",
|
||||
"No channels available. Create your first channel to get started.": "No channels available. Create your first channel to get started.",
|
||||
@@ -3306,6 +3321,7 @@
|
||||
"No channels selected": "No channels selected",
|
||||
"No chat presets configured. Click \"Add chat preset\" to get started.": "No chat presets configured. Click \"Add chat preset\" to get started.",
|
||||
"No chat presets match your search": "No chat presets match your search",
|
||||
"No configured channel models match this metadata rule.": "No configured channel models match this metadata rule.",
|
||||
"No conflict entries available.": "No conflict entries available.",
|
||||
"No conflicts match your search.": "No conflicts match your search.",
|
||||
"No connection info found in clipboard": "No connection info found in clipboard",
|
||||
@@ -3356,6 +3372,7 @@
|
||||
"No mappings configured. Click \"Add Row\" to get started.": "No mappings configured. Click \"Add Row\" to get started.",
|
||||
"No marketplace sources configured.": "No marketplace sources configured.",
|
||||
"No matches found": "No matches found",
|
||||
"No matching channels": "No matching channels",
|
||||
"No matching icons": "No matching icons",
|
||||
"No matching items": "No matching items",
|
||||
"No matching results": "No matching results",
|
||||
@@ -3485,6 +3502,7 @@
|
||||
"Not in pricing table": "Not in pricing table",
|
||||
"Not included": "Not included",
|
||||
"Not installed": "Not installed",
|
||||
"Not listed": "Not listed",
|
||||
"Not provided by this source": "Not provided by this source",
|
||||
"Not recorded": "Not recorded",
|
||||
"Not registered": "Not registered",
|
||||
@@ -3726,6 +3744,7 @@
|
||||
"Parsed {{count}} service account file(s)": "Parsed {{count}} service account file(s)",
|
||||
"Parsed plugin metadata": "Parsed plugin metadata",
|
||||
"Partial Submission": "Partial Submission",
|
||||
"Partly shown": "Partly shown",
|
||||
"Pass Headers": "Pass Headers",
|
||||
"Pass request body directly to upstream": "Pass request body directly to upstream",
|
||||
"Pass specified request headers to upstream": "Pass specified request headers to upstream",
|
||||
@@ -4394,6 +4413,7 @@
|
||||
"Required events:": "Required events:",
|
||||
"Required provider, authentication, model, and group settings": "Required provider, authentication, model, and group settings",
|
||||
"Required to expose MjProxy-style image generation to end users.": "Required to expose MjProxy-style image generation to end users.",
|
||||
"Requires an available channel and access to its group.": "Requires an available channel and access to its group.",
|
||||
"Rerank": "Rerank",
|
||||
"Reroll": "Reroll",
|
||||
"Research, analysis, scientific reasoning": "Research, analysis, scientific reasoning",
|
||||
@@ -4894,6 +4914,7 @@
|
||||
"socks5://user:pass@host:port": "socks5://user:pass@host:port",
|
||||
"Soft Errors": "Soft Errors",
|
||||
"Some channels failed: {{errorMsg}}": "Some channels failed: {{errorMsg}}",
|
||||
"Some matching models are hidden or have no available channel.": "Some matching models are hidden or have no available channel.",
|
||||
"Some plugin information could not be read": "Some plugin information could not be read",
|
||||
"Something went wrong!": "Something went wrong!",
|
||||
"Sonnet Model": "Sonnet Model",
|
||||
@@ -5561,6 +5582,7 @@
|
||||
"Unable to read clipboard": "Unable to read clipboard",
|
||||
"Unauthorized": "Unauthorized",
|
||||
"Unauthorized Access": "Unauthorized Access",
|
||||
"Unavailable": "Unavailable",
|
||||
"Unbind": "Unbind",
|
||||
"Unbind failed": "Unbind failed",
|
||||
"Unbound {{provider}}": "Unbound {{provider}}",
|
||||
@@ -5899,6 +5921,7 @@
|
||||
"View model statistics and charts": "View model statistics and charts",
|
||||
"View other accounts' audit logs": "View other accounts' audit logs",
|
||||
"View Pricing": "View Pricing",
|
||||
"View pricing for {{model}}": "View pricing for {{model}}",
|
||||
"View scope": "View scope",
|
||||
"View the complete details for this": "View the complete details for this",
|
||||
"View the complete details for this log entry": "View the complete details for this log entry",
|
||||
@@ -5915,6 +5938,7 @@
|
||||
"vip": "vip",
|
||||
"VIP users with premium access": "VIP users with premium access",
|
||||
"Visible": "Visible",
|
||||
"Visible only to users with access to the model’s groups.": "Visible only to users with access to the model’s groups.",
|
||||
"Vision": "Vision",
|
||||
"Vision, image / video, document chat": "Vision, image / video, document chat",
|
||||
"Visit Settings → General and adjust quota options...": "Visit Settings → General and adjust quota options...",
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"{{count}} vendors": "{{count}} fournisseurs",
|
||||
"{{count}} weeks ago": "il y a {{count}} semaines",
|
||||
"{{created}} models created, {{updated}} models updated, {{vendors}} vendors created.": "{{created}} modèles créés, {{updated}} modèles mis à jour, {{vendors}} fournisseurs créés.",
|
||||
"{{currency}} / {{unit}} tokens": "{{currency}} / {{unit}} jetons",
|
||||
"{{currency}} price per 1M input tokens.": "Prix en {{currency}} par million de tokens en entrée.",
|
||||
"{{currency}} price per 1M tokens.": "Prix en {{currency}} par million de tokens.",
|
||||
"{{field}} updated to {{value}}": "{{field}} mis à jour en {{value}}",
|
||||
@@ -233,6 +234,8 @@
|
||||
"Add group rules": "Ajouter des règles de groupe",
|
||||
"Add management route": "Ajouter une route de gestion",
|
||||
"Add Mapping": "Ajouter un mappage",
|
||||
"Add metadata": "Compléter",
|
||||
"Add metadata to all selected models first.": "Complétez d’abord les métadonnées de tous les modèles sélectionnés.",
|
||||
"Add method": "Ajouter une méthode",
|
||||
"Add missing models": "Ajouter les modèles manquants",
|
||||
"Add Mode": "Ajouter un mode",
|
||||
@@ -380,6 +383,7 @@
|
||||
"Allow inference geography passthrough": "Autoriser le passage de la géographie d'inférence",
|
||||
"Allow inference_geo passthrough": "Autoriser la transmission de inference_geo",
|
||||
"Allow Insecure Origins": "Autoriser les origines non sécurisées",
|
||||
"Allow listing when a channel is available and the user has group access. This does not change API access.": "Autorise l’affichage si un canal est disponible et son groupe accessible. Ne modifie pas l’accès API.",
|
||||
"Allow metadata sync": "Autoriser la synchronisation des métadonnées",
|
||||
"Allow new users to register": "Autoriser les nouveaux utilisateurs à s'inscrire",
|
||||
"Allow Private IPs": "Autoriser les IP privées",
|
||||
@@ -634,6 +638,7 @@
|
||||
"Automatically sync model list when upstream changes are detected": "Synchroniser automatiquement la liste des modèles lorsque des changements en amont sont détectés",
|
||||
"Availability (last 24h)": "Disponibilité (dernières 24 h)",
|
||||
"Available": "Disponible",
|
||||
"Available channels: {{count}}": "Canaux : {{count}}",
|
||||
"Available credits are ordered by soonest expiration.": "Les crédits disponibles sont triés par expiration la plus proche.",
|
||||
"Available disk space": "Espace disque disponible",
|
||||
"Available Models": "Modèles disponibles",
|
||||
@@ -910,6 +915,7 @@
|
||||
"Channel:": "Canal :",
|
||||
"channel(s)? This action cannot be undone.": "canal(aux) ? Cette action ne peut pas être annulée.",
|
||||
"Channels": "Canaux",
|
||||
"Channels {{channels}} · Groups {{groups}}": "Canaux {{channels}} · Groupes {{groups}}",
|
||||
"Channels and groups": "Canaux et groupes",
|
||||
"Channels deleted successfully": "Canaux supprimés avec succès",
|
||||
"Character chat, storytelling, persona": "Discussion de personnages, narration, persona",
|
||||
@@ -1658,7 +1664,9 @@
|
||||
"Display name for this payment method.": "Nom d'affichage pour ce mode de paiement.",
|
||||
"Display name shown to users.": "Nom d'affichage affiché aux utilisateurs.",
|
||||
"Display Options": "Options d'affichage",
|
||||
"Display policy": "Règle d’affichage",
|
||||
"Display Token Statistics": "Afficher les statistiques des jetons",
|
||||
"Displayed": "Affiché",
|
||||
"Displayed in": "Affiché en",
|
||||
"Displays the mobile sidebar.": "Affiche la barre latérale mobile.",
|
||||
"Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.": "Ne faites pas trop confiance à cette fonctionnalité. L'IP peut être usurpée. Veuillez l'utiliser avec nginx, CDN et autres passerelles.",
|
||||
@@ -2513,6 +2521,7 @@
|
||||
"Hidden {{count}} models from model square": "{{count}} modèles masqués dans la galerie",
|
||||
"Hidden from {{group}}": "Masqué pour {{group}}",
|
||||
"Hidden from catalog": "Masqué du catalogue",
|
||||
"Hidden from the model square by metadata policy.": "Masqué dans le catalogue par la politique de métadonnées.",
|
||||
"Hide": "Masquer",
|
||||
"Hide API key": "Masquer la clé API",
|
||||
"Hide from catalog": "Masquer du catalogue",
|
||||
@@ -2869,6 +2878,8 @@
|
||||
"List of origins (one per line) allowed for Passkey registration and authentication.": "Liste des origines (une par ligne) autorisées pour l'enregistrement et l'authentification des clés d'accès (Passkey).",
|
||||
"List registered task plugins and bind them when creating or editing task plugin channels.": "Lister les plugins de tâche enregistrés et les lier à la création ou à la modification de canaux.",
|
||||
"List view": "Vue en liste",
|
||||
"Listing also depends on metadata visibility and the user’s group access.": "L’affichage dépend aussi de la visibilité des métadonnées et des groupes accessibles à l’utilisateur.",
|
||||
"Listing hidden": "Masqué",
|
||||
"Live refresh pauses when no task is running": "L'actualisation en direct est suspendue lorsqu'aucune tâche n'est en cours",
|
||||
"LLM Leaderboard": "Classement des LLM",
|
||||
"LLM prompt helper": "Assistant prompt LLM",
|
||||
@@ -3021,6 +3032,7 @@
|
||||
"Metadata": "Métadonnées",
|
||||
"Metadata changes": "Modifications des métadonnées",
|
||||
"Metadata language": "Langue des métadonnées",
|
||||
"Metadata only": "Métadonnées seules",
|
||||
"Metadata sync completed": "Métadonnées synchronisées",
|
||||
"Metadata sync disabled": "Synchronisation désactivée",
|
||||
"Metadata sync failed": "Échec de la synchronisation",
|
||||
@@ -3046,6 +3058,7 @@
|
||||
"Minute": "Minute",
|
||||
"minutes": "minutes",
|
||||
"Missing code": "Code manquant",
|
||||
"Missing metadata": "Sans métadonnées",
|
||||
"Missing Models": "Modèles manquants",
|
||||
"Missing user data from Passkey login response": "Données utilisateur manquantes de la réponse de connexion Passkey",
|
||||
"Mistral": "Mistral",
|
||||
@@ -3117,7 +3130,7 @@
|
||||
"Model selected": "Modèle sélectionné",
|
||||
"Model shown in model square": "Modèle affiché dans la galerie",
|
||||
"Model Square": "Place des modèles",
|
||||
"Model square visibility": "Visibilité dans la galerie",
|
||||
"Model square visibility": "Visibilité",
|
||||
"Model Tags": "Tags de modèle",
|
||||
"Model to use for testing": "Modèle à utiliser pour les tests",
|
||||
"Model to use when testing channel connectivity": "Modèle à utiliser lors du test de la connectivité du canal",
|
||||
@@ -3298,6 +3311,8 @@
|
||||
"No changes": "Aucune modification",
|
||||
"No changes made": "Aucune modification effectuée",
|
||||
"No changes to save": "Aucune modification à enregistrer",
|
||||
"No channel is configured. This model will not appear in the model square.": "Aucun canal configuré. Ce modèle ne figurera pas dans le catalogue.",
|
||||
"No channel is currently available. This model will not appear in the model square.": "Aucun canal disponible. Ce modèle ne figure pas dans le catalogue.",
|
||||
"No channel selected": "Aucun canal sélectionné",
|
||||
"No channel type found.": "Aucun type de canal trouvé.",
|
||||
"No channels available. Create your first channel to get started.": "Aucun canal disponible. Créez votre premier canal pour commencer.",
|
||||
@@ -3306,6 +3321,7 @@
|
||||
"No channels selected": "Aucun canal sélectionné",
|
||||
"No chat presets configured. Click \"Add chat preset\" to get started.": "Aucun préréglage de chat configuré. Cliquez sur \"Ajouter un préréglage de chat\" pour commencer.",
|
||||
"No chat presets match your search": "Aucun préréglage de chat ne correspond à votre recherche",
|
||||
"No configured channel models match this metadata rule.": "Aucun modèle de canal ne correspond à cette règle de métadonnées.",
|
||||
"No conflict entries available.": "Aucune entrée de conflit disponible.",
|
||||
"No conflicts match your search.": "Aucun conflit ne correspond à votre recherche.",
|
||||
"No connection info found in clipboard": "Aucune info de connexion trouvée dans le presse-papiers",
|
||||
@@ -3356,6 +3372,7 @@
|
||||
"No mappings configured. Click \"Add Row\" to get started.": "Aucun mappage configuré. Cliquez sur « Ajouter une ligne » pour commencer.",
|
||||
"No marketplace sources configured.": "Aucune source de marché configurée.",
|
||||
"No matches found": "Aucune correspondance trouvée",
|
||||
"No matching channels": "Aucun canal associé",
|
||||
"No matching icons": "Aucune icône correspondante",
|
||||
"No matching items": "Aucun élément correspondant",
|
||||
"No matching results": "Aucun résultat correspondant",
|
||||
@@ -3485,6 +3502,7 @@
|
||||
"Not in pricing table": "Absent du tableau tarifaire",
|
||||
"Not included": "Non inclus",
|
||||
"Not installed": "Non installé",
|
||||
"Not listed": "Masqué",
|
||||
"Not provided by this source": "Non fourni par cette source",
|
||||
"Not recorded": "Non enregistré",
|
||||
"Not registered": "Non enregistré",
|
||||
@@ -3726,6 +3744,7 @@
|
||||
"Parsed {{count}} service account file(s)": "{{count}} fichier(s) de compte de service analysé(s)",
|
||||
"Parsed plugin metadata": "Métadonnées du plugin analysées",
|
||||
"Partial Submission": "Soumission partielle",
|
||||
"Partly shown": "Affichage partiel",
|
||||
"Pass Headers": "Transmettre les en-têtes",
|
||||
"Pass request body directly to upstream": "Transmettre le corps de la requête directement à l'upstream",
|
||||
"Pass specified request headers to upstream": "Transmettre les en-têtes de requête spécifiés en amont",
|
||||
@@ -4394,6 +4413,7 @@
|
||||
"Required events:": "Événements requis :",
|
||||
"Required provider, authentication, model, and group settings": "Paramètres requis de fournisseur, authentification, modèles et groupes",
|
||||
"Required to expose MjProxy-style image generation to end users.": "Requis pour exposer la génération d'images style MjProxy aux utilisateurs finaux.",
|
||||
"Requires an available channel and access to its group.": "Nécessite un canal disponible et l’accès à son groupe.",
|
||||
"Rerank": "Reclasser",
|
||||
"Reroll": "Relancer",
|
||||
"Research, analysis, scientific reasoning": "Recherche, analyse, raisonnement scientifique",
|
||||
@@ -4894,6 +4914,7 @@
|
||||
"socks5://user:pass@host:port": "socks5://user:pass@host:port",
|
||||
"Soft Errors": "Erreurs douces",
|
||||
"Some channels failed: {{errorMsg}}": "Certains canaux ont échoué : {{errorMsg}}",
|
||||
"Some matching models are hidden or have no available channel.": "Certains modèles correspondants sont masqués ou n’ont aucun canal disponible.",
|
||||
"Some plugin information could not be read": "Certaines informations du plugin sont illisibles",
|
||||
"Something went wrong!": "Quelque chose s'est mal passé !",
|
||||
"Sonnet Model": "Modèle Sonnet",
|
||||
@@ -5561,6 +5582,7 @@
|
||||
"Unable to read clipboard": "Impossible de lire le presse-papiers",
|
||||
"Unauthorized": "Non autorisé",
|
||||
"Unauthorized Access": "Accès non autorisé",
|
||||
"Unavailable": "Indisponible",
|
||||
"Unbind": "Dissocier",
|
||||
"Unbind failed": "Échec de la dissociation",
|
||||
"Unbound {{provider}}": "{{provider}} dissocié",
|
||||
@@ -5899,6 +5921,7 @@
|
||||
"View model statistics and charts": "Afficher les statistiques et graphiques des modèles",
|
||||
"View other accounts' audit logs": "Voir les journaux d’audit des autres comptes",
|
||||
"View Pricing": "Voir les tarifs",
|
||||
"View pricing for {{model}}": "Voir les tarifs de {{model}}",
|
||||
"View scope": "Portée de la vue",
|
||||
"View the complete details for this": "Voir les détails complets de ce",
|
||||
"View the complete details for this log entry": "Voir les détails complets de cette entrée de journal",
|
||||
@@ -5915,6 +5938,7 @@
|
||||
"vip": "vip",
|
||||
"VIP users with premium access": "Utilisateurs VIP avec accès premium",
|
||||
"Visible": "Visible",
|
||||
"Visible only to users with access to the model’s groups.": "Visible uniquement aux utilisateurs ayant accès aux groupes du modèle.",
|
||||
"Vision": "Vision",
|
||||
"Vision, image / video, document chat": "Vision, image / vidéo, conversation sur document",
|
||||
"Visit Settings → General and adjust quota options...": "Visitez Paramètres → Général et ajustez les options de quota...",
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"{{count}} vendors": "{{count}} ベンダー",
|
||||
"{{count}} weeks ago": "{{count}} 週間前",
|
||||
"{{created}} models created, {{updated}} models updated, {{vendors}} vendors created.": "モデル {{created}} 件を作成、{{updated}} 件を更新、ベンダー {{vendors}} 件を作成しました。",
|
||||
"{{currency}} / {{unit}} tokens": "{{currency}} / {{unit}} トークン",
|
||||
"{{currency}} price per 1M input tokens.": "入力100万トークンあたりの価格({{currency}})。",
|
||||
"{{currency}} price per 1M tokens.": "100万トークンあたりの価格({{currency}})。",
|
||||
"{{field}} updated to {{value}}": "{{field}} を {{value}} に更新しました",
|
||||
@@ -233,6 +234,8 @@
|
||||
"Add group rules": "グループルールを追加",
|
||||
"Add management route": "管理ルートを追加",
|
||||
"Add Mapping": "マッピングを追加",
|
||||
"Add metadata": "情報追加",
|
||||
"Add metadata to all selected models first.": "選択したすべてのモデルにメタデータを追加してください。",
|
||||
"Add method": "メソッドを追加",
|
||||
"Add missing models": "不足しているモデルを追加",
|
||||
"Add Mode": "モードを追加",
|
||||
@@ -380,6 +383,7 @@
|
||||
"Allow inference geography passthrough": "推論ジオグラフィのパススルーを許可",
|
||||
"Allow inference_geo passthrough": "inference_geo パススルーを許可",
|
||||
"Allow Insecure Origins": "安全でないオリジンを許可",
|
||||
"Allow listing when a channel is available and the user has group access. This does not change API access.": "表示を許可しても、利用可能なチャネルとグループへのアクセス権が必要です。API のアクセス権は変更されません。",
|
||||
"Allow metadata sync": "メタデータの同期を許可",
|
||||
"Allow new users to register": "新規ユーザーの登録を許可",
|
||||
"Allow Private IPs": "プライベートIPを許可",
|
||||
@@ -634,6 +638,7 @@
|
||||
"Automatically sync model list when upstream changes are detected": "アップストリームの変更が検出されたときにモデルリストを自動的に同期",
|
||||
"Availability (last 24h)": "可用性(過去 24 時間)",
|
||||
"Available": "空き",
|
||||
"Available channels: {{count}}": "利用可能 {{count}}",
|
||||
"Available credits are ordered by soonest expiration.": "利用可能なクレジットは有効期限の近い順に表示されます。",
|
||||
"Available disk space": "利用可能なディスク容量",
|
||||
"Available Models": "利用可能なモデル",
|
||||
@@ -910,6 +915,7 @@
|
||||
"Channel:": "チャネル:",
|
||||
"channel(s)? This action cannot be undone.": "チャネルを削除しますか?この操作は元に戻せません。",
|
||||
"Channels": "チャネル",
|
||||
"Channels {{channels}} · Groups {{groups}}": "チャネル {{channels}} · グループ {{groups}}",
|
||||
"Channels and groups": "チャネルとグループ",
|
||||
"Channels deleted successfully": "チャネルが正常に削除されました",
|
||||
"Character chat, storytelling, persona": "キャラクター会話・ストーリーテリング・ペルソナ",
|
||||
@@ -1658,7 +1664,9 @@
|
||||
"Display name for this payment method.": "この支払い方法の表示名。",
|
||||
"Display name shown to users.": "ユーザーに表示される表示名。",
|
||||
"Display Options": "表示オプション",
|
||||
"Display policy": "表示設定",
|
||||
"Display Token Statistics": "トークン統計を表示",
|
||||
"Displayed": "表示中",
|
||||
"Displayed in": "表示単位",
|
||||
"Displays the mobile sidebar.": "モバイルサイドバーを表示します。",
|
||||
"Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.": "この機能を過信しないでください。IPは偽装される可能性があります。nginx、CDNなどのゲートウェイと併用してください。",
|
||||
@@ -2513,6 +2521,7 @@
|
||||
"Hidden {{count}} models from model square": "モデル広場で {{count}} 件のモデルを非表示にしました",
|
||||
"Hidden from {{group}}": "{{group}} から非表示",
|
||||
"Hidden from catalog": "カタログで非表示",
|
||||
"Hidden from the model square by metadata policy.": "メタデータ設定によりモデル一覧で非表示になっています。",
|
||||
"Hide": "非表示にする",
|
||||
"Hide API key": "APIキーを非表示",
|
||||
"Hide from catalog": "カタログから非表示",
|
||||
@@ -2869,6 +2878,8 @@
|
||||
"List of origins (one per line) allowed for Passkey registration and authentication.": "Passkeyの登録と認証が許可されているオリジン(1行に1つ)のリスト。",
|
||||
"List registered task plugins and bind them when creating or editing task plugin channels.": "登録済みのタスクプラグインを一覧し、チャネル作成・編集時に紐付けます。",
|
||||
"List view": "リスト表示",
|
||||
"Listing also depends on metadata visibility and the user’s group access.": "表示はメタデータの表示設定とユーザーのグループアクセスにも依存します。",
|
||||
"Listing hidden": "非表示",
|
||||
"Live refresh pauses when no task is running": "実行中のタスクがない場合、自動更新は一時停止します",
|
||||
"LLM Leaderboard": "LLM リーダーボード",
|
||||
"LLM prompt helper": "LLMプロンプトヘルパー",
|
||||
@@ -3021,6 +3032,7 @@
|
||||
"Metadata": "メタデータ",
|
||||
"Metadata changes": "メタデータの変更",
|
||||
"Metadata language": "メタデータの言語",
|
||||
"Metadata only": "情報のみ",
|
||||
"Metadata sync completed": "メタデータの同期が完了しました",
|
||||
"Metadata sync disabled": "メタデータ同期は禁止されています",
|
||||
"Metadata sync failed": "メタデータの同期に失敗しました",
|
||||
@@ -3046,6 +3058,7 @@
|
||||
"Minute": "分",
|
||||
"minutes": "分",
|
||||
"Missing code": "コードが不足しています",
|
||||
"Missing metadata": "情報未設定",
|
||||
"Missing Models": "不足しているモデル",
|
||||
"Missing user data from Passkey login response": "パスキーログイン応答からユーザーデータが欠落しています",
|
||||
"Mistral": "Mistral",
|
||||
@@ -3117,7 +3130,7 @@
|
||||
"Model selected": "選択済みモデル",
|
||||
"Model shown in model square": "モデル広場に表示しました",
|
||||
"Model Square": "モデル広場",
|
||||
"Model square visibility": "モデル広場での表示",
|
||||
"Model square visibility": "一覧表示",
|
||||
"Model Tags": "モデルタグ",
|
||||
"Model to use for testing": "テストに使用するモデル",
|
||||
"Model to use when testing channel connectivity": "チャネル接続性をテストする際に使用するモデル",
|
||||
@@ -3298,6 +3311,8 @@
|
||||
"No changes": "変更なし",
|
||||
"No changes made": "変更はありません",
|
||||
"No changes to save": "保存する変更がありません",
|
||||
"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.": "利用可能なチャネルがないため、現在モデル一覧には表示されません。",
|
||||
"No channel selected": "チャネルが選択されていません",
|
||||
"No channel type found.": "チャネルタイプが見つかりません。",
|
||||
"No channels available. Create your first channel to get started.": "利用可能なチャネルがありません。最初のチャネルを作成して開始してください。",
|
||||
@@ -3306,6 +3321,7 @@
|
||||
"No channels selected": "チャネルが選択されていません",
|
||||
"No chat presets configured. Click \"Add chat preset\" to get started.": "チャットプリセットが設定されていません。「チャットプリセットを追加」をクリックして開始してください。",
|
||||
"No chat presets match your search": "検索に一致するチャットプリセットがありません",
|
||||
"No configured channel models match this metadata rule.": "このメタデータルールに一致するチャネルモデルはありません。",
|
||||
"No conflict entries available.": "利用可能な競合エントリはありません。",
|
||||
"No conflicts match your search.": "検索条件に一致する競合はありません。",
|
||||
"No connection info found in clipboard": "クリップボードに接続情報が見つかりません",
|
||||
@@ -3356,6 +3372,7 @@
|
||||
"No mappings configured. Click \"Add Row\" to get started.": "マッピングが設定されていません。「行を追加」をクリックして開始してください。",
|
||||
"No marketplace sources configured.": "マーケットのソースが設定されていません。",
|
||||
"No matches found": "一致するものが見つかりません",
|
||||
"No matching channels": "該当チャネルなし",
|
||||
"No matching icons": "一致するアイコンはありません",
|
||||
"No matching items": "一致する項目がありません",
|
||||
"No matching results": "一致する結果がありません",
|
||||
@@ -3485,6 +3502,7 @@
|
||||
"Not in pricing table": "料金グループ表にありません",
|
||||
"Not included": "未登録",
|
||||
"Not installed": "未インストール",
|
||||
"Not listed": "非表示",
|
||||
"Not provided by this source": "このソースでは提供されていません",
|
||||
"Not recorded": "記録なし",
|
||||
"Not registered": "未登録",
|
||||
@@ -3726,6 +3744,7 @@
|
||||
"Parsed {{count}} service account file(s)": "__ PH_0 __サービスアカウントファイルを解析しました",
|
||||
"Parsed plugin metadata": "解析されたプラグインメタデータ",
|
||||
"Partial Submission": "部分送信",
|
||||
"Partly shown": "一部表示",
|
||||
"Pass Headers": "ヘッダーをパススルー",
|
||||
"Pass request body directly to upstream": "リクエストボディを直接アップストリームに渡す",
|
||||
"Pass specified request headers to upstream": "指定のリクエストヘッダーを上流に透過",
|
||||
@@ -4394,6 +4413,7 @@
|
||||
"Required events:": "必須イベント:",
|
||||
"Required provider, authentication, model, and group settings": "必須のプロバイダー、認証、モデル、グループ設定",
|
||||
"Required to expose MjProxy-style image generation to end users.": "エンドユーザーに MjProxy スタイルの画像生成を公開するために必要です。",
|
||||
"Requires an available channel and access to its group.": "利用可能なチャネルと、そのグループへのアクセス権が必要です。",
|
||||
"Rerank": "再ランク付け",
|
||||
"Reroll": "やり直し",
|
||||
"Research, analysis, scientific reasoning": "リサーチ・分析・科学的推論",
|
||||
@@ -4894,6 +4914,7 @@
|
||||
"socks5://user:pass@host:port": "socks5://user:pass@host:port",
|
||||
"Soft Errors": "ソフトエラー",
|
||||
"Some channels failed: {{errorMsg}}": "一部のチャネルが失敗しました:{{errorMsg}}",
|
||||
"Some matching models are hidden or have no available channel.": "一致するモデルの一部は非表示、または利用可能なチャネルがありません。",
|
||||
"Some plugin information could not be read": "プラグイン情報の一部を読み取れません",
|
||||
"Something went wrong!": "問題が発生しました!",
|
||||
"Sonnet Model": "Sonnet モデル",
|
||||
@@ -5561,6 +5582,7 @@
|
||||
"Unable to read clipboard": "クリップボードを読み取れません",
|
||||
"Unauthorized": "未認証",
|
||||
"Unauthorized Access": "不正アクセス",
|
||||
"Unavailable": "表示不可",
|
||||
"Unbind": "連携解除",
|
||||
"Unbind failed": "連携解除に失敗しました",
|
||||
"Unbound {{provider}}": "{{provider}}の連携を解除しました",
|
||||
@@ -5899,6 +5921,7 @@
|
||||
"View model statistics and charts": "モデルの統計とグラフを表示",
|
||||
"View other accounts' audit logs": "他のアカウントの監査ログを表示",
|
||||
"View Pricing": "価格を見る",
|
||||
"View pricing for {{model}}": "{{model}} の料金を表示",
|
||||
"View scope": "表示範囲",
|
||||
"View the complete details for this": "この",
|
||||
"View the complete details for this log entry": "このログエントリの完全な詳細を表示",
|
||||
@@ -5915,6 +5938,7 @@
|
||||
"vip": "vip",
|
||||
"VIP users with premium access": "プレミアムアクセス権を持つVIPユーザー",
|
||||
"Visible": "表示",
|
||||
"Visible only to users with access to the model’s groups.": "モデルのグループにアクセスできるユーザーにのみ表示されます。",
|
||||
"Vision": "ビジョン",
|
||||
"Vision, image / video, document chat": "ビジョン・画像/動画・ドキュメントチャット",
|
||||
"Visit Settings → General and adjust quota options...": "「設定」→「一般」にアクセスして、クォータオプションを調整してください...",
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"{{count}} vendors": "поставщиков: {{count}}",
|
||||
"{{count}} weeks ago": "{{count}} недель назад",
|
||||
"{{created}} models created, {{updated}} models updated, {{vendors}} vendors created.": "Создано моделей: {{created}}, обновлено: {{updated}}, создано поставщиков: {{vendors}}.",
|
||||
"{{currency}} / {{unit}} tokens": "{{currency}} / {{unit}} токенов",
|
||||
"{{currency}} price per 1M input tokens.": "Цена в {{currency}} за 1 млн входных токенов.",
|
||||
"{{currency}} price per 1M tokens.": "Цена в {{currency}} за 1 млн токенов.",
|
||||
"{{field}} updated to {{value}}": "{{field}} обновлено на {{value}}",
|
||||
@@ -233,6 +234,8 @@
|
||||
"Add group rules": "Добавить правила группы",
|
||||
"Add management route": "Добавить служебный маршрут",
|
||||
"Add Mapping": "Добавить сопоставление",
|
||||
"Add metadata": "Дополнить",
|
||||
"Add metadata to all selected models first.": "Сначала добавьте метаданные ко всем выбранным моделям.",
|
||||
"Add method": "Добавить метод",
|
||||
"Add missing models": "Добавить отсутствующие модели",
|
||||
"Add Mode": "Добавить режим",
|
||||
@@ -380,6 +383,7 @@
|
||||
"Allow inference geography passthrough": "Разрешить проброс географии инференса",
|
||||
"Allow inference_geo passthrough": "Разрешить передачу inference_geo",
|
||||
"Allow Insecure Origins": "Разрешить небезопасные источники",
|
||||
"Allow listing when a channel is available and the user has group access. This does not change API access.": "Разрешает отображение при наличии доступного канала и доступа к его группе. Не меняет доступ к API.",
|
||||
"Allow metadata sync": "Разрешить синхронизацию метаданных",
|
||||
"Allow new users to register": "Разрешить регистрацию новым пользователям",
|
||||
"Allow Private IPs": "Разрешить частные IP-адреса",
|
||||
@@ -634,6 +638,7 @@
|
||||
"Automatically sync model list when upstream changes are detected": "Автоматически синхронизировать список моделей при обнаружении изменений у провайдера",
|
||||
"Availability (last 24h)": "Доступность (последние 24 ч)",
|
||||
"Available": "Доступно",
|
||||
"Available channels: {{count}}": "Доступно: {{count}}",
|
||||
"Available credits are ordered by soonest expiration.": "Доступные сбросы отсортированы по ближайшему истечению.",
|
||||
"Available disk space": "Доступное дисковое пространство",
|
||||
"Available Models": "Доступные модели",
|
||||
@@ -910,6 +915,7 @@
|
||||
"Channel:": "Канал:",
|
||||
"channel(s)? This action cannot be undone.": "канал(ы)? Это действие нельзя отменить.",
|
||||
"Channels": "Каналы",
|
||||
"Channels {{channels}} · Groups {{groups}}": "Каналы {{channels}} · Группы {{groups}}",
|
||||
"Channels and groups": "Каналы и группы",
|
||||
"Channels deleted successfully": "Каналы успешно удалены",
|
||||
"Character chat, storytelling, persona": "Диалог с персонажем, сторителлинг, персона",
|
||||
@@ -1658,7 +1664,9 @@
|
||||
"Display name for this payment method.": "Отображаемое имя для этого метода оплаты.",
|
||||
"Display name shown to users.": "Отображаемое название для пользователей.",
|
||||
"Display Options": "Параметры отображения",
|
||||
"Display policy": "Правило показа",
|
||||
"Display Token Statistics": "Показать статистику токенов",
|
||||
"Displayed": "Отображается",
|
||||
"Displayed in": "Отображается в",
|
||||
"Displays the mobile sidebar.": "Отображает мобильную боковую панель.",
|
||||
"Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.": "Не доверяйте этой функции слишком сильно. IP может быть подделан. Используйте с nginx, CDN и другими шлюзами.",
|
||||
@@ -2513,6 +2521,7 @@
|
||||
"Hidden {{count}} models from model square": "Скрыто моделей в каталоге: {{count}}",
|
||||
"Hidden from {{group}}": "Скрыта от {{group}}",
|
||||
"Hidden from catalog": "Скрыто в каталоге",
|
||||
"Hidden from the model square by metadata policy.": "Модель скрыта из каталога настройками метаданных.",
|
||||
"Hide": "Скрыть",
|
||||
"Hide API key": "Скрыть API ключ",
|
||||
"Hide from catalog": "Скрыть из каталога",
|
||||
@@ -2869,6 +2878,8 @@
|
||||
"List of origins (one per line) allowed for Passkey registration and authentication.": "Список источников (один на строку), разрешенных для регистрации и аутентификации Passkey.",
|
||||
"List registered task plugins and bind them when creating or editing task plugin channels.": "Просматривать зарегистрированные плагины задач и привязывать их при создании или изменении каналов.",
|
||||
"List view": "Вид списка",
|
||||
"Listing also depends on metadata visibility and the user’s group access.": "Отображение также зависит от настроек видимости метаданных и доступа пользователя к группам.",
|
||||
"Listing hidden": "Скрыто",
|
||||
"Live refresh pauses when no task is running": "Автообновление приостанавливается, когда нет выполняемых задач",
|
||||
"LLM Leaderboard": "Рейтинг LLM",
|
||||
"LLM prompt helper": "Помощник с промптом для LLM",
|
||||
@@ -3021,6 +3032,7 @@
|
||||
"Metadata": "Метаданные",
|
||||
"Metadata changes": "Изменения метаданных",
|
||||
"Metadata language": "Язык метаданных",
|
||||
"Metadata only": "Только метаданные",
|
||||
"Metadata sync completed": "Синхронизация метаданных завершена",
|
||||
"Metadata sync disabled": "Синхронизация метаданных запрещена",
|
||||
"Metadata sync failed": "Не удалось синхронизировать метаданные",
|
||||
@@ -3046,6 +3058,7 @@
|
||||
"Minute": "Минута",
|
||||
"minutes": "минут",
|
||||
"Missing code": "Код отсутствует",
|
||||
"Missing metadata": "Нет метаданных",
|
||||
"Missing Models": "Отсутствующие модели",
|
||||
"Missing user data from Passkey login response": "В ответе Passkey для входа отсутствуют данные пользователя",
|
||||
"Mistral": "Mistral",
|
||||
@@ -3117,7 +3130,7 @@
|
||||
"Model selected": "Модель выбрана",
|
||||
"Model shown in model square": "Модель отображается в каталоге",
|
||||
"Model Square": "Витрина моделей",
|
||||
"Model square visibility": "Видимость в каталоге моделей",
|
||||
"Model square visibility": "В каталоге",
|
||||
"Model Tags": "Теги моделей",
|
||||
"Model to use for testing": "Модель для использования при тестировании",
|
||||
"Model to use when testing channel connectivity": "Модель для использования при тестировании подключения канала",
|
||||
@@ -3298,6 +3311,8 @@
|
||||
"No changes": "Нет изменений",
|
||||
"No changes made": "Изменения не внесены",
|
||||
"No changes to save": "Нет изменений для сохранения",
|
||||
"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.": "Нет доступных каналов. Сейчас модель не отображается в каталоге.",
|
||||
"No channel selected": "Канал не выбран",
|
||||
"No channel type found.": "Тип канала не найден.",
|
||||
"No channels available. Create your first channel to get started.": "Нет доступных каналов. Создайте свой первый канал, чтобы начать.",
|
||||
@@ -3306,6 +3321,7 @@
|
||||
"No channels selected": "Каналы не выбраны",
|
||||
"No chat presets configured. Click \"Add chat preset\" to get started.": "Пресеты чата не настроены. Нажмите \"Добавить пресет чата\", чтобы начать.",
|
||||
"No chat presets match your search": "Нет пресетов чата, соответствующих вашему поиску",
|
||||
"No configured channel models match this metadata rule.": "Ни одна модель настроенных каналов не соответствует этому правилу метаданных.",
|
||||
"No conflict entries available.": "Нет доступных записей конфликтов.",
|
||||
"No conflicts match your search.": "Конфликты, соответствующие вашему поиску, не найдены.",
|
||||
"No connection info found in clipboard": "В буфере обмена нет данных подключения",
|
||||
@@ -3356,6 +3372,7 @@
|
||||
"No mappings configured. Click \"Add Row\" to get started.": "Нет настроенных сопоставлений. Нажмите \"Добавить строку\", чтобы начать.",
|
||||
"No marketplace sources configured.": "Источники магазина не настроены.",
|
||||
"No matches found": "Совпадений не найдено",
|
||||
"No matching channels": "Нет совпадений",
|
||||
"No matching icons": "Подходящих иконок нет",
|
||||
"No matching items": "Нет подходящих элементов",
|
||||
"No matching results": "Нет совпадений",
|
||||
@@ -3485,6 +3502,7 @@
|
||||
"Not in pricing table": "Нет в таблице тарифных групп",
|
||||
"Not included": "Не включена",
|
||||
"Not installed": "Не установлен",
|
||||
"Not listed": "Скрыта",
|
||||
"Not provided by this source": "Не предоставлено этим источником",
|
||||
"Not recorded": "Не записано",
|
||||
"Not registered": "Не зарегистрирован",
|
||||
@@ -3726,6 +3744,7 @@
|
||||
"Parsed {{count}} service account file(s)": "Проанализировано файлов сервисного аккаунта {{count}}",
|
||||
"Parsed plugin metadata": "Распознанные метаданные плагина",
|
||||
"Partial Submission": "Частичная отправка",
|
||||
"Partly shown": "Частично",
|
||||
"Pass Headers": "Пропустить заголовки",
|
||||
"Pass request body directly to upstream": "Передать тело запроса напрямую вышестоящему серверу",
|
||||
"Pass specified request headers to upstream": "Передать указанные заголовки запроса вышестоящему серверу",
|
||||
@@ -4394,6 +4413,7 @@
|
||||
"Required events:": "Обязательные события:",
|
||||
"Required provider, authentication, model, and group settings": "Обязательные настройки провайдера, аутентификации, моделей и групп",
|
||||
"Required to expose MjProxy-style image generation to end users.": "Необходимо для предоставления генерации изображений в стиле MjProxy конечным пользователям.",
|
||||
"Requires an available channel and access to its group.": "Нужен доступный канал и доступ к его группе.",
|
||||
"Rerank": "Переранжировать",
|
||||
"Reroll": "Повторить",
|
||||
"Research, analysis, scientific reasoning": "Исследования, анализ, научные рассуждения",
|
||||
@@ -4894,6 +4914,7 @@
|
||||
"socks5://user:pass@host:port": "socks5://user:pass@host:port",
|
||||
"Soft Errors": "Мягкие ошибки",
|
||||
"Some channels failed: {{errorMsg}}": "Некоторые каналы не удались: {{errorMsg}}",
|
||||
"Some matching models are hidden or have no available channel.": "Часть соответствующих моделей скрыта или не имеет доступных каналов.",
|
||||
"Some plugin information could not be read": "Не удалось прочитать часть данных плагина",
|
||||
"Something went wrong!": "Что-то пошло не так!",
|
||||
"Sonnet Model": "Модель Sonnet",
|
||||
@@ -5561,6 +5582,7 @@
|
||||
"Unable to read clipboard": "Не удалось прочитать буфер обмена",
|
||||
"Unauthorized": "Не авторизован",
|
||||
"Unauthorized Access": "Несанкционированный доступ",
|
||||
"Unavailable": "Недоступно",
|
||||
"Unbind": "Отвязать",
|
||||
"Unbind failed": "Не удалось отвязать",
|
||||
"Unbound {{provider}}": "{{provider}} отвязан",
|
||||
@@ -5899,6 +5921,7 @@
|
||||
"View model statistics and charts": "Просмотр статистики и графиков моделей",
|
||||
"View other accounts' audit logs": "Просмотр аудита других аккаунтов",
|
||||
"View Pricing": "Посмотреть цены",
|
||||
"View pricing for {{model}}": "Цены для {{model}}",
|
||||
"View scope": "Область просмотра",
|
||||
"View the complete details for this": "Просмотр полных деталей этой",
|
||||
"View the complete details for this log entry": "Просмотр полной информации об этой записи журнала",
|
||||
@@ -5915,6 +5938,7 @@
|
||||
"vip": "vip",
|
||||
"VIP users with premium access": "VIP-пользователи с премиум-доступом",
|
||||
"Visible": "Видима",
|
||||
"Visible only to users with access to the model’s groups.": "Отображается только пользователям с доступом к группам модели.",
|
||||
"Vision": "Зрение",
|
||||
"Vision, image / video, document chat": "Зрение, изображения / видео, чат по документам",
|
||||
"Visit Settings → General and adjust quota options...": "Перейдите в Настройки → Общие и настройте параметры квоты...",
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"{{count}} vendors": "{{count}} nhà cung cấp",
|
||||
"{{count}} weeks ago": "{{count}} tuần trước",
|
||||
"{{created}} models created, {{updated}} models updated, {{vendors}} vendors created.": "Đã tạo {{created}} mô hình, cập nhật {{updated}} mô hình và tạo {{vendors}} nhà cung cấp.",
|
||||
"{{currency}} / {{unit}} tokens": "{{currency}} / {{unit}} token",
|
||||
"{{currency}} price per 1M input tokens.": "Giá bằng {{currency}} cho mỗi 1 triệu token đầu vào.",
|
||||
"{{currency}} price per 1M tokens.": "Giá bằng {{currency}} cho mỗi 1 triệu token.",
|
||||
"{{field}} updated to {{value}}": "{{field}} đã cập nhật thành {{value}}",
|
||||
@@ -233,6 +234,8 @@
|
||||
"Add group rules": "Thêm quy tắc nhóm",
|
||||
"Add management route": "Thêm route quản lý",
|
||||
"Add Mapping": "Thêm ánh xạ",
|
||||
"Add metadata": "Bổ sung",
|
||||
"Add metadata to all selected models first.": "Hãy bổ sung siêu dữ liệu cho tất cả mô hình đã chọn trước.",
|
||||
"Add method": "Thêm phương thức",
|
||||
"Add missing models": "Thêm mô hình còn thiếu",
|
||||
"Add Mode": "Thêm Chế độ",
|
||||
@@ -380,6 +383,7 @@
|
||||
"Allow inference geography passthrough": "Cho phép chuyển tiếp vị trí địa lý suy luận",
|
||||
"Allow inference_geo passthrough": "Cho phép truyền inference_geo",
|
||||
"Allow Insecure Origins": "Cho phép Nguồn gốc Không an toàn",
|
||||
"Allow listing when a channel is available and the user has group access. This does not change API access.": "Cho phép hiển thị khi có kênh khả dụng và quyền truy cập nhóm. Không thay đổi quyền truy cập API.",
|
||||
"Allow metadata sync": "Cho phép đồng bộ siêu dữ liệu",
|
||||
"Allow new users to register": "Cho phép người dùng mới đăng ký",
|
||||
"Allow Private IPs": "Cho phép IP riêng",
|
||||
@@ -634,6 +638,7 @@
|
||||
"Automatically sync model list when upstream changes are detected": "Tự động đồng bộ danh sách mô hình khi phát hiện thay đổi từ nguồn",
|
||||
"Availability (last 24h)": "Khả dụng (24 giờ qua)",
|
||||
"Available": "Khả dụng",
|
||||
"Available channels: {{count}}": "Kênh khả dụng: {{count}}",
|
||||
"Available credits are ordered by soonest expiration.": "Các lượt khả dụng được sắp xếp theo thời điểm hết hạn gần nhất.",
|
||||
"Available disk space": "Dung lượng đĩa khả dụng",
|
||||
"Available Models": "Mô hình khả dụng",
|
||||
@@ -910,6 +915,7 @@
|
||||
"Channel:": "Kênh:",
|
||||
"channel(s)? This action cannot be undone.": "kênh(s)? Hành động này không thể hoàn tác.",
|
||||
"Channels": "Kênh",
|
||||
"Channels {{channels}} · Groups {{groups}}": "Kênh {{channels}} · Nhóm {{groups}}",
|
||||
"Channels and groups": "Kênh và nhóm",
|
||||
"Channels deleted successfully": "Xóa kênh thành công",
|
||||
"Character chat, storytelling, persona": "Trò chuyện nhân vật, kể chuyện, nhân cách hoá",
|
||||
@@ -1658,7 +1664,9 @@
|
||||
"Display name for this payment method.": "Tên hiển thị cho phương thức thanh toán này.",
|
||||
"Display name shown to users.": "Tên hiển thị cho người dùng.",
|
||||
"Display Options": "Tùy chọn hiển thị",
|
||||
"Display policy": "Quy tắc hiển thị",
|
||||
"Display Token Statistics": "Hiển thị Thống kê Token",
|
||||
"Displayed": "Đang hiển thị",
|
||||
"Displayed in": "Hiển thị theo",
|
||||
"Displays the mobile sidebar.": "Hiển thị thanh bên di động.",
|
||||
"Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.": "Đừng tin tưởng quá mức vào tính năng này. IP có thể bị giả mạo. Hãy sử dụng cùng với nginx, CDN và các gateway khác.",
|
||||
@@ -2513,6 +2521,7 @@
|
||||
"Hidden {{count}} models from model square": "Đã ẩn {{count}} mô hình khỏi kho mô hình",
|
||||
"Hidden from {{group}}": "Ẩn khỏi {{group}}",
|
||||
"Hidden from catalog": "Ẩn trong danh mục",
|
||||
"Hidden from the model square by metadata policy.": "Đã ẩn khỏi danh mục theo cài đặt siêu dữ liệu.",
|
||||
"Hide": "Ẩn",
|
||||
"Hide API key": "Ẩn khóa API",
|
||||
"Hide from catalog": "Ẩn khỏi danh mục",
|
||||
@@ -2869,6 +2878,8 @@
|
||||
"List of origins (one per line) allowed for Passkey registration and authentication.": "Danh sách các nguồn gốc (mỗi dòng một mục) được phép đăng ký và xác thực Passkey.",
|
||||
"List registered task plugins and bind them when creating or editing task plugin channels.": "Liệt kê plugin tác vụ đã đăng ký và gắn chúng khi tạo hoặc sửa kênh.",
|
||||
"List view": "Xem dạng danh sách",
|
||||
"Listing also depends on metadata visibility and the user’s group access.": "Việc hiển thị còn phụ thuộc vào cài đặt siêu dữ liệu và quyền truy cập nhóm của người dùng.",
|
||||
"Listing hidden": "Đã ẩn",
|
||||
"Live refresh pauses when no task is running": "Tự động làm mới tạm dừng khi không có tác vụ nào đang chạy",
|
||||
"LLM Leaderboard": "Bảng xếp hạng LLM",
|
||||
"LLM prompt helper": "Trợ lý prompt LLM",
|
||||
@@ -3021,6 +3032,7 @@
|
||||
"Metadata": "Siêu dữ liệu",
|
||||
"Metadata changes": "Thay đổi siêu dữ liệu",
|
||||
"Metadata language": "Ngôn ngữ siêu dữ liệu",
|
||||
"Metadata only": "Chỉ siêu dữ liệu",
|
||||
"Metadata sync completed": "Đồng bộ siêu dữ liệu hoàn tất",
|
||||
"Metadata sync disabled": "Đã tắt đồng bộ siêu dữ liệu",
|
||||
"Metadata sync failed": "Đồng bộ siêu dữ liệu thất bại",
|
||||
@@ -3046,6 +3058,7 @@
|
||||
"Minute": "Phút",
|
||||
"minutes": "phút",
|
||||
"Missing code": "Thiếu mã",
|
||||
"Missing metadata": "Thiếu siêu dữ liệu",
|
||||
"Missing Models": "Thiếu Mô hình",
|
||||
"Missing user data from Passkey login response": "Thiếu dữ liệu người dùng từ phản hồi đăng nhập Passkey",
|
||||
"Mistral": "Mistral",
|
||||
@@ -3117,7 +3130,7 @@
|
||||
"Model selected": "Đã chọn mô hình",
|
||||
"Model shown in model square": "Đã hiển thị mô hình trong kho mô hình",
|
||||
"Model Square": "Quảng trường mô hình",
|
||||
"Model square visibility": "Hiển thị trong kho mô hình",
|
||||
"Model square visibility": "Hiển thị",
|
||||
"Model Tags": "Thẻ mô hình",
|
||||
"Model to use for testing": "Mô hình dùng để kiểm thử",
|
||||
"Model to use when testing channel connectivity": "Mô hình để sử dụng khi kiểm tra kết nối kênh",
|
||||
@@ -3298,6 +3311,8 @@
|
||||
"No changes": "Không có thay đổi",
|
||||
"No changes made": "Không có thay đổi nào được thực hiện",
|
||||
"No changes to save": "Không có thay đổi nào để lưu",
|
||||
"No channel is configured. This model will not appear in the model square.": "Chưa cấu hình kênh. Mô hình này sẽ không xuất hiện trong danh mục.",
|
||||
"No channel is currently available. This model will not appear in the model square.": "Hiện không có kênh khả dụng. Mô hình này sẽ không xuất hiện trong danh mục.",
|
||||
"No channel selected": "Chưa chọn kênh nào",
|
||||
"No channel type found.": "Không tìm thấy loại kênh.",
|
||||
"No channels available. Create your first channel to get started.": "Không có kênh nào khả dụng. Hãy tạo kênh đầu tiên của bạn để bắt đầu.",
|
||||
@@ -3306,6 +3321,7 @@
|
||||
"No channels selected": "Không có kênh nào được chọn",
|
||||
"No chat presets configured. Click \"Add chat preset\" to get started.": "Chưa cấu hình cài đặt trước trò chuyện. Nhấp vào \"Thêm cài đặt trước trò chuyện\" để bắt đầu.",
|
||||
"No chat presets match your search": "Không có cài đặt trước trò chuyện nào khớp với tìm kiếm của bạn",
|
||||
"No configured channel models match this metadata rule.": "Không có mô hình nào trong các kênh đã cấu hình khớp với quy tắc siêu dữ liệu này.",
|
||||
"No conflict entries available.": "Không có mục xung đột nào khả dụng.",
|
||||
"No conflicts match your search.": "Không có xung đột nào khớp với tìm kiếm của bạn.",
|
||||
"No connection info found in clipboard": "Không tìm thấy thông tin kết nối trong bảng tạm",
|
||||
@@ -3356,6 +3372,7 @@
|
||||
"No mappings configured. Click \"Add Row\" to get started.": "Chưa có ánh xạ nào được cấu hình. Nhấp vào \"Thêm hàng\" để bắt đầu.",
|
||||
"No marketplace sources configured.": "Chưa cấu hình nguồn chợ plugin nào.",
|
||||
"No matches found": "Không tìm thấy kết quả nào",
|
||||
"No matching channels": "Không có kênh khớp",
|
||||
"No matching icons": "Không có biểu tượng phù hợp",
|
||||
"No matching items": "Không có mục phù hợp",
|
||||
"No matching results": "Không có kết quả phù hợp",
|
||||
@@ -3485,6 +3502,7 @@
|
||||
"Not in pricing table": "Không có trong bảng định giá",
|
||||
"Not included": "Không bao gồm",
|
||||
"Not installed": "Chưa cài đặt",
|
||||
"Not listed": "Ẩn",
|
||||
"Not provided by this source": "Nguồn này không cung cấp",
|
||||
"Not recorded": "Chưa ghi nhận",
|
||||
"Not registered": "Chưa đăng ký",
|
||||
@@ -3726,6 +3744,7 @@
|
||||
"Parsed {{count}} service account file(s)": "Đã phân tích {{count}} tệp tài khoản dịch vụ",
|
||||
"Parsed plugin metadata": "Siêu dữ liệu plugin đã phân tích",
|
||||
"Partial Submission": "Gửi một phần",
|
||||
"Partly shown": "Hiển thị một phần",
|
||||
"Pass Headers": "Chuyển tiếp tiêu đề",
|
||||
"Pass request body directly to upstream": "Truyền phần thân yêu cầu trực tiếp lên upstream",
|
||||
"Pass specified request headers to upstream": "Chuyển tiếp header yêu cầu chỉ định lên upstream",
|
||||
@@ -4394,6 +4413,7 @@
|
||||
"Required events:": "Sự kiện bắt buộc:",
|
||||
"Required provider, authentication, model, and group settings": "Thiết lập bắt buộc về nhà cung cấp, xác thực, mô hình và nhóm",
|
||||
"Required to expose MjProxy-style image generation to end users.": "Cần thiết để cung cấp tính năng tạo hình ảnh kiểu MjProxy cho người dùng cuối.",
|
||||
"Requires an available channel and access to its group.": "Cần có kênh khả dụng và quyền truy cập nhóm tương ứng.",
|
||||
"Rerank": "Re-rank",
|
||||
"Reroll": "Quay lại",
|
||||
"Research, analysis, scientific reasoning": "Nghiên cứu, phân tích, suy luận khoa học",
|
||||
@@ -4894,6 +4914,7 @@
|
||||
"socks5://user:pass@host:port": "socks5://user:pass@host:port",
|
||||
"Soft Errors": "Lỗi mềm",
|
||||
"Some channels failed: {{errorMsg}}": "Một số kênh thất bại: {{errorMsg}}",
|
||||
"Some matching models are hidden or have no available channel.": "Một số mô hình khớp đã bị ẩn hoặc không có kênh khả dụng.",
|
||||
"Some plugin information could not be read": "Không đọc được một số thông tin plugin",
|
||||
"Something went wrong!": "Có gì đó không ổn!",
|
||||
"Sonnet Model": "Mô hình Sonnet",
|
||||
@@ -5561,6 +5582,7 @@
|
||||
"Unable to read clipboard": "Không thể đọc bảng tạm",
|
||||
"Unauthorized": "Chưa xác thực",
|
||||
"Unauthorized Access": "Truy cập trái phép",
|
||||
"Unavailable": "Không thể hiển thị",
|
||||
"Unbind": "Hủy liên kết",
|
||||
"Unbind failed": "Hủy liên kết thất bại",
|
||||
"Unbound {{provider}}": "Đã hủy liên kết {{provider}}",
|
||||
@@ -5899,6 +5921,7 @@
|
||||
"View model statistics and charts": "Xem thống kê và biểu đồ mô hình",
|
||||
"View other accounts' audit logs": "Xem nhật ký kiểm toán của tài khoản khác",
|
||||
"View Pricing": "View price",
|
||||
"View pricing for {{model}}": "Xem giá {{model}}",
|
||||
"View scope": "Phạm vi xem",
|
||||
"View the complete details for this": "Xem chi tiết đầy đủ của",
|
||||
"View the complete details for this log entry": "Xem chi tiết đầy đủ cho mục nhật ký này",
|
||||
@@ -5915,6 +5938,7 @@
|
||||
"vip": "vip",
|
||||
"VIP users with premium access": "Người dùng VIP với quyền truy cập cao cấp",
|
||||
"Visible": "Hiển thị",
|
||||
"Visible only to users with access to the model’s groups.": "Chỉ hiển thị cho người dùng có quyền truy cập các nhóm của mô hình.",
|
||||
"Vision": "Thị giác",
|
||||
"Vision, image / video, document chat": "Thị giác, ảnh / video, hỏi đáp tài liệu",
|
||||
"Visit Settings → General and adjust quota options...": "Truy cập Cài đặt → Chung và điều chỉnh tùy chọn hạn mức...",
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"{{count}} vendors": "{{count}} 間供應商",
|
||||
"{{count}} weeks ago": "{{count}} 週前",
|
||||
"{{created}} models created, {{updated}} models updated, {{vendors}} vendors created.": "已建立 {{created}} 個模型、更新 {{updated}} 個模型,並新增 {{vendors}} 個供應商。",
|
||||
"{{currency}} / {{unit}} tokens": "{{currency}} / {{unit}} Token",
|
||||
"{{currency}} price per 1M input tokens.": "每百萬輸入 Token 的價格,單位為 {{currency}}。",
|
||||
"{{currency}} price per 1M tokens.": "每百萬 Token 的價格,單位為 {{currency}}。",
|
||||
"{{field}} updated to {{value}}": "{{field}} 已更新為 {{value}}",
|
||||
@@ -233,6 +234,8 @@
|
||||
"Add group rules": "新增分組規則",
|
||||
"Add management route": "新增管理路由",
|
||||
"Add Mapping": "新增映射",
|
||||
"Add metadata": "補充資料",
|
||||
"Add metadata to all selected models first.": "請先為所有選取模型補充中繼資料。",
|
||||
"Add method": "新增方式",
|
||||
"Add missing models": "新增缺失模型",
|
||||
"Add Mode": "新增模式",
|
||||
@@ -380,6 +383,7 @@
|
||||
"Allow inference geography passthrough": "允許推理地理位置透傳",
|
||||
"Allow inference_geo passthrough": "允許 inference_geo 透傳",
|
||||
"Allow Insecure Origins": "允許不安全來源",
|
||||
"Allow listing when a channel is available and the user has group access. This does not change API access.": "允許顯示後,仍需有可用渠道,並符合使用者群組存取條件。不影響 API 存取權限。",
|
||||
"Allow metadata sync": "允許資料同步",
|
||||
"Allow new users to register": "允許新用戶註冊",
|
||||
"Allow Private IPs": "允許私有 IP",
|
||||
@@ -634,6 +638,7 @@
|
||||
"Automatically sync model list when upstream changes are detected": "偵測到上游模型變更時自動同步模型清單",
|
||||
"Availability (last 24h)": "可用率(最近 24 小時)",
|
||||
"Available": "可用",
|
||||
"Available channels: {{count}}": "可用渠道 {{count}}",
|
||||
"Available credits are ordered by soonest expiration.": "可用次數按最早到期排序。",
|
||||
"Available disk space": "可用磁碟空間",
|
||||
"Available Models": "可用模型",
|
||||
@@ -910,6 +915,7 @@
|
||||
"Channel:": "渠道:",
|
||||
"channel(s)? This action cannot be undone.": "渠道?此操作無法撤銷。",
|
||||
"Channels": "渠道",
|
||||
"Channels {{channels}} · Groups {{groups}}": "渠道 {{channels}} · 群組 {{groups}}",
|
||||
"Channels and groups": "渠道與分組",
|
||||
"Channels deleted successfully": "渠道刪除成功",
|
||||
"Character chat, storytelling, persona": "角色對話、劇情創作、人設扮演",
|
||||
@@ -1658,7 +1664,9 @@
|
||||
"Display name for this payment method.": "此支付方式的顯示名稱。",
|
||||
"Display name shown to users.": "顯示給用戶的名稱。",
|
||||
"Display Options": "顯示選項",
|
||||
"Display policy": "顯示策略",
|
||||
"Display Token Statistics": "顯示 Token 統計資訊",
|
||||
"Displayed": "正常顯示",
|
||||
"Displayed in": "顯示單位",
|
||||
"Displays the mobile sidebar.": "顯示流動側邊欄。",
|
||||
"Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.": "請勿過度信任此功能,IP 可能被偽造,請配合 nginx 和 cdn 等閘道使用",
|
||||
@@ -2513,6 +2521,7 @@
|
||||
"Hidden {{count}} models from model square": "已從模型廣場隱藏 {{count}} 個模型",
|
||||
"Hidden from {{group}}": "對 {{group}} 屏蔽",
|
||||
"Hidden from catalog": "在目錄隱藏",
|
||||
"Hidden from the model square by metadata policy.": "已透過中繼資料策略在模型廣場隱藏。",
|
||||
"Hide": "隱藏",
|
||||
"Hide API key": "隱藏 API 金鑰",
|
||||
"Hide from catalog": "從目錄隱藏",
|
||||
@@ -2869,6 +2878,8 @@
|
||||
"List of origins (one per line) allowed for Passkey registration and authentication.": "允許用於 Passkey 註冊和身份驗證的來源清單(每行一個)。",
|
||||
"List registered task plugins and bind them when creating or editing task plugin channels.": "列出已註冊的任務外掛,並在建立或編輯任務外掛渠道時綁定它們。",
|
||||
"List view": "列表檢視",
|
||||
"Listing also depends on metadata visibility and the user’s group access.": "是否顯示亦取決於中繼資料顯示策略及使用者可用群組。",
|
||||
"Listing hidden": "已隱藏",
|
||||
"Live refresh pauses when no task is running": "無任務執行時暫停自動重新整理",
|
||||
"LLM Leaderboard": "LLM 排行榜",
|
||||
"LLM prompt helper": "LLM 輔助設計提示詞",
|
||||
@@ -3021,6 +3032,7 @@
|
||||
"Metadata": "元資訊",
|
||||
"Metadata changes": "資料變更",
|
||||
"Metadata language": "資料語言",
|
||||
"Metadata only": "僅中繼資料",
|
||||
"Metadata sync completed": "資料同步完成",
|
||||
"Metadata sync disabled": "已禁止資料同步",
|
||||
"Metadata sync failed": "資料同步失敗",
|
||||
@@ -3046,6 +3058,7 @@
|
||||
"Minute": "分鐘",
|
||||
"minutes": "分鐘",
|
||||
"Missing code": "缺少代碼",
|
||||
"Missing metadata": "缺中繼資料",
|
||||
"Missing Models": "缺失的模型",
|
||||
"Missing user data from Passkey login response": "Passkey 登入回應中缺少用戶數據",
|
||||
"Mistral": "Mistral",
|
||||
@@ -3117,7 +3130,7 @@
|
||||
"Model selected": "已選擇模型",
|
||||
"Model shown in model square": "已在模型廣場顯示",
|
||||
"Model Square": "模型廣場",
|
||||
"Model square visibility": "模型廣場顯示",
|
||||
"Model square visibility": "廣場顯示",
|
||||
"Model Tags": "模型標籤",
|
||||
"Model to use for testing": "用於測試的模型",
|
||||
"Model to use when testing channel connectivity": "測試渠道連接時使用的模型",
|
||||
@@ -3298,6 +3311,8 @@
|
||||
"No changes": "沒有變更",
|
||||
"No changes made": "未進行任何更改",
|
||||
"No changes to save": "沒有需要儲存的更改",
|
||||
"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.": "暫無可用渠道,目前不會在模型廣場顯示。",
|
||||
"No channel selected": "未選擇渠道",
|
||||
"No channel type found.": "未找到渠道類型。",
|
||||
"No channels available. Create your first channel to get started.": "沒有可用的渠道。建立您的第一個渠道即可開始使用。",
|
||||
@@ -3306,6 +3321,7 @@
|
||||
"No channels selected": "未選擇渠道",
|
||||
"No chat presets configured. Click \"Add chat preset\" to get started.": "未設定聊天預設。點擊「新增聊天預設」開始。",
|
||||
"No chat presets match your search": "沒有匹配的聊天預設",
|
||||
"No configured channel models match this metadata rule.": "沒有渠道模型符合此中繼資料規則。",
|
||||
"No conflict entries available.": "沒有可用的衝突條目。",
|
||||
"No conflicts match your search.": "沒有衝突匹配您的搜尋。",
|
||||
"No connection info found in clipboard": "剪貼簿中未找到連線資訊",
|
||||
@@ -3356,6 +3372,7 @@
|
||||
"No mappings configured. Click \"Add Row\" to get started.": "未設定映射。點擊「新增列」開始。",
|
||||
"No marketplace sources configured.": "尚未設定任何市集來源。",
|
||||
"No matches found": "未找到匹配項",
|
||||
"No matching channels": "無相符渠道",
|
||||
"No matching icons": "找不到相符圖示",
|
||||
"No matching items": "沒有匹配項",
|
||||
"No matching results": "無匹配結果",
|
||||
@@ -3485,6 +3502,7 @@
|
||||
"Not in pricing table": "不在定價分組表中",
|
||||
"Not included": "未加入",
|
||||
"Not installed": "未安裝",
|
||||
"Not listed": "隱藏",
|
||||
"Not provided by this source": "該來源未提供",
|
||||
"Not recorded": "未記錄",
|
||||
"Not registered": "未注册",
|
||||
@@ -3726,6 +3744,7 @@
|
||||
"Parsed {{count}} service account file(s)": "已解析 {{count}} 個服務賬號檔案",
|
||||
"Parsed plugin metadata": "解析出的外掛元数据",
|
||||
"Partial Submission": "部分提交確認",
|
||||
"Partly shown": "部分顯示",
|
||||
"Pass Headers": "透傳請求頭",
|
||||
"Pass request body directly to upstream": "將請求體直接傳遞給上游",
|
||||
"Pass specified request headers to upstream": "把指定請求頭透傳到上游請求",
|
||||
@@ -4394,6 +4413,7 @@
|
||||
"Required events:": "必需事件:",
|
||||
"Required provider, authentication, model, and group settings": "必填的供應商、鑒權、模型和分組設定",
|
||||
"Required to expose MjProxy-style image generation to end users.": "需要向終端用戶開放 MjProxy 風格的圖像生成。",
|
||||
"Requires an available channel and access to its group.": "仍需有可用渠道,並符合使用者群組存取條件。",
|
||||
"Rerank": "重新排序",
|
||||
"Reroll": "重繪",
|
||||
"Research, analysis, scientific reasoning": "研究、分析與科學推理",
|
||||
@@ -4894,6 +4914,7 @@
|
||||
"socks5://user:pass@host:port": "socks5://user:pass@host:port",
|
||||
"Soft Errors": "軟錯誤",
|
||||
"Some channels failed: {{errorMsg}}": "部分渠道失敗:{{errorMsg}}",
|
||||
"Some matching models are hidden or have no available channel.": "部分符合規則的模型已隱藏或暫無可用渠道。",
|
||||
"Some plugin information could not be read": "部分外掛資訊無法讀取",
|
||||
"Something went wrong!": "出現錯誤!",
|
||||
"Sonnet Model": "Sonnet 模型",
|
||||
@@ -5561,6 +5582,7 @@
|
||||
"Unable to read clipboard": "無法讀取剪貼簿",
|
||||
"Unauthorized": "未經授權",
|
||||
"Unauthorized Access": "未經授權的存取",
|
||||
"Unavailable": "無法顯示",
|
||||
"Unbind": "解綁",
|
||||
"Unbind failed": "解綁失敗",
|
||||
"Unbound {{provider}}": "已解綁 {{provider}}",
|
||||
@@ -5899,6 +5921,7 @@
|
||||
"View model statistics and charts": "查看模型統計和圖表",
|
||||
"View other accounts' audit logs": "查看其他帳戶稽核日誌",
|
||||
"View Pricing": "查看定價",
|
||||
"View pricing for {{model}}": "查看 {{model}} 的定價",
|
||||
"View scope": "檢視範圍",
|
||||
"View the complete details for this": "查看此條",
|
||||
"View the complete details for this log entry": "查看此日誌條目的完整詳情",
|
||||
@@ -5915,6 +5938,7 @@
|
||||
"vip": "vip",
|
||||
"VIP users with premium access": "擁有高級存取權限的 VIP 用戶",
|
||||
"Visible": "可見",
|
||||
"Visible only to users with access to the model’s groups.": "僅向有權存取模型所屬群組的使用者顯示。",
|
||||
"Vision": "視覺",
|
||||
"Vision, image / video, document chat": "視覺理解、圖像 / 影片、文檔對話",
|
||||
"Visit Settings → General and adjust quota options...": "訪問設定 → 通用並調整配額選項...",
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"{{count}} vendors": "{{count}} 家厂商",
|
||||
"{{count}} weeks ago": "{{count}} 周前",
|
||||
"{{created}} models created, {{updated}} models updated, {{vendors}} vendors created.": "已创建 {{created}} 个模型,更新 {{updated}} 个模型,新增 {{vendors}} 个供应商。",
|
||||
"{{currency}} / {{unit}} tokens": "{{currency}} / {{unit}} Token",
|
||||
"{{currency}} price per 1M input tokens.": "每百万输入 Token 的价格,单位为 {{currency}}。",
|
||||
"{{currency}} price per 1M tokens.": "每百万 Token 的价格,单位为 {{currency}}。",
|
||||
"{{field}} updated to {{value}}": "{{field}} 已更新为 {{value}}",
|
||||
@@ -233,6 +234,8 @@
|
||||
"Add group rules": "添加分组规则",
|
||||
"Add management route": "添加管理路由",
|
||||
"Add Mapping": "添加映射",
|
||||
"Add metadata": "补充资料",
|
||||
"Add metadata to all selected models first.": "请先为所有选中模型补充元数据。",
|
||||
"Add method": "添加方法",
|
||||
"Add missing models": "添加缺失模型",
|
||||
"Add Mode": "添加模式",
|
||||
@@ -380,6 +383,7 @@
|
||||
"Allow inference geography passthrough": "允许推理地理位置透传",
|
||||
"Allow inference_geo passthrough": "允许 inference_geo 透传",
|
||||
"Allow Insecure Origins": "允许不安全来源",
|
||||
"Allow listing when a channel is available and the user has group access. This does not change API access.": "允许展示后,仍需有可用渠道,并满足用户分组访问条件。不影响 API 访问权限。",
|
||||
"Allow metadata sync": "允许资料同步",
|
||||
"Allow new users to register": "允许新用户注册",
|
||||
"Allow Private IPs": "允许私有 IP",
|
||||
@@ -634,6 +638,7 @@
|
||||
"Automatically sync model list when upstream changes are detected": "检测到上游模型变更时自动同步模型列表",
|
||||
"Availability (last 24h)": "可用率(最近 24 小时)",
|
||||
"Available": "可用",
|
||||
"Available channels: {{count}}": "可用渠道 {{count}}",
|
||||
"Available credits are ordered by soonest expiration.": "可用次数按最早到期排序。",
|
||||
"Available disk space": "可用磁盘空间",
|
||||
"Available Models": "可用模型",
|
||||
@@ -910,6 +915,7 @@
|
||||
"Channel:": "渠道:",
|
||||
"channel(s)? This action cannot be undone.": "渠道?此操作无法撤销。",
|
||||
"Channels": "渠道",
|
||||
"Channels {{channels}} · Groups {{groups}}": "渠道 {{channels}} · 分组 {{groups}}",
|
||||
"Channels and groups": "渠道与分组",
|
||||
"Channels deleted successfully": "渠道删除成功",
|
||||
"Character chat, storytelling, persona": "角色对话、剧情创作、人设扮演",
|
||||
@@ -1658,7 +1664,9 @@
|
||||
"Display name for this payment method.": "此支付方式的显示名称。",
|
||||
"Display name shown to users.": "显示给用户的名称。",
|
||||
"Display Options": "显示选项",
|
||||
"Display policy": "展示策略",
|
||||
"Display Token Statistics": "显示 Token 统计信息",
|
||||
"Displayed": "正常展示",
|
||||
"Displayed in": "显示单位",
|
||||
"Displays the mobile sidebar.": "显示移动侧边栏。",
|
||||
"Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.": "请勿过度信任此功能,IP 可能被伪造,请配合 nginx 和 cdn 等网关使用",
|
||||
@@ -2513,6 +2521,7 @@
|
||||
"Hidden {{count}} models from model square": "已从模型广场隐藏 {{count}} 个模型",
|
||||
"Hidden from {{group}}": "对 {{group}} 屏蔽",
|
||||
"Hidden from catalog": "在目录隐藏",
|
||||
"Hidden from the model square by metadata policy.": "已通过元数据策略在模型广场隐藏。",
|
||||
"Hide": "隐藏",
|
||||
"Hide API key": "隐藏 API 密钥",
|
||||
"Hide from catalog": "从目录隐藏",
|
||||
@@ -2869,6 +2878,8 @@
|
||||
"List of origins (one per line) allowed for Passkey registration and authentication.": "允许用于 Passkey 注册和身份验证的来源列表(每行一个)。",
|
||||
"List registered task plugins and bind them when creating or editing task plugin channels.": "列出已注册的任务插件,并在创建或编辑任务插件渠道时绑定它们。",
|
||||
"List view": "列表视图",
|
||||
"Listing also depends on metadata visibility and the user’s group access.": "是否展示还取决于元数据展示策略和用户可用分组。",
|
||||
"Listing hidden": "已隐藏",
|
||||
"Live refresh pauses when no task is running": "无任务运行时暂停自动刷新",
|
||||
"LLM Leaderboard": "LLM 排行榜",
|
||||
"LLM prompt helper": "LLM 辅助设计提示词",
|
||||
@@ -3021,6 +3032,7 @@
|
||||
"Metadata": "元信息",
|
||||
"Metadata changes": "资料变更",
|
||||
"Metadata language": "资料语言",
|
||||
"Metadata only": "仅元数据",
|
||||
"Metadata sync completed": "资料同步完成",
|
||||
"Metadata sync disabled": "已禁止资料同步",
|
||||
"Metadata sync failed": "资料同步失败",
|
||||
@@ -3046,6 +3058,7 @@
|
||||
"Minute": "分钟",
|
||||
"minutes": "分钟",
|
||||
"Missing code": "缺少代码",
|
||||
"Missing metadata": "缺元数据",
|
||||
"Missing Models": "缺失的模型",
|
||||
"Missing user data from Passkey login response": "Passkey 登录响应中缺少用户数据",
|
||||
"Mistral": "Mistral",
|
||||
@@ -3117,7 +3130,7 @@
|
||||
"Model selected": "已选择模型",
|
||||
"Model shown in model square": "已在模型广场显示",
|
||||
"Model Square": "模型广场",
|
||||
"Model square visibility": "模型广场展示",
|
||||
"Model square visibility": "广场展示",
|
||||
"Model Tags": "模型标签",
|
||||
"Model to use for testing": "用于测试的模型",
|
||||
"Model to use when testing channel connectivity": "测试渠道连接时使用的模型",
|
||||
@@ -3298,6 +3311,8 @@
|
||||
"No changes": "没有变更",
|
||||
"No changes made": "未进行任何更改",
|
||||
"No changes to save": "没有需要保存的更改",
|
||||
"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.": "暂无可用渠道,当前不会在模型广场展示。",
|
||||
"No channel selected": "未选择渠道",
|
||||
"No channel type found.": "未找到渠道类型。",
|
||||
"No channels available. Create your first channel to get started.": "没有可用的渠道。创建您的第一个渠道即可开始使用。",
|
||||
@@ -3306,6 +3321,7 @@
|
||||
"No channels selected": "未选择渠道",
|
||||
"No chat presets configured. Click \"Add chat preset\" to get started.": "未配置聊天预设。点击\"添加聊天预设\"开始。",
|
||||
"No chat presets match your search": "没有匹配的聊天预设",
|
||||
"No configured channel models match this metadata rule.": "没有渠道模型匹配此元数据规则。",
|
||||
"No conflict entries available.": "没有可用的冲突条目。",
|
||||
"No conflicts match your search.": "没有冲突匹配您的搜索。",
|
||||
"No connection info found in clipboard": "剪贴板中未找到连接信息",
|
||||
@@ -3356,6 +3372,7 @@
|
||||
"No mappings configured. Click \"Add Row\" to get started.": "未配置映射。点击 \"添加行\" 开始。",
|
||||
"No marketplace sources configured.": "尚未配置任何市场源。",
|
||||
"No matches found": "未找到匹配项",
|
||||
"No matching channels": "无匹配渠道",
|
||||
"No matching icons": "未找到匹配图标",
|
||||
"No matching items": "没有匹配项",
|
||||
"No matching results": "无匹配结果",
|
||||
@@ -3485,6 +3502,7 @@
|
||||
"Not in pricing table": "不在定价分组表中",
|
||||
"Not included": "未加入",
|
||||
"Not installed": "未安装",
|
||||
"Not listed": "隐藏",
|
||||
"Not provided by this source": "该源未提供",
|
||||
"Not recorded": "未记录",
|
||||
"Not registered": "未注册",
|
||||
@@ -3726,6 +3744,7 @@
|
||||
"Parsed {{count}} service account file(s)": "已解析 {{count}} 个服务账号文件",
|
||||
"Parsed plugin metadata": "解析出的插件元数据",
|
||||
"Partial Submission": "部分提交确认",
|
||||
"Partly shown": "部分展示",
|
||||
"Pass Headers": "透传请求头",
|
||||
"Pass request body directly to upstream": "将请求体直接传递给上游",
|
||||
"Pass specified request headers to upstream": "把指定请求头透传到上游请求",
|
||||
@@ -4394,6 +4413,7 @@
|
||||
"Required events:": "必需事件:",
|
||||
"Required provider, authentication, model, and group settings": "必填的供应商、鉴权、模型和分组设置",
|
||||
"Required to expose MjProxy-style image generation to end users.": "需要向终端用户开放 MjProxy 风格的图像生成。",
|
||||
"Requires an available channel and access to its group.": "仍需有可用渠道,并满足用户分组访问条件。",
|
||||
"Rerank": "重新排序",
|
||||
"Reroll": "重绘",
|
||||
"Research, analysis, scientific reasoning": "研究、分析与科学推理",
|
||||
@@ -4894,6 +4914,7 @@
|
||||
"socks5://user:pass@host:port": "socks5://user:pass@host:port",
|
||||
"Soft Errors": "软错误",
|
||||
"Some channels failed: {{errorMsg}}": "部分渠道失败:{{errorMsg}}",
|
||||
"Some matching models are hidden or have no available channel.": "部分匹配模型已隐藏或暂无可用渠道。",
|
||||
"Some plugin information could not be read": "部分插件信息无法读取",
|
||||
"Something went wrong!": "出现错误!",
|
||||
"Sonnet Model": "Sonnet 模型",
|
||||
@@ -5561,6 +5582,7 @@
|
||||
"Unable to read clipboard": "无法读取剪贴板",
|
||||
"Unauthorized": "未授权",
|
||||
"Unauthorized Access": "未经授权的访问",
|
||||
"Unavailable": "无法展示",
|
||||
"Unbind": "解绑",
|
||||
"Unbind failed": "解绑失败",
|
||||
"Unbound {{provider}}": "已解绑 {{provider}}",
|
||||
@@ -5899,6 +5921,7 @@
|
||||
"View model statistics and charts": "查看模型统计和图表",
|
||||
"View other accounts' audit logs": "查看其他账户审计日志",
|
||||
"View Pricing": "查看定价",
|
||||
"View pricing for {{model}}": "查看 {{model}} 的定价",
|
||||
"View scope": "查看范围",
|
||||
"View the complete details for this": "查看此条",
|
||||
"View the complete details for this log entry": "查看此日志条目的完整详情",
|
||||
@@ -5915,6 +5938,7 @@
|
||||
"vip": "vip",
|
||||
"VIP users with premium access": "拥有高级访问权限的 VIP 用户",
|
||||
"Visible": "可见",
|
||||
"Visible only to users with access to the model’s groups.": "仅向有权访问模型所属分组的用户展示。",
|
||||
"Vision": "视觉",
|
||||
"Vision, image / video, document chat": "视觉理解、图像 / 视频、文档对话",
|
||||
"Visit Settings → General and adjust quota options...": "访问设置 → 通用并调整配额选项...",
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user