Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions constant/context_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
222 changes: 222 additions & 0 deletions controller/combo.go
Original file line number Diff line number Diff line change
@@ -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(),
})
}
61 changes: 61 additions & 0 deletions docs/design/combo-feature.md
Original file line number Diff line number Diff line change
@@ -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:<name>` 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` |
54 changes: 53 additions & 1 deletion middleware/distributor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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 {
Expand All @@ -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)
Expand Down
Loading