Compare commits

...

2 Commits

Author SHA1 Message Date
6eeb5832a3 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 个。
2026-10-17 00:37:32 +08:00
a189c4f61c fix(channels): zcode 适配器真正继承 anthropic 基座(此前 9 个函数只有 2 个)
## 缺陷(潜伏,从未被触发)
`channels-zcode` 适配器的手法是把 `anthropic.lua` 源码读进来执行、只覆盖
`transform_request`(身份块注入),其余协议转换全部复用基座。

但那句读取写在**适配器内部**。实测:
- 适配器运行在**另一个沙箱 VM** —— 那里 `__llmsproxy_plugin_dir` 与
  `LLMSPROXY_PLUGIN_DIR` **都不存在**、**也没有文件系统访问**;
- ⇒ `loadAnthropic()` 必然返回 nil,**且静默降级**:适配器照常装载,
  但只有它自己定义的 **2** 个函数,缺
  `transform_response` / `transform_stream_chunk` / `serialize_*` /
  `deserialize_request` 共 **7** 个。

**后果**:内核 `Transform()` 对缺失函数**响亮报错**(不是静默透传)⇒
非流式在响应阶段 502,流式**逐帧**报错 ⇒ 该渠道**完全不可用**。

**为什么一直没暴露**:zcode 从来没有凭据(0 账号)⇒ 源未注册
⇒ 该路径从未被调用过。拿到账号的那一刻才会炸,且报错指向内核而非配置。

## 修法
基座源码改由**插件主体**读好并**内联**进适配器源码(插件 VM 有 io,
实测可读)。与 v2.0.35 buddy 适配器同一手法。

两处必须注意的坑:
1. **不能拼接后 gsub 替换**:gsub 的替换串里 `%` 有特殊含义(`%1` 是捕获
   引用),而适配器源码含 `%s`/`%d` 等格式化串 ⇒ 会破坏语法。
   改用 `find(ph, 1, true)` + `sub` 的纯字符串替换。
2. **占位符 token 不能在注释里出现字面量**(第一版就栽在这:gsub 先命中了
   解释它的那行注释,替换掉注释、代码里仍留着未定义全局)。
3. 顺带补上**插件主体缺失的 `plugin.adaptersDir()`** —— 原先这个函数只
   存在于 zcode 适配器源码**内部**,插件主体从来没有过。

## 判据
- `internal/core`(**真实路径**,会咬变异):
  - `TestZcodeAdapterInjectsOfficialIdentityBlocks`
  - `TestZcodeAdapterKeepsAnthropicProtocolConversion`(工具调用 / 图像块存活)
  - `TestZcodeAdapterFailsLoudlyWithoutAnthropicBase` —— 语义已随设计更新:
    基座不可得现在应在**安装期**失败(新增 `adapters_dir` 测试缝隙把
    "基座缺失"从运行期逐帧炸变成安装期一条明确错误)
- `internal/lua/adapter_zcode_base_test.go`:验证源码模板 + 合并逻辑,
  **并如实标注边界** —— 它在测试里自己做替换,所以**不能**证明安装处内联了
  非空基座(实测:把 `asrc` 换成空串它不报红;那份由上面的 core 判据覆盖)。

**变异验证**:把安装处的 `asrc` 换成空基座 ⇒ core 三条判据全部报红;
用重构前的适配器源码 ⇒ `TestZcodeAdapterInheritsAnthropicBase` 报红(缺 7 个)。
2026-10-17 00:01:00 +08:00
6 changed files with 335 additions and 55 deletions

View File

@ -252,14 +252,16 @@ func TestZcodeAdapterKeepsAnthropicProtocolConversion(t *testing.T) {
})
}
// ★ 基座(anthropic.lua)读不到时必须**响亮失败**,不得静默退化。
// ★ 基座(anthropic.lua)读不到时必须在**安装期响亮失败**,不得静默退化。
//
// 反例危害:若"尽力而为"(只注入身份块、不作协议转换),会产出一个身份块
// 看着正确的请求,而 tools/图像/流式结构全未转换 —— 上游可能 200,但模型
// 拿不到工具定义,症状是"模型变笨了",与准入毫不相干,排查会完全跑偏。
//
// 判据看的是**适配器的真实产出**:把适配器目录指到空目录(基座不可得),
// transform_request 必须返回错误而不是原样透传。
// ⚠ 判据随设计更新(v2.0.37):基座**不再由适配器自己读**(适配器沙箱没有
//
// fs,那样必然失败且静默降级)。现在由**插件主体**读好并内联 ⇒ 基座不可得
// 时应在**安装期**就失败(返回 err),而不是等到运行期才发现。
func TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(t *testing.T) {
// 隔离目录:插件目录下没有兄弟 adapters/ 目录 ⇒ 基座读不到
dir := t.TempDir()
@ -278,21 +280,26 @@ func TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(t *testing.T) {
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
vm := c.VM()
if vm == nil {
t.Fatal("VM 不可用")
// 用空目录重新安装:基座不可得 ⇒ 必须**安装期**失败且原因点明 anthropic
res, err := ps.Call("channels", "__adapter_install_probe", map[string]interface{}{
"adapters_dir": filepath.Join(dir, "adapters-no-base"),
})
if err != nil {
t.Fatal(err)
}
// 插件自装时若基座读不到,适配器仍应装载(身份块是静态的),
// 但调用必须报错
out, err := vm.Transform("channels-zcode", "transform_request",
`{"model":"m","messages":[{"role":"user","content":"hi"}]}`)
if err == nil {
t.Fatalf("基座不可得时必须报错,却成功产出了:%s", out)
j := res.(map[string]interface{})["json"].(map[string]interface{})
zc, ok := j["zcode_reinstall"].(map[string]interface{})
if !ok {
t.Fatalf("安装探针应回报 zcode_reinstall 结果,实得 %v", j)
}
if !strings.Contains(err.Error(), "基座") {
t.Fatalf("错误信息应点明基座问题(可定位),实得 %v", err)
if zc["ok"] == true {
t.Fatalf("基座不可得时安装必须失败(否则运行期才炸、且是逐帧炸),实得 %v", zc)
}
t.Logf("✓ 基座缺失时响亮失败: %v", err)
msg, _ := zc["err"].(string)
if !strings.Contains(msg, "anthropic") {
t.Fatalf("错误信息应点明基座问题(可定位),实得 %q", msg)
}
t.Logf("✓ 基座缺失时安装期响亮失败: %s", msg)
}
// ★ 日期块必须**幂等**:已以 <system-reminder> 开头则不重复插。

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)
}
}

View File

@ -0,0 +1,97 @@
package lua
import (
"os"
"regexp"
"strings"
"testing"
)
// ★ zcode 适配器**源码模板**必须真的能把 anthropic 基座合并进来。
//
// 背景:此前适配器在**沙箱里自己读 anthropic.lua** ⇒ 必然失败(沙箱无 fs、
// 无 plugin_dir)且**静默降级** ⇒ 只有它自己定义的 2 个函数,缺 7 个
// ⇒ 每次调用内核报 "missing transform_response" ⇒ 渠道**完全不可用**。
// 该缺陷一直潜伏,因为 zcode 从来没有凭据 ⇒ 源未注册 ⇒ 从未被调用过。
//
// ⚠️ **本判据的边界(务必如实理解)**:它在测试里**自己**做占位符替换,
//
// 所以它证明的是「**模板形态**正确 + 合并逻辑能吃下基座」,
// 而**不能**证明「installZcodeAdapter 真的内联了非空基座」——
// 把安装处的 `asrc` 换成空串,本判据**不会**报红(已实测)。
// 安装接线由 internal/core 的端到端判据覆盖:
// TestZcodeAdapterInjectsOfficialIdentityBlocks
// TestZcodeAdapterKeepsAnthropicProtocolConversion(工具调用/图像块存活)
// TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(基座不可得 ⇒ 安装期报错)
// 这三条**会**咬住"内联空基座"这个变异(已实测)。
// ⇒ 教训:判据自己复现被测逻辑时,必须说清它到底覆盖了哪一段。
func TestZcodeAdapterInheritsAnthropicBase(t *testing.T) {
psrc, _ := os.ReadFile("plugins/channels.lua")
s := string(psrc)
diag := regexp.MustCompile(`(?s)local ZCODE_DIAG_SRC = \[===\[(.*?)\]===\]`).FindStringSubmatch(s)[1]
ad := regexp.MustCompile(`(?s)local ZCODE_ADAPTER_SRC = \[===\[(.*?)\]===\]`).FindStringSubmatch(s)[1]
asrc, err := bundledAdapters.ReadFile("adapters/anthropic.lua")
if err != nil {
t.Fatal(err)
}
full := "local Z = (function()\n" + diag + "\nend)()\n" + ad
// ⚠ 占位符不等于"适配器里能读到基座":真正要验的是**装载后 9 个函数都在**。
// 故这里按 installZcodeAdapter 的同一手法内联,再逐函数断言。
if strings.Contains(full, "__INLINE_ANTHROPIC_SRC__") {
full = strings.ReplaceAll(full, "__INLINE_ANTHROPIC_SRC__", quoteLua(string(asrc)))
} else {
t.Fatal("适配器源码必须有基座占位符(基座由插件主体内联;" +
"适配器沙箱里读不到文件)")
}
vm := NewVM(t.TempDir())
defer vm.Stop()
if err := vm.LoadAdapterSource("channels-zcode", full); err != nil {
t.Fatalf("装载失败: %v", err)
}
want := []string{"transform_request", "transform_response", "transform_stream_chunk",
"transform_error", "serialize_chunk", "serialize_close", "serialize_open_preamble",
"serialize_response", "deserialize_request"}
var lack []string
for _, f := range want {
if !vm.HasFn("channels-zcode", f) {
lack = append(lack, f)
}
}
if len(lack) > 0 {
t.Fatalf("★ zcode 仍缺 %d 个函数(%v)⇒ 基座继承失败 ⇒ "+
"每次调用都会报 'missing <fn>' ⇒ 渠道完全不可用", len(lack), lack)
}
// 行为验证:基座的 transform_response 必须真的能转换 anthropic 响应
out, err := vm.Transform("channels-zcode", "transform_response",
`{"id":"m1","type":"message","role":"assistant","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":2}}`)
if err != nil {
t.Fatalf("transform_response 调用失败: %v", err)
}
if !strings.Contains(out, "hi") {
t.Errorf("基座 transform_response 未产出内容: %s", out)
}
t.Logf("✓ zcode 继承基座成功(9/9 函数),transform_response → %s", out)
}
func quoteLua(s string) string {
var b strings.Builder
b.WriteByte('"')
for i := 0; i < len(s); i++ {
switch c := s[i]; c {
case '"':
b.WriteString(`\"`)
case '\\':
b.WriteString(`\\`)
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
default:
b.WriteByte(c)
}
}
b.WriteByte('"')
return b.String()
}

View File

@ -77,6 +77,28 @@ plugin.K = {
ZCODE_ORIGIN = "https://zcode.z.ai",
}
-- ★★ plugin.adaptersDir():适配器目录 = 插件目录的**兄弟**目录
-- (<data>/plugins 与 <data>/adapters)。由插件目录推导,不猜绝对路径
-- (测试与多实例部署都靠它隔离)。
--
-- ⚠️ 为什么必须放在**插件主体**:适配器运行在**另一个沙箱 VM**,
-- 那里 `__llmsproxy_plugin_dir` 与 `LLMSPROXY_PLUGIN_DIR` **都不存在**、
-- **也没有文件系统访问** ⇒ 在适配器里读任何文件都拿不到东西且**静默降级**
-- (实测:zcode 适配器因此只拿到自己定义的 2 个函数,缺 7 个,
-- 每次调用内核报 "missing transform_response" ⇒ 渠道完全不可用)。
-- 所以凡是要"读兄弟文件"的,一律在插件主体做好再内联进去。
function plugin.adaptersDir()
local pd = __llmsproxy_plugin_dir
if type(pd) ~= "string" or pd == "" then
pd = (os.getenv and os.getenv("LLMSPROXY_PLUGIN_DIR")) or ""
end
if type(pd) ~= "string" or pd == "" then return nil end
local base = pd:gsub("/+$", "")
local parent = base:match("^(.*)/[^/]+$")
if not parent then return nil end
return parent .. "/adapters"
end
-- 申请的内核权限。设置页会把"申请中但未授权"标红。
-- 声明本插件要用的内核动作(运维据此授予;declared 与 granted 成对展示)。
--
@ -10201,38 +10223,23 @@ local EXPECTED_MIN_CHARS = 2800
-- ============================================================================
-- 复用 anthropic 适配器
-- ============================================================================
-- 适配器目录 = 插件目录的**兄弟**目录(<data>/plugins 与 <data>/adapters)。
-- 由插件目录推导,不猜绝对路径(测试与多实例部署都靠它隔离)。
local function adaptersDir()
local pd = __llmsproxy_plugin_dir
if type(pd) ~= "string" or pd == "" then
pd = (os.getenv and os.getenv("LLMSPROXY_PLUGIN_DIR")) or ""
end
if type(pd) ~= "string" or pd == "" then return nil end
local base = pd:gsub("/+$", "")
local parent = base:match("^(.*)/[^/]+$")
if not parent then return nil end
return parent .. "/adapters"
end
-- loadAnthropic():在本地环境里执行内核的 anthropic 适配器源码,拿回它的表。
local function loadAnthropic()
local dir = adaptersDir()
if not dir then return nil, "无法推导适配器目录" end
local f = io.open(dir .. "/anthropic.lua", "r")
if not f then return nil, "读不到 anthropic.lua(" .. dir .. ")" end
local src = f:read("*a")
f:close()
local chunk, lerr = load(src, "anthropic.lua")
if not chunk then return nil, "anthropic.lua 无法编译: " .. tostring(lerr) end
-- ★★ 基座由**插件主体内联**进来(见下方 §基座占位符),
-- **不能**在适配器里自己读文件。实测(2026-10-16):
-- 适配器运行在**另一个沙箱 VM**,那里 `__llmsproxy_plugin_dir` 与
-- `LLMSPROXY_PLUGIN_DIR` **都不存在**,**也没有文件系统访问**
-- ⇒ 原先那段"就地读 anthropic.lua"必然失败且**静默降级**:
-- 适配器照常装载,但只有它自己定义的 2 个函数,
-- 缺 transform_response / transform_stream_chunk 等 **7 个**。
-- ⇒ 后果:每次调用内核对缺失函数**响亮报错**(不是静默透传)
-- ⇒ 流式请求逐帧报错 ⇒ 该渠道**完全不可用**。
-- 该缺陷一直潜伏:zcode 从来没有凭据 ⇒ 源未注册 ⇒ 从未被调用过。
local BASE = (function()
local chunk, lerr = load(__INLINE_ANTHROPIC_SRC__, "anthropic-base.lua")
if not chunk then return nil, lerr end
local ok, base = pcall(chunk)
if not ok or type(base) ~= "table" then
return nil, "anthropic.lua 执行失败: " .. tostring(base)
end
return base, nil
end
local BASE, BASE_ERR = loadAnthropic()
if not ok or type(base) ~= "table" then return nil, tostring(base) end
return base
end)()
if BASE then
for k, v in pairs(BASE) do
if adapter[k] == nil then adapter[k] = v end
@ -10617,8 +10624,18 @@ end
-- system 缺官方身份块结构直接 405 + 3012(实测),与 HTTP 头/版本/频率全无关。
-- 通用 anthropic 适配器不注入身份块 ⇒ 即使拿到 zcode 账号也**必然**被拒。
-- 幂等(update 失败退化为 install);失败只记日志,不阻断插件加载。
local function installZcodeAdapter()
-- dirOverride 仅用于测试/诊断:强制指定适配器目录,让"基座不可得"
-- 成为**安装期可观测**的失败(否则它只会在运行期逐帧炸,且原因指向内核)。
local function installZcodeAdapter(dirOverride)
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
-- ★ 读到 anthropic 基座源码并在**插件主体**内联进适配器源码。
-- ⚠ 不能在适配器里自己读文件:适配器沙箱没有 fs、也没有 plugin_dir,
-- 那样会**静默降级**成"只有自己定义的 2 个函数"(见适配器顶部注释)。
local adir = dirOverride or plugin.adaptersDir()
if not adir then return false, "无法推导适配器目录" end
local af = io.open(adir .. "/anthropic.lua", "r")
if not af then return false, "读不到 anthropic.lua(" .. adir .. ")" end
local asrc = af:read("*a"); af:close()
-- ★ 把 3012 判据/文案/诊断模块**前置**到适配器源码里。
-- 这样适配器与插件主体用的是**同一份源码文本**(ZCODE_DIAG_SRC)——
-- 否则"请勿连续重试"这句话会只在一条路径上有(参考为此专门抽成单一份)。
@ -10627,6 +10644,13 @@ local function installZcodeAdapter()
-- 且装载会静默成功)。故把它包进一个函数,让 return 只结束该函数。
local full = "local Z = (function()\n" .. ZCODE_DIAG_SRC ..
"\nend)()\n" .. ZCODE_ADAPTER_SRC
-- ⚠ 用**纯字符串替换**而不是 gsub:gsub 的替换串里 `%` 有特殊含义
-- (`%1` 是捕获引用),而适配器源码含 `%s`/`%d` 等格式化串 ⇒ 会破坏语法。
-- ⚠ 占位符 token 刻意在注释里**不写字面量**,否则 find 会先命中注释。
local ph = "__INLINE_ANTHROPIC_SRC__"
local qi = full:find(ph, 1, true)
if not qi then return false, "适配器源码缺少基座占位符" end
full = full:sub(1, qi - 1) .. string.format("%q", asrc) .. full:sub(qi + #ph)
local f, lerr = load(full)
if not f then return false, "源码语法错误: " .. tostring(lerr) end
local _, uerr = __llmsproxy_adapters.update("channels-zcode", full)
@ -11744,13 +11768,21 @@ end
-- plugin.__adapter_install_probe:只给测试/诊断用 —— 报告本插件自装适配器的
-- 装载结果(含语法错误详情)。"装了但没生效"这类问题没有它只能靠猜。
function plugin.__adapter_install_probe()
function plugin.__adapter_install_probe(args)
args = args or {}
-- 带 adapters_dir 时**重新安装** zcode:用来验证「基座不可得 ⇒ 安装期报错」。
-- 之所以要这个缝隙:基座现在由**插件主体内联**(适配器沙箱无 fs),
-- 安装失败若不在此暴露,就只会在运行期逐帧炸,且错误指向内核而非配置。
if args.adapters_dir then
local ok, err = installZcodeAdapter(args.adapters_dir)
return { json = { zcode_reinstall = { ok = ok and true or false, err = err } } }
end
return { json = {
codearts = plugin._codearts_adapter,
trae = plugin._trae_adapter,
gemini = plugin._gemini_adapter,
zcode = plugin._zcode_adapter,
} }
codearts = plugin._codearts_adapter,
trae = plugin._trae_adapter,
gemini = plugin._gemini_adapter,
zcode = plugin._zcode_adapter,
} }
end
-- plugin.__sync_source_probe:只给测试/诊断用 —— 手动触发一次源同步并
@ -11850,4 +11882,6 @@ do
if not tok2 then logEvent("TRAE 适配器装载失败: " .. tostring(terr2)) end
end
return plugin

View File

@ -1459,8 +1459,18 @@ func (p *Provider) apiErrReason(status int, raw string) string {
return fmt.Sprintf("api error %d: %s", status, types.OneLine(trimmed, 200))
}
}
log.Printf("[provider] unhandled upstream error body (adapter %q lacks transform_error): status=%d body=%.300s",
p.adapter, status, types.OneLine(raw, 300))
// ⚠️ 措辞必须**区分两种原因**(实测教训):适配器**有** transform_error 钩子
// 但它返回 nil(不认识的错误体)是**常见**情况,而"真的没这个钩子"是另一回事。
// 原先一律说 "lacks transform_error" —— 我照着这句话去适配器里找那个函数,
// 查了几轮才发现函数明明在、只是抽不出消息。
// 这里直接问内核要事实,不再猜(判据也据此断言)。
if p.vm.HasFn(p.adapter, "transform_error") {
log.Printf("[provider] unhandled upstream error body (adapter %q: transform_error returned no reason for this shape): status=%d body=%.300s",
p.adapter, status, types.OneLine(raw, 300))
} else {
log.Printf("[provider] unhandled upstream error body (adapter %q defines no transform_error): status=%d body=%.300s",
p.adapter, status, types.OneLine(raw, 300))
}
return fmt.Sprintf("api error %d: unknown error", status)
}