Commit 0c76e4da by CaIon

feat(models): rework model/vendor management and pricing

Introduce a unified model management experience: catalog metadata
validation, vendor management, batch delete with channel/pricing
cleanup, model pricing snapshot editing with optimistic concurrency,
and an upstream ratio-sync flow with price cells. Move configuration
into dedicated pricing config/metadata-sync/vendor-management backend
services and add audit records for model/vendor/pricing mutations.

Rework the models page around vendors and model connections, add
model-pricing and vendor-management dialogs, and replace the shared
Select usages with the Combobox component across subscriptions,
plugins, OAuth presets, audit filters, and settings. Add the model
pricing panel and verify behavior with focused tests.
parent 6f233399
package controller
import (
"errors"
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
func GetModelPricingConfig(c *gin.Context) {
snapshot, err := model.GetModelPricingSnapshot(c.QueryArray("model"))
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, snapshot)
}
func UpdateModelPricingConfig(c *gin.Context) {
var request struct {
Changes []model.ModelPricingChange `json:"changes"`
}
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
if err := model.UpdateModelPricing(request.Changes); err != nil {
status := http.StatusBadRequest
if errors.Is(err, model.ErrModelPricingConflict) {
status = http.StatusConflict
}
c.JSON(status, gin.H{"success": false, "message": err.Error()})
return
}
names := make([]string, 0, len(request.Changes))
for _, change := range request.Changes {
names = append(names, change.ModelName)
}
recordManageAudit(c, "model.pricing.update", map[string]interface{}{"models": names})
common.ApiSuccess(c, gin.H{"updated_models": names})
}
...@@ -3,6 +3,7 @@ package controller ...@@ -3,6 +3,7 @@ package controller
import ( import (
"net/http" "net/http"
"github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
...@@ -20,6 +21,6 @@ func GetRatioConfig(c *gin.Context) { ...@@ -20,6 +21,6 @@ func GetRatioConfig(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
"data": ratio_setting.GetExposedData(), "data": billing_setting.GetPricingSyncData(map[string]any(ratio_setting.GetExposedData())),
}) })
} }
...@@ -139,6 +139,75 @@ func getLocalPricingSyncData() map[string]any { ...@@ -139,6 +139,75 @@ func getLocalPricingSyncData() map[string]any {
return data return data
} }
// effectivePricingSyncData follows the billing engine's mode precedence. An
// inactive expression and numeric settings covered by an active expression
// are not separate prices and must not appear as synchronization differences.
func effectivePricingSyncData(data map[string]any) map[string]any {
result := make(map[string]any, len(pricingSyncFields))
names := make(map[string]struct{})
for _, field := range pricingSyncFields {
entries := make(map[string]any)
for name, raw := range valueMap(data[field]) {
value := normalizeSyncValue(field, raw)
if numericPricingSyncFields[field] {
number, ok := value.(float64)
if !ok || math.IsNaN(number) || math.IsInf(number, 0) || number < 0 {
continue
}
}
entries[name] = value
names[name] = struct{}{}
}
result[field] = entries
}
modes := valueMap(result[billing_setting.BillingModeField])
expressions := valueMap(result[billing_setting.BillingExprField])
for name := range names {
expression, _ := expressions[name].(string)
if modes[name] == billing_setting.BillingModeTieredExpr {
if strings.TrimSpace(expression) == "" {
for _, field := range pricingSyncFields {
delete(valueMap(result[field]), name)
}
continue
}
expressions[name] = strings.TrimSpace(expression)
for field := range numericPricingSyncFields {
delete(valueMap(result[field]), name)
}
continue
}
delete(expressions, name)
modes[name] = billing_setting.BillingModeRatio
_, fixed := valueMap(result["model_price"])[name]
_, token := valueMap(result["model_ratio"])[name]
if !fixed && !token {
for _, field := range pricingSyncFields {
delete(valueMap(result[field]), name)
}
continue
}
if fixed {
for field := range numericPricingSyncFields {
if field != "model_price" {
delete(valueMap(result[field]), name)
}
}
}
}
return result
}
func modelPricingSyncValues(data map[string]any, name string) map[string]any {
values := make(map[string]any)
for _, field := range pricingSyncFields {
if value, exists := valueMap(data[field])[name]; exists {
values[field] = value
}
}
return values
}
func FetchUpstreamRatios(c *gin.Context) { func FetchUpstreamRatios(c *gin.Context) {
var req dto.UpstreamRequest var req dto.UpstreamRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
...@@ -381,9 +450,9 @@ func FetchUpstreamRatios(c *gin.Context) { ...@@ -381,9 +450,9 @@ func FetchUpstreamRatios(c *gin.Context) {
var pricingItems []struct { var pricingItems []struct {
ModelName string `json:"model_name"` ModelName string `json:"model_name"`
QuotaType int `json:"quota_type"` QuotaType int `json:"quota_type"`
ModelRatio float64 `json:"model_ratio"` ModelRatio *float64 `json:"model_ratio"`
ModelPrice float64 `json:"model_price"` ModelPrice *float64 `json:"model_price"`
CompletionRatio float64 `json:"completion_ratio"` CompletionRatio *float64 `json:"completion_ratio"`
CacheRatio *float64 `json:"cache_ratio"` CacheRatio *float64 `json:"cache_ratio"`
CreateCacheRatio *float64 `json:"create_cache_ratio"` CreateCacheRatio *float64 `json:"create_cache_ratio"`
ImageRatio *float64 `json:"image_ratio"` ImageRatio *float64 `json:"image_ratio"`
...@@ -413,16 +482,22 @@ func FetchUpstreamRatios(c *gin.Context) { ...@@ -413,16 +482,22 @@ func FetchUpstreamRatios(c *gin.Context) {
if item.ModelName == "" { if item.ModelName == "" {
continue continue
} }
if item.BillingMode == billing_setting.BillingModeTieredExpr && strings.TrimSpace(item.BillingExpr) != "" { if item.BillingMode == billing_setting.BillingModeTieredExpr {
billingModeMap[item.ModelName] = billing_setting.BillingModeTieredExpr billingModeMap[item.ModelName] = billing_setting.BillingModeTieredExpr
billingExprMap[item.ModelName] = item.BillingExpr billingExprMap[item.ModelName] = item.BillingExpr
continue
} }
if item.QuotaType == 1 { if item.QuotaType == 1 {
modelPriceMap[item.ModelName] = item.ModelPrice if item.ModelPrice != nil {
modelPriceMap[item.ModelName] = *item.ModelPrice
}
} else { } else {
modelRatioMap[item.ModelName] = item.ModelRatio if item.ModelRatio != nil {
// completionRatio 可能为 0,此时也直接赋值,保持与上游一致 modelRatioMap[item.ModelName] = *item.ModelRatio
completionRatioMap[item.ModelName] = item.CompletionRatio }
if item.CompletionRatio != nil {
completionRatioMap[item.ModelName] = *item.CompletionRatio
}
} }
if item.CacheRatio != nil { if item.CacheRatio != nil {
cacheRatioMap[item.ModelName] = *item.CacheRatio cacheRatioMap[item.ModelName] = *item.CacheRatio
...@@ -495,7 +570,7 @@ func FetchUpstreamRatios(c *gin.Context) { ...@@ -495,7 +570,7 @@ func FetchUpstreamRatios(c *gin.Context) {
wg.Wait() wg.Wait()
close(ch) close(ch)
localData := getLocalPricingSyncData() localData := effectivePricingSyncData(getLocalPricingSyncData())
var testResults []dto.TestResult var testResults []dto.TestResult
var successfulChannels []struct { var successfulChannels []struct {
...@@ -518,16 +593,39 @@ func FetchUpstreamRatios(c *gin.Context) { ...@@ -518,16 +593,39 @@ func FetchUpstreamRatios(c *gin.Context) {
successfulChannels = append(successfulChannels, struct { successfulChannels = append(successfulChannels, struct {
name string name string
data map[string]any data map[string]any
}{name: r.Name, data: r.Data}) }{name: r.Name, data: effectivePricingSyncData(r.Data)})
} }
} }
differences := buildDifferences(localData, successfulChannels) differences := buildDifferences(localData, successfulChannels)
type modelSyncPrices struct {
Current map[string]any `json:"current"`
Upstreams map[string]map[string]any `json:"upstreams"`
}
prices := make(map[string]modelSyncPrices, len(differences))
for name, fields := range differences {
row := modelSyncPrices{Current: modelPricingSyncValues(localData, name), Upstreams: make(map[string]map[string]any)}
_, expressionPriority := fields[billing_setting.BillingExprField]
for _, channel := range successfulChannels {
candidate := modelPricingSyncValues(channel.data, name)
if expressionPriority && candidate[billing_setting.BillingModeField] != billing_setting.BillingModeTieredExpr {
continue
}
_, hasRatio := candidate["model_ratio"]
_, hasPrice := candidate["model_price"]
_, hasExpression := candidate[billing_setting.BillingExprField]
if hasRatio || hasPrice || hasExpression {
row.Upstreams[channel.name] = candidate
}
}
prices[name] = row
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"data": gin.H{ "data": gin.H{
"differences": differences, "differences": differences,
"prices": prices,
"test_results": testResults, "test_results": testResults,
}, },
}) })
...@@ -538,6 +636,16 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { ...@@ -538,6 +636,16 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
data map[string]any data map[string]any
}) map[string]map[string]dto.DifferenceItem { }) map[string]map[string]dto.DifferenceItem {
differences := make(map[string]map[string]dto.DifferenceItem) differences := make(map[string]map[string]dto.DifferenceItem)
localData = effectivePricingSyncData(localData)
normalizedChannels := make([]struct {
name string
data map[string]any
}, 0, len(successfulChannels))
for _, channel := range successfulChannels {
channel.data = effectivePricingSyncData(channel.data)
normalizedChannels = append(normalizedChannels, channel)
}
successfulChannels = normalizedChannels
allModels := make(map[string]struct{}) allModels := make(map[string]struct{})
...@@ -591,7 +699,16 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { ...@@ -591,7 +699,16 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
} }
for modelName := range allModels { for modelName := range allModels {
expressionPriority := valueMap(localData[billing_setting.BillingModeField])[modelName] == billing_setting.BillingModeTieredExpr
for _, channel := range successfulChannels {
if valueMap(channel.data[billing_setting.BillingModeField])[modelName] == billing_setting.BillingModeTieredExpr {
expressionPriority = true
}
}
for _, ratioType := range pricingSyncFields { for _, ratioType := range pricingSyncFields {
if expressionPriority && numericPricingSyncFields[ratioType] {
continue
}
var localValue interface{} = nil var localValue interface{} = nil
if val, exists := valueMap(localData[ratioType])[modelName]; exists { if val, exists := valueMap(localData[ratioType])[modelName]; exists {
localValue = normalizeSyncValue(ratioType, val) localValue = normalizeSyncValue(ratioType, val)
...@@ -603,6 +720,9 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { ...@@ -603,6 +720,9 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
hasDifference := false hasDifference := false
for _, channel := range successfulChannels { for _, channel := range successfulChannels {
if expressionPriority && valueMap(channel.data[billing_setting.BillingModeField])[modelName] != billing_setting.BillingModeTieredExpr {
continue
}
var upstreamValue interface{} = nil var upstreamValue interface{} = nil
if val, exists := valueMap(channel.data[ratioType])[modelName]; exists { if val, exists := valueMap(channel.data[ratioType])[modelName]; exists {
......
package controller
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/config"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/http"
"net/http/httptest"
)
func TestPricingSyncExpressionPriority(t *testing.T) {
expression := `tier("base", p * 2 + c * 8 + cr * 0)`
cases := []struct {
name string
local map[string]any
source map[string]any
wantFields []string
}{
{"equal expressions suppress stale ratios", map[string]any{"billing_mode": map[string]string{"m": "tiered_expr"}, "billing_expr": map[string]string{"m": expression}}, map[string]any{"billing_mode": map[string]string{"m": "tiered_expr"}, "billing_expr": map[string]string{"m": expression}, "model_ratio": map[string]float64{"m": 3}, "model_price": map[string]float64{"m": 2}}, nil},
{"local expression excludes legacy-only source", map[string]any{"billing_mode": map[string]string{"m": "tiered_expr"}, "billing_expr": map[string]string{"m": expression}}, map[string]any{"model_ratio": map[string]float64{"m": 3}, "completion_ratio": map[string]float64{"m": 2}}, nil},
{"expression imports without legacy conflicts", map[string]any{"model_ratio": map[string]float64{"m": 1}}, map[string]any{"billing_mode": map[string]string{"m": "tiered_expr"}, "billing_expr": map[string]string{"m": expression}, "model_ratio": map[string]float64{"m": 3}, "model_price": map[string]float64{"m": 2}}, []string{"billing_mode", "billing_expr"}},
{"inactive expression follows explicit ratio mode", map[string]any{"model_ratio": map[string]float64{"m": 1}}, map[string]any{"billing_mode": map[string]string{"m": "ratio"}, "billing_expr": map[string]string{"m": expression}, "model_ratio": map[string]float64{"m": 3}}, []string{"model_ratio"}},
{"empty active expression never imports a false free price", map[string]any{}, map[string]any{"billing_mode": map[string]string{"m": "tiered_expr"}, "billing_expr": map[string]string{"m": " "}, "model_ratio": map[string]float64{"m": 0}}, nil},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
diff := buildDifferences(tt.local, []struct {
name string
data map[string]any
}{{"source", tt.source}})
fields := make([]string, 0, len(diff["m"]))
for field := range diff["m"] {
fields = append(fields, field)
}
assert.ElementsMatch(t, tt.wantFields, fields)
})
}
}
func TestRatioConfigExportsEffectiveExpressions(t *testing.T) {
before := config.GlobalConfig.ExportAllConfigs()
expose := ratio_setting.IsExposeRatioEnabled()
t.Cleanup(func() {
config.UpdateConfigFromMap(config.GlobalConfig.Get("billing_setting"), map[string]string{"billing_mode": before["billing_setting.billing_mode"], "billing_expr": before["billing_setting.billing_expr"]})
ratio_setting.SetExposeRatioEnabled(expose)
})
config.UpdateConfigFromMap(config.GlobalConfig.Get("billing_setting"), map[string]string{"billing_mode": `{"sync-export":"tiered_expr"}`, "billing_expr": `{"sync-export":"tier(\"base\", p * 2)"}`})
ratio_setting.SetExposeRatioEnabled(true)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/ratio_config", nil)
GetRatioConfig(c)
var response struct {
Success bool
Data map[string]any
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
assert.Equal(t, billing_setting.BillingModeTieredExpr, valueMap(response.Data["billing_mode"])["sync-export"])
assert.Equal(t, `tier("base", p * 2)`, valueMap(response.Data["billing_expr"])["sync-export"])
}
func TestPricingSyncCompleteSourcesAndArrayFormats(t *testing.T) {
before := config.GlobalConfig.ExportAllConfigs()
oldRatios, oldCompletion := ratio_setting.ModelRatio2JSONString(), ratio_setting.CompletionRatio2JSONString()
t.Cleanup(func() {
config.UpdateConfigFromMap(config.GlobalConfig.Get("billing_setting"), map[string]string{"billing_mode": before["billing_setting.billing_mode"], "billing_expr": before["billing_setting.billing_expr"]})
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(oldRatios))
require.NoError(t, ratio_setting.UpdateCompletionRatioByJSONString(oldCompletion))
})
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"sync-token":1}`))
require.NoError(t, ratio_setting.UpdateCompletionRatioByJSONString(`{"sync-token":2}`))
expression := `len <= 200000 ? tier("short", p * 2 + c * 8 + cr * 0) : tier("long", p * 4 + c * 12)`
expressions, err := common.Marshal(map[string]string{"sync-already": expression})
require.NoError(t, err)
config.UpdateConfigFromMap(config.GlobalConfig.Get("billing_setting"), map[string]string{"billing_mode": `{"sync-already":"tiered_expr"}`, "billing_expr": string(expressions)})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var data any
if r.URL.Path == "/ratio_config" {
data = map[string]any{
"billing_mode": map[string]string{"sync-already": "tiered_expr", "sync-expression": "tiered_expr"},
"billing_expr": map[string]string{"sync-already": expression, "sync-expression": expression},
"model_ratio": map[string]float64{"sync-already": 9, "sync-expression": 9, "sync-token": 1},
"model_price": map[string]float64{"sync-expression": 4},
"completion_ratio": map[string]float64{"sync-token": 4},
"cache_ratio": map[string]float64{"sync-token": 0},
}
} else {
data = []map[string]any{
{"model_name": "sync-already", "model_ratio": 5, "completion_ratio": 3},
{"model_name": "sync-expression", "model_ratio": 2, "model_price": 1},
{"model_name": "sync-array-expression", "billing_mode": "tiered_expr", "billing_expr": expression, "quota_type": 1, "model_price": 0},
{"model_name": "sync-unpriced"},
{"model_name": "sync-invalid-expression", "billing_mode": "tiered_expr", "billing_expr": "", "model_ratio": 0},
{"model_name": "sync-free", "model_ratio": 0, "completion_ratio": 0},
}
}
encoded, err := common.Marshal(map[string]any{"success": true, "data": data})
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(encoded)
}))
defer server.Close()
var response struct {
Success bool
Data struct {
Differences map[string]map[string]dto.DifferenceItem
Prices map[string]struct {
Current map[string]any
Upstreams map[string]map[string]any
}
TestResults []dto.TestResult `json:"test_results"`
}
}
body := map[string]any{"upstreams": []map[string]any{
{"id": 1, "name": "Expressions", "base_url": server.URL, "endpoint": "/ratio_config"},
{"id": 2, "name": "Legacy", "base_url": server.URL, "endpoint": "/pricing"},
}}
recorder := modelManagementRequest(t, FetchUpstreamRatios, http.MethodPost, "/api/channel/fetch_upstream_ratios", body, &response)
require.True(t, response.Success, recorder.Body.String())
require.Len(t, response.Data.TestResults, 2)
for _, result := range response.Data.TestResults {
require.Equal(t, "success", result.Status, result.Error)
}
assert.NotContains(t, response.Data.Differences, "sync-already")
assert.NotContains(t, response.Data.Differences, "sync-unpriced")
assert.NotContains(t, response.Data.Differences, "sync-invalid-expression")
assert.Equal(t, map[string]any{"billing_mode": "tiered_expr", "billing_expr": expression}, response.Data.Prices["sync-expression"].Upstreams["Expressions(1)"])
assert.NotContains(t, response.Data.Prices["sync-expression"].Upstreams, "Legacy(2)")
assert.Equal(t, map[string]any{"billing_mode": "tiered_expr", "billing_expr": expression}, response.Data.Prices["sync-array-expression"].Upstreams["Legacy(2)"])
assert.Equal(t, float64(1), response.Data.Prices["sync-token"].Upstreams["Expressions(1)"]["model_ratio"], "unchanged base prices are included for a complete price preview")
assert.Equal(t, float64(4), response.Data.Prices["sync-token"].Upstreams["Expressions(1)"]["completion_ratio"])
assert.Equal(t, float64(0), response.Data.Prices["sync-token"].Upstreams["Expressions(1)"]["cache_ratio"])
assert.Equal(t, float64(0), response.Data.Prices["sync-free"].Upstreams["Legacy(2)"]["model_ratio"])
}
package controller package controller
import ( import (
"errors"
"net/http"
"strconv" "strconv"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
...@@ -9,28 +11,14 @@ import ( ...@@ -9,28 +11,14 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// GetAllVendors 获取供应商列表(分页) // GetAllVendors uses the same paged filters and counts as the search endpoint.
func GetAllVendors(c *gin.Context) { func GetAllVendors(c *gin.Context) { SearchVendors(c) }
pageInfo := common.GetPageQuery(c)
vendors, err := model.GetAllVendors(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
var total int64
model.DB.Model(&model.Vendor{}).Count(&total)
pageInfo.SetTotal(int(total))
pageInfo.SetItems(vendors)
common.ApiSuccess(c, pageInfo)
}
// SearchVendors 搜索供应商
func SearchVendors(c *gin.Context) { func SearchVendors(c *gin.Context) {
keyword := c.Query("keyword")
pageInfo := common.GetPageQuery(c) pageInfo := common.GetPageQuery(c)
vendors, total, err := model.SearchVendors(keyword, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) vendors, total, err := model.SearchVendors(c.Query("keyword"), pageInfo.GetStartIdx(), pageInfo.GetPageSize(), c.Query("association"))
if err != nil { if err != nil {
common.ApiError(c, err) vendorAPIError(c, err)
return return
} }
pageInfo.SetTotal(int(total)) pageInfo.SetTotal(int(total))
...@@ -43,12 +31,12 @@ func GetVendorMeta(c *gin.Context) { ...@@ -43,12 +31,12 @@ func GetVendorMeta(c *gin.Context) {
idStr := c.Param("id") idStr := c.Param("id")
id, err := strconv.Atoi(idStr) id, err := strconv.Atoi(idStr)
if err != nil { if err != nil {
common.ApiError(c, err) vendorAPIError(c, err)
return return
} }
v, err := model.GetVendorByID(id) v, err := model.GetVendorByID(id)
if err != nil { if err != nil {
common.ApiError(c, err) vendorAPIError(c, err)
return return
} }
common.ApiSuccess(c, v) common.ApiSuccess(c, v)
...@@ -58,26 +46,14 @@ func GetVendorMeta(c *gin.Context) { ...@@ -58,26 +46,14 @@ func GetVendorMeta(c *gin.Context) {
func CreateVendorMeta(c *gin.Context) { func CreateVendorMeta(c *gin.Context) {
var v model.Vendor var v model.Vendor
if err := c.ShouldBindJSON(&v); err != nil { if err := c.ShouldBindJSON(&v); err != nil {
common.ApiError(c, err) vendorAPIError(c, err)
return
}
if v.Name == "" {
common.ApiErrorMsg(c, "供应商名称不能为空")
return return
} }
// 创建前先检查名称
if dup, err := model.IsVendorNameDuplicated(0, v.Name); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "供应商名称已存在")
return
}
if err := v.Insert(); err != nil { if err := v.Insert(); err != nil {
common.ApiError(c, err) vendorAPIError(c, err)
return return
} }
recordManageAudit(c, "vendor.metadata.save", map[string]any{"vendor_id": v.Id, "name": v.Name})
common.ApiSuccess(c, &v) common.ApiSuccess(c, &v)
} }
...@@ -85,26 +61,18 @@ func CreateVendorMeta(c *gin.Context) { ...@@ -85,26 +61,18 @@ func CreateVendorMeta(c *gin.Context) {
func UpdateVendorMeta(c *gin.Context) { func UpdateVendorMeta(c *gin.Context) {
var v model.Vendor var v model.Vendor
if err := c.ShouldBindJSON(&v); err != nil { if err := c.ShouldBindJSON(&v); err != nil {
common.ApiError(c, err) vendorAPIError(c, err)
return return
} }
if v.Id == 0 { if v.Id == 0 {
common.ApiErrorMsg(c, "缺少供应商 ID") common.ApiErrorMsg(c, "缺少供应商 ID")
return return
} }
// 名称冲突检查
if dup, err := model.IsVendorNameDuplicated(v.Id, v.Name); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "供应商名称已存在")
return
}
if err := v.Update(); err != nil { if err := v.Update(); err != nil {
common.ApiError(c, err) vendorAPIError(c, err)
return return
} }
recordManageAudit(c, "vendor.metadata.save", map[string]any{"vendor_id": v.Id, "name": v.Name})
common.ApiSuccess(c, &v) common.ApiSuccess(c, &v)
} }
...@@ -113,12 +81,58 @@ func DeleteVendorMeta(c *gin.Context) { ...@@ -113,12 +81,58 @@ func DeleteVendorMeta(c *gin.Context) {
idStr := c.Param("id") idStr := c.Param("id")
id, err := strconv.Atoi(idStr) id, err := strconv.Atoi(idStr)
if err != nil { if err != nil {
common.ApiError(c, err) vendorAPIError(c, err)
return return
} }
if err := model.DB.Delete(&model.Vendor{}, id).Error; err != nil { if err := model.DeleteVendors([]int{id}); err != nil {
common.ApiError(c, err) vendorAPIError(c, err)
return return
} }
recordManageAudit(c, "vendor.metadata.delete", map[string]any{"vendor_id": id})
common.ApiSuccess(c, nil) common.ApiSuccess(c, nil)
} }
func vendorAPIError(c *gin.Context, err error) {
status := http.StatusBadRequest
payload := gin.H{"success": false, "message": err.Error()}
var references *model.VendorReferenceError
if errors.Is(err, model.ErrVendorConflict) {
status = http.StatusConflict
payload["code"] = "VENDOR_CONFLICT"
}
if errors.As(err, &references) {
status = http.StatusConflict
payload["code"] = "VENDOR_REFERENCED"
payload["reference_counts"] = references.Counts
}
c.JSON(status, payload)
}
func PreviewVendorOperation(c *gin.Context) {
var request model.VendorOperation
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
vendorAPIError(c, err)
return
}
preview, err := model.PreviewVendorOperation(request)
if err != nil {
vendorAPIError(c, err)
return
}
common.ApiSuccess(c, preview)
}
func ApplyVendorOperation(c *gin.Context) {
var request model.VendorOperation
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
vendorAPIError(c, err)
return
}
result, err := model.ApplyVendorOperation(request)
if err != nil {
vendorAPIError(c, err)
return
}
recordManageAudit(c, "vendor."+request.Action, map[string]any{"source_vendor_ids": request.VendorIDs, "target_vendor_id": request.TargetVendorID, "updated_model_ids": result.UpdatedModels, "deleted_vendor_ids": result.DeletedVendors})
common.ApiSuccess(c, result)
}
...@@ -286,7 +286,7 @@ func (channel *Channel) UpdateAbilities(tx *gorm.DB) error { ...@@ -286,7 +286,7 @@ func (channel *Channel) UpdateAbilities(tx *gorm.DB) error {
} }
// Then add new abilities // Then add new abilities
models_ := strings.Split(channel.Models, ",") models_ := channel.GetModels()
groups_ := strings.Split(channel.Group, ",") groups_ := strings.Split(channel.Group, ",")
abilitySet := make(map[string]struct{}) abilitySet := make(map[string]struct{})
abilities := make([]Ability, 0, len(models_)) abilities := make([]Ability, 0, len(models_))
......
...@@ -58,7 +58,7 @@ func InitChannelCache() { ...@@ -58,7 +58,7 @@ func InitChannelCache() {
} }
groups := strings.Split(channel.Group, ",") groups := strings.Split(channel.Group, ",")
for _, group := range groups { for _, group := range groups {
models := strings.Split(channel.Models, ",") models := channel.GetModels()
for _, model := range models { for _, model := range models {
if _, ok := newGroup2model2channels[group][model]; !ok { if _, ok := newGroup2model2channels[group][model]; !ok {
newGroup2model2channels[group][model] = make([]int, 0) newGroup2model2channels[group][model] = make([]int, 0)
......
package model package model
import ( import (
"errors"
"sort"
"strconv" "strconv"
"strings" "strings"
...@@ -22,18 +24,19 @@ type BoundChannel struct { ...@@ -22,18 +24,19 @@ type BoundChannel struct {
} }
type Model struct { type Model struct {
Id int `json:"id"` Id int `json:"id"`
ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name_delete_at,priority:1"` ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name_delete_at,priority:1"`
Description string `json:"description,omitempty" gorm:"type:text"` Description string `json:"description,omitempty" gorm:"type:text"`
Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"` Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"`
Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"` Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"`
VendorID int `json:"vendor_id,omitempty" gorm:"index"` VendorID int `json:"vendor_id,omitempty" gorm:"index"`
Endpoints string `json:"endpoints,omitempty" gorm:"type:text"` Endpoints string `json:"endpoints,omitempty" gorm:"type:text"`
Status int `json:"status" gorm:"default:1"` SupportedEndpoints []string `json:"supported_endpoints,omitempty" gorm:"-"`
SyncOfficial int `json:"sync_official" gorm:"default:1"` Status int `json:"status" gorm:"default:1"`
CreatedTime int64 `json:"created_time" gorm:"bigint"` SyncOfficial int `json:"sync_official" gorm:"default:1"`
UpdatedTime int64 `json:"updated_time" gorm:"bigint"` CreatedTime int64 `json:"created_time" gorm:"bigint"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name_delete_at,priority:2"` UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name_delete_at,priority:2"`
BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"` BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"`
EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"` EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"`
...@@ -45,24 +48,19 @@ type Model struct { ...@@ -45,24 +48,19 @@ type Model struct {
} }
func (mi *Model) Insert() error { func (mi *Model) Insert() error {
now := common.GetTimestamp() return metadataTransaction(func(tx *gorm.DB) error {
mi.CreatedTime = now if err := validateModelVendor(tx, mi.VendorID); err != nil {
mi.UpdatedTime = now return err
}
// 保存原始值(因为 Create 后可能被 GORM 的 default 标签覆盖为 1) now := common.GetTimestamp()
originalStatus := mi.Status mi.CreatedTime, mi.UpdatedTime = now, now
originalSyncOfficial := mi.SyncOfficial status, syncOfficial := mi.Status, mi.SyncOfficial
if err := tx.Create(mi).Error; err != nil {
// 先创建记录(GORM 会对零值字段应用默认值) return err
if err := DB.Create(mi).Error; err != nil { }
return err mi.Status, mi.SyncOfficial = status, syncOfficial
} return tx.Model(&Model{}).Where("id = ?", mi.Id).Updates(map[string]any{"status": status, "sync_official": syncOfficial}).Error
})
// 使用保存的原始值进行更新,确保零值能正确保存
return DB.Model(&Model{}).Where("id = ?", mi.Id).Updates(map[string]interface{}{
"status": originalStatus,
"sync_official": originalSyncOfficial,
}).Error
} }
func IsModelNameDuplicated(id int, name string) (bool, error) { func IsModelNameDuplicated(id int, name string) (bool, error) {
...@@ -75,15 +73,127 @@ func IsModelNameDuplicated(id int, name string) (bool, error) { ...@@ -75,15 +73,127 @@ func IsModelNameDuplicated(id int, name string) (bool, error) {
} }
func (mi *Model) Update() error { func (mi *Model) Update() error {
mi.UpdatedTime = common.GetTimestamp() return metadataTransaction(func(tx *gorm.DB) error {
// 使用 Select 强制更新所有字段,包括零值 if err := validateModelVendor(tx, mi.VendorID); err != nil {
return DB.Model(&Model{}).Where("id = ?", mi.Id). return err
Select("model_name", "description", "icon", "tags", "vendor_id", "endpoints", "status", "sync_official", "name_rule", "updated_time"). }
Updates(mi).Error mi.UpdatedTime = common.GetTimestamp()
return tx.Model(&Model{}).Where("id = ?", mi.Id).
Select("model_name", "description", "icon", "tags", "vendor_id", "endpoints", "status", "sync_official", "name_rule", "updated_time").Updates(mi).Error
})
} }
func (mi *Model) Delete() error { func (mi *Model) Delete() error {
return DB.Delete(mi).Error _, err := DeleteModelMetadata([]int{mi.Id}, false, false)
return err
}
type ModelDeleteResult struct {
DeletedCount int `json:"deleted_count"`
UpdatedChannels int `json:"updated_channels"`
}
// DeleteModelMetadata optionally removes exact model names from every channel.
// Channel removal requires exact-match metadata records. Pricing removal
// clears the selected names without expanding metadata matching rules.
func DeleteModelMetadata(ids []int, removeFromChannels, removePricing bool) (ModelDeleteResult, error) {
result := ModelDeleteResult{}
if len(ids) == 0 || len(ids) > 1000 {
return result, errors.New("select between 1 and 1000 models")
}
selected := make(map[int]struct{}, len(ids))
for _, id := range ids {
if id <= 0 {
return result, errors.New("invalid model ID")
}
selected[id] = struct{}{}
}
modelIDs := make([]int, 0, len(selected))
for id := range selected {
modelIDs = append(modelIDs, id)
}
sort.Ints(modelIDs)
names := make(map[string]struct{}, len(modelIDs))
deleteRecords := func(tx *gorm.DB) error {
var records []Model
if err := lockForUpdate(tx).Where("id IN ?", modelIDs).Order("id").Find(&records).Error; err != nil {
return err
}
if len(records) != len(modelIDs) {
return errors.New("selected models changed; reload before deleting")
}
for _, record := range records {
if removeFromChannels && record.NameRule != NameRuleExact {
return errors.New("only exact-match models can be removed from channels")
}
names[record.ModelName] = struct{}{}
}
if removeFromChannels {
var channels []Channel
// Read only routing fields. Lock channels in a consistent order, then
// update models and abilities in the same transaction as metadata.
if err := lockForUpdate(tx).Select("id", "models", "status", "group", "priority", "weight", "tag").Order("id").Find(&channels).Error; err != nil {
return err
}
for _, channel := range channels {
models := channel.GetModels()
remaining := make([]string, 0, len(models))
for _, name := range models {
if _, remove := names[strings.TrimSpace(name)]; !remove {
remaining = append(remaining, name)
}
}
if len(remaining) == len(models) {
continue
}
channel.Models = strings.Join(remaining, ",")
if err := tx.Model(&Channel{}).Where("id = ?", channel.Id).Update("models", channel.Models).Error; err != nil {
return err
}
if err := channel.UpdateAbilities(tx); err != nil {
return err
}
result.UpdatedChannels++
}
}
if err := tx.Where("id IN ?", modelIDs).Delete(&Model{}).Error; err != nil {
return err
}
result.DeletedCount = len(records)
return nil
}
var err error
if removePricing {
// Use the pricing mutation path so both option persistence and runtime
// publication stay serialized with ordinary pricing saves. All database
// writes share one transaction; runtime prices publish only after commit.
metadataMutationMu.Lock()
defer metadataMutationMu.Unlock()
err = mutateModelPricingOptions(func(tx *gorm.DB, values map[string]map[string]any) error {
if err := lockMetadataMutation(tx); err != nil {
return err
}
if err := deleteRecords(tx); err != nil {
return err
}
for _, entries := range values {
for name := range names {
delete(entries, name)
}
}
return nil
})
} else {
err = metadataTransaction(deleteRecords)
}
if err != nil {
return ModelDeleteResult{}, err
}
if result.UpdatedChannels > 0 {
InitChannelCache()
}
RefreshPricing()
return result, nil
} }
func GetVendorModelCounts() (map[int64]int64, error) { func GetVendorModelCounts() (map[int64]int64, error) {
...@@ -109,30 +219,21 @@ func GetAllModels(offset int, limit int) ([]*Model, error) { ...@@ -109,30 +219,21 @@ func GetAllModels(offset int, limit int) ([]*Model, error) {
return models, err return models, err
} }
func GetBoundChannelsByModelsMap(modelNames []string) (map[string][]BoundChannel, error) { // ModelConnection describes an enabled route independently of catalog visibility or price.
result := make(map[string][]BoundChannel) type ModelConnection struct {
if len(modelNames) == 0 { AbilityWithChannel
return result, nil ChannelName string `json:"channel_name"`
} }
type row struct {
Model string func GetModelConnections() ([]ModelConnection, error) {
Name string var connections []ModelConnection
Type int err := DB.Table("abilities").
} Select("abilities.*, channels.type as channel_type, channels.name as channel_name").
var rows []row Joins("JOIN channels ON abilities.channel_id = channels.id").
err := DB.Table("channels"). Where("abilities.enabled = ? AND channels.status = ?", true, common.ChannelStatusEnabled).
Select("abilities.model as model, channels.name as name, channels.type as type"). Order("abilities.model, abilities.channel_id").
Joins("JOIN abilities ON abilities.channel_id = channels.id"). Scan(&connections).Error
Where("abilities.model IN ? AND abilities.enabled = ?", modelNames, true). return connections, err
Distinct().
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, r := range rows {
result[r.Model] = append(result[r.Model], BoundChannel{Name: r.Name, Type: r.Type})
}
return result, nil
} }
func normalizeLookupValues(values []string) []string { func normalizeLookupValues(values []string) []string {
......
package model
import (
"crypto/sha256"
"errors"
"fmt"
"strings"
"sync"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var metadataMutationMu sync.Mutex
// metadataTransaction serializes changes to vendors and model ownership before
// acquiring model/vendor row locks. The option anchor also covers empty tables.
func metadataTransaction(change func(*gorm.DB) error) error {
metadataMutationMu.Lock()
defer metadataMutationMu.Unlock()
return DB.Transaction(func(tx *gorm.DB) error {
if err := lockMetadataMutation(tx); err != nil {
return err
}
return change(tx)
})
}
func lockMetadataMutation(tx *gorm.DB) error {
anchor := Option{Key: "metadata_sync_lock", Value: ""}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&anchor).Error; err != nil {
return err
}
return lockForUpdate(tx).Where(commonKeyCol+" = ?", anchor.Key).First(&anchor).Error
}
var ErrMetadataSyncConflict = errors.New("metadata changed; preview again before applying")
var MetadataSyncFields = []string{"description", "icon", "tags", "vendor", "endpoints", "name_rule", "status"}
type MetadataValues struct {
Description string `json:"description"`
Icon string `json:"icon"`
Tags string `json:"tags"`
Vendor string `json:"vendor"`
Endpoints string `json:"endpoints"`
NameRule int `json:"name_rule"`
Status int `json:"status"`
}
type MetadataSyncSelection struct {
ModelName string `json:"model_name"`
RecordVersion string `json:"record_version"`
Create bool `json:"create"`
Fields []string `json:"fields"`
}
type MetadataSyncUpdate struct {
MetadataSyncSelection
Values MetadataValues
}
type MetadataSyncResult struct {
CreatedModels []string `json:"created_models"`
UpdatedModels []MetadataSyncSelection `json:"updated_models"`
CreatedVendors []string `json:"created_vendors"`
}
func MetadataRecordVersion(local *Model, localVendor, upstreamVendor *Vendor) string {
encoded, _ := common.Marshal([]any{local, localVendor, upstreamVendor})
return fmt.Sprintf("%x", sha256.Sum256(encoded))
}
func GetMetadataSyncState(db *gorm.DB) (map[string]*Model, map[string]*Vendor, error) {
var models []*Model
var vendors []*Vendor
if err := db.Session(&gorm.Session{}).Order("id").Find(&models).Error; err != nil {
return nil, nil, err
}
if err := db.Session(&gorm.Session{}).Order("id").Find(&vendors).Error; err != nil {
return nil, nil, err
}
modelMap := make(map[string]*Model, len(models))
vendorMap := make(map[string]*Vendor, len(vendors))
for _, item := range models {
modelMap[item.ModelName] = item
}
for _, item := range vendors {
vendorMap[item.Name] = item
}
return modelMap, vendorMap, nil
}
func ValidateMetadataValues(values MetadataValues) error {
if values.NameRule < NameRuleExact || values.NameRule > NameRuleSuffix {
return errors.New("invalid metadata matching rule")
}
if values.Status != 0 && values.Status != 1 {
return errors.New("invalid catalog visibility")
}
return ValidateModelEndpoints(values.Endpoints)
}
// ValidateModelEndpoints accepts the existing map form (custom paths) and
// type-array form (declared protocols), but never arbitrary JSON scalars.
func ValidateModelEndpoints(raw string) error {
if strings.TrimSpace(raw) == "" {
return nil
}
var value any
if err := common.UnmarshalJsonStr(raw, &value); err != nil {
return fmt.Errorf("invalid endpoints: %w", err)
}
switch endpoints := value.(type) {
case []any:
for _, endpoint := range endpoints {
if text, ok := endpoint.(string); !ok || strings.TrimSpace(text) == "" {
return errors.New("endpoint types must be non-empty strings")
}
}
case map[string]any:
for key, endpoint := range endpoints {
if strings.TrimSpace(key) == "" {
return errors.New("endpoint type is required")
}
switch details := endpoint.(type) {
case string:
if !strings.HasPrefix(details, "/") {
return errors.New("endpoint paths must start with /")
}
case map[string]any:
path, _ := details["path"].(string)
if !strings.HasPrefix(path, "/") {
return errors.New("endpoint paths must start with /")
}
if method, exists := details["method"]; exists {
switch method {
case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS":
default:
return errors.New("invalid endpoint HTTP method")
}
}
default:
return errors.New("endpoint configuration must be a path or object")
}
}
default:
return errors.New("endpoints must be a JSON object or array")
}
return nil
}
func ApplyMetadataSync(updates []MetadataSyncUpdate, upstreamVendors map[string]Vendor) (*MetadataSyncResult, error) {
if len(updates) == 0 {
return nil, errors.New("select metadata changes before applying")
}
seen := make(map[string]bool)
for _, update := range updates {
if seen[update.ModelName] || strings.TrimSpace(update.ModelName) == "" {
return nil, errors.New("invalid or duplicate model selection")
}
seen[update.ModelName] = true
if update.RecordVersion == "" {
return nil, ErrMetadataSyncConflict
}
if err := ValidateMetadataValues(update.Values); err != nil {
return nil, err
}
if !update.Create && len(update.Fields) == 0 {
return nil, errors.New("select fields to update")
}
allowed := make(map[string]bool)
for _, field := range MetadataSyncFields {
allowed[field] = true
}
for _, field := range update.Fields {
if !allowed[field] {
return nil, fmt.Errorf("unsupported metadata field: %s", field)
}
}
}
result := &MetadataSyncResult{CreatedModels: []string{}, UpdatedModels: []MetadataSyncSelection{}, CreatedVendors: []string{}}
err := metadataTransaction(func(tx *gorm.DB) error {
locals, vendors, err := GetMetadataSyncState(lockForUpdate(tx))
if err != nil {
return err
}
vendorByID := make(map[int]*Vendor)
for _, vendor := range vendors {
vendorByID[vendor.Id] = vendor
}
// Verify the entire selection before any write, including shared vendors.
for _, update := range updates {
local := locals[update.ModelName]
if local != nil && local.SyncOfficial == 0 {
return fmt.Errorf("metadata sync is disabled for %s", update.ModelName)
}
var localVendor *Vendor
if local != nil {
localVendor = vendorByID[local.VendorID]
}
if MetadataRecordVersion(local, localVendor, FindMetadataVendor(vendors, update.Values.Vendor)) != update.RecordVersion {
return fmt.Errorf("%w: %s", ErrMetadataSyncConflict, update.ModelName)
}
if update.Create && local != nil || !update.Create && local == nil {
return ErrMetadataSyncConflict
}
}
for _, update := range updates {
fields := make(map[string]any)
selected := update.Fields
if update.Create {
selected = MetadataSyncFields
}
for _, field := range selected {
switch field {
case "description":
fields[field] = update.Values.Description
case "icon":
fields[field] = update.Values.Icon
case "tags":
fields[field] = update.Values.Tags
case "endpoints":
fields[field] = update.Values.Endpoints
case "name_rule":
fields[field] = update.Values.NameRule
case "status":
fields[field] = update.Values.Status
case "vendor":
vendorID := 0
name := update.Values.Vendor
if name != "" {
vendor := FindMetadataVendor(vendors, name)
if vendor == nil {
up, ok := upstreamVendors[name]
if !ok {
return fmt.Errorf("upstream vendor not found: %s", name)
}
vendor = &Vendor{Name: name, Description: up.Description, Icon: up.Icon, Status: 1, CreatedTime: common.GetTimestamp(), UpdatedTime: common.GetTimestamp()}
if err := validateVendorMetadata(tx, vendor); err != nil {
return err
}
if err := tx.Create(vendor).Error; err != nil {
return err
}
vendors[name] = vendor
result.CreatedVendors = append(result.CreatedVendors, name)
}
vendorID = vendor.Id
}
fields["vendor_id"] = vendorID
}
}
fields["updated_time"] = common.GetTimestamp()
if update.Create {
fields["model_name"] = update.ModelName
fields["sync_official"] = 1
fields["created_time"] = common.GetTimestamp()
if err := tx.Model(&Model{}).Create(fields).Error; err != nil {
return err
}
result.CreatedModels = append(result.CreatedModels, update.ModelName)
} else {
if err := tx.Model(&Model{}).Where("id = ?", locals[update.ModelName].Id).Updates(fields).Error; err != nil {
return err
}
result.UpdatedModels = append(result.UpdatedModels, update.MetadataSyncSelection)
}
}
return nil
})
if err != nil {
return nil, err
}
RefreshPricing()
return result, nil
}
...@@ -229,6 +229,9 @@ func validateOptionValue(key string, value string) error { ...@@ -229,6 +229,9 @@ func validateOptionValue(key string, value string) error {
} }
func UpdateOption(key string, value string) error { func UpdateOption(key string, value string) error {
if IsModelPricingOption(key) {
return UpdateModelPricingOptions(map[string]string{key: value})
}
if err := validateOptionValue(key, value); err != nil { if err := validateOptionValue(key, value); err != nil {
return err return err
} }
......
package model package model
import ( import (
"slices"
"strings" "strings"
) )
...@@ -70,6 +71,16 @@ var defaultVendorIcons = map[string]string{ ...@@ -70,6 +71,16 @@ var defaultVendorIcons = map[string]string{
// initDefaultVendorMapping 简化的默认供应商映射 // initDefaultVendorMapping 简化的默认供应商映射
func initDefaultVendorMapping(metaMap map[string]*Model, vendorMap map[int]*Vendor, enableAbilities []AbilityWithChannel) { func initDefaultVendorMapping(metaMap map[string]*Model, vendorMap map[int]*Vendor, enableAbilities []AbilityWithChannel) {
patterns := make([]string, 0, len(defaultVendorRules))
for pattern := range defaultVendorRules {
patterns = append(patterns, pattern)
}
slices.SortFunc(patterns, func(a, b string) int {
if len(a) != len(b) {
return len(b) - len(a)
}
return strings.Compare(a, b)
})
for _, ability := range enableAbilities { for _, ability := range enableAbilities {
modelName := ability.Model modelName := ability.Model
if _, exists := metaMap[modelName]; exists { if _, exists := metaMap[modelName]; exists {
...@@ -79,9 +90,10 @@ func initDefaultVendorMapping(metaMap map[string]*Model, vendorMap map[int]*Vend ...@@ -79,9 +90,10 @@ func initDefaultVendorMapping(metaMap map[string]*Model, vendorMap map[int]*Vend
// 匹配供应商 // 匹配供应商
vendorID := 0 vendorID := 0
modelLower := strings.ToLower(modelName) modelLower := strings.ToLower(modelName)
for pattern, vendorName := range defaultVendorRules { for _, pattern := range patterns {
vendorName := defaultVendorRules[pattern]
if strings.Contains(modelLower, pattern) { if strings.Contains(modelLower, pattern) {
vendorID = getOrCreateVendor(vendorName, vendorMap) vendorID = getDisplayVendor(vendorName, vendorMap)
break break
} }
} }
...@@ -96,28 +108,46 @@ func initDefaultVendorMapping(metaMap map[string]*Model, vendorMap map[int]*Vend ...@@ -96,28 +108,46 @@ func initDefaultVendorMapping(metaMap map[string]*Model, vendorMap map[int]*Vend
} }
} }
// 查找或创建供应商 // Default vendor entries are presentation data. Reading pricing must never
func getOrCreateVendor(vendorName string, vendorMap map[int]*Vendor) int { // recreate a deleted/merged database record.
// 查找现有供应商 var defaultVendorDisplayIDs = map[string]int{
"360": -1001,
"Anthropic": -1002,
"Cloudflare": -1003,
"Cohere": -1004,
"DeepSeek": -1005,
"Google": -1006,
"Jina": -1007,
"Meta": -1008,
"MiniMax": -1009,
"Mistral": -1010,
"Moonshot": -1011,
"OpenAI": -1012,
"Vidu": -1013,
"xAI": -1014,
"即梦": -1015,
"字节跳动": -1016,
"快手": -1017,
"智谱": -1018,
"百度": -1019,
"腾讯": -1020,
"讯飞": -1021,
"阿里巴巴": -1022,
"零一万物": -1023,
}
func getDisplayVendor(vendorName string, vendorMap map[int]*Vendor) int {
for id, vendor := range vendorMap { for id, vendor := range vendorMap {
if vendor.Name == vendorName { if strings.EqualFold(vendor.Name, vendorName) {
return id return id
} }
} }
id := defaultVendorDisplayIDs[vendorName]
// 创建新供应商 if id == 0 {
newVendor := &Vendor{
Name: vendorName,
Status: 1,
Icon: getDefaultVendorIcon(vendorName),
}
if err := newVendor.Insert(); err != nil {
return 0 return 0
} }
vendorMap[id] = &Vendor{Id: id, Name: vendorName, Status: 1, Icon: getDefaultVendorIcon(vendorName)}
vendorMap[newVendor.Id] = newVendor return id
return newVendor.Id
} }
// 获取供应商默认图标 // 获取供应商默认图标
......
package model
import (
"crypto/sha256"
"errors"
"fmt"
"slices"
"strings"
"unicode/utf8"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
var ErrVendorConflict = errors.New("vendor data changed; preview again before applying")
// VendorReferenceError reports every referenced vendor in a rejected delete.
type VendorReferenceError struct {
Counts map[int]int64 `json:"reference_counts"`
}
func (e *VendorReferenceError) Error() string {
return "vendors are still referenced by models; transfer or clear their assignments first"
}
func FindMetadataVendor(vendors map[string]*Vendor, name string) *Vendor {
for _, vendor := range vendors {
if strings.EqualFold(strings.TrimSpace(vendor.Name), strings.TrimSpace(name)) {
return vendor
}
}
return nil
}
func validateModelVendor(tx *gorm.DB, id int) error {
if id == 0 {
return nil
}
if id < 0 {
return errors.New("select a saved vendor")
}
var count int64
if err := tx.Model(&Vendor{}).Where("id = ?", id).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return errors.New("vendor does not exist")
}
return nil
}
func validateVendorMetadata(tx *gorm.DB, vendor *Vendor) error {
vendor.Name = strings.TrimSpace(vendor.Name)
vendor.Icon = strings.TrimSpace(vendor.Icon)
if vendor.Name == "" {
return errors.New("vendor name is required")
}
if utf8.RuneCountInString(vendor.Name) > 128 || utf8.RuneCountInString(vendor.Icon) > 128 {
return errors.New("vendor name and icon must not exceed 128 characters")
}
var others []Vendor
if err := tx.Select("id", "name").Where("id <> ?", vendor.Id).Find(&others).Error; err != nil {
return err
}
for _, other := range others {
if strings.EqualFold(strings.TrimSpace(other.Name), vendor.Name) {
return errors.New("vendor name already exists")
}
}
return nil
}
func VendorRecordVersion(v *Vendor) string {
encoded, _ := common.Marshal([]any{v.Id, v.Name, v.Description, v.Icon, v.Status, v.CreatedTime, v.UpdatedTime})
return fmt.Sprintf("%x", sha256.Sum256(encoded))
}
// VendorOperation uses explicit selections for both preview and application.
type VendorOperation struct {
Action string `json:"action"`
VendorIDs []int `json:"vendor_ids"`
ModelIDs []int `json:"model_ids"`
TargetVendorID int `json:"target_vendor_id"`
ExpectedVersion string `json:"expected_version,omitempty"`
}
type VendorAssignmentModel struct {
ID int `json:"id"`
ModelName string `json:"model_name"`
NameRule int `json:"name_rule"`
VendorID int `json:"vendor_id"`
VendorName string `json:"vendor_name"`
UpdatedTime int64 `json:"updated_time"`
}
type VendorOperationPreview struct {
Action string `json:"action"`
Sources []Vendor `json:"sources"`
Target *Vendor `json:"target"`
Models []VendorAssignmentModel `json:"models"`
Version string `json:"version"`
}
type VendorOperationResult struct {
UpdatedModels []int `json:"updated_models"`
DeletedVendors []int `json:"deleted_vendors"`
}
func buildVendorOperationPreview(db *gorm.DB, operation VendorOperation) (*VendorOperationPreview, error) {
if operation.Action != "assign" && operation.Action != "merge" && operation.Action != "delete" {
return nil, errors.New("unsupported vendor operation")
}
ids := append([]int{}, operation.VendorIDs...)
if operation.Action == "assign" {
ids = append([]int{}, operation.ModelIDs...)
}
if len(ids) == 0 || len(ids) > 1000 {
return nil, errors.New("select between 1 and 1000 records")
}
slices.Sort(ids)
for i, id := range ids {
if id <= 0 || i > 0 && ids[i-1] == id {
return nil, errors.New("invalid or duplicate selection")
}
}
preview := &VendorOperationPreview{Action: operation.Action, Sources: []Vendor{}, Models: []VendorAssignmentModel{}}
var vendors []Vendor
if err := db.Session(&gorm.Session{}).Order("id").Find(&vendors).Error; err != nil {
return nil, err
}
byID := make(map[int]*Vendor, len(vendors))
for i := range vendors {
byID[vendors[i].Id] = &vendors[i]
}
if operation.Action != "delete" {
if operation.TargetVendorID < 0 || operation.Action == "merge" && operation.TargetVendorID == 0 {
return nil, errors.New("select a saved target vendor")
}
if operation.TargetVendorID != 0 {
preview.Target = byID[operation.TargetVendorID]
if preview.Target == nil {
return nil, fmt.Errorf("%w: target vendor does not exist", ErrVendorConflict)
}
}
}
var models []Model
query := db.Session(&gorm.Session{}).Model(&Model{}).Order("id")
if operation.Action == "assign" {
query = query.Where("id IN ?", ids)
} else {
for _, id := range ids {
vendor := byID[id]
if vendor == nil {
return nil, fmt.Errorf("%w: source vendor does not exist", ErrVendorConflict)
}
if operation.Action == "merge" && id == operation.TargetVendorID {
return nil, errors.New("target vendor cannot also be a source")
}
preview.Sources = append(preview.Sources, *vendor)
}
query = query.Where("vendor_id IN ?", ids)
}
if err := query.Find(&models).Error; err != nil {
return nil, err
}
if operation.Action == "assign" && len(models) != len(ids) {
return nil, fmt.Errorf("%w: a selected model no longer exists", ErrVendorConflict)
}
seen := make(map[int]bool)
modelVersions := make([]string, 0, len(models))
counts := make(map[int]int64)
for _, item := range models {
modelVersions = append(modelVersions, MetadataRecordVersion(&item, nil, nil))
row := VendorAssignmentModel{ID: item.Id, ModelName: item.ModelName, NameRule: item.NameRule, VendorID: item.VendorID, UpdatedTime: item.UpdatedTime}
if vendor := byID[item.VendorID]; vendor != nil {
row.VendorName = vendor.Name
if operation.Action == "assign" && !seen[vendor.Id] {
preview.Sources = append(preview.Sources, *vendor)
seen[vendor.Id] = true
}
}
preview.Models = append(preview.Models, row)
counts[item.VendorID]++
}
if operation.Action == "delete" && len(models) > 0 {
return nil, &VendorReferenceError{Counts: counts}
}
slices.SortFunc(preview.Sources, func(a, b Vendor) int { return a.Id - b.Id })
encoded, err := common.Marshal([]any{preview, modelVersions})
if err != nil {
return nil, err
}
preview.Version = fmt.Sprintf("%x", sha256.Sum256(encoded))
return preview, nil
}
func PreviewVendorOperation(operation VendorOperation) (*VendorOperationPreview, error) {
return buildVendorOperationPreview(DB, operation)
}
func ApplyVendorOperation(operation VendorOperation) (*VendorOperationResult, error) {
if operation.ExpectedVersion == "" {
return nil, ErrVendorConflict
}
return applyVendorOperation(operation)
}
func DeleteVendors(ids []int) error {
_, err := applyVendorOperation(VendorOperation{Action: "delete", VendorIDs: ids})
return err
}
func applyVendorOperation(operation VendorOperation) (*VendorOperationResult, error) {
result := &VendorOperationResult{UpdatedModels: []int{}, DeletedVendors: []int{}}
err := metadataTransaction(func(tx *gorm.DB) error {
preview, err := buildVendorOperationPreview(lockForUpdate(tx), operation)
if err != nil {
return err
}
if operation.ExpectedVersion != "" && operation.ExpectedVersion != preview.Version {
return ErrVendorConflict
}
for _, item := range preview.Models {
if item.VendorID != operation.TargetVendorID {
result.UpdatedModels = append(result.UpdatedModels, item.ID)
}
}
if len(result.UpdatedModels) > 0 {
if err := tx.Model(&Model{}).Where("id IN ?", result.UpdatedModels).Updates(map[string]any{"vendor_id": operation.TargetVendorID, "updated_time": common.GetTimestamp()}).Error; err != nil {
return err
}
}
if operation.Action == "merge" || operation.Action == "delete" {
for _, vendor := range preview.Sources {
result.DeletedVendors = append(result.DeletedVendors, vendor.Id)
}
if err := tx.Where("id IN ?", result.DeletedVendors).Delete(&Vendor{}).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
RefreshPricing()
return result, nil
}
...@@ -13,6 +13,8 @@ import ( ...@@ -13,6 +13,8 @@ import (
// 本表同样遵循 3NF 设计范式 // 本表同样遵循 3NF 设计范式
type Vendor struct { type Vendor struct {
ModelCount int64 `json:"model_count" gorm:"-"`
Version string `json:"version,omitempty" gorm:"-"`
Id int `json:"id"` Id int `json:"id"`
Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name_delete_at,priority:1"` Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name_delete_at,priority:1"`
Description string `json:"description,omitempty" gorm:"type:text"` Description string `json:"description,omitempty" gorm:"type:text"`
...@@ -25,10 +27,20 @@ type Vendor struct { ...@@ -25,10 +27,20 @@ type Vendor struct {
// Insert 创建新的供应商记录 // Insert 创建新的供应商记录
func (v *Vendor) Insert() error { func (v *Vendor) Insert() error {
now := common.GetTimestamp() v.Id = 0
v.CreatedTime = now err := metadataTransaction(func(tx *gorm.DB) error {
v.UpdatedTime = now if err := validateVendorMetadata(tx, v); err != nil {
return DB.Create(v).Error return err
}
now := common.GetTimestamp()
v.CreatedTime, v.UpdatedTime, v.Status = now, now, 1
return tx.Create(v).Error
})
if err == nil {
v.Version = VendorRecordVersion(v)
RefreshPricing()
}
return err
} }
// IsVendorNameDuplicated 检查供应商名称是否重复(排除自身 ID) // IsVendorNameDuplicated 检查供应商名称是否重复(排除自身 ID)
...@@ -43,14 +55,29 @@ func IsVendorNameDuplicated(id int, name string) (bool, error) { ...@@ -43,14 +55,29 @@ func IsVendorNameDuplicated(id int, name string) (bool, error) {
// Update 更新供应商记录 // Update 更新供应商记录
func (v *Vendor) Update() error { func (v *Vendor) Update() error {
v.UpdatedTime = common.GetTimestamp() err := metadataTransaction(func(tx *gorm.DB) error {
return DB.Save(v).Error var saved Vendor
if err := tx.First(&saved, v.Id).Error; err != nil {
return err
}
if v.Version != "" && v.Version != VendorRecordVersion(&saved) {
return ErrVendorConflict
}
if err := validateVendorMetadata(tx, v); err != nil {
return err
}
v.CreatedTime, v.Status, v.UpdatedTime = saved.CreatedTime, saved.Status, common.GetTimestamp()
return tx.Model(&Vendor{}).Where("id = ?", v.Id).Updates(map[string]any{"name": v.Name, "description": v.Description, "icon": v.Icon, "updated_time": v.UpdatedTime}).Error
})
if err == nil {
v.Version = VendorRecordVersion(v)
RefreshPricing()
}
return err
} }
// Delete 软删除供应商 // Delete rejects referenced vendors rather than leaving orphaned model records.
func (v *Vendor) Delete() error { func (v *Vendor) Delete() error { return DeleteVendors([]int{v.Id}) }
return DB.Delete(v).Error
}
// GetVendorByID 根据 ID 获取供应商 // GetVendorByID 根据 ID 获取供应商
func GetVendorByID(id int) (*Vendor, error) { func GetVendorByID(id int) (*Vendor, error) {
...@@ -59,23 +86,35 @@ func GetVendorByID(id int) (*Vendor, error) { ...@@ -59,23 +86,35 @@ func GetVendorByID(id int) (*Vendor, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := DB.Model(&Model{}).Where("vendor_id = ?", id).Count(&v.ModelCount).Error; err != nil {
return nil, err
}
v.Version = VendorRecordVersion(&v)
return &v, nil return &v, nil
} }
// GetAllVendors 获取全部供应商(分页) // GetAllVendors 获取全部供应商(分页)
func GetAllVendors(offset int, limit int) ([]*Vendor, error) { func GetAllVendors(offset int, limit int) ([]*Vendor, error) {
var vendors []*Vendor vendors, _, err := SearchVendors("", offset, limit)
err := DB.Offset(offset).Limit(limit).Find(&vendors).Error
return vendors, err return vendors, err
} }
// SearchVendors 按关键字搜索供应商 // SearchVendors filters persisted vendor records and counts actual model assignments.
func SearchVendors(keyword string, offset int, limit int) ([]*Vendor, int64, error) { func SearchVendors(keyword string, offset, limit int, association ...string) ([]*Vendor, int64, error) {
db := DB.Model(&Vendor{}) db := DB.Model(&Vendor{})
if keyword != "" { if keyword != "" {
like := "%" + keyword + "%" like := "%" + keyword + "%"
db = db.Where("name LIKE ? OR description LIKE ?", like, like) db = db.Where("name LIKE ? OR description LIKE ?", like, like)
} }
if len(association) > 0 {
references := DB.Model(&Model{}).Select("1").Where("models.vendor_id = vendors.id")
switch association[0] {
case "linked":
db = db.Where("EXISTS (?)", references)
case "unlinked":
db = db.Where("NOT EXISTS (?)", references)
}
}
var total int64 var total int64
if err := db.Count(&total).Error; err != nil { if err := db.Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
...@@ -84,5 +123,13 @@ func SearchVendors(keyword string, offset int, limit int) ([]*Vendor, int64, err ...@@ -84,5 +123,13 @@ func SearchVendors(keyword string, offset int, limit int) ([]*Vendor, int64, err
if err := db.Offset(offset).Limit(limit).Order("id DESC").Find(&vendors).Error; err != nil { if err := db.Offset(offset).Limit(limit).Order("id DESC").Find(&vendors).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
counts, err := GetVendorModelCounts()
if err != nil {
return nil, 0, err
}
for _, vendor := range vendors {
vendor.ModelCount = counts[int64(vendor.Id)]
vendor.Version = VendorRecordVersion(vendor)
}
return vendors, total, nil return vendors, total, nil
} }
...@@ -205,6 +205,8 @@ func SetApiRouter(router *gin.Engine) { ...@@ -205,6 +205,8 @@ func SetApiRouter(router *gin.Engine) {
{ {
optionRoute.GET("/", controller.GetOptions) optionRoute.GET("/", controller.GetOptions)
optionRoute.PUT("/", controller.UpdateOption) optionRoute.PUT("/", controller.UpdateOption)
optionRoute.GET("/model_pricing", controller.GetModelPricingConfig)
optionRoute.PATCH("/model_pricing", controller.UpdateModelPricingConfig)
optionRoute.POST("/payment_compliance", controller.ConfirmPaymentCompliance) optionRoute.POST("/payment_compliance", controller.ConfirmPaymentCompliance)
optionRoute.GET("/channel_affinity_cache", controller.GetChannelAffinityCacheStats) optionRoute.GET("/channel_affinity_cache", controller.GetChannelAffinityCacheStats)
optionRoute.DELETE("/channel_affinity_cache", controller.ClearChannelAffinityCache) optionRoute.DELETE("/channel_affinity_cache", controller.ClearChannelAffinityCache)
...@@ -366,6 +368,8 @@ func SetApiRouter(router *gin.Engine) { ...@@ -366,6 +368,8 @@ func SetApiRouter(router *gin.Engine) {
vendorRoute := apiRouter.Group("/vendors") vendorRoute := apiRouter.Group("/vendors")
vendorRoute.Use(middleware.AdminAuth()) vendorRoute.Use(middleware.AdminAuth())
{ {
vendorRoute.POST("/operations/preview", controller.PreviewVendorOperation)
vendorRoute.POST("/operations", controller.ApplyVendorOperation)
vendorRoute.GET("/", controller.GetAllVendors) vendorRoute.GET("/", controller.GetAllVendors)
vendorRoute.GET("/search", controller.SearchVendors) vendorRoute.GET("/search", controller.SearchVendors)
vendorRoute.GET("/:id", controller.GetVendorMeta) vendorRoute.GET("/:id", controller.GetVendorMeta)
...@@ -379,6 +383,7 @@ func SetApiRouter(router *gin.Engine) { ...@@ -379,6 +383,7 @@ func SetApiRouter(router *gin.Engine) {
{ {
modelsRoute.GET("/sync_upstream/preview", controller.SyncUpstreamPreview) modelsRoute.GET("/sync_upstream/preview", controller.SyncUpstreamPreview)
modelsRoute.POST("/sync_upstream", controller.SyncUpstreamModels) modelsRoute.POST("/sync_upstream", controller.SyncUpstreamModels)
modelsRoute.POST("/delete", controller.BatchDeleteModelMeta)
modelsRoute.GET("/missing", controller.GetMissingModels) modelsRoute.GET("/missing", controller.GetMissingModels)
modelsRoute.GET("/", controller.GetAllModelsMeta) modelsRoute.GET("/", controller.GetAllModelsMeta)
modelsRoute.GET("/search", controller.SearchModelsMeta) modelsRoute.GET("/search", controller.SearchModelsMeta)
......
...@@ -72,6 +72,15 @@ func GetBillingExpr(model string) (string, bool) { ...@@ -72,6 +72,15 @@ func GetBillingExpr(model string) (string, bool) {
return "", false return "", false
} }
func GetBuiltinBillingExpr(model string) (string, bool) {
expression, ok := builtinBillingExpr[model]
return expression, ok
}
func GetBuiltinBillingExprCopy() map[string]string {
return lo.Assign(builtinBillingExpr)
}
func GetBillingModeCopy() map[string]string { func GetBillingModeCopy() map[string]string {
modes := lo.Assign(billingSetting.BillingMode) modes := lo.Assign(billingSetting.BillingMode)
for model := range builtinBillingExpr { for model := range builtinBillingExpr {
......
...@@ -407,6 +407,25 @@ func GetDefaultModelPriceMap() map[string]float64 { ...@@ -407,6 +407,25 @@ func GetDefaultModelPriceMap() map[string]float64 {
return defaultModelPrice return defaultModelPrice
} }
// GetDefaultPricingMaps returns independent copies for model-level reset and
// first-write initialization; callers cannot mutate the built-in defaults.
func GetDefaultPricingMaps() map[string]map[string]float64 {
defaults := map[string]map[string]float64{
"ModelPrice": defaultModelPrice, "ModelRatio": defaultModelRatio,
"CompletionRatio": defaultCompletionRatio, "CacheRatio": defaultCacheRatio,
"CreateCacheRatio": defaultCreateCacheRatio, "ImageRatio": defaultImageRatio,
"AudioRatio": defaultAudioRatio, "AudioCompletionRatio": defaultAudioCompletionRatio,
}
result := make(map[string]map[string]float64, len(defaults))
for key, values := range defaults {
result[key] = make(map[string]float64, len(values))
for name, value := range values {
result[key][name] = value
}
}
return result
}
func CompletionRatio2JSONString() string { func CompletionRatio2JSONString() string {
return completionRatioMap.MarshalJSONString() return completionRatioMap.MarshalJSONString()
} }
......
...@@ -17,8 +17,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,8 +17,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import * as React from 'react' import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { StatusBadgeList } from '@/components/status-badge' import { StatusBadgeList } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
...@@ -30,6 +37,8 @@ interface BadgeListCellProps { ...@@ -30,6 +37,8 @@ interface BadgeListCellProps {
items: React.ReactNode[] items: React.ReactNode[]
max?: number max?: number
tooltipClassName?: string tooltipClassName?: string
expandable?: boolean
expandLabel?: string
} }
/** /**
...@@ -41,13 +50,45 @@ export function BadgeListCell({ ...@@ -41,13 +50,45 @@ export function BadgeListCell({
items, items,
max = 2, max = 2,
tooltipClassName, tooltipClassName,
expandable = false,
expandLabel,
}: BadgeListCellProps) { }: BadgeListCellProps) {
const { t } = useTranslation()
if (items.length === 0) { if (items.length === 0) {
return <span className='text-muted-foreground text-xs'>-</span> return <span className='text-muted-foreground text-xs'>-</span>
} }
const showTooltip = items.length > max const showTooltip = items.length > max
if (expandable && showTooltip) {
return (
<div className='flex min-w-0 items-center gap-1'>
<StatusBadgeList
items={items.slice(0, max)}
max={max}
renderItem={(item) => item}
/>
<Popover>
<PopoverTrigger
render={
<Button
variant='ghost'
size='sm'
className='h-6 shrink-0 px-1'
aria-label={expandLabel ?? t('Show all tags')}
/>
}
>
+{items.length - max}
</PopoverTrigger>
<PopoverContent className='max-h-64 overflow-auto'>
<div className='flex flex-wrap gap-2'>{items}</div>
</PopoverContent>
</Popover>
</div>
)
}
return ( return (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
......
...@@ -131,6 +131,9 @@ export type DataTablePageProps<TData> = { ...@@ -131,6 +131,9 @@ export type DataTablePageProps<TData> = {
*/ */
bulkActions?: React.ReactNode bulkActions?: React.ReactNode
/** Allow selection actions in the mobile list when opted in. */
showMobileBulkActions?: boolean
/** /**
* Custom mobile list node — fully replaces the default {@link MobileCardList}. * Custom mobile list node — fully replaces the default {@link MobileCardList}.
*/ */
...@@ -141,6 +144,7 @@ export type DataTablePageProps<TData> = { ...@@ -141,6 +144,7 @@ export type DataTablePageProps<TData> = {
* Ignored if `mobile` is provided. * Ignored if `mobile` is provided.
*/ */
mobileProps?: { mobileProps?: {
enableRowSelection?: boolean
getRowKey?: (row: Row<TData>) => string | number getRowKey?: (row: Row<TData>) => string | number
getRowClassName?: (row: Row<TData>) => string | undefined getRowClassName?: (row: Row<TData>) => string | undefined
} }
...@@ -351,7 +355,7 @@ export function DataTablePage<TData>(props: DataTablePageProps<TData>) { ...@@ -351,7 +355,7 @@ export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
{/* Bulk actions are typically a fixed-position toolbar; let the consumer {/* Bulk actions are typically a fixed-position toolbar; let the consumer
handle its own visibility, we just gate it to non-mobile. */} handle its own visibility, we just gate it to non-mobile. */}
{!showMobile && props.bulkActions} {(!showMobile || props.showMobileBulkActions) && props.bulkActions}
{paginationNode} {paginationNode}
</> </>
...@@ -463,6 +467,7 @@ function renderMobile<TData>( ...@@ -463,6 +467,7 @@ function renderMobile<TData>(
} else { } else {
mobileContent = ( mobileContent = (
<MobileCardList <MobileCardList
enableRowSelection={props.mobileProps?.enableRowSelection}
table={props.table} table={props.table}
isLoading={props.isLoading} isLoading={props.isLoading}
emptyTitle={props.emptyTitle} emptyTitle={props.emptyTitle}
......
...@@ -39,6 +39,7 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -39,6 +39,7 @@ For commercial licensing, please contact support@quantumnous.com
import * as React from 'react' import * as React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Checkbox } from '@/components/ui/checkbox'
import { import {
Empty, Empty,
EmptyDescription, EmptyDescription,
...@@ -54,6 +55,7 @@ import { CardRowContent } from './card-row-content' ...@@ -54,6 +55,7 @@ import { CardRowContent } from './card-row-content'
interface MobileCardListProps<TData> { interface MobileCardListProps<TData> {
table: Table<TData> table: Table<TData>
enableRowSelection?: boolean
isLoading?: boolean isLoading?: boolean
emptyTitle?: string emptyTitle?: string
emptyDescription?: string emptyDescription?: string
...@@ -116,6 +118,7 @@ function FallbackListSkeleton() { ...@@ -116,6 +118,7 @@ function FallbackListSkeleton() {
export function MobileCardList<TData>(props: MobileCardListProps<TData>) { export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
const { const {
table, table,
enableRowSelection = false,
isLoading = false, isLoading = false,
emptyTitle, emptyTitle,
emptyDescription, emptyDescription,
...@@ -158,6 +161,18 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) { ...@@ -158,6 +161,18 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
return ( return (
<div className='divide-y overflow-hidden rounded-lg border'> <div className='divide-y overflow-hidden rounded-lg border'>
{enableRowSelection && (
<label className='flex items-center gap-2 px-3 py-2 text-xs'>
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(Boolean(value))
}
/>
{t('Select all')}
</label>
)}
{rows.map((row) => { {rows.map((row) => {
const key = getRowKey ? getRowKey(row) : row.id const key = getRowKey ? getRowKey(row) : row.id
return ( return (
...@@ -168,7 +183,23 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) { ...@@ -168,7 +183,23 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
getRowClassName?.(row) getRowClassName?.(row)
)} )}
> >
<CardRowContent row={row} compact={hasCompactMeta} /> <div className='flex min-w-0 items-start gap-2'>
{enableRowSelection && (
<Checkbox
className='mt-0.5'
checked={row.getIsSelected()}
onCheckedChange={(value) =>
row.toggleSelected(Boolean(value))
}
aria-label={t('Select row {{number}}', {
number: row.index + 1,
})}
/>
)}
<div className='min-w-0 flex-1'>
<CardRowContent row={row} compact={hasCompactMeta} />
</div>
</div>
</div> </div>
) )
})} })}
......
...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { type Table } from '@tanstack/react-table' import type { Table } from '@tanstack/react-table'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useState, useEffect, useLayoutEffect, useRef } from 'react' import { useState, useEffect, useLayoutEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
...@@ -33,6 +33,7 @@ import { cn } from '@/lib/utils' ...@@ -33,6 +33,7 @@ import { cn } from '@/lib/utils'
type DataTableBulkActionsProps<TData> = { type DataTableBulkActionsProps<TData> = {
table: Table<TData> table: Table<TData>
placement?: 'floating' | 'inline'
entityName: string entityName: string
children: React.ReactNode children: React.ReactNode
} }
...@@ -50,23 +51,29 @@ type DataTableBulkActionsProps<TData> = { ...@@ -50,23 +51,29 @@ type DataTableBulkActionsProps<TData> = {
export function DataTableBulkActions<TData>({ export function DataTableBulkActions<TData>({
table, table,
entityName, entityName,
placement = 'floating',
children, children,
}: DataTableBulkActionsProps<TData>): React.ReactNode | null { }: DataTableBulkActionsProps<TData>): React.ReactNode | null {
const { t } = useTranslation() const { t } = useTranslation()
const selectedRows = table.getFilteredSelectedRowModel().rows const selectedRows = table.getFilteredSelectedRowModel().rows
const selectedCount = selectedRows.length const selectedCount = selectedRows.length
const toolbarRef = useRef<HTMLDivElement>(null) const toolbarRef = useRef<HTMLDivElement>(null)
const buttonsRef = useRef<NodeListOf<HTMLButtonElement> | null>(null) const buttonsRef = useRef<HTMLButtonElement[]>([])
const [announcement, setAnnouncement] = useState('') const [announcement, setAnnouncement] = useState('')
useLayoutEffect(() => { useLayoutEffect(() => {
buttonsRef.current = toolbarRef.current?.querySelectorAll('button') ?? null buttonsRef.current = toolbarRef.current
? [...toolbarRef.current.querySelectorAll('button')]
: []
}) })
// Announce selection changes to screen readers // Announce selection changes to screen readers
useEffect(() => { useEffect(() => {
if (selectedCount > 0) { if (selectedCount > 0) {
const message = `${selectedCount} ${entityName}${selectedCount > 1 ? 's' : ''} selected. Bulk actions toolbar is available.` const message = t(
'{{count}} records selected. Bulk actions are available.',
{ count: selectedCount }
)
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setAnnouncement(message) setAnnouncement(message)
...@@ -74,7 +81,7 @@ export function DataTableBulkActions<TData>({ ...@@ -74,7 +81,7 @@ export function DataTableBulkActions<TData>({
const timer = setTimeout(() => setAnnouncement(''), 3000) const timer = setTimeout(() => setAnnouncement(''), 3000)
return () => clearTimeout(timer) return () => clearTimeout(timer)
} }
}, [selectedCount, entityName]) }, [selectedCount, entityName, t])
const handleClearSelection = () => { const handleClearSelection = () => {
table.resetRowSelection() table.resetRowSelection()
...@@ -82,10 +89,10 @@ export function DataTableBulkActions<TData>({ ...@@ -82,10 +89,10 @@ export function DataTableBulkActions<TData>({
const handleKeyDown = (event: React.KeyboardEvent) => { const handleKeyDown = (event: React.KeyboardEvent) => {
const buttons = buttonsRef.current const buttons = buttonsRef.current
if (!buttons) return if (buttons.length === 0) return
const currentIndex = Array.from(buttons).findIndex( const currentIndex = buttons.indexOf(
(button) => button === document.activeElement document.activeElement as HTMLButtonElement
) )
switch (event.key) { switch (event.key) {
...@@ -108,7 +115,7 @@ export function DataTableBulkActions<TData>({ ...@@ -108,7 +115,7 @@ export function DataTableBulkActions<TData>({
break break
case 'End': case 'End':
event.preventDefault() event.preventDefault()
buttons[buttons.length - 1]?.focus() buttons.at(-1)?.focus()
break break
case 'Escape': { case 'Escape': {
// Check if the Escape key came from a dropdown trigger or content // Check if the Escape key came from a dropdown trigger or content
...@@ -161,13 +168,16 @@ export function DataTableBulkActions<TData>({ ...@@ -161,13 +168,16 @@ export function DataTableBulkActions<TData>({
<div <div
ref={toolbarRef} ref={toolbarRef}
role='toolbar' role='toolbar'
aria-label={`Bulk actions for ${selectedCount} selected ${entityName}${selectedCount > 1 ? 's' : ''}`} aria-label={t('Bulk actions for {{count}} selected records', {
count: selectedCount,
})}
aria-describedby='bulk-actions-description' aria-describedby='bulk-actions-description'
tabIndex={-1} tabIndex={-1}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className={cn( className={cn(
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl', placement === 'floating'
'transition-all delay-100 duration-300 ease-out hover:scale-105', ? 'fixed bottom-6 left-1/2 z-50 w-max max-w-[calc(100vw-2rem)] -translate-x-1/2 rounded-xl transition-all delay-100 duration-300 ease-out hover:scale-105'
: 'shrink-0 rounded-xl',
'focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none' 'focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none'
)} )}
> >
...@@ -176,7 +186,7 @@ export function DataTableBulkActions<TData>({ ...@@ -176,7 +186,7 @@ export function DataTableBulkActions<TData>({
'p-2 shadow-xl', 'p-2 shadow-xl',
'rounded-xl border', 'rounded-xl border',
'bg-background/95 supports-[backdrop-filter]:bg-background/60 backdrop-blur-lg', 'bg-background/95 supports-[backdrop-filter]:bg-background/60 backdrop-blur-lg',
'flex items-center gap-x-2' 'flex flex-wrap items-center gap-2'
)} )}
> >
<Tooltip> <Tooltip>
...@@ -213,13 +223,13 @@ export function DataTableBulkActions<TData>({ ...@@ -213,13 +223,13 @@ export function DataTableBulkActions<TData>({
<Badge <Badge
variant='default' variant='default'
className='min-w-8 rounded-lg' className='min-w-8 rounded-lg'
aria-label={`${selectedCount} selected`} aria-label={t('{{count}} selected', { count: selectedCount })}
> >
{selectedCount} {selectedCount}
</Badge>{' '} </Badge>{' '}
<span className='hidden sm:inline'> <span className='hidden sm:inline'>
{entityName} {entityName}
{selectedCount > 1 ? 's' : ''} {selectedCount > 1 && /^[a-z]+$/i.test(entityName) ? 's' : ''}
</span>{' '} </span>{' '}
{t('selected')} {t('selected')}
</div> </div>
......
...@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { Grid2X2, Table2 } from 'lucide-react' import { Grid2X2, Table2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
...@@ -80,19 +81,17 @@ export function DataTableViewModeToggle(props: DataTableViewModeToggleProps) { ...@@ -80,19 +81,17 @@ export function DataTableViewModeToggle(props: DataTableViewModeToggleProps) {
<Tooltip key={segment.value}> <Tooltip key={segment.value}>
<TooltipTrigger <TooltipTrigger
render={ render={
<button <Button
type='button' type='button'
variant={isActive ? 'default' : 'ghost'}
size='icon-sm'
onClick={() => props.onChange(segment.value)} onClick={() => props.onChange(segment.value)}
aria-label={segment.tooltip}
aria-pressed={isActive} aria-pressed={isActive}
className={cn( className='h-full w-7'
'inline-flex h-full w-7 items-center justify-center rounded-md text-xs font-medium transition-all',
isActive
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
> >
<Icon className='size-3.5' /> <Icon className='size-3.5' />
</button> </Button>
} }
/> />
<TooltipContent side='bottom' className='text-xs'> <TooltipContent side='bottom' className='text-xs'>
......
...@@ -52,6 +52,7 @@ SectionPageLayoutBreadcrumb.displayName = 'SectionPageLayout.Breadcrumb' ...@@ -52,6 +52,7 @@ SectionPageLayoutBreadcrumb.displayName = 'SectionPageLayout.Breadcrumb'
export type SectionPageLayoutProps = { export type SectionPageLayoutProps = {
children: ReactNode children: ReactNode
fixedContent?: boolean fixedContent?: boolean
stackActionsOnMobile?: boolean
} }
export function SectionPageLayout(props: SectionPageLayoutProps) { export function SectionPageLayout(props: SectionPageLayoutProps) {
...@@ -68,12 +69,13 @@ export function SectionPageLayout(props: SectionPageLayoutProps) { ...@@ -68,12 +69,13 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
if (!isValidElement(node)) return if (!isValidElement(node)) return
const child = node as ReactElement<SlotProps> const child = node as ReactElement<SlotProps>
if (child.type === SectionPageLayoutTitle) title = child.props.children if (child.type === SectionPageLayoutTitle) title = child.props.children
else if (child.type === SectionPageLayoutActions) else if (child.type === SectionPageLayoutActions) {
actions = child.props.children actions = child.props.children
else if (child.type === SectionPageLayoutContent) } else if (child.type === SectionPageLayoutContent) {
content = child.props.children content = child.props.children
else if (child.type === SectionPageLayoutBreadcrumb) } else if (child.type === SectionPageLayoutBreadcrumb) {
breadcrumb = child.props.children breadcrumb = child.props.children
}
}) })
return ( return (
...@@ -84,7 +86,13 @@ export function SectionPageLayout(props: SectionPageLayoutProps) { ...@@ -84,7 +86,13 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
<div className='mb-2 sm:mb-3'>{breadcrumb}</div> <div className='mb-2 sm:mb-3'>{breadcrumb}</div>
)} )}
<div className='flex flex-wrap items-center justify-between gap-x-3 gap-y-2 sm:gap-x-4'> <div className='flex flex-wrap items-center justify-between gap-x-3 gap-y-2 sm:gap-x-4'>
<div className='min-w-0 flex-1'> <div
className={
props.stackActionsOnMobile
? 'min-w-0 flex-1 max-sm:basis-full'
: 'min-w-0 flex-1'
}
>
<h2 className='truncate text-base font-bold tracking-tight sm:text-lg'> <h2 className='truncate text-base font-bold tracking-tight sm:text-lg'>
{title} {title}
</h2> </h2>
......
/*
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 { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Combobox } from '@/components/ui/combobox'
import { getLobeIcon, getLobeIconNames } from '@/lib/lobe-icon'
export function LobeIconField(props: {
id?: string
value: string
onChange: (value: string) => void
allowInheritance?: boolean
inheritedIcon?: string
inheritedName?: string
}) {
const { t } = useTranslation()
const [custom, setCustom] = useState(false)
const usesCustom = !props.allowInheritance || Boolean(props.value) || custom
const options = useMemo(
() =>
getLobeIconNames().map((name) => ({
value: name,
label: name,
icon: getLobeIcon(name, 18),
})),
[]
)
const effectiveIcon = props.value || props.inheritedIcon
return (
<div className='space-y-3'>
{props.allowInheritance && (
<div className='flex flex-wrap gap-2'>
<Button
type='button'
size='sm'
variant={!usesCustom ? 'secondary' : 'outline'}
aria-pressed={!usesCustom}
onClick={() => {
setCustom(false)
props.onChange('')
}}
>
{t('Inherit vendor icon')}
</Button>
<Button
type='button'
size='sm'
variant={usesCustom ? 'secondary' : 'outline'}
aria-pressed={usesCustom}
onClick={() => setCustom(true)}
>
{t('Custom model icon')}
</Button>
</div>
)}
{usesCustom && (
<div className='flex min-w-0 items-center gap-2'>
<Combobox
id={props.id}
options={options}
value={props.value}
onValueChange={(value) => props.onChange(value ?? '')}
allowCustomValue
searchPlaceholder={t('Search icons or enter an icon key')}
emptyText={t('No matching icons')}
className='min-w-0 flex-1'
/>
{props.value && (
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => props.onChange('')}
>
{t('Clear')}
</Button>
)}
</div>
)}
<div className='bg-muted/40 flex items-center gap-3 rounded-lg border p-3'>
<span className='flex size-9 shrink-0 items-center justify-center'>
{getLobeIcon(effectiveIcon, 28)}
</span>
<div className='min-w-0 text-xs'>
<p className='font-medium'>{t('Effective icon')}</p>
<p className='text-muted-foreground break-all'>
{effectiveIcon || t('Default placeholder')}
</p>
{props.allowInheritance && !props.value && (
<p className='text-muted-foreground'>
{t('Inherited from {{vendor}}', {
vendor: props.inheritedName || t('No vendor'),
})}
</p>
)}
</div>
</div>
{usesCustom && (
<p className='text-muted-foreground text-xs'>
{t('Select a suggested icon or keep an existing advanced icon key.')}
</p>
)}
</div>
)
}
/*
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 { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { Combobox } from '../combobox'
const options = [
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Google' },
{ value: 'disabled', label: 'Unavailable provider', disabled: true },
]
function Fixture() {
const [value, setValue] = useState('openai')
return <><Combobox options={options} value={value} onValueChange={(next) => setValue(next ?? '')} aria-label='Provider' emptyText='No matching provider' /><output>{value}</output></>
}
describe('searchable single selection', () => {
it('searches labels and values without committing text, shows empty results, and restores the selection on Escape', async () => {
render(<Fixture />)
const user = userEvent.setup()
const input = screen.getByRole('combobox', { name: 'Provider' })
expect(input).toHaveValue('OpenAI')
await user.click(input)
await user.type(input, 'missing')
expect(screen.getByText('No matching provider')).toBeVisible()
expect(screen.getByText('openai')).toHaveTextContent('openai')
await user.keyboard('{Escape}')
expect(input).toHaveValue('OpenAI')
await user.click(input)
await user.type(input, 'gemini')
expect(screen.getByRole('option', { name: 'Google' })).toBeVisible()
await user.keyboard('{ArrowDown}{Enter}')
await waitFor(() => expect(input).toHaveValue('Google'))
expect(screen.getByText('gemini')).toHaveTextContent('gemini')
})
it('respects disabled controls and options', async () => {
const change = vi.fn()
const view = render(<Combobox options={options} value='openai' onValueChange={change} aria-label='Provider' disabled />)
const user = userEvent.setup()
expect(screen.getByRole('combobox', { name: 'Provider' })).toBeDisabled()
view.rerender(<Combobox options={options} value='openai' onValueChange={change} aria-label='Provider' />)
await user.click(screen.getByRole('combobox', { name: 'Provider' }))
expect(screen.getByRole('option', { name: 'Unavailable provider' })).toHaveAttribute('aria-disabled', 'true')
await user.click(screen.getByRole('option', { name: 'Unavailable provider' }))
expect(change).not.toHaveBeenCalled()
})
})
...@@ -27,10 +27,12 @@ export type ComboboxInputOption = { ...@@ -27,10 +27,12 @@ export type ComboboxInputOption = {
value: string value: string
label: string label: string
icon?: React.ReactNode icon?: React.ReactNode
disabled?: boolean
description?: string
} }
interface ComboboxInputProps { interface ComboboxInputProps {
options: ComboboxInputOption[] options: readonly ComboboxInputOption[]
value?: string value?: string
onValueChange: (value: string) => void onValueChange: (value: string) => void
placeholder?: string placeholder?: string
...@@ -141,6 +143,7 @@ export function ComboboxInput({ ...@@ -141,6 +143,7 @@ export function ComboboxInput({
break break
case 'Escape': case 'Escape':
e.preventDefault() e.preventDefault()
e.stopPropagation()
setOpen(false) setOpen(false)
setSearchValue('') setSearchValue('')
break break
...@@ -229,7 +232,7 @@ export function ComboboxInput({ ...@@ -229,7 +232,7 @@ export function ComboboxInput({
value === option.value ? 'opacity-100' : 'opacity-0' value === option.value ? 'opacity-100' : 'opacity-0'
)} )}
/> />
{option.icon && <span>{option.icon}</span>} {option.icon && <span aria-hidden>{option.icon}</span>}
<span className='truncate'>{option.label}</span> <span className='truncate'>{option.label}</span>
</li> </li>
))} ))}
......
...@@ -24,6 +24,7 @@ import { ...@@ -24,6 +24,7 @@ import {
} from '@hugeicons/core-free-icons' } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react' import { HugeiconsIcon } from '@hugeicons/react'
import * as React from 'react' import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
...@@ -39,8 +40,8 @@ import { ...@@ -39,8 +40,8 @@ import {
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
type LegacyComboboxProps = { type LegacyComboboxProps = {
options: ComboboxInputOption[] options: readonly ComboboxInputOption[]
value?: string value?: string | null
onValueChange?: (value: string | null) => void onValueChange?: (value: string | null) => void
placeholder?: string placeholder?: string
searchPlaceholder?: string searchPlaceholder?: string
...@@ -49,6 +50,14 @@ type LegacyComboboxProps = { ...@@ -49,6 +50,14 @@ type LegacyComboboxProps = {
className?: string className?: string
id?: string id?: string
openOnFocus?: boolean openOnFocus?: boolean
disabled?: boolean
name?: string
onBlur?: React.FocusEventHandler<HTMLInputElement>
ref?: React.Ref<HTMLInputElement>
'aria-label'?: string
'aria-labelledby'?: string
'aria-describedby'?: string
'aria-invalid'?: React.AriaAttributes['aria-invalid']
} }
function Combobox(props: LegacyComboboxProps): React.ReactElement function Combobox(props: LegacyComboboxProps): React.ReactElement
...@@ -61,6 +70,7 @@ function Combobox( ...@@ -61,6 +70,7 @@ function Combobox(
| LegacyComboboxProps | LegacyComboboxProps
) { ) {
if ('options' in props) { if ('options' in props) {
if (!props.allowCustomValue) return <OptionCombobox {...props} />
return ( return (
<LegacyComboboxInput <LegacyComboboxInput
id={props.id} id={props.id}
...@@ -79,6 +89,70 @@ function Combobox( ...@@ -79,6 +89,70 @@ function Combobox(
return <ComboboxPrimitive.Root {...props} /> return <ComboboxPrimitive.Root {...props} />
} }
function OptionCombobox(props: LegacyComboboxProps) {
const { t } = useTranslation()
const [open, setOpen] = React.useState(false)
const [search, setSearch] = React.useState('')
const anchor = useComboboxAnchor()
const selected = props.options.find((option) => option.value === props.value)
const displayedValue = selected?.label ?? props.value ?? ''
return (
<ComboboxPrimitive.Root
items={props.options}
value={selected ?? null}
name={props.name}
disabled={props.disabled}
open={open && !props.disabled}
inputValue={open ? search : displayedValue}
onInputValueChange={(value, details) => {
if (details.reason === 'input-change') setSearch(value)
}}
onOpenChange={(nextOpen) => {
setOpen(nextOpen)
setSearch('')
}}
onValueChange={(option) => {
if (option) props.onValueChange?.(option.value)
}}
filter={(option, query) => {
const term = query.trim().toLowerCase()
return option.label.toLowerCase().includes(term) || option.value.toLowerCase().includes(term)
}}
isItemEqualToValue={(item, value) => item.value === value.value}
>
<div ref={anchor} className={cn('min-w-0', props.className)}>
<ComboboxInput
ref={props.ref}
id={props.id}
disabled={props.disabled}
onBlur={props.onBlur}
onFocus={() => {
if (props.openOnFocus !== false) setOpen(true)
}}
aria-label={props['aria-label']}
aria-labelledby={props['aria-labelledby']}
aria-describedby={props['aria-describedby']}
aria-invalid={props['aria-invalid']}
placeholder={props.searchPlaceholder ?? props.placeholder ?? t('Search...')}
triggerAriaLabel={props['aria-label'] ?? t('Open')}
className='h-full min-h-8 w-full'
/>
</div>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>{props.emptyText ?? t('No results found')}</ComboboxEmpty>
<ComboboxList>
{(option: ComboboxInputOption) => (
<ComboboxItem key={option.value} value={option} disabled={option.disabled}>
{option.icon && <span aria-hidden>{option.icon}</span>}
<span className='min-w-0 break-words'>{option.label}{option.description && <span className='text-muted-foreground block text-xs break-all'>{option.description}</span>}</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</ComboboxPrimitive.Root>
)
}
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) { function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot='combobox-value' {...props} /> return <ComboboxPrimitive.Value data-slot='combobox-value' {...props} />
} }
...@@ -127,10 +201,12 @@ function ComboboxInput({ ...@@ -127,10 +201,12 @@ function ComboboxInput({
disabled = false, disabled = false,
showTrigger = true, showTrigger = true,
showClear = false, showClear = false,
triggerAriaLabel,
...props ...props
}: ComboboxPrimitive.Input.Props & { }: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean showTrigger?: boolean
showClear?: boolean showClear?: boolean
triggerAriaLabel?: string
}) { }) {
return ( return (
<InputGroup className={cn('w-auto', className)}> <InputGroup className={cn('w-auto', className)}>
...@@ -143,7 +219,7 @@ function ComboboxInput({ ...@@ -143,7 +219,7 @@ function ComboboxInput({
<InputGroupButton <InputGroupButton
size='icon-xs' size='icon-xs'
variant='ghost' variant='ghost'
render={<ComboboxTrigger />} render={<ComboboxTrigger aria-label={triggerAriaLabel} />}
data-slot='input-group-button' data-slot='input-group-button'
className='group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent' className='group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent'
disabled={disabled} disabled={disabled}
......
...@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { Combobox } from '@/components/ui/combobox'
import { import {
ArrowDown, ArrowDown,
ArrowDownToLine, ArrowDownToLine,
...@@ -57,14 +58,7 @@ import { ...@@ -57,14 +58,7 @@ import {
PopoverTitle, PopoverTitle,
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover' } from '@/components/ui/popover'
import { import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { import {
...@@ -723,43 +717,16 @@ export function AdvancedCustomEditorDialog({ ...@@ -723,43 +717,16 @@ export function AdvancedCustomEditorDialog({
</p> </p>
</div> </div>
<div className='flex flex-wrap gap-2'> <div className='flex flex-wrap gap-2'>
<Select <Combobox
items={availableIncomingPathOptions} options={availableIncomingPathOptions}
value={null} value=''
onValueChange={(incomingPath) => { onValueChange={(incomingPath) => {
if (typeof incomingPath === 'string') addRoute(incomingPath) if (typeof incomingPath === 'string') addRoute(incomingPath)
}} }}
> disabled={availableIncomingPathOptions.length === 0}
<SelectTrigger className='w-full'
size='sm' placeholder={t('Add route')}
disabled={availableIncomingPathOptions.length === 0} />
>
<Plus data-icon='inline-start' />
<SelectValue placeholder={t('Add route')} />
</SelectTrigger>
<SelectContent
align='end'
alignItemWithTrigger={false}
className={longSelectContentClass}
>
<SelectGroup>
{availableIncomingPathOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className={longSelectItemClass}
>
<div className='flex min-w-0 flex-col gap-1 leading-snug whitespace-normal'>
<span>{option.label}</span>
<span className='text-muted-foreground font-mono text-xs break-all'>
{option.value}
</span>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Select <Select
value={null} value={null}
onValueChange={(value) => { onValueChange={(value) => {
...@@ -1154,7 +1121,6 @@ function RouteGroupEditor({ ...@@ -1154,7 +1121,6 @@ function RouteGroupEditor({
const { t } = useTranslation() const { t } = useTranslation()
const incomingPath = group.incomingPath || '/v1/chat/completions' const incomingPath = group.incomingPath || '/v1/chat/completions'
const isModelListGroup = incomingPath === ADVANCED_CUSTOM_MODEL_LIST_PATH const isModelListGroup = incomingPath === ADVANCED_CUSTOM_MODEL_LIST_PATH
const incomingPathLabel = getAdvancedCustomIncomingPathLabel(incomingPath)
const catchAllRoute = group.routeRows.find((routeRow) => const catchAllRoute = group.routeRows.find((routeRow) =>
isCatchAllRoute(routeRow.route) isCatchAllRoute(routeRow.route)
) )
...@@ -1202,44 +1168,17 @@ function RouteGroupEditor({ ...@@ -1202,44 +1168,17 @@ function RouteGroupEditor({
</Badge> </Badge>
) : null} ) : null}
</div> </div>
<Select <Combobox
items={ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS} options={ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.map((option) => ({
value={incomingPath} ...option,
onValueChange={onIncomingPathChange} description: option.value,
> disabled: (option.value !== incomingPath && usedIncomingPaths.has(option.value)) ||
<SelectTrigger className='h-9 max-w-full lg:max-w-[420px]'> (option.value === ADVANCED_CUSTOM_MODEL_LIST_PATH && group.routeRows.length > 1),
<SelectValue className='min-w-0 truncate'> }))}
{incomingPathLabel} value={incomingPath}
</SelectValue> onValueChange={onIncomingPathChange}
</SelectTrigger> className='h-9 max-w-full lg:max-w-[420px]'
<SelectContent />
alignItemWithTrigger={false}
className={longSelectContentClass}
>
<SelectGroup>
{ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={option.value}
disabled={
(option.value !== incomingPath &&
usedIncomingPaths.has(option.value)) ||
(option.value === ADVANCED_CUSTOM_MODEL_LIST_PATH &&
group.routeRows.length > 1)
}
className={longSelectItemClass}
>
<div className='flex min-w-0 flex-col gap-1 leading-snug whitespace-normal'>
<span>{option.label}</span>
<span className='text-muted-foreground font-mono text-xs break-all'>
{option.value}
</span>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div> </div>
{!isModelListGroup ? ( {!isModelListGroup ? (
......
...@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { Combobox } from '@/components/ui/combobox'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import type { import type {
ColumnDef, ColumnDef,
...@@ -62,14 +63,7 @@ import { Button } from '@/components/ui/button' ...@@ -62,14 +63,7 @@ import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
...@@ -199,9 +193,6 @@ const endpointTypeOptions: Array<{ value: string; label: string }> = [ ...@@ -199,9 +193,6 @@ const endpointTypeOptions: Array<{ value: string; label: string }> = [
{ value: 'embeddings', label: 'Embeddings (/v1/embeddings)' }, { value: 'embeddings', label: 'Embeddings (/v1/embeddings)' },
] ]
const endpointSelectContentClass = 'w-[460px] max-w-[calc(100vw-2rem)]'
const endpointSelectItemClass =
'items-start py-2 [&_[data-slot=select-item-text]]:min-w-0 [&_[data-slot=select-item-text]]:shrink [&_[data-slot=select-item-text]]:whitespace-normal'
const STREAM_INCOMPATIBLE_ENDPOINTS = new Set([ const STREAM_INCOMPATIBLE_ENDPOINTS = new Set([
'embeddings', 'embeddings',
...@@ -996,36 +987,14 @@ function ChannelTestDialogContent({ ...@@ -996,36 +987,14 @@ function ChannelTestDialogContent({
<div className='grid gap-4 md:grid-cols-2'> <div className='grid gap-4 md:grid-cols-2'>
<div className='grid gap-2'> <div className='grid gap-2'>
<Label htmlFor='endpoint-type'>{t('Endpoint Type')}</Label> <Label htmlFor='endpoint-type'>{t('Endpoint Type')}</Label>
<Select <Combobox
items={endpointSelectItems} options={endpointSelectItems}
value={endpointType} value={endpointType}
onValueChange={handleEndpointTypeChange} onValueChange={handleEndpointTypeChange}
> id='endpoint-type'
<SelectTrigger id='endpoint-type' className='w-full min-w-0'> className='w-full min-w-0'
<SelectValue placeholder={t('Auto detect (default)')}
className='min-w-0 truncate' />
placeholder={t('Auto detect (default)')}
/>
</SelectTrigger>
<SelectContent
alignItemWithTrigger={false}
className={endpointSelectContentClass}
>
<SelectGroup>
{endpointSelectItems.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className={endpointSelectItemClass}
>
<span className='min-w-0 leading-snug break-words whitespace-normal'>
{option.label}
</span>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'> <p className='text-muted-foreground text-xs'>
{t( {t(
'Override the endpoint used for testing. Leave empty to auto detect.' 'Override the endpoint used for testing. Leave empty to auto detect.'
......
...@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { Combobox } from '@/components/ui/combobox'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
...@@ -30,14 +31,7 @@ import { Button } from '@/components/ui/button' ...@@ -30,14 +31,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { import {
...@@ -303,37 +297,15 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) { ...@@ -303,37 +297,15 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
</div> </div>
<div className='flex gap-2'> <div className='flex gap-2'>
<Select<string> <Combobox
items={[ options={availableModels.map((model) => ({ value: model, label: model }))}
...availableModels.map((model) => ({ onValueChange={(value: string | null) => {
value: model, if (value !== null && !selectedModels.includes(value)) setSelectedModels([...selectedModels, value])
label: model, }}
})), className='flex-1'
]} placeholder={t('Add from available models...')}
onValueChange={(value) => { aria-label={t('Add from available models...')}
if (value === null) return />
if (!selectedModels.includes(value)) {
setSelectedModels([...selectedModels, value])
}
}}
>
<SelectTrigger className='flex-1'>
<SelectValue
placeholder={t('Add from available models...')}
/>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<ScrollArea className='h-60'>
{availableModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</ScrollArea>
</SelectGroup>
</SelectContent>
</Select>
</div> </div>
<div className='flex gap-2'> <div className='flex gap-2'>
......
...@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { Combobox } from '@/components/ui/combobox'
import { import {
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
...@@ -47,14 +48,7 @@ import { ...@@ -47,14 +48,7 @@ import {
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
...@@ -1763,31 +1757,19 @@ export function ParamOverrideEditorDialog( ...@@ -1763,31 +1757,19 @@ export function ParamOverrideEditorDialog(
<span className='text-muted-foreground text-xs font-medium'> <span className='text-muted-foreground text-xs font-medium'>
{t('Template')} {t('Template')}
</span> </span>
<Select <Combobox
items={[ options={[
...templatePresetOptions.map((o) => ({ ...templatePresetOptions.map((o) => ({
value: o.value, value: o.value,
label: t(o.label), label: t(o.label),
})), })),
]} ]}
value={templatePresetKey} value={templatePresetKey}
onValueChange={(v) => onValueChange={(v) =>
setTemplatePresetKey(v || 'operations_default') setTemplatePresetKey(v || 'operations_default')
} }
> className='h-8 w-[220px]'
<SelectTrigger className='h-8 w-[220px]'> />
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{templatePresetOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button <Button
type='button' type='button'
variant='outline' variant='outline'
...@@ -2159,34 +2141,22 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) { ...@@ -2159,34 +2141,22 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
<div className='grid gap-3 sm:grid-cols-2'> <div className='grid gap-3 sm:grid-cols-2'>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<label className='text-xs font-medium'>{t('Operation Type')}</label> <label className='text-xs font-medium'>{t('Operation Type')}</label>
<Select <Combobox
items={[ options={[
...OPERATION_MODE_OPTIONS.map((o) => ({ ...OPERATION_MODE_OPTIONS.map((o) => ({
value: o.value, value: o.value,
label: t(o.label), label: t(o.label),
})), })),
]} ]}
value={mode} value={mode}
onValueChange={(nextMode) => onValueChange={(nextMode) =>
nextMode !== null && nextMode !== null &&
ruleEditorProps.updateOperation(operation.id, { ruleEditorProps.updateOperation(operation.id, {
mode: nextMode, mode: nextMode,
}) })
} }
> className='h-9'
<SelectTrigger className='h-9'> />
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{OPERATION_MODE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div> </div>
{(meta.path || meta.pathOptional) && ( {(meta.path || meta.pathOptional) && (
<div className='space-y-1.5'> <div className='space-y-1.5'>
...@@ -2548,15 +2518,15 @@ function ConditionEditor(conditionEditorProps: ConditionEditorProps) { ...@@ -2548,15 +2518,15 @@ function ConditionEditor(conditionEditorProps: ConditionEditorProps) {
<label className='text-[10px] font-medium'> <label className='text-[10px] font-medium'>
{t('Match Mode')} {t('Match Mode')}
</label> </label>
<Select <Combobox
items={[ options={[
...CONDITION_MODE_OPTIONS.map((o) => ({ ...CONDITION_MODE_OPTIONS.map((o) => ({
value: o.value, value: o.value,
label: t(o.label), label: t(o.label),
})), })),
]} ]}
value={condition.mode} value={condition.mode}
onValueChange={(v) => onValueChange={(v) =>
v !== null && v !== null &&
conditionEditorProps.updateCondition( conditionEditorProps.updateCondition(
conditionEditorProps.operationId, conditionEditorProps.operationId,
...@@ -2564,20 +2534,8 @@ function ConditionEditor(conditionEditorProps: ConditionEditorProps) { ...@@ -2564,20 +2534,8 @@ function ConditionEditor(conditionEditorProps: ConditionEditorProps) {
{ mode: v } { mode: v }
) )
} }
> className='h-8 text-xs'
<SelectTrigger className='h-8 text-xs'> />
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{CONDITION_MODE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div> </div>
<div className='space-y-1'> <div className='space-y-1'>
<label className='text-[10px] font-medium'> <label className='text-[10px] font-medium'>
...@@ -3068,15 +3026,15 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) { ...@@ -3068,15 +3026,15 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
<label className='text-[10px] font-medium'> <label className='text-[10px] font-medium'>
{t('Match Mode')} {t('Match Mode')}
</label> </label>
<Select <Combobox
items={[ options={[
...CONDITION_MODE_OPTIONS.map((o) => ({ ...CONDITION_MODE_OPTIONS.map((o) => ({
value: o.value, value: o.value,
label: t(o.label), label: t(o.label),
})), })),
]} ]}
value={rule.mode} value={rule.mode}
onValueChange={(v) => onValueChange={(v) =>
v !== null && v !== null &&
pruneObjectsEditorProps.updateRule( pruneObjectsEditorProps.updateRule(
pruneObjectsEditorProps.operationId, pruneObjectsEditorProps.operationId,
...@@ -3084,20 +3042,8 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) { ...@@ -3084,20 +3042,8 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
{ mode: v } { mode: v }
) )
} }
> className='h-7 text-xs'
<SelectTrigger className='h-7 text-xs'> />
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{CONDITION_MODE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div> </div>
<div className='space-y-0.5'> <div className='space-y-0.5'>
<label className='text-[10px] font-medium'> <label className='text-[10px] font-medium'>
......
...@@ -81,14 +81,7 @@ import { ...@@ -81,14 +81,7 @@ import {
} from '@/components/ui/form' } from '@/components/ui/form'
import { IconBadge, type IconBadgeTone } from '@/components/ui/icon-badge' import { IconBadge, type IconBadgeTone } from '@/components/ui/icon-badge'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { import {
Sheet, Sheet,
...@@ -1987,9 +1980,9 @@ export function ChannelMutateDrawer({ ...@@ -1987,9 +1980,9 @@ export function ChannelMutateDrawer({
<FormItem> <FormItem>
<FormLabel>{t('Task plugin *')}</FormLabel> <FormLabel>{t('Task plugin *')}</FormLabel>
{canBindTaskPlugin ? ( {canBindTaskPlugin ? (
<Select <FormControl><Combobox
value={field.value} value={field.value}
onValueChange={(value) => { onValueChange={(value) => {
field.onChange(value) field.onChange(value)
const plugin = const plugin =
taskPluginOptionsQuery.data?.find( taskPluginOptionsQuery.data?.find(
...@@ -2005,35 +1998,17 @@ export function ChannelMutateDrawer({ ...@@ -2005,35 +1998,17 @@ export function ChannelMutateDrawer({
) )
} }
}} }}
items={( options={(
taskPluginOptionsQuery.data ?? [] taskPluginOptionsQuery.data ?? []
).map((plugin) => ({ ).map((plugin) => ({
value: plugin.key, value: plugin.key,
label: `${plugin.name} (${plugin.key})`, label: `${plugin.name} (${plugin.key})`,
}))} }))}
> className='w-full'
<FormControl> placeholder={t(
<SelectTrigger>
<SelectValue
placeholder={t(
'Select task plugin' 'Select task plugin'
)} )}
/> /></FormControl>
</SelectTrigger>
</FormControl>
<SelectContent>
{(
taskPluginOptionsQuery.data ?? []
).map((plugin) => (
<SelectItem
key={plugin.key}
value={plugin.key}
>
{plugin.name} ({plugin.key})
</SelectItem>
))}
</SelectContent>
</Select>
) : ( ) : (
<FormControl> <FormControl>
<Input <Input
......
/*
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 { describe, expect, it } from 'vitest'
import { buildPricingChanges, type ModelPricingConfig } from '../api'
import {
applyPriceSyncSelections,
applyPricingDraft,
pricingFromDraft,
pricingOptions,
pricingRow,
} from '../pricing'
describe('shared model pricing', () => {
it('preserves explicit zero prices and cache-write configuration', () => {
expect(
pricingFromDraft({
name: 'example',
billingMode: 'per-token',
ratio: '0',
completionRatio: '2',
cacheRatio: '0',
createCacheRatio: '1.25',
})
).toEqual({
'billing_setting.billing_mode': 'ratio',
ModelRatio: 0,
CompletionRatio: 2,
CacheRatio: 0,
CreateCacheRatio: 1.25,
})
expect(
pricingFromDraft({
name: 'example',
billingMode: 'per-request',
price: '',
})
).not.toHaveProperty('ModelPrice')
expect(
pricingFromDraft({
name: 'example',
billingMode: 'per-request',
price: '0',
})
).toHaveProperty('ModelPrice', 0)
})
it('keeps token and task expressions intact through both editing and sync', () => {
for (const expression of [
'len <= 200000 ? tier("short", p * 2 + cr * 0.2 + cc * 2.5) : tier("long", p * 4)',
'tier("base", u("seconds") * 0.4)',
]) {
const values = {
'billing_setting.billing_mode': 'tiered_expr',
'billing_setting.billing_expr': expression,
ModelRatio: 1,
}
expect(pricingFromDraft(pricingRow('example', values))).toEqual(values)
}
})
it('does not persist another model’s built-in display expression when editing one price', () => {
const options = pricingOptions({
ModelPrice: '{"edited":1}',
BillingMode: '{"builtin":"tiered_expr"}',
BillingExpr: '{"builtin":"tier(\\"base\\", p * 2)"}',
})
const snapshot: ModelPricingConfig = {
options,
empty_version: 'empty',
entries: [
{
model_name: 'edited',
version: 'v1',
configured: { ModelPrice: 1 },
effective: { ModelPrice: 1 },
},
{
model_name: 'builtin',
version: 'empty',
configured: {},
effective: {
'billing_setting.billing_mode': 'tiered_expr',
'billing_setting.billing_expr': 'tier("base", p * 2)',
},
},
],
}
const after = applyPricingDraft(options, {
name: 'edited',
billingMode: 'per-request',
price: '2',
})
expect(buildPricingChanges(snapshot, options, after)).toEqual([
{
model_name: 'edited',
expected_version: 'v1',
pricing: { ModelPrice: 2, 'billing_setting.billing_mode': 'ratio' },
},
])
})
it('clears conflicting expression settings when a fixed price is selected for sync', () => {
const options = pricingOptions({
ModelRatio: '{"example":1}',
CreateCacheRatio: '{"example":1.25}',
BillingMode: '{"example":"tiered_expr"}',
BillingExpr: '{"example":"tier(\\"base\\", p * 2)"}',
})
const after = applyPriceSyncSelections(options, {
example: { model_price: 0 },
})
expect(JSON.parse(after.ModelPrice)).toEqual({ example: 0 })
expect(JSON.parse(after.ModelRatio)).toEqual({})
expect(JSON.parse(after.CreateCacheRatio)).toEqual({})
expect(JSON.parse(after['billing_setting.billing_expr'])).toEqual({})
expect(JSON.parse(after['billing_setting.billing_mode'])).toEqual({
example: 'ratio',
})
})
it('rejects invalid prices instead of silently coercing them', () => {
for (const price of ['-1', 'NaN', 'Infinity', 'invalid']) {
expect(() =>
pricingFromDraft({ name: 'example', billingMode: 'per-request', price })
).toThrow()
}
})
})
/*
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 {
useMutation,
useQuery,
useQueryClient,
type QueryClient,
} from '@tanstack/react-query'
import { isAxiosError } from 'axios'
import { t } from 'i18next'
import type { BillingUsageSchema } from '@/features/pricing/types'
import { api } from '@/lib/api'
import { ROLE } from '@/lib/roles'
import { useAuthStore } from '@/stores/auth-store'
import {
PRICING_KEYS,
pricingValuesByModel,
type PricingOptions,
type PricingValues,
} from './pricing'
export type ModelPricingEntry = {
model_name: string
version: string
configured: PricingValues
effective: PricingValues
usage_schema?: BillingUsageSchema
}
export type ModelPricingConfig = {
entries: ModelPricingEntry[]
options: PricingOptions
empty_version: string
}
export type ModelPricingChange = {
model_name: string
expected_version: string
pricing: PricingValues
reset?: boolean
}
export function useCanEditModelPricing() {
return useAuthStore((state) => state.auth.user?.role === ROLE.SUPER_ADMIN)
}
export async function getModelPricing(
names: string[] = []
): Promise<ModelPricingConfig> {
const params = new URLSearchParams()
for (const name of names) params.append('model', name)
const res = await api.get('/api/option/model_pricing', { params })
if (!res.data.success) {
throw new Error(res.data.message || t('Failed to load model pricing'))
}
return res.data.data
}
export function useModelPricing(names: string[] = [], enabled = true) {
const canEdit = useCanEditModelPricing()
return useQuery({
queryKey: ['model-pricing-config', ...names],
queryFn: () => getModelPricing(names),
enabled: enabled && canEdit,
refetchOnWindowFocus: false,
})
}
export async function invalidateModelPricing(client: QueryClient) {
await Promise.all([
client.invalidateQueries({ queryKey: ['model-pricing-config'] }),
client.invalidateQueries({ queryKey: ['system-options'] }),
client.invalidateQueries({ queryKey: ['pricing'] }),
client.invalidateQueries({ queryKey: ['models'] }),
])
}
export async function saveModelPricing(changes: ModelPricingChange[]) {
if (!changes.length) return
try {
const res = await api.patch('/api/option/model_pricing', { changes })
if (!res.data.success) {
throw new Error(res.data.message || t('Failed to save model pricing'))
}
} catch (error) {
if (
isAxiosError<{ message?: string }>(error) &&
error.response?.data.message
) {
throw new Error(error.response.data.message, { cause: error })
}
throw error
}
}
export function useSaveModelPricing() {
const client = useQueryClient()
return useMutation({
mutationFn: saveModelPricing,
onSuccess: () => invalidateModelPricing(client),
})
}
// Only dirty model fields are applied to stored configuration. Display-only
// built-in expressions for other models never become administrator overrides.
export function buildPricingChanges(
snapshot: ModelPricingConfig,
before: PricingOptions,
after: PricingOptions
): ModelPricingChange[] {
const previous = pricingValuesByModel(before)
const next = pricingValuesByModel(after)
const entries = new Map(
snapshot.entries.map((entry) => [entry.model_name, entry])
)
const changes: ModelPricingChange[] = []
for (const name of new Set([...previous.keys(), ...next.keys()])) {
const oldValues = previous.get(name) ?? {}
const newValues = next.get(name) ?? {}
const dirty = PRICING_KEYS.filter(
(key) => oldValues[key] !== newValues[key]
)
if (!dirty.length) continue
const entry = entries.get(name)
const pricing = { ...entry?.configured }
for (const key of dirty) {
delete pricing[key]
if (newValues[key] !== undefined) pricing[key] = newValues[key]
}
if (newValues['billing_setting.billing_mode'] === 'tiered_expr') {
pricing['billing_setting.billing_mode'] = 'tiered_expr'
pricing['billing_setting.billing_expr'] =
newValues['billing_setting.billing_expr']
}
changes.push({
model_name: name,
expected_version: entry?.version ?? snapshot.empty_version,
pricing,
})
}
return changes
}
/*
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 { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
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 {
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 {
useCanEditModelPricing,
useModelPricing,
useSaveModelPricing,
type ModelPricingEntry,
} from './api'
import { pricingFromDraft, pricingRow } from './pricing'
export function ModelPricingPanel(props: {
modelName: string
onDirtyChange?: (dirty: boolean) => void
}) {
const { t } = useTranslation()
const canEdit = useCanEditModelPricing()
const query = useModelPricing([props.modelName], Boolean(props.modelName))
const save = useSaveModelPricing()
const [entry, setEntry] = useState<ModelPricingEntry | null>(null)
const [resetOpen, setResetOpen] = useState(false)
const editor = useRef<ModelPricingEditorPanelHandle>(null)
const editData = useMemo(() => {
if (!entry) return null
const values = { ...entry.configured }
if (entry.effective['billing_setting.billing_mode'] === 'tiered_expr') {
values['billing_setting.billing_mode'] = 'tiered_expr'
values['billing_setting.billing_expr'] =
entry.effective['billing_setting.billing_expr']
}
return pricingRow(entry.model_name, values)
}, [entry])
useEffect(() => {
const loaded = query.data?.entries.find(
(item) => item.model_name === props.modelName
)
if (loaded && (!entry || entry.model_name !== props.modelName)) {
setEntry(loaded)
}
}, [query.data, entry, props.modelName])
const persist = async (reset = false) => {
if (!entry) return
try {
const draft = reset ? null : await editor.current?.commitDraft()
if (!reset && !draft) return
await save.mutateAsync([
{
model_name: entry.model_name,
expected_version: entry.version,
pricing: draft ? pricingFromDraft(draft) : {},
reset,
},
])
const refreshed = await query.refetch()
setEntry(
refreshed.data?.entries.find(
(item) => item.model_name === props.modelName
) ?? null
)
setResetOpen(false)
toast.success(t('Model pricing saved'))
} catch (error) {
handleServerError(error)
}
}
if (!canEdit) {
return (
<div className='text-muted-foreground p-6 text-sm'>
{t('Model pricing is managed by a super administrator.')}
</div>
)
}
if (query.isError) {
return (
<ErrorState
description={query.error.message}
onRetry={() => void query.refetch()}
/>
)
}
if (!editData || !entry) return <LoadingState />
const effectivePricing = {
...pricingRow(entry.model_name, entry.effective),
hasConflict: false,
}
return (
<div className='flex min-h-0 flex-1 flex-col gap-3'>
<div className='flex flex-wrap items-center justify-between gap-2 px-4 pt-3'>
<div>
<p className='text-muted-foreground text-xs'>
{Object.keys(entry.configured).length
? 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'
size='sm'
onClick={() => setResetOpen(true)}
disabled={save.isPending}
>
{t('Restore default pricing')}
</Button>
</div>
{save.isError && (
<div className='px-4'>
<p role='alert' className='text-destructive mb-2 text-sm'>
{save.error?.message}
</p>
<Button
variant='outline'
size='sm'
onClick={async () => {
const refreshed = await query.refetch()
const loaded = refreshed.data?.entries.find(
(item) => item.model_name === props.modelName
)
if (loaded) {
setEntry(loaded)
save.reset()
}
}}
>
{t('Reload pricing')}
</Button>
</div>
)}
<ModelPricingEditorPanel
ref={editor}
editData={editData}
usageSchema={entry.usage_schema}
onDirtyChange={props.onDirtyChange}
onSave={() => persist()}
isSaving={save.isPending}
className='rounded-none border-0'
/>
<ConfirmDialog
open={resetOpen}
onOpenChange={setResetOpen}
title={t('Restore default pricing')}
desc={t(
'Remove this model’s custom pricing and use the built-in defaults. A model without a default may become unpriced.'
)}
confirmText={t('Restore defaults')}
isLoading={save.isPending}
handleConfirm={() => void persist(true)}
/>
</div>
)
}
/*
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 { t } from 'i18next'
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
import type { ModelRatioData } from '@/features/system-settings/models/model-pricing-core'
import {
buildModelSnapshots,
type ModelPricingSnapshot,
} from '@/features/system-settings/models/model-pricing-snapshots'
export const PRICING_KEYS = [
'ModelPrice',
'ModelRatio',
'CompletionRatio',
'CacheRatio',
'CreateCacheRatio',
'ImageRatio',
'AudioRatio',
'AudioCompletionRatio',
'billing_setting.billing_mode',
'billing_setting.billing_expr',
] as const
export type PricingKey = (typeof PRICING_KEYS)[number]
export type PricingValues = Partial<Record<PricingKey, number | string>>
export type PricingOptions = Record<PricingKey, string>
export const pricingFieldMap = {
price: 'ModelPrice',
ratio: 'ModelRatio',
completionRatio: 'CompletionRatio',
cacheRatio: 'CacheRatio',
createCacheRatio: 'CreateCacheRatio',
imageRatio: 'ImageRatio',
audioRatio: 'AudioRatio',
audioCompletionRatio: 'AudioCompletionRatio',
} as const
export function pricingOptions(
values: Record<string, string | boolean>
): PricingOptions {
return Object.fromEntries(
PRICING_KEYS.map((key) => {
let value = values[key]
if (key === 'billing_setting.billing_mode') value ??= values.BillingMode
if (key === 'billing_setting.billing_expr') value ??= values.BillingExpr
return [key, typeof value === 'string' ? value : '{}']
})
) as PricingOptions
}
export function pricingRows(options: PricingOptions): ModelPricingSnapshot[] {
return buildModelSnapshots({
modelPrice: options.ModelPrice,
modelRatio: options.ModelRatio,
completionRatio: options.CompletionRatio,
cacheRatio: options.CacheRatio,
createCacheRatio: options.CreateCacheRatio,
imageRatio: options.ImageRatio,
audioRatio: options.AudioRatio,
audioCompletionRatio: options.AudioCompletionRatio,
billingMode: options['billing_setting.billing_mode'],
billingExpr: options['billing_setting.billing_expr'],
})
}
export function pricingRow(
name: string,
values: PricingValues
): ModelRatioData {
const options = Object.fromEntries(
PRICING_KEYS.map((key) => [
key,
JSON.stringify(values[key] === undefined ? {} : { [name]: values[key] }),
])
) as PricingOptions
const row = pricingRows(options)[0]
let billingMode: ModelRatioData['billingMode'] = 'per-token'
if (row?.billingMode === 'tiered_expr') billingMode = 'tiered_expr'
else if (row?.price) billingMode = 'per-request'
return { ...row, name, billingMode }
}
export function pricingFromDraft(data: ModelRatioData): PricingValues {
const values: PricingValues = {
'billing_setting.billing_mode':
data.billingMode === 'tiered_expr' ? 'tiered_expr' : 'ratio',
}
for (const [field, key] of Object.entries(pricingFieldMap)) {
const value = data[field as keyof typeof pricingFieldMap]
if (value === undefined || value === '') continue
const number = Number(value)
if (!Number.isFinite(number) || number < 0) {
throw new Error(t('Enter a finite, non-negative price'))
}
if (
data.billingMode === 'tiered_expr' ||
(data.billingMode === 'per-request'
? key === 'ModelPrice'
: key !== 'ModelPrice')
) {
values[key] = number
}
}
if (data.billingMode === 'tiered_expr') {
values['billing_setting.billing_expr'] = combineBillingExpr(
data.billingExpr || '',
data.requestRuleExpr || ''
)
}
return values
}
export function applyPricingDraft(
options: PricingOptions,
data: ModelRatioData,
names: string[] = [data.name]
): PricingOptions {
return applyPricingValues(options, pricingFromDraft(data), names)
}
function applyPricingValues(
options: PricingOptions,
values: PricingValues,
names: string[]
): PricingOptions {
return Object.fromEntries(
PRICING_KEYS.map((key) => {
const map = JSON.parse(options[key]) as Record<string, number | string>
for (const name of names) {
delete map[name]
if (values[key] !== undefined) {
Object.defineProperty(map, name, {
value: values[key],
enumerable: true,
writable: true,
configurable: true,
})
}
}
return [key, JSON.stringify(map)]
})
) as PricingOptions
}
export function pricingValuesByModel(
options: PricingOptions
): Map<string, PricingValues> {
const models = new Map<string, PricingValues>()
for (const key of PRICING_KEYS) {
const map: unknown = JSON.parse(options[key])
if (map === null || typeof map !== 'object' || Array.isArray(map)) {
throw new Error(t('Pricing must be a JSON object'))
}
for (const [name, value] of Object.entries(map)) {
if (typeof value !== 'number' && typeof value !== 'string') {
throw new Error(t('Invalid pricing value'))
}
const model = models.get(name) ?? {}
model[key] = value
models.set(name, model)
}
}
return models
}
export function applyPriceSyncSelections(
options: PricingOptions,
selections: Record<string, Record<string, number | string>>
): PricingOptions {
let result = options
for (const [name, fields] of Object.entries(selections)) {
const next: PricingValues = {}
const expression =
typeof fields.billing_expr === 'string' &&
fields.billing_expr.trim() !== ''
if (expression) {
next['billing_setting.billing_mode'] = 'tiered_expr'
next['billing_setting.billing_expr'] = fields.billing_expr
} else {
next['billing_setting.billing_mode'] = 'ratio'
const fixed = fields.model_price !== undefined
for (const [field, value] of Object.entries(fields)) {
if (field === 'billing_mode' || field === 'billing_expr') continue
if (fixed && field !== 'model_price') continue
const key = field
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('') as PricingKey
if (PRICING_KEYS.includes(key)) next[key] = value
}
}
const validated = pricingFromDraft(pricingRow(name, next))
if (expression) {
// Sync is a literal import. The editor may normalize request-rule
// parentheses, which would otherwise produce another upstream diff.
validated['billing_setting.billing_expr'] = fields.billing_expr
}
result = applyPricingValues(result, validated, [name])
}
return result
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
import { useAuthStore } from '@/stores/auth-store'
import { ModelDeleteDialog } from '../components/dialogs/model-delete-dialog'
const models = [
{ id: 7, model_name: 'example-model', name_rule: 0 },
{ id: 8, model_name: 'another-model', name_rule: 0 },
]
function Fixture(props: { batch?: boolean; onSuccess?: () => void }) {
const [open, setOpen] = useState(true)
return (
<>
<button type='button' onClick={() => setOpen(true)}>
Open deletion
</button>
{open && (
<ModelDeleteDialog
models={props.batch ? models : models.slice(0, 1)}
onClose={() => setOpen(false)}
onSuccess={props.onSuccess}
/>
)}
</>
)
}
function mount(batch = false) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
const onSuccess = vi.fn()
const invalidate = vi.spyOn(client, 'invalidateQueries')
render(
<QueryClientProvider client={client}>
<Fixture batch={batch} onSuccess={onSuccess} />
</QueryClientProvider>
)
return { onSuccess, invalidate }
}
afterEach(() => {
cleanup()
useAuthStore.getState().auth.reset()
})
describe('model deletion', () => {
it('defaults to keeping channels and resets the option after cancelling', async () => {
const remove = vi.spyOn(api, 'delete').mockResolvedValue({
data: {
success: true,
data: { deleted_count: 1, updated_channels: 0 },
},
})
const { onSuccess, invalidate } = mount()
const user = userEvent.setup()
expect(
screen.getByRole('checkbox', { name: 'Also remove from all channels' })
).not.toBeChecked()
await user.click(
screen.getByRole('checkbox', { name: 'Also remove from all channels' })
)
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(remove).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'Open deletion' }))
expect(
screen.getByRole('checkbox', { name: 'Also remove from all channels' })
).not.toBeChecked()
await user.click(screen.getByRole('button', { name: 'Delete' }))
await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce())
expect(remove).toHaveBeenCalledWith('/api/models/7', {
params: { remove_from_channels: false, remove_pricing: false },
})
expect(invalidate).not.toHaveBeenCalledWith({ queryKey: ['channels'] })
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
it('submits one batch with channel removal and preserves selection after failure for retry', async () => {
const post = vi
.spyOn(api, 'post')
.mockResolvedValueOnce({
data: { success: false, message: 'Channel update failed' },
})
.mockResolvedValue({
data: {
success: true,
data: { deleted_count: 2, updated_channels: 3 },
},
})
const { onSuccess, invalidate } = mount(true)
const user = userEvent.setup()
await user.click(
screen.getByRole('checkbox', { name: 'Also remove from all channels' })
)
await user.click(screen.getByRole('button', { name: 'Delete' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'Channel update failed'
)
expect(onSuccess).not.toHaveBeenCalled()
expect(
screen.getByRole('checkbox', { name: 'Also remove from all channels' })
).toBeChecked()
await user.click(screen.getByRole('button', { name: 'Delete' }))
await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce())
expect(post).toHaveBeenLastCalledWith('/api/models/delete', {
model_ids: [7, 8],
remove_from_channels: true,
remove_pricing: false,
})
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['channels'] })
})
it('disables dismissal and repeated submission while a single removal is pending', async () => {
let complete!: (response: unknown) => void
const remove = vi.spyOn(api, 'delete').mockImplementation(
() =>
new Promise((resolve) => {
complete = resolve
})
)
const { onSuccess } = mount()
const user = userEvent.setup()
await user.click(
screen.getByRole('checkbox', { name: 'Also remove from all channels' })
)
await user.click(screen.getByRole('button', { name: 'Delete' }))
expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled()
expect(
screen.getByRole('checkbox', { name: 'Also remove from all channels' })
).toHaveAttribute('aria-disabled', 'true')
await user.keyboard('{Escape}')
expect(screen.getByRole('alertdialog')).toBeVisible()
expect(remove).toHaveBeenCalledTimes(1)
expect(remove).toHaveBeenCalledWith('/api/models/7', {
params: { remove_from_channels: true, remove_pricing: false },
})
complete({
data: { success: true, data: { deleted_count: 1, updated_channels: 2 } },
})
await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce())
})
})
it('lets a super administrator remove pricing independently of channel removal', async () => {
useAuthStore.getState().auth.setUser({ id: 1, username: 'root', role: 100 })
const post = vi.spyOn(api, 'post').mockResolvedValue({
data: { success: true, data: { deleted_count: 2, updated_channels: 0 } },
})
const { onSuccess, invalidate } = mount(true)
const user = userEvent.setup()
expect(
screen.getByRole('checkbox', { name: 'Also remove pricing' })
).not.toBeChecked()
await user.click(
screen.getByRole('checkbox', { name: 'Also remove pricing' })
)
expect(
screen.getByText(/Built-in pricing may become effective again/)
).toBeVisible()
expect(screen.queryByText(/Pricing will be retained/)).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Delete' }))
await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce())
expect(post).toHaveBeenCalledWith('/api/models/delete', {
model_ids: [7, 8],
remove_from_channels: false,
remove_pricing: true,
})
expect(invalidate).toHaveBeenCalledWith({
queryKey: ['model-pricing-config'],
})
expect(invalidate).not.toHaveBeenCalledWith({ queryKey: ['channels'] })
})
it('keeps pricing removal unavailable to an ordinary administrator', () => {
useAuthStore.getState().auth.setUser({ id: 2, username: 'admin', role: 10 })
mount()
expect(
screen.getByRole('checkbox', { name: 'Also remove pricing' })
).toHaveAttribute('aria-disabled', 'true')
expect(
screen.getByText('Model pricing is managed by a super administrator.')
).toBeVisible()
})
it.each([1, 2, 3])(
'disables channel removal when selection includes matching rule %i',
(rule) => {
const client = new QueryClient({
defaultOptions: { mutations: { retry: false } },
})
render(
<QueryClientProvider client={client}>
<ModelDeleteDialog
models={[models[1], { ...models[0], name_rule: rule }]}
onClose={() => {}}
/>
</QueryClientProvider>
)
expect(
screen.getByRole('checkbox', { name: /Also remove from all channels/ })
).toHaveAttribute('aria-disabled', 'true')
expect(screen.getByText('Only available for exact matching')).toBeVisible()
}
)
...@@ -33,7 +33,7 @@ import type { ...@@ -33,7 +33,7 @@ import type {
PrefillGroupsResponse, PrefillGroupsResponse,
SyncLocale, SyncLocale,
SyncSource, SyncSource,
SyncOverwritePayload, MetadataSyncRequest,
DeploymentSettingsResponse, DeploymentSettingsResponse,
ListDeploymentsResponse, ListDeploymentsResponse,
} from './types' } from './types'
...@@ -76,7 +76,10 @@ export async function getModel(id: number): Promise<GetModelResponse> { ...@@ -76,7 +76,10 @@ export async function getModel(id: number): Promise<GetModelResponse> {
export async function createModel( export async function createModel(
data: Partial<Model> data: Partial<Model>
): Promise<{ success: boolean; message?: string; data?: Model }> { ): Promise<{ success: boolean; message?: string; data?: Model }> {
const res = await api.post('/api/models/', data) const res = await api.post('/api/models/', data, {
skipBusinessError: true,
skipErrorHandler: true,
})
return res.data return res.data
} }
...@@ -86,7 +89,10 @@ export async function createModel( ...@@ -86,7 +89,10 @@ export async function createModel(
export async function updateModel( export async function updateModel(
data: Partial<Model> & { id: number } data: Partial<Model> & { id: number }
): Promise<{ success: boolean; message?: string; data?: Model }> { ): Promise<{ success: boolean; message?: string; data?: Model }> {
const res = await api.put('/api/models/', data) const res = await api.put('/api/models/', data, {
skipBusinessError: true,
skipErrorHandler: true,
})
return res.data return res.data
} }
...@@ -105,9 +111,16 @@ export async function updateModelStatus( ...@@ -105,9 +111,16 @@ export async function updateModelStatus(
* Delete model * Delete model
*/ */
export async function deleteModel( export async function deleteModel(
id: number id: number,
): Promise<{ success: boolean; message?: string }> { removeFromChannels = false,
const res = await api.delete(`/api/models/${id}`) removePricing = false
): Promise<{ success: boolean; message?: string; data: ModelDeleteResult }> {
const res = await api.delete(`/api/models/${id}`, {
params: {
remove_from_channels: removeFromChannels,
remove_pricing: removePricing,
},
})
return res.data return res.data
} }
...@@ -132,6 +145,7 @@ export async function getVendors(params?: { ...@@ -132,6 +145,7 @@ export async function getVendors(params?: {
* Search vendors * Search vendors
*/ */
export async function searchVendors(params: { export async function searchVendors(params: {
association?: string
keyword?: string keyword?: string
p?: number p?: number
page_size?: number page_size?: number
...@@ -185,11 +199,9 @@ export async function deleteVendor( ...@@ -185,11 +199,9 @@ export async function deleteVendor(
/** /**
* Sync upstream models (missing only or with overwrite) * Sync upstream models (missing only or with overwrite)
*/ */
export async function syncUpstream(params?: { export async function syncUpstream(
locale?: SyncLocale params: MetadataSyncRequest
source?: SyncSource ): Promise<SyncUpstreamResponse> {
overwrite?: SyncOverwritePayload[]
}): Promise<SyncUpstreamResponse> {
const res = await api.post('/api/models/sync_upstream', params) const res = await api.post('/api/models/sync_upstream', params)
return res.data return res.data
} }
...@@ -216,17 +228,6 @@ export async function previewUpstreamDiff(params?: { ...@@ -216,17 +228,6 @@ export async function previewUpstreamDiff(params?: {
return res.data return res.data
} }
/**
* Apply upstream overwrite
*/
export async function applyUpstreamOverwrite(params: {
overwrite: SyncOverwritePayload[]
locale?: SyncLocale
source?: SyncSource
}): Promise<SyncUpstreamResponse> {
return syncUpstream(params)
}
// ============================================================================ // ============================================================================
// Utility Operations // Utility Operations
// ============================================================================ // ============================================================================
...@@ -631,3 +632,21 @@ export async function checkClusterNameAvailability(name: string): Promise<{ ...@@ -631,3 +632,21 @@ export async function checkClusterNameAvailability(name: string): Promise<{
}) })
return res.data return res.data
} }
export interface ModelDeleteResult {
deleted_count: number
updated_channels: number
}
export async function deleteModels(
modelIds: number[],
removeFromChannels = false,
removePricing = false
): Promise<{ success: boolean; message?: string; data: ModelDeleteResult }> {
const res = await api.post('/api/models/delete', {
model_ids: modelIds,
remove_from_channels: removeFromChannels,
remove_pricing: removePricing,
})
return res.data
}
...@@ -17,14 +17,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,14 +17,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { type Table } from '@tanstack/react-table' import type { Table } from '@tanstack/react-table'
import { Power, PowerOff, Trash2, Copy } from 'lucide-react' import { Eye, EyeOff, Trash2, Copy, Building2, Unlink } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table' import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
Tooltip, Tooltip,
...@@ -33,12 +32,11 @@ import { ...@@ -33,12 +32,11 @@ import {
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { copyToClipboard } from '@/lib/copy-to-clipboard' import { copyToClipboard } from '@/lib/copy-to-clipboard'
import { import { handleBatchEnableModels, handleBatchDisableModels } from '../lib'
handleBatchEnableModels,
handleBatchDisableModels,
handleBatchDeleteModels,
} from '../lib'
import type { Model } from '../types' import type { Model } from '../types'
import type { VendorOperation } from '../vendor-api'
import { ModelDeleteDialog } from './dialogs/model-delete-dialog'
import { VendorOperationDialog } from './dialogs/vendor-operation-dialog'
interface DataTableBulkActionsProps<TData> { interface DataTableBulkActionsProps<TData> {
table: Table<TData> table: Table<TData>
...@@ -49,6 +47,8 @@ export function DataTableBulkActions<TData>({ ...@@ -49,6 +47,8 @@ export function DataTableBulkActions<TData>({
}: DataTableBulkActionsProps<TData>) { }: DataTableBulkActionsProps<TData>) {
const { t } = useTranslation() const { t } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [vendorOperation, setVendorOperation] =
useState<VendorOperation | null>(null)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const selectedRows = table.getFilteredSelectedRowModel().rows const selectedRows = table.getFilteredSelectedRowModel().rows
...@@ -76,13 +76,6 @@ export function DataTableBulkActions<TData>({ ...@@ -76,13 +76,6 @@ export function DataTableBulkActions<TData>({
handleBatchDisableModels(selectedIds, queryClient, handleClearSelection) handleBatchDisableModels(selectedIds, queryClient, handleClearSelection)
} }
const handleDeleteAll = () => {
handleBatchDeleteModels(selectedIds, queryClient, () => {
setShowDeleteConfirm(false)
handleClearSelection()
})
}
const handleCopyNames = async () => { const handleCopyNames = async () => {
const names = selectedModels.map((m) => m.model_name).join(',') const names = selectedModels.map((m) => m.model_name).join(',')
const success = await copyToClipboard(names) const success = await copyToClipboard(names)
...@@ -95,7 +88,42 @@ export function DataTableBulkActions<TData>({ ...@@ -95,7 +88,42 @@ export function DataTableBulkActions<TData>({
return ( return (
<> <>
{vendorOperation && (
<VendorOperationDialog
selection={vendorOperation}
onClose={() => setVendorOperation(null)}
onSuccess={handleClearSelection}
/>
)}
<BulkActionsToolbar table={table} entityName='model'> <BulkActionsToolbar table={table} entityName='model'>
<Button
variant='outline'
size='icon'
className='size-8'
title={t('Change vendor')}
aria-label={t('Change vendor')}
onClick={() =>
setVendorOperation({ action: 'assign', model_ids: selectedIds })
}
>
<Building2 />
</Button>
<Button
variant='outline'
size='icon'
className='size-8'
title={t('Clear vendor')}
aria-label={t('Clear vendor')}
onClick={() =>
setVendorOperation({
action: 'assign',
model_ids: selectedIds,
target_vendor_id: 0,
})
}
>
<Unlink />
</Button>
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={
...@@ -104,16 +132,18 @@ export function DataTableBulkActions<TData>({ ...@@ -104,16 +132,18 @@ export function DataTableBulkActions<TData>({
size='icon' size='icon'
onClick={handleEnableAll} onClick={handleEnableAll}
className='size-8' className='size-8'
aria-label={t('Enable selected models')} aria-label={t('Show selected models in model square')}
title={t('Enable selected models')} title={t('Show selected models in model square')}
/> />
} }
> >
<Power /> <Eye />
<span className='sr-only'>{t('Enable selected models')}</span> <span className='sr-only'>
{t('Show selected models in model square')}
</span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{t('Enable selected models')}</p> <p>{t('Show selected models in model square')}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
...@@ -125,16 +155,18 @@ export function DataTableBulkActions<TData>({ ...@@ -125,16 +155,18 @@ export function DataTableBulkActions<TData>({
size='icon' size='icon'
onClick={handleDisableAll} onClick={handleDisableAll}
className='size-8' className='size-8'
aria-label={t('Disable selected models')} aria-label={t('Hide selected models from model square')}
title={t('Disable selected models')} title={t('Hide selected models from model square')}
/> />
} }
> >
<PowerOff /> <EyeOff />
<span className='sr-only'>{t('Disable selected models')}</span> <span className='sr-only'>
{t('Hide selected models from model square')}
</span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{t('Disable selected models')}</p> <p>{t('Hide selected models from model square')}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
...@@ -181,32 +213,13 @@ export function DataTableBulkActions<TData>({ ...@@ -181,32 +213,13 @@ export function DataTableBulkActions<TData>({
</Tooltip> </Tooltip>
</BulkActionsToolbar> </BulkActionsToolbar>
{/* Delete Confirmation Dialog */} {showDeleteConfirm && (
<Dialog <ModelDeleteDialog
open={showDeleteConfirm} models={selectedModels}
onOpenChange={setShowDeleteConfirm} onClose={() => setShowDeleteConfirm(false)}
title={t('Delete Models?')} onSuccess={handleClearSelection}
description={t( />
'Are you sure you want to delete {{count}} model(s)? This action cannot be undone.', )}
{ count: selectedIds.length }
)}
contentHeight='auto'
footer={
<>
<Button
variant='outline'
onClick={() => setShowDeleteConfirm(false)}
>
{t('Cancel')}
</Button>
<Button variant='destructive' onClick={handleDeleteAll}>
{t('Delete')}
</Button>
</>
}
>
{' '}
</Dialog>
</> </>
) )
} }
...@@ -18,29 +18,20 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,29 +18,20 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import type { Row } from '@tanstack/react-table' import type { Row } from '@tanstack/react-table'
import { Pencil, Power, PowerOff, Trash2 } from 'lucide-react' import { Eye, EyeOff, Trash2 } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataTableRowActionMenu } from '@/components/data-table/core/row-action-menu' import { DataTableRowActionMenu } from '@/components/data-table/core/row-action-menu'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuShortcut, DropdownMenuShortcut,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { import { handleToggleModelStatus, isModelEnabled } from '../lib'
handleDeleteModel,
handleToggleModelStatus,
isModelEnabled,
} from '../lib'
import type { Model } from '../types' import type { Model } from '../types'
import { ModelDeleteDialog } from './dialogs/model-delete-dialog'
import { useModels } from './models-provider' import { useModels } from './models-provider'
interface DataTableRowActionsProps { interface DataTableRowActionsProps {
...@@ -65,48 +56,23 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { ...@@ -65,48 +56,23 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
handleToggleModelStatus(model.id, model.status, queryClient) handleToggleModelStatus(model.id, model.status, queryClient)
} }
const toggleLabel = isEnabled ? t('Disable') : t('Enable') const toggleLabel = isEnabled
? t('Hide from model square')
: t('Show in model square')
return ( return (
<div className='-ml-1.5 flex items-center gap-1'> <div className='-ml-1.5 flex items-center gap-1'>
<Tooltip> <Button variant='ghost' size='sm' onClick={handleEdit}>
<TooltipTrigger {t('Edit')}
render={ </Button>
<Button
variant='ghost'
size='icon-sm'
onClick={handleEdit}
aria-label={t('Edit')}
/>
}
>
<Pencil />
</TooltipTrigger>
<TooltipContent>{t('Edit')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={handleToggleStatus}
aria-label={toggleLabel}
className={
isEnabled
? 'text-destructive hover:text-destructive'
: 'text-success hover:text-success'
}
/>
}
>
{isEnabled ? <PowerOff /> : <Power />}
</TooltipTrigger>
<TooltipContent>{toggleLabel}</TooltipContent>
</Tooltip>
<DataTableRowActionMenu ariaLabel={t('Open menu')}> <DataTableRowActionMenu ariaLabel={t('Open menu')}>
<DropdownMenuItem onClick={handleToggleStatus}>
{toggleLabel}
<DropdownMenuShortcut>
{isEnabled ? <EyeOff size={16} /> : <Eye size={16} />}
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onSelect={(e) => { onSelect={(e) => {
e.preventDefault() e.preventDefault()
...@@ -121,21 +87,12 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { ...@@ -121,21 +87,12 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
</DropdownMenuItem> </DropdownMenuItem>
</DataTableRowActionMenu> </DataTableRowActionMenu>
<ConfirmDialog {deleteConfirmOpen && (
open={deleteConfirmOpen} <ModelDeleteDialog
onOpenChange={setDeleteConfirmOpen} models={[model]}
title={t('Delete Model')} onClose={() => setDeleteConfirmOpen(false)}
desc={t( />
'Are you sure you want to delete model "{{name}}"? This action cannot be undone.', )}
{ name: model.model_name }
)}
confirmText={t('Delete')}
destructive
handleConfirm={() => {
handleDeleteModel(model.id, queryClient)
setDeleteConfirmOpen(false)
}}
/>
</div> </div>
) )
} }
...@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { Combobox } from '@/components/ui/combobox'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo } from 'react' import { useEffect, useMemo } from 'react'
...@@ -43,14 +44,7 @@ import { ...@@ -43,14 +44,7 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form' } from '@/components/ui/form'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { import {
Sheet, Sheet,
SheetClose, SheetClose,
...@@ -459,32 +453,19 @@ export function CreateDeploymentDrawer({ ...@@ -459,32 +453,19 @@ export function CreateDeploymentDrawer({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('Hardware type')}</FormLabel> <FormLabel>{t('Hardware type')}</FormLabel>
<Select <FormControl><Combobox
items={[ options={[
...hardwareOptions.map((opt) => ({ ...hardwareOptions.map((opt) => ({
value: opt.value, value: opt.value,
label: opt.label, label: opt.label,
})), })),
]} ]}
value={field.value} value={field.value}
onValueChange={(v) => field.onChange(v)} onValueChange={(v) => field.onChange(v)}
disabled={isLoadingHardware} disabled={isLoadingHardware}
> className='w-full'
<FormControl> placeholder={t('Select')}
<SelectTrigger className='w-full'> /></FormControl>
<SelectValue placeholder={t('Select')} />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{hardwareOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
......
...@@ -17,11 +17,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,11 +17,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { ChevronLeft, ChevronRight, Loader2, Plus, Search } from 'lucide-react' import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Dialog } from '@/components/dialog' import { Dialog } from '@/components/dialog'
import { ErrorState } from '@/components/error-state'
import { LoadingState } from '@/components/loading-state'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
...@@ -55,9 +57,15 @@ export function MissingModelsDialog({ ...@@ -55,9 +57,15 @@ export function MissingModelsDialog({
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const { data, isLoading } = useQuery({ const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: modelsQueryKeys.missing(), queryKey: modelsQueryKeys.missing(),
queryFn: getMissingModels, queryFn: async () => {
const response = await getMissingModels()
if (!response.success) {
throw new Error(response.message || t('Operation failed'))
}
return response
},
enabled: open, enabled: open,
}) })
...@@ -123,19 +131,28 @@ export function MissingModelsDialog({ ...@@ -123,19 +131,28 @@ export function MissingModelsDialog({
contentHeight='min(74vh, 760px)' contentHeight='min(74vh, 760px)'
bodyClassName='space-y-4' bodyClassName='space-y-4'
initialFocus={!isMobile} initialFocus={!isMobile}
footer={
<Button variant='outline' onClick={() => setOpen('sync-wizard')}>
{t('Sync missing metadata')}
</Button>
}
> >
{isLoading ? ( {isLoading && <LoadingState />}
<div className='flex items-center justify-center py-12'> {isError && (
<Loader2 className='h-8 w-8 animate-spin' /> <ErrorState
</div> description={error.message}
) : missingModels.length === 0 ? ( onRetry={() => void refetch()}
/>
)}
{!isLoading && !isError && missingModels.length === 0 && (
<div className='text-muted-foreground py-12 text-center'> <div className='text-muted-foreground py-12 text-center'>
<p>{t('No missing models found.')}</p> <p>{t('No missing models found.')}</p>
<p className='text-sm'> <p className='text-sm'>
{t('All models in use are properly configured.')} {t('All models in use are properly configured.')}
</p> </p>
</div> </div>
) : ( )}
{!isLoading && !isError && missingModels.length > 0 && (
<div className='flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto'> <div className='flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto'>
<div className='flex flex-shrink-0 items-center justify-between gap-3'> <div className='flex flex-shrink-0 items-center justify-between gap-3'>
<div className='text-muted-foreground text-sm whitespace-nowrap'> <div className='text-muted-foreground text-sm whitespace-nowrap'>
......
/*
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 { useMutation, useQueryClient } from '@tanstack/react-query'
import { isAxiosError } from 'axios'
import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import {
useCanEditModelPricing,
invalidateModelPricing,
} from '@/features/model-pricing/api'
import { deleteModel, deleteModels } from '../../api'
import type { Model } from '../../types'
import { invalidateVendorData } from '../../vendor-api'
interface ModelDeleteDialogProps {
models: Pick<Model, 'id' | 'model_name' | 'name_rule'>[]
onClose: () => void
onSuccess?: () => void
}
export function ModelDeleteDialog(props: ModelDeleteDialogProps) {
const { t } = useTranslation()
const checkboxId = useId()
const pricingCheckboxId = useId()
const canEditPricing = useCanEditModelPricing()
const supportsChannelRemoval = props.models.every(
(model) => model.name_rule === 0
)
const [removePricing, setRemovePricing] = useState(false)
const [removeFromChannels, setRemoveFromChannels] = useState(false)
const client = useQueryClient()
const mutation = useMutation({
mutationFn: async () => {
const ids = props.models.map((model) => model.id)
const response =
ids.length === 1
? await deleteModel(
ids[0],
removeFromChannels && supportsChannelRemoval,
removePricing && canEditPricing
)
: await deleteModels(
ids,
removeFromChannels && supportsChannelRemoval,
removePricing && canEditPricing
)
if (!response.success) {
throw new Error(response.message || t('Failed to delete model'))
}
return response.data
},
onSuccess: async (result) => {
await invalidateVendorData(client)
if (removePricing) await invalidateModelPricing(client)
if (removeFromChannels) {
await client.invalidateQueries({ queryKey: ['channels'] })
}
toast.success(
t('Successfully deleted {{count}} model(s)', {
count: result.deleted_count,
})
)
props.onSuccess?.()
props.onClose()
},
})
const description =
props.models.length === 1
? t('Delete model "{{name}}"?', { name: props.models[0].model_name })
: t('Delete {{count}} models?', { count: props.models.length })
let errorMessage = mutation.error?.message
if (isAxiosError<{ message?: string }>(mutation.error)) {
errorMessage = mutation.error.response?.data.message || errorMessage
}
return (
<ConfirmDialog
open
onOpenChange={(open) => {
if (!open && !mutation.isPending) props.onClose()
}}
title={t('Delete Models?')}
desc={description}
confirmText={t('Delete')}
destructive
disabled={!props.models.length}
isLoading={mutation.isPending}
handleConfirm={() => mutation.mutate()}
>
<div className='space-y-3'>
<div className='flex items-start gap-2'>
<Checkbox
id={checkboxId}
className='mt-0.5'
checked={removeFromChannels && supportsChannelRemoval}
disabled={mutation.isPending || !supportsChannelRemoval}
onCheckedChange={(checked) =>
setRemoveFromChannels(checked === true)
}
/>
<Label htmlFor={checkboxId} className='flex-wrap leading-normal'>
{t('Also remove from all channels')}
{!supportsChannelRemoval && (
<span className='text-muted-foreground text-xs font-normal'>
{t('Only available for exact matching')}
</span>
)}
</Label>
</div>
<div className='flex items-start gap-2'>
<Checkbox
id={pricingCheckboxId}
className='mt-0.5'
checked={removePricing}
disabled={mutation.isPending || !canEditPricing}
onCheckedChange={(checked) => setRemovePricing(checked === true)}
/>
<Label htmlFor={pricingCheckboxId} className='leading-normal'>
{t('Also remove pricing')}
</Label>
</div>
{(removePricing || !canEditPricing) && (
<p className='text-muted-foreground text-sm'>
{canEditPricing
? t('Built-in pricing may become effective again.')
: t('Model pricing is managed by a super administrator.')}
</p>
)}
{mutation.isError && (
<p role='alert' className='text-destructive text-sm'>
{errorMessage || t('Failed to delete model')}
</p>
)}
</div>
</ConfirmDialog>
)
}
/*
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 { Dialog } from '@/components/dialog'
import { UpstreamRatioSync } from '@/features/system-settings/models/upstream-ratio-sync'
export function PriceSyncDialog(props: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { t } = useTranslation()
return (
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={t('Sync model pricing')}
description={t('Compare prices and choose a source for each model.')}
contentClassName='sm:max-w-6xl'
contentHeight='min(75vh, 800px)'
bodyClassName='h-full'
>
{props.open && <UpstreamRatioSync />}
</Dialog>
)
}
/*
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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { StaticDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { ErrorState } from '@/components/error-state'
import { LoadingState } from '@/components/loading-state'
import { Button } from '@/components/ui/button'
import { Combobox } from '@/components/ui/combobox'
import { Label } from '@/components/ui/label'
import { getLobeIcon } from '@/lib/lobe-icon'
import { getVendors } from '../../api'
import { vendorsQueryKeys } from '../../lib'
import {
applyVendorOperation,
invalidateVendorData,
previewVendorOperation,
vendorErrorMessage,
type VendorOperation,
} from '../../vendor-api'
// Mount a fresh dialog for each explicit selection. Previewing never writes.
export function VendorOperationDialog(props: {
selection: VendorOperation
onClose: () => void
onSuccess?: () => void
}) {
const { t } = useTranslation()
const client = useQueryClient()
const [target, setTarget] = useState(
props.selection.target_vendor_id?.toString() ?? ''
)
const action = props.selection.action
const vendorsQuery = useQuery({
queryKey: vendorsQueryKeys.list(),
queryFn: async () => {
const response = await getVendors({ page_size: 1000 })
if (!response.success) {
throw new Error(response.message || t('Failed to load vendors'))
}
return response
},
enabled: action !== 'delete',
})
const vendorOptions = (vendorsQuery.data?.data?.items ?? []).map(
(vendor) => ({
value: String(vendor.id),
label: vendor.name,
icon: getLobeIcon(vendor.icon, 18),
})
)
if (action === 'assign') {
vendorOptions.unshift({
value: '0',
label: t('No vendor'),
icon: getLobeIcon('', 18),
})
}
const request: VendorOperation = {
...props.selection,
target_vendor_id: Number(target),
vendor_ids:
action === 'merge'
? props.selection.vendor_ids?.filter((id) => id !== Number(target))
: props.selection.vendor_ids,
}
const preview = useMutation({
mutationFn: () => previewVendorOperation(request),
})
const apply = useMutation({
mutationFn: () =>
applyVendorOperation({
...request,
expected_version: preview.data?.version,
}),
onSuccess: async (result) => {
await invalidateVendorData(client)
toast.success(
t(
'Updated {{models}} model assignments and deleted {{vendors}} vendor records.',
{
models: result.updated_models.length,
vendors: result.deleted_vendors.length,
}
)
)
props.onSuccess?.()
props.onClose()
},
})
let title = t('Change model vendor')
if (action === 'merge') title = t('Merge vendors')
if (action === 'delete') title = t('Delete vendors')
const busy = preview.isPending || apply.isPending
const valid =
action === 'delete' ||
(target !== '' &&
(action !== 'merge' || Boolean(request.vendor_ids?.length)))
return (
<Dialog
open
onOpenChange={(open) => {
if (!open && !busy) props.onClose()
}}
title={title}
description={t(
'Review the affected records before applying. Pricing, channels, and model names are preserved.'
)}
contentClassName='sm:max-w-4xl'
contentHeight='min(75vh, 760px)'
footer={
<>
<Button variant='outline' disabled={busy} onClick={props.onClose}>
{t('Cancel')}
</Button>
{!preview.data && (
<Button
disabled={
!valid || busy || (action !== 'delete' && vendorsQuery.isError)
}
onClick={() => preview.mutate()}
>
{t('Preview changes')}
</Button>
)}
{preview.data && (
<Button
variant={action === 'assign' ? 'default' : 'destructive'}
disabled={busy || apply.isError}
onClick={() => apply.mutate()}
>
{busy ? t('Applying...') : t('Apply changes')}
</Button>
)}
</>
}
>
<div className='space-y-4'>
{action !== 'delete' && (
<div className='space-y-2'>
<Label htmlFor='vendor-operation-target'>
{action === 'merge' ? t('Keep this vendor') : t('Target vendor')}
</Label>
<Combobox
id='vendor-operation-target'
disabled={busy}
options={vendorOptions}
value={target}
onValueChange={(value) => {
if (busy) return
setTarget(value ?? '')
preview.reset()
apply.reset()
}}
searchPlaceholder={t('Search vendors')}
emptyText={t('No vendors found')}
/>
{action === 'merge' && (
<p className='text-muted-foreground text-sm'>
{t(
'Source vendors will be deleted after their model assignments are moved. The target vendor’s details are preserved.'
)}
</p>
)}
</div>
)}
{action === 'delete' && (
<p className='text-sm'>
{t(
'Delete {{count}} selected vendor records? Vendors with linked models cannot be deleted.',
{ count: request.vendor_ids?.length ?? 0 }
)}
</p>
)}
{action !== 'delete' && vendorsQuery.isError && (
<ErrorState
description={vendorErrorMessage(vendorsQuery.error)}
onRetry={() => void vendorsQuery.refetch()}
/>
)}
{busy && <LoadingState />}
{preview.isError && (
<ErrorState
description={vendorErrorMessage(preview.error)}
onRetry={() => preview.mutate()}
/>
)}
{apply.isError && (
<ErrorState
description={vendorErrorMessage(apply.error)}
action={
<Button
variant='outline'
onClick={() => {
apply.reset()
preview.reset()
preview.mutate()
}}
>
{t('Preview again')}
</Button>
}
/>
)}
{preview.data && (
<>
<div className='space-y-2 rounded-lg border p-3 text-sm'>
<p>
{t('Source vendors')}:{' '}
{preview.data.sources.map((vendor) => vendor.name).join(', ') ||
t('No vendor')}
</p>
{action !== 'delete' && (
<p>
{t('Target vendor')}:{' '}
{preview.data.target?.name || t('No vendor')}
</p>
)}
<p>
{t('{{count}} linked model records', {
count: preview.data.models.length,
})}
</p>
</div>
{action === 'delete' ? (
<StaticDataTable
data={preview.data.sources}
columns={[
{
id: 'vendor',
header: t('Vendor'),
cell: (vendor) => vendor.name,
},
{
id: 'action',
header: t('Planned action'),
cell: () => t('Delete vendor record'),
},
]}
/>
) : (
<StaticDataTable
tableClassName='min-w-[480px]'
data={preview.data.models}
columns={[
{
id: 'model',
header: t('Model'),
cell: (model) => (
<span className='block max-w-60 font-mono break-all whitespace-normal'>
{model.model_name}
</span>
),
},
{
id: 'source',
header: t('Current vendor'),
cell: (model) => model.vendor_name || t('No vendor'),
},
{
id: 'target',
header: t('Target vendor'),
cell: () => preview.data?.target?.name || t('No vendor'),
},
]}
/>
)}
</>
)}
</div>
</Dialog>
)
}
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment