mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据:
1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。
本项目版本号**只由 `git describe --tags` 推导**(Makefile VERSION),
仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。
2. 逐项核对确认 **main 是 release 的内容严格子集**:
- `main` 独有的**文件**:0 个;
- `internal/lua/plugins.go` / `internal/core/kernel_actions.go` 等内核文件
main 独有行数:0;
- `deploy.sh` 那 14 行只是 emoji 清理(release 刻意去掉了 emoji);
- `channels.lua` 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。
- main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在**
(`git cherry` 逐条列出均为 `-`,即已合入)。
3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号",
且不会丢任何 main 独有的修复。
本次带回 v2.0.12 的全部内容(37 个提交):
- 源串行 + 按模型节流(照 model-gate)
- 已失效模型存储 + 远端目录闸门
- 余额分档选号
- 模型能力元数据(models.dev,isFree 权威判定)
- **凭据续期修复**(此前恒报 ok:true 却一个请求都不发)
- 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正
- 内核级:出站授权持久化重启后不生效、`__llmsproxy_plugin_dir` ground truth
校验:`git diff release/v2.0.x` = 0 行;`go build -tags luajit ./...` 通过。
166 lines
5.7 KiB
Go
166 lines
5.7 KiB
Go
package core
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"llmsproxy/internal/lua"
|
||
)
|
||
|
||
// 有道登录闭环的判据:portal 会把授权重定向回 **本机 loopback 接收器**
|
||
// (127.0.0.1:{port}/auth/callback 精确路径),而不是用户机器的 localhost、
|
||
// 也不是本插件的 /api/plugins/http 挂载点。远程用户在服务端浏览器里完成后,
|
||
// 重定向落本机端口,接收器写文件,poll_login 读它完成交换。
|
||
//
|
||
// 参考 lobsterai-oauth.ts 明确:redirect_uri 必须该形态(登录页会校验),
|
||
// state 不匹配必须拒绝(400),交换体含 authCode/firstKeyfrom/latestKeyfrom/
|
||
// uuid/version 五字段,凭据必须带 uuid+first_keyfrom(否则续期失败)。
|
||
func TestLobsterLoopbackClosedLoop(t *testing.T) {
|
||
var exch int
|
||
var gotVersion string
|
||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
exch++
|
||
var b map[string]string
|
||
_ = json.NewDecoder(r.Body).Decode(&b)
|
||
if b["authCode"] != "AC-1" {
|
||
t.Errorf("exchange authCode = %q", b["authCode"])
|
||
}
|
||
gotVersion = b["version"]
|
||
if b["firstKeyfrom"] == "" || b["uuid"] == "" {
|
||
t.Errorf("exchange 缺字段: %#v", b)
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
io.WriteString(w, `{"code":0,"data":{"accessToken":"AT-1","refreshToken":"RT-1","expiresIn":3600,"user":{"id":"UID1"}}}`)
|
||
}))
|
||
defer ts.Close()
|
||
|
||
// 版本号是 exchange 的必填字段,**不能写死**:2026-10-13 实测上游已是
|
||
// 2026.9.23,而参考的兜底常量还停在 2026.9.4。此处假上游返回真形态
|
||
// (非统一信封,值在 data.value.version)。
|
||
ver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
io.WriteString(w, `{"data":{"value":{"version":"2026.9.23","date":"2026-9-23"}}}`)
|
||
}))
|
||
defer ver.Close()
|
||
|
||
c := kernelTestCore(t)
|
||
ps := c.Plugins()
|
||
src, err := readBundledChannels(t)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ps.LoadSource("channels", src); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "remove_source", "status"}, false)
|
||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||
"platform": "lobsterai",
|
||
"callback": map[string]interface{}{"token_url": ts.URL},
|
||
"client_version": map[string]interface{}{"url": ver.URL},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||
Method: "POST", Body: `{"platform":"lobsterai"}`, Role: "admin",
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
j := out.JSON.(map[string]interface{})
|
||
sess := j["session"].(map[string]interface{})
|
||
nonce := sess["nonce"].(string)
|
||
sid := sess["id"].(string)
|
||
redirectURI, _ := j["redirect_uri"].(string)
|
||
if !strings.HasPrefix(redirectURI, "http://127.0.0.1:") || !strings.HasSuffix(redirectURI, "/auth/callback") {
|
||
t.Fatalf("redirect_uri 必须是 127.0.0.1:{port}/auth/callback,实得 %q", redirectURI)
|
||
}
|
||
url, _ := j["url"].(string)
|
||
if !strings.Contains(url, "/portal#/login?") {
|
||
t.Fatalf("授权页应是 /portal#/login?…,实得 %q", url)
|
||
}
|
||
if !strings.Contains(url, "source=electron") {
|
||
t.Fatalf("授权页缺 source=electron: %q", url)
|
||
}
|
||
|
||
t.Run("行1 state 不匹配被拒", func(t *testing.T) {
|
||
resp, err := http.Get(redirectURI + "?code=AC-1&state=WRONG")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != 400 {
|
||
t.Fatalf("state 不匹配应 400,实得 %d", resp.StatusCode)
|
||
}
|
||
if exch != 0 {
|
||
t.Fatal("state 不匹配不应触发交换")
|
||
}
|
||
})
|
||
|
||
t.Run("行2 正确回调完成登录并落完整凭据", func(t *testing.T) {
|
||
resp, err := http.Get(redirectURI + "?code=AC-1&state=" + nonce)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resp.Body.Close()
|
||
if resp.StatusCode != 200 {
|
||
t.Fatalf("合法回调应 200,实得 %d", resp.StatusCode)
|
||
}
|
||
// 接收器写文件后 poll_login 读它完成交换(可能需要一拍)
|
||
var st map[string]interface{}
|
||
for i := 0; i < 20; i++ {
|
||
o, err := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
st = o.JSON.(map[string]interface{})
|
||
if st["state"] != "pending" {
|
||
break
|
||
}
|
||
time.Sleep(100 * time.Millisecond)
|
||
}
|
||
if st["state"] != "completed" {
|
||
t.Fatalf("应完成登录,实得 %v", st)
|
||
}
|
||
raw, ok, err := ps.GetSecret("channels", "channels:lobsterai")
|
||
if err != nil || !ok {
|
||
t.Fatalf("凭据未落库: %v", err)
|
||
}
|
||
var cred map[string]interface{}
|
||
if err := json.Unmarshal([]byte(raw), &cred); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, k := range []string{"access_token", "refresh_token", "uuid", "first_keyfrom", "expires_at", "uid"} {
|
||
if cred[k] == nil || cred[k] == "" {
|
||
t.Errorf("凭据缺 %s(有道的 uuid/first_keyfrom 丢了就续期失败): %s", k, raw)
|
||
}
|
||
}
|
||
if cred["access_token"] != "AT-1" {
|
||
t.Fatalf("access_token = %v", cred["access_token"])
|
||
}
|
||
// 版本号必须来自远端(2026.9.23),不能是写死的兜底 2026.9.4
|
||
if gotVersion != "2026.9.23" {
|
||
t.Fatalf("exchange version 应为远端 2026.9.23(写死 2026.9.4 是 bug),实得 %q", gotVersion)
|
||
}
|
||
})
|
||
|
||
t.Run("行3 接收器用完即收", func(t *testing.T) {
|
||
// 完成后监听应已关闭(连接被拒)
|
||
_, err := http.Get(redirectURI + "?code=AC-1&state=" + nonce)
|
||
if err == nil {
|
||
t.Fatal("接收器应在完成后关闭")
|
||
} else if !strings.Contains(err.Error(), "refused") && !strings.Contains(err.Error(), "connect") {
|
||
t.Logf("关闭判定: %v", err)
|
||
}
|
||
})
|
||
_ = fmt.Sprint
|
||
}
|