mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6eeb5832a3 | |||
| a189c4f61c | |||
| 58073a6cbe |
73
internal/core/channels_lua_locals_test.go
Normal file
73
internal/core/channels_lua_locals_test.go
Normal file
@ -0,0 +1,73 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ channels.lua 必须留出顶层 local 余量 —— 否则**下一次顶层改动就编译失败**。
|
||||
//
|
||||
// 实测事实(2026-10-16):该文件的主 chunk 曾**顶到 Lua 上限 200**,
|
||||
// 往顶部加**哪怕 1 个**顶层 local 就报
|
||||
//
|
||||
// main function has more than 200 local variables
|
||||
//
|
||||
// 而这炸的不是某个功能 —— 是**整个渠道插件**(插件加载失败 ⇒ 全渠道不可用)。
|
||||
//
|
||||
// ⚠️ 判据为什么用 luajit 而不是 luac:
|
||||
//
|
||||
// 内核链接的是 libluajit-5.1.so.2;而 `luac`(Lua 5.4)对同一文件给出
|
||||
// **不同的**结论。用错工具会得到假绿。
|
||||
//
|
||||
// ⚠️ 判据为什么是「注入 N 个 local 仍能编译」而不是「数一遍 local」:
|
||||
//
|
||||
// 主 chunk 的 local 数受**作用域嵌套**影响,静态计数不可靠(同一个文件
|
||||
// 用不同脚本数出 228/232/248 三个数)。而"能不能再加"是**决定性**的,
|
||||
// 也正是我们真正需要的性质。
|
||||
func TestChannelsLuaHasLocalHeadroom(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := exec.LookPath("luajit"); err != nil {
|
||||
t.Skip("luajit 不在 PATH(判据需要与内核相同的 Lua 实现)")
|
||||
}
|
||||
|
||||
const headroom = 10
|
||||
for _, n := range []int{1, headroom} {
|
||||
probe := strings.Repeat("local __headroom = 1\n", n) + src
|
||||
cmd := exec.Command("luajit", "-e", "local f,e=loadfile(arg[1]); if not f then io.stderr:write(tostring(e)) os.exit(1) end", "/dev/stdin")
|
||||
cmd.Stdin = strings.NewReader(probe)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("★ 顶层 local 余量不足:加 %d 个 local 就无法编译 ⇒ "+
|
||||
"下一次顶层改动会**整插件加载失败**(全渠道不可用)\n %s\n"+
|
||||
"修法:把纯常量收进 plugin.K 表(挂表字段不占 local 槽位)", n, strings.TrimSpace(string(out)))
|
||||
}
|
||||
}
|
||||
t.Logf("✓ channels.lua 仍有 ≥%d 个顶层 local 余量", headroom)
|
||||
}
|
||||
|
||||
// 配对判据:常量必须确实在 plugin.K 里(防止有人把余量"省"出来的同时又
|
||||
// 把常量改回裸 local —— 那会让下一个改动的余量再次归零)。
|
||||
func TestChannelsConstantsLiveInPluginK(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
if !strings.Contains(s, "plugin.K = {") {
|
||||
t.Fatal("必须有 plugin.K 常量表(它是 local 余量的来源)")
|
||||
}
|
||||
// 抽样:这些曾占用 local 槽位的纯常量必须已挂表
|
||||
for _, n := range []string{"STORE_NAME", "SESSION_TTL", "B64_ALPHABET", "MODELS_DEV_URL"} {
|
||||
if !regexp.MustCompile(`(?m)^\s+` + n + ` = `).MatchString(s) {
|
||||
t.Errorf("%s 应定义在 plugin.K 表内", n)
|
||||
}
|
||||
if regexp.MustCompile(`(?m)^local ` + n + ` = `).MatchString(s) {
|
||||
t.Errorf("%s 又变回裸 local 了(会吃掉 local 余量)", n)
|
||||
}
|
||||
}
|
||||
t.Log("✓ 纯常量已集中在 plugin.K")
|
||||
}
|
||||
@ -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> 开头则不重复插。
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
99
internal/lua/adapter_prelude_error_shapes_test.go
Normal file
99
internal/lua/adapter_prelude_error_shapes_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
97
internal/lua/adapter_zcode_base_test.go
Normal file
97
internal/lua/adapter_zcode_base_test.go
Normal 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()
|
||||
}
|
||||
@ -47,6 +47,58 @@ local plugin = {
|
||||
author = "llmsproxy",
|
||||
}
|
||||
|
||||
-- ★★ 顶层常量集中到 plugin.K:本文件主 chunk 的 local 数已**顶到 Lua 上限 200**,
|
||||
-- 再加一个顶层 local 就编译失败(实测 +1 即 `main function has more than 200
|
||||
-- local variables`),而且炸的是**整个渠道插件**(插件加载失败 = 全渠道不可用)。
|
||||
-- 挂表字段**不占** local 槽位,故把纯常量收进这里腾出余量。
|
||||
--
|
||||
-- ⚠️ 只搬**纯常量**(字符串/数字/自闭合字面量):它们没有前向引用问题,
|
||||
-- 搬迁是纯机械的。函数保持 local(它们之间的调用顺序是真实依赖)。
|
||||
plugin.K = {
|
||||
CODEARTS_SNAP = "https://snap-access.cn-north-4.myhuaweicloud.com",
|
||||
CODEARTS_LOGIN_BASE = "https://devcloud.cn-north-4.huaweicloud.com/doer/redirect",
|
||||
HUAWEI_AUTH_BASE = "https://auth.huaweicloud.com/authui/login.html",
|
||||
CODEARTS_TICKET_PLUGIN_NAME = "snap_jetbrains",
|
||||
CODEARTS_TICKET_PLUGIN_VERSION = "26.3.3",
|
||||
QODER_UTC8_OFFSET_MS = 8 * 3600 * 1000,
|
||||
QODER_REFRESH_HOUR_UTC8 = 10,
|
||||
QODER_MACHINE_ENVIRONMENT = "3",
|
||||
STORE_NAME = ".channels-store.json",
|
||||
B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
|
||||
B64_LOOKUP = {},
|
||||
SESSION_TTL = 900, -- 15 分钟,过期会话在读取时惰性清理
|
||||
MODELS_DEV_URL = "https://models.dev/api.json",
|
||||
MODELS_DEV_TTL = 6 * 3600, -- 6 小时(照参考的 TTL 量级)
|
||||
EXPIRING_WINDOW_DAYS_DEFAULT = 15,
|
||||
BALANCE_CACHE_TTL = 60,
|
||||
DEAD_MODEL_TTL_DAYS = 30,
|
||||
REMOTE_CATALOG_COOLDOWN = 30, -- 秒,照参考 REMOTE_CATALOG_COOLDOWN_MS
|
||||
INJECT_PLATFORM_LIMIT = 32,
|
||||
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 成对展示)。
|
||||
--
|
||||
@ -1139,7 +1191,6 @@ local PLATFORMS = {
|
||||
}
|
||||
|
||||
-- 华为云 snap-access 引擎域名(连接与签名都用它)。
|
||||
local CODEARTS_SNAP = "https://snap-access.cn-north-4.myhuaweicloud.com"
|
||||
|
||||
-- CodeArts(华为云 snap-access)的请求签名与每日领取。
|
||||
--
|
||||
@ -1218,7 +1269,7 @@ end
|
||||
|
||||
-- codeartsAccountInfo(cred):账号信息(statistics/plugin),判断是否积分账户。
|
||||
local function codeartsAccountInfo(cred)
|
||||
local req, err = codeartsSignedRequest("GET", CODEARTS_SNAP .. "/snap-manager/v1/statistics/plugin", cred, nil)
|
||||
local req, err = codeartsSignedRequest("GET", plugin.K.CODEARTS_SNAP .. "/snap-manager/v1/statistics/plugin", cred, nil)
|
||||
if not req then return nil, err end
|
||||
local resp, herr = __llmsproxy_http(req)
|
||||
if herr or not resp or resp.status ~= 200 then
|
||||
@ -1264,7 +1315,7 @@ local function codeartsClaim(plat, tok, args)
|
||||
|
||||
-- ② 活动列表
|
||||
local areq = codeartsSignedRequest("GET",
|
||||
CODEARTS_SNAP .. "/v1/ops/delivery?channel=IDE", cred, nil)
|
||||
plugin.K.CODEARTS_SNAP .. "/v1/ops/delivery?channel=IDE", cred, nil)
|
||||
if not areq then return nil, "活动列表请求构造失败" end
|
||||
local aresp, aerr = __llmsproxy_http(areq)
|
||||
if aerr or not aresp or aresp.status ~= 200 then
|
||||
@ -1299,7 +1350,7 @@ local function codeartsClaim(plat, tok, args)
|
||||
|
||||
-- ④ claim
|
||||
local body = json.encode({ campaignId = campaignId, channel = "IDE" })
|
||||
local creq = codeartsSignedRequest("POST", CODEARTS_SNAP .. "/v1/ops/claim", cred, body)
|
||||
local creq = codeartsSignedRequest("POST", plugin.K.CODEARTS_SNAP .. "/v1/ops/claim", cred, body)
|
||||
if not creq then return nil, "claim 请求构造失败" end
|
||||
local cresp, cerr = __llmsproxy_http(creq)
|
||||
if cerr or not cresp or cresp.status ~= 200 then
|
||||
@ -1313,7 +1364,7 @@ local function codeartsClaim(plat, tok, args)
|
||||
local benefit = cok and type(crec) == "table" and (((crec.data or {}).benefit) or nil) or nil
|
||||
if type(benefit) == "table" and benefit.id then
|
||||
local fbody = json.encode({ id = benefit.id, channel = "IDE" })
|
||||
local freq = codeartsSignedRequest("POST", CODEARTS_SNAP .. "/v1/ops/confirm", cred, fbody)
|
||||
local freq = codeartsSignedRequest("POST", plugin.K.CODEARTS_SNAP .. "/v1/ops/confirm", cred, fbody)
|
||||
if freq then
|
||||
local fresp, ferr = __llmsproxy_http(freq)
|
||||
-- 只在真正失败时降级提示,不改变整体成败(积分已在待确认态)
|
||||
@ -1360,7 +1411,7 @@ end
|
||||
-- OAUTH_THEME = 2(Dark) OAUTH_LOCALE = zh-cn
|
||||
-- code_challenge_method = **SHA-256**(不是 S256!portal 以此识别 OAuth,
|
||||
-- 错值会回退到旧 ticket 流程)
|
||||
-- 旧流程回退(ticket):CODEARTS_LOGIN_BASE + /snap-manager/v1/login/ticket
|
||||
-- 旧流程回退(ticket):plugin.K.CODEARTS_LOGIN_BASE + /snap-manager/v1/login/ticket
|
||||
-- ⚠️ 常量收进**表**(`plugin.consts`)而不是顶层 `local`。
|
||||
-- 原因很实际:本文件顶层 local 已**顶到 Lua 单函数 200 个上限**,多一个就让
|
||||
-- 整个插件**加载失败**(`too many local variables`);报错在编译期,
|
||||
@ -1591,8 +1642,8 @@ local function codeartsTicketPoll(ticketId, secret, pluginName, pluginVersion)
|
||||
local resp = __llmsproxy_http({
|
||||
url = url, method = "GET",
|
||||
headers = { ["Content-Type"] = "application/json;charset=UTF-8",
|
||||
["plugin-name"] = pluginName or CODEARTS_TICKET_PLUGIN_NAME,
|
||||
["plugin-version"] = pluginVersion or CODEARTS_TICKET_PLUGIN_VERSION },
|
||||
["plugin-name"] = pluginName or plugin.K.CODEARTS_TICKET_PLUGIN_NAME,
|
||||
["plugin-version"] = pluginVersion or plugin.K.CODEARTS_TICKET_PLUGIN_VERSION },
|
||||
})
|
||||
if resp and resp.status == 200 then
|
||||
local ok, rec = pcall(json.decode, resp.body or "")
|
||||
@ -1621,12 +1672,8 @@ local function codeartsTicketPoll(ticketId, secret, pluginName, pluginVersion)
|
||||
end
|
||||
|
||||
-- 旧 ticket 流程(portal 判定新式不可用时回退)
|
||||
local CODEARTS_LOGIN_BASE = "https://devcloud.cn-north-4.huaweicloud.com/doer/redirect"
|
||||
local HUAWEI_AUTH_BASE = "https://auth.huaweicloud.com/authui/login.html"
|
||||
local CODEARTS_CREDENTIAL_ENDPOINT =
|
||||
"https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/login/ticket"
|
||||
local CODEARTS_TICKET_PLUGIN_NAME = "snap_jetbrains"
|
||||
local CODEARTS_TICKET_PLUGIN_VERSION = "26.3.3"
|
||||
|
||||
-- 生成 PKCE 配对(照参考 generatePkcePair:verifier = 48 随机字节的 base64url,
|
||||
-- challenge = sha256(verifier) 的 base64url)。
|
||||
@ -1665,8 +1712,6 @@ end
|
||||
QODER_SASH_BASE = "https://openapi.qoder.sh"
|
||||
|
||||
-- UTC+8 偏移(毫秒)。活动按 UTC+8 结算,口径与参考一致。
|
||||
local QODER_UTC8_OFFSET_MS = 8 * 3600 * 1000
|
||||
local QODER_REFRESH_HOUR_UTC8 = 10
|
||||
|
||||
-- hasQoderCampaignRefreshedToday():今天的活动列表是否已刷新。
|
||||
local function qoderCampaignRefreshed(nowMs)
|
||||
@ -1676,8 +1721,8 @@ local function qoderCampaignRefreshed(nowMs)
|
||||
-- **必须用算术平移**判 UTC+8,不能用 os.date("*t")(本机时区):
|
||||
-- 活动按 UTC+8 结算,取本机时区在出差/改时区时会判错方向
|
||||
-- (偏东提前把当天记为已处理、真漏领;偏西一天判两次)。
|
||||
local utc8 = os.date("!*t", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000))
|
||||
return (utc8.hour or 0) >= QODER_REFRESH_HOUR_UTC8
|
||||
local utc8 = os.date("!*t", math.floor((nowMs + plugin.K.QODER_UTC8_OFFSET_MS) / 1000))
|
||||
return (utc8.hour or 0) >= plugin.K.QODER_REFRESH_HOUR_UTC8
|
||||
end
|
||||
|
||||
-- 读数字/字符串(容忍字符串形式与缺失),照参考 readNumber/readString
|
||||
@ -1895,7 +1940,6 @@ end
|
||||
-- 4. 两者都拿不到时**照常发请求**(不带这两个头)—— 保守降级,
|
||||
-- 纯插件登录、未装桌面端的用户没有该文件,不能让整个积分功能报错。
|
||||
-- 5. 缓存解析结果:拿不到时也记 null,不反复读盘/反复 spawn。
|
||||
local QODER_MACHINE_ENVIRONMENT = "3"
|
||||
|
||||
local function qoderMachineCache()
|
||||
plugin.state.qoderMachine = plugin.state.qoderMachine or { done = false }
|
||||
@ -1972,7 +2016,7 @@ local function qoderReadFromRuntimeInfo()
|
||||
f:write('{"account":""}')
|
||||
f:close()
|
||||
local cmd = ("timeout 20 %s %s %s < %s 2>/dev/null"):format(
|
||||
exe, QODER_MACHINE_ENVIRONMENT, "--account-stdin", tmp)
|
||||
exe, plugin.K.QODER_MACHINE_ENVIRONMENT, "--account-stdin", tmp)
|
||||
local h = io.popen(cmd)
|
||||
local out = h and h:read("*a") or ""
|
||||
if h then h:close() end
|
||||
@ -2566,7 +2610,6 @@ end
|
||||
-- 即便这个文件泄露,也不含任何 token。
|
||||
--
|
||||
-- 写入:tmp + rename 原子替换。读:缺失/损坏 → 用内置默认值(不阻塞启动)。
|
||||
local STORE_NAME = ".channels-store.json"
|
||||
|
||||
-- storeDir 求插件目录:优先 config.yaml 的 plugin_dir,否则用本文件所在目录。
|
||||
-- 插件与内核同机、有完整 OS 权限,读自己的配置是能力范围内的事。
|
||||
@ -2625,7 +2668,7 @@ local PERSIST_KEYS = {
|
||||
|
||||
local function persistState()
|
||||
local dir = storeDir()
|
||||
local path = dir .. "/" .. STORE_NAME
|
||||
local path = dir .. "/" .. plugin.K.STORE_NAME
|
||||
local snap = {}
|
||||
for _, k in ipairs(PERSIST_KEYS) do
|
||||
if plugin.state[k] ~= nil then snap[k] = plugin.state[k] end
|
||||
@ -2680,7 +2723,7 @@ end
|
||||
-- 迁移后删除遗留文件:留着它只会让「哪个文件权威」变成永久的疑团。
|
||||
local function loadOwnSnapshot()
|
||||
local dir = storeDir()
|
||||
local f = io.open(dir .. "/" .. STORE_NAME, "r")
|
||||
local f = io.open(dir .. "/" .. plugin.K.STORE_NAME, "r")
|
||||
if f then
|
||||
local txt = f:read("*a")
|
||||
f:close()
|
||||
@ -2716,7 +2759,7 @@ local function migrateLegacyKernelState()
|
||||
local written, werr = persistState()
|
||||
if written then
|
||||
os.remove(legacy)
|
||||
io.stderr:write("[channels] 已迁移遗留状态到 " .. STORE_NAME .. "\n")
|
||||
io.stderr:write("[channels] 已迁移遗留状态到 " .. plugin.K.STORE_NAME .. "\n")
|
||||
else
|
||||
io.stderr:write("[channels] 遗留状态迁移写入失败(保留原文件): "
|
||||
.. tostring(werr) .. "\n")
|
||||
@ -2946,16 +2989,14 @@ end
|
||||
-- 为读一个 JWT 声明而给内核加一个通用解码函数,是把插件的事推给内核 ——
|
||||
-- 插件能做就该自己做。纯 Lua 实现约 20 行,无依赖、无内核改动。
|
||||
-- 接受标准/URL-safe 两种字母表,容忍缺失填充。
|
||||
local B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
local B64_LOOKUP = {}
|
||||
for i = 1, #B64_ALPHABET do B64_LOOKUP[B64_ALPHABET:sub(i, i)] = i - 1 end
|
||||
for i = 1, #plugin.K.B64_ALPHABET do plugin.K.B64_LOOKUP[plugin.K.B64_ALPHABET:sub(i, i)] = i - 1 end
|
||||
local function b64url_decode(input)
|
||||
if type(input) ~= "string" or input == "" then return nil end
|
||||
-- 归一化:URL-safe → 标准,去空白,去掉填充(下面按 4 位一组补零处理)
|
||||
local s = input:gsub("%s", ""):gsub("-", "+"):gsub("_", "/"):gsub("=", "")
|
||||
local out, acc, bits = {}, 0, 0
|
||||
for i = 1, #s do
|
||||
local v = B64_LOOKUP[s:sub(i, i)]
|
||||
local v = plugin.K.B64_LOOKUP[s:sub(i, i)]
|
||||
if v == nil then return nil end -- 非法字符:如实失败,不猜
|
||||
acc = acc * 64 + v
|
||||
bits = bits + 6
|
||||
@ -3948,7 +3989,6 @@ end
|
||||
-- 登录会话放 plugin.state.sessions:内核没有"登录"概念,这正是插件该自己干的
|
||||
-- 事。表结构 { id, platform, flow, url, user_code, nonce, state, account,
|
||||
-- created_at, expires_at }。nonce 只在 begin 响应里出现一次,存侧不外泄。
|
||||
local SESSION_TTL = 900 -- 15 分钟,过期会话在读取时惰性清理
|
||||
|
||||
newToken = function()
|
||||
-- 用 hmac 自己跟自己造一个不可猜的 id(内核给过 sha256_hex)
|
||||
@ -4967,7 +5007,7 @@ local function initLoginSession(plat, args, now)
|
||||
nonce = newToken():sub(1, 32),
|
||||
state = "pending",
|
||||
created_at = now,
|
||||
expires_at = now + SESSION_TTL,
|
||||
expires_at = now + plugin.K.SESSION_TTL,
|
||||
}
|
||||
if plat.id == "lobsterai" then
|
||||
local seed = newToken()
|
||||
@ -6077,8 +6117,6 @@ plugin.schedule = {
|
||||
-- ⚠️ 我们额外的一条:本插件是**服务端**进程、没有"渲染路径",
|
||||
-- 但 5MB 的下载仍不能放在请求路径上 —— 故挂在 refresh_models 定时任务里,
|
||||
-- 与模型目录刷新同一节拍。请求路径只读缓存。
|
||||
local MODELS_DEV_URL = "https://models.dev/api.json"
|
||||
local MODELS_DEV_TTL = 6 * 3600 -- 6 小时(照参考的 TTL 量级)
|
||||
--
|
||||
-- ⚠️ **已知取不到,属设计约束而非故障**:该端点实测 **5.64 MB**,而内核的
|
||||
-- `__llmsproxy_http` 有 **4 MiB 响应上限**(`httpMaxResponse`,刻意的堆保护 ——
|
||||
@ -6141,11 +6179,11 @@ end
|
||||
local function refreshCapabilities()
|
||||
local cached = plugin.state.capabilitiesMeta
|
||||
local now = os.time and os.time() or 0
|
||||
if cached and cached.at and cached.at <= now and (now - cached.at) < MODELS_DEV_TTL then
|
||||
if cached and cached.at and cached.at <= now and (now - cached.at) < plugin.K.MODELS_DEV_TTL then
|
||||
return false, "未到刷新时间"
|
||||
end
|
||||
local resp, err = __llmsproxy_http({
|
||||
url = MODELS_DEV_URL, method = "GET", timeout_ms = 30000,
|
||||
url = plugin.K.MODELS_DEV_URL, method = "GET", timeout_ms = 30000,
|
||||
headers = { ["Accept"] = "application/json" },
|
||||
})
|
||||
if err or not resp or resp.status ~= 200 then
|
||||
@ -6296,7 +6334,6 @@ end
|
||||
-- → 17 > 15 ⇒ 全算永久 ⇒ 锁定后立刻「无可用账号」
|
||||
-- 中国版按 30 天发包,故"距到期"天然落在 17~30 天 —— 判据本身没错,这类池
|
||||
-- 想用锁定就得把窗口放宽(如设 31)。窗口**只能由用户显式改**,默认 15。
|
||||
local EXPIRING_WINDOW_DAYS_DEFAULT = 15
|
||||
|
||||
expiringWindowDays = function()
|
||||
-- 优先级:settings(面板可改)→ env(部署可改)→ 默认 15。
|
||||
@ -6316,7 +6353,7 @@ expiringWindowDays = function()
|
||||
local n = tonumber(raw)
|
||||
if n and n >= 0 then return n end
|
||||
end
|
||||
return EXPIRING_WINDOW_DAYS_DEFAULT
|
||||
return plugin.K.EXPIRING_WINDOW_DAYS_DEFAULT
|
||||
end
|
||||
|
||||
-- balanceTier(expiring, permanent, allow_permanent):
|
||||
@ -6344,7 +6381,6 @@ end
|
||||
-- 开着、时间只向前流:一笔距到期 15.02 天的余额,在用户什么都不做的两分钟里就
|
||||
-- 越过了线。若把分类也缓存,选号会继续按"永久"处理一笔马上要作废的积分
|
||||
-- (锁定时更糟:本该可用的号被判成不可用)。故命中缓存也**每次重新分桶**。
|
||||
local BALANCE_CACHE_TTL = 60
|
||||
|
||||
-- (余额缓存改用通用 TTL 缓存后,这个助手不再需要)
|
||||
|
||||
@ -6360,7 +6396,7 @@ local function accountBalanceRaw(provider, accountId)
|
||||
if not tok or tok == "" then return nil end
|
||||
local plate = plat
|
||||
local act = acct
|
||||
return ttlCacheGet("balance", provider .. ":" .. accountId, BALANCE_CACHE_TTL, function()
|
||||
return ttlCacheGet("balance", provider .. ":" .. accountId, plugin.K.BALANCE_CACHE_TTL, function()
|
||||
-- 复用既有的余额抓取(credits_balances 用的同一个助手,避免两处口径漂移)
|
||||
local rec, err = fetchAccountJSON(plate.balance, tok,
|
||||
credentialFull(act.credentialRef), plate)
|
||||
@ -6500,7 +6536,6 @@ local function isModelGone(message)
|
||||
end
|
||||
|
||||
-- 失效记录 TTL(秒)。照参考默认 30 天;过期自动复活,避免永久隐藏。
|
||||
local DEAD_MODEL_TTL_DAYS = 30
|
||||
|
||||
local function deadModelsOf(provider)
|
||||
plugin.state.deadModels = plugin.state.deadModels or {}
|
||||
@ -6523,7 +6558,7 @@ end
|
||||
deadModelIds = function(provider)
|
||||
local p = deadModelsOf(provider)
|
||||
local now = os.time and os.time() or 0
|
||||
local ttl = DEAD_MODEL_TTL_DAYS * 86400
|
||||
local ttl = plugin.K.DEAD_MODEL_TTL_DAYS * 86400
|
||||
local out = {}
|
||||
for id, meta in pairs(p) do
|
||||
local at = type(meta) == "table" and tonumber(meta.at) or 0
|
||||
@ -6567,7 +6602,6 @@ end
|
||||
--
|
||||
-- ⚠️ 本 Lua 无协程(单 VM 单锁),做不了"共享同一个 Promise"。用**时间戳
|
||||
-- 闸门**达到等价效果:记录 in_flight_at(防同轮重复)+ retry_at(失败冷却)。
|
||||
local REMOTE_CATALOG_COOLDOWN = 30 -- 秒,照参考 REMOTE_CATALOG_COOLDOWN_MS
|
||||
|
||||
local function catalogGateOf(provider)
|
||||
plugin.state.catalogGate = plugin.state.catalogGate or {}
|
||||
@ -6585,7 +6619,7 @@ local function catalogGateCooling(provider, now)
|
||||
local g = catalogGateOf(provider)
|
||||
if not g.retry_at then return false end
|
||||
if now >= g.retry_at then return false end
|
||||
if g.retry_at - now > REMOTE_CATALOG_COOLDOWN then return false end
|
||||
if g.retry_at - now > plugin.K.REMOTE_CATALOG_COOLDOWN then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
@ -6603,7 +6637,7 @@ end
|
||||
local function catalogGateEnd(provider, got, now)
|
||||
local g = catalogGateOf(provider)
|
||||
g.in_flight_at = nil
|
||||
g.retry_at = got and nil or (now + REMOTE_CATALOG_COOLDOWN)
|
||||
g.retry_at = got and nil or (now + plugin.K.REMOTE_CATALOG_COOLDOWN)
|
||||
end
|
||||
|
||||
-- refresh_models():定时把远端模型目录拉回来并重同步所有源。
|
||||
@ -6725,9 +6759,9 @@ end
|
||||
-- 出差/改时区会把日界判错方向。
|
||||
local function todayKey()
|
||||
local nowMs = (os.time and os.time() or 0) * 1000
|
||||
local utc8 = os.date("!*d", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000))
|
||||
local utc8m = os.date("!*m", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000))
|
||||
local utc8y = os.date("!*Y", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000))
|
||||
local utc8 = os.date("!*d", math.floor((nowMs + plugin.K.QODER_UTC8_OFFSET_MS) / 1000))
|
||||
local utc8m = os.date("!*m", math.floor((nowMs + plugin.K.QODER_UTC8_OFFSET_MS) / 1000))
|
||||
local utc8y = os.date("!*Y", math.floor((nowMs + plugin.K.QODER_UTC8_OFFSET_MS) / 1000))
|
||||
return tostring(utc8y) .. "-" .. tostring(utc8m) .. "-" .. tostring(utc8)
|
||||
end
|
||||
|
||||
@ -8260,7 +8294,6 @@ end
|
||||
-- 语义:整表替换(与 set_endpoints 的按字段覆盖不同)—— 注入是声明式的
|
||||
-- “这就是本轮我要用的目录”,避免测试之间残留状态。传空数组即清空。
|
||||
-- 容量上限:注入属于运维/测试能力,不承担生产目录职责,限 32 条够用。
|
||||
local INJECT_PLATFORM_LIMIT = 32
|
||||
-- ============================================================================
|
||||
-- 服务端浏览器栈的运维面(依赖预检 / 取凭据 / 停栈)
|
||||
-- ============================================================================
|
||||
@ -8420,8 +8453,8 @@ function plugin.inject_platforms(req)
|
||||
if type(list) ~= "table" then
|
||||
return { status = 400, json = { error = "platforms 必须是数组" } }
|
||||
end
|
||||
if #list > INJECT_PLATFORM_LIMIT then
|
||||
return { status = 400, json = { error = "最多注入 " .. INJECT_PLATFORM_LIMIT .. " 个平台" } }
|
||||
if #list > plugin.K.INJECT_PLATFORM_LIMIT then
|
||||
return { status = 400, json = { error = "最多注入 " .. plugin.K.INJECT_PLATFORM_LIMIT .. " 个平台" } }
|
||||
end
|
||||
-- 校验:每条必须有 id,且不得与内建目录重名(注入不能劫持真实厂商)。
|
||||
local builtin = {}
|
||||
@ -9256,7 +9289,6 @@ end
|
||||
-- isAutoCheckinExcluded):代价发生在调用期间(拉 headful Chromium +
|
||||
-- 阿里云按设备限流 150 次/小时),"等它报错再判定"来不及。
|
||||
-- * 用户**手动**点领取时带上 captcha param 即可走通(面板交互)。
|
||||
local ZCODE_ORIGIN = "https://zcode.z.ai"
|
||||
|
||||
-- zcodeHeaders:照参考 buildZcodeHeaders。X-Device-Mid 是**硬需求**
|
||||
-- (缺它 billing 全家桶回 400)。
|
||||
@ -9280,7 +9312,7 @@ end
|
||||
-- zcodeClaimablePlans(cred):查可领 plan(preview)。
|
||||
local function zcodeClaimablePlans(cred)
|
||||
local av = tostring(cred.app_version or "1.0.0")
|
||||
local url = ZCODE_ORIGIN .. "/api/v1/zcode-plan/billing/preview"
|
||||
local url = plugin.K.ZCODE_ORIGIN .. "/api/v1/zcode-plan/billing/preview"
|
||||
.. "?app_version=" .. rfc3986Escape(av) .. "&platform=win32"
|
||||
local resp, err = __llmsproxy_http({
|
||||
url = url, method = "GET",
|
||||
@ -9310,7 +9342,7 @@ function claimZcode(plat, tok, args)
|
||||
|
||||
-- ① 补激活上报(照参考:不需要 Authorization)
|
||||
__llmsproxy_http({
|
||||
url = ZCODE_ORIGIN .. "/api/v1/event/report", method = "POST",
|
||||
url = plugin.K.ZCODE_ORIGIN .. "/api/v1/event/report", method = "POST",
|
||||
headers = { ["Content-Type"] = "application/json" },
|
||||
body = json.encode({ event = "activation", app_version = tostring(cred.app_version or "1.0.0") }),
|
||||
})
|
||||
@ -9339,7 +9371,7 @@ function claimZcode(plat, tok, args)
|
||||
local granted, failedMsg = 0, nil
|
||||
for _, planId in ipairs(plans) do
|
||||
local resp, err = __llmsproxy_http({
|
||||
url = ZCODE_ORIGIN .. "/api/v1/zcode-plan/billing/claim", method = "POST",
|
||||
url = plugin.K.ZCODE_ORIGIN .. "/api/v1/zcode-plan/billing/claim", method = "POST",
|
||||
headers = zcodeHeaders(cred, {
|
||||
authorization = "Bearer " .. tostring(jwt),
|
||||
captcha = { param = captcha.param, region = captcha.region or "cn" },
|
||||
@ -10191,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
|
||||
@ -10607,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)——
|
||||
-- 否则"请勿连续重试"这句话会只在一条路径上有(参考为此专门抽成单一份)。
|
||||
@ -10617,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)
|
||||
@ -11734,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:只给测试/诊断用 —— 手动触发一次源同步并
|
||||
@ -11840,4 +11882,6 @@ do
|
||||
if not tok2 then logEvent("TRAE 适配器装载失败: " .. tostring(terr2)) end
|
||||
end
|
||||
|
||||
|
||||
|
||||
return plugin
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user