diff --git a/constant/context_key.go b/constant/context_key.go index b856bc3dda14..88a2808dc046 100644 --- a/constant/context_key.go +++ b/constant/context_key.go @@ -72,4 +72,8 @@ const ( // fallback in authHelper (finishAdminAudit) skips its record to avoid // duplicate entries. ContextKeyAuditLogged ContextKey = "audit_logged" + + // combo routing + ContextKeyComboName ContextKey = "combo_name" + ContextKeyComboStrategy ContextKey = "combo_strategy" ) diff --git a/controller/combo.go b/controller/combo.go new file mode 100644 index 000000000000..2678956da072 --- /dev/null +++ b/controller/combo.go @@ -0,0 +1,222 @@ +package controller + +import ( + "fmt" + "strconv" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" +) + +// CreateCombo handles POST /api/combo/ +func CreateCombo(c *gin.Context) { + var combo model.Combo + if err := c.ShouldBindJSON(&combo); err != nil { + common.ApiErrorMsg(c, fmt.Sprintf("Invalid request: %s", err.Error())) + return + } + + // Validate required fields + if combo.Name == "" { + common.ApiErrorMsg(c, "Combo name is required") + return + } + if combo.Models == "" { + common.ApiErrorMsg(c, "At least one model is required") + return + } + if combo.Strategy == "" { + combo.Strategy = "fallback" + } + if combo.Strategy != "fallback" && combo.Strategy != "random" && combo.Strategy != "weighted" && combo.Strategy != "round_robin" { + common.ApiErrorMsg(c, "Strategy must be one of: fallback, random, weighted, round_robin") + return + } + userId := c.GetInt("id") + combo.UserId = userId + combo.Status = 1 + combo.CreatedTime = time.Now().Unix() + + // Check name uniqueness scoped to this user. + if existing, _ := model.GetComboByNameUserId(combo.Name, userId); existing != nil { + common.ApiErrorMsg(c, "Combo name already exists") + return + } + + if err := combo.Insert(); err != nil { + common.ApiErrorMsg(c, fmt.Sprintf("Failed to create combo: %s", err.Error())) + return + } + + common.ApiSuccess(c, combo) +} + +// GetComboList handles GET /api/combo/ +func GetComboList(c *gin.Context) { + userId := c.GetInt("id") + role := c.GetInt("role") + pageInfo := common.GetPageQuery(c) + + var combos []*model.Combo + var total int64 + var err error + + if role >= common.RoleAdminUser { + // Admins see all combos + combos, total, err = model.GetAllCombos(pageInfo) + } else { + combos, err = model.GetCombosByUserId(userId) + if err == nil { + total = int64(len(combos)) + } + } + + if err != nil { + common.ApiErrorMsg(c, fmt.Sprintf("Failed to get combos: %s", err.Error())) + return + } + + if combos == nil { + combos = []*model.Combo{} + } + + common.ApiSuccess(c, gin.H{ + "items": combos, + "total": total, + "page": pageInfo.GetPage(), + "page_size": pageInfo.GetPageSize(), + }) +} + +// GetCombo handles GET /api/combo/:id +func GetCombo(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiErrorMsg(c, "Invalid combo id") + return + } + + combo, err := model.GetComboById(id) + if err != nil { + common.ApiErrorMsg(c, "Combo not found") + return + } + + // Ownership check: admin or owner + userId := c.GetInt("id") + role := c.GetInt("role") + if role < common.RoleAdminUser && combo.UserId != userId { + common.ApiErrorMsg(c, "Combo not found") + return + } + + common.ApiSuccess(c, combo) +} + +// UpdateCombo handles PUT /api/combo/:id +func UpdateCombo(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiErrorMsg(c, "Invalid combo id") + return + } + + combo, err := model.GetComboById(id) + if err != nil { + common.ApiErrorMsg(c, "Combo not found") + return + } + + // Ownership check + userId := c.GetInt("id") + role := c.GetInt("role") + if role < common.RoleAdminUser && combo.UserId != userId { + common.ApiErrorMsg(c, "Combo not found") + return + } + + var updateData model.Combo + if err := c.ShouldBindJSON(&updateData); err != nil { + common.ApiErrorMsg(c, fmt.Sprintf("Invalid request: %s", err.Error())) + return + } + + if updateData.Name != "" { + // Check name uniqueness scoped to this user. + if existing, _ := model.GetComboByNameUserId(updateData.Name, combo.UserId); existing != nil && existing.Id != combo.Id { + common.ApiErrorMsg(c, "Combo name already exists") + return + } + combo.Name = updateData.Name + } + + if updateData.Status >= 0 && updateData.Status <= 1 { + combo.Status = updateData.Status + } + + if err := combo.Update(); err != nil { + common.ApiErrorMsg(c, fmt.Sprintf("Failed to update combo: %s", err.Error())) + return + } + + common.ApiSuccess(c, combo) +} + +// DeleteCombo handles DELETE /api/combo/:id +func DeleteCombo(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiErrorMsg(c, "Invalid combo id") + return + } + + userId := c.GetInt("id") + role := c.GetInt("role") + + var delErr error + if role >= common.RoleAdminUser { + delErr = model.DeleteComboById(id) + } else { + delErr = model.DeleteComboById(id, userId) + } + + if delErr != nil { + common.ApiErrorMsg(c, fmt.Sprintf("Failed to delete combo: %s", delErr.Error())) + return + } + + common.ApiSuccess(c, gin.H{"id": id}) +} + +// SearchCombos handles GET /api/combo/search +func SearchCombos(c *gin.Context) { + keyword := c.Query("keyword") + if keyword == "" { + common.ApiErrorMsg(c, "Search keyword is required") + return + } + + pageInfo := common.GetPageQuery(c) + combos, total, err := model.SearchCombos(keyword, pageInfo) + if err != nil { + common.ApiErrorMsg(c, fmt.Sprintf("Failed to search combos: %s", err.Error())) + return + } + + if combos == nil { + combos = []*model.Combo{} + } + + common.ApiSuccess(c, gin.H{ + "items": combos, + "total": total, + "page": pageInfo.GetPage(), + "page_size": pageInfo.GetPageSize(), + }) +} diff --git a/docs/design/combo-feature.md b/docs/design/combo-feature.md new file mode 100644 index 000000000000..b19c1a3fc455 --- /dev/null +++ b/docs/design/combo-feature.md @@ -0,0 +1,61 @@ +# Combo Feature — Multi-Model Routing + +> **Date**: 2026-06-11 +> **Status**: Phase 1 complete (backend CRUD) + +## Concept + +A **Combo** bundles multiple models with a routing strategy into a named configuration. +Users send requests to a combo (via `model: "combo:my-combo"`), and the system resolves +which model(s) to use based on the strategy, then routes through the existing channel +selection layer. + +--- + +## Data Model + +| Field | Type | Description | +|---|---|---| +| `id` | `int` (PK, auto-increment) | | +| `name` | `varchar(128)` unique | Combo identifier, used as `combo:` in requests | +| `user_id` | `int` | Creator / owner | +| `models` | `text` | CSV — `"gpt-4,claude-3,gemini-pro"` | +| `strategy` | `varchar(32)` | `fallback` / `random` / `weighted` / `round_robin` | +| `weights` | `text` | JSON map for weighted: `{"gpt-4":3,"claude-3":2}` | +| `status` | `int` (0/1) | 1 = enabled | +| `created_time` | `bigint` | Unix timestamp | + +--- + +## Routing Strategies + +| Strategy | Behaviour | +|---|---| +| `fallback` | Iterate models in order → pick first that has an available channel | +| `random` | Uniform random selection from the model list | +| `weighted` | Weighted random using `weights` JSON | +| `round_robin` | Atomic counter → cycling through models evenly | + +--- + +## Phases + +- **Phase 1** ✅ — Backend CRUD + DB migration +- **Phase 2** ✅ — Routing integration in `middleware/distributor.go` +- **Phase 3** ✅ — Frontend management UI +- **Phase 4** — Advanced (parallel, billing, sharing) + +--- + +## Key Files + +| Layer | File | +|---|---| +| Model | `model/combo.go` | +| Migration | `model/main.go` | +| Controller | `controller/combo.go` | +| Routes | `router/api-router.go` | +| Service | `service/combo_routing.go` | +| Context keys | `constant/context_key.go` | +| Frontend feature | `web/default/src/features/combos/` | +| i18n | `web/default/src/i18n/locales/*.json` | diff --git a/middleware/distributor.go b/middleware/distributor.go index 258aebb57037..000b9f8a43dc 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -38,6 +38,57 @@ func Distribute() func(c *gin.Context) { abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidRequest, map[string]any{"Error": err.Error()})) return } + if ok { + // Specific channel override — skip combo routing + } else if strings.HasPrefix(modelRequest.Model, "combo:") && shouldSelectChannel { + // --- Combo Routing --- + comboName := strings.TrimPrefix(modelRequest.Model, "combo:") + if comboName == "" { + abortWithOpenAiMessage(c, http.StatusBadRequest, "Combo name is empty") + return + } + userId := c.GetInt("id") + combo, comboErr := model.GetComboByNameUserId(comboName, userId) + if comboErr != nil || combo == nil { + abortWithOpenAiMessage(c, http.StatusNotFound, "Combo not found: "+comboName) + return + } + if combo.Status != 1 { + abortWithOpenAiMessage(c, http.StatusForbidden, "Combo is disabled: "+comboName) + return + } + + // Store combo context for downstream (logging, billing) + common.SetContextKey(c, constant.ContextKeyComboName, combo.Name) + common.SetContextKey(c, constant.ContextKeyComboStrategy, combo.Strategy) + + tokenGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) + result, comboErr := service.ResolveComboModel(c, combo, tokenGroup) + if comboErr != nil { + abortWithOpenAiMessage(c, http.StatusServiceUnavailable, "Combo routing failed: "+comboErr.Error(), types.ErrorCodeModelNotFound) + return + } + + // Rewrite request body model field from "combo:xxx" to resolved model name + if rewriteErr := service.RewriteRequestBodyModel(c, result.ResolvedModel); rewriteErr != nil { + common.SysError("combo: failed to rewrite request body: " + rewriteErr.Error()) + abortWithOpenAiMessage(c, http.StatusInternalServerError, "Failed to rewrite request body") + return + } + + // Update modelRequest for downstream model-limit checks etc. + modelRequest.Model = result.ResolvedModel + + // For fallback (channel pre-resolved), bypass normal channel selection + if result.Channel != nil { + channel = result.Channel + shouldSelectChannel = false + if tokenGroup == "auto" && result.Group != "" { + common.SetContextKey(c, constant.ContextKeyAutoGroup, result.Group) + } + } + // For other strategies, normal channel selection runs below with resolved model name + } if ok { id, err := strconv.Atoi(channelId.(string)) if err != nil { @@ -53,7 +104,8 @@ func Distribute() func(c *gin.Context) { abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorChannelDisabled)) return } - } else { + } + if !ok && !strings.HasPrefix(modelRequest.Model, "combo:") { // Select a channel for the user // check token model mapping modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled) diff --git a/model/combo.go b/model/combo.go new file mode 100644 index 000000000000..99176184639a --- /dev/null +++ b/model/combo.go @@ -0,0 +1,137 @@ +package model + +import ( + "time" + + "github.com/QuantumNous/new-api/common" +) + +// Combo bundles multiple models with a routing strategy. +// Users reference a combo via model: "combo:" in their requests. +type Combo struct { + Id int `json:"id"` + Name string `json:"name" gorm:"uniqueIndex:idx_combo_name_user_id;type:varchar(128)"` + UserId int `json:"user_id" gorm:"uniqueIndex:idx_combo_name_user_id;index"` + Models string `json:"models" gorm:"type:text"` // CSV: "gpt-4,claude-3,gemini-pro" + Strategy string `json:"strategy" gorm:"type:varchar(32)"` // fallback | random | weighted | round_robin + Weights string `json:"weights" gorm:"type:text"` // JSON: {"gpt-4":3,"claude-3":2} + Status int `json:"status" gorm:"default:1"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` +} + +func (c *Combo) TableName() string { + return "combos" +} + +// Insert creates a new combo record. +func (c *Combo) Insert() error { + c.CreatedTime = time.Now().Unix() + return DB.Create(c).Error +} + +// Update persists changes to an existing combo. +func (c *Combo) Update() error { + return DB.Model(c).Updates(map[string]interface{}{ + "name": c.Name, + "models": c.Models, + "strategy": c.Strategy, + "weights": c.Weights, + "status": c.Status, + }).Error +} + +// Delete removes a combo by id. +func (c *Combo) Delete() error { + return DB.Delete(c).Error +} + +// GetComboById retrieves a combo by its primary key. +func GetComboById(id int) (*Combo, error) { + var combo Combo + err := DB.First(&combo, "id = ?", id).Error + if err != nil { + return nil, err + } + return &combo, nil +} + +// GetComboByNameUserId retrieves a combo by its name and user id. +func GetComboByNameUserId(name string, userId int) (*Combo, error) { + var combo Combo + err := DB.First(&combo, "name = ? AND user_id = ?", name, userId).Error + if err != nil { + return nil, err + } + return &combo, nil +} + +// GetComboByName retrieves a combo by its name (legacy). +// Deprecated: use GetComboByNameUserId for user-scoped lookups. +func GetComboByName(name string) (*Combo, error) { + var combo Combo + err := DB.First(&combo, "name = ?", name).Error + if err != nil { + return nil, err + } + return &combo, nil +} + +// GetCombosByUserId returns all combos owned by a specific user. +func GetCombosByUserId(userId int) ([]*Combo, error) { + var combos []*Combo + err := DB.Where("user_id = ?", userId).Order("id DESC").Find(&combos).Error + return combos, err +} + +// GetAllCombos returns all combos in the system (admin view). +func GetAllCombos(pageInfo *common.PageInfo) ([]*Combo, int64, error) { + var combos []*Combo + var total int64 + + query := DB.Model(&Combo{}) + err := query.Count(&total).Error + if err != nil { + return nil, 0, err + } + + err = query.Order("id DESC"). + Limit(pageInfo.GetPageSize()). + Offset(pageInfo.GetStartIdx()). + Find(&combos).Error + return combos, total, err +} + +// SearchCombos searches combos by name keyword. +func SearchCombos(keyword string, pageInfo *common.PageInfo) ([]*Combo, int64, error) { + var combos []*Combo + var total int64 + + query := DB.Model(&Combo{}).Where("name LIKE ?", "%"+keyword+"%") + err := query.Count(&total).Error + if err != nil { + return nil, 0, err + } + + err = query.Order("id DESC"). + Limit(pageInfo.GetPageSize()). + Offset(pageInfo.GetStartIdx()). + Find(&combos).Error + return combos, total, err +} + +// DeleteComboById deletes a combo by id, optionally scoped to a user. +func DeleteComboById(id int, userId ...int) error { + query := DB.Model(&Combo{}) + if len(userId) > 0 && userId[0] > 0 { + query = query.Where("user_id = ?", userId[0]) + } + query = query.Delete(&Combo{}, "id = ?", id) + return query.Error +} + +// GetComboCountByUserId returns the number of combos owned by a user. +func GetComboCountByUserId(userId int) (int64, error) { + var count int64 + err := DB.Model(&Combo{}).Where("user_id = ?", userId).Count(&count).Error + return count, err +} diff --git a/model/main.go b/model/main.go index 6d9002462873..10f224a87908 100644 --- a/model/main.go +++ b/model/main.go @@ -281,6 +281,7 @@ func migrateDB() error { &CustomOAuthProvider{}, &UserOAuthBinding{}, &PerfMetric{}, + &Combo{}, ) if err != nil { return err @@ -330,6 +331,7 @@ func migrateDBFast() error { {&CustomOAuthProvider{}, "CustomOAuthProvider"}, {&UserOAuthBinding{}, "UserOAuthBinding"}, {&PerfMetric{}, "PerfMetric"}, + {&Combo{}, "Combo"}, } // 动态计算migration数量,确保errChan缓冲区足够大 errChan := make(chan error, len(migrations)) diff --git a/router/api-router.go b/router/api-router.go index baf7cda20152..7556c4a4e5e6 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -333,6 +333,17 @@ func SetApiRouter(router *gin.Engine) { prefillGroupRoute.DELETE("/:id", controller.DeletePrefillGroup) } + comboRoute := apiRouter.Group("/combo") + comboRoute.Use(middleware.UserAuth()) + { + comboRoute.GET("/", controller.GetComboList) + comboRoute.GET("/search", controller.SearchCombos) + comboRoute.GET("/:id", controller.GetCombo) + comboRoute.POST("/", controller.CreateCombo) + comboRoute.PUT("/:id", controller.UpdateCombo) + comboRoute.DELETE("/:id", controller.DeleteCombo) + } + mjRoute := apiRouter.Group("/mj") mjRoute.GET("/self", middleware.UserAuth(), controller.GetUserMidjourney) mjRoute.GET("/", middleware.AdminAuth(), controller.GetAllMidjourney) diff --git a/service/combo_routing.go b/service/combo_routing.go new file mode 100644 index 000000000000..48fa0813015a --- /dev/null +++ b/service/combo_routing.go @@ -0,0 +1,261 @@ +package service + +import ( + "errors" + "math/rand" + "strings" + "sync" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting" + "github.com/tidwall/sjson" + + "github.com/gin-gonic/gin" +) + +// ErrComboNoSatisfiedChannel is returned when no model in the combo +// has an available channel for the given group. +var ErrComboNoSatisfiedChannel = errors.New("combo: no available channel for any model in the combo") + +// ErrComboEmpty is returned when the combo has no models configured. +var ErrComboEmpty = errors.New("combo: no models configured") + +// ComboRoutingResult holds the result of combo routing. +type ComboRoutingResult struct { + ResolvedModel string // The actual model name to use + Channel *model.Channel // Non-nil only if the strategy resolved a channel (fallback) + Group string // The auto group that was selected (set during fallback + auto) +} + +// ResolveComboModel performs combo routing and returns the resolved model name +// (and optionally a channel for fallback strategy). +// +// For fallback: iterates combo models in order, picks the first that has an +// available channel in the given group, and returns both model and channel. +// +// For random/weighted/round_robin: picks a model by strategy, returns only the +// model name — channel selection proceeds normally via Distribute(). +func ResolveComboModel(c *gin.Context, combo *model.Combo, tokenGroup string) (*ComboRoutingResult, error) { + if combo == nil { + return nil, errors.New("combo is nil") + } + if combo.Status != 1 { + return nil, errors.New("combo is disabled") + } + + models := parseComboModels(combo.Models) + if len(models) == 0 { + return nil, ErrComboEmpty + } + + switch combo.Strategy { + case "fallback": + return resolveFallback(c, combo, models, tokenGroup) + case "random": + return resolveRandom(combo, models), nil + case "weighted": + return resolveWeighted(combo, models), nil + case "round_robin": + return resolveRoundRobin(combo, models), nil + default: + // Default to first model + return &ComboRoutingResult{ResolvedModel: models[0]}, nil + } +} + +// parseComboModels splits a CSV model string and trims whitespace. +func parseComboModels(modelsStr string) []string { + parts := strings.Split(modelsStr, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + trimmed := strings.TrimSpace(p) + if trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +// resolveFallback iterates models in order, trying to find a channel for each. +// Returns the first model that has an available channel. +func resolveFallback(c *gin.Context, combo *model.Combo, models []string, tokenGroup string) (*ComboRoutingResult, error) { + userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) + + if tokenGroup == "auto" { + if len(setting.GetAutoGroups()) == 0 { + return nil, errors.New("auto groups is not enabled") + } + autoGroups := GetUserAutoGroup(userGroup) + + for _, g := range autoGroups { + for _, modelName := range models { + channel, err := model.GetChannel(g, modelName, 0) + if err == nil && channel != nil { + logger.LogDebug(c, "Combo fallback: selected model %s in group %s (channel %d)", modelName, g, channel.Id) + return &ComboRoutingResult{ + ResolvedModel: modelName, + Channel: channel, + Group: g, + }, nil + } + } + } + } else { + for _, modelName := range models { + channel, err := model.GetChannel(tokenGroup, modelName, 0) + if err == nil && channel != nil { + logger.LogDebug(c, "Combo fallback: selected model %s in group %s (channel %d)", modelName, tokenGroup, channel.Id) + return &ComboRoutingResult{ + ResolvedModel: modelName, + Channel: channel, + }, nil + } + } + } + + return nil, ErrComboNoSatisfiedChannel +} + +// resolveRandom picks a model uniformly at random. +func resolveRandom(combo *model.Combo, models []string) *ComboRoutingResult { + selected := models[rand.Intn(len(models))] + return &ComboRoutingResult{ResolvedModel: selected} +} + +// resolveWeighted picks a model based on weights JSON. +func resolveWeighted(combo *model.Combo, models []string) *ComboRoutingResult { + weights := parseWeights(combo.Weights, models) + + totalWeight := 0 + for _, w := range weights { + totalWeight += w + } + if totalWeight <= 0 { + // Fall back to uniform random + return resolveRandom(combo, models) + } + + r := rand.Intn(totalWeight) + cumulative := 0 + for _, modelName := range models { + cumulative += weights[modelName] + if r < cumulative { + return &ComboRoutingResult{ResolvedModel: modelName} + } + } + + // Fallback (shouldn't happen) + return &ComboRoutingResult{ResolvedModel: models[len(models)-1]} +} + +// parseWeights parses the weights JSON string, ensuring all combo models have a weight. +// Missing models get weight 1. +func parseWeights(weightsStr string, models []string) map[string]int { + parsed := make(map[string]int) + if weightsStr != "" && weightsStr != "{}" { + if err := common.Unmarshal([]byte(weightsStr), &parsed); err != nil { + // Invalid JSON — ignore, all models get default weight + parsed = make(map[string]int) + } + } + + // Ensure all models have a weight + modelSet := make(map[string]bool, len(models)) + for _, m := range models { + modelSet[m] = true + if _, exists := parsed[m]; !exists { + parsed[m] = 1 + } + } + + // Remove weights for models not in the combo + for k := range parsed { + if !modelSet[k] { + delete(parsed, k) + } + } + + return parsed +} + +var ( + comboRoundRobinMutex sync.Mutex + comboRoundRobinCounters = make(map[int]int) // combo ID → next index +) + +// resolveRoundRobin cycles through models using an in-memory counter. +func resolveRoundRobin(combo *model.Combo, models []string) *ComboRoutingResult { + comboRoundRobinMutex.Lock() + defer comboRoundRobinMutex.Unlock() + + idx := comboRoundRobinCounters[combo.Id] % len(models) + comboRoundRobinCounters[combo.Id]++ + return &ComboRoutingResult{ResolvedModel: models[idx]} +} + +// GetComboFallbackChannel is used by the distributor when a combo with fallback +// strategy needs to pre-select a channel. It tries each model in the combo until +// one succeeds. +func GetComboFallbackChannel(c *gin.Context, combo *model.Combo, tokenGroup string) (*model.Channel, string, error) { + result, err := ResolveComboModel(c, combo, tokenGroup) + if err != nil { + return nil, "", err + } + if result.Channel == nil { + return nil, result.ResolvedModel, errors.New("combo fallback: no channel resolved") + } + return result.Channel, result.ResolvedModel, nil +} + +// RewriteRequestBodyModel replaces the "model" field in the request body JSON with resolvedModel. +// This ensures downstream code sees the actual model name, not "combo:xxx". +func RewriteRequestBodyModel(c *gin.Context, resolvedModel string) error { + storage, err := common.GetBodyStorage(c) + if err != nil { + return err + } + + body, err := storage.Bytes() + if err != nil { + return err + } + + // Close old storage to release resources + _ = storage.Close() + + newBody := replaceModelFieldInJSON(body, resolvedModel) + + // Create a new body storage with replaced content + newStorage, err := common.CreateBodyStorage(newBody) + if err != nil { + return err + } + + // Set the request body to the new storage + c.Request.Body = newStorage + + // Update the body storage in context so downstream code uses the new body + c.Set(common.KeyBodyStorage, newStorage) + + // Also set the old-style byte cache for backward compat + c.Set(common.KeyRequestBody, newBody) + + return nil +} + +// replaceModelFieldInJSON parses the JSON body and replaces the "model" field value. +// It uses sjson (already a project dependency) for safe JSON manipulation. +func replaceModelFieldInJSON(body []byte, newModel string) []byte { + // Use sjson.Set to replace the "model" field at the top level. + // sjson handles all edge cases (escaped strings, nested objects, etc.). + jsonStr := string(body) + result, err := sjson.Set(jsonStr, "model", newModel) + if err != nil { + // If sjson fails, return original body unchanged + return body + } + return []byte(result) +} diff --git a/web/default/src/features/combos/api.ts b/web/default/src/features/combos/api.ts new file mode 100644 index 000000000000..98e38a864664 --- /dev/null +++ b/web/default/src/features/combos/api.ts @@ -0,0 +1,101 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { api } from '@/lib/api' +import type { + Combo, + ComboFormData, + GetCombosParams, + GetCombosResponse, +} from './types' + +// ============================================================================ +// Combo Management +// ============================================================================ + +// Get paginated combo list +export async function getCombos( + params: GetCombosParams = {} +): Promise { + const { page = 1, page_size, keyword } = params + const res = await api.get('/api/combo/', { + params: { page, page_size, keyword }, + }) + return res.data.data +} + +// Search combos by name keyword (with pagination) +export async function searchCombos( + keyword: string, + page: number = 1, + pageSize?: number +): Promise { + const res = await api.get('/api/combo/search', { + params: { keyword, page, page_size: pageSize }, + }) + return res.data.data +} + +// Get single combo by ID +export async function getCombo(id: number): Promise { + const res = await api.get(`/api/combo/${id}`) + return res.data.data +} + +// Create a new combo +export async function createCombo( + data: ComboFormData +): Promise { + const res = await api.post('/api/combo/', data) + return res.data.data +} + +// Update an existing combo +export async function updateCombo( + id: number, + data: ComboFormData +): Promise { + const res = await api.put(`/api/combo/${id}`, data) + return res.data.data +} + +// Delete a single combo +export async function deleteCombo(id: number): Promise { + await api.delete(`/api/combo/${id}`) +} + +// Batch delete multiple combos +export async function batchDeleteCombos( + ids: number[] +): Promise<{ success: boolean; deleted_count: number }> { + const res = await api({ + url: '/api/combo/', + method: 'delete', + data: { ids }, + }) + return res.data.data +} + +// Update combo status (enable/disable) +export async function updateComboStatus( + id: number, + status: number +): Promise { + const res = await api.put(`/api/combo/${id}`, { status }) + return res.data.data +} diff --git a/web/default/src/features/combos/components/combos-bulk-actions.tsx b/web/default/src/features/combos/components/combos-bulk-actions.tsx new file mode 100644 index 000000000000..ea1d8171e1e6 --- /dev/null +++ b/web/default/src/features/combos/components/combos-bulk-actions.tsx @@ -0,0 +1,65 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useState } from 'react' +import { type Table } from '@tanstack/react-table' +import { Trash2 } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { Button } from '@/components/ui/button' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table' +import { CombosMultiDeleteDialog } from './combos-multi-delete-dialog' +import type { Combo } from '../types' + +export function CombosBulkActions({ table }: { table: Table }) { + const { t } = useTranslation() + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const selectedRows = table.getFilteredSelectedRowModel().rows + const selectedIds = selectedRows.map((r) => r.original.id) + + return ( + <> + + + + + + {t('Delete selected combos')} + + + + + ) +} diff --git a/web/default/src/features/combos/components/combos-cells.tsx b/web/default/src/features/combos/components/combos-cells.tsx new file mode 100644 index 000000000000..53f638486cbc --- /dev/null +++ b/web/default/src/features/combos/components/combos-cells.tsx @@ -0,0 +1,52 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useTranslation } from 'react-i18next' +import type { Combo } from '../types' + +export function StrategyCell({ combo }: { combo: Combo }) { + const { t } = useTranslation() + const strategyMap: Record = { + fallback: t('Fallback'), + random: t('Random'), + weighted: t('Weighted'), + round_robin: t('Round Robin'), + } + const label = strategyMap[combo.strategy] || combo.strategy + return ( + + {label} + + ) +} + +export function StatusCell({ combo }: { combo: Combo }) { + const { t } = useTranslation() + const enabled = combo.status === 1 + return ( + + {enabled ? t('Enabled') : t('Disabled')} + + ) +} diff --git a/web/default/src/features/combos/components/combos-columns.tsx b/web/default/src/features/combos/components/combos-columns.tsx new file mode 100644 index 000000000000..753853c342ee --- /dev/null +++ b/web/default/src/features/combos/components/combos-columns.tsx @@ -0,0 +1,122 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useTranslation } from 'react-i18next' +import { type ColumnDef } from '@tanstack/react-table' +import { DataTableColumnHeader } from '@/components/data-table' +import { Checkbox } from '@/components/ui/checkbox' +import { type Combo } from '../types' +import { CombosRowActions } from './combos-row-actions' +import { StrategyCell, StatusCell } from './combos-cells' + +export function useCombosColumns(): ColumnDef[] { + const { t } = useTranslation() + return [ + { + id: 'select', + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label={t('Select all')} + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label={t('Select row')} + /> + ), + enableSorting: false, + enableHiding: false, + size: 40, + }, + { + accessorKey: 'name', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.getValue('name')} +
+ ), + enableSorting: true, + enableHiding: false, + size: 200, + }, + { + accessorKey: 'models', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.getValue('models')} +
+ ), + enableSorting: false, + enableHiding: false, + size: 220, + }, + { + accessorKey: 'strategy', + header: ({ column }) => ( + + ), + cell: ({ row }) => , + enableSorting: true, + enableHiding: false, + size: 140, + }, + { + accessorKey: 'status', + header: ({ column }) => ( + + ), + cell: ({ row }) => , + enableSorting: true, + enableHiding: false, + size: 120, + }, + { + accessorKey: 'created_time', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const time = row.getValue('created_time') as number | null + if (time == null) return '-' + return new Date(time * 1000).toLocaleString() + }, + enableSorting: true, + enableHiding: false, + size: 160, + }, + { + id: 'actions', + header: () =>
{t('Actions')}
, + cell: ({ row }) => , + enableSorting: false, + enableHiding: false, + size: 80, + }, + ] +} diff --git a/web/default/src/features/combos/components/combos-delete-dialog.tsx b/web/default/src/features/combos/components/combos-delete-dialog.tsx new file mode 100644 index 000000000000..e7dace17072c --- /dev/null +++ b/web/default/src/features/combos/components/combos-delete-dialog.tsx @@ -0,0 +1,82 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { deleteCombo } from '../api' +import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants' +import { useCombos } from './combos-provider' + +export function CombosDeleteDialog() { + const { t } = useTranslation() + const { open, setOpen, currentRow, triggerRefresh } = useCombos() + const [isDeleting, setIsDeleting] = useState(false) + + const handleDelete = async () => { + if (!currentRow) return + setIsDeleting(true) + try { + await deleteCombo(currentRow.id) + toast.success(t(SUCCESS_MESSAGES.COMBO_DELETED)) + setOpen(null) + triggerRefresh() + } catch { + toast.error(t(ERROR_MESSAGES.UNEXPECTED)) + } finally { + setIsDeleting(false) + } + } + + return ( + !v && setOpen(null)}> + + + {t('Are you sure?')} + + {t('This will permanently delete combo')}{' '} + {currentRow?.name} + .{t('This action cannot be undone.')} + + + + + {t('Cancel')} + + + {isDeleting ? t('Deleting...') : t('Delete')} + + + + + ) +} diff --git a/web/default/src/features/combos/components/combos-dialogs.tsx b/web/default/src/features/combos/components/combos-dialogs.tsx new file mode 100644 index 000000000000..3e29b7a54631 --- /dev/null +++ b/web/default/src/features/combos/components/combos-dialogs.tsx @@ -0,0 +1,36 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { CombosMutateDrawer } from './combos-mutate-drawer' +import { CombosDeleteDialog } from './combos-delete-dialog' +import { useCombos } from './combos-provider' + +export function CombosDialogs() { + const { open, setOpen, currentRow } = useCombos() + + return ( + <> + !isOpen && setOpen(null)} + currentRow={open === 'update' ? currentRow || undefined : undefined} + /> + + + ) +} diff --git a/web/default/src/features/combos/components/combos-multi-delete-dialog.tsx b/web/default/src/features/combos/components/combos-multi-delete-dialog.tsx new file mode 100644 index 000000000000..562c7718ec1c --- /dev/null +++ b/web/default/src/features/combos/components/combos-multi-delete-dialog.tsx @@ -0,0 +1,89 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { deleteCombo } from '../api' +import { useCombos } from './combos-provider' +import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants' + +export function CombosMultiDeleteDialog({ + open, + onOpenChange, + selectedIds, +}: { + open: boolean + onOpenChange: (open: boolean) => void + selectedIds: number[] +}) { + const { t } = useTranslation() + const { triggerRefresh } = useCombos() + const [isDeleting, setIsDeleting] = useState(false) + + const handleDelete = async () => { + setIsDeleting(true) + try { + await Promise.all(selectedIds.map((id) => deleteCombo(id))) + toast.success(t(SUCCESS_MESSAGES.COMBO_BATCH_DELETED)) + triggerRefresh() + onOpenChange(false) + } catch { + toast.error(t(ERROR_MESSAGES.UNEXPECTED)) + } finally { + setIsDeleting(false) + } + } + + return ( + + + + {t('Are you sure?')} + + {t('This will permanently delete')} {selectedIds.length}{' '} + {selectedIds.length === 1 ? t('combo') : t('combos')} + .{t('This action cannot be undone.')} + + + + + {t('Cancel')} + + + {isDeleting ? t('Deleting...') : t('Delete')} + + + + + ) +} diff --git a/web/default/src/features/combos/components/combos-mutate-drawer.tsx b/web/default/src/features/combos/components/combos-mutate-drawer.tsx new file mode 100644 index 000000000000..338570f494f3 --- /dev/null +++ b/web/default/src/features/combos/components/combos-mutate-drawer.tsx @@ -0,0 +1,302 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + sideDrawerContentClassName, + sideDrawerFooterClassName, + sideDrawerFormClassName, + sideDrawerHeaderClassName, + sideDrawerSwitchItemClassName, +} from '@/components/drawer-layout' +import { createCombo, updateCombo, getCombo } from '../api' +import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants' +import { comboFormSchema, type ComboFormValues, type ComboFormData } from '../types' +import { useCombos } from './combos-provider' +import type { Combo } from '../types' + +type ComboMutateDrawerProps = { + open: boolean + onOpenChange: (open: boolean) => void + currentRow?: Combo +} + +const DEFAULT_COMBO_FORM_VALUES: ComboFormValues = { + name: '', + models: '', + strategy: 'fallback', + weights: '', + status: 1, +} + +export function CombosMutateDrawer({ + open, + onOpenChange, + currentRow, +}: ComboMutateDrawerProps) { + const { t } = useTranslation() + const isUpdate = !!currentRow + const { triggerRefresh } = useCombos() + const [isSubmitting, setIsSubmitting] = useState(false) + + const form = useForm({ + resolver: zodResolver(comboFormSchema), + defaultValues: { ...DEFAULT_COMBO_FORM_VALUES }, + }) + + const strategy = form.watch('strategy') + const showWeights = strategy === 'weighted' + + useEffect(() => { + let active = true + const targetId = currentRow?.id + + if (open && isUpdate && targetId) { + void (async () => { + try { + const result = await getCombo(targetId) + if (!active || currentRow?.id !== targetId) return + form.reset({ + name: result.name, + models: result.models ?? '', + strategy: result.strategy, + weights: result.weights ?? '', + status: result.status, + }) + } catch { + if (active) toast.error(t(ERROR_MESSAGES.FETCH_ONE_FAILED)) + } + })() + } else if (open && !isUpdate) { + form.reset({ ...DEFAULT_COMBO_FORM_VALUES }) + } + return () => { + active = false + } + }, [open, isUpdate, currentRow, form, t]) + + const onSubmit = async (data: ComboFormValues) => { + setIsSubmitting(true) + try { + const payload = data + if (payload.strategy !== 'weighted') { + payload.weights = undefined + } + if (isUpdate && currentRow) { + await updateCombo(currentRow.id, payload as ComboFormData) + toast.success(t(SUCCESS_MESSAGES.COMBO_UPDATED)) + } else { + await createCombo(payload as ComboFormData) + toast.success(t(SUCCESS_MESSAGES.COMBO_CREATED)) + } + onOpenChange(false) + triggerRefresh() + } catch { + toast.error(t(ERROR_MESSAGES.UNEXPECTED)) + } finally { + setIsSubmitting(false) + } + } + + return ( + + + + {isUpdate ? t('Edit Combo') : t('Create Combo')} + + {isUpdate + ? t('Update combo configuration') + : t('Create a new combo')} + + + +
+
+ + ( + + {t('Name')} + + + + + + )} + /> + + ( + + {t('Models')} + +