From 252e3486bd387811e6b3175ca43ba02e8653692d Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 14 Oct 2026 16:46:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(channels):=20=E5=87=AD=E6=8D=AE=E7=BB=AD?= =?UTF-8?q?=E6=9C=9F=E6=AD=A4=E5=89=8D=E6=98=AF=E5=81=87=E7=9A=84=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E7=8E=B0=E5=9C=A8=E7=9C=9F=E5=8F=91?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E3=80=81=E7=9C=9F=E5=9B=9E=E5=86=99=E3=80=81?= =?UTF-8?q?=E7=9C=9F=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ★ 修的是一个**比不做更坏**的缺陷:`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 个包全绿。 --- internal/core/channels_refresh_test.go | 193 +++++++++++++++++++++++ internal/lua/plugins/channels.lua | 202 ++++++++++++++++++++++++- 2 files changed, 388 insertions(+), 7 deletions(-) create mode 100644 internal/core/channels_refresh_test.go diff --git a/internal/core/channels_refresh_test.go b/internal/core/channels_refresh_test.go new file mode 100644 index 0000000..c605050 --- /dev/null +++ b/internal/core/channels_refresh_test.go @@ -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) + } +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index b8054b6..16e784d 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -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