feat: add configurable upstream error message override - #9
Merged
Conversation
当上游供应商返回的错误信息包含 "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 行为、本地/上游错误区分、 关键词匹配与规范化。
Mira PR Walkthrough🔍 Reviewing this PR… |
RelayErrorHandler previously deferred MarkUpstreamOrigin at function entry, so an io.ReadAll failure (a local infrastructure error whose message is not sourced from the upstream body) was mislabeled as upstream-origin and could be overridden by the error message override. Move the defer past the io.ReadAll error check so only paths whose message text is taken from the response body are marked upstream. Add a regression test covering the I/O failure path.
Collaborator
Author
修复:
|
…pact suffix refs Merge origin/main into feat/error-message-override. Resolved conflicts in 5 files by keeping both feature additions (error override settings and the channel failover / model alias settings) side by side: - model/option.go (ErrorOverrideEnabled + ChannelFailoverEnabled cases) - system-behavior-section.tsx (both FormField blocks) - operations/index.tsx, section-registry.tsx, types.ts (both fields) While validating the merge, found that main's refactor bb234ff (QuantumNous#6770) removed ratio_setting.CompactModelSuffix / WithCompactModelSuffix but the failover/alias code merged in #8 still referenced them: - setting/model_setting/model_alias.go: ResolveModelAlias stripped and re-added the compact suffix around alias resolution. Now resolves the requested name verbatim, matching QuantumNous#6770's removal of compact-suffix special-casing. Updated the compact test case to assert verbatim aliasing of a compact-suffixed name instead. - middleware/distributor.go: dropped the strings.TrimSuffix(resolvedModel, ratio_setting.CompactModelSuffix) call so the rewritten request body model matches the resolved alias exactly. Also fixed two pre-existing build/vet breakages from the #8 merge that surfaced once compact-suffix references compiled: - controller/relay.go: shouldRetryTaskRelay gained a failoverEnabled bool parameter on the call site (line 637) but the definition was not updated; added the parameter to the signature and removed the stale reference to the unqualified failoverEnabled identifier. - model/channel_cache_failover_test.go: imported AdvancedCustomConfig from the root dto package, but it lives in relaykit/dto; switched the import alias accordingly. Verified: go build ./... + go vet ./... pass, relaykit builds standalone (GOWORK=off), and ./setting/operation_setting, ./service, ./model, ./setting/model_setting, relaykit/types tests all pass.
…toGroupBadge The three failing assertions expected two auto-group frames (AutoGroupBadge plus GroupRatioBadge), but AutoGroupBadge has been commented out in ApiKeyGroupCell since it was introduced in QuantumNous#6590 — the Cross-group StatusBadge occupies that slot instead. The test never matched the implementation. Corrected the expectations to the actual render output rather than re-enabling a badge that was deliberately disabled: - ratio present: 1 frame + 1 flow border (was 2 + 2) - reduced motion: 1 static frame, 0 flow borders (was 2 + 0) - ratio absent: GroupRatioBadge returns null, so 0 frames and 0 flow borders, with only the Cross-group badge left (was 1 + 1 asserting an 'Auto' text that the cell no longer renders on its own) Renamed the affected test titles to describe the single ring, and noted why only one frame exists so the next reader does not re-add the second. Verified: bun test passes 155/155 across 31 files, bun run typecheck clean.
任务链路原先用 `!taskErr.LocalError` 反推「来自上游」,方向是错的:本站自产错误 默认 LocalError == false,安全性完全依赖关键词没有碰巧命中。 - dto.TaskError 新增 FromUpstream 正向标记,与重试判定用的 LocalError 正交; respondTaskError 改为只在 FromUpstream 为 true 时覆写,并在覆写前记录原始文案 - TaskErrorFromAPIError 不再让预扣费错误落入覆写范围:订阅额度不足文案含 "quota", 原先开关打开后用户会拿到 Service Unavailable,看不到真实的额度原因 - 新增 service.TaskErrorWrapperUpstream,标记确实取自上游响应体的适配器错误 (relay_task fetch、ali、suno、jimeng、hailuo;kling 保留 LocalError 不重试并补标记) - ReplaceMessage 一并清空 OpenAIError.Param:该字段承载上游 request id (见 ali rerank 适配器),留着等于继续泄漏覆写想隐藏的上游链路 - RelayErrorHandler 去掉一揽子 defer 打标,改为按路径标记:响应体解析失败且不回显 body 时文案完全由本站生成(仅含状态码),不再误标为上游来源 其他:i18n 新键按字母序归位(en.json 是 sync 脚本的排序基准),前端默认关键词 改为小写以与后端存储/回显一致。 新增测试:respondTaskError 覆写门控(本地额度/无可用 key 不覆写、上游命中覆写、 429 文案保留)、RelayErrorHandler 按路径标记、ReplaceMessage 清空 Param。
Review of #9 surfaced four problems with the override's coverage. Midjourney: the override at the mjErr exit could only ever fire on this site's own errors. Every MidjourneyResponse reaching that branch is locally produced (param validation, quota, DB/IO); mj-proxy handlers io.Copy the upstream body straight to the client and return nil, so upstream text never passes through there. In practice it masked the local quota_not_enough from RelaySwapFace/RelayMidjourneySubmit, hiding from users the real reason their own balance was short. Removed it and documented where MJ upstream text actually exits. Error log: RecordErrorLog's Content is echoed back to the requesting user via /api/log/self, so it is an outward-facing exit like the HTTP body. With the override on, clients got Service Unavailable in the response and the raw upstream billing text in the log page. The user-visible content is now overridden too, with the original moved to admin_info.original_error, which formatUserLogs strips for non-admins. Added the non-mutating ShouldOverrideUpstreamError predicate so retry and channel-disable decisions keep seeing the untouched error. Task FailReason: async polling persists the upstream failure reason and users read it back through the task query endpoints. Masked at that read boundary for user-facing callers; admin views keep the original. Local reasons (sweepTimedOutTasks, FailTaskInfo) are keyword-free and unaffected. TaskError.Data is cleared when the message is overridden, matching ReplaceMessage clearing Metadata/Param. Verified: go build ./..., go vet, go test ./... all pass; relaykit builds and tests independently with GOWORK=off.
代码评审发现覆写只挡住了 relay 响应体与部分任务查询路径,同一份上游文案仍有
五个用户可见出口漏出,本次全部堵上:
- /v1/videos/{id}:ConvertToOpenAIVideo 直接用落库的上游原始数据构建响应体,
绕过 TaskModel2Dto。新增 overrideOpenAIVideoUpstreamError,用 gjson/sjson
在 JSON 层只替换 error.message —— sora 的 converter 整体透传上游对象,
结构体往返会丢字段。
- 任务链路的错误日志:processChannelError 收到的 NewAPIError 由 TaskError
现场构造,丢掉了 FromUpstream,日志页始终写上游原文。新增
service.APIErrorFromTaskError 承接标记,与 TaskErrorFromAPIError 成对。
- /api/mj/self:面向用户的 MJ 任务列表未覆写 fail_reason,与 /mj/task 查询
(coverMidjourneyTaskDto)不一致;管理员列表保留原文。
- 任务退款日志:RefundTaskQuota 的 other.reason 与任务 FailReason 是同一份
文案,且经 /api/log/self 回显。写库前覆写,原文改记 admin_info。
- 六个任务适配器把上游 body 拼进 unmarshal 失败的错误文案,却用
TaskErrorWrapper(本站来源),改为 TaskErrorWrapperUpstream。
另外两处一致性修正:
- Relay 的 defer 不再先 OverrideUpstreamError 再 ReplaceMessage 写两遍,
改为 ShouldOverrideUpstreamError + 一次 ReplaceMessage。
- processChannelError 覆写后的 logContent 与 MaskSensitiveErrorWithStatusCode
对齐,StatusCode 为 0 时不再写出 status_code=0。
补齐 zh-TW/ja/fr/ru/vi 五种语言的错误信息覆写文案并重跑 i18n:sync。
新增测试:overrideOpenAIVideoUpstreamError(掩码后其余上游字段逐字保留)、
APIErrorFromTaskError(标记与状态码透传、Error 为空时回退 Message)、
RefundTaskQuota 日志覆写与 admin_info 留档、MJ 用户列表覆写 / 管理员列表留原文。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Important
📝 变更描述 / Description
当上游供应商返回的错误信息包含
No available、quota、credits、top-up等关键词时,原始文案会直接透传给客户端,暴露本站的上游账户欠费状态与转售链路(例如上游本身就是另一个 new-api 站点,错误形如No available channel for model gpt-4o under group default)。本 PR 新增一个可在「运维 → 系统行为」(
/system-settings/operations/behavior)开关的「错误信息覆写」功能:命中关键词的上游错误统一返回Service Unavailable,HTTP 状态码与error.code/error.type保持原值。为什么这样改能生效:
relaykit/types的NewAPIError新增fromUpstream标记。RelayErrorHandler(唯一通用上游错误解析函数,被全部 relay 格式调用)按返回路径逐条标记——文案取自上游响应体的路径才打标;从上游 body 直接解析错误的渠道适配器改用新增的WithUpstreamOpenAIError/WithUpstreamClaudeError构造函数。ReplaceMessage同时改写Err与RelayError。因为ToOpenAIError()对openai_error类型直接返回RelayError(不读Err),仅用既有SetMessage不足以覆写给客户端的响应体;同时清空Metadata(OpenRouter 路径会把上游原始 JSON 塞进 metadata)与Param(ali rerank 等适配器会把上游 request id 塞进该字段)。logger.LogError之后执行,因此后台日志、DisableChannel关键词判定、NormalizeViolationFeeError的 CSAM 标记检测看到的始终是原始上游文案。上游/本地的判定方向(关键设计点):
区分上游与本地一律使用正向标记,而不是「非本地即上游」。原因是本站自产错误的默认状态就是「未标记」:若用反推,安全性会完全依赖覆写关键词没有碰巧命中本站文案,管理员一旦配置了偏宽的关键词就会静默掩盖本站自身的报错。
types.IsFromUpstreamError()读私有fromUpstream。dto.TaskError新增FromUpstream字段,与重试判定用的LocalError是两个正交维度(LocalError已被shouldRetryTaskRelay使用,不能复用)。只有确实把上游响应体文案塞进错误的位置才标记:relay_task的 fetch 失败、ali / suno / jimeng / hailuo 适配器的上游 API 错误;kling 保留LocalError(不重试)并补FromUpstream。覆盖的对外出口(每一个都要处理,否则等于没做):
同一份上游文案有多个出口,只改 HTTP 响应体是不够的:
Relay、Playground的 defer 出口。RecordErrorLog的Content会通过/api/log/self回显给发起请求的用户,和响应体一样是对外出口。覆写生效时同步覆写该字段,原文改记到other.admin_info.original_error(model.formatUserLogs会为普通用户剥离整个admin_info)。为此新增不修改错误对象的ShouldOverrideUpstreamError谓词,保证重试与渠道禁用判定仍然看到未被改动的原始错误。FailReason—— 轮询把上游失败原因落库(service/task_polling.go的task.FailReason = taskResult.Reason),用户随后通过任务查询接口读到它。在读取边界覆写:TaskModel2Dto新增maskUpstreamFailReason参数,面向用户的 relay 查询与GetUserTask传true,管理员列表GetAllTask传false(排障需要原文);MJ 的coverMidjourneyTaskDto同样处理。落库仍存原文。TaskError.Data—— 带json:"data"会返回客户端,覆写 Message 时一并清空,与ReplaceMessage清Metadata/Param保持一致。边界:
insufficient_user_quota)由本站产生,TaskErrorFromAPIError不打上游标记,文案不会被掩盖——用户始终能看到真实的额度原因。do_request_failed等)同样不打标记。error.type、error.code全部保持原值,仅替换message。Midjourney 不做覆写(结论与最初设想相反): 能走到
RelayMidjourney的mjErr分支的MidjourneyResponse全部是本站自产的(参数校验、额度不足、DB/IO 失败)。mj-proxy 各 handler 拿到上游响应后是把上游 body 原样io.Copy给客户端再return nil,上游错误文案根本不经过那个分支。因此在该处按关键词覆写只可能误伤本站错误——实测会把mjproxy_handler.go里RelaySwapFace/RelayMidjourneySubmit的本地quota_not_enough打成Service Unavailable,让用户看不到自己额度不足的真实原因。已移除该处覆写并在代码注释里写清原因。MJ 上游文案的真正出口是被代理的响应体本身(要改就得重写上游 JSON,破坏 mj-proxy 协议兼容性,不在本功能范围)与任务FailReason(已在读取边界处理)。已知残留(有意保留,非遗漏):
error.code/error.type按设计保持原值。当上游本身是另一个 new-api 站点时,error.code可能仍是no_available_channel之类的值,客户端据此仍可推断部分信息。本功能的目标是隐去可读文案中的账务细节,而非完全同质化错误响应;如需更严格的隐藏,应另开一个「同时归一化 code/type」的选项,避免破坏依赖error.code做分支的客户端。FailReason只能按关键词门控:Task表没有来源标记列,补一列要跨 SQLite/MySQL/PostgreSQL 三种数据库做迁移,代价与收益不匹配。本站自产的FailReason是「任务超时(%d分钟)」这类中文文案与upstream returned error这类固定串,都不含默认关键词,已由测试钉住;但管理员配置过宽的关键词时本站文案仍可能被误伤。顺带修复的编译问题(非本功能范围,但必须处理): 当前
main分支无法编译,有两处独立错误,本 PR 一并修掉:ratio_setting.CompactModelSuffix未定义(middleware/distributor.go、setting/model_setting/model_alias.go)。成因是两个提交的合并顺序:9bdac8a6(全局别名,2026-07-26)是照着 compact 后缀 API 写的,而bb234ff4(refactor(responses): remove compact model suffix handling,refactor(responses): remove compact model suffix handling QuantumNous/new-api#6770,2026-08-11)删掉了setting/ratio_setting/compact_suffix.go并清理了 10 个文件里的后缀处理,唯独漏掉了别名这条路径。本 PR 把清理补完:ResolveModelAlias不再「剥离 compact 后缀 → 解析别名 → 补回后缀」,别名按完整模型名整体匹配。没有实际行为变更 —— main 从未编译成功,后缀感知的别名解析实际上一天都没跑过。反过来单独补回CompactModelSuffix才是错的:那会让别名去拆解一个计费、模型映射、codex 模型发现和渠道测试都已不再认识的后缀。shouldRetryTaskRelay参数数量不匹配:controller/relay.go的声明是 4 参,调用方传 5 个,函数体又引用了failoverEnabled。补上该参数。另有一处测试与实现不符(非编译错误):
web/src/features/keys/components/api-key-group-cell.tsx已把AutoGroupBadge注释停用(该位置由 Cross-groupStatusBadge承担),但api-key-group-cell.test.tsx仍断言frames.length === 2。本 PR 对齐断言。🚀 变更类型 / Type of change
🔗 关联任务 / Related Issue
✅ 提交前检查项 / Checklist
📸 运行证明 / Proof of Work
后端验证:
go build ./...(根模块)通过go vet ./service/... ./controller/... ./relay/... ./setting/... ./dto/...通过cd relaykit && GOWORK=off go build ./... && GOWORK=off go vet ./types/... && GOWORK=off go test ./types/...全部通过(relaykit 独立可构建)go test ./...全部通过,无 FAIL新增测试:
controller/task_error_override_test.go:respondTaskError覆写门控——订阅额度不足 / 用户额度不足 /channel_no_available_key等本地错误即使文案命中关键词也不覆写;上游错误命中才覆写;429 的本站限流文案不被覆写;覆写时清空Data、未覆写时保留Data。relay/task_fail_reason_override_test.go:TaskModel2Dto的FailReason覆写——用户视图掩盖上游原因、管理员视图保留原文、本站自产的超时/固定串文案不被误伤、空文案与开关关闭时不变。service/error_test.go:RelayErrorHandler按路径标记(解析失败不回显 body → 本地;回显 body → 上游;结构化 provider error → 上游;纯 message body → 上游);读 body 失败不标记上游。relaykit/types/error_test.go:ReplaceMessage清空Metadata与Param,同时保留 StatusCode / Type / Code;WithUpstream*标记上游;NewError包装后标记通过errors.As传递。setting/operation_setting/error_override_setting_test.go:开关关闭不覆写;本地错误不覆写;上游命中credits/top-up/quota/no available覆写;大小写不敏感;状态码/类型/code 保留;nil 安全;空关键词列表;关键词字符串解析与往返;ShouldOverrideUpstreamError谓词不修改错误对象。前端验证:
bun run i18n:sync成功(新键已按字母序归入en.json,其余语言由脚本按基准顺序同步)bun run build成功端到端手测路径:开关关闭 → 客户端拿到上游原始 message;开关打开 → 客户端拿到
Service Unavailable (request id: ...),状态码与error.code不变;用户额度清零 / 本站无渠道 / 任务接口订阅额度不足 / MJ 本站额度不足时仍返回本地原文案;开关打开时后台错误日志仍记录上游原始全文,用户日志页看到的是覆写后文案。