fix(provider): 上游错误文案别再丢 —— 补网关/腾讯错误体形状 + 修误导性日志

## 发现路径:读生产日志(不是测试报红)
```
unhandled upstream error body (adapter "openai" lacks transform_error):
  status=404 body={"error_msg":"404 Route Not Found"}
unhandled upstream error body (adapter "buddy" lacks transform_error):
  status=400 body={"code":11101,"msg":"Non-stream chat request is currently not supported",...}
```

### ★ 缺陷 A:错误体形状漏了「网关」与「腾讯系」两类
`__prelude.openai_error()` 只认 `error.message` / `error`(裸串) / `{code,message}`
/ `detail` / HTML `<title>` / 裸文本 六种,**没有** `error_msg`(华为/APISIX 网关)
与 `msg`(腾讯系用 msg 而非 message)⇒ 返回 nil
⇒ 客户端只看到 `api error 404: unknown error`,**而 body 里明明有可用文案**。

修法照参考(llm-adapter.ts:524 `errorDetail()` 显式认 `error_code`/`error_msg`;
buddy-adapter.ts:576 认 `msg` + `displayMsg.{zh,en}`):
- 新增 `{error_msg}` / `{msg}` / `{error_code,+error_msg}` 三种形状;
- 新增 `displayMsg.{zh,en}` 本地化文案,**排在最前**(参考注释明说"本地化文案
  最可读";其早期实现只读 `error.*`/`message` ⇒ 全部落空 ⇒ 把 291 字符原始
  报文糊进错误提示,而服务端早已备好中文说明)。

覆盖 7 个用共享前导的适配器:buddy/deepseek/github/groq/kimicode/openai/sensenova。

### ★ 缺陷 B:内核那句日志会把人带偏
措辞是 `adapter %q lacks transform_error`,但 **openai.lua:139 与 buddy.lua:166
都明明定义了该钩子** —— 真实原因只是它**返回了 nil**(认不出那个形状)。
我照着这句话去适配器里找"有没有这个函数",查了几轮才发现函数在、是返回值空。
修法:内核直接查事实(`p.vm.HasFn(adapter, "transform_error")`),
两种原因**分开措辞**,不再猜。

## 判据
`internal/lua/adapter_prelude_error_shapes_test.go`:
- 12 个形状子用例(4 个新增 + 6 个回归 + 2 个 displayMsg),
  **装载仓库里真实的 openai.lua**(含共享前导),不是自造桩 ——
  第一版用空 VM 跑,12 个全 FAIL(`adapter openai not loaded`),
  那等于什么也没验。
- 配对判据 `...StillReportsUnknownShapes`:真正认不出的形状仍须 `ok=false`,
  防止"为了让上面通过就把『抽不出』和『没这个钩子』混为一谈"。
- 变异:删掉新增的 error_msg/msg 分支 ⇒ APISIX 用例报红。

## 备注
`anthropic/gemini/mistral/ollama/opencodezen/qwen38/trae` 有自己写的
`transform_error`,不走共享前导,**未获得**本次新增形状(各自协议的错误体
本就不同)。此处只修共享前导的 7 个。
This commit is contained in:
JianFeeeee
2026-10-17 00:37:32 +08:00
parent 9e5a5f550b
commit aea1a2c48d
3 changed files with 144 additions and 2 deletions

View File

@ -343,6 +343,39 @@ function __prelude.openai_error(status, body)
return resp.detail
end
-- 7/8/9. 网关与腾讯系形状(实测漏过,见函数头注释)。
-- error_code 与 error_msg 同现时拼起来 —— 只看 msg 会丢掉可分类的码
-- (参考 llm-adapter.ts:531 把两者都放进 parts 再 join)。
local msg = nil
if type(resp.error_msg) == "string" and resp.error_msg ~= "" then
msg = resp.error_msg
elseif type(resp.msg) == "string" and resp.msg ~= "" then
msg = resp.msg
end
-- 腾讯系(buddy/workbuddy)用 msg + displayMsg.{zh,en},且**没有** message。
-- 参考 buddy-adapter.ts:576 明确:本地化文案最可读,排在**最前**。
local localized = nil
if type(resp.displayMsg) == "table" then
local dm = resp.displayMsg
if type(dm.zh) == "string" and dm.zh ~= "" then localized = dm.zh
elseif type(dm.en) == "string" and dm.en ~= "" then localized = dm.en end
end
if msg then
local code = nil
if type(resp.error_code) == "string" and resp.error_code ~= "" then
code = resp.error_code
elseif resp.code ~= nil and type(resp.code) ~= "string" then
code = tostring(resp.code)
end
local detail = code and (code .. ": " .. msg) or msg
if localized then return localized .. " " .. detail end
return detail
end
if localized then return localized end
if type(resp.error_code) == "string" and resp.error_code ~= "" then
return resp.error_code
end
return nil
end

View File

@ -0,0 +1,99 @@
package lua
import (
"testing"
)
// ★ 上游错误体抽取必须覆盖**网关与腾讯系**形状 —— 这两类实测都漏过。
//
// 发现路径(2026-10-16 读生产日志):
//
// unhandled upstream error body (adapter "openai" lacks transform_error):
// status=404 body={"error_msg":"404 Route Not Found"}
// unhandled upstream error body (adapter "buddy" lacks transform_error):
// status=400 body={"code":11101,"msg":"Non-stream chat request is not supported"}
//
// 两者都是 `{"error_msg":...}` / `{"msg":...}` —— 旧实现只认
// error/message/detail,于是返回 nil ⇒ 客户端只看到
// `api error 404: unknown error`,而 body 里明明有可用文案。
//
// ⚠️ 那两个适配器**都定义了** transform_error(openai.lua:139、buddy.lua:166),
// 内核却报 "lacks" ⇒ 一句会把排查带偏的诊断(已另行修正内核文案)。
func TestPreludeErrorExtractionCoversGatewayShapes(t *testing.T) {
vm := NewVM(t.TempDir())
defer vm.Stop()
// 用**仓库里的真实** openai 适配器(含共享前导),不是自造桩:
// 判据必须走真实实现,否则"补了形状"这件事根本没被验。
src, err := bundledAdapters.ReadFile("adapters/openai.lua")
if err != nil {
t.Fatal(err)
}
if err := vm.LoadAdapterSource("openai", string(src)); err != nil {
t.Fatalf("装载 openai 适配器: %v", err)
}
cases := []struct {
name, body, want string
}{
{"APISIX error_msg",
`{"error_msg":"404 Route Not Found"}`, "404 Route Not Found"},
{"腾讯 msg(buddy 11101)",
`{"code":11101,"msg":"Non-stream chat request is currently not supported"}`,
"11101: Non-stream chat request is currently not supported"},
{"error_code + error_msg",
`{"error_code":"APIG.0301","error_msg":"verify ak sk signature fail"}`,
"APIG.0301: verify ak sk signature fail"},
{"腾讯 displayMsg 本地化(最可读,排最前)",
`{"code":11101,"msg":"Non-stream chat request is currently not supported",` +
`"displayMsg":{"en":"Invalid request. Please retry","zh":"请求内容不合法,请重试"}}`,
"请求内容不合法,请重试 11101: Non-stream chat request is currently not supported"},
{"仅 displayMsg",
`{"displayMsg":{"zh":"只有本地化文案"}}`, "只有本地化文案"},
{"仅 error_code",
`{"error_code":"InferHub.4291.200"}`, "InferHub.4291.200"},
// 回归:原有的 6 种形状不得丢
{"OpenAI 信封", `{"error":{"message":"boom"}}`, "boom"},
{"裸字符串 error", `{"error":"boom"}`, "boom"},
{"扁平 code/message", `{"message":"boom"}`, "boom"},
{"FastAPI detail", `{"detail":"boom"}`, "boom"},
{"HTML title", `<html><title>413 Too Large</title></html>`, "413 Too Large"},
{"裸文本", "plain failure line\nmore", "plain failure line"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, ok, err := vm.TransformError("openai", 400, c.body)
if err != nil {
t.Fatalf("TransformError: %v", err)
}
if !ok {
t.Fatalf("★ 抽不出消息(返回 ok=false)⇒ 客户端只会看到 "+
"'unknown error',而 body 里明明有:%s", c.body)
}
if got != c.want {
t.Fatalf("抽出的文案不对\n 实得: %q\n 期望: %q", got, c.want)
}
})
}
}
// 配对判据:真正**没有** transform_error 的适配器仍应返回 ok=false
// (不能为了让上面那条通过就把"抽不出"和"没这个钩子"混为一谈)。
func TestPreludeErrorExtractionStillReportsUnknownShapes(t *testing.T) {
vm := NewVM(t.TempDir())
defer vm.Stop()
src, err := bundledAdapters.ReadFile("adapters/openai.lua")
if err != nil {
t.Fatal(err)
}
if err := vm.LoadAdapterSource("openai", string(src)); err != nil {
t.Fatalf("装载 openai 适配器: %v", err)
}
// 一个确实认不出的 JSON(没有 message/error/detail/msg/error_msg)
got, ok, err := vm.TransformError("openai", 400, `{"weird":true}`)
if err != nil {
t.Fatalf("TransformError: %v", err)
}
if ok {
t.Fatalf("认不出的形状不该硬凑文案,实得 %q", got)
}
}