Skip to content
Merged
9 changes: 6 additions & 3 deletions controller/midjourney.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/system_setting"

"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -332,11 +333,13 @@ func GetUserMidjourney(c *gin.Context) {
items := model.GetAllUserTask(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
total := model.CountAllUserTask(userId, queryParams)

if setting.MjForwardUrlEnabled {
for i, midjourney := range items {
for _, midjourney := range items {
if setting.MjForwardUrlEnabled {
midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId
items[i] = midjourney
}
// 面向用户的 MJ 任务列表同样是上游失败原因的出口,与 /mj/task 查询
// (coverMidjourneyTaskDto)保持一致;管理员列表 GetAllMidjourney 保留原文。
midjourney.FailReason = operation_setting.OverrideUpstreamMessage(midjourney.FailReason)
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(items)
Expand Down
78 changes: 78 additions & 0 deletions controller/midjourney_fail_reason_override_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package controller

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

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

"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)

// The Midjourney task list is a user-facing outlet for the same upstream failure text
// the /mj/task fetch path already masks (relay.coverMidjourneyTaskDto). The user list
// must mask it while the admin list keeps the original for diagnosis.
func TestMidjourneyListFailReasonOverride(t *testing.T) {
enableTaskErrorOverride(t)
gin.SetMode(gin.TestMode)

previousDB := model.DB
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Midjourney{}))
model.DB = db
t.Cleanup(func() { model.DB = previousDB })

const userID = 7
const upstreamReason = "insufficient credits, please top-up your account"
const localReason = "获取渠道信息失败,请联系管理员,渠道ID:3"

require.NoError(t, db.Create(&model.Midjourney{
UserId: userID, MjId: "mj-upstream", Status: "FAILURE", FailReason: upstreamReason,
}).Error)
require.NoError(t, db.Create(&model.Midjourney{
UserId: userID, MjId: "mj-local", Status: "FAILURE", FailReason: localReason,
}).Error)

failReasons := func(handler gin.HandlerFunc) map[string]string {
t.Helper()
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/mj/self", nil)
c.Set("id", userID)

handler(c)
require.Equal(t, http.StatusOK, recorder.Code)

var response struct {
Data struct {
Items []struct {
MjId string `json:"mj_id"`
FailReason string `json:"fail_reason"`
} `json:"items"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
reasons := make(map[string]string, len(response.Data.Items))
for _, item := range response.Data.Items {
reasons[item.MjId] = item.FailReason
}
return reasons
}

userReasons := failReasons(GetUserMidjourney)
assert.Equal(t, operation_setting.ErrorOverrideMessage, userReasons["mj-upstream"])
// This site's own failure text must survive the user-facing path.
assert.Equal(t, localReason, userReasons["mj-local"])

adminReasons := failReasons(GetAllMidjourney)
assert.Equal(t, upstreamReason, adminReasons["mj-upstream"])
assert.Equal(t, localReason, adminReasons["mj-local"])
}
2 changes: 2 additions & 0 deletions controller/playground.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/operation_setting"

"github.com/gin-gonic/gin"
)
Expand All @@ -17,6 +18,7 @@ func Playground(c *gin.Context) {

defer func() {
if newAPIError != nil {
operation_setting.OverrideUpstreamError(newAPIError)
c.JSON(newAPIError.StatusCode, gin.H{
"error": newAPIError.ToOpenAIError(),
})
Expand Down
58 changes: 51 additions & 7 deletions controller/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,14 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
defer func() {
if newAPIError != nil {
logger.LogError(c, fmt.Sprintf("relay error: %s", common.LocalLogPreview(newAPIError.Error())))
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
// 必须在错误日志之后执行:日志与渠道禁用判定始终使用原始上游文案。
// 覆写文案与 request id 一次写入:上游错误的 ToOpenAIError() 直接返回 RelayError,
// 不读 Err,SetMessage 改不到响应体,必须走 ReplaceMessage
if operation_setting.ShouldOverrideUpstreamError(newAPIError) {
newAPIError.ReplaceMessage(common.MessageWithRequestId(operation_setting.ErrorOverrideMessage, requestId))
} else {
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
}
switch relayFormat {
case types.RelayFormatOpenAIRealtime:
helper.WssError(c, ws, newAPIError.ToOpenAIError())
Expand Down Expand Up @@ -431,13 +438,26 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
adminInfo["multi_key_index"] = common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex)
}
service.AppendChannelAffinityAdminInfo(c, adminInfo)
// 错误日志的 Content 会通过 /api/log/self 回显给发起请求的用户,因此它和 HTTP 响应
// 一样是对外出口。覆写生效时这里必须同步覆写,否则客户端在响应里看到
// Service Unavailable,转头在日志页仍能读到上游账务原文。原文改记到
// admin_info,model.formatUserLogs 会为普通用户剥离整个 admin_info。
logContent := err.MaskSensitiveErrorWithStatusCode()
if operation_setting.ShouldOverrideUpstreamError(err) {
adminInfo["original_error"] = logContent
logContent = operation_setting.ErrorOverrideMessage
// 与 MaskSensitiveErrorWithStatusCode 保持一致:无状态码时不写 status_code=0
if err.StatusCode != 0 {
logContent = fmt.Sprintf("status_code=%d, %s", err.StatusCode, logContent)
}
}
other["admin_info"] = adminInfo
startTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime)
if startTime.IsZero() {
startTime = time.Now()
}
useTimeSeconds := int(time.Since(startTime).Seconds())
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, useTimeSeconds, common.GetContextKeyBool(c, constant.ContextKeyIsStream), userGroup, other)
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, logContent, tokenId, useTimeSeconds, common.GetContextKeyBool(c, constant.ContextKeyIsStream), userGroup, other)
}

}
Expand Down Expand Up @@ -475,13 +495,23 @@ func RelayMidjourney(c *gin.Context) {
mjErr.Result = "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。"
statusCode = http.StatusTooManyRequests
}
description := fmt.Sprintf("%s %s", mjErr.Description, mjErr.Result)
channelId := c.GetInt("channel_id")
logger.LogError(c, fmt.Sprintf("relay error (channel #%d, status code %d): %s", channelId, statusCode, description))
// 这里一律不覆写:能走到 mjErr 的 MidjourneyResponse 全部是本站自产的(参数校验、
// 额度不足、DB/IO 失败)。mj-proxy 各 handler 拿到上游响应后是把上游 body 原样
// io.Copy 给客户端再 return nil 的,上游错误文案根本不经过这个分支。
// 之前在此处按关键词覆写会把本站的 quota_not_enough(mjproxy_handler.go 的
// RelaySwapFace / RelayMidjourneySubmit)误伤成 Service Unavailable,
// 让用户看不到自己额度不足的真实原因。
// MJ 上游文案的出口是被代理的响应体本身与任务的 FailReason,前者要改写就得重写
// 上游 JSON,会破坏 mj-proxy 协议兼容性,故不在本功能范围内;后者已在
// TaskModel2Dto / coverMidjourneyTaskDto 的读取边界处理。
c.JSON(statusCode, gin.H{
"description": fmt.Sprintf("%s %s", mjErr.Description, mjErr.Result),
"description": description,
"type": "upstream_error",
"code": mjErr.Code,
})
channelId := c.GetInt("channel_id")
logger.LogError(c, fmt.Sprintf("relay error (channel #%d, status code %d): %s", channelId, statusCode, fmt.Sprintf("%s %s", mjErr.Description, mjErr.Result)))
}
}

Expand Down Expand Up @@ -617,7 +647,7 @@ func RelayTask(c *gin.Context) {
processChannelError(c,
*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey,
common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()),
types.NewOpenAIError(taskErr.Error, types.ErrorCodeBadResponseStatusCode, taskErr.StatusCode))
service.APIErrorFromTaskError(taskErr))
}

if taskFailoverEnabled {
Expand Down Expand Up @@ -674,10 +704,24 @@ func respondTaskError(c *gin.Context, taskErr *taskdto.TaskError) {
if taskErr.StatusCode == http.StatusTooManyRequests {
taskErr.Message = "当前分组上游负载已饱和,请稍后再试"
}
// 仅覆写文案确实取自上游任务平台的错误。这里必须用正向标记 FromUpstream 判定:
// 本站自产错误(读 body 失败、解析失败、预扣费额度不足)默认 LocalError == false,
// 用 !LocalError 反推会把它们一并掩盖。
if taskErr.FromUpstream {
if overridden := operation_setting.OverrideUpstreamMessage(taskErr.Message); overridden != taskErr.Message {
// 覆写前先记录原始上游文案,后台日志与排障始终可见全文
logger.LogError(c, fmt.Sprintf("task upstream error overridden: %s", common.LocalLogPreview(taskErr.Message)))
taskErr.Message = overridden
// Data 带 json:"data" 会一并返回客户端。当前 task 适配器不往里塞上游 body,
// 但覆写掉 Message 却留着一个可能承载上游原文的字段是自相矛盾的,
// 与 NewAPIError.ReplaceMessage 清 Metadata/Param 保持一致。
taskErr.Data = nil
}
}
c.JSON(taskErr.StatusCode, taskErr)
}

func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *taskdto.TaskError, retryTimes int) bool {
func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *taskdto.TaskError, retryTimes int, failoverEnabled bool) bool {
if taskErr == nil {
return false
}
Expand Down
11 changes: 7 additions & 4 deletions controller/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ func GetAllTask(c *gin.Context) {
items := model.TaskGetAllTasks(pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
total := model.TaskCountAllTasks(queryParams)
pageInfo.SetTotal(int(total))
pageInfo.SetItems(tasksToDto(items, true))
// 管理员视图不掩盖上游失败原因,排障需要原文
pageInfo.SetItems(tasksToDto(items, true, false))
common.ApiSuccess(c, pageInfo)
}

Expand All @@ -56,11 +57,13 @@ func GetUserTask(c *gin.Context) {
items := model.TaskGetAllUserTask(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
total := model.TaskCountAllUserTask(userId, queryParams)
pageInfo.SetTotal(int(total))
pageInfo.SetItems(tasksToDto(items, false))
pageInfo.SetItems(tasksToDto(items, false, true))
common.ApiSuccess(c, pageInfo)
}

func tasksToDto(tasks []*model.Task, fillUser bool) []*dto.TaskDto {
// tasksToDto 转换任务列表。maskUpstreamFailReason 单独传参而不复用 fillUser:填充用户名
// 与是否掩盖上游失败原因是两个无关维度,绑在一起会让后续加入新调用方时选错默认值。
func tasksToDto(tasks []*model.Task, fillUser bool, maskUpstreamFailReason bool) []*dto.TaskDto {
var userIdMap map[int]*model.UserBase
if fillUser {
userIdMap = make(map[int]*model.UserBase)
Expand All @@ -82,7 +85,7 @@ func tasksToDto(tasks []*model.Task, fillUser bool) []*dto.TaskDto {
task.Username = user.Username
}
}
result[i] = relay.TaskModel2Dto(task)
result[i] = relay.TaskModel2Dto(task, maskUpstreamFailReason)
}
return result
}
Loading
Loading