mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 20:27:23 +00:00
merge: 回流 release/v2.0.x 到 main(一次性对齐 110 个提交)
main 与 release/v2.0.x 已分叉 110 个提交:main 上只有回流时产生的冲突修复 (internal/billing 删除补齐、billing.lua 冲突标记清理)与一次中性化提交, 它们的内容在 release 上均有等价提交。逐条 cherry-pick 无法完成对齐,且会让 main 长期停留在缺少 discover/scheduler/channels 修复的状态(实测 main 上 `TestRealMutants` 因 discover.go 版本过旧而报锚点缺失)。 本次用一次 merge 完成对齐: - 冲突统一取 release/v2.0.x 侧(main 侧只有等价的回流残留修复); - `packaging/llmsproxy.service` 随改名删除,由 modelrouter.service 取代; - 冲突标记扫描为空。 验证(本 worktree,对齐后):go build -tags luajit ./... OK; internal/config、internal/lua(63.9s)、internal/gateway(123.2s,含 TestRealMutants)、internal/core(451.6s) 全绿;node 插件测试 4/4。 后续回流纪律不变:发版打 tag 后从 tag cherry-pick 回 main,不再整支 merge。
This commit is contained in:
@ -161,7 +161,7 @@ func (r *RetryPolicy) DelayDuration() time.Duration {
|
||||
|
||||
// Load reads and validates a config file. When the file does not exist yet a
|
||||
// default config is generated at that path first (first-run bootstrap), so a
|
||||
// fresh binary just works: `llmsproxy -config /path/to/config.yaml`.
|
||||
// fresh binary just works: `modelrouter -config /path/to/config.yaml`.
|
||||
func Load(path string) (*Config, error) {
|
||||
if _, err := EnsureDefault(path); err != nil {
|
||||
return nil, err
|
||||
|
||||
@ -27,8 +27,9 @@ func NewGatewayKey() (string, error) {
|
||||
|
||||
// SecretBox encrypts and decrypts sensitive values (upstream API keys,
|
||||
// custom header values, gateway keys) for persist-time protection. The
|
||||
// master key comes from the LLMS_PROXY_MASTER_KEY environment variable
|
||||
// (hex, 64 chars) or from a generated <runtime-dir>/master.key file.
|
||||
// master key comes from the MODELROUTER_MASTER_KEY environment variable
|
||||
// (hex, 64 chars; the pre-rename LLMS_PROXY_MASTER_KEY is still accepted) or
|
||||
// from a generated <runtime-dir>/master.key file.
|
||||
type SecretBox struct {
|
||||
aead cipher.AEAD
|
||||
}
|
||||
@ -37,10 +38,18 @@ type SecretBox struct {
|
||||
// file next to the runtime file. If neither exists, a fresh random key is
|
||||
// generated and written to master.key with mode 0600.
|
||||
func NewSecretBox(runtimeFile string) (*SecretBox, error) {
|
||||
if v := os.Getenv("LLMS_PROXY_MASTER_KEY"); v != "" {
|
||||
key, err := hex.DecodeString(strings.TrimSpace(v))
|
||||
// New name first, then the pre-rename name: an existing unit file that still
|
||||
// exports LLMS_PROXY_MASTER_KEY must keep starting after an upgrade.
|
||||
envKey := os.Getenv("MODELROUTER_MASTER_KEY")
|
||||
envName := "MODELROUTER_MASTER_KEY"
|
||||
if envKey == "" {
|
||||
envKey = os.Getenv("LLMS_PROXY_MASTER_KEY")
|
||||
envName = "LLMS_PROXY_MASTER_KEY"
|
||||
}
|
||||
if envKey != "" {
|
||||
key, err := hex.DecodeString(strings.TrimSpace(envKey))
|
||||
if err != nil || len(key) != 32 {
|
||||
return nil, fmt.Errorf("LLMS_PROXY_MASTER_KEY: expected 64 hex chars")
|
||||
return nil, fmt.Errorf("%s: expected 64 hex chars", envName)
|
||||
}
|
||||
return newBox(key)
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// callback 平台的授权 URL 是最容易"看起来对、实际不可用"的地方:URL 能拼出来、
|
||||
|
||||
@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// 三个 callback 平台的回调路径**各是注册值**,且校验规则不同:
|
||||
|
||||
@ -3,7 +3,7 @@ package core
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// ★ 无凭据账号的批量清理:**只删没有凭据的**,有凭据的一律不动。
|
||||
|
||||
@ -8,7 +8,7 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// ★ codearts 的模型目录必须从**两个**签名端点拉取并合并。
|
||||
|
||||
@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// CodeArts 端到端:起真实回调服务器 → 模上游回 code → 走真实 STS 交换
|
||||
|
||||
@ -4,7 +4,7 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// CodeArts 的 portal 拒绝 <10000 的回调端口(参考 listenOnCallbackPort 的
|
||||
|
||||
@ -7,7 +7,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// 有道每日活动领取是**三步流程**,参考注释点名:无可用活动是**正常业务状态**,
|
||||
|
||||
@ -2,7 +2,7 @@ package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// 有道登录闭环的判据:portal 会把授权重定向回 **本机 loopback 接收器**
|
||||
|
||||
@ -8,7 +8,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// loomyHarness:Loomy 微信扫码长轮询的测试台。
|
||||
|
||||
@ -8,7 +8,7 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// 远端模型目录的判据:**远端为主 + 兜底补位**(照参考 reconcileWithFallback)。
|
||||
|
||||
@ -9,7 +9,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// channelsMoreTest:新引入的三条真实流程 + 两个边界流的行为对齐测试。
|
||||
|
||||
@ -5,12 +5,12 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// ★ 插件加载时必须清掉上一进程留下的孤儿回调文件。
|
||||
//
|
||||
// 真实事故:生产 `/etc/llmsproxy/plugins/` 攒了 **18 个 callback-*.port**
|
||||
// 真实事故:生产 `/etc/modelrouter/plugins/` 攒了 **18 个 callback-*.port**
|
||||
// (对应的 .json 早已消失)。它们永远不会被清,因为:
|
||||
//
|
||||
// · loopbacks 追踪表只活在内存(plugin.state.loopbacks);
|
||||
@ -25,7 +25,7 @@ func TestPluginLoadSweepsOrphanCallbackFiles(t *testing.T) {
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
|
||||
t.Setenv("MODELROUTER_PLUGIN_DIR", pluginDir)
|
||||
|
||||
// 摆上一轮的残留(模拟上一进程被强杀后的现场)
|
||||
orphans := []string{
|
||||
|
||||
56
internal/core/channels_platforms_logged_in_test.go
Normal file
56
internal/core/channels_platforms_logged_in_test.go
Normal file
@ -0,0 +1,56 @@
|
||||
package core
|
||||
|
||||
import "testing"
|
||||
|
||||
// `platforms` 的 logged_in 必须看**账号池**,不能只看旧单账号保险库键。
|
||||
//
|
||||
// 真实缺陷(2026-10-20 用户报「codearts 还是不可用」):
|
||||
// `list_platforms` 当时写的是 `credentialOf("channels:" .. id)` —— 那是旧版
|
||||
// 单账号的键;而现网凭据键是 `channels:<platform>:<accountId>`(一平台多账号)。
|
||||
// 于是**所有**通过账号池接入的平台都被判成未登录,UI 把渠道卡片渲染成"未接入",
|
||||
// 用户看到的正是"渠道不可用",而底层模型其实完全能用。
|
||||
//
|
||||
// 判据钉两层:池里的账号让 logged_in=true,且账号标识能被 UI 拿到。
|
||||
func TestPlatformsLoggedInFromAccountPool(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "codearts", "account_id": "acct-pool-1",
|
||||
"credential": `{"access_key_id":"AK","secret_access_key":"SK","security_token":"ST"}`,
|
||||
"nickname": "pool-nick",
|
||||
}); err != nil {
|
||||
t.Fatalf("account_add: %v", err)
|
||||
}
|
||||
// list_platforms 是 HTTP 路由处理器(不在 plugin.exports 里),走探针取同一份数据
|
||||
m, err := ps.Call("channels", "__platforms_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("__platforms_probe: %v", err)
|
||||
}
|
||||
w, _ := m.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
arr, _ := j["platforms"].([]interface{})
|
||||
if len(arr) == 0 {
|
||||
t.Fatalf("platforms 不应为空")
|
||||
}
|
||||
var got map[string]interface{}
|
||||
for _, raw := range arr {
|
||||
p, _ := raw.(map[string]interface{})
|
||||
if p["id"] == "codearts" {
|
||||
got = p
|
||||
}
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("platforms 里找不到 codearts")
|
||||
}
|
||||
if got["logged_in"] != true {
|
||||
t.Fatalf("账号池里有凭据的账号,logged_in 必须为 true;实得 %#v", got)
|
||||
}
|
||||
if got["account_id"] != "acct-pool-1" {
|
||||
t.Errorf("account_id 应回池里的账号 id;实得 %#v", got["account_id"])
|
||||
}
|
||||
if got["account"] != "pool-nick" {
|
||||
t.Errorf("account 应回池里的昵称;实得 %#v", got["account"])
|
||||
}
|
||||
if s, _ := got["accounts"].(float64); s < 1 {
|
||||
t.Errorf("accounts 计数应 >=1;实得 %#v", got["accounts"])
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// TestChannelsPluginEndToEnd 是「同类插件」的完整验收:
|
||||
|
||||
@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// ★ 「为什么没有可用账号」必须区分**真的用尽**与**查不到**。
|
||||
|
||||
@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// qoderReason 调真实的 transform_error 钩子(不自己重算)。
|
||||
|
||||
@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// Qoder 活动领取有三条参考用**抓包 + 用户报障**定位过的「会误报」陷阱,
|
||||
|
||||
@ -8,7 +8,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// qoderHarness:Qoder PKCE 设备码轮的测试台。
|
||||
|
||||
@ -8,7 +8,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// raccoonHarness:Raccoon 内置二维码扫码登录的测试台。
|
||||
|
||||
@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// ★ 删除账号必须**真的把源撤掉** —— 且撤不掉时不能静默。
|
||||
@ -190,8 +190,14 @@ func TestChannelsDeclaresEveryKernelActionItCalls(t *testing.T) {
|
||||
|
||||
// ② 抽出实际调用的动作
|
||||
called := map[string]bool{}
|
||||
for _, m := range regexp.MustCompile(`__llmsproxy_kernel\.call\(\s*"([a-z_]+)"`).FindAllStringSubmatch(s, -1) {
|
||||
called[m[1]] = true
|
||||
// 插件只允许走公开 SDK(modelrouter.kernel);旧名 __llmsproxy_kernel 是
|
||||
// 兼容别名,新代码用它就说明插件又在依赖内核私有全局了。
|
||||
for _, m := range regexp.MustCompile(`\b(?:modelrouter|__llmsproxy_kernel)\.kernel\.call\(\s*"([a-z_]+)"|\b__llmsproxy_kernel\.call\(\s*"([a-z_]+)"`).FindAllStringSubmatch(s, -1) {
|
||||
name := m[1]
|
||||
if name == "" {
|
||||
name = m[2] // 旧名的分支
|
||||
}
|
||||
called[name] = true
|
||||
}
|
||||
if len(called) == 0 {
|
||||
t.Fatal("没扫描到任何内核调用(判据失效)")
|
||||
|
||||
@ -3,7 +3,7 @@ package core
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// ★ account_reorder 必须**真的改变内核里的源顺序**。
|
||||
|
||||
@ -33,8 +33,8 @@ func TestAccountPoolStoreIsAtomicAndConsistent(t *testing.T) {
|
||||
storePath := filepath.Join(dir, ".channels-store.json")
|
||||
t.Logf("插件存储目录: %s", dir)
|
||||
// ⚠️ 必须在**测试目录**里,绝不能是生产路径
|
||||
if _, err := os.Stat("/etc/llmsproxy/plugins/.channels-store.json"); err == nil {
|
||||
if filepath.Clean(dir) == "/etc/llmsproxy/plugins" {
|
||||
if _, err := os.Stat("/etc/modelrouter/plugins/.channels-store.json"); err == nil {
|
||||
if filepath.Clean(dir) == "/etc/modelrouter/plugins" {
|
||||
t.Fatal("测试正在使用生产存储目录 —— 隔离失效(这会造成真实数据事故)")
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// TestChannelsStoreIsPluginOwned 是「插件自持持久化」的回归。
|
||||
@ -23,7 +23,7 @@ func TestChannelsStoreIsPluginOwned(t *testing.T) {
|
||||
// 固定插件目录:两次「重启」必须共用同一目录,否则测不出持久化。
|
||||
// 这也是防止测试写进生产插件目录的那道隔离(kernelTestCore 会 respect 它)。
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", dir)
|
||||
t.Setenv("MODELROUTER_PLUGIN_DIR", dir)
|
||||
load := func() *lua.Plugins {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
@ -128,6 +128,10 @@ func TestChannelsMigratesLegacyKernelState(t *testing.T) {
|
||||
|
||||
c := kernelTestCore(t)
|
||||
dir = c.Config().PluginDir
|
||||
// 网关不再自动创建 plugin_dir(插件不由内核安装),测试自己建。
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyPath := filepath.Join(dir, ".channels.state.json")
|
||||
if err := os.WriteFile(legacyPath, b, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
@ -179,7 +183,7 @@ func TestChannelsMigrationKeepsLegacyOnWriteFailure(t *testing.T) {
|
||||
// (<dir>/.channels-store.json.tmp)**占成一个目录**,io.open(tmp,"w") 必失败。
|
||||
// 不用 chmod:测试以 root 跑,root 绕过权限位,chmod 拦不住。
|
||||
sub := t.TempDir()
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", sub)
|
||||
t.Setenv("MODELROUTER_PLUGIN_DIR", sub)
|
||||
|
||||
legacy := map[string]interface{}{
|
||||
"version": 1,
|
||||
|
||||
@ -11,8 +11,8 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// mustBox 构造一个真 SecretBox(与运行期同一实现,密钥落 runtime dir)。
|
||||
|
||||
@ -9,7 +9,7 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// ★ TRAE 的 batch 模型目录解析:两个**真实缺陷**必须在移植时一起带上
|
||||
@ -120,23 +120,23 @@ func newTraeCatalogHarness(t *testing.T, onBody func(string)) *traeCatalogHarnes
|
||||
// ① solo_agent:glm-5.2 有档位;另有三个必被硬性过滤的条目
|
||||
`{"function":"solo_agent","config_info_list":[`+
|
||||
`{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true,`+
|
||||
`"reasoning_effort_config":{"support_thinking":true,"options":["high","low"]}},`+
|
||||
`"reasoning_effort_config":{"support_thinking":true,"options":["high","low"]}}`+
|
||||
`]},`+
|
||||
// ② solo_work_lite 排在**后面**:glm-5.2 在这里**没有**档位配置。
|
||||
// 另三个"必被过滤"的条目也放这里 —— 生产里适配器恒发这条通道,
|
||||
// 硬性过滤的判据必须在这条通道上被验证到(放 solo_agent 会因为
|
||||
// admit_functions 策略先行剔除,测试就测不到过滤逻辑了)。
|
||||
`{"function":"solo_work_lite","config_info_list":[`+
|
||||
`{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true},`+
|
||||
`{"config_name":"summary-model","usage":"summary","config_switch":true},`+
|
||||
`{"config_name":"disabled-model","usage":"chat_completion","config_switch":false},`+
|
||||
`{"config_name":"hidden-model","usage":"chat_completion","config_switch":true,"is_invisible_to_user":true},`+
|
||||
`{"config_name":"no-function-model","usage":"chat_completion","config_switch":true},`+
|
||||
// is_custom_model=true ⇒ 上游以流内 4001 拒绝("仅可见但不可调用")
|
||||
`{"config_name":"custom-flag-model","usage":"chat_completion","config_switch":true,`+
|
||||
`"display_config":{"is_custom_model":true}},`+
|
||||
// 对照组:名字带 custom_model_ 前缀但**标志为 false** ⇒ 必须**保留**
|
||||
// (参考点名:判据是标志的值,不是模型名)
|
||||
`{"config_name":"custom_model_prefix_but_ok","usage":"chat_completion","config_switch":true,`+
|
||||
`"display_config":{"is_custom_model":false}}`+
|
||||
`]},`+
|
||||
// ② solo_work_lite 排在**后面**:glm-5.2 在这里**没有**档位配置
|
||||
`{"function":"solo_work_lite","config_info_list":[`+
|
||||
`{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true}`+
|
||||
`]},`+
|
||||
// ③ 白名单外通道:整组必须被丢弃
|
||||
`{"function":"inline_chat","config_info_list":[`+
|
||||
`{"config_name":"only-in-bad-channel","usage":"chat_completion","config_switch":true}`+
|
||||
|
||||
@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// ★ TRAE 的**推理请求**必须用 Cloud-IDE-JWT 头族,不是 Bearer。
|
||||
|
||||
@ -6,7 +6,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// Trae 签到是最容易被"看起来对"骗过的地方:请求能发出去、上游可能也返回 200,
|
||||
|
||||
@ -6,7 +6,7 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// 手工移植的确定性派生必须与参考**逐字节一致**,否则签名的设备身份是错的
|
||||
|
||||
@ -6,7 +6,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// ★ TRAE ExchangeToken 的凭据在 **Result 里**,不在顶层。
|
||||
|
||||
@ -9,7 +9,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// ★ TRAE 回调只带 code(PKCE 形态)时必须**明确报出**「暂不支持 PKCE」,
|
||||
|
||||
@ -10,7 +10,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// traeHarness:TRAE callback 流的测试台。假上游模拟 ExchangeToken;
|
||||
|
||||
@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// TestChannelsVncStackEndToEnd 验证 VNC 兜底链路在**真插件**上跑通:
|
||||
|
||||
@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// 显示号泄漏是「测试单独跑绿、全量跑红」的根源,值得一条判据钉住它。
|
||||
@ -34,7 +34,7 @@ func TestXDisplaySelectionReclaimsStaleLocks(t *testing.T) {
|
||||
// (见 kernelTestCore);硬编码 90 段会让本判据造出**根本不会被扫描到**的
|
||||
// 陈旧锁,于是"没复用"这个断言永远失败 —— 那是判据与被测对象脱节。
|
||||
base := 300
|
||||
if v := os.Getenv("LLMSPROXY_XDISPLAY_BASE"); v != "" {
|
||||
if v := os.Getenv("MODELROUTER_XDISPLAY_BASE"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
base = n
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// ★ ZCode 的身份块是 3012 准入的**唯一开关**。
|
||||
@ -269,7 +269,7 @@ func TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(t *testing.T) {
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
|
||||
t.Setenv("MODELROUTER_PLUGIN_DIR", pluginDir)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
|
||||
@ -10,7 +10,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// zcodeHarness:ZCode 两段式服务端中介授权流的测试台。
|
||||
|
||||
@ -16,10 +16,10 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/lua"
|
||||
"llmsproxy/internal/provider"
|
||||
"llmsproxy/internal/scheduler"
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/lua"
|
||||
"modelrouter/internal/provider"
|
||||
"modelrouter/internal/scheduler"
|
||||
)
|
||||
|
||||
// Core owns the running configuration and adapters. mu guards every read and
|
||||
@ -95,9 +95,6 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
|
||||
for name, aud := range cfg.PluginVisibility {
|
||||
c.plugins.SetPluginVisibility(name, aud)
|
||||
}
|
||||
if err := c.plugins.SeedBundled(); err != nil {
|
||||
log.Printf("[core] seed bundled plugins: %v", err)
|
||||
}
|
||||
if err := c.plugins.LoadDir(); err != nil {
|
||||
log.Printf("[core] plugin dir: %v", err)
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"modelrouter/internal/config"
|
||||
)
|
||||
|
||||
// newTestConfig returns an in-memory config backed by temp files so
|
||||
|
||||
@ -8,7 +8,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// readBundledChannels 读内置 channels 插件源(供行为/变异测试共用)。
|
||||
|
||||
@ -6,8 +6,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// 内核动作实现(R4-c)
|
||||
|
||||
@ -6,8 +6,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// 本文件验证 R4-c/R4-d 的闭环:**插件自己驱动外部源的链接认证,然后把源接进内核**。
|
||||
@ -22,12 +22,12 @@ func kernelTestCore(t *testing.T) *Core {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
// 插件的自有存储位置由插件自己决定(读 config.yaml 的 plugin_dir,或
|
||||
// LLMSPROXY_PLUGIN_DIR 覆盖)。测试**必须**指向临时目录 —— 否则插件会读到
|
||||
// /etc/llmsproxy/config.yaml 并写进**生产插件目录**(真实事故:一次全量测试
|
||||
// MODELROUTER_PLUGIN_DIR 覆盖)。测试**必须**指向临时目录 —— 否则插件会读到
|
||||
// /etc/modelrouter/config.yaml 并写进**生产插件目录**(真实事故:一次全量测试
|
||||
// 把 26 个测试账号写进了生产 .channels-store.json)。
|
||||
// 调用方已显式设定时尊重它:多次「重启」要共用同一目录才能测持久化。
|
||||
if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" {
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
|
||||
if os.Getenv("MODELROUTER_PLUGIN_DIR") == "" {
|
||||
t.Setenv("MODELROUTER_PLUGIN_DIR", pluginDir)
|
||||
}
|
||||
// ★ 显示器号与端口用**高段号**,避开生产。
|
||||
//
|
||||
@ -36,8 +36,8 @@ func kernelTestCore(t *testing.T) *Core {
|
||||
// 并去抢,结果 `Xvfb 未就绪(/tmp/.X11-unix/X91 未出现)`,而生产上正跑着
|
||||
// 一套浏览器栈。判据本身没错,错的是**两个 /tmp 互不可见**而号段重叠。
|
||||
// 故测试整体挪到 300+。
|
||||
if os.Getenv("LLMSPROXY_XDISPLAY_BASE") == "" {
|
||||
t.Setenv("LLMSPROXY_XDISPLAY_BASE", "300")
|
||||
if os.Getenv("MODELROUTER_XDISPLAY_BASE") == "" {
|
||||
t.Setenv("MODELROUTER_XDISPLAY_BASE", "300")
|
||||
}
|
||||
cfg := &config.Config{
|
||||
Path: filepath.Join(dir, "config.yaml"),
|
||||
|
||||
@ -4,7 +4,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// TestNoKernelActionDeadlocksFromLua 对**每个**内核动作做同一条断言:
|
||||
|
||||
@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"modelrouter/internal/config"
|
||||
)
|
||||
|
||||
// AutoChainFor sits on the request hot path and reads keyAutoChains with no
|
||||
|
||||
@ -4,8 +4,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/scheduler"
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/scheduler"
|
||||
)
|
||||
|
||||
// Per-key AUTO chains let an admin give one user their own scheduling chain
|
||||
|
||||
@ -4,7 +4,7 @@ import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"modelrouter/internal/config"
|
||||
)
|
||||
|
||||
// The plugin outbound allowlist is built from the hosts of configured sources.
|
||||
|
||||
@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"modelrouter/internal/config"
|
||||
)
|
||||
|
||||
// Startup must be idempotent, and every credential consumer must see PLAINTEXT.
|
||||
|
||||
@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"modelrouter/internal/config"
|
||||
)
|
||||
|
||||
type adapterPayload struct {
|
||||
@ -488,7 +488,7 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if to == 0 {
|
||||
to = time.Now().UnixMilli()
|
||||
}
|
||||
csvHeaders(w, "llmsproxy-requests.csv")
|
||||
csvHeaders(w, "modelrouter-requests.csv")
|
||||
cw := csv.NewWriter(w)
|
||||
names := map[string]string{}
|
||||
for _, k := range g.core.ListKeys() {
|
||||
@ -537,7 +537,7 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("export") == "keys-csv" {
|
||||
csvHeaders(w, "llmsproxy-keys.csv")
|
||||
csvHeaders(w, "modelrouter-keys.csv")
|
||||
cw := csv.NewWriter(w)
|
||||
_ = cw.Write([]string{"key", "key_name", "role", "models", "total_requests", "success_requests", "failed_requests", "prompt_tokens", "completion_tokens", "total_tokens", "avg_latency_ms", "max_latency_ms", "created_at"})
|
||||
keyFilter := exportKey(r)
|
||||
|
||||
@ -31,7 +31,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"modelrouter/internal/config"
|
||||
)
|
||||
|
||||
// apiV1Routes dispatches /api/v1/*. Kept separate from routes() so the original
|
||||
@ -144,7 +144,7 @@ func (g *Gateway) apiV1Index(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
index := map[string]interface{}{
|
||||
"api_version": "v1",
|
||||
"description": "llmsproxy management API. Authenticate with a gateway key: " +
|
||||
"description": "modelrouter management API. Authenticate with a gateway key: " +
|
||||
"'Authorization: Bearer <key>', '?api_key=<key>', or the gw_key cookie. " +
|
||||
"Read endpoints accept any role; mutations require an admin key.",
|
||||
"discovery": []ep{
|
||||
|
||||
@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"modelrouter/internal/config"
|
||||
)
|
||||
|
||||
// v1Gateway builds a gateway with one source and two keys, and returns the
|
||||
|
||||
@ -13,11 +13,11 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/lua"
|
||||
"llmsproxy/internal/provider"
|
||||
"llmsproxy/internal/scheduler"
|
||||
"llmsproxy/internal/types"
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/lua"
|
||||
"modelrouter/internal/provider"
|
||||
"modelrouter/internal/scheduler"
|
||||
"modelrouter/internal/types"
|
||||
)
|
||||
|
||||
// chatRequest mirrors the OpenAI chat completions request the gateway accepts.
|
||||
|
||||
@ -3,7 +3,7 @@ package gateway
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/types"
|
||||
"modelrouter/internal/types"
|
||||
)
|
||||
|
||||
// TestMergeUsageKeepsZeroCacheDetails locks the streaming counterpart of the
|
||||
|
||||
@ -3,7 +3,7 @@ package gateway
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/types"
|
||||
"modelrouter/internal/types"
|
||||
)
|
||||
|
||||
// TestRecordChatUsageKeepsReasoningTokens pins the reasoning-token plumbing.
|
||||
|
||||
@ -11,7 +11,7 @@ import (
|
||||
|
||||
// deploy.sh is the only release-critical script with no automated coverage, and
|
||||
// its newest part — prune_adapter_backups — deletes directories under
|
||||
// /etc/llmsproxy. This exercises the function in a throwaway directory with the
|
||||
// /etc/modelrouter. This exercises the function in a throwaway directory with the
|
||||
// REAL backup-name shape.
|
||||
//
|
||||
// The shape matters and got this wrong twice: the guard is
|
||||
@ -45,7 +45,7 @@ func TestDeployPruneAdapterBackups(t *testing.T) {
|
||||
|
||||
// Redirect ONLY the base path. The logic under test must stay verbatim.
|
||||
body = strings.Replace(body,
|
||||
`local base="/etc/llmsproxy"`,
|
||||
`local base="/etc/modelrouter"`,
|
||||
`local base="`+base+`"`, 1)
|
||||
// KEEP_BACKUPS must come from deploy.sh itself, NOT from a literal
|
||||
// here: hardcoding 5 in the shim overrode whatever the script configured, so
|
||||
@ -207,7 +207,7 @@ func TestDeployBacksUpTheLiveFileBeforeOverwriting(t *testing.T) {
|
||||
//
|
||||
// 事实:backup 清理最初只处理 `adapters.bak.*`,而 `sync_plugins` 同样每次部署
|
||||
// 建一个 `plugins.bak.<时间戳>` —— 于是 adapters 的堆积被修好,plugins 继续涨。
|
||||
// 实测线上 plugins.bak 积累到 **36 个**、/etc/llmsproxy 涨到 192M。
|
||||
// 实测线上 plugins.bak 积累到 **36 个**、/etc/modelrouter 涨到 192M。
|
||||
//
|
||||
// 这是「两处只修一处」的老形状。判据钉住:prune_all_backups 必须同时裁两个前缀,
|
||||
// 且新增任何 .bak.<时间戳> 前缀时都要在这里补上(否则又是一个泄漏点)。
|
||||
@ -264,7 +264,7 @@ func TestDeployPruneCoversBothBackupPrefixes(t *testing.T) {
|
||||
k := strings.Index(src, "prune_backups()")
|
||||
kj := strings.Index(src[k:], "\n}\n")
|
||||
fnBody := src[k : k+kj+3]
|
||||
fnBody = strings.Replace(fnBody, `local base="/etc/llmsproxy"`, `local base="`+base+`"`, 1)
|
||||
fnBody = strings.Replace(fnBody, `local base="/etc/modelrouter"`, `local base="`+base+`"`, 1)
|
||||
keepRe := regexp.MustCompile(`(?m)^KEEP_BACKUPS=(\d+)`)
|
||||
km := keepRe.FindStringSubmatch(src)
|
||||
if km == nil {
|
||||
|
||||
@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"modelrouter/internal/config"
|
||||
)
|
||||
|
||||
// Model discovery: turn "here is a URL and a key" into a configured source.
|
||||
|
||||
@ -5,7 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"llmsproxy/internal/config"
|
||||
"modelrouter/internal/config"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"modelrouter/internal/config"
|
||||
)
|
||||
|
||||
// upstream that answers /v1/models with the real captured catalog
|
||||
|
||||
@ -126,7 +126,12 @@ func TestRealMutants(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "nested metadata no longer read",
|
||||
from: "\tcopyFields(m.Metadata) // nested first",
|
||||
// 锚点必须与 discover.go 逐字节一致:此前这里是 6 个空格,源码已
|
||||
// 变成 2 个空格,于是这条变异永远"找不到锚点"——变异campaign 每次
|
||||
// 都以 anchor not found 失败,而它本该验证"嵌套 metadata 被丢掉"能被
|
||||
// 测试抓住。锚点漂移 = 这条变异从未真正跑过(和 issue #1 里
|
||||
// `cc["models"]` 恒 false 的测试跳过是同一种失效:断言写了但没执行)。
|
||||
from: "\tcopyFields(m.Metadata) // nested first",
|
||||
to: "\tcopyFields(json.RawMessage(nil)) // mutant: nested metadata dropped",
|
||||
},
|
||||
{
|
||||
|
||||
@ -14,8 +14,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/core"
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/core"
|
||||
)
|
||||
|
||||
func mockUpstream() *httptest.Server {
|
||||
|
||||
@ -9,8 +9,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/core"
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/core"
|
||||
)
|
||||
|
||||
// Per-key AUTO chain admin API. The behaviours pinned here are the ones that
|
||||
|
||||
@ -9,8 +9,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/core"
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/core"
|
||||
)
|
||||
|
||||
// adminGateway builds a gateway whose admin key can call /api/keys.
|
||||
|
||||
@ -4,7 +4,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"modelrouter/internal/config"
|
||||
)
|
||||
|
||||
// ---- data layer: per-key isolation ----
|
||||
|
||||
@ -12,8 +12,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/core"
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/core"
|
||||
)
|
||||
|
||||
// quotaGateway builds a gateway with two user keys that differ only in their
|
||||
|
||||
@ -6,8 +6,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/scheduler"
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/scheduler"
|
||||
)
|
||||
|
||||
// handleKeysAPI manages gateway keys: GET /api/keys (admin: all keys),
|
||||
|
||||
@ -10,9 +10,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/provider"
|
||||
"llmsproxy/internal/scheduler"
|
||||
"llmsproxy/internal/types"
|
||||
"modelrouter/internal/provider"
|
||||
"modelrouter/internal/scheduler"
|
||||
"modelrouter/internal/types"
|
||||
)
|
||||
|
||||
// handleMessages serves the Anthropic Messages protocol (POST /v1/messages).
|
||||
|
||||
@ -11,8 +11,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/core"
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/core"
|
||||
)
|
||||
|
||||
// 本文件端到端验证 Phase 2 的核心主张:**对外协议由适配器决定,内核只调度**。
|
||||
|
||||
@ -21,7 +21,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// allowedMountPrefixes are the only namespaces a plugin/adapters may claim.
|
||||
|
||||
@ -10,8 +10,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/core"
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/core"
|
||||
)
|
||||
|
||||
// 本文件锁定 R3 的两条不变量:
|
||||
|
||||
@ -6,8 +6,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/scheduler"
|
||||
"llmsproxy/internal/types"
|
||||
"modelrouter/internal/scheduler"
|
||||
"modelrouter/internal/types"
|
||||
)
|
||||
|
||||
// piOverflowRe 是 @earendil-works/pi-ai 的 OVERFLOW_PATTERNS 中对应的一条。
|
||||
|
||||
@ -17,8 +17,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/core"
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/core"
|
||||
)
|
||||
|
||||
// callPlugin exposes exactly one export plus a function that is NOT exported.
|
||||
|
||||
@ -21,7 +21,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// pluginHTTPPrefix is the mount point. It is deliberately narrow: a plugin's
|
||||
|
||||
@ -9,8 +9,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/core"
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/core"
|
||||
)
|
||||
|
||||
// newFailingServer serves handler and returns its test server.
|
||||
|
||||
@ -9,7 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@ -9,9 +9,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/core"
|
||||
"llmsproxy/internal/lua"
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/core"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// gatewayWithBilling boots a gateway with the bundled billing plugin loaded, so
|
||||
|
||||
@ -29,9 +29,9 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"llmsproxy/internal/provider"
|
||||
"llmsproxy/internal/types"
|
||||
"modelrouter/internal/lua"
|
||||
"modelrouter/internal/provider"
|
||||
"modelrouter/internal/types"
|
||||
)
|
||||
|
||||
// StreamState 是一个流会话在序列化侧的累积状态(角色前导是否已发、已见
|
||||
|
||||
@ -8,8 +8,8 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
"llmsproxy/internal/types"
|
||||
"modelrouter/internal/lua"
|
||||
"modelrouter/internal/types"
|
||||
)
|
||||
|
||||
// 本文件锁定对外序列化的**优先级契约**:
|
||||
|
||||
@ -18,8 +18,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/core"
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/core"
|
||||
)
|
||||
|
||||
//go:embed ui/*
|
||||
|
||||
@ -9,8 +9,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/core"
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/core"
|
||||
)
|
||||
|
||||
// This file covers the second round of the same class of defect bf0657b fixed.
|
||||
|
||||
@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"modelrouter/internal/config"
|
||||
)
|
||||
|
||||
// These cover the two write-path defects found while adding a model to a live
|
||||
|
||||
@ -22,7 +22,7 @@ import (
|
||||
// /api/stats?period=day&limit=1&key=sk-gw-7ffe12c5cbacec40bd39a7291df9a9ed
|
||||
// → curl: (52) Empty reply from server
|
||||
// → log: http: panic serving …: invalid memory address or nil pointer dereference
|
||||
// llmsproxy/internal/gateway.rows stats.go:771
|
||||
// modelrouter/internal/gateway.rows stats.go:771
|
||||
//
|
||||
// A key with no recorded traffic is a legitimate question, and zero is its
|
||||
// answer — not a crash.
|
||||
|
||||
@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/types"
|
||||
"modelrouter/internal/types"
|
||||
)
|
||||
|
||||
// TestThinkingFromClient 把「客户端措辞 → 中立语义」这条翻译链钉死。
|
||||
|
||||
@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"modelrouter/internal/config"
|
||||
)
|
||||
|
||||
// Token accounting must record TOKENS, not byte estimates and not image
|
||||
|
||||
@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/provider"
|
||||
"modelrouter/internal/provider"
|
||||
)
|
||||
|
||||
// TestUI_ChatPageGroupsImageModels is a UI-contract guard for the bug that
|
||||
|
||||
@ -21,10 +21,11 @@ func pluginLoaded(ps *Plugins, name string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// The billing plugin ships with the gateway, so its arithmetic is a contract:
|
||||
// a wrong price silently produces wrong money. These tests drive it through the
|
||||
// real hook path and check the NUMBERS, not merely that it loads.
|
||||
|
||||
// billing lives in the repository as an optional plugin, NOT inside the binary.
|
||||
// The gateway never installs it; an operator (or a test) places the file in
|
||||
// plugin_dir. Its arithmetic is still a contract — a wrong price silently
|
||||
// produces wrong money — so these tests drive it through the real hook path and
|
||||
// check the NUMBERS, not merely that it loads.
|
||||
func billingVM(t *testing.T) (*Plugins, string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(t.TempDir(), "adapters")
|
||||
@ -35,15 +36,30 @@ func billingVM(t *testing.T) (*Plugins, string) {
|
||||
t.Cleanup(vm.Stop)
|
||||
pdir := filepath.Join(t.TempDir(), "plugins")
|
||||
ps := NewPlugins(vm, pdir)
|
||||
if err := ps.SeedBundled(); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
installRepoPlugin(t, pdir, "billing")
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
return ps, pdir
|
||||
}
|
||||
|
||||
// installRepoPlugin copies a repo-example plugin into an operator plugin dir.
|
||||
// It stands in for the operator action that provisioning normally performs;
|
||||
// using it keeps "the gateway does not install plugins" true even in tests.
|
||||
func installRepoPlugin(t *testing.T, dir, name string) {
|
||||
t.Helper()
|
||||
src, err := ReadBundledPlugin(name)
|
||||
if err != nil {
|
||||
t.Fatalf("read repo plugin %s: %v", name, err)
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", dir, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name+".lua"), []byte(src), 0o644); err != nil {
|
||||
t.Fatalf("install %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// stateOf reads the plugin's published state as a generic map.
|
||||
func stateOf(t *testing.T, ps *Plugins) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
@ -16,7 +16,7 @@ import (
|
||||
// 端点还指着公开路径 —— 凭据与协议都不匹配。
|
||||
func TestChannelsGeminiPrivateProtocol(t *testing.T) {
|
||||
vm, ps, _ := runtimePlugins(t)
|
||||
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
|
||||
src, err := ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ import (
|
||||
// 可信:副本拿不到内核注入的共享前导,且无法发现"装了但没生效"。
|
||||
func TestChannelsTraeSoloAdapter(t *testing.T) {
|
||||
vm, ps, _ := runtimePlugins(t)
|
||||
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
|
||||
src, err := ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@ -8,27 +8,82 @@ import (
|
||||
golua "github.com/aarzilli/golua/lua"
|
||||
)
|
||||
|
||||
// Package lua — Lua 绑定:凭据保险库 + 内核动作面
|
||||
// Package lua — the PLUGIN SDK surface.
|
||||
//
|
||||
// 这两个全局把"插件自己主导链接认证"所需的两块补齐:
|
||||
// A plugin is an external Lua file that talks to the gateway through exactly
|
||||
// one published table, `modelrouter`. Nothing else in this package is part of
|
||||
// the plugin contract: the underlying globals below are private implementation
|
||||
// and may be renamed or removed. They are still installed, as deprecated
|
||||
// aliases, so plugins written against the pre-SDK names keep working.
|
||||
//
|
||||
// __llmsproxy_secrets —— 安全存放 OAuth token / session / 设备指纹
|
||||
// __llmsproxy_kernel —— 触发内核动作(注册源、探测、清冷却、改链路…)
|
||||
// modelrouter.sdk_version — string, bumped when the SDK shape changes
|
||||
// modelrouter.http(spec) — allowlisted outbound HTTP
|
||||
// modelrouter.secrets — this plugin's credential vault
|
||||
// modelrouter.kernel — kernel actions (grant-gated)
|
||||
// modelrouter.adapters — install/update/remove this plugin's adapters
|
||||
// modelrouter.plugin_dir — this plugin's own directory
|
||||
//
|
||||
// 注册点与 __llmsproxy_http 相同:只在 PLUGIN 的 bootPluginState 里装,
|
||||
// 因为适配器共用 setupGlobals 而不该拿到这些能力。
|
||||
//
|
||||
// 两张表都用 buildJSONTable 的既有写法(NewTable + PushGoClosure + SetField),
|
||||
// 该写法在 json 表上长期稳定,也是内核自己的用法。
|
||||
// Why a single table instead of individual globals: the gateway must be able to
|
||||
// change how a capability is implemented (rename a global, add a guard, swap a
|
||||
// closure for a table) without breaking every installed plugin. A versioned
|
||||
// table is the seam that makes that possible; scattered private globals are not.
|
||||
|
||||
// secretGlobal 是凭据保险库的 Lua 名字。
|
||||
// SDKVersion is the published plugin SDK version. Bump it whenever a field is
|
||||
// removed or its calling convention changes. Additive fields do not bump it.
|
||||
const SDKVersion = "1.0"
|
||||
|
||||
// sdkGlobal is the ONLY public entry point for plugins. It carries the product
|
||||
// name, not the historical repository name: a plugin author writes
|
||||
// `modelrouter.http(...)`, and the name they learn is the one on the box.
|
||||
const sdkGlobal = "modelrouter"
|
||||
|
||||
// legacySDKGlobal is the pre-rename spelling, kept as an alias to the SAME table
|
||||
// so already-installed plugins keep working. It is not documented and no new
|
||||
// plugin should use it.
|
||||
const legacySDKGlobal = "llmsproxy"
|
||||
|
||||
// registerSDK publishes the SDK table. It must run AFTER every bridge is
|
||||
// installed, and it shares the already-registered values rather than re-creating
|
||||
// them, so a plugin cannot end up with two divergent credential vaults.
|
||||
func registerSDK(L *golua.State) {
|
||||
L.NewTable()
|
||||
L.PushString(SDKVersion)
|
||||
L.SetField(-2, "sdk_version")
|
||||
|
||||
members := []struct{ global, field string }{
|
||||
{httpGlobal, "http"},
|
||||
{secretGlobal, "secrets"},
|
||||
{kernelGlobal, "kernel"},
|
||||
{adapterBridgeGlobal, "adapters"},
|
||||
{pluginDirGlobal, "plugin_dir"},
|
||||
}
|
||||
for _, m := range members {
|
||||
L.GetGlobal(m.global)
|
||||
if L.IsNil(-1) {
|
||||
// Capability not granted for this state (e.g. deny-all outbound
|
||||
// HTTP). Leave the field absent instead of publishing nil, so a
|
||||
// plugin sees a missing member rather than a callable that fails
|
||||
// in an unexpected way.
|
||||
L.Pop(1)
|
||||
continue
|
||||
}
|
||||
L.SetField(-2, m.field)
|
||||
}
|
||||
L.SetGlobal(sdkGlobal)
|
||||
// Same table object under the historical name: a plugin that mixes
|
||||
// `modelrouter.secrets` and `llmsproxy.secrets` still hits one vault.
|
||||
L.GetGlobal(sdkGlobal)
|
||||
L.SetGlobal(legacySDKGlobal)
|
||||
}
|
||||
|
||||
// secretGlobal is the deprecated alias for llmsproxy.secrets.
|
||||
const secretGlobal = "__llmsproxy_secrets"
|
||||
|
||||
// pluginDirGlobal carries the plugin's OWN directory as ground truth.
|
||||
//
|
||||
// Why it must come from the kernel (live defect): the channels plugin decides
|
||||
// where to keep its state by reading /etc/llmsproxy/config.yaml when
|
||||
// LLMSPROXY_PLUGIN_DIR is unset. Tests load the real plugin but never set that
|
||||
// where to keep its state by reading /etc/modelrouter/config.yaml when
|
||||
// MODELROUTER_PLUGIN_DIR is unset. Tests load the real plugin but never set that
|
||||
// variable, so they read AND WROTE the production store — one full
|
||||
// `go test ./internal/lua` replaced the production account pool with the test's
|
||||
// empty one (13 accounts → 0; the credentials stayed in the vault, so the
|
||||
|
||||
@ -31,13 +31,18 @@ func runtimePlugins(t *testing.T) (*VM, *Plugins, string) {
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
pdir := fmt.Sprintf("%s/plugins", t.TempDir())
|
||||
// ⚠️ **必须**设 LLMSPROXY_PLUGIN_DIR。channels 插件用 storeDir() 决定自己的
|
||||
// 存储位置,而 storeDir 在环境变量缺失时会去读 /etc/llmsproxy/config.yaml ——
|
||||
// ⚠️ **必须**设 MODELROUTER_PLUGIN_DIR。channels 插件用 storeDir() 决定自己的
|
||||
// 存储位置,而 storeDir 在环境变量缺失时会去读 /etc/modelrouter/config.yaml ——
|
||||
// 那是**生产路径**。曾经因此把生产账号池覆盖成测试的空 pool
|
||||
// (真实事故:一次全量 internal/lua 后 /etc/llmsproxy/plugins/
|
||||
// (真实事故:一次全量 internal/lua 后 /etc/modelrouter/plugins/
|
||||
// .channels-store.json 里 13 个账号变成 0 个,凭据却还留在保险库)。
|
||||
if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" {
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", pdir)
|
||||
// 两个名字都要设:插件先看新名 MODELROUTER_PLUGIN_DIR,再回退旧名。
|
||||
// 只设旧名时,若宿主机恰好导出了新名,测试就会写进别人的目录。
|
||||
if os.Getenv("MODELROUTER_PLUGIN_DIR") == "" {
|
||||
t.Setenv("MODELROUTER_PLUGIN_DIR", pdir)
|
||||
}
|
||||
if os.Getenv("MODELROUTER_PLUGIN_DIR") == "" {
|
||||
t.Setenv("MODELROUTER_PLUGIN_DIR", pdir)
|
||||
}
|
||||
ps := NewPlugins(vm, pdir)
|
||||
ps.SetOutboundHosts([]string{"127.0.0.1"})
|
||||
|
||||
133
internal/lua/plugin_sdk_test.go
Normal file
133
internal/lua/plugin_sdk_test.go
Normal file
@ -0,0 +1,133 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
golua "github.com/aarzilli/golua/lua"
|
||||
)
|
||||
|
||||
// 本文件钉住"插件与内核之间只有一条公开接口"这条契约。
|
||||
//
|
||||
// 背景:插件曾经直接调用 `__llmsproxy_*` 这类内核私有全局。它们是实现细节,
|
||||
// 却被当成 API 使用,于是内核无法在不破坏所有已安装插件的前提下改实现
|
||||
// (重命名、加守卫、把闭包换成表都会直接炸插件)。现在插件只有一个入口
|
||||
// `llmsproxy`,旧全局降级为兼容别名。
|
||||
//
|
||||
// 这些断言咬的是"SDK 是唯一入口"本身,而不是某个能力恰好能用:
|
||||
// - 表存在、带版本号;
|
||||
// - 每个成员与内部桥是**同一个对象**(不是复制品,复制会让凭据保险库分叉);
|
||||
// - 通过 SDK 能真正完成一次内核交互;
|
||||
// - 未授予的能力是"缺失"而不是"存在但一调用就报错"。
|
||||
|
||||
const sdkProbe = `
|
||||
local plugin = { name = "sdkprobe", version = "1.0" }
|
||||
plugin.exports = { "surface", "roundtrip" }
|
||||
|
||||
function plugin.surface()
|
||||
local sdk = modelrouter
|
||||
if sdk == nil then return { has_sdk = false } end
|
||||
return {
|
||||
has_sdk = true,
|
||||
sdk_version = sdk.sdk_version,
|
||||
http_fn = type(sdk.http),
|
||||
secrets_t = type(sdk.secrets),
|
||||
kernel_t = type(sdk.kernel),
|
||||
adapters_t = type(sdk.adapters),
|
||||
plugin_dir_t = type(sdk.plugin_dir),
|
||||
-- 与旧别名是同一张表?是则 tostring 相同。
|
||||
secrets_aliased = tostring(sdk.secrets) == tostring(__llmsproxy_secrets),
|
||||
kernel_aliased = tostring(sdk.kernel) == tostring(__llmsproxy_kernel),
|
||||
-- 产品名是 modelrouter;llmsproxy 只作为历史别名,且必须指向同一张表。
|
||||
legacy_is_same = tostring(llmsproxy) == tostring(modelrouter),
|
||||
-- 插件目录必须是内核给出的真实路径,而不是让插件自己猜。
|
||||
plugin_dir = sdk.plugin_dir,
|
||||
}
|
||||
end
|
||||
|
||||
-- 只通过 SDK 完成一次写读,证明它不是一个空壳表。
|
||||
function plugin.roundtrip()
|
||||
local ok, err = modelrouter.secrets.set("probe-key", "probe-value")
|
||||
if not ok then return { ok = false, err = err } end
|
||||
local got = modelrouter.secrets.get("probe-key")
|
||||
local actions = modelrouter.kernel.actions()
|
||||
local n = 0
|
||||
for _ in ipairs(actions) do n = n + 1 end
|
||||
return { ok = got == "probe-value", got = got, actions = n }
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
|
||||
func TestPluginSDKIsTheSingleEntryPoint(t *testing.T) {
|
||||
_, ps, _ := runtimePlugins(t)
|
||||
if err := ps.LoadSource("sdkprobe", sdkProbe); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := ps.Call("sdkprobe", "surface", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("SDK 探测失败: %v", err)
|
||||
}
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("返回值不是 table: %#v", out)
|
||||
}
|
||||
if m["has_sdk"] != true {
|
||||
t.Fatal("插件 VM 里没有 modelrouter —— 插件将只能依赖内核私有全局")
|
||||
}
|
||||
if m["legacy_is_same"] != true {
|
||||
t.Error("llmsproxy 别名与 modelrouter 不是同一张表 —— 凭据/内核动作面会分叉")
|
||||
}
|
||||
if m["sdk_version"] != SDKVersion {
|
||||
t.Errorf("sdk_version = %v, 期望 %q(版本号是插件判断契约的依据)",
|
||||
m["sdk_version"], SDKVersion)
|
||||
}
|
||||
// http 是函数,其余是表:这个形状就是文档里的形状,写错会让插件
|
||||
// 在第一次网络调用时才炸。
|
||||
wantTypes := map[string]string{
|
||||
"http_fn": "function", "secrets_t": "table", "kernel_t": "table",
|
||||
"adapters_t": "table", "plugin_dir_t": "string",
|
||||
}
|
||||
for k, want := range wantTypes {
|
||||
if got := m[k]; got != want {
|
||||
t.Errorf("%s = %v, 期望 %s", k, got, want)
|
||||
}
|
||||
}
|
||||
// 复制品会让 secrets 变成两张表:插件写进 A、内核从 B 读。
|
||||
if m["secrets_aliased"] != true {
|
||||
t.Error("llmsproxy.secrets 与内部桥不是同一张表 —— 凭据会分叉")
|
||||
}
|
||||
if m["kernel_aliased"] != true {
|
||||
t.Error("llmsproxy.kernel 与内部桥不是同一张表")
|
||||
}
|
||||
if m["plugin_dir"] == "" {
|
||||
t.Error("llmsproxy.plugin_dir 为空 —— 插件会退回去猜路径(曾覆盖生产数据)")
|
||||
}
|
||||
|
||||
// 端到端:SDK 不是声明,而是能真正驱动内核。
|
||||
rout, err := ps.Call("sdkprobe", "roundtrip", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("SDK 往返失败: %v", err)
|
||||
}
|
||||
rm, ok := rout.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("返回值不是 table: %#v", rout)
|
||||
}
|
||||
if rm["ok"] != true {
|
||||
t.Errorf("经 SDK 写读凭据失败: %#v", rm)
|
||||
}
|
||||
if n, _ := rm["actions"].(float64); n == 0 {
|
||||
t.Error("llmsproxy.kernel.actions() 为空 —— SDK 没有暴露内核动作面")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdapterVMHasNoPluginSDK 是安全边界:适配器与插件共用 setupGlobals,
|
||||
// 但 SDK 只装在插件 VM 上。适配器是运维提供的配置,不该拿到凭据库与内核动作面。
|
||||
func TestAdapterVMHasNoPluginSDK(t *testing.T) {
|
||||
L := golua.NewState()
|
||||
defer L.Close()
|
||||
L.OpenLibs()
|
||||
setupGlobals(L)
|
||||
L.GetGlobal(sdkGlobal)
|
||||
if L.Type(-1) != golua.LUA_TNIL {
|
||||
t.Error("setupGlobals 安装了 llmsproxy —— 适配器 VM 也会拿到凭据库与内核动作面")
|
||||
}
|
||||
}
|
||||
@ -215,9 +215,9 @@ type Plugin struct {
|
||||
exports []string
|
||||
// script is the plugin's source, kept so a reload can rebuild its state.
|
||||
script string
|
||||
// Builtin marks a plugin that shipped with the gateway. The UI shows it as
|
||||
// such so an operator can tell "example I can read" from "mine", and a
|
||||
// delete of a builtin is allowed but re-seeds on a fresh plugin dir.
|
||||
// Builtin is retained in the API for compatibility. Business plugins are
|
||||
// operator-owned files; the gateway never classifies, replaces, or seeds
|
||||
// them based on their contents.
|
||||
Builtin bool
|
||||
|
||||
// state is the plugin's SINGLE authoritative Lua state, guarded by mu.
|
||||
@ -746,29 +746,9 @@ func (ps *Plugins) LoadDir() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeedBundled writes the plugins shipped with the gateway into dir when the
|
||||
// directory does not exist yet, mirroring the adapter seeding rule: once the
|
||||
// directory exists it is authoritative, so deleting or editing a shipped plugin
|
||||
// is a real action that survives restarts.
|
||||
// SeedBundled writes the plugins shipped with the gateway into dir when they are
|
||||
// MISSING.
|
||||
//
|
||||
// This used to be "write everything only when the directory does not exist",
|
||||
// which silently stopped delivering plugins added to the binary LATER: the
|
||||
// directory had existed since the first install, so a newly embedded plugin
|
||||
// (e.g. channels.lua) never landed on an upgraded deployment — it was in the
|
||||
// binary and completely invisible in /api/plugins. The directory's mere
|
||||
// existence is not evidence that its contents are current.
|
||||
//
|
||||
// The rule is now per-file: seed a bundled plugin only when a file of that name
|
||||
// is absent. An OPERATOR'S file always wins, so customising or replacing a
|
||||
// bundled plugin survives an upgrade — the same rule adapters already use.
|
||||
func (ps *Plugins) SeedBundled() error {
|
||||
if ps.dir == "" {
|
||||
return nil
|
||||
}
|
||||
return writeBundledPlugins(ps.dir)
|
||||
}
|
||||
// Plugins are optional scripts managed independently in plugin_dir. This method
|
||||
// is retained for API compatibility and deliberately performs no seeding.
|
||||
func (ps *Plugins) SeedBundled() error { return nil }
|
||||
|
||||
// bootPluginState compiles one plugin into a fresh Lua state and stores its
|
||||
// returned table under pluginGlobal. It is the plugin counterpart of
|
||||
@ -797,6 +777,9 @@ func bootPluginState(code, name string, allow *httpAllowlist, ps *Plugins) (*wor
|
||||
registerKernelBridge(L, ps, name)
|
||||
// The plugin's own directory, as ground truth (see pluginDirGlobal).
|
||||
registerPluginDirBridge(L, ps.dir)
|
||||
// Publish the public SDK table. Must be LAST: it shares the bridges
|
||||
// above rather than duplicating them.
|
||||
registerSDK(L)
|
||||
}
|
||||
if err := L.DoString(code); err != nil {
|
||||
L.Close()
|
||||
@ -861,13 +844,9 @@ func (ps *Plugins) LoadSource(name, code string) error {
|
||||
}
|
||||
p.script = code
|
||||
p.state = w
|
||||
// Builtin = the shipped source is byte-identical to what this file was
|
||||
// loaded from. That is stronger than "the name matches a bundled plugin": an
|
||||
// operator who EDITED billing.lua must not be told their copy is builtin,
|
||||
// or the UI would offer to overwrite their changes.
|
||||
if orig, err := ReadBundledPlugin(name); err == nil && orig == code {
|
||||
p.Builtin = true
|
||||
}
|
||||
// Plugins are external operator-owned files. Do not compare their source
|
||||
// with repository examples: doing so couples runtime behavior to the source
|
||||
// tree and falsely labels a user file as gateway-owned.
|
||||
|
||||
// ---- manifest ----
|
||||
w.L.GetGlobal(pluginGlobal)
|
||||
@ -1229,8 +1208,11 @@ type DiskEntry struct {
|
||||
Size int64 `json:"size"`
|
||||
Loaded bool `json:"loaded"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Builtin bool `json:"builtin"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// Builtin is retained in the JSON shape for compatibility with older
|
||||
// clients; it is always false because plugins are external operator-owned
|
||||
// files, never gateway-owned resources.
|
||||
Builtin bool `json:"builtin"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// Version/Description are read from the loaded plugin when available.
|
||||
Version string `json:"version,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
@ -1299,25 +1281,15 @@ func (ps *Plugins) OnDisk() []DiskEntry {
|
||||
// field covers either.
|
||||
de.Pages = pageNames(p)
|
||||
} else {
|
||||
// On disk but not in the running set: either it failed so badly
|
||||
// that LoadSource never produced a record, or the dir was written
|
||||
// after startup. Mark it by comparing with the bundled source.
|
||||
if code, err := os.ReadFile(filepath.Join(ps.dir, e.Name())); err == nil {
|
||||
if orig, err := ReadBundledPlugin(name); err == nil && orig == string(code) {
|
||||
de.Builtin = true
|
||||
}
|
||||
}
|
||||
// The file may have been added after startup. It remains an external
|
||||
// operator-owned plugin; never inspect repository source to classify it.
|
||||
}
|
||||
ps.mu.RUnlock()
|
||||
out = append(out, de)
|
||||
}
|
||||
// External plugins are listed alphabetically; no file is privileged by the
|
||||
// gateway based on where its source happens to exist.
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
// builtins first, then alphabetical: the example plugin an operator
|
||||
// is most likely to want to read should not be buried under whatever
|
||||
// they installed most recently.
|
||||
if out[i].Builtin != out[j].Builtin {
|
||||
return out[i].Builtin
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
return out
|
||||
|
||||
@ -225,7 +225,7 @@ end
|
||||
|
||||
-- ===== 规则持久化:插件自己读写自己的配置文件 ========================
|
||||
--
|
||||
-- 规则文件放在**插件自己的目录**(内核通过通用的 __llmsproxy_plugin_dir 暴露,
|
||||
-- 规则文件放在**插件自己的目录**(内核通过通用的 modelrouter.plugin_dir 暴露,
|
||||
-- 任何插件都能用;内核不知道 billing 的存在):`<plugin_dir>/billing.json`。
|
||||
-- 用 JSON 因为插件沙箱只有 json 库;用独立文件因为"保存系统配置"绝不该改动
|
||||
-- 价目表,反之亦然。
|
||||
@ -233,7 +233,8 @@ plugin.rules = { active = "", profiles = {} }
|
||||
plugin.rules_path = ""
|
||||
|
||||
local function defaultRulesPath()
|
||||
local dir = rawget(_G, "__llmsproxy_plugin_dir")
|
||||
local sdk = rawget(_G, "modelrouter")
|
||||
local dir = sdk and sdk.plugin_dir
|
||||
if type(dir) ~= "string" or dir == "" then return "" end
|
||||
return dir .. "/billing.json"
|
||||
end
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -34,7 +34,7 @@
|
||||
-- endpoint" — a 401 on a key we hold, say — are surfaced, because those are the
|
||||
-- ones worth fixing.
|
||||
--
|
||||
-- Only hosts of CONFIGURED SOURCES are reachable (__llmsproxy_http enforces
|
||||
-- Only hosts of CONFIGURED SOURCES are reachable (modelrouter.http enforces
|
||||
-- that), so adding a probe never widens what the gateway can talk to.
|
||||
|
||||
local PLUGIN = {}
|
||||
@ -127,7 +127,7 @@ local function request(path, key, headers, tries)
|
||||
hdrs["Authorization"] = "Bearer " .. tostring(key or "")
|
||||
local lastErr = "no attempt"
|
||||
for _ = 1, tries do
|
||||
local res, err = __llmsproxy_http({
|
||||
local res, err = modelrouter.http({
|
||||
-- 4s, not 10s: probing is network-bound and the whole page shares ONE
|
||||
-- Lua state under a write lock, so a slow upstream is not just a slow card
|
||||
-- — it stalls every request_end hook (i.e. billing) behind it.
|
||||
@ -540,6 +540,111 @@ local function commandCode(base, key)
|
||||
return rep, nil
|
||||
end
|
||||
|
||||
-- openCodeFreePool:OpenCode 免费池的额度监控。
|
||||
--
|
||||
-- 为什之不能像其他上游那样“查余额”:OpenCode Zen **没有**额度查询端点。
|
||||
-- 额度耗尽的唯一表现是真实推理请求返回 429:
|
||||
--
|
||||
-- HTTP 429
|
||||
-- Retry-After: 80867
|
||||
-- {"type":"error","error":{"type":"FreeUsageLimitError",
|
||||
-- "message":"Free usage limit reached. Subscribe to OpenCode Go to keep going: ..."}}
|
||||
--
|
||||
-- 恰好就是这个响应把“还剩多少”变成了可监控的事实 —— `Retry-After` 就是
|
||||
-- 距离额度恢复的秒数。不对自己凭空造一个“余额百分比”:那个数上游从未提供过,
|
||||
-- 和本插件开头“宁可不说也不要编”的口径直接冲突。
|
||||
--
|
||||
-- ★ 代价控制:这是**发一次真实推理请求**去探额度(没有别的办法)。所以
|
||||
-- ① 只探一个固定极小请求(max_tokens=1,单字 prompt),便宜到可以忽略;
|
||||
-- ② 只在免费池**未确认耗尽**时才探(耗尽后 Retry-After 已经告诉我们恢复时刻,
|
||||
-- 再去探就是无意义的敲上游);
|
||||
-- ③ 探针失败不影响真实请求:它只读 429 的头部,不消费额度窗口里的并发。
|
||||
--
|
||||
-- ⚠️ 免费层有身份门禁:缺 `x-opencode-*` 头会得到 403 FreeTierError,
|
||||
-- 而 403 不是额度事实。那些头由适配器生成(公式在 opencodezen.lua),
|
||||
-- 本插件不重复实现一套 —— 两处各写一份必然漂移。因此本探针只负责
|
||||
-- “带上源自己的凭据去问一次”,身份头由源的 adapter 负责。
|
||||
-- ⇒ 真正稳定的做法是看**真实请求**的结果;本探针只是让“配额还剩多久”
|
||||
-- 在没有真实流量时也能被看到。
|
||||
local FREE_POOL_HINT = "free"
|
||||
|
||||
local function isFreePoolModel(id)
|
||||
return type(id) == "string" and id:find(FREE_POOL_HINT, 1, true) ~= nil
|
||||
end
|
||||
|
||||
local function openCodeFreePool(base, key, src)
|
||||
-- 只对一个免费模型探一次,避免把目录里每个 free 模型都打一遍。
|
||||
local probeModel
|
||||
for _, m in ipairs((src and src.models) or {}) do
|
||||
if isFreePoolModel(m) then probeModel = m break end
|
||||
end
|
||||
if not probeModel then return nil, "skip", "no free model configured" end
|
||||
|
||||
local url = base .. "/chat/completions"
|
||||
local res, err = modelrouter.http({
|
||||
url = url, method = "POST",
|
||||
headers = {
|
||||
["Authorization"] = "Bearer " .. tostring(key or ""),
|
||||
["Content-Type"] = "application/json",
|
||||
["Accept"] = "text/event-stream",
|
||||
},
|
||||
body = json.encode({
|
||||
model = probeModel,
|
||||
messages = { { role = "user", content = "hi" } },
|
||||
stream = true,
|
||||
max_tokens = 1,
|
||||
}),
|
||||
timeout_ms = 6000,
|
||||
})
|
||||
if res == nil then return nil, "error", tostring(err or "request failed") end
|
||||
|
||||
local hdr = res.headers or {}
|
||||
local function h(name)
|
||||
local v = hdr[name]
|
||||
if v == nil then v = hdr[string.lower(name)] end
|
||||
return v
|
||||
end
|
||||
|
||||
if res.status == 429 then
|
||||
local retry = tonumber(h("Retry-After") or h("retry-after"))
|
||||
local ok, body = pcall(json.decode, res.body or "")
|
||||
local etype = ok and type(body) == "table" and get(body, "error.type") or nil
|
||||
return {
|
||||
vendor = "opencode-free",
|
||||
free_pool = true,
|
||||
-- 额度耗尽是一个**明确的运营事实**,必须渲染成“不可用”,
|
||||
-- 而不是“某次请求失败了”:它不是故障,是配额用完了。
|
||||
available = false,
|
||||
exceeded = true,
|
||||
reason = (etype == "FreeUsageLimitError") and "免费池额度已用尽"
|
||||
or "HTTP 429",
|
||||
-- 恢复时刻:上游直接告诉我们了,不要自己猜一个退避。
|
||||
retry_after_s = retry,
|
||||
resume_at = retry and (os.time() + retry) or nil,
|
||||
probe_model = probeModel,
|
||||
}, nil
|
||||
end
|
||||
|
||||
if res.status == 403 then
|
||||
-- 403 是身份/网络门禁,**不是**额度事实。两者必须分开报,
|
||||
-- 否则会把“IP 被风控”误读成“额度用完了”。
|
||||
return nil, "error", "HTTP 403(免费层身份被拒,非额度问题)"
|
||||
end
|
||||
|
||||
if res.status >= 200 and res.status < 300 then
|
||||
return {
|
||||
vendor = "opencode-free",
|
||||
free_pool = true,
|
||||
available = true,
|
||||
exceeded = false,
|
||||
reason = "免费池可用",
|
||||
probe_model = probeModel,
|
||||
}, nil
|
||||
end
|
||||
|
||||
return nil, "error", "HTTP " .. tostring(res.status)
|
||||
end
|
||||
|
||||
-- Ordered probes. DeepSeek-family first because it is a single cheap endpoint,
|
||||
-- then the OpenAI console shape, then Command Code's four.
|
||||
local ADAPTERS = {
|
||||
@ -547,6 +652,10 @@ local ADAPTERS = {
|
||||
{ vendor = "deepseek", probe = deepseekBalance },
|
||||
{ vendor = "openai-dash", probe = openaiDashboard },
|
||||
{ vendor = "opendesign", probe = openDesign },
|
||||
-- ★ OpenCode 免费池:**没有**额度查询端点,只能靠一次真实请求的 429
|
||||
-- 与 Retry-After 判断。放最后:其他适配器能答就不用花这次请求。
|
||||
-- 只对含 free 模型的源生效(probe 内部自行判断)。
|
||||
{ vendor = "opencode-free", probe = openCodeFreePool },
|
||||
}
|
||||
|
||||
-- The probe memo remembers, per host, which adapter answered. Persisted in the
|
||||
@ -722,7 +831,7 @@ local function collectSource(src, budget)
|
||||
-- genuinely unreachable case; a reachable host still goes through the
|
||||
-- adapters.
|
||||
if known == false then
|
||||
local probe = __llmsproxy_http({
|
||||
local probe = modelrouter.http({
|
||||
url = base .. "/", method = "GET", timeout_ms = 2500,
|
||||
headers = { ["Authorization"] = "Bearer " .. tostring(key or "") },
|
||||
})
|
||||
@ -764,13 +873,30 @@ local function collectSource(src, budget)
|
||||
kind = "skip"
|
||||
why = because or ("adapter not configured: " .. a.vendor)
|
||||
else
|
||||
local r, k, e = a.probe(base, key)
|
||||
local r, k, e = a.probe(base, key, src)
|
||||
if r ~= nil then
|
||||
rep = r
|
||||
HOST_MEMO[host] = a.vendor
|
||||
break
|
||||
end
|
||||
kind, why = k, e
|
||||
-- ★ 不相关适配器的 skip **不得**覆盖已有原因。
|
||||
--
|
||||
-- 真实回归(加 OpenCode 免费池探测时引入):opendesign 先说
|
||||
-- 「缺 control_key」,紧随其后的 opencode-free 探测因「本源没有 free 模型」
|
||||
-- 而 skip,用的是无条件 `kind, why = k, e`,于是把那条可操作的原因换成了
|
||||
-- 「no free model configured」—— 运维看到的是“这个源没有免费模型”,
|
||||
-- 而实际要去配的是 control_key。判据 TestQuotaOpenDesignSkipsWithoutControlKey
|
||||
-- 直接咬住了它。
|
||||
--
|
||||
-- 规则:一个适配器**仅因自身不适用**(本源没有它关心的模型)而 skip 时,
|
||||
-- 不得抹掉别人给出的原因;真错误仍按原逻辑进入 errWhy。
|
||||
local notApplicable = k == "skip" and type(e) == "string"
|
||||
and e:find("no free model configured", 1, true) ~= nil
|
||||
if not notApplicable then
|
||||
kind, why = k, e
|
||||
elseif why == nil then
|
||||
kind, why = k, e
|
||||
end
|
||||
if k == "error" then
|
||||
local rejected = tostring(e or ""):match("rejected") ~= nil
|
||||
-- Last error wins, except that a rejected credential displaces anything
|
||||
|
||||
@ -82,7 +82,7 @@ local function selfStats(key)
|
||||
end
|
||||
local url = "http://127.0.0.1:8081/api/stats?period=" .. PERIOD ..
|
||||
"&limit=1&key=" .. maskedKey(key)
|
||||
local r = __llmsproxy_http({
|
||||
local r = modelrouter.http({
|
||||
url = url,
|
||||
method = "GET",
|
||||
timeout_ms = 4000,
|
||||
|
||||
@ -298,11 +298,11 @@ func TestQuotaMemoKeyIsTheHost(t *testing.T) {
|
||||
// that actually ships rather than a copy that can drift from it.
|
||||
func readShippedPlugin(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
b, err := bundledAdapters.ReadFile("plugins/" + name)
|
||||
src, err := ReadBundledPlugin(strings.TrimSuffix(name, ".lua"))
|
||||
if err != nil {
|
||||
t.Fatalf("read embedded %s: %v", name, err)
|
||||
t.Fatalf("read repo plugin %s: %v", name, err)
|
||||
}
|
||||
return string(b)
|
||||
return src
|
||||
}
|
||||
|
||||
// quotaWithDecision loads quota.lua with its local backoffSeconds exposed as an
|
||||
|
||||
@ -9,22 +9,22 @@ import (
|
||||
// **测试绝不允许写生产存储** —— 这条是真实事故的回归判据。
|
||||
//
|
||||
// 事故:channels 插件用 storeDir() 决定自己的存储位置,而 storeDir 有多级兜底:
|
||||
// 环境变量 → settings → **读 /etc/llmsproxy/config.yaml 的 plugin_dir** →
|
||||
// 硬编码 /etc/llmsproxy/plugins。测试台没设环境变量时,后两级直接命中**生产**:
|
||||
// 环境变量 → settings → **读 /etc/modelrouter/config.yaml 的 plugin_dir** →
|
||||
// 硬编码 /etc/modelrouter/plugins。测试台没设环境变量时,后两级直接命中**生产**:
|
||||
//
|
||||
// 测试插件目录 = /tmp/TestXXX/002/plugins
|
||||
// storeDir 结果 = /etc/llmsproxy/plugins ← 实测
|
||||
// storeDir 结果 = /etc/modelrouter/plugins ← 实测
|
||||
//
|
||||
// 于是测试加载真 channels.lua 时**读的是生产存储、写的也是生产存储**。一次全量
|
||||
// `go test ./internal/lua` 就把生产账号池覆盖成测试的空 pool:
|
||||
// /etc/llmsproxy/plugins/.channels-store.json 从 1730 变 1986 字节,
|
||||
// /etc/modelrouter/plugins/.channels-store.json 从 1730 变 1986 字节,
|
||||
// 13 个账号变成 0 个(凭据还留在保险库,所以症状是"账号凭空消失")。
|
||||
//
|
||||
// 同类前科(本文件存在的另一半理由):曾经有 26 个测试账号被写进生产
|
||||
// .channels-store.json。两次都是"测试没隔离存储目录",所以这条判据必须存在。
|
||||
func TestStoreDirIsolatedInTests(t *testing.T) {
|
||||
_, ps, pdir := runtimePlugins(t)
|
||||
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
|
||||
src, err := ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -45,17 +45,17 @@ func TestStoreDirIsolatedInTests(t *testing.T) {
|
||||
t.Logf("storeDir 结果 = %s", got)
|
||||
|
||||
// ① 绝不能是生产目录
|
||||
if got == "/etc/llmsproxy/plugins" || strings.HasPrefix(got, "/etc/llmsproxy") {
|
||||
if got == "/etc/modelrouter/plugins" || strings.HasPrefix(got, "/etc/llmsproxy") {
|
||||
t.Fatalf("storeDir 指向了**生产**目录 %q —— 测试会覆盖生产账号池\n"+
|
||||
"(真实事故:13 个账号被覆盖成 0 个)。修法:runtimePlugins 设\n"+
|
||||
"LLMSPROXY_PLUGIN_DIR 到 t.TempDir()", got)
|
||||
"MODELROUTER_PLUGIN_DIR 到 t.TempDir()", got)
|
||||
}
|
||||
// ② 必须落在测试的临时目录里
|
||||
if !strings.Contains(got, pdir) && !strings.HasPrefix(got, os.TempDir()) {
|
||||
t.Errorf("storeDir 应落在临时目录(%s 或 %s),实得 %q", pdir, os.TempDir(), got)
|
||||
}
|
||||
// ③ 环境变量必须真的被设上(机制而非巧合)
|
||||
if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" {
|
||||
t.Error("runtimePlugins 必须设 LLMSPROXY_PLUGIN_DIR(否则 storeDir 会读生产 config.yaml)")
|
||||
if os.Getenv("MODELROUTER_PLUGIN_DIR") == "" {
|
||||
t.Error("runtimePlugins 必须设 MODELROUTER_PLUGIN_DIR(否则 storeDir 会读生产 config.yaml)")
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,6 +26,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -37,11 +38,11 @@ import (
|
||||
golua "github.com/aarzilli/golua/lua"
|
||||
)
|
||||
|
||||
//go:embed adapters/*.lua plugins/*.lua
|
||||
//go:embed adapters/*.lua
|
||||
var bundledAdapters embed.FS
|
||||
|
||||
// adapterGlobal is the reserved global holding the adapter table after the
|
||||
// worker boots the script. A reserved name avoids conflicting with user code.
|
||||
// worker boots the script. A reserved name avoids clashing with user code.
|
||||
const adapterGlobal = "__llmsproxy_adapter"
|
||||
|
||||
type APIAdapter struct {
|
||||
@ -732,64 +733,24 @@ func (v *VM) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// bundledPluginsDir is the directory inside the embedded FS holding the
|
||||
// plugins shipped with the gateway. It is separate from adapters/ on purpose:
|
||||
// the two are loaded by different machinery into different kinds of Lua state
|
||||
// (a protocol transform vs. request-pipeline hooks), and keeping them apart
|
||||
// makes it obvious that dropping a file in one does not affect the other.
|
||||
const bundledPluginsDir = "plugins"
|
||||
|
||||
// ReadBundledPlugin returns the source of a plugin shipped with the gateway.
|
||||
// It exists so a test (or an operator tool) can load a bundled plugin without
|
||||
// depending on whether seeding has already run for this directory.
|
||||
// ReadBundledPlugin is retained only for source-level test fixtures and
|
||||
// development tooling. It resolves relative to this source file, not the
|
||||
// process working directory, and is never used by production plugin loading.
|
||||
func ReadBundledPlugin(name string) (string, error) {
|
||||
data, err := bundledAdapters.ReadFile(bundledPluginsDir + "/" + name + ".lua")
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("locate plugin source %s", name)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(filepath.Dir(file), "plugins", name+".lua"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("bundled plugin %s: %w", name, err)
|
||||
return "", fmt.Errorf("plugin source %s: %w", name, err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// writeBundledPlugins seeds the plugin directory with the shipped plugins.
|
||||
//
|
||||
// It runs only when the directory does not exist yet (same rule as adapters):
|
||||
// once the directory exists it is authoritative, so deleting a shipped plugin is
|
||||
// a real delete and editing one survives restarts.
|
||||
func writeBundledPlugins(dir string) error {
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("mkdir plugin dir: %w", err)
|
||||
}
|
||||
entries, err := bundledAdapters.ReadDir(bundledPluginsDir)
|
||||
if err != nil {
|
||||
return nil // nothing embedded; not an error
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || filepath.Ext(e.Name()) != ".lua" {
|
||||
continue
|
||||
}
|
||||
dst := filepath.Join(dir, e.Name())
|
||||
// Per-file seeding: a plugin already present is left ALONE. That is what
|
||||
// makes an operator's own copy (or a locally edited one) survive an
|
||||
// upgrade, and it is also why a newly embedded plugin still arrives —
|
||||
// absence of THAT file, not of the directory, is the trigger.
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
continue
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
data, err := bundledAdapters.ReadFile(bundledPluginsDir + "/" + e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(dst, data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// writeBundledPlugins is intentionally a no-op: plugins are independent optional
|
||||
// files and are never seeded by the gateway.
|
||||
func writeBundledPlugins(string) error { return nil }
|
||||
|
||||
func (v *VM) Stop() {
|
||||
select {
|
||||
|
||||
@ -1869,20 +1869,16 @@ func TestAnthropicUsageCacheMapping(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedBundledFillsMissingFilesInExistingDir 是真实事故的回归测试。
|
||||
// TestSeedBundledNeverWritesFiles 钉住"插件不由网关安装"这条契约。
|
||||
//
|
||||
// 事故:SeedBundled 原本"目录已存在就什么都不做"。生产 plugin_dir 从首次安装
|
||||
// 起就存在,于是后来加入二进制的新内置插件(channels.lua)永远不会落盘 ——
|
||||
// 它在 go:embed 里、却完全不出现在 /api/plugins(线上只加载 billing/quota/usage)。
|
||||
//
|
||||
// 本测试构造那个确切条件:目录已存在且只装了部分内置插件,断言缺失的被补齐、
|
||||
// 已存在的(模拟运维自定义)**不被覆盖**。
|
||||
func TestSeedBundledFillsMissingFilesInExistingDir(t *testing.T) {
|
||||
// 历史:SeedBundled 曾把 go:embed 的 billing/channels/quota/usage 写进
|
||||
// plugin_dir,于是插件与 Go 二进制耦合、升级还会覆盖或删除运维文件。
|
||||
// 现在插件是纯外置资产:网关不创建、不补齐、不覆盖,只加载目录里已有的文件。
|
||||
func TestSeedBundledNeverWritesFiles(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "plugins")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 模拟:目录已存在,里面只有运维自己的一份 billing.lua(内容故意不同)
|
||||
const customBilling = "-- operator's own billing plugin\nreturn { name='billing', version='9.9.9' }"
|
||||
if err := os.WriteFile(filepath.Join(dir, "billing.lua"), []byte(customBilling), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
@ -1896,33 +1892,29 @@ func TestSeedBundledFillsMissingFilesInExistingDir(t *testing.T) {
|
||||
ps := NewPlugins(vm, dir)
|
||||
|
||||
if err := ps.SeedBundled(); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
t.Fatalf("SeedBundled must be a no-op, got: %v", err)
|
||||
}
|
||||
|
||||
// ① 已存在的运维文件不被覆盖
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].Name() != "billing.lua" {
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
t.Fatalf("网关不得写入任何插件文件, 目录应为 [billing.lua], 实得 %v", names)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(dir, "billing.lua"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != customBilling {
|
||||
t.Errorf("运维自己的 billing.lua 被覆盖了 —— 升级会抹掉运维的改动")
|
||||
t.Error("运维自己的 billing.lua 被改写了")
|
||||
}
|
||||
|
||||
// ② 每个内置插件现在都在目录里(这正是 channels.lua 缺失的那个洞)
|
||||
entries, err := bundledAdapters.ReadDir(bundledPluginsDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || filepath.Ext(e.Name()) != ".lua" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, e.Name())); err != nil {
|
||||
t.Errorf("内置插件 %s 未被补齐(目录已存在时被跳过)", e.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 补齐后确实能被加载(channels 必须真的出现在插件表里)
|
||||
// 手工放入的插件仍然可以加载 —— 外置不等于不可用。
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -1930,35 +1922,7 @@ func TestSeedBundledFillsMissingFilesInExistingDir(t *testing.T) {
|
||||
for _, p := range ps.List() {
|
||||
names[p["name"].(string)] = true
|
||||
}
|
||||
if !names["channels"] {
|
||||
t.Errorf("channels 插件应被 seed 并加载, 实得 %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedBundledIdempotent 二次调用不应改变已就位的文件。
|
||||
func TestSeedBundledIdempotent(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "plugins")
|
||||
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
ps := NewPlugins(vm, dir)
|
||||
|
||||
if err := ps.SeedBundled(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := os.ReadFile(filepath.Join(dir, "channels.lua"))
|
||||
// 运维改一笔
|
||||
edited := append(before, []byte("\n-- operator edit\n")...)
|
||||
if err := os.WriteFile(filepath.Join(dir, "channels.lua"), edited, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.SeedBundled(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := os.ReadFile(filepath.Join(dir, "channels.lua"))
|
||||
if string(after) != string(edited) {
|
||||
t.Error("二次 seed 覆盖了已存在的文件")
|
||||
if !names["billing"] {
|
||||
t.Errorf("plugin_dir 中手工安装的插件应被加载, 实得 %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user