10 коммитов

Автор SHA1 Сообщение Дата
  fengsilin 2d1a8d2f28 fix: 设置 chat_id + 优化日志查询参数构建 2 месяцев назад
  fengsilin 3e86f523db feat(playground): temperature/top_p 参数互斥设置 2 месяцев назад
  fengsilin d26ad9e635 feat: 渠道-模型级联选择 + 隐藏首页统计卡片 2 месяцев назад
  fengsilin bb708510fe docs: 用户倍率渠道-模型级联选择器设计文档 2 месяцев назад
  fengsilin b026f6bc04 feat: 用户倍率前端展示 + 渠道下拉选择 + 上游调试日志 2 месяцев назад
  fengsilin 9dbf3365fe feat(pricing): 添加用户-模型-渠道倍率功能 2 месяцев назад
  fengsilin 17a0c2c26a refactor(playground): 隐藏对话页面的分组选择框 2 месяцев назад
  fengsilin b033edec56 fix: pending sync records 队列被 quota=0 旧记录阻塞 2 месяцев назад
  fengsilin 8945f8c39e fix: synced 用户剩余额度取 synced_quota + 记录 chat ID 2 месяцев назад
  fengsilin e41f2951f7 fix: preserve relay body and capture invalid responses body 2 месяцев назад
35 измененных файлов: 1184 добавлений и 131 удалений
  1. +10
    -0
      controller/playground.go
  2. +116
    -0
      controller/user_channel_ratio.go
  3. +41
    -0
      docs/superpowers/specs/2026-05-08-cascading-channel-model-selector-design.md
  4. +3
    -0
      model/main.go
  5. +2
    -1
      model/pending_sync_record.go
  6. +114
    -0
      model/user_channel_ratio.go
  7. +105
    -0
      model/user_channel_ratio_test.go
  8. +12
    -10
      relay/channel/api_request.go
  9. +2
    -0
      relay/channel/claude/relay-claude.go
  10. +12
    -2
      relay/channel/openai/chat_via_responses.go
  11. +3
    -0
      relay/channel/openai/relay-openai.go
  12. +6
    -1
      relay/channel/openai/relay_responses.go
  13. +40
    -0
      relay/channel/openai/upstream_body_test.go
  14. +11
    -0
      relay/common/request_identity.go
  15. +24
    -1
      relay/common/request_identity_test.go
  16. +2
    -2
      relay/compatible_handler.go
  17. +45
    -23
      relay/helper/price.go
  18. +11
    -0
      router/api-router.go
  19. +3
    -0
      service/log_info_generate.go
  20. +16
    -11
      service/quota.go
  21. +22
    -0
      setting/operation_setting/playground_setting.go
  22. +2
    -1
      types/price_data.go
  23. +2
    -1
      web/src/components/playground/OptimizedComponents.js
  24. +8
    -0
      web/src/components/playground/ParameterControl.jsx
  25. +4
    -1
      web/src/components/playground/SettingsPanel.jsx
  26. +8
    -0
      web/src/components/settings/OperationSetting.jsx
  27. +3
    -1
      web/src/components/table/users/UsersColumnDefs.jsx
  28. +8
    -0
      web/src/components/table/users/modals/EditUserModal.jsx
  29. +241
    -0
      web/src/components/table/users/modals/UserRatioSection.jsx
  30. +62
    -44
      web/src/helpers/render.jsx
  31. +75
    -8
      web/src/hooks/playground/usePlaygroundState.js
  32. +56
    -22
      web/src/hooks/usage-logs/useUsageLogsData.jsx
  33. +2
    -2
      web/src/pages/Home/components/HeroSection.jsx
  34. +3
    -0
      web/src/pages/Playground/index.jsx
  35. +110
    -0
      web/src/pages/Setting/Operation/SettingsPlayground.jsx

+ 10
- 0
controller/playground.go Просмотреть файл

@@ -4,9 +4,11 @@ import (
"errors" "errors"
"fmt" "fmt"


"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common" relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/types" "github.com/QuantumNous/new-api/types"


"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -54,3 +56,11 @@ func Playground(c *gin.Context) {


Relay(c, types.RelayFormatOpenAI) Relay(c, types.RelayFormatOpenAI)
} }

// GetPlaygroundConfig 获取 Playground 公开配置(无需认证)
func GetPlaygroundConfig(c *gin.Context) {
setting := operation_setting.GetPlaygroundSetting()
common.ApiSuccess(c, gin.H{
"mutual_exclusive_params": setting.MutualExclusiveParams,
})
}

+ 116
- 0
controller/user_channel_ratio.go Просмотреть файл

@@ -0,0 +1,116 @@
package controller

import (
"net/http"
"strconv"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"

"github.com/gin-gonic/gin"
)

func GetUserChannelRatios(c *gin.Context) {
userIdStr := c.Param("user_id")
userId, err := strconv.Atoi(userIdStr)
if err != nil {
common.ApiError(c, err)
return
}

ratios, err := model.GetUserChannelRatiosByUserId(userId)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": ratios,
})
}

type createUserChannelRatioRequest struct {
UserId int `json:"user_id"`
ModelName string `json:"model_name"`
ChannelId int `json:"channel_id"`
Ratio float64 `json:"ratio"`
}

func CreateUserChannelRatio(c *gin.Context) {
var req createUserChannelRatioRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}

if req.Ratio <= 0 {
common.ApiErrorMsg(c, "ratio must be positive")
return
}

ucr := &model.UserChannelRatio{
UserId: req.UserId,
ModelName: req.ModelName,
ChannelId: req.ChannelId,
Ratio: req.Ratio,
}
if err := ucr.Insert(); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": ucr,
})
}

func UpdateUserChannelRatio(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}

var req struct {
Ratio float64 `json:"ratio"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}
if req.Ratio <= 0 {
common.ApiErrorMsg(c, "ratio must be positive")
return
}

ucr := &model.UserChannelRatio{
Id: id,
Ratio: req.Ratio,
}
if err := ucr.Update(); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": ucr,
})
}

func DeleteUserChannelRatio(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}

if err := model.DeleteUserChannelRatioById(id); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
})
}

+ 41
- 0
docs/superpowers/specs/2026-05-08-cascading-channel-model-selector-design.md Просмотреть файл

@@ -0,0 +1,41 @@
---
created: 2026-05-08
status: approved
---

# Cascading Channel-Model Selector for User Ratio

## Goal

Improve the "Add Ratio" modal in UserRatioSection: select channel first, then pick a model from that channel's supported models via dropdown instead of manual text input.

## Scope

Single file: `web/src/components/table/users/modals/UserRatioSection.jsx`

No backend changes — the existing `/api/channel/` endpoint already returns the `models` field (comma-separated string) for each channel.

## Design

### Data flow

1. `loadChannels()` fetches channel list, stores two things:
- `channelOptions`: `[{ label, value }]` for the channel dropdown (existing)
- `channelMap`: `{ [channelId]: channelObject }` for looking up `models` by channel ID (new)
2. When user selects a channel in the form:
- Parse `channel.models` by comma into an array
- Set `modelOptions` to `[{ label: model, value: model }]` for each model
- Clear any previously selected model value
3. Model dropdown uses `modelOptions` with `filter` and `showClear`, disabled when no channel is selected

### Form field order

1. Channel (Select, with search) — existing
2. Model (Select, with search) — changed from Input to Select
3. Ratio (InputNumber) — existing

### What does NOT change

- Backend API and data model
- Table columns and display
- Submit payload format (`model_name`, `channel_id`, `ratio`)

+ 3
- 0
model/main.go Просмотреть файл

@@ -208,6 +208,7 @@ func InitDB() (err error) {
} }
LoadEmailQuotaCache() LoadEmailQuotaCache()
LoadChannelPricingCache() LoadChannelPricingCache()
LoadUserChannelRatioCache()
return nil return nil
} else { } else {
common.FatalLog(err) common.FatalLog(err)
@@ -285,6 +286,7 @@ func migrateDB() error {
&UserOAuthBinding{}, &UserOAuthBinding{},
&ChannelPricing{}, &ChannelPricing{},
&PricingTag{}, &PricingTag{},
&UserChannelRatio{},
&PendingSyncRecord{}, &PendingSyncRecord{},
&QuotaSyncLog{}, &QuotaSyncLog{},
&EmailQuotaRule{}, &EmailQuotaRule{},
@@ -345,6 +347,7 @@ func migrateDBFast() error {
{&UserOAuthBinding{}, "UserOAuthBinding"}, {&UserOAuthBinding{}, "UserOAuthBinding"},
{&ChannelPricing{}, "ChannelPricing"}, {&ChannelPricing{}, "ChannelPricing"},
{&PricingTag{}, "PricingTag"}, {&PricingTag{}, "PricingTag"},
{&UserChannelRatio{}, "UserChannelRatio"},
{&PendingSyncRecord{}, "PendingSyncRecord"}, {&PendingSyncRecord{}, "PendingSyncRecord"},
{&QuotaSyncLog{}, "QuotaSyncLog"}, {&QuotaSyncLog{}, "QuotaSyncLog"},
{&EmailQuotaRule{}, "EmailQuotaRule"}, {&EmailQuotaRule{}, "EmailQuotaRule"},


+ 2
- 1
model/pending_sync_record.go Просмотреть файл

@@ -87,9 +87,10 @@ func SettlePendingSyncRecord(requestId string, actualQuota int) (bool, error) {
} }


// GetPendingRecordsForSync 获取待同步记录(只同步 settled 和 failed) // GetPendingRecordsForSync 获取待同步记录(只同步 settled 和 failed)
// 优先同步 quota > 0 的记录,quota=0 的旧记录不会阻塞正常扣费同步
func GetPendingRecordsForSync(limit, maxRetry int) []PendingSyncRecord { func GetPendingRecordsForSync(limit, maxRetry int) []PendingSyncRecord {
var records []PendingSyncRecord var records []PendingSyncRecord
DB.Where("status IN ? AND retry_count < ?",
DB.Where("status IN ? AND retry_count < ? AND quota > 0",
[]string{PendingSyncStatusSettled, PendingSyncStatusFailed}, []string{PendingSyncStatusSettled, PendingSyncStatusFailed},
maxRetry). maxRetry).
Limit(limit). Limit(limit).


+ 114
- 0
model/user_channel_ratio.go Просмотреть файл

@@ -0,0 +1,114 @@
package model

import (
"strconv"
"sync"

"github.com/QuantumNous/new-api/common"
)

var (
userChannelRatioCache = make(map[string]float64) // key: "userId:modelName:channelId" -> ratio
userChannelRatioCacheLock sync.RWMutex
)

// UserChannelRatio 用户-模型-渠道倍率表
type UserChannelRatio struct {
Id int `json:"id" gorm:"primaryKey"`
UserId int `json:"user_id" gorm:"not null;uniqueIndex:idx_user_model_channel,priority:1"`
ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:idx_user_model_channel,priority:2"`
ChannelId int `json:"channel_id" gorm:"not null;uniqueIndex:idx_user_model_channel,priority:3"`
Ratio float64 `json:"ratio" gorm:"default:1"`
CreatedAt int64 `json:"created_at" gorm:"bigint"`
UpdatedAt int64 `json:"updated_at" gorm:"bigint"`
}

func getUserChannelRatioCacheKey(userId int, modelName string, channelId int) string {
return strconv.Itoa(userId) + ":" + modelName + ":" + strconv.Itoa(channelId)
}

func setUserChannelRatioCache(key string, ratio float64) {
userChannelRatioCacheLock.Lock()
userChannelRatioCache[key] = ratio
userChannelRatioCacheLock.Unlock()
}

func removeUserChannelRatioCache(key string) {
userChannelRatioCacheLock.Lock()
delete(userChannelRatioCache, key)
userChannelRatioCacheLock.Unlock()
}

// GetUserChannelRatio 获取用户在指定模型+渠道的倍率(纯内存读)
// 未命中返回 1.0(不影响计费)
func GetUserChannelRatio(userId int, modelName string, channelId int) float64 {
key := getUserChannelRatioCacheKey(userId, modelName, channelId)
userChannelRatioCacheLock.RLock()
ratio, ok := userChannelRatioCache[key]
userChannelRatioCacheLock.RUnlock()
if !ok {
return 1.0
}
return ratio
}

// LoadUserChannelRatioCache 全量加载到内存(启动时调用)
func LoadUserChannelRatioCache() {
var records []*UserChannelRatio
if err := DB.Find(&records).Error; err != nil {
common.SysError("[UserChannelRatio] LoadCache failed: " + err.Error())
return
}
userChannelRatioCacheLock.Lock()
userChannelRatioCache = make(map[string]float64, len(records))
for _, r := range records {
key := getUserChannelRatioCacheKey(r.UserId, r.ModelName, r.ChannelId)
userChannelRatioCache[key] = r.Ratio
}
userChannelRatioCacheLock.Unlock()
common.SysLog("[UserChannelRatio] cache loaded " + strconv.Itoa(len(records)) + " records")
}

func (ucr *UserChannelRatio) Insert() error {
ucr.CreatedAt = common.GetTimestamp()
ucr.UpdatedAt = common.GetTimestamp()
err := DB.Create(ucr).Error
if err == nil {
setUserChannelRatioCache(getUserChannelRatioCacheKey(ucr.UserId, ucr.ModelName, ucr.ChannelId), ucr.Ratio)
}
return err
}

func (ucr *UserChannelRatio) Update() error {
var existing UserChannelRatio
if err := DB.First(&existing, ucr.Id).Error; err != nil {
return err
}
ucr.UpdatedAt = common.GetTimestamp()
err := DB.Model(&UserChannelRatio{}).Where("id = ?", ucr.Id).
Select("ratio", "updated_at").
Updates(ucr).Error
if err == nil {
setUserChannelRatioCache(getUserChannelRatioCacheKey(existing.UserId, existing.ModelName, existing.ChannelId), ucr.Ratio)
}
return err
}

func DeleteUserChannelRatioById(id int) error {
var existing UserChannelRatio
if err := DB.First(&existing, id).Error; err != nil {
return err
}
err := DB.Delete(&existing).Error
if err == nil {
removeUserChannelRatioCache(getUserChannelRatioCacheKey(existing.UserId, existing.ModelName, existing.ChannelId))
}
return err
}

// GetUserChannelRatiosByUserId 获取指定用户的所有倍率记录
func GetUserChannelRatiosByUserId(userId int) ([]*UserChannelRatio, error) {
var list []*UserChannelRatio
err := DB.Where("user_id = ?", userId).Find(&list).Error
return list, err
}

+ 105
- 0
model/user_channel_ratio_test.go Просмотреть файл

@@ -0,0 +1,105 @@
package model

import (
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)

func setupUserChannelRatioDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(1)

origDB := DB
DB = db
common.UsingSQLite = true
common.RedisEnabled = false

require.NoError(t, db.AutoMigrate(&UserChannelRatio{}))

t.Cleanup(func() {
DB = origDB
sqlDB.Close()
})
return db
}

func TestUserChannelRatioCRUD(t *testing.T) {
setupUserChannelRatioDB(t)
LoadUserChannelRatioCache()

ucr := &UserChannelRatio{
UserId: 1,
ModelName: "gpt-4o",
ChannelId: 5,
Ratio: 0.8,
}
err := ucr.Insert()
require.NoError(t, err)
assert.True(t, ucr.Id > 0)

ratio := GetUserChannelRatio(1, "gpt-4o", 5)
assert.Equal(t, 0.8, ratio)

ratio = GetUserChannelRatio(999, "nonexistent", 999)
assert.Equal(t, 1.0, ratio)

ucr.Ratio = 1.2
err = ucr.Update()
require.NoError(t, err)
ratio = GetUserChannelRatio(1, "gpt-4o", 5)
assert.Equal(t, 1.2, ratio)

list, err := GetUserChannelRatiosByUserId(1)
require.NoError(t, err)
assert.Len(t, list, 1)
assert.Equal(t, "gpt-4o", list[0].ModelName)

err = DeleteUserChannelRatioById(ucr.Id)
require.NoError(t, err)
ratio = GetUserChannelRatio(1, "gpt-4o", 5)
assert.Equal(t, 1.0, ratio)

list, err = GetUserChannelRatiosByUserId(1)
require.NoError(t, err)
assert.Len(t, list, 0)
}

func TestUserChannelRatioUniqueConstraint(t *testing.T) {
setupUserChannelRatioDB(t)
LoadUserChannelRatioCache()

ucr1 := &UserChannelRatio{UserId: 1, ModelName: "gpt-4o", ChannelId: 5, Ratio: 0.8}
err := ucr1.Insert()
require.NoError(t, err)

ucr2 := &UserChannelRatio{UserId: 1, ModelName: "gpt-4o", ChannelId: 5, Ratio: 1.0}
err = ucr2.Insert()
assert.Error(t, err)

ucr3 := &UserChannelRatio{UserId: 1, ModelName: "gpt-4o", ChannelId: 6, Ratio: 1.5}
err = ucr3.Insert()
assert.NoError(t, err)
}

func TestLoadUserChannelRatioCache(t *testing.T) {
setupUserChannelRatioDB(t)

ucr1 := &UserChannelRatio{UserId: 1, ModelName: "gpt-4o", ChannelId: 5, Ratio: 0.8}
ucr2 := &UserChannelRatio{UserId: 2, ModelName: "claude-3", ChannelId: 10, Ratio: 1.5}
require.NoError(t, ucr1.Insert())
require.NoError(t, ucr2.Insert())

LoadUserChannelRatioCache()

assert.Equal(t, 0.8, GetUserChannelRatio(1, "gpt-4o", 5))
assert.Equal(t, 1.5, GetUserChannelRatio(2, "claude-3", 10))
assert.Equal(t, 1.0, GetUserChannelRatio(3, "nonexistent", 99))
}

+ 12
- 10
relay/channel/api_request.go Просмотреть файл

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"bytes"
"io" "io"
"net/http" "net/http"
"regexp" "regexp"
@@ -293,13 +294,12 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
if err != nil { if err != nil {
return nil, fmt.Errorf("setup request header failed: %w", err) return nil, fmt.Errorf("setup request header failed: %w", err)
} }
// 在 SetupRequestHeader 之后应用 Header Override,确保用户设置优先级最高
// 这样可以覆盖默认的 Authorization header 设置
headerOverride, err := processHeaderOverride(info, c) headerOverride, err := processHeaderOverride(info, c)
if err != nil { if err != nil {
return nil, err return nil, err
} }
applyHeaderOverrideToRequest(req, headerOverride) applyHeaderOverrideToRequest(req, headerOverride)
dumpUpstreamRequest(req)
resp, err := doRequest(c, req, info) resp, err := doRequest(c, req, info)
if err != nil { if err != nil {
return nil, fmt.Errorf("do request failed: %w", err) return nil, fmt.Errorf("do request failed: %w", err)
@@ -319,20 +319,18 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod
if err != nil { if err != nil {
return nil, fmt.Errorf("new request failed: %w", err) return nil, fmt.Errorf("new request failed: %w", err)
} }
// set form data
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
headers := req.Header headers := req.Header
err = a.SetupRequestHeader(c, &headers, info) err = a.SetupRequestHeader(c, &headers, info)
if err != nil { if err != nil {
return nil, fmt.Errorf("setup request header failed: %w", err) return nil, fmt.Errorf("setup request header failed: %w", err)
} }
// 在 SetupRequestHeader 之后应用 Header Override,确保用户设置优先级最高
// 这样可以覆盖默认的 Authorization header 设置
headerOverride, err := processHeaderOverride(info, c) headerOverride, err := processHeaderOverride(info, c)
if err != nil { if err != nil {
return nil, err return nil, err
} }
applyHeaderOverrideToRequest(req, headerOverride) applyHeaderOverrideToRequest(req, headerOverride)
dumpUpstreamRequest(req)
resp, err := doRequest(c, req, info) resp, err := doRequest(c, req, info)
if err != nil { if err != nil {
return nil, fmt.Errorf("do request failed: %w", err) return nil, fmt.Errorf("do request failed: %w", err)
@@ -340,6 +338,15 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod
return resp, nil return resp, nil
} }


func dumpUpstreamRequest(req *http.Request) {
if !common2.DebugEnabled || req == nil {
return
}
bodyBytes, _ := io.ReadAll(req.Body)
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
common2.SysLog(fmt.Sprintf("[UpstreamRequest] URL: %s\nHeaders: %v\nBody: %s", req.URL.String(), req.Header, string(bodyBytes)))
}

func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*websocket.Conn, error) { func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*websocket.Conn, error) {
fullRequestURL, err := a.GetRequestURL(info) fullRequestURL, err := a.GetRequestURL(info)
if err != nil { if err != nil {
@@ -347,11 +354,6 @@ func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
} }
targetHeader := http.Header{} targetHeader := http.Header{}
err = a.SetupRequestHeader(c, &targetHeader, info) err = a.SetupRequestHeader(c, &targetHeader, info)
if err != nil {
return nil, fmt.Errorf("setup request header failed: %w", err)
}
// 在 SetupRequestHeader 之后应用 Header Override,确保用户设置优先级最高
// 这样可以覆盖默认的 Authorization header 设置
headerOverride, err := processHeaderOverride(info, c) headerOverride, err := processHeaderOverride(info, c)
if err != nil { if err != nil {
return nil, err return nil, err


+ 2
- 0
relay/channel/claude/relay-claude.go Просмотреть файл

@@ -790,6 +790,7 @@ func ClaudeStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.
} }


HandleStreamFinalResponse(c, info, claudeInfo) HandleStreamFinalResponse(c, info, claudeInfo)
relaycommon.SetRelayChatID(c, claudeInfo.ResponseId)
return claudeInfo.Usage, nil return claudeInfo.Usage, nil
} }


@@ -803,6 +804,7 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
return types.WithClaudeError(*claudeError, http.StatusInternalServerError) return types.WithClaudeError(*claudeError, http.StatusInternalServerError)
} }
maybeMarkClaudeRefusal(c, claudeResponse.StopReason) maybeMarkClaudeRefusal(c, claudeResponse.StopReason)
relaycommon.SetRelayChatID(c, claudeResponse.Id)
if claudeInfo.Usage == nil { if claudeInfo.Usage == nil {
claudeInfo.Usage = &dto.Usage{} claudeInfo.Usage = &dto.Usage{}
} }


+ 12
- 2
relay/channel/openai/chat_via_responses.go Просмотреть файл

@@ -52,7 +52,9 @@ func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
} }


if err := common.Unmarshal(body, &responsesResp); err != nil { if err := common.Unmarshal(body, &responsesResp); err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
apiErr := types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
apiErr.UpstreamBody = service.TruncateBody(string(body))
return nil, apiErr
} }


if oaiError := responsesResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { if oaiError := responsesResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
@@ -61,10 +63,14 @@ func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
return nil, apiErr return nil, apiErr
} }


relaycommon.SetRelayChatID(c, responsesResp.ID)

chatId := helper.GetResponseID(c) chatId := helper.GetResponseID(c)
chatResp, usage, err := service.ResponsesResponseToChatCompletionsResponse(&responsesResp, chatId) chatResp, usage, err := service.ResponsesResponseToChatCompletionsResponse(&responsesResp, chatId)
if err != nil { if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
apiErr := types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
apiErr.UpstreamBody = service.TruncateBody(string(body))
return nil, apiErr
} }


if usage == nil || usage.TotalTokens == 0 { if usage == nil || usage.TotalTokens == 0 {
@@ -437,6 +443,10 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo


case "response.completed": case "response.completed":
if streamResp.Response != nil { if streamResp.Response != nil {
if streamResp.Response.ID != "" {
responseId = streamResp.Response.ID
relaycommon.SetRelayChatID(c, responseId)
}
if streamResp.Response.Model != "" { if streamResp.Response.Model != "" {
model = streamResp.Response.Model model = streamResp.Response.Model
} }


+ 3
- 0
relay/channel/openai/relay-openai.go Просмотреть файл

@@ -189,6 +189,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
applyUsagePostProcessing(info, usage, common.StringToByteSlice(lastStreamData)) applyUsagePostProcessing(info, usage, common.StringToByteSlice(lastStreamData))


HandleFinalResponse(c, info, lastStreamData, responseId, createAt, model, systemFingerprint, usage, containStreamUsage) HandleFinalResponse(c, info, lastStreamData, responseId, createAt, model, systemFingerprint, usage, containStreamUsage)
relaycommon.SetRelayChatID(c, responseId)


return usage, nil return usage, nil
} }
@@ -225,6 +226,8 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
} }


relaycommon.SetRelayChatID(c, simpleResponse.Id)

if oaiError := simpleResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { if oaiError := simpleResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
} }


+ 6
- 1
relay/channel/openai/relay_responses.go Просмотреть файл

@@ -28,7 +28,9 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
} }
err = common.Unmarshal(responseBody, &responsesResponse) err = common.Unmarshal(responseBody, &responsesResponse)
if err != nil { if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
apiErr := types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
apiErr.UpstreamBody = service.TruncateBody(string(responseBody))
return nil, apiErr
} }
if oaiError := responsesResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { if oaiError := responsesResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
apiErr := types.WithOpenAIError(*oaiError, resp.StatusCode) apiErr := types.WithOpenAIError(*oaiError, resp.StatusCode)
@@ -36,6 +38,8 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
return nil, apiErr return nil, apiErr
} }


relaycommon.SetRelayChatID(c, responsesResponse.ID)

if responsesResponse.HasImageGenerationCall() { if responsesResponse.HasImageGenerationCall() {
c.Set("image_generation_call", true) c.Set("image_generation_call", true)
c.Set("image_generation_call_quality", responsesResponse.GetQuality()) c.Set("image_generation_call_quality", responsesResponse.GetQuality())
@@ -104,6 +108,7 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
} }
} }
relaycommon.SetRelayChatID(c, streamResponse.Response.ID)
if streamResponse.Response.HasImageGenerationCall() { if streamResponse.Response.HasImageGenerationCall() {
c.Set("image_generation_call", true) c.Set("image_generation_call", true)
c.Set("image_generation_call_quality", streamResponse.Response.GetQuality()) c.Set("image_generation_call_quality", streamResponse.Response.GetQuality())


+ 40
- 0
relay/channel/openai/upstream_body_test.go Просмотреть файл

@@ -1,10 +1,16 @@
package openai package openai


import ( import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing" "testing"


"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -62,3 +68,37 @@ func TestResponsesStreamEventErrorParsing(t *testing.T) {
assert.Nil(t, streamResp.Error) assert.Nil(t, streamResp.Error)
}) })
} }

func TestOaiResponsesHandlerAttachesUpstreamBodyOnInvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)

resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"incomplete":`)),
}

usage, err := OaiResponsesHandler(c, &relaycommon.RelayInfo{}, resp)
require.Nil(t, usage)
require.Error(t, err)
assert.NotEmpty(t, err.UpstreamBody)
assert.Contains(t, err.UpstreamBody, `{"incomplete":`)
}

func TestOaiResponsesToChatHandlerAttachesUpstreamBodyOnInvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)

resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"incomplete":`)),
}

usage, err := OaiResponsesToChatHandler(c, &relaycommon.RelayInfo{}, resp)
require.Nil(t, usage)
require.Error(t, err)
assert.NotEmpty(t, err.UpstreamBody)
assert.Contains(t, err.UpstreamBody, `{"incomplete":`)
}

+ 11
- 0
relay/common/request_identity.go Просмотреть файл

@@ -1,6 +1,7 @@
package common package common


import ( import (
"io"
"net/http" "net/http"
"strings" "strings"


@@ -53,14 +54,24 @@ func ExtractTopLevelChatID(c *gin.Context) (string, error) {
if c == nil { if c == nil {
return "", nil return "", nil
} }
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.Request.Header.Get("Content-Type"))), "application/json") {
return "", nil
}
bodyStorage, err := commonpkg.GetBodyStorage(c) bodyStorage, err := commonpkg.GetBodyStorage(c)
if err != nil { if err != nil {
return "", err return "", err
} }
bodyBytes, err := bodyStorage.Bytes() bodyBytes, err := bodyStorage.Bytes()
if err != nil || len(bodyBytes) == 0 { if err != nil || len(bodyBytes) == 0 {
if _, seekErr := bodyStorage.Seek(0, io.SeekStart); seekErr == nil {
c.Request.Body = io.NopCloser(bodyStorage)
}
return "", err return "", err
} }
if _, seekErr := bodyStorage.Seek(0, io.SeekStart); seekErr != nil {
return "", seekErr
}
c.Request.Body = io.NopCloser(bodyStorage)
var payload map[string]interface{} var payload map[string]interface{}
if err := commonpkg.Unmarshal(bodyBytes, &payload); err != nil { if err := commonpkg.Unmarshal(bodyBytes, &payload); err != nil {
return "", nil return "", nil


+ 24
- 1
relay/common/request_identity_test.go Просмотреть файл

@@ -2,6 +2,7 @@ package common


import ( import (
"bytes" "bytes"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -14,12 +15,17 @@ func TestExtractChatIDFromReusableBody(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(`{"model":"gpt-4o-mini","chat_id":"chat_abc","messages":[{"role":"user","content":"hi"}]}`))
body := `{"model":"gpt-4o-mini","chat_id":"chat_abc","messages":[{"role":"user","content":"hi"}]}`
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json") c.Request.Header.Set("Content-Type", "application/json")


chatID, err := ExtractTopLevelChatID(c) chatID, err := ExtractTopLevelChatID(c)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "chat_abc", chatID) require.Equal(t, "chat_abc", chatID)

bodyAfter, err := io.ReadAll(c.Request.Body)
require.NoError(t, err)
require.Equal(t, body, string(bodyAfter))
} }


func TestExtractChatIDReturnsEmptyWhenMissing(t *testing.T) { func TestExtractChatIDReturnsEmptyWhenMissing(t *testing.T) {
@@ -87,3 +93,20 @@ func TestCaptureUpstreamIDFromDialResponseHeaders(t *testing.T) {
CaptureUpstreamIDFromHTTPResponse(info, resp) CaptureUpstreamIDFromHTTPResponse(info, resp)
require.Equal(t, "ws_upstream_1", info.UpstreamID) require.Equal(t, "ws_upstream_1", info.UpstreamID)
} }

func TestExtractTopLevelChatIDSkipsNonJSONBody(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := "--boundary\r\nContent-Disposition: form-data; name=\"chat_id\"\r\n\r\nchat_abc\r\n--boundary--\r\n"
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/edits", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "multipart/form-data; boundary=boundary")

chatID, err := ExtractTopLevelChatID(c)
require.NoError(t, err)
require.Empty(t, chatID)

bodyAfter, err := io.ReadAll(c.Request.Body)
require.NoError(t, err)
require.Equal(t, body, string(bodyAfter))
}

+ 2
- 2
relay/compatible_handler.go Просмотреть файл

@@ -285,7 +285,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
dCachedCreationRatio := decimal.NewFromFloat(cachedCreationRatio) dCachedCreationRatio := decimal.NewFromFloat(cachedCreationRatio)
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)


ratio := dModelRatio.Mul(dGroupRatio)
ratio := dModelRatio.Mul(dGroupRatio).Mul(decimal.NewFromFloat(relayInfo.PriceData.UserChannelRatio))


// openai web search 工具计费 // openai web search 工具计费
var dWebSearchQuota decimal.Decimal var dWebSearchQuota decimal.Decimal
@@ -399,7 +399,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
quotaCalculateDecimal = decimal.NewFromInt(1) quotaCalculateDecimal = decimal.NewFromInt(1)
} }
} else { } else {
quotaCalculateDecimal = dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio)
quotaCalculateDecimal = dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio).Mul(decimal.NewFromFloat(relayInfo.PriceData.UserChannelRatio))
} }
// 添加 responses tools call 调用的配额 // 添加 responses tools call 调用的配额
quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota) quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota)


+ 45
- 23
relay/helper/price.go Просмотреть файл

@@ -168,6 +168,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
CacheCreationRatio: cacheCreationRatio, CacheCreationRatio: cacheCreationRatio,
CacheCreation5mRatio: cacheCreationRatio, CacheCreation5mRatio: cacheCreationRatio,
CacheCreation1hRatio: cacheCreationRatio * types.ClaudeCacheCreation1hMultiplier, CacheCreation1hRatio: cacheCreationRatio * types.ClaudeCacheCreation1hMultiplier,
UserChannelRatio: 1.0,
} }


if common.DebugEnabled { if common.DebugEnabled {
@@ -207,6 +208,7 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) types.
ModelPrice: modelPrice, ModelPrice: modelPrice,
Quota: quota, Quota: quota,
GroupRatioInfo: groupRatioInfo, GroupRatioInfo: groupRatioInfo,
UserChannelRatio: 1.0,
} }
return priceData return priceData
} }
@@ -232,34 +234,54 @@ func UpdatePriceDataForChannelPricing(c *gin.Context, info *relaycommon.RelayInf
} }


cp, found := model.GetEffectivePricing(info.OriginModelName, channelId) cp, found := model.GetEffectivePricing(info.OriginModelName, channelId)
if !found {
return
}
if found {
info.PriceData.ModelRatio = cp.ModelRatio
info.PriceData.CompletionRatio = cp.CompletionRatio
info.PriceData.UsePrice = cp.QuotaType == model.QuotaTypeByCall
if info.PriceData.UsePrice {
info.PriceData.ModelPrice = cp.ModelPrice
} else {
info.PriceData.ModelPrice = -1
}


info.PriceData.ModelRatio = cp.ModelRatio
info.PriceData.CompletionRatio = cp.CompletionRatio
info.PriceData.UsePrice = cp.QuotaType == model.QuotaTypeByCall
if info.PriceData.UsePrice {
info.PriceData.ModelPrice = cp.ModelPrice
} else {
info.PriceData.ModelPrice = -1
}
info.PriceData.ApplyChannelPricingRatios(cp.CacheRatio, cp.CacheCreationRatio, cp.ImageRatio, cp.AudioRatio, cp.AudioCompletionRatio)


info.PriceData.ApplyChannelPricingRatios(cp.CacheRatio, cp.CacheCreationRatio, cp.ImageRatio, cp.AudioRatio, cp.AudioCompletionRatio)
if info.PriceData.UsePrice {
info.PriceData.QuotaToPreConsume = int(
cp.ModelPrice * common.QuotaPerUnit * info.PriceData.GroupRatioInfo.GroupRatio)
} else {
estimateTokens := info.GetEstimatePromptTokens()
if estimateTokens > 0 {
ratio := cp.ModelRatio * info.PriceData.GroupRatioInfo.GroupRatio
info.PriceData.QuotaToPreConsume = int(float64(estimateTokens) * ratio)
}
}


if info.PriceData.UsePrice {
info.PriceData.QuotaToPreConsume = int(
cp.ModelPrice * common.QuotaPerUnit * info.PriceData.GroupRatioInfo.GroupRatio)
} else {
estimateTokens := info.GetEstimatePromptTokens()
if estimateTokens > 0 {
ratio := cp.ModelRatio * info.PriceData.GroupRatioInfo.GroupRatio
info.PriceData.QuotaToPreConsume = int(float64(estimateTokens) * ratio)
if common.DebugEnabled {
println(fmt.Sprintf("[ChannelPricing] updatePriceData: model=%s channel=%d modelRatio=%.4f completionRatio=%.4f cacheRatio=%.4f imageRatio=%.4f audioRatio=%.4f",
info.OriginModelName, channelId, cp.ModelRatio, cp.CompletionRatio, cp.CacheRatio, cp.ImageRatio, cp.AudioRatio))
} }
} }


if common.DebugEnabled {
println(fmt.Sprintf("[ChannelPricing] updatePriceData: model=%s channel=%d modelRatio=%.4f completionRatio=%.4f cacheRatio=%.4f imageRatio=%.4f audioRatio=%.4f",
info.OriginModelName, channelId, cp.ModelRatio, cp.CompletionRatio, cp.CacheRatio, cp.ImageRatio, cp.AudioRatio))
// 应用用户-模型-渠道倍率(始终执行,不依赖渠道定价是否存在)
ApplyUserChannelRatio(c, info, channelId)
}

// ApplyUserChannelRatio 在渠道选定后应用用户倍率
func ApplyUserChannelRatio(c *gin.Context, info *relaycommon.RelayInfo, channelId int) {
if info == nil || channelId <= 0 {
return
}
userId := common.GetContextKeyInt(c, constant.ContextKeyUserId)
if userId <= 0 {
return
}
userRatio := model.GetUserChannelRatio(userId, info.OriginModelName, channelId)
if userRatio == 1.0 {
return
}
info.PriceData.UserChannelRatio = userRatio
if info.PriceData.QuotaToPreConsume > 0 {
info.PriceData.QuotaToPreConsume = int(float64(info.PriceData.QuotaToPreConsume) * userRatio)
} }
} }

+ 11
- 0
router/api-router.go Просмотреть файл

@@ -48,6 +48,7 @@ func SetApiRouter(router *gin.Engine) {
// Standard OAuth providers (GitHub, Discord, OIDC, LinuxDO) - unified route // Standard OAuth providers (GitHub, Discord, OIDC, LinuxDO) - unified route
apiRouter.GET("/oauth/:provider", middleware.CriticalRateLimit(), controller.HandleOAuth) apiRouter.GET("/oauth/:provider", middleware.CriticalRateLimit(), controller.HandleOAuth)
apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig) apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig)
apiRouter.GET("/playground/config", controller.GetPlaygroundConfig)


apiRouter.POST("/stripe/webhook", controller.StripeWebhook) apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
apiRouter.POST("/creem/webhook", controller.CreemWebhook) apiRouter.POST("/creem/webhook", controller.CreemWebhook)
@@ -202,6 +203,16 @@ func SetApiRouter(router *gin.Engine) {
channelPricingRoute.DELETE("/default/*name", controller.ClearDefaultChannel) channelPricingRoute.DELETE("/default/*name", controller.ClearDefaultChannel)
} }


// 用户倍率路由(管理员权限)
userChannelRatioRoute := apiRouter.Group("/user_channel_ratio")
userChannelRatioRoute.Use(middleware.AdminAuth())
{
userChannelRatioRoute.GET("/:user_id", controller.GetUserChannelRatios)
userChannelRatioRoute.POST("/", controller.CreateUserChannelRatio)
userChannelRatioRoute.PUT("/:id", controller.UpdateUserChannelRatio)
userChannelRatioRoute.DELETE("/:id", controller.DeleteUserChannelRatio)
}

// 定价标签路由(管理员权限) // 定价标签路由(管理员权限)
pricingTagRoute := apiRouter.Group("/pricing_tag") pricingTagRoute := apiRouter.Group("/pricing_tag")
pricingTagRoute.Use(middleware.AdminAuth()) pricingTagRoute.Use(middleware.AdminAuth())


+ 3
- 0
service/log_info_generate.go Просмотреть файл

@@ -41,6 +41,9 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
other["cache_ratio"] = cacheRatio other["cache_ratio"] = cacheRatio
other["model_price"] = modelPrice other["model_price"] = modelPrice
other["user_group_ratio"] = userGroupRatio other["user_group_ratio"] = userGroupRatio
if relayInfo != nil && relayInfo.PriceData.UserChannelRatio != 1.0 {
other["user_channel_ratio"] = relayInfo.PriceData.UserChannelRatio
}
other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli()) other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli())
if relayInfo.ReasoningEffort != "" { if relayInfo.ReasoningEffort != "" {
other["reasoning_effort"] = relayInfo.ReasoningEffort other["reasoning_effort"] = relayInfo.ReasoningEffort


+ 16
- 11
service/quota.go Просмотреть файл

@@ -30,13 +30,14 @@ type TokenDetails struct {
} }


type QuotaInfo struct { type QuotaInfo struct {
InputDetails TokenDetails
OutputDetails TokenDetails
ModelName string
UsePrice bool
ModelPrice float64
ModelRatio float64
GroupRatio float64
InputDetails TokenDetails
OutputDetails TokenDetails
ModelName string
UsePrice bool
ModelPrice float64
ModelRatio float64
GroupRatio float64
UserChannelRatio float64
} }


func hasCustomModelRatio(modelName string, currentRatio float64) bool { func hasCustomModelRatio(modelName string, currentRatio float64) bool {
@@ -53,7 +54,7 @@ func calculateAudioQuota(info QuotaInfo) int {
quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
groupRatio := decimal.NewFromFloat(info.GroupRatio) groupRatio := decimal.NewFromFloat(info.GroupRatio)


quota := modelPrice.Mul(quotaPerUnit).Mul(groupRatio)
quota := modelPrice.Mul(quotaPerUnit).Mul(groupRatio).Mul(decimal.NewFromFloat(info.UserChannelRatio))
return int(quota.IntPart()) return int(quota.IntPart())
} }


@@ -63,7 +64,7 @@ func calculateAudioQuota(info QuotaInfo) int {


groupRatio := decimal.NewFromFloat(info.GroupRatio) groupRatio := decimal.NewFromFloat(info.GroupRatio)
modelRatio := decimal.NewFromFloat(info.ModelRatio) modelRatio := decimal.NewFromFloat(info.ModelRatio)
ratio := groupRatio.Mul(modelRatio)
ratio := groupRatio.Mul(modelRatio).Mul(decimal.NewFromFloat(info.UserChannelRatio))


inputTextTokens := decimal.NewFromInt(int64(info.InputDetails.TextTokens)) inputTextTokens := decimal.NewFromInt(int64(info.InputDetails.TextTokens))
outputTextTokens := decimal.NewFromInt(int64(info.OutputDetails.TextTokens)) outputTextTokens := decimal.NewFromInt(int64(info.OutputDetails.TextTokens))
@@ -134,6 +135,7 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
UsePrice: relayInfo.UsePrice, UsePrice: relayInfo.UsePrice,
ModelRatio: modelRatio, ModelRatio: modelRatio,
GroupRatio: actualGroupRatio, GroupRatio: actualGroupRatio,
UserChannelRatio: relayInfo.PriceData.UserChannelRatio,
} }


quota := calculateAudioQuota(quotaInfo) quota := calculateAudioQuota(quotaInfo)
@@ -187,6 +189,7 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
UsePrice: usePrice, UsePrice: usePrice,
ModelRatio: modelRatio, ModelRatio: modelRatio,
GroupRatio: groupRatio, GroupRatio: groupRatio,
UserChannelRatio: relayInfo.PriceData.UserChannelRatio,
} }


quota := calculateAudioQuota(quotaInfo) quota := calculateAudioQuota(quotaInfo)
@@ -249,6 +252,7 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
completionRatio := relayInfo.PriceData.CompletionRatio completionRatio := relayInfo.PriceData.CompletionRatio
modelRatio := relayInfo.PriceData.ModelRatio modelRatio := relayInfo.PriceData.ModelRatio
groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
userChannelRatio := relayInfo.PriceData.UserChannelRatio
modelPrice := relayInfo.PriceData.ModelPrice modelPrice := relayInfo.PriceData.ModelPrice
cacheRatio := relayInfo.PriceData.CacheRatio cacheRatio := relayInfo.PriceData.CacheRatio
cacheTokens := usage.PromptTokensDetails.CachedTokens cacheTokens := usage.PromptTokensDetails.CachedTokens
@@ -283,9 +287,9 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
calculateQuota += float64(remainingCacheCreationTokens) * cacheCreationRatio calculateQuota += float64(remainingCacheCreationTokens) * cacheCreationRatio
} }
calculateQuota += float64(completionTokens) * completionRatio calculateQuota += float64(completionTokens) * completionRatio
calculateQuota = calculateQuota * groupRatio * modelRatio
calculateQuota = calculateQuota * groupRatio * modelRatio * userChannelRatio
} else { } else {
calculateQuota = modelPrice * common.QuotaPerUnit * groupRatio
calculateQuota = modelPrice * common.QuotaPerUnit * groupRatio * userChannelRatio
} }


if modelRatio != 0 && calculateQuota <= 0 { if modelRatio != 0 && calculateQuota <= 0 {
@@ -390,6 +394,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
UsePrice: usePrice, UsePrice: usePrice,
ModelRatio: modelRatio, ModelRatio: modelRatio,
GroupRatio: groupRatio, GroupRatio: groupRatio,
UserChannelRatio: relayInfo.PriceData.UserChannelRatio,
} }


quota := calculateAudioQuota(quotaInfo) quota := calculateAudioQuota(quotaInfo)


+ 22
- 0
setting/operation_setting/playground_setting.go Просмотреть файл

@@ -0,0 +1,22 @@
package operation_setting

import "github.com/QuantumNous/new-api/setting/config"

// PlaygroundSetting Playground 配置
type PlaygroundSetting struct {
MutualExclusiveParams string `json:"mutual_exclusive_params"` // temperature/top_p 互斥的模型前缀列表,换行分隔
}

// 默认配置
var playgroundSetting = PlaygroundSetting{
MutualExclusiveParams: "deepseek-v4-flash\ndeepseek-v4-pro\ndeepseek-reasoner\no1-\no3-\no4-\ngpt-5",
}

func init() {
config.GlobalConfig.Register("playground_setting", &playgroundSetting)
}

// GetPlaygroundSetting 获取 Playground 配置
func GetPlaygroundSetting() *PlaygroundSetting {
return &playgroundSetting
}

+ 2
- 1
types/price_data.go Просмотреть файл

@@ -29,6 +29,7 @@ type PriceData struct {
Quota int // 按次计费的最终额度(MJ / Task) Quota int // 按次计费的最终额度(MJ / Task)
QuotaToPreConsume int // 按量计费的预消耗额度 QuotaToPreConsume int // 按量计费的预消耗额度
GroupRatioInfo GroupRatioInfo GroupRatioInfo GroupRatioInfo
UserChannelRatio float64 // 用户-模型-渠道倍率(默认 1.0)
} }


// ApplyChannelPricingRatios 将渠道定价的扩展比率应用到 PriceData(非零值覆盖) // ApplyChannelPricingRatios 将渠道定价的扩展比率应用到 PriceData(非零值覆盖)
@@ -64,5 +65,5 @@ func (p *PriceData) AddOtherRatio(key string, ratio float64) {
} }


func (p *PriceData) ToSetting() string { func (p *PriceData) ToSetting() string {
return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, CacheCreation5mRatio: %f, CacheCreation1hRatio: %f, QuotaToPreConsume: %d, ImageRatio: %f, AudioRatio: %f, AudioCompletionRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.CacheCreation5mRatio, p.CacheCreation1hRatio, p.QuotaToPreConsume, p.ImageRatio, p.AudioRatio, p.AudioCompletionRatio)
return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, CacheCreation5mRatio: %f, CacheCreation1hRatio: %f, QuotaToPreConsume: %d, ImageRatio: %f, AudioRatio: %f, AudioCompletionRatio: %f, UserChannelRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.CacheCreation5mRatio, p.CacheCreation1hRatio, p.QuotaToPreConsume, p.ImageRatio, p.AudioRatio, p.AudioCompletionRatio, p.UserChannelRatio)
} }

+ 2
- 1
web/src/components/playground/OptimizedComponents.js Просмотреть файл

@@ -75,7 +75,8 @@ export const OptimizedSettingsPanel = React.memo(
JSON.stringify(prevProps.previewPayload) === JSON.stringify(prevProps.previewPayload) ===
JSON.stringify(nextProps.previewPayload) && JSON.stringify(nextProps.previewPayload) &&
JSON.stringify(prevProps.messages) === JSON.stringify(nextProps.messages) && JSON.stringify(prevProps.messages) === JSON.stringify(nextProps.messages) &&
JSON.stringify(prevProps.channels) === JSON.stringify(nextProps.channels)
JSON.stringify(prevProps.channels) === JSON.stringify(nextProps.channels) &&
prevProps.mutualExclusive === nextProps.mutualExclusive
); );
}, },
); );


+ 8
- 0
web/src/components/playground/ParameterControl.jsx Просмотреть файл

@@ -37,12 +37,20 @@ const ParameterControl = ({
onInputChange, onInputChange,
onParameterToggle, onParameterToggle,
disabled = false, disabled = false,
mutualExclusive = false,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();


return ( return (
<> <>
{/* Temperature */} {/* Temperature */}
{mutualExclusive && (
<div className='mb-3 px-2 py-1.5 bg-amber-50 border border-amber-200 rounded-lg'>
<Typography.Text className='text-xs text-amber-600'>
{t('此模型的 Temperature 和 Top P 互斥,只能启用一个')}
</Typography.Text>
</div>
)}
<div <div
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.temperature || disabled ? 'opacity-50' : ''}`} className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.temperature || disabled ? 'opacity-50' : ''}`}
> >


+ 4
- 1
web/src/components/playground/SettingsPanel.jsx Просмотреть файл

@@ -36,6 +36,7 @@ const SettingsPanel = ({
showDebugPanel, showDebugPanel,
customRequestMode, customRequestMode,
customRequestBody, customRequestBody,
mutualExclusive = false,
onInputChange, onInputChange,
onParameterToggle, onParameterToggle,
onCloseSettings, onCloseSettings,
@@ -114,7 +115,7 @@ const SettingsPanel = ({
defaultPayload={previewPayload} defaultPayload={previewPayload}
/> />


{/* 分组选择 */}
{/* 分组选择 - 已隐藏
<div className={customRequestMode ? 'opacity-50' : ''}> <div className={customRequestMode ? 'opacity-50' : ''}>
<div className='flex items-center gap-2 mb-2'> <div className='flex items-center gap-2 mb-2'>
<Users size={16} className='text-gray-500' /> <Users size={16} className='text-gray-500' />
@@ -145,6 +146,7 @@ const SettingsPanel = ({
disabled={customRequestMode} disabled={customRequestMode}
/> />
</div> </div>
*/}


{/* 模型选择 */} {/* 模型选择 */}
<div className={customRequestMode ? 'opacity-50' : ''}> <div className={customRequestMode ? 'opacity-50' : ''}>
@@ -230,6 +232,7 @@ const SettingsPanel = ({
onInputChange={onInputChange} onInputChange={onInputChange}
onParameterToggle={onParameterToggle} onParameterToggle={onParameterToggle}
disabled={customRequestMode} disabled={customRequestMode}
mutualExclusive={mutualExclusive}
/> />
</div> </div>




+ 8
- 0
web/src/components/settings/OperationSetting.jsx Просмотреть файл

@@ -29,6 +29,7 @@ import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring
import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit'; import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit';
import SettingsCheckin from '../../pages/Setting/Operation/SettingsCheckin'; import SettingsCheckin from '../../pages/Setting/Operation/SettingsCheckin';
import SettingsRegionSync from '../../pages/Setting/Operation/SettingsRegionSync'; import SettingsRegionSync from '../../pages/Setting/Operation/SettingsRegionSync';
import SettingsPlayground from '../../pages/Setting/Operation/SettingsPlayground';
import { API, showError, toBoolean } from '../../helpers'; import { API, showError, toBoolean } from '../../helpers';


const OperationSetting = () => { const OperationSetting = () => {
@@ -98,6 +99,9 @@ const OperationSetting = () => {


/* 令牌设置 */ /* 令牌设置 */
'token_setting.max_user_tokens': 1000, 'token_setting.max_user_tokens': 1000,

/* Playground 设置 */
'playground_setting.mutual_exclusive_params': '',
}); });


let [loading, setLoading] = useState(false); let [loading, setLoading] = useState(false);
@@ -181,6 +185,10 @@ const OperationSetting = () => {
<Card style={{ marginTop: '10px' }}> <Card style={{ marginTop: '10px' }}>
<SettingsRegionSync options={inputs} refresh={onRefresh} /> <SettingsRegionSync options={inputs} refresh={onRefresh} />
</Card> </Card>
{/* Playground 参数互斥设置 */}
<Card style={{ marginTop: '10px' }}>
<SettingsPlayground options={inputs} refresh={onRefresh} />
</Card>
</Spin> </Spin>
</> </>
); );


+ 3
- 1
web/src/components/table/users/UsersColumnDefs.jsx Просмотреть файл

@@ -137,7 +137,9 @@ const renderStatistics = (text, record, showEnableDisableModal, t) => {
const renderQuotaUsage = (text, record, t) => { const renderQuotaUsage = (text, record, t) => {
const { Paragraph } = Typography; const { Paragraph } = Typography;
const used = parseInt(record.used_quota) || 0; const used = parseInt(record.used_quota) || 0;
const remain = parseInt(record.quota) || 0;
const remain = record.source === 'synced'
? (parseInt(record.synced_quota) || 0)
: (parseInt(record.quota) || 0);
const total = used + remain; const total = used + remain;
const percent = total > 0 ? (remain / total) * 100 : 0; const percent = total > 0 ? (remain / total) * 100 : 0;
const popoverContent = ( const popoverContent = (


+ 8
- 0
web/src/components/table/users/modals/EditUserModal.jsx Просмотреть файл

@@ -56,6 +56,7 @@ import {
IconPlus, IconPlus,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import UserBindingManagementModal from './UserBindingManagementModal'; import UserBindingManagementModal from './UserBindingManagementModal';
import UserRatioSection from './UserRatioSection';


const { Text, Title } = Typography; const { Text, Title } = Typography;


@@ -327,6 +328,13 @@ const EditUserModal = (props) => {
</Card> </Card>
)} )}



{/* 倍率设置 */}
{userId && (
<Card className='!rounded-2xl shadow-sm border-0'>
<UserRatioSection userId={userId} />
</Card>
)}
{/* 绑定信息入口 */} {/* 绑定信息入口 */}
{userId && ( {userId && (
<Card className='!rounded-2xl shadow-sm border-0'> <Card className='!rounded-2xl shadow-sm border-0'>


+ 241
- 0
web/src/components/table/users/modals/UserRatioSection.jsx Просмотреть файл

@@ -0,0 +1,241 @@
import React, { useEffect, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { API, showError, showSuccess } from '../../../../helpers';
import {
Button,
Table,
InputNumber,
Modal,
Form,
Avatar,
Typography,
Popconfirm,
} from '@douyinfe/semi-ui';
import { IconStar } from '@douyinfe/semi-icons';

const { Text } = Typography;

const UserRatioSection = ({ userId }) => {
const { t } = useTranslation();
const [ratios, setRatios] = useState([]);
const [loading, setLoading] = useState(false);
const [addModalVisible, setAddModalVisible] = useState(false);
const [channelOptions, setChannelOptions] = useState([]);
const [modelOptions, setModelOptions] = useState([]);
const formApiRef = useRef(null);

const loadRatios = async () => {
if (!userId) return;
setLoading(true);
try {
const res = await API.get(`/api/user_channel_ratio/${userId}`);
const { success, data, message } = res.data;
if (success) {
setRatios(data || []);
} else {
showError(message);
}
} catch (e) {
showError(e.message);
}
setLoading(false);
};

const loadChannels = async () => {
try {
const res = await API.get('/api/channel/?p=0&page_size=500');
const { success, data, message } = res.data;
if (success) {
const options = (data?.items || []).map((ch) => ({
label: ch.remark
? `${ch.name} (${ch.remark})`
: `${ch.name} (ID: ${ch.id})`,
value: ch.id,
models: (ch.models || '').split(',').map((m) => m.trim()).filter(Boolean),
}));
setChannelOptions(options);
} else {
showError(message);
}
} catch (e) {
showError(e.message);
}
};

useEffect(() => {
loadRatios();
loadChannels();
}, [userId]);

const handleAdd = async (values) => {
const res = await API.post('/api/user_channel_ratio/', {
user_id: userId,
model_name: values.model_name,
channel_id: values.channel_id,
ratio: values.ratio,
});
const { success, message } = res.data;
if (success) {
showSuccess(t('添加成功'));
setAddModalVisible(false);
loadRatios();
} else {
showError(message);
}
};

const handleUpdate = async (id, newRatio) => {
const res = await API.put(`/api/user_channel_ratio/${id}`, {
ratio: newRatio,
});
const { success, message } = res.data;
if (success) {
showSuccess(t('更新成功'));
loadRatios();
} else {
showError(message);
}
};

const handleDelete = async (id) => {
const res = await API.delete(`/api/user_channel_ratio/${id}`);
const { success, message } = res.data;
if (success) {
showSuccess(t('删除成功'));
loadRatios();
} else {
showError(message);
}
};

const columns = [
{ title: t('模型'), dataIndex: 'model_name', key: 'model_name' },
{
title: t('渠道'),
dataIndex: 'channel_id',
key: 'channel_id',
render: (channelId) => {
const ch = channelOptions.find((c) => c.value === channelId);
return ch ? ch.label : channelId;
},
},
{
title: t('倍率'),
dataIndex: 'ratio',
key: 'ratio',
render: (text, record) => (
<InputNumber
value={text}
min={0.01}
step={0.1}
onBlur={(val) => {
if (val !== text) handleUpdate(record.id, val);
}}
style={{ width: 100 }}
/>
),
},
{
title: t('操作'),
key: 'action',
render: (_, record) => (
<Popconfirm
title={t('确认删除?')}
onConfirm={() => handleDelete(record.id)}
>
<Button type='danger' size='small'>
{t('删除')}
</Button>
</Popconfirm>
),
},
];

return (
<>
<div className='flex items-center justify-between mb-2'>
<div className='flex items-center'>
<Avatar size='small' color='orange' className='mr-2 shadow-md'>
<IconStar size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('倍率设置')}</Text>
<div className='text-xs text-gray-600'>
{t(
'为该用户设置特定模型+渠道的倍率乘数,未设置时默认为 1',
)}
</div>
</div>
</div>
<Button size='small' onClick={() => setAddModalVisible(true)}>
{t('添加倍率')}
</Button>
</div>

<Table
columns={columns}
dataSource={ratios}
loading={loading}
rowKey='id'
size='small'
pagination={false}
empty={t('暂无倍率设置')}
/>

<Modal
title={t('添加倍率')}
visible={addModalVisible}
onOk={() => formApiRef.current?.submitForm()}
onCancel={() => setAddModalVisible(false)}
>
<Form
getFormApi={(api) => (formApiRef.current = api)}
onSubmit={handleAdd}
>
<Form.Select
field='channel_id'
label={t('渠道')}
placeholder={t('请选择渠道')}
optionList={channelOptions}
filter
showClear
rules={[{ required: true, message: t('请选择渠道') }]}
style={{ width: '100%' }}
onChange={(val) => {
formApiRef.current?.setValue('model_name', undefined);
const selected = channelOptions.find((c) => c.value === val);
if (selected?.models) {
setModelOptions(selected.models.map((m) => ({ label: m, value: m })));
} else {
setModelOptions([]);
}
}}
/>
<Form.Select
field='model_name'
label={t('模型')}
placeholder={t('请选择模型')}
optionList={modelOptions}
filter
showClear
disabled={modelOptions.length === 0}
rules={[{ required: true, message: t('请选择模型') }]}
style={{ width: '100%' }}
/>
<Form.InputNumber
field='ratio'
label={t('倍率')}
placeholder='1.0'
initValue={1.0}
min={0.01}
step={0.1}
rules={[{ required: true, message: t('请输入倍率') }]}
style={{ width: '100%' }}
/>
</Form>
</Modal>
</>
);
};

export default UserRatioSection;

+ 62
- 44
web/src/helpers/render.jsx Просмотреть файл

@@ -1312,6 +1312,7 @@ export function renderModelPrice(
audioInputPrice = 0, audioInputPrice = 0,
imageGenerationCall = false, imageGenerationCall = false,
imageGenerationCallPrice = 0, imageGenerationCallPrice = 0,
userChannelRatio,
) { ) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
groupRatio, groupRatio,
@@ -1322,17 +1323,19 @@ export function renderModelPrice(
// 获取货币配置 // 获取货币配置
const { symbol, rate } = getCurrencyConfig(); const { symbol, rate } = getCurrencyConfig();


const ucr = (userChannelRatio != null && userChannelRatio !== 1.0) ? userChannelRatio : null;

if (modelPrice !== -1) { if (modelPrice !== -1) {
const displayPrice = (modelPrice * rate).toFixed(6); const displayPrice = (modelPrice * rate).toFixed(6);
const displayTotal = (modelPrice * groupRatio * rate).toFixed(6);
const displayTotal = (modelPrice * groupRatio * (ucr || 1) * rate).toFixed(6);
const ratioParts = `${ratioLabel}:${groupRatio}` + (ucr ? ` * 用户倍率:${ucr}` : '');
return i18next.t( return i18next.t(
'模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}',
'模型价格:{{symbol}}{{price}} * {{ratioParts}} = {{symbol}}{{total}}',
{ {
symbol: symbol, symbol: symbol,
price: displayPrice, price: displayPrice,
ratio: groupRatio,
ratioParts,
total: displayTotal, total: displayTotal,
ratioType: ratioLabel,
}, },
); );
} else { } else {
@@ -1363,6 +1366,10 @@ export function renderModelPrice(
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio + (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
imageGenerationCallPrice * groupRatio; imageGenerationCallPrice * groupRatio;


if (ucr) {
price *= ucr;
}

return ( return (
<> <>
<article> <article>
@@ -1490,13 +1497,14 @@ export function renderModelPrice(


// 构建输出部分描述 // 构建输出部分描述
const outputDesc = i18next.t( const outputDesc = i18next.t(
'输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}',
'输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}{{userRatio}}',
{ {
completion: completionTokens, completion: completionTokens,
symbol: symbol, symbol: symbol,
compPrice: (completionRatioPrice * rate).toFixed(6), compPrice: (completionRatioPrice * rate).toFixed(6),
ratio: groupRatio, ratio: groupRatio,
ratioType: ratioLabel, ratioType: ratioLabel,
userRatio: ucr ? ` * 用户倍率 ${ucr}` : '',
}, },
); );


@@ -1571,6 +1579,7 @@ export function renderLogContent(
webSearchCallCount = 0, webSearchCallCount = 0,
fileSearch = false, fileSearch = false,
fileSearchCallCount = 0, fileSearchCallCount = 0,
userChannelRatio,
) { ) {
const { const {
ratio, ratio,
@@ -1581,51 +1590,55 @@ export function renderLogContent(
// 获取货币配置 // 获取货币配置
const { symbol, rate } = getCurrencyConfig(); const { symbol, rate } = getCurrencyConfig();


const userRatioSuffix = (userChannelRatio != null && userChannelRatio !== 1.0)
? i18next.t(',用户倍率 {{userChannelRatio}}', { userChannelRatio })
: '';

let result;
if (modelPrice !== -1) { if (modelPrice !== -1) {
return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', {
result = i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', {
symbol: symbol, symbol: symbol,
price: (modelPrice * rate).toFixed(6), price: (modelPrice * rate).toFixed(6),
ratioType: ratioLabel, ratioType: ratioLabel,
ratio, ratio,
}); });
} else if (image) {
result = i18next.t(
'模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},图片输入倍率 {{imageRatio}},{{ratioType}} {{ratio}}',
{
modelRatio: modelRatio,
cacheRatio: cacheRatio,
completionRatio: completionRatio,
imageRatio: imageRatio,
ratioType: ratioLabel,
ratio,
},
);
} else if (webSearch) {
result = i18next.t(
'模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}},Web 搜索调用 {{webSearchCallCount}} 次',
{
modelRatio: modelRatio,
cacheRatio: cacheRatio,
completionRatio: completionRatio,
ratioType: ratioLabel,
ratio,
webSearchCallCount,
},
);
} else { } else {
if (image) {
return i18next.t(
'模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},图片输入倍率 {{imageRatio}},{{ratioType}} {{ratio}}',
{
modelRatio: modelRatio,
cacheRatio: cacheRatio,
completionRatio: completionRatio,
imageRatio: imageRatio,
ratioType: ratioLabel,
ratio,
},
);
} else if (webSearch) {
return i18next.t(
'模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}},Web 搜索调用 {{webSearchCallCount}} 次',
{
modelRatio: modelRatio,
cacheRatio: cacheRatio,
completionRatio: completionRatio,
ratioType: ratioLabel,
ratio,
webSearchCallCount,
},
);
} else {
return i18next.t(
'模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}}',
{
modelRatio: modelRatio,
cacheRatio: cacheRatio,
completionRatio: completionRatio,
ratioType: ratioLabel,
ratio,
},
);
}
result = i18next.t(
'模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}}',
{
modelRatio: modelRatio,
cacheRatio: cacheRatio,
completionRatio: completionRatio,
ratioType: ratioLabel,
ratio,
},
);
} }
return result + userRatioSuffix;
} }


export function renderModelPriceSimple( export function renderModelPriceSimple(
@@ -2148,6 +2161,7 @@ export function renderClaudeLogContent(
cacheCreationRatio5m = 1.0, cacheCreationRatio5m = 1.0,
cacheCreationTokens1h = 0, cacheCreationTokens1h = 0,
cacheCreationRatio1h = 1.0, cacheCreationRatio1h = 1.0,
userChannelRatio,
) { ) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
groupRatio, groupRatio,
@@ -2158,13 +2172,17 @@ export function renderClaudeLogContent(
// 获取货币配置 // 获取货币配置
const { symbol, rate } = getCurrencyConfig(); const { symbol, rate } = getCurrencyConfig();


const userRatioSuffix = (userChannelRatio != null && userChannelRatio !== 1.0)
? i18next.t(',用户倍率 {{userChannelRatio}}', { userChannelRatio })
: '';

if (modelPrice !== -1) { if (modelPrice !== -1) {
return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', { return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', {
symbol: symbol, symbol: symbol,
price: (modelPrice * rate).toFixed(6), price: (modelPrice * rate).toFixed(6),
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
});
}) + userRatioSuffix;
} else { } else {
const hasSplitCacheCreation = const hasSplitCacheCreation =
cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0; cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
@@ -2217,7 +2235,7 @@ export function renderClaudeLogContent(
}), }),
]; ];


return parts.join(',');
return parts.join(',') + userRatioSuffix;
} }
} }




+ 75
- 8
web/src/hooks/playground/usePlaygroundState.js Просмотреть файл

@@ -17,7 +17,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 { useState, useCallback, useRef, useEffect } from 'react';
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
DEFAULT_MESSAGES, DEFAULT_MESSAGES,
@@ -32,7 +32,7 @@ import {
loadMessages, loadMessages,
saveMessages, saveMessages,
} from '../../components/playground/configStorage'; } from '../../components/playground/configStorage';
import { processIncompleteThinkTags } from '../../helpers';
import { processIncompleteThinkTags, API } from '../../helpers';


export const usePlaygroundState = () => { export const usePlaygroundState = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -120,17 +120,70 @@ export const usePlaygroundState = () => {
const saveConfigTimeoutRef = useRef(null); const saveConfigTimeoutRef = useRef(null);
const saveMessagesTimeoutRef = useRef(null); const saveMessagesTimeoutRef = useRef(null);


// 互斥模型前缀配置
const [mutualExclusivePrefixes, setMutualExclusivePrefixes] = useState([]);

// 加载互斥配置
useEffect(() => {
const fetchConfig = async () => {
try {
const res = await API.get('/api/playground/config');
const { success, data } = res.data;
if (success && data?.mutual_exclusive_params) {
const prefixes = data.mutual_exclusive_params
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
setMutualExclusivePrefixes(prefixes);
}
} catch (e) {
// 静默失败,不影响 Playground 使用
}
};
fetchConfig();
}, []);

// 判断当前模型是否需要互斥
const isMutualExclusiveModel = useCallback(
(modelName) => {
if (!modelName || mutualExclusivePrefixes.length === 0) return false;
return mutualExclusivePrefixes.some((prefix) =>
modelName.toLowerCase().startsWith(prefix.toLowerCase()),
);
},
[mutualExclusivePrefixes],
);

const isMutualExclusive = useMemo(
() => isMutualExclusiveModel(inputs.model),
[inputs.model, isMutualExclusiveModel],
);

// 配置更新函数 // 配置更新函数
const handleInputChange = useCallback((name, value) => { const handleInputChange = useCallback((name, value) => {
setInputs((prev) => ({ ...prev, [name]: value })); setInputs((prev) => ({ ...prev, [name]: value }));
}, []); }, []);


const handleParameterToggle = useCallback((paramName) => {
setParameterEnabled((prev) => ({
...prev,
[paramName]: !prev[paramName],
}));
}, []);
const handleParameterToggle = useCallback(
(paramName) => {
setParameterEnabled((prev) => {
const newVal = !prev[paramName];
const updated = { ...prev, [paramName]: newVal };

// temperature/top_p 互斥逻辑
if (isMutualExclusiveModel(inputs.model)) {
if (paramName === 'temperature' && newVal) {
updated.top_p = false;
} else if (paramName === 'top_p' && newVal) {
updated.temperature = false;
}
}

return updated;
});
},
[inputs.model, isMutualExclusiveModel],
);


// 消息保存函数 - 改为立即保存,可以接受参数 // 消息保存函数 - 改为立即保存,可以接受参数
const saveMessagesImmediately = useCallback( const saveMessagesImmediately = useCallback(
@@ -218,6 +271,18 @@ export const usePlaygroundState = () => {
}; };
}, []); }, []);


// 模型切换时,若互斥模型且两个参数都启用,自动禁用 top_p
useEffect(() => {
if (isMutualExclusiveModel(inputs.model)) {
setParameterEnabled((prev) => {
if (prev.temperature && prev.top_p) {
return { ...prev, top_p: false };
}
return prev;
});
}
}, [inputs.model, isMutualExclusiveModel]);

// 页面首次加载时,若最后一条消息仍处于 LOADING/INCOMPLETE 状态,自动修复 // 页面首次加载时,若最后一条消息仍处于 LOADING/INCOMPLETE 状态,自动修复
useEffect(() => { useEffect(() => {
if (!Array.isArray(message) || message.length === 0) return; if (!Array.isArray(message) || message.length === 0) return;
@@ -301,6 +366,8 @@ export const usePlaygroundState = () => {
// 处理函数 // 处理函数
handleInputChange, handleInputChange,
handleParameterToggle, handleParameterToggle,
isMutualExclusiveModel,
isMutualExclusive,
debouncedSaveConfig, debouncedSaveConfig,
saveMessagesImmediately, saveMessagesImmediately,
handleConfigImport, handleConfigImport,


+ 56
- 22
web/src/hooks/usage-logs/useUsageLogsData.jsx Просмотреть файл

@@ -22,7 +22,6 @@ import { useTranslation } from 'react-i18next';
import { Modal } from '@douyinfe/semi-ui'; import { Modal } from '@douyinfe/semi-ui';
import { import {
API, API,
getTodayStartTimestamp,
isAdmin, isAdmin,
showError, showError,
showSuccess, showSuccess,
@@ -87,7 +86,6 @@ export const useLogsData = () => {


// Form state // Form state
const [formApi, setFormApi] = useState(null); const [formApi, setFormApi] = useState(null);
let now = new Date();
const formInitValues = { const formInitValues = {
username: '', username: '',
token_name: '', token_name: '',
@@ -97,10 +95,7 @@ export const useLogsData = () => {
request_id: '', request_id: '',
chat_id: '', chat_id: '',
upstream_id: '', upstream_id: '',
dateRange: [
timestamp2string(getTodayStartTimestamp()),
timestamp2string(now.getTime() / 1000 + 3600),
],
dateRange: [],
logType: '0', logType: '0',
}; };


@@ -213,13 +208,15 @@ export const useLogsData = () => {
const getFormValues = () => { const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {}; const formValues = formApi ? formApi.getValues() : {};


let start_timestamp = timestamp2string(getTodayStartTimestamp());
let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600);
let start_timestamp = '';
let end_timestamp = '';


if ( if (
formValues.dateRange && formValues.dateRange &&
Array.isArray(formValues.dateRange) && Array.isArray(formValues.dateRange) &&
formValues.dateRange.length === 2
formValues.dateRange.length === 2 &&
formValues.dateRange[0] &&
formValues.dateRange[1]
) { ) {
start_timestamp = formValues.dateRange[0]; start_timestamp = formValues.dateRange[0];
end_timestamp = formValues.dateRange[1]; end_timestamp = formValues.dateRange[1];
@@ -251,10 +248,15 @@ export const useLogsData = () => {
logType: formLogType, logType: formLogType,
} = getFormValues(); } = getFormValues();
const currentLogType = formLogType !== undefined ? formLogType : logType; const currentLogType = formLogType !== undefined ? formLogType : logType;
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
let url = `/api/log/self/stat?type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
url = encodeURI(url);
let localStartTimestamp = start_timestamp ? Date.parse(start_timestamp) / 1000 : 0;
let localEndTimestamp = end_timestamp ? Date.parse(end_timestamp) / 1000 : 0;
const params = new URLSearchParams({ type: currentLogType });
if (localStartTimestamp) params.set('start_timestamp', localStartTimestamp);
if (localEndTimestamp) params.set('end_timestamp', localEndTimestamp);
if (token_name) params.set('token_name', token_name);
if (model_name) params.set('model_name', model_name);
if (group) params.set('group', group);
let url = `/api/log/self/stat?${params.toString()}`;
let res = await API.get(url); let res = await API.get(url);
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
@@ -276,10 +278,17 @@ export const useLogsData = () => {
logType: formLogType, logType: formLogType,
} = getFormValues(); } = getFormValues();
const currentLogType = formLogType !== undefined ? formLogType : logType; const currentLogType = formLogType !== undefined ? formLogType : logType;
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
let url = `/api/log/stat?type=${currentLogType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`;
url = encodeURI(url);
let localStartTimestamp = start_timestamp ? Date.parse(start_timestamp) / 1000 : 0;
let localEndTimestamp = end_timestamp ? Date.parse(end_timestamp) / 1000 : 0;
const params = new URLSearchParams({ type: currentLogType });
if (localStartTimestamp) params.set('start_timestamp', localStartTimestamp);
if (localEndTimestamp) params.set('end_timestamp', localEndTimestamp);
if (username) params.set('username', username);
if (token_name) params.set('token_name', token_name);
if (model_name) params.set('model_name', model_name);
if (channel) params.set('channel', channel);
if (group) params.set('group', group);
let url = `/api/log/stat?${params.toString()}`;
let res = await API.get(url); let res = await API.get(url);
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
@@ -422,6 +431,7 @@ export const useLogsData = () => {
other.cache_creation_ratio_1h || other.cache_creation_ratio_1h ||
other.cache_creation_ratio || other.cache_creation_ratio ||
1.0, 1.0,
other?.user_channel_ratio,
) )
: renderLogContent( : renderLogContent(
other?.model_ratio, other?.model_ratio,
@@ -436,6 +446,7 @@ export const useLogsData = () => {
other.web_search_call_count || 0, other.web_search_call_count || 0,
other.file_search || false, other.file_search || false,
other.file_search_call_count || 0, other.file_search_call_count || 0,
other?.user_channel_ratio,
), ),
}); });
if (logs[i]?.content) { if (logs[i]?.content) {
@@ -537,6 +548,7 @@ export const useLogsData = () => {
other?.audio_input_price || 0, other?.audio_input_price || 0,
other?.image_generation_call || false, other?.image_generation_call || false,
other?.image_generation_call_price || 0, other?.image_generation_call_price || 0,
other?.user_channel_ratio,
); );
} }
expandDataLocal.push({ expandDataLocal.push({
@@ -550,6 +562,12 @@ export const useLogsData = () => {
value: other.reasoning_effort, value: other.reasoning_effort,
}); });
} }
if (other?.user_channel_ratio) {
expandDataLocal.push({
key: t('用户倍率'),
value: other.user_channel_ratio,
});
}
} }
if (logs[i].type === 6) { if (logs[i].type === 6) {
if (other?.task_id) { if (other?.task_id) {
@@ -675,14 +693,30 @@ export const useLogsData = () => {
? formLogType ? formLogType
: logType; : logType;


let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
let localStartTimestamp = start_timestamp ? Date.parse(start_timestamp) / 1000 : 0;
let localEndTimestamp = end_timestamp ? Date.parse(end_timestamp) / 1000 : 0;

const params = new URLSearchParams({
p: startIdx,
page_size: pageSize,
type: currentLogType,
});
if (localStartTimestamp) params.set('start_timestamp', localStartTimestamp);
if (localEndTimestamp) params.set('end_timestamp', localEndTimestamp);
if (username) params.set('username', username);
if (token_name) params.set('token_name', token_name);
if (model_name) params.set('model_name', model_name);
if (channel) params.set('channel', channel);
if (group) params.set('group', group);
if (request_id) params.set('request_id', request_id);
if (chat_id) params.set('chat_id', chat_id);
if (upstream_id) params.set('upstream_id', upstream_id);

if (isAdminUser) { if (isAdminUser) {
url = `/api/log/?p=${startIdx}&page_size=${pageSize}&type=${currentLogType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}&request_id=${request_id}&chat_id=${chat_id}&upstream_id=${upstream_id}`;
url = `/api/log/?${params.toString()}`;
} else { } else {
url = `/api/log/self/?p=${startIdx}&page_size=${pageSize}&type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}&request_id=${request_id}&chat_id=${chat_id}&upstream_id=${upstream_id}`;
url = `/api/log/self/?${params.toString()}`;
} }
url = encodeURI(url);
const res = await API.get(url); const res = await API.get(url);
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {


+ 2
- 2
web/src/pages/Home/components/HeroSection.jsx Просмотреть файл

@@ -172,14 +172,14 @@ const HeroSection = () => {
</div> </div>


{/* 统计数据 */} {/* 统计数据 */}
<div className="flex flex-wrap gap-8 pt-2">
{/* <div className="flex flex-wrap gap-8 pt-2">
{stats.map((stat, index) => ( {stats.map((stat, index) => (
<div key={index} className="stat-card"> <div key={index} className="stat-card">
<span className="stat-card-value">{stat.value}</span> <span className="stat-card-value">{stat.value}</span>
<span className="stat-card-label">{stat.label}</span> <span className="stat-card-label">{stat.label}</span>
</div> </div>
))} ))}
</div>
</div> */}
</div> </div>


{/* 右侧功能卡片 */} {/* 右侧功能卡片 */}


+ 3
- 0
web/src/pages/Playground/index.jsx Просмотреть файл

@@ -104,6 +104,8 @@ const Playground = () => {
chatRef, chatRef,
handleInputChange, handleInputChange,
handleParameterToggle, handleParameterToggle,
isMutualExclusiveModel,
isMutualExclusive,
debouncedSaveConfig, debouncedSaveConfig,
saveMessagesImmediately, saveMessagesImmediately,
handleConfigImport, handleConfigImport,
@@ -537,6 +539,7 @@ const Playground = () => {
showDebugPanel={showDebugPanel} showDebugPanel={showDebugPanel}
customRequestMode={customRequestMode} customRequestMode={customRequestMode}
customRequestBody={customRequestBody} customRequestBody={customRequestBody}
mutualExclusive={isMutualExclusive}
onInputChange={handleInputChange} onInputChange={handleInputChange}
onParameterToggle={handleParameterToggle} onParameterToggle={handleParameterToggle}
onCloseSettings={() => setShowSettings(false)} onCloseSettings={() => setShowSettings(false)}


+ 110
- 0
web/src/pages/Setting/Operation/SettingsPlayground.jsx Просмотреть файл

@@ -0,0 +1,110 @@
import React, { useEffect, useState, useRef } from 'react';
import { Button, Col, Form, Row, Spin, Typography } from '@douyinfe/semi-ui';
import {
compareObjects,
API,
showError,
showSuccess,
showWarning,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';

export default function SettingsPlayground(props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({
'playground_setting.mutual_exclusive_params': '',
});
const refForm = useRef();
const [inputsRow, setInputsRow] = useState(inputs);

function handleFieldChange(fieldName) {
return (value) => {
setInputs((inputs) => ({ ...inputs, [fieldName]: value }));
};
}

function onSubmit() {
const updateArray = compareObjects(inputs, inputsRow);
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
const requestQueue = updateArray.map((item) => {
let value = String(inputs[item.key]);
return API.put('/api/option/', {
key: item.key,
value,
});
});
setLoading(true);
Promise.all(requestQueue)
.then((res) => {
if (requestQueue.length === 1) {
if (res.includes(undefined)) return;
} else if (requestQueue.length > 1) {
if (res.includes(undefined))
return showError(t('部分保存失败,请重试'));
}
showSuccess(t('保存成功'));
props.refresh();
})
.catch(() => {
showError(t('保存失败,请重试'));
})
.finally(() => {
setLoading(false);
});
}

useEffect(() => {
const currentInputs = {};
for (let key in props.options) {
if (Object.keys(inputs).includes(key)) {
currentInputs[key] = props.options[key];
}
}
setInputs(currentInputs);
setInputsRow(structuredClone(currentInputs));
refForm.current.setValues(currentInputs);
}, [props.options]);

return (
<>
<Spin spinning={loading}>
<Form
values={inputs}
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Form.Section text={t('Playground 参数互斥设置')}>
<Typography.Text
type='tertiary'
style={{ marginBottom: 16, display: 'block' }}
>
{t(
'配置 temperature 和 top_p 互斥的模型列表。每行一个模型前缀,匹配的模型在 Playground 中只能启用 temperature 或 top_p 其中一个。',
)}
</Typography.Text>
<Row gutter={16}>
<Col span={24}>
<Form.TextArea
field={'playground_setting.mutual_exclusive_params'}
label={t('互斥模型前缀列表')}
placeholder={`deepseek-v4-flash\ndeepseek-v4-pro\ndeepseek-reasoner\no1-\no3-\no4-\ngpt-5`}
rows={8}
onChange={handleFieldChange(
'playground_setting.mutual_exclusive_params',
)}
autosize
/>
</Col>
</Row>
<Row>
<Button size='default' onClick={onSubmit}>
{t('保存 Playground 设置')}
</Button>
</Row>
</Form.Section>
</Form>
</Spin>
</>
);
}

Загрузка…
Отмена
Сохранить