From 0b5f1db87e96038556e0c71602696bb1e4c4030c Mon Sep 17 00:00:00 2001 From: ItzPlana Date: Wed, 12 Aug 2026 14:34:40 +0200 Subject: [PATCH 1/7] feat: add configurable upstream error message override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当上游供应商返回的错误信息包含 "No available"、"quota"、"credits"、 "top-up" 等关键词时,原始文案会直接透传给客户端,暴露本站的上游账户 欠费状态与转售链路(例如上游本身就是另一个 new-api 站点)。新增一个 可在「运维 → 系统行为」开关的「错误信息覆写」功能:命中关键词的 上游错误统一返回 "Service Unavailable",HTTP 状态码与 error.code / error.type 保持原值。 边界: - 只覆写从上游传递到本站的错误,本站自身产生的错误(用户额度不足、 distributor 无可用渠道、请求校验失败等)一律不覆写。 - 后台日志与渠道自动禁用判定始终使用原始上游文案,覆写仅作用于 写给客户端的响应体。 实现: - relaykit/types: NewAPIError 增加 fromUpstream 标记与 MarkUpstreamOrigin / IsFromUpstreamError;新增 ReplaceMessage 同时改写 Err 与 RelayError(ToOpenAIError 对 openai_error 直接 返回 RelayError,SetMessage 不足以覆写响应)并清空上游 Metadata。 新增 WithUpstreamOpenAIError / WithUpstreamClaudeError 构造函数。 - setting/operation_setting: 新增 error_override_setting.go,沿用 AutomaticDisableKeywords 的换行分隔关键词存储范式。 - service/error.go: RelayErrorHandler 通过 defer 给所有返回路径打上 上游标记;渠道适配器与 violation_fee.go 的包装错误同步标记。 - controller: relay.go / playground.go 在日志记录之后、响应写出前 执行覆写;任务与 Midjourney 链路走 OverrideUpstreamMessage。 - 前端: 系统行为区块新增开关与可编辑关键词文本框,i18n 同步全部语言。 - 测试: 覆盖标记传递、ReplaceMessage 行为、本地/上游错误区分、 关键词匹配与规范化。 --- controller/playground.go | 2 + controller/relay.go | 22 +- model/option.go | 6 + relay/channel/ali/image.go | 2 +- relay/channel/ali/rerank.go | 2 +- relay/channel/claude/relay-claude.go | 4 +- relay/channel/jimeng/image.go | 2 +- relay/channel/minimax/image.go | 2 +- relay/channel/openai/chat_via_responses.go | 6 +- relay/channel/openai/relay-openai.go | 2 +- relay/channel/openai/relay_image.go | 4 +- relay/channel/openai/relay_responses.go | 2 +- .../channel/openai/relay_responses_compact.go | 2 +- relay/channel/openai/responses_via_chat.go | 4 +- relay/channel/palm/relay-palm.go | 2 +- relay/channel/tencent/relay-tencent.go | 2 +- relay/channel/zhipu/relay-zhipu.go | 2 +- relay/channel/zhipu_4v/image.go | 2 +- relaykit/types/error.go | 61 +++++ relaykit/types/error_test.go | 140 ++++++++++++ service/error.go | 4 + service/violation_fee.go | 13 +- .../error_override_setting.go | 69 ++++++ .../error_override_setting_test.go | 213 ++++++++++++++++++ .../general/system-behavior-section.tsx | 53 +++++ .../system-settings/operations/index.tsx | 2 + .../operations/section-registry.tsx | 2 + web/src/features/system-settings/types.ts | 2 + .../i18n/locales/_reports/_sync-report.json | 8 +- .../locales/_reports/fr.untranslated.json | 2 + .../locales/_reports/ja.untranslated.json | 4 + .../locales/_reports/ru.untranslated.json | 4 + .../locales/_reports/vi.untranslated.json | 2 + web/src/i18n/locales/en.json | 4 + web/src/i18n/locales/fr.json | 4 + web/src/i18n/locales/ja.json | 4 + web/src/i18n/locales/ru.json | 4 + web/src/i18n/locales/vi.json | 4 + web/src/i18n/locales/zh-TW.json | 4 + web/src/i18n/locales/zh.json | 4 + 40 files changed, 647 insertions(+), 30 deletions(-) create mode 100644 relaykit/types/error_test.go create mode 100644 setting/operation_setting/error_override_setting.go create mode 100644 setting/operation_setting/error_override_setting_test.go diff --git a/controller/playground.go b/controller/playground.go index 1c9c8d3b77d9..e434c892aade 100644 --- a/controller/playground.go +++ b/controller/playground.go @@ -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" ) @@ -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(), }) diff --git a/controller/relay.go b/controller/relay.go index 8dccfe76dddd..99959a663609 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -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)) + // 必须在错误日志之后执行:日志与渠道禁用判定始终使用原始上游文案 + if operation_setting.OverrideUpstreamError(newAPIError) { + // 覆写后的错误须用 ReplaceMessage 附加 request id:上游错误的 ToOpenAIError() + // 直接返回 RelayError,不读 Err,SetMessage 改不到响应体 + newAPIError.ReplaceMessage(common.MessageWithRequestId(newAPIError.Error(), requestId)) + } else { + newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId)) + } switch relayFormat { case types.RelayFormatOpenAIRealtime: helper.WssError(c, ws, newAPIError.ToOpenAIError()) @@ -440,13 +447,16 @@ 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)) + // MidjourneyResponse 没有 LocalError 标志,但本站自产的错误是 bind_request_body_failed、 + // task_no_found 这类码状字符串,不含覆写关键词,故关键词门控足以避免误伤 c.JSON(statusCode, gin.H{ - "description": fmt.Sprintf("%s %s", mjErr.Description, mjErr.Result), + "description": operation_setting.OverrideUpstreamMessage(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))) } } @@ -616,6 +626,10 @@ func respondTaskError(c *gin.Context, taskErr *taskdto.TaskError) { if taskErr.StatusCode == http.StatusTooManyRequests { taskErr.Message = "当前分组上游负载已饱和,请稍后再试" } + // LocalError 为 false 表示文案来自上游任务平台,需按错误信息覆写设置处理 + if !taskErr.LocalError { + taskErr.Message = operation_setting.OverrideUpstreamMessage(taskErr.Message) + } c.JSON(taskErr.StatusCode, taskErr) } diff --git a/model/option.go b/model/option.go index 503f5102d71f..f0c57252e99b 100644 --- a/model/option.go +++ b/model/option.go @@ -174,6 +174,8 @@ func InitOptionMap() { common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString() common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength) common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString() + common.OptionMap["ErrorOverrideEnabled"] = strconv.FormatBool(operation_setting.ErrorOverrideEnabled) + common.OptionMap["ErrorOverrideKeywords"] = operation_setting.ErrorOverrideKeywordsToString() common.OptionMap["AutomaticDisableStatusCodes"] = operation_setting.AutomaticDisableStatusCodesToString() common.OptionMap["AutomaticRetryStatusCodes"] = operation_setting.AutomaticRetryStatusCodesToString() common.OptionMap["ExposeRatioEnabled"] = strconv.FormatBool(ratio_setting.IsExposeRatioEnabled()) @@ -372,6 +374,8 @@ func updateOptionMap(key string, value string) (err error) { operation_setting.DemoSiteEnabled = boolValue case "SelfUseModeEnabled": operation_setting.SelfUseModeEnabled = boolValue + case "ErrorOverrideEnabled": + operation_setting.ErrorOverrideEnabled = boolValue case "CheckSensitiveOnPromptEnabled": setting.CheckSensitiveOnPromptEnabled = boolValue case "ModelRequestRateLimitEnabled": @@ -590,6 +594,8 @@ func updateOptionMap(key string, value string) (err error) { setting.SensitiveWordsFromString(value) case "AutomaticDisableKeywords": operation_setting.AutomaticDisableKeywordsFromString(value) + case "ErrorOverrideKeywords": + operation_setting.ErrorOverrideKeywordsFromString(value) case "AutomaticDisableStatusCodes": err = operation_setting.AutomaticDisableStatusCodesFromString(value) case "AutomaticRetryStatusCodes": diff --git a/relay/channel/ali/image.go b/relay/channel/ali/image.go index 6913fa346aa6..cf46725e487e 100644 --- a/relay/channel/ali/image.go +++ b/relay/channel/ali/image.go @@ -317,7 +317,7 @@ func aliImageHandler(a *Adaptor, c *gin.Context, resp *http.Response, info *rela return types.NewError(err, types.ErrorCodeBadResponse), nil } if aliResponse.Output.TaskStatus != "SUCCEEDED" { - return types.WithOpenAIError(types.OpenAIError{ + return types.WithUpstreamOpenAIError(types.OpenAIError{ Message: aliResponse.Output.Message, Type: "ali_error", Param: "", diff --git a/relay/channel/ali/rerank.go b/relay/channel/ali/rerank.go index ac2afbd3d3db..7bc3e358a2f6 100644 --- a/relay/channel/ali/rerank.go +++ b/relay/channel/ali/rerank.go @@ -46,7 +46,7 @@ func RerankHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayI } if aliResponse.Code != "" { - return types.WithOpenAIError(types.OpenAIError{ + return types.WithUpstreamOpenAIError(types.OpenAIError{ Message: aliResponse.Message, Type: aliResponse.Code, Param: aliResponse.RequestId, diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 2f424b32abdd..b3615ab70b33 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -91,7 +91,7 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud return types.NewError(err, types.ErrorCodeBadResponseBody) } if claudeError := claudeResponse.GetClaudeError(); claudeError != nil && claudeError.Type != "" { - return types.WithClaudeError(*claudeError, http.StatusInternalServerError) + return types.WithUpstreamClaudeError(*claudeError, http.StatusInternalServerError) } if claudeResponse.StopReason != "" { maybeMarkClaudeRefusal(c, claudeResponse.StopReason) @@ -221,7 +221,7 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud return types.NewError(err, types.ErrorCodeBadResponseBody) } if claudeError := claudeResponse.GetClaudeError(); claudeError != nil && claudeError.Type != "" { - return types.WithClaudeError(*claudeError, http.StatusInternalServerError) + return types.WithUpstreamClaudeError(*claudeError, http.StatusInternalServerError) } maybeMarkClaudeRefusal(c, claudeResponse.StopReason) if claudeInfo.Usage == nil { diff --git a/relay/channel/jimeng/image.go b/relay/channel/jimeng/image.go index 888531bdd99d..689e5ed1be6d 100644 --- a/relay/channel/jimeng/image.go +++ b/relay/channel/jimeng/image.go @@ -64,7 +64,7 @@ func jimengImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.R // Check if the response indicates an error if jimengResponse.Code != 10000 { - return nil, types.WithOpenAIError(types.OpenAIError{ + return nil, types.WithUpstreamOpenAIError(types.OpenAIError{ Message: jimengResponse.Message, Type: "jimeng_error", Param: "", diff --git a/relay/channel/minimax/image.go b/relay/channel/minimax/image.go index c86cd47dba72..c7349ae28e9a 100644 --- a/relay/channel/minimax/image.go +++ b/relay/channel/minimax/image.go @@ -187,7 +187,7 @@ func miniMaxImageHandler(c *gin.Context, resp *http.Response, info *relaycommon. return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } if minimaxResponse.BaseResp.StatusCode != 0 { - return nil, types.WithOpenAIError(types.OpenAIError{ + return nil, types.WithUpstreamOpenAIError(types.OpenAIError{ Message: minimaxResponse.BaseResp.StatusMsg, Type: "minimax_image_error", Code: fmt.Sprintf("%d", minimaxResponse.BaseResp.StatusCode), diff --git a/relay/channel/openai/chat_via_responses.go b/relay/channel/openai/chat_via_responses.go index 25caeb5854bd..a6dfa071481b 100644 --- a/relay/channel/openai/chat_via_responses.go +++ b/relay/channel/openai/chat_via_responses.go @@ -38,7 +38,7 @@ func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp } if oaiError := responsesResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { - return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) + return nil, types.WithUpstreamOpenAIError(*oaiError, resp.StatusCode) } chatResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatOpenAI, &responsesResp) @@ -124,7 +124,7 @@ func OaiResponsesToChatBufferedStreamHandler(c *gin.Context, info *relaycommon.R case "response.failed", "response.error": if streamResp.Response != nil { if oaiErr := streamResp.Response.GetOpenAIError(); oaiErr != nil && oaiErr.Type != "" { - streamErr = types.WithOpenAIError(*oaiErr, http.StatusInternalServerError) + streamErr = types.WithUpstreamOpenAIError(*oaiErr, http.StatusInternalServerError) break } } @@ -283,7 +283,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo if streamResp.Type == "response.error" || streamResp.Type == "response.failed" { if streamResp.Response != nil { if oaiErr := streamResp.Response.GetOpenAIError(); oaiErr != nil && oaiErr.Type != "" { - streamErr = types.WithOpenAIError(*oaiErr, http.StatusInternalServerError) + streamErr = types.WithUpstreamOpenAIError(*oaiErr, http.StatusInternalServerError) sr.Stop(streamErr) return } diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index 9a0619eb27f5..3f9ceaff70d1 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -250,7 +250,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo } if oaiError := simpleResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { - return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) + return nil, types.WithUpstreamOpenAIError(*oaiError, resp.StatusCode) } for _, choice := range simpleResponse.Choices { diff --git a/relay/channel/openai/relay_image.go b/relay/channel/openai/relay_image.go index 1e6be0dd4cbe..60682cc8cbaf 100644 --- a/relay/channel/openai/relay_image.go +++ b/relay/channel/openai/relay_image.go @@ -46,7 +46,7 @@ func OpenaiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http. } if oaiError := usageResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { - return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) + return nil, types.WithUpstreamOpenAIError(*oaiError, resp.StatusCode) } updateOpenAIImageCount(info, gjson.GetBytes(responseBody, "data.#").Int()) @@ -247,7 +247,7 @@ func openaiImageJSONAsStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } if oaiError := usageResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { - return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) + return nil, types.WithUpstreamOpenAIError(*oaiError, resp.StatusCode) } normalizeOpenAIUsage(&usageResp.Usage) applyUsagePostProcessing(info, &usageResp.Usage, responseBody) diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go index ceca1af3b381..3fa6f5f7d6a9 100644 --- a/relay/channel/openai/relay_responses.go +++ b/relay/channel/openai/relay_responses.go @@ -31,7 +31,7 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } if oaiError := responsesResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { - return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) + return nil, types.WithUpstreamOpenAIError(*oaiError, resp.StatusCode) } // 写入新的 response body diff --git a/relay/channel/openai/relay_responses_compact.go b/relay/channel/openai/relay_responses_compact.go index ff30d36e417b..4bbceb11a514 100644 --- a/relay/channel/openai/relay_responses_compact.go +++ b/relay/channel/openai/relay_responses_compact.go @@ -25,7 +25,7 @@ func OaiResponsesCompactionHandler(c *gin.Context, resp *http.Response) (*dto.Us return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } if oaiError := compactResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { - return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) + return nil, types.WithUpstreamOpenAIError(*oaiError, resp.StatusCode) } service.IOCopyBytesGracefully(c, resp, responseBody) diff --git a/relay/channel/openai/responses_via_chat.go b/relay/channel/openai/responses_via_chat.go index 53b9d33cbc0e..47880d0f2cea 100644 --- a/relay/channel/openai/responses_via_chat.go +++ b/relay/channel/openai/responses_via_chat.go @@ -32,7 +32,7 @@ func OaiChatToResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } if oaiError := chatResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { - return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) + return nil, types.WithUpstreamOpenAIError(*oaiError, resp.StatusCode) } if responseID := helper.GetResponseID(c); responseID != "" { @@ -97,7 +97,7 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo var errorResp dto.OpenAITextResponse if err := common.UnmarshalJsonStr(data, &errorResp); err == nil { if oaiError := errorResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { - streamErr = types.WithOpenAIError(*oaiError, resp.StatusCode) + streamErr = types.WithUpstreamOpenAIError(*oaiError, resp.StatusCode) sr.Stop(streamErr) return } diff --git a/relay/channel/palm/relay-palm.go b/relay/channel/palm/relay-palm.go index 89ac14c7d010..fc8a24dc0f10 100644 --- a/relay/channel/palm/relay-palm.go +++ b/relay/channel/palm/relay-palm.go @@ -113,7 +113,7 @@ func palmHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respons return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } if palmResponse.Error.Code != 0 || len(palmResponse.Candidates) == 0 { - return nil, types.WithOpenAIError(types.OpenAIError{ + return nil, types.WithUpstreamOpenAIError(types.OpenAIError{ Message: palmResponse.Error.Message, Type: palmResponse.Error.Status, Param: "", diff --git a/relay/channel/tencent/relay-tencent.go b/relay/channel/tencent/relay-tencent.go index 611c186defa7..34e39d1fefaa 100644 --- a/relay/channel/tencent/relay-tencent.go +++ b/relay/channel/tencent/relay-tencent.go @@ -145,7 +145,7 @@ func tencentHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Resp return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } if tencentSb.Response.Error.Code != 0 { - return nil, types.WithOpenAIError(types.OpenAIError{ + return nil, types.WithUpstreamOpenAIError(types.OpenAIError{ Message: tencentSb.Response.Error.Message, Code: tencentSb.Response.Error.Code, }, resp.StatusCode) diff --git a/relay/channel/zhipu/relay-zhipu.go b/relay/channel/zhipu/relay-zhipu.go index 0c280e2b705d..fae2f2f2332a 100644 --- a/relay/channel/zhipu/relay-zhipu.go +++ b/relay/channel/zhipu/relay-zhipu.go @@ -234,7 +234,7 @@ func zhipuHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respon return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } if !zhipuResponse.Success { - return nil, types.WithOpenAIError(types.OpenAIError{ + return nil, types.WithUpstreamOpenAIError(types.OpenAIError{ Message: zhipuResponse.Msg, Code: zhipuResponse.Code, }, resp.StatusCode) diff --git a/relay/channel/zhipu_4v/image.go b/relay/channel/zhipu_4v/image.go index cdb35a82c8ad..bd97804c5dfd 100644 --- a/relay/channel/zhipu_4v/image.go +++ b/relay/channel/zhipu_4v/image.go @@ -67,7 +67,7 @@ func zhipu4vImageHandler(c *gin.Context, resp *http.Response, info *relaycommon. } if zhipuResp.Error != nil && zhipuResp.Error.Message != "" { - return nil, types.WithOpenAIError(types.OpenAIError{ + return nil, types.WithUpstreamOpenAIError(types.OpenAIError{ Message: zhipuResp.Error.Message, Type: "zhipu_image_error", Code: zhipuResp.Error.Code, diff --git a/relaykit/types/error.go b/relaykit/types/error.go index 387fdad76948..b26a04efa085 100644 --- a/relaykit/types/error.go +++ b/relaykit/types/error.go @@ -92,6 +92,7 @@ type NewAPIError struct { RelayError any skipRetry bool recordErrorLog *bool + fromUpstream bool errorType ErrorType errorCode ErrorCode StatusCode int @@ -177,6 +178,39 @@ func (e *NewAPIError) SetMessage(message string) { e.Err = errors.New(message) } +// MarkUpstreamOrigin records that this error's message text came from an upstream +// provider response rather than being produced locally by the gateway. Callers that +// decode an upstream error body must set this so the message can be treated as +// upstream-controlled text downstream. +func (e *NewAPIError) MarkUpstreamOrigin() { + if e == nil { + return + } + e.fromUpstream = true +} + +// ReplaceMessage rewrites the user-facing message on every representation of the +// error while preserving StatusCode, type and code. Unlike SetMessage it also +// updates RelayError, because ToOpenAIError/ToClaudeError return the provider +// payload verbatim for upstream errors and never read Err. Upstream metadata is +// dropped because it carries the raw provider error body. +func (e *NewAPIError) ReplaceMessage(message string) { + if e == nil { + return + } + e.Err = errors.New(message) + e.Metadata = nil + switch relayError := e.RelayError.(type) { + case OpenAIError: + relayError.Message = message + relayError.Metadata = nil + e.RelayError = relayError + case ClaudeError: + relayError.Message = message + e.RelayError = relayError + } +} + func (e *NewAPIError) ToOpenAIError() OpenAIError { var result OpenAIError switch e.errorType { @@ -363,6 +397,23 @@ func WithClaudeError(claudeError ClaudeError, statusCode int, ops ...NewAPIError return e } +// WithUpstreamOpenAIError builds an error from an OpenAI-shaped payload decoded out +// of an upstream provider response, recording that the message text is upstream +// controlled. Channel adaptors that surface a provider error must use this instead of +// WithOpenAIError, which is also used for locally constructed errors. +func WithUpstreamOpenAIError(openAIError OpenAIError, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError { + e := WithOpenAIError(openAIError, statusCode, ops...) + e.MarkUpstreamOrigin() + return e +} + +// WithUpstreamClaudeError is the Claude-shaped counterpart of WithUpstreamOpenAIError. +func WithUpstreamClaudeError(claudeError ClaudeError, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError { + e := WithClaudeError(claudeError, statusCode, ops...) + e.MarkUpstreamOrigin() + return e +} + func IsChannelError(err *NewAPIError) bool { if err == nil { return false @@ -378,6 +429,16 @@ func IsSkipRetryError(err *NewAPIError) bool { return err.skipRetry } +// IsFromUpstreamError reports whether the error message originated from an upstream +// provider response. Errors produced by the gateway itself return false. +func IsFromUpstreamError(err *NewAPIError) bool { + if err == nil { + return false + } + + return err.fromUpstream +} + func ErrOptionWithSkipRetry() NewAPIErrorOptions { return func(e *NewAPIError) { e.skipRetry = true diff --git a/relaykit/types/error_test.go b/relaykit/types/error_test.go new file mode 100644 index 000000000000..af4a0c05c247 --- /dev/null +++ b/relaykit/types/error_test.go @@ -0,0 +1,140 @@ +package types + +import ( + "encoding/json" + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMarkUpstreamOrigin_NilSafe(t *testing.T) { + var e *NewAPIError + require.NotPanics(t, func() { e.MarkUpstreamOrigin() }) + assert.False(t, IsFromUpstreamError(e)) +} + +func TestReplaceMessage_NilSafe(t *testing.T) { + var e *NewAPIError + require.NotPanics(t, func() { e.ReplaceMessage("x") }) +} + +func TestIsFromUpstreamError_Nil(t *testing.T) { + assert.False(t, IsFromUpstreamError(nil)) +} + +func TestWithUpstreamOpenAIError_MarksOrigin(t *testing.T) { + e := WithUpstreamOpenAIError(OpenAIError{ + Message: "Your credit balance is too low, please top up", + Type: "upstream_error", + Code: "credits_exhausted", + }, http.StatusPaymentRequired) + + require.True(t, IsFromUpstreamError(e)) + assert.Equal(t, http.StatusPaymentRequired, e.StatusCode) + assert.Equal(t, ErrorTypeOpenAIError, e.GetErrorType()) + assert.Equal(t, ErrorCode("credits_exhausted"), e.GetErrorCode()) +} + +func TestWithOpenAIError_DoesNotMarkOrigin(t *testing.T) { + e := WithOpenAIError(OpenAIError{ + Message: "some local message", + Type: "upstream_error", + }, http.StatusBadRequest) + assert.False(t, IsFromUpstreamError(e)) +} + +func TestWithUpstreamClaudeError_MarksOrigin(t *testing.T) { + e := WithUpstreamClaudeError(ClaudeError{ + Type: "error", + Message: "You exceeded your current quota", + }, http.StatusTooManyRequests) + + require.True(t, IsFromUpstreamError(e)) + assert.Equal(t, ErrorTypeClaudeError, e.GetErrorType()) + assert.Equal(t, http.StatusTooManyRequests, e.StatusCode) +} + +func TestWithClaudeError_DoesNotMarkOrigin(t *testing.T) { + e := WithClaudeError(ClaudeError{ + Type: "error", + Message: "local", + }, http.StatusBadRequest) + assert.False(t, IsFromUpstreamError(e)) +} + +// ReplaceMessage must rewrite every user-facing representation while preserving +// StatusCode, type, code, and clearing upstream metadata. +func TestReplaceMessage_OpenAIError(t *testing.T) { + metadata := json.RawMessage(`{"raw":"upstream-secret"}`) + e := WithUpstreamOpenAIError(OpenAIError{ + Message: "No available channel for model gpt-4o under group default", + Type: "upstream_error", + Code: "no_available_channel", + Metadata: metadata, + }, http.StatusServiceUnavailable) + e.Metadata = metadata + + e.ReplaceMessage("Service Unavailable") + + // Err mirrors the new message. + assert.Equal(t, "Service Unavailable", e.Error()) + + // ToOpenAIError returns the RelayError payload verbatim, so it must reflect the new message. + openAI := e.ToOpenAIError() + assert.Equal(t, "Service Unavailable", openAI.Message) + assert.Nil(t, openAI.Metadata) + + // Preserved fields. + assert.Equal(t, http.StatusServiceUnavailable, e.StatusCode) + assert.Equal(t, ErrorTypeOpenAIError, e.GetErrorType()) + assert.Equal(t, ErrorCode("no_available_channel"), e.GetErrorCode()) + assert.Nil(t, e.Metadata) +} + +func TestReplaceMessage_ClaudeError(t *testing.T) { + e := WithUpstreamClaudeError(ClaudeError{ + Type: "error", + Message: "You exceeded your current quota", + }, http.StatusTooManyRequests) + + e.ReplaceMessage("Service Unavailable") + + assert.Equal(t, "Service Unavailable", e.Error()) + claude := e.ToClaudeError() + assert.Equal(t, "Service Unavailable", claude.Message) + assert.Equal(t, "error", claude.Type) + assert.Equal(t, http.StatusTooManyRequests, e.StatusCode) + assert.Equal(t, ErrorTypeClaudeError, e.GetErrorType()) +} + +func TestReplaceMessage_DefaultErrorType(t *testing.T) { + e := NewErrorWithStatusCode(errors.New("upstream quota exhausted"), + ErrorCodeBadResponseStatusCode, http.StatusBadGateway) + e.MarkUpstreamOrigin() + require.True(t, IsFromUpstreamError(e)) + + e.ReplaceMessage("Service Unavailable") + + assert.Equal(t, "Service Unavailable", e.Error()) + openAI := e.ToOpenAIError() + assert.Equal(t, "Service Unavailable", openAI.Message) + assert.Equal(t, http.StatusBadGateway, e.StatusCode) + assert.Equal(t, ErrorTypeNewAPIError, e.GetErrorType()) +} + +// NewError preserves a deeply wrapped *NewAPIError, so the upstream marker must +// survive wrapping carried by errors.As. +func TestUpstreamMarker_SurvivesNewErrorWrap(t *testing.T) { + upstream := WithUpstreamOpenAIError(OpenAIError{ + Message: "Please top up your credits", + Type: "upstream_error", + }, http.StatusPaymentRequired) + + wrapped := NewError(upstream, ErrorCodeBadResponseStatusCode) + + require.True(t, IsFromUpstreamError(wrapped)) + assert.Equal(t, http.StatusPaymentRequired, wrapped.StatusCode) +} diff --git a/service/error.go b/service/error.go index f14f1bbab660..96ffa8fd0aca 100644 --- a/service/error.go +++ b/service/error.go @@ -86,6 +86,10 @@ func ClaudeErrorWrapperLocal(err error, code string, statusCode int) *dto.Claude func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFail bool) (newApiErr *types.NewAPIError) { newApiErr = types.InitOpenAIError(types.ErrorCodeBadResponseStatusCode, resp.StatusCode) + // 该函数的错误文案全部来自上游响应,标记后可被错误信息覆写识别 + defer func() { + newApiErr.MarkUpstreamOrigin() + }() responseBody, err := io.ReadAll(resp.Body) if err != nil { diff --git a/service/violation_fee.go b/service/violation_fee.go index f51533629d76..26c34bfb66d5 100644 --- a/service/violation_fee.go +++ b/service/violation_fee.go @@ -45,7 +45,12 @@ func WrapAsViolationFeeGrokCSAM(err *types.NewAPIError) *types.NewAPIError { oai := err.ToOpenAIError() oai.Type = string(types.ErrorCodeViolationFeeGrokCSAM) oai.Code = string(types.ErrorCodeViolationFeeGrokCSAM) - return types.WithOpenAIError(oai, err.StatusCode, types.ErrOptionWithSkipRetry()) + wrapped := types.WithOpenAIError(oai, err.StatusCode, types.ErrOptionWithSkipRetry()) + // 重新包装不应改变错误来源,否则上游错误会被误判为本站错误 + if types.IsFromUpstreamError(err) { + wrapped.MarkUpstreamOrigin() + } + return wrapped } // NormalizeViolationFeeError ensures: @@ -64,7 +69,11 @@ func NormalizeViolationFeeError(err *types.NewAPIError) *types.NewAPIError { if IsViolationFeeCode(err.GetErrorCode()) { oai := err.ToOpenAIError() - return types.WithOpenAIError(oai, err.StatusCode, types.ErrOptionWithSkipRetry()) + wrapped := types.WithOpenAIError(oai, err.StatusCode, types.ErrOptionWithSkipRetry()) + if types.IsFromUpstreamError(err) { + wrapped.MarkUpstreamOrigin() + } + return wrapped } return err diff --git a/setting/operation_setting/error_override_setting.go b/setting/operation_setting/error_override_setting.go new file mode 100644 index 000000000000..7f4adffc0573 --- /dev/null +++ b/setting/operation_setting/error_override_setting.go @@ -0,0 +1,69 @@ +package operation_setting + +import ( + "strings" + + "github.com/QuantumNous/new-api/relaykit/types" +) + +// ErrorOverrideMessage 覆写后统一返回给客户端的错误文案 +const ErrorOverrideMessage = "Service Unavailable" + +// 错误信息覆写:命中关键词的上游错误统一替换文案,避免向客户端暴露上游账务状态与转售链路。 +// 仅作用于上游传递到本站的错误;本站自身产生的错误(用户额度不足、无可用渠道等)不受影响。 +var ErrorOverrideEnabled = false + +// 关键词统一以小写存储,匹配时对错误文案做小写化后比对 +var ErrorOverrideKeywords = []string{"no available", "quota", "credits", "top-up"} + +func ErrorOverrideKeywordsToString() string { + return strings.Join(ErrorOverrideKeywords, "\n") +} + +func ErrorOverrideKeywordsFromString(s string) { + ErrorOverrideKeywords = []string{} + ak := strings.Split(s, "\n") + for _, k := range ak { + k = strings.TrimSpace(k) + k = strings.ToLower(k) + if k != "" { + ErrorOverrideKeywords = append(ErrorOverrideKeywords, k) + } + } +} + +// OverrideUpstreamError 在错误写给客户端前覆写其文案,返回是否已覆写。必须在错误日志记录 +// 之后调用,后台日志与渠道自动禁用判定始终使用原始上游文案。状态码、error.type、error.code +// 保持不变。 +func OverrideUpstreamError(err *types.NewAPIError) bool { + if !types.IsFromUpstreamError(err) { + return false + } + if !shouldOverrideErrorMessage(err.Error()) { + return false + } + err.ReplaceMessage(ErrorOverrideMessage) + return true +} + +// OverrideUpstreamMessage 供不经过 NewAPIError 的链路使用(任务平台、Midjourney)。 +// 调用方需自行确认文案来自上游。 +func OverrideUpstreamMessage(message string) string { + if !shouldOverrideErrorMessage(message) { + return message + } + return ErrorOverrideMessage +} + +func shouldOverrideErrorMessage(message string) bool { + if !ErrorOverrideEnabled || message == "" { + return false + } + lowerMessage := strings.ToLower(message) + for _, keyword := range ErrorOverrideKeywords { + if strings.Contains(lowerMessage, keyword) { + return true + } + } + return false +} diff --git a/setting/operation_setting/error_override_setting_test.go b/setting/operation_setting/error_override_setting_test.go new file mode 100644 index 000000000000..8c50cad1d453 --- /dev/null +++ b/setting/operation_setting/error_override_setting_test.go @@ -0,0 +1,213 @@ +package operation_setting + +import ( + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/QuantumNous/new-api/relaykit/types" +) + +// restoreOverrideState resets the package-level override settings to the defaults used +// by each test and reinstalls them after the test runs, so tests stay isolated. +func restoreOverrideState(t *testing.T) { + t.Helper() + origEnabled := ErrorOverrideEnabled + origKeywords := ErrorOverrideKeywords + t.Cleanup(func() { + ErrorOverrideEnabled = origEnabled + ErrorOverrideKeywords = origKeywords + }) + ErrorOverrideEnabled = true + ErrorOverrideKeywords = []string{"no available", "quota", "credits", "top-up"} +} + +func TestOverrideUpstreamMessage_DisabledKeepsMessage(t *testing.T) { + orig := ErrorOverrideEnabled + t.Cleanup(func() { ErrorOverrideEnabled = orig }) + ErrorOverrideEnabled = false + + msg := "Your credit balance is too low, please top up" + assert.Equal(t, msg, OverrideUpstreamMessage(msg)) + // Empty message is never overridden even when enabled. + assert.Equal(t, "", OverrideUpstreamMessage("")) +} + +func TestOverrideUpstreamMessage_MatchesKeywords(t *testing.T) { + restoreOverrideState(t) + + cases := []struct { + name string + message string + want string + }{ + {"credits", "insufficient credits", ErrorOverrideMessage}, + {"top-up", "Please top-up your account", ErrorOverrideMessage}, + {"quota", "You exceeded your current quota", ErrorOverrideMessage}, + {"no available", "No available channel for model gpt-4o under group default", ErrorOverrideMessage}, + {"case-insensitive credits", "CREDITS exhausted", ErrorOverrideMessage}, + {"case-insensitive quota", "QUOTA exceeded", ErrorOverrideMessage}, + {"no match", "Internal Server Error", "Internal Server Error"}, + {"empty", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, OverrideUpstreamMessage(tc.message)) + }) + } +} + +func TestOverrideUpstreamError_LocalErrorsNotOverridden(t *testing.T) { + restoreOverrideState(t) + + // 本地错误:用户额度不足(由本站产生,未标记上游来源)。 + localQuota := types.NewErrorWithStatusCode( + errors.New("用户额度不足, 剩余额度: $0.00"), + types.ErrorCodeInsufficientUserQuota, http.StatusPaymentRequired) + assert.False(t, OverrideUpstreamError(localQuota)) + assert.Equal(t, "用户额度不足, 剩余额度: $0.00", localQuota.Error()) + + // 本地错误:distributor 的「无可用渠道」文案虽然命中 "no available" 关键词, + // 但因未标记上游来源,不应被覆写。 + localNoChannel := types.NewErrorWithStatusCode( + errors.New("No available channel for model gpt-4o under group default (distributor)"), + types.ErrorCodeBadResponseStatusCode, http.StatusServiceUnavailable) + assert.False(t, OverrideUpstreamError(localNoChannel)) + assert.Contains(t, localNoChannel.Error(), "No available channel") +} + +func TestOverrideUpstreamError_UpstreamErrorsOverridden(t *testing.T) { + restoreOverrideState(t) + + cases := []struct { + name string + build func() *types.NewAPIError + message string + }{ + { + name: "openai credits", + build: func() *types.NewAPIError { + return types.WithUpstreamOpenAIError(types.OpenAIError{ + Message: "insufficient credits balance, please top up", + Type: "upstream_error", + Code: "credits_exhausted", + }, http.StatusPaymentRequired) + }, + message: "insufficient credits balance, please top up", + }, + { + name: "claude quota", + build: func() *types.NewAPIError { + return types.WithUpstreamClaudeError(types.ClaudeError{ + Type: "error", + Message: "You exceeded your current quota", + }, http.StatusTooManyRequests) + }, + message: "You exceeded your current quota", + }, + { + name: "openai no available", + build: func() *types.NewAPIError { + return types.WithUpstreamOpenAIError(types.OpenAIError{ + Message: "No available channel for model gpt-4o under group default", + Type: "upstream_error", + Code: "no_available_channel", + }, http.StatusServiceUnavailable) + }, + message: "No available channel for model gpt-4o under group default", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.build() + require.True(t, types.IsFromUpstreamError(err)) + require.True(t, OverrideUpstreamError(err)) + + // Every user-facing representation is rewritten. + assert.Equal(t, ErrorOverrideMessage, err.Error()) + openAI := err.ToOpenAIError() + assert.Equal(t, ErrorOverrideMessage, openAI.Message) + assert.Nil(t, openAI.Metadata) + }) + } +} + +func TestOverrideUpstreamError_PreservesStatusCodeTypeCode(t *testing.T) { + restoreOverrideState(t) + + err := types.WithUpstreamOpenAIError(types.OpenAIError{ + Message: "Please top up your credits", + Type: "upstream_error", + Code: "credits_exhausted", + }, http.StatusPaymentRequired) + originalCode := err.GetErrorCode() + originalType := err.GetErrorType() + + require.True(t, OverrideUpstreamError(err)) + + assert.Equal(t, http.StatusPaymentRequired, err.StatusCode) + assert.Equal(t, originalType, err.GetErrorType()) + assert.Equal(t, originalCode, err.GetErrorCode()) +} + +func TestOverrideUpstreamError_DisabledDoesNothing(t *testing.T) { + orig := ErrorOverrideEnabled + t.Cleanup(func() { ErrorOverrideEnabled = orig }) + ErrorOverrideEnabled = false + + err := types.WithUpstreamOpenAIError(types.OpenAIError{ + Message: "quota exhausted", + Type: "upstream_error", + }, http.StatusTooManyRequests) + assert.False(t, OverrideUpstreamError(err)) + assert.Equal(t, "quota exhausted", err.Error()) +} + +func TestOverrideUpstreamError_NilSafe(t *testing.T) { + restoreOverrideState(t) + require.NotPanics(t, func() { OverrideUpstreamError(nil) }) +} + +func TestOverrideUpstreamMessage_NoKeywords(t *testing.T) { + origEnabled := ErrorOverrideEnabled + origKeywords := ErrorOverrideKeywords + t.Cleanup(func() { + ErrorOverrideEnabled = origEnabled + ErrorOverrideKeywords = origKeywords + }) + ErrorOverrideEnabled = true + ErrorOverrideKeywords = nil + + msg := "Your credits are too low" + assert.Equal(t, msg, OverrideUpstreamMessage(msg)) +} + +func TestErrorOverrideKeywordsFromString_ParsesAndNormalizes(t *testing.T) { + orig := ErrorOverrideKeywords + t.Cleanup(func() { ErrorOverrideKeywords = orig }) + + ErrorOverrideKeywordsFromString("Credits\n Quota \n\n\ntop-up\n ") + + require.Equal(t, []string{"credits", "quota", "top-up"}, ErrorOverrideKeywords) +} + +func TestErrorOverrideKeywordsFromString_EmptyStringClears(t *testing.T) { + orig := ErrorOverrideKeywords + t.Cleanup(func() { ErrorOverrideKeywords = orig }) + + ErrorOverrideKeywordsFromString("") + require.Empty(t, ErrorOverrideKeywords) +} + +func TestErrorOverrideKeywordsToString_RoundTrip(t *testing.T) { + orig := ErrorOverrideKeywords + t.Cleanup(func() { ErrorOverrideKeywords = orig }) + + ErrorOverrideKeywords = []string{"no available", "quota", "credits", "top-up"} + s := ErrorOverrideKeywordsToString() + ErrorOverrideKeywordsFromString(s) + require.Equal(t, []string{"no available", "quota", "credits", "top-up"}, ErrorOverrideKeywords) +} diff --git a/web/src/features/system-settings/general/system-behavior-section.tsx b/web/src/features/system-settings/general/system-behavior-section.tsx index 5a44c89b7b72..ef0a4ec6db9c 100644 --- a/web/src/features/system-settings/general/system-behavior-section.tsx +++ b/web/src/features/system-settings/general/system-behavior-section.tsx @@ -26,9 +26,12 @@ import { FormControl, FormDescription, FormField, + FormItem, FormLabel, + FormMessage, } from '@/components/ui/form' import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' import { SettingsForm, @@ -44,6 +47,8 @@ const behaviorSchema = z.object({ DefaultCollapseSidebar: z.boolean(), DemoSiteEnabled: z.boolean(), SelfUseModeEnabled: z.boolean(), + ErrorOverrideEnabled: z.boolean(), + ErrorOverrideKeywords: z.string(), }) type BehaviorFormValues = z.infer @@ -145,6 +150,54 @@ export function SystemBehaviorSection({ )} /> + + ( + + + {t('Error Message Override')} + + {t( + 'Replace upstream error messages that match a keyword with a generic message, so upstream billing state is not exposed. Errors produced by this site are never replaced.' + )} + + + + + + + )} + /> + + {form.watch('ErrorOverrideEnabled') && ( + ( + + {t('Override keywords')} + +