mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
fix(channels): 凭据续期此前是假的 —— 现在真发请求、真回写、真报错
★ 修的是一个**比不做更坏**的缺陷:`account_refresh` 只重同步了源、**一个换
token 的请求都不发**,却恒返回 `ok:true` ⇒ `refresh_creds` 打出
「凭据已续期 N 个」而实际什么都没续。日志在说谎 —— 排查时会去看网络、看上游,
而真因是根本没有这段代码。这是本轮第三次遇到"静默恒真"(前两次是
`account_test` 恒返回 ok、`pcall` 只取 ok 不看返回值)。
实现要点(每条都是参考踩过的坑):
- **各平台续期字段名不同**,写错服务端只回泛化的认证失败、极难定位:
Cline 是**驼峰** `refreshToken` + `grantType`(不是 OAuth 的
refresh_token/grant_type)、响应套 `{success,data}` 信封且字段是**驼峰**
`accessToken`;Google 走标准 `refresh_token` 表单编码 + client_secret。
- **Google 偶尔轮换 refresh_token**:响应给了新的**必须回写**,否则下次用旧的
直接失效。
- **`expires_in` 是相对秒数** → 必须换算绝对时刻,否则每轮都判定"快过期"重续。
- **按需续期必须回写索引的 expiresAt**(照 expiry-sync):不回写会出现
「显示已过期、但发消息正常」的数据源分叉。
- 失败**如实回错**(HTTP 状态 / `error_description` 都带出来),但仍重同步源 ——
旧 token 可能还没真过期,不该因续期失败就撤源。
- `refresh_creds` 的计数判据改用 `refreshed`,**不是** `ok`:
`account_refresh` 在"平台未声明续期方式"时也回 ok:true(源照旧同步),
用 ok 计数就会把"没续"算成"续了"—— 这正是日志说谎的机制。
判据(必须**看到真请求**,否则还是在验一个恒真的返回值):
1. 假上游断言**真发出 1 次请求**(原实现是 0 次却报成功)、路径正确、请求体是
驼峰 `refreshToken`/`grantType`、新 token 与轮换的 refresh_token 都落进凭据;
2. 401 时**绝不报 refreshed=true**,且错误含 HTTP 状态;无 refresh_token 时
提示"需重新登录";
3. `expires_in:7200` → 凭据里是**绝对** expires_at(不是 7200),且
**账号列表**(不是保险库探针)里 read 到索引 expiresAt 已回写。
另修两个同处踩过的坑:`credentialIsRefreshable` 定义在使用点**之后**
(Lua local 前向引用,第 10 次)、`urlencode` 不存在(Google 表单编码需要)。
回归:9 个包全绿。
This commit is contained in:
193
internal/core/channels_refresh_test.go
Normal file
193
internal/core/channels_refresh_test.go
Normal file
@ -0,0 +1,193 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st
|
||||
|
||||
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
|
||||
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__clear_capabilities_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
|
||||
|
||||
|
||||
|
||||
@ -282,6 +282,16 @@ local PLATFORMS = {
|
||||
url = "https://api.cline.bot/api/v1/auth/register",
|
||||
method = "POST",
|
||||
body = '{"accessToken":"{access_token}","refreshToken":"{refresh_token}"}',
|
||||
-- 静默续期(照参考 cline.ts)。
|
||||
-- ⚠️ 三个字段名陷阱,写错服务端只回泛化的认证失败、极难定位:
|
||||
-- ① 请求体是**驼峰** `refreshToken` + `grantType`(不是 OAuth 的
|
||||
-- refresh_token/grant_type);
|
||||
-- ② 响应套 `{success,data}` 信封;
|
||||
-- ③ 响应字段是**驼峰** `accessToken`。
|
||||
refresh = {
|
||||
style = "cline",
|
||||
url = "https://api.cline.bot/api/v1/auth/refresh",
|
||||
},
|
||||
},
|
||||
hosts = { "api.workos.com", "api.cline.bot", "app.cline.bot" },
|
||||
-- cline 的 chat 路径是 /api/v1/chat/completions(参考 CLINE_CHAT_PATH)。
|
||||
@ -542,6 +552,16 @@ local PLATFORMS = {
|
||||
-- 参考明言:上游 Cloud Code 用的是**最朴素的 authorization_code +
|
||||
-- client_secret**,刻意不带 PKCE。
|
||||
},
|
||||
-- 静默续期(照参考 gemini-oauth):标准 OAuth `refresh_token` 表单(**平台级**,
|
||||
-- 不是 callback 内 —— refreshCredential 读的是 plat.refresh)。
|
||||
-- 同样必须带 client_secret;且 **Google 偶尔轮换 refresh_token**,
|
||||
-- 响应给了新的必须回写(由 refreshCredential 负责),否则下次用旧的直接失效。
|
||||
refresh = {
|
||||
style = "google",
|
||||
url = "https://oauth2.googleapis.com/token",
|
||||
client_id = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
|
||||
client_secret = "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",
|
||||
},
|
||||
hosts = { "accounts.google.com", "oauth2.googleapis.com", "cloudcode-pa.googleapis.com" },
|
||||
base_url = "https://cloudcode-pa.googleapis.com",
|
||||
-- Cloud Code 走**私有信封协议**(/v1internal: 动词 + 身份五头 + 字母序
|
||||
@ -2805,10 +2825,127 @@ local function credentialExpiresAtMs(cred)
|
||||
end
|
||||
|
||||
-- credentialIsRefreshable 凭据是否携带可静默续期的 refresh_token。
|
||||
-- ⚠️ 必须定义在 refreshCredential **之前**:Lua 的 local 只从定义处向后可见,
|
||||
-- 在定义前调用会拿到 nil 并报 "attempt to call a nil value"(第 10 次踩)。
|
||||
local function credentialIsRefreshable(cred)
|
||||
return type(cred) == "table" and type(cred.refresh_token) == "string" and cred.refresh_token ~= ""
|
||||
end
|
||||
|
||||
-- urlencode:表单编码(Google 续期走 x-www-form-urlencoded)。
|
||||
-- ⚠️ 不能只转义常见几个字符:client_secret / refresh_token 里会出现
|
||||
-- `-` `_` `.` `~` 之外的各种符号,漏转义会让服务端收到被截断的值。
|
||||
local function urlencode(v)
|
||||
return (tostring(v or ""):gsub("[^%w%-%._~]", function(c)
|
||||
return string.format("%%%02X", string.byte(c))
|
||||
end))
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- 凭据静默续期(照参考 refresh.ts / cline.ts / gemini-oauth.ts)
|
||||
-- ============================================================================
|
||||
-- ⚠️ **此前这里是假的**:`account_refresh` 只重同步了源、一个换 token 的请求
|
||||
-- 都不发,却恒返回 `ok:true` ⇒ `refresh_creds` 打出「凭据已续期 N 个」而实际
|
||||
-- 什么都没续。这是**比不做更坏**的形态:日志在说谎,排查时会去看网络、
|
||||
-- 看上游,而真因是根本没有这段代码。
|
||||
--
|
||||
-- 参考的实现要点(每一条都是踩过的坑):
|
||||
--
|
||||
-- 1. **续期请求体各平台字段名不同**,写错服务端不给明确报错,只回一个泛化
|
||||
-- 的认证失败,极难定位:
|
||||
-- * Cline:驼峰 `refreshToken` + `grantType`(**不是** OAuth 标准的
|
||||
-- `refresh_token`/`grant_type`);
|
||||
-- * Gemini/Google:标准 `refresh_token` + `grant_type=refresh_token`
|
||||
-- (表单编码,走 client_id/client_secret);
|
||||
-- * 其余走各平台自己的 exchange。
|
||||
-- 2. **响应字段名同样不统一**:Cline 是 `accessToken`(驼峰)+ `{success,data}`
|
||||
-- 信封;Google 是 `access_token`。解析必须容忍两种。
|
||||
-- 3. **Google 偶尔轮换 refresh_token**:响应里给了新的**必须回写**,否则下次
|
||||
-- 续期用旧的直接失效。
|
||||
-- 4. **`expires_in` 是相对秒数**,必须换算成绝对时刻再写回索引 ——
|
||||
-- 否则 `refresh_creds` 每轮都判定"快过期了"又续一遍。
|
||||
--
|
||||
-- 声明式设计(插件域内,内核不参与):平台用 `refresh = { url=, style=,
|
||||
-- body=, ... }` 描述自己的续期,本助手按 style 组装。
|
||||
local REFRESH_STYLES = {
|
||||
-- Cline:驼峰字段 + JSON body + {success,data} 信封
|
||||
cline = {
|
||||
body = function(cred, r)
|
||||
return json.encode({ refreshToken = cred.refresh_token or "",
|
||||
grantType = "refresh_token" })
|
||||
end,
|
||||
headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json" },
|
||||
-- 响应套 {success,data} 且字段是驼峰 accessToken(照 parseClineTokenPayload)
|
||||
pick = function(rec)
|
||||
local d = type(rec.data) == "table" and rec.data or rec
|
||||
return d.accessToken or d.access_token, d.refreshToken or d.refresh_token
|
||||
end,
|
||||
},
|
||||
-- Google/Gemini:标准 OAuth 表单编码
|
||||
google = {
|
||||
form = true,
|
||||
body = function(cred, r)
|
||||
return "client_id=" .. urlencode(r.client_id or "")
|
||||
.. "&client_secret=" .. urlencode(r.client_secret or "")
|
||||
.. "&refresh_token=" .. urlencode(cred.refresh_token or "")
|
||||
.. "&grant_type=refresh_token"
|
||||
end,
|
||||
headers = { ["Content-Type"] = "application/x-www-form-urlencoded",
|
||||
["Accept"] = "application/json" },
|
||||
-- ⚠️ Google 偶尔轮换 refresh_token:给了新的必须回写
|
||||
pick = function(rec) return rec.access_token, rec.refresh_token end,
|
||||
},
|
||||
}
|
||||
|
||||
-- refreshCredential(plat, acct):真发一次续期请求,成功则**回写凭据与索引**。
|
||||
-- 返回 (ok, message)。失败原因必须可诊断(不能只说"失败了")。
|
||||
local function refreshCredential(plat, acct)
|
||||
local r = plat.refresh
|
||||
if type(r) ~= "table" or not r.url then return false, "该平台未声明续期方式" end
|
||||
local cred = credentialFull(acct.credentialRef)
|
||||
if type(cred) ~= "table" then return false, "凭据无法解析" end
|
||||
if not credentialIsRefreshable(cred) then return false, "无 refresh_token,需重新登录" end
|
||||
local style = REFRESH_STYLES[r.style or "google"]
|
||||
if not style then return false, "未知续期风格: " .. tostring(r.style) end
|
||||
local body = style.body(cred, r)
|
||||
local resp, err = __llmsproxy_http({
|
||||
url = r.url, method = r.method or "POST", headers = style.headers, body = body,
|
||||
})
|
||||
if err then return false, "续期请求失败: " .. tostring(err) end
|
||||
if not resp or resp.status ~= 200 then
|
||||
return false, "续期 HTTP " .. tostring(resp and resp.status)
|
||||
end
|
||||
local okd, rec = pcall(json.decode, resp.body or "")
|
||||
if not okd or type(rec) ~= "table" then return false, "续期响应无法解析" end
|
||||
local newTok, newRefresh = style.pick(rec)
|
||||
if type(newTok) ~= "string" or newTok == "" then
|
||||
-- 服务端的失败信息要带出来(照参考:不吞诊断信息)
|
||||
local msg = rec.error_description or rec.error or rec.message
|
||||
return false, "续期响应无 access_token" .. (msg and (": " .. tostring(msg)) or "")
|
||||
end
|
||||
-- 回写凭据(**只能走这条路写凭据** —— 手工塞密文会导致二次加密)
|
||||
cred.access_token = newTok
|
||||
if type(newRefresh) == "string" and newRefresh ~= "" then
|
||||
cred.refresh_token = newRefresh -- Google 会轮换
|
||||
end
|
||||
-- expires_in 是**相对秒数** → 换算绝对时刻(否则每轮都判定"快过期")
|
||||
local rec2 = type(rec.data) == "table" and rec.data or rec
|
||||
local ei = tonumber(rec2.expires_in or rec.expires_in)
|
||||
if ei and ei > 0 then
|
||||
cred.expires_at = tostring((os.time and os.time() or 0) + ei)
|
||||
end
|
||||
__llmsproxy_secrets.set(acct.credentialRef, json.encode(cred))
|
||||
-- 回写索引的过期时刻(照 expiry-sync:按需续期**必须**回写,否则会出现
|
||||
-- 「显示已过期、但发消息正常」的数据源分叉)
|
||||
if cred.expires_at then
|
||||
local ms = credentialExpiresAtMs(cred)
|
||||
if ms then acct.expiresAt = math.floor(ms / 1000) end
|
||||
end
|
||||
acct.refreshable = true
|
||||
poolAdd(plat.id, acct)
|
||||
markDirty()
|
||||
return true, "已续期"
|
||||
end
|
||||
|
||||
-- 模型黑名单(对齐参考 model.setDisabled):provider → { [modelId] = true }
|
||||
-- ============================================================================
|
||||
-- 远端模型目录:**远端为主 + 兜底补位**(照参考的 reconcileWithFallback)
|
||||
@ -5682,7 +5819,7 @@ end
|
||||
function plugin.refresh_creds()
|
||||
local lead = 3600 -- 提前 1 小时续(照参考的 lead 语义)
|
||||
local now = os.time and os.time() or 0
|
||||
local refreshed, skipped = 0, 0
|
||||
local refreshed, skipped, failed = 0, 0, {}
|
||||
for _, plat in ipairs(allPlatforms()) do
|
||||
if plat.exchange_body or plat.refresh_url then
|
||||
for _, a in ipairs(poolList(plat.id)) do
|
||||
@ -5697,8 +5834,17 @@ function plugin.refresh_creds()
|
||||
end
|
||||
if due then
|
||||
local ok, err = plugin.account_refresh({ platform = plat.id, account_id = a.id })
|
||||
if ok and ok.json and ok.json.ok then refreshed = refreshed + 1
|
||||
else skipped = skipped + 1 end
|
||||
-- ⚠️ 判据用 `refreshed`,**不是** `ok`:account_refresh 在"平台未
|
||||
-- 声明续期方式"时也会回 ok:true(源照旧同步),用 ok 计数就会把
|
||||
-- "没续"算成"续了" —— 这正是此前日志说谎的机制。
|
||||
if ok and ok.json and ok.json.refreshed then refreshed = refreshed + 1
|
||||
else
|
||||
skipped = skipped + 1
|
||||
local why = ok and ok.json and ok.json.message or err
|
||||
if why and why ~= "该平台未声明续期方式" then
|
||||
failed[#failed+1] = plat.id .. ": " .. tostring(why)
|
||||
end
|
||||
end
|
||||
else
|
||||
skipped = skipped + 1
|
||||
end
|
||||
@ -5707,6 +5853,9 @@ function plugin.refresh_creds()
|
||||
end
|
||||
end
|
||||
if refreshed > 0 then logEvent("凭据已续期 " .. refreshed .. " 个") end
|
||||
if #failed > 0 then
|
||||
logEvent("凭据续期失败 " .. #failed .. " 个:" .. table.concat(failed, "; "):sub(1, 300))
|
||||
end
|
||||
end
|
||||
|
||||
function plugin.auto_claim()
|
||||
@ -6074,9 +6223,19 @@ function plugin.account_refresh(req)
|
||||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||||
local acct = poolGet(plat.id, args and args.account_id)
|
||||
if not acct then return { status = 400, json = { error = "账号不存在" } } end
|
||||
local tok = credentialOf(acct.credentialRef)
|
||||
if tok and tok ~= "" then syncAccountSource(plat.id, acct.id) end
|
||||
return { status = 200, json = { ok = true } }
|
||||
-- ★ 真续期:此前这里只重同步源、一个换 token 的请求都不发却恒返回 ok:true,
|
||||
-- 于是 `refresh_creds` 打出「凭据已续期 N 个」而什么都没续 —— 日志在说谎。
|
||||
local rok, rmsg = refreshCredential(plat, acct)
|
||||
if not rok then
|
||||
-- 失败必须**如实回错**(供 UI 显示与日志诊断)。但仍重同步源:
|
||||
-- 旧 token 可能还没真过期,不该因为续期失败就把源撤掉。
|
||||
local tok = credentialOf(acct.credentialRef)
|
||||
if tok and tok ~= "" then syncAccountSource(plat.id, acct.id) end
|
||||
return { status = 200, json = { ok = false, refreshed = false, message = rmsg } }
|
||||
end
|
||||
-- 续期成功 → 用**新 token** 重建源(旧源的 Authorization 头已失效)
|
||||
syncAccountSource(plat.id, acct.id)
|
||||
return { status = 200, json = { ok = true, refreshed = true, message = rmsg } }
|
||||
end
|
||||
|
||||
-- account_reorder:调账号优先级(对齐 account.reorder)
|
||||
@ -8955,6 +9114,35 @@ function plugin.__parse_capability_probe(args)
|
||||
reasoning = reasoning, with_ctx = with_ctx, sample = sample } }
|
||||
end
|
||||
|
||||
-- plugin.__inject_refresh_platform_probe:只给测试用 —— 造一个带续期声明的平台
|
||||
-- 与一条账号。**夹具绝不写进生产源码**(此前 example/googleish/loopbackish 这类
|
||||
-- 测试平台被写进生产目录的事故就是教训)。
|
||||
function plugin.__inject_refresh_platform_probe(args)
|
||||
args = args or {}
|
||||
local id = tostring(args.id or "")
|
||||
-- ⚠️ injected_platforms 是**数组**(platformById 用 ipairs 遍历),不是映射。
|
||||
plugin.state.injected_platforms = plugin.state.injected_platforms or {}
|
||||
local lst = plugin.state.injected_platforms
|
||||
local repl = nil
|
||||
for i, p in ipairs(lst) do if type(p) == "table" and p.id == id then repl = i break end end
|
||||
local entry = {
|
||||
id = id,
|
||||
label = id,
|
||||
flow = "manual",
|
||||
adapter = "openai",
|
||||
base_url = "http://127.0.0.1:1/v1/chat/completions",
|
||||
refresh = { style = tostring(args.style or "google"), url = tostring(args.refresh_url or "") },
|
||||
models = { "m1" },
|
||||
hosts = { "127.0.0.1" },
|
||||
}
|
||||
if repl then lst[repl] = entry else lst[#lst + 1] = entry end
|
||||
-- 建账号(凭据走保险库)
|
||||
local ref = "channels:" .. id
|
||||
__llmsproxy_secrets.set(ref, tostring(args.credential or "{}"))
|
||||
poolAdd(id, { id = "a1", credentialRef = ref, priority = 1, expiresAt = 0 })
|
||||
return { json = { ok = true } }
|
||||
end
|
||||
|
||||
-- plugin.__clear_capabilities_probe:只给测试用 —— 清能力缓存(模拟冷启动)。
|
||||
function plugin.__clear_capabilities_probe()
|
||||
plugin.state.capabilities = nil
|
||||
|
||||
Reference in New Issue
Block a user