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 ./...` 通过。
194 lines
7.8 KiB
Go
194 lines
7.8 KiB
Go
package core
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"sync/atomic"
|
||
"testing"
|
||
)
|
||
|
||
// 凭据静默续期(照参考 refresh.ts / cline.ts / gemini-oauth.ts)。
|
||
//
|
||
// ⚠️ 修的是一个**比不做更坏**的缺陷:`account_refresh` 此前只重同步源、一个换
|
||
// token 的请求都不发,却恒返回 `ok:true` ⇒ `refresh_creds` 打出「凭据已续期 N 个」
|
||
// 而实际什么都没续。日志在说谎,排查时会去看网络、看上游,而真因是根本没有
|
||
// 这段代码。
|
||
//
|
||
// 判据必须**看到真请求** —— 否则就还是在验一个恒真的返回值。
|
||
func TestAccountRefreshSendsRealRequest(t *testing.T) {
|
||
var hits int32
|
||
var gotBody, gotPath string
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
atomic.AddInt32(&hits, 1)
|
||
gotPath = r.URL.Path
|
||
b := make([]byte, 1024)
|
||
n, _ := r.Body.Read(b)
|
||
gotBody = string(b[:n])
|
||
w.Header().Set("Content-Type", "application/json")
|
||
// 照参考:Cline 响应套 {success,data} 信封,字段是**驼峰** accessToken
|
||
_, _ = w.Write([]byte(`{"success":true,"data":{"accessToken":"NEW-TOKEN-CAMEL","refreshToken":"NEW-REFRESH"}}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
_, ps := poolCore(t)
|
||
// 注入一个测试平台(夹具绝不写进生产源码)
|
||
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
|
||
"id": "rfptest", "refresh_url": srv.URL + "/api/v1/auth/refresh",
|
||
"style": "cline", "credential": `{"access_token":"OLD","refresh_token":"RT1"}`,
|
||
}); err != nil {
|
||
t.Fatalf("注入平台: %v", err)
|
||
}
|
||
|
||
out, err := ps.Call("channels", "account_refresh", map[string]interface{}{
|
||
"platform": "rfptest", "account_id": "a1",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("account_refresh: %v", err)
|
||
}
|
||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||
|
||
// ① 必须真的发出了请求(这是本判据的核心 —— 原实现 hits=0)
|
||
if n := atomic.LoadInt32(&hits); n != 1 {
|
||
t.Fatalf("必须真发一次续期请求,实得 %d 次(原实现是 0 次却报成功)", n)
|
||
}
|
||
if !strings.Contains(gotPath, "/api/v1/auth/refresh") {
|
||
t.Errorf("请求路径不对: %s", gotPath)
|
||
}
|
||
// ② 请求体必须是**驼峰** refreshToken/grantType(照参考 cline.ts)
|
||
var sent map[string]interface{}
|
||
if err := json.Unmarshal([]byte(gotBody), &sent); err != nil {
|
||
t.Fatalf("请求体不是 JSON(Cline 要 JSON body): %q", gotBody)
|
||
}
|
||
if _, ok := sent["refreshToken"]; !ok {
|
||
t.Errorf("请求体必须是驼峰 refreshToken(不是 refresh_token),实得 %v", sent)
|
||
}
|
||
if sent["grantType"] != "refresh_token" {
|
||
t.Errorf("grantType 应为 refresh_token,实得 %v", sent["grantType"])
|
||
}
|
||
|
||
// ③ 必须报 refreshed=true,且**新 token 真的落进凭据**
|
||
if j["refreshed"] != true {
|
||
t.Errorf("应报 refreshed=true,实得 %v(message=%v)", j["refreshed"], j["message"])
|
||
}
|
||
if j["ok"] != true {
|
||
t.Errorf("应报 ok=true,实得 %v", j["ok"])
|
||
}
|
||
rd, _ := ps.Call("channels", "__read_cred_probe", map[string]interface{}{
|
||
"ref": "channels:rfptest",
|
||
})
|
||
rj, _ := rd.(map[string]interface{})["json"].(map[string]interface{})
|
||
cred, _ := rj["credential"].(string)
|
||
if !strings.Contains(cred, "NEW-TOKEN-CAMEL") {
|
||
t.Errorf("新 token 必须写回凭据,实得 %q", cred)
|
||
}
|
||
// ④ 轮换的 refresh_token 也要回写(Google 场景;Cline 也返回了新的)
|
||
if !strings.Contains(cred, "NEW-REFRESH") {
|
||
t.Errorf("轮换的 refresh_token 必须回写,实得 %q", cred)
|
||
}
|
||
}
|
||
|
||
// 失败时**必须如实回错误**,不能像原实现那样恒 ok:true。
|
||
func TestAccountRefreshReportsFailure(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.WriteHeader(401)
|
||
_, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"refresh token expired"}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
_, ps := poolCore(t)
|
||
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
|
||
"id": "rfpfail", "refresh_url": srv.URL + "/refresh", "style": "google",
|
||
"credential": `{"access_token":"OLD","refresh_token":"RT1"}`,
|
||
}); err != nil {
|
||
t.Fatalf("注入: %v", err)
|
||
}
|
||
out, _ := ps.Call("channels", "account_refresh", map[string]interface{}{
|
||
"platform": "rfpfail", "account_id": "a1",
|
||
})
|
||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||
// ⚠️ 原实现在这里恒 ok:true/refreshed 隐含成功 —— 这就是日志说谎的机制
|
||
if j["refreshed"] == true {
|
||
t.Error("续期失败时绝不能报 refreshed=true")
|
||
}
|
||
msg, _ := j["message"].(string)
|
||
if !strings.Contains(msg, "401") {
|
||
t.Errorf("失败原因应含 HTTP 状态(可诊断),实得 %q", msg)
|
||
}
|
||
|
||
// 无 refresh_token 时必须明确说"需重新登录",而不是笼统的失败
|
||
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
|
||
"id": "rfpnort", "refresh_url": srv.URL + "/refresh", "style": "google",
|
||
"credential": `{"access_token":"OLD"}`,
|
||
}); err != nil {
|
||
t.Fatalf("注入: %v", err)
|
||
}
|
||
out2, _ := ps.Call("channels", "account_refresh", map[string]interface{}{
|
||
"platform": "rfpnort", "account_id": "a1",
|
||
})
|
||
j2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
|
||
m2, _ := j2["message"].(string)
|
||
if !strings.Contains(m2, "重新登录") {
|
||
t.Errorf("无 refresh_token 时应提示需重新登录,实得 %q", m2)
|
||
}
|
||
}
|
||
|
||
// expires_in 是**相对秒数**,必须换算成绝对时刻 —— 否则每轮都判定"快过期"再续一遍。
|
||
func TestRefreshWritesBackAbsoluteExpiry(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte(`{"access_token":"NEW","refresh_token":"R2","expires_in":7200}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
_, ps := poolCore(t)
|
||
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
|
||
"id": "rfpexp", "refresh_url": srv.URL + "/token", "style": "google",
|
||
"credential": `{"access_token":"OLD","refresh_token":"RT1"}`,
|
||
}); err != nil {
|
||
t.Fatalf("注入: %v", err)
|
||
}
|
||
if _, err := ps.Call("channels", "account_refresh", map[string]interface{}{
|
||
"platform": "rfpexp", "account_id": "a1",
|
||
}); err != nil {
|
||
t.Fatalf("account_refresh: %v", err)
|
||
}
|
||
rd, _ := ps.Call("channels", "__read_cred_probe", map[string]interface{}{
|
||
"ref": "channels:rfpexp",
|
||
})
|
||
rj, _ := rd.(map[string]interface{})["json"].(map[string]interface{})
|
||
cred, _ := rj["credential"].(string)
|
||
var m map[string]interface{}
|
||
_ = json.Unmarshal([]byte(cred), &m)
|
||
ea, _ := m["expires_at"].(string)
|
||
if ea == "" {
|
||
t.Fatal("expires_in 必须换算成绝对 expires_at 写回(否则每轮都重续)")
|
||
}
|
||
// 必须是**绝对**时刻(10 位秒级),不是相对秒数 7200
|
||
if ea == "7200" {
|
||
t.Error("expires_at 写成了相对秒数 —— 必须换算成绝对时刻")
|
||
}
|
||
if len(ea) < 9 {
|
||
t.Errorf("expires_at 看着不像绝对时刻: %q", ea)
|
||
}
|
||
// 索引的 expiresAt 也必须回写(照 expiry-sync:否则出现"显示已过期但能发消息"分叉)。
|
||
// ⚠️ 必须通过**账号列表**端点读索引 —— __read_cred_probe 只读保险库,
|
||
// 用它验索引会永远看到零,把"没验到"误判成"没写"。
|
||
al, _ := ps.Call("channels", "account_list", map[string]interface{}{"platform": "rfpexp"})
|
||
aj, _ := al.(map[string]interface{})["json"].(map[string]interface{})
|
||
accounts := asArrInterface(aj["accounts"])
|
||
if len(accounts) == 0 {
|
||
t.Fatal("账号列表为空")
|
||
}
|
||
first, _ := accounts[0].(map[string]interface{})
|
||
idx, _ := first["expiresAt"].(float64)
|
||
if idx == 0 {
|
||
t.Error("索引的 expiresAt 必须一并回写(expiry-sync 的分叉缺陷)")
|
||
}
|
||
// 且应当是**绝对**秒级时刻(不是相对 7200)
|
||
if idx > 0 && idx < 1e9 {
|
||
t.Errorf("索引 expiresAt 应换算成绝对时刻,实得 %.0f", idx)
|
||
}
|
||
}
|