| Автор | SHA1 | Сообщение | Дата |
|---|---|---|---|
|
|
2d1a8d2f28 |
fix: 设置 chat_id + 优化日志查询参数构建
- Claude 和 OpenAI Responses 转发中设置 RelayChatID - 日志查询使用 URLSearchParams 替代手动字符串拼接 - 日志查询日期范围改为可选,不再强制默认今天 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
2 месяцев назад |
|
|
3e86f523db |
feat(playground): temperature/top_p 参数互斥设置
部分上游模型不允许同时设置 temperature 和 top_p,新增管理员可配置的 模型前缀列表,匹配的模型在 Playground 中自动互斥切换两个参数。 - 后端新增 PlaygroundSetting 配置 + GET /api/playground/config 公开端点 - 运营设置页面新增 Playground 互斥模型前缀编辑 textarea - Playground 自动检测模型名匹配,启用一个参数自动禁用另一个 - 默认包含 deepseek-v4-flash/pro、deepseek-reasoner、o1-/o3-/o4-、gpt-5 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
2 месяцев назад |
|
|
d26ad9e635 |
feat: 渠道-模型级联选择 + 隐藏首页统计卡片
- UserRatioSection 模型输入改为级联下拉,选渠道后自动加载模型列表 - models 信息嵌入 channelOptions 消除冗余 channelMap 状态 - 注释掉首页 HeroSection 统计数据卡片 Co-Authored-By: Claude <noreply@anthropic.com> |
2 месяцев назад |
|
|
bb708510fe |
docs: 用户倍率渠道-模型级联选择器设计文档
Co-Authored-By: Claude <noreply@anthropic.com> |
2 месяцев назад |
|
|
b026f6bc04 |
feat: 用户倍率前端展示 + 渠道下拉选择 + 上游调试日志
- UserRatioSection 渠道 ID 输入改为带搜索的下拉选择器 - 使用日志详情、计费过程、展开行均显示用户倍率 - renderLogContent suffix 统一追加,消除 4 处重复 - 提取 dumpUpstreamRequest 辅助函数,debug 日志走 SysLog Co-Authored-By: Claude <noreply@anthropic.com> |
2 месяцев назад |
|
|
9dbf3365fe |
feat(pricing): 添加用户-模型-渠道倍率功能
为每个用户添加独立于全局定价的倍率乘数,支持按 (userId, model, channel) 三维 精确控制用户级别定价。倍率以乘法叠加在现有 modelRatio × groupRatio 之上。 - 新增 user_channel_ratios 表,写入内存缓存(写穿透模式) - 新增 /api/user_channel_ratio/ CRUD API(AdminAuth) - 计费路径集成:compatible_handler、PostClaudeConsumeQuota、 PostWssConsumeQuota、PostAudioConsumeQuota、calculateAudioQuota - 前端:EditUserModal 内嵌 UserRatioSection 组件 - 修复 Update() 缓存 key 零值 bug(先加载再更新) - 修复 PreWssConsumeQuota 缺少 UserChannelRatio 导致归零 - 修复 ModelPriceHelperPerCall 缺少默认值 1.0 Co-Authored-By: Claude <noreply@anthropic.com> |
2 месяцев назад |
|
|
17a0c2c26a |
refactor(playground): 隐藏对话页面的分组选择框
Co-Authored-By: Claude <noreply@anthropic.com> |
2 месяцев назад |
|
|
b033edec56 |
fix: pending sync records 队列被 quota=0 旧记录阻塞
GetPendingRecordsForSync 添加 quota > 0 过滤条件,避免因上游超时 产生的 quota=0 旧记录(4月29日网络超时遗留)占满批次,阻塞新 的正常的扣费同步到 master 节点。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
2 месяцев назад |
|
|
8945f8c39e |
fix: synced 用户剩余额度取 synced_quota + 记录 chat ID
- 前端用户列表:synced 用户显式取 synced_quota 字段作为剩余额度 - 各 OpenAI handler 中记录 relayChatID 用于日志追踪 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
2 месяцев назад |
|
|
e41f2951f7 | fix: preserve relay body and capture invalid responses body | 2 месяцев назад |
| @@ -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, | |||||
| }) | |||||
| } | |||||
| @@ -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, | |||||
| }) | |||||
| } | |||||
| @@ -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`) | |||||
| @@ -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"}, | ||||
| @@ -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). | ||||
| @@ -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 | |||||
| } | |||||
| @@ -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)) | |||||
| } | |||||
| @@ -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 | ||||
| @@ -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{} | ||||
| } | } | ||||
| @@ -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 | ||||
| } | } | ||||
| @@ -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) | ||||
| } | } | ||||
| @@ -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()) | ||||
| @@ -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":`) | |||||
| } | |||||
| @@ -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 | ||||
| @@ -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)) | |||||
| } | |||||
| @@ -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) | ||||
| @@ -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) | |||||
| } | } | ||||
| } | } | ||||
| @@ -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()) | ||||
| @@ -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 | ||||
| @@ -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) | ||||
| @@ -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 | |||||
| } | |||||
| @@ -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) | |||||
| } | } | ||||
| @@ -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 | |||||
| ); | ); | ||||
| }, | }, | ||||
| ); | ); | ||||
| @@ -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' : ''}`} | ||||
| > | > | ||||
| @@ -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> | ||||
| @@ -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> | ||||
| </> | </> | ||||
| ); | ); | ||||
| @@ -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 = ( | ||||
| @@ -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'> | ||||
| @@ -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; | |||||
| @@ -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; | |||||
| } | } | ||||
| } | } | ||||
| @@ -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, | ||||
| @@ -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) { | ||||
| @@ -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> | ||||
| {/* 右侧功能卡片 */} | {/* 右侧功能卡片 */} | ||||
| @@ -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)} | ||||
| @@ -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> | |||||
| </> | |||||
| ); | |||||
| } | |||||