Skip to content

feat: add configurable upstream error message override - #9

Merged
ItzArona merged 8 commits into
mainfrom
feat/error-message-override
Aug 12, 2026
Merged

feat: add configurable upstream error message override#9
ItzArona merged 8 commits into
mainfrom
feat/error-message-override

Conversation

@ItzPlana

@ItzPlana ItzPlana commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

⚠️ 提交说明 / PR Notice

Important

  • 请提供人工撰写的简洁摘要,避免直接粘贴未经整理的 AI 输出。

📝 变更描述 / Description

当上游供应商返回的错误信息包含 No availablequotacreditstop-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 保持原值。

为什么这样改能生效:

  1. relaykit/typesNewAPIError 新增 fromUpstream 标记。RelayErrorHandler(唯一通用上游错误解析函数,被全部 relay 格式调用)按返回路径逐条标记——文案取自上游响应体的路径才打标;从上游 body 直接解析错误的渠道适配器改用新增的 WithUpstreamOpenAIError / WithUpstreamClaudeError 构造函数。
  2. 新增 ReplaceMessage 同时改写 ErrRelayError。因为 ToOpenAIError()openai_error 类型直接返回 RelayError(不读 Err),仅用既有 SetMessage 不足以覆写给客户端的响应体;同时清空 Metadata(OpenRouter 路径会把上游原始 JSON 塞进 metadata)与 Param(ali rerank 等适配器会把上游 request id 塞进该字段)。
  3. 覆写在每个出口的 logger.LogError 之后执行,因此后台日志、DisableChannel 关键词判定、NormalizeViolationFeeError 的 CSAM 标记检测看到的始终是原始上游文案。

上游/本地的判定方向(关键设计点):

区分上游与本地一律使用正向标记,而不是「非本地即上游」。原因是本站自产错误的默认状态就是「未标记」:若用反推,安全性会完全依赖覆写关键词没有碰巧命中本站文案,管理员一旦配置了偏宽的关键词就会静默掩盖本站自身的报错。

  • Relay 链路:types.IsFromUpstreamError() 读私有 fromUpstream
  • 任务链路:dto.TaskError 新增 FromUpstream 字段,与重试判定用的 LocalError 是两个正交维度(LocalError 已被 shouldRetryTaskRelay 使用,不能复用)。只有确实把上游响应体文案塞进错误的位置才标记:relay_task 的 fetch 失败、ali / suno / jimeng / hailuo 适配器的上游 API 错误;kling 保留 LocalError(不重试)并补 FromUpstream

覆盖的对外出口(每一个都要处理,否则等于没做):

同一份上游文案有多个出口,只改 HTTP 响应体是不够的:

  1. HTTP 错误响应 —— RelayPlayground 的 defer 出口。
  2. 用户可见错误日志 —— RecordErrorLogContent 会通过 /api/log/self 回显给发起请求的用户,和响应体一样是对外出口。覆写生效时同步覆写该字段,原文改记到 other.admin_info.original_errormodel.formatUserLogs 会为普通用户剥离整个 admin_info)。为此新增不修改错误对象的 ShouldOverrideUpstreamError 谓词,保证重试与渠道禁用判定仍然看到未被改动的原始错误。
  3. 异步任务 FailReason —— 轮询把上游失败原因落库(service/task_polling.gotask.FailReason = taskResult.Reason),用户随后通过任务查询接口读到它。在读取边界覆写:TaskModel2Dto 新增 maskUpstreamFailReason 参数,面向用户的 relay 查询与 GetUserTasktrue,管理员列表 GetAllTaskfalse(排障需要原文);MJ 的 coverMidjourneyTaskDto 同样处理。落库仍存原文。
  4. TaskError.Data —— 带 json:"data" 会返回客户端,覆写 Message 时一并清空,与 ReplaceMessageMetadata/Param 保持一致。

边界:

  • 用户额度不足 / 订阅额度不足(insufficient_user_quota)由本站产生,TaskErrorFromAPIError 不打上游标记,文案不会被掩盖——用户始终能看到真实的额度原因。
  • distributor 无可用渠道、请求校验 / 读 body / 连接上游失败(do_request_failed 等)同样不打标记。
  • 响应体解析失败且不回显 body 时,文案完全由本站生成(仅含状态码),不标记为上游来源。
  • 不改状态码:HTTP status code、error.typeerror.code 全部保持原值,仅替换 message
  • 后台日志一律保留原始错误全文(任务链路在覆写前额外记一条原始文案)。

Midjourney 不做覆写(结论与最初设想相反): 能走到 RelayMidjourneymjErr 分支的 MidjourneyResponse 全部是本站自产的(参数校验、额度不足、DB/IO 失败)。mj-proxy 各 handler 拿到上游响应后是把上游 body 原样 io.Copy 给客户端再 return nil,上游错误文案根本不经过那个分支。因此在该处按关键词覆写只可能误伤本站错误——实测会把 mjproxy_handler.goRelaySwapFace / 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 做分支的客户端。
  • MJ 被代理的上游响应体不覆写(见上)。
  • 任务 FailReason 只能按关键词门控:Task 表没有来源标记列,补一列要跨 SQLite/MySQL/PostgreSQL 三种数据库做迁移,代价与收益不匹配。本站自产的 FailReason 是「任务超时(%d分钟)」这类中文文案与 upstream returned error 这类固定串,都不含默认关键词,已由测试钉住;但管理员配置过宽的关键词时本站文案仍可能被误伤。

顺带修复的编译问题(非本功能范围,但必须处理): 当前 main 分支无法编译,有两处独立错误,本 PR 一并修掉:

  1. ratio_setting.CompactModelSuffix 未定义(middleware/distributor.gosetting/model_setting/model_alias.go)。成因是两个提交的合并顺序:9bdac8a6(全局别名,2026-07-26)是照着 compact 后缀 API 写的,而 bb234ff4refactor(responses): remove compact model suffix handlingrefactor(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 模型发现和渠道测试都已不再认识的后缀。
  2. shouldRetryTaskRelay 参数数量不匹配:controller/relay.go 的声明是 4 参,调用方传 5 个,函数体又引用了 failoverEnabled。补上该参数。

另有一处测试与实现不符(非编译错误):web/src/features/keys/components/api-key-group-cell.tsx 已把 AutoGroupBadge 注释停用(该位置由 Cross-group StatusBadge 承担),但 api-key-group-cell.test.tsx 仍断言 frames.length === 2。本 PR 对齐断言。

🚀 变更类型 / Type of change

  • ✨ 新功能 (New feature)
  • 🐛 Bug 修复 (Bug fix) —— main 分支编译修复

🔗 关联任务 / Related Issue

  • 暂无关联 Issue

✅ 提交前检查项 / Checklist

  • 人工确认: 我已亲自整理并撰写此描述,没有直接粘贴未经处理的 AI 输出。
  • 非重复提交: 已搜索现有 Issues 与 PRs,确认非重复提交。
  • 变更理解: 已理解这些更改的工作原理及可能影响。
  • 范围聚焦: 除上文说明的 main 分支编译修复与测试断言对齐外,本 PR 未包含与当前任务无关的改动。
  • 本地验证: 已在本地运行并通过测试或手动验证。
  • 安全合规: 代码中无敏感凭据,且符合项目代码规范。

📸 运行证明 / 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.gorespondTaskError 覆写门控——订阅额度不足 / 用户额度不足 / channel_no_available_key 等本地错误即使文案命中关键词也不覆写;上游错误命中才覆写;429 的本站限流文案不被覆写;覆写时清空 Data、未覆写时保留 Data
  • relay/task_fail_reason_override_test.goTaskModel2DtoFailReason 覆写——用户视图掩盖上游原因、管理员视图保留原文、本站自产的超时/固定串文案不被误伤、空文案与开关关闭时不变。
  • service/error_test.goRelayErrorHandler 按路径标记(解析失败不回显 body → 本地;回显 body → 上游;结构化 provider error → 上游;纯 message body → 上游);读 body 失败不标记上游。
  • relaykit/types/error_test.goReplaceMessage 清空 MetadataParam,同时保留 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 本站额度不足时仍返回本地原文案;开关打开时后台错误日志仍记录上游原始全文,用户日志页看到的是覆写后文案。

当上游供应商返回的错误信息包含 "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 行为、本地/上游错误区分、
  关键词匹配与规范化。
@plana-autoreview

Copy link
Copy Markdown

Mira PR Walkthrough

🔍 Reviewing this PR…

@ItzArona
ItzArona requested a balanced review from Copilot August 12, 2026 13:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.
@ItzPlana

Copy link
Copy Markdown
Collaborator Author

修复:RelayErrorHandlerdefer 位置错误

针对审查中指出的高风险问题,已提交修复:将 MarkUpstreamOrigindefer 从函数入口移到 io.ReadAll 成功之后。

问题

此前 defer { MarkUpstreamOrigin() } 位于函数入口,覆盖全部返回路径。当 io.ReadAll(resp.Body) 失败时(网络断连、OOM 等本站基础设施错误),其错误文案并非来自上游响应体,却被误标记为 fromUpstream = true。一旦该本地错误信息恰好命中覆写关键词,会被错误地覆写为 Service Unavailable

修复

  • service/error.go:把 defer 下移到 io.ReadAll 错误检查之后。从 CloseResponseBodyGracefully 往后的所有返回路径(JSON 解析失败、结构化错误、空 message)仍照常被标记为上游来源——这些路径的文案确实取自上游响应体;io.ReadAll 失败的早返回路径则不被标记。
  • service/error_test.go:新增 TestRelayErrorHandlerIOErrorNotMarkedUpstream,用一个始终返回错误的 io.ReadCloser 触发读取失败,断言返回的 NewAPIError 不被标记为上游来源。

验证

  • go build ./service/... 通过
  • go vet ./service/... 通过
  • go test ./service/... -run RelayErrorHandler 全部通过(含原有 4 个 + 新增 1 个)

Commit: 1953ed06

…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 用户列表覆写 / 管理员列表留原文。
@ItzArona
ItzArona merged commit 8ef367f into main Aug 12, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants